feat(system): add opt-in automatic updates for the core NOMAD app

Adds a self-update path for the NOMAD admin/core image that runs without a
human in the loop, gated by layered safety checks. Recreation remains the
sidecar's job; this only decides *whether* to update now and requests it.

An hourly job (AutoUpdateJob) evaluates a side-effect-free decision pipeline
(AutoUpdateService.evaluate):
 - opt-in: disabled by default
 - in a user-configured local-time window (handles midnight wrap)
 - an eligible release: same major (major bumps stay manual), strictly newer,
   past a configurable cool-off, GA only (no drafts/prereleases), strict semver
 - pre-flight: sidecar present, no in-flight system update / downloads / app
   installs, and sufficient host disk (estimated from the registry manifest)
When all pass, it drives SystemUpdateService.requestUpdate() with a vetted tag.

Failure backoff auto-disables after 3 genuine update-request failures; transient
release-lookup errors are skips (offline-first appliances are routinely without
connectivity). Re-enabling clears the backoff state.

Settings UI exposes the toggle, window, and cool-off, with live status (eligible
target, in/out of window, last result/error). A `node ace auto-update:dry-run`
command exercises the full pipeline — and a deterministic --scenarios suite the
pure decision logic — without ever triggering an update.

New KVStore keys under autoUpdate.* hold config + last-run state.
This commit is contained in:
jakeaturner 2026-06-04 06:27:40 +00:00
parent d175259219
commit 02d985db5e
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
18 changed files with 1429 additions and 10 deletions

View File

@ -117,6 +117,41 @@ For now, we recommend using network-level controls to manage access if you're pl
## Contributing
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
### Testing Auto-Updates (Dry Run)
The Command Center can automatically install **minor/patch** updates of itself during a configurable window, after a cool-off period, and only when pre-flight checks pass (sufficient disk for the new image, no downloads or app installs in progress). Major versions always require a manual update.
Because exercising this logic with real version bumps is impractical, an Ace command runs the **entire decision pipeline without ever triggering an update**. Run it from the `admin/` directory:
```bash
# 1) Deterministic scenario suite — no network, DB, or Docker required.
# Proves every branch (major-only, cool-off, prerelease/draft, window wrap, …)
# and exits non-zero on failure, so it's safe to wire into CI.
node ace auto-update:dry-run --scenarios
# 2) Simulate "what would happen if I were running 1.32.0 right now?"
# against the LIVE GitHub releases feed and real pre-flight checks:
node ace auto-update:dry-run --current=1.32.0 --force-enabled
# 3) Fully offline simulation with a canned release list and a fixed clock:
node ace auto-update:dry-run --current=1.32.0 --force-enabled \
--releases-file=./fixtures/releases.json --now=2026-06-04T21:00:00Z \
--window-start=20:00 --window-end=23:00 --cooloff=72 --skip-preflight
```
It prints the resolved decision — current version, whether the clock is inside the window, the eligible target (if any), and pre-flight blockers — ending in a clear verdict such as `WOULD UPDATE → v1.33.2` or `WOULD NOT UPDATE (outside-window): …`. **No real update is ever requested.**
| Flag | Description |
|------|-------------|
| `--scenarios` | Run the built-in deterministic scenario suite and exit |
| `--current=<version>` | Simulate this currently-running version (e.g. `1.32.0`) |
| `--force-enabled` | Treat auto-update as enabled, ignoring the saved setting |
| `--cooloff=<hours>` | Override the cool-off period |
| `--window-start=<HH:MM>` / `--window-end=<HH:MM>` | Override the update window |
| `--now=<ISO timestamp>` | Simulate the clock at a specific time |
| `--releases-file=<path>` | Use a local JSON releases array instead of fetching GitHub (offline) |
| `--skip-preflight` | Bypass the Docker/disk/queue pre-flight checks |
## Community & Resources
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project

View File

@ -3,7 +3,7 @@ import { BenchmarkService } from '#services/benchmark_service'
import { MapService } from '#services/map_service'
import { OllamaService } from '#services/ollama_service'
import { SystemService } from '#services/system_service'
import { getSettingSchema, updateSettingSchema } from '#validators/settings'
import { getSettingSchema, updateSettingSchema, validateSettingValue } from '#validators/settings'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@ -118,6 +118,10 @@ export default class SettingsController {
async updateSetting({ request, response }: HttpContext) {
const reqData = await request.validateUsing(updateSettingSchema)
const valueError = validateSettingValue(reqData.key, reqData.value)
if (valueError) {
return response.status(422).send({ success: false, message: valueError })
}
await this.systemService.updateSetting(reqData.key, reqData.value)
return response.status(200).send({ success: true, message: 'Setting updated successfully' })
}

View File

@ -2,6 +2,9 @@ import { DockerService } from '#services/docker_service';
import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { AutoUpdateService } from '#services/auto_update_service'
import { DownloadService } from '#services/download_service'
import { QueueService } from '#services/queue_service'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import {
affectServiceValidator,
@ -126,6 +129,27 @@ export default class SystemController {
response.send({ logs });
}
async getAutoUpdateStatus({ response }: HttpContext) {
// Construct inline reusing already-injected singletons + the QueueService
// singleton (its constructor is private to prevent Redis connection leaks,
// so we must not let the container new a fresh one).
const autoUpdateService = new AutoUpdateService(
this.dockerService,
new DownloadService(QueueService.getInstance()),
this.systemService,
this.systemUpdateService,
this.containerRegistryService
)
try {
const status = await autoUpdateService.getStatus()
response.send(status)
} catch (error) {
logger.error({ err: error }, '[SystemController] Failed to get auto-update status')
response.status(500).send({ error: 'Failed to retrieve auto-update status' })
}
}
async subscribeToReleaseNotes({ request }: HttpContext) {
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);

View File

@ -0,0 +1,80 @@
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { DockerService } from '#services/docker_service'
import { DownloadService } from '#services/download_service'
import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { AutoUpdateService } from '#services/auto_update_service'
import logger from '@adonisjs/core/services/logger'
/**
* Hourly job that evaluates whether the NOMAD application should auto-update right
* now and, if so, requests it. All gating (opt-in, window, eligibility, cool-off,
* pre-flight, backoff) lives in {@link AutoUpdateService}; 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.
*/
export class AutoUpdateJob {
static get queue() {
return 'system'
}
static get key() {
return 'auto-update'
}
async handle(_job: Job) {
logger.info('[AutoUpdateJob] Evaluating auto-update...')
const dockerService = new DockerService()
const autoUpdateService = new AutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new SystemUpdateService(),
new ContainerRegistryService()
)
const result = await autoUpdateService.attempt()
logger.info(`[AutoUpdateJob] ${result.updated ? 'Updating' : 'No update'}: ${result.reason}`)
return result
}
static async schedule() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'hourly-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('[AutoUpdateJob] 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(`[AutoUpdateJob] Dispatched ad-hoc auto-update evaluation job ${job.id}`)
return job
}
}

View File

@ -0,0 +1,636 @@
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import axios from 'axios'
import { DateTime } from 'luxon'
import KVStore from '#models/kv_store'
import Service from '#models/service'
import { DockerService } from '#services/docker_service'
import { DownloadService } from '#services/download_service'
import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { isNewerVersion, parseMajorVersion } from '../utils/version.js'
/** Docker image repository for the NOMAD admin/core image (tag applied per-release). */
const NOMAD_IMAGE_REPO = 'ghcr.io/crosstalk-solutions/project-nomad'
const RELEASES_URL = 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases'
/** Defaults for user-configurable settings (server-local time window + cool-off). */
const DEFAULT_WINDOW_START = '02:00'
const DEFAULT_WINDOW_END = '05:00'
const DEFAULT_COOLOFF_HOURS = 72
/** Require free space >= imageSize * factor to cover decompressed layers + headroom. */
const DISK_SAFETY_FACTOR = 2
/** Conservative fallback when the registry image size can't be determined. */
const MIN_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GiB
/** Genuine failures before auto-update disables itself to avoid an update loop. */
const MAX_CONSECUTIVE_FAILURES = 3
/**
* Only tags matching strict semver are eligible. Defense-in-depth: the selected
* tag becomes `target_tag`, which the sidecar interpolates into a host-side `sed`
* (install/sidecar-updater/update-watcher.sh) so a malformed tag must never be
* able to reach it, even though releases come from a trusted repo.
*/
const SEMVER_TAG = /^\d+\.\d+\.\d+$/
/** Cache the GitHub releases feed in-process to avoid hammering the API. */
const RELEASES_CACHE_TTL_MS = 15 * 60 * 1000
/** Briefly remember a failed fetch so repeated calls don't each block on the timeout. */
const RELEASES_FAILURE_TTL_MS = 60 * 1000
export interface AutoUpdateConfig {
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
}
export interface EligibleTarget {
version: string
tag: string
publishedAt: string
}
type BlockerSeverity = 'skip' | 'failure'
export interface Blocker {
reason: string
severity: BlockerSeverity
}
export interface PreflightResult {
ok: boolean
blockers: Blocker[]
}
/** Minimal shape of a GitHub release entry we depend on. */
export interface GithubRelease {
tag_name?: string
published_at?: string
draft?: boolean
prerelease?: boolean
}
/**
* Inputs that can be injected to exercise the decision pipeline deterministically
* (used by the dry-run command/tests). All are optional; when omitted the real
* settings/clock/GitHub feed/pre-flight are used, exactly as production runs.
*/
export interface EvaluateOverrides {
currentVersion?: string
releases?: GithubRelease[]
now?: DateTime
forceEnabled?: boolean
windowStart?: string
windowEnd?: string
cooloffHours?: number
/** Treat pre-flight as passing without touching Docker/disk/queues. */
skipPreflight?: boolean
/** Substitute a canned pre-flight result. */
fakePreflight?: PreflightResult
}
export type DecisionOutcome =
| 'disabled'
| 'outside-window'
| 'eligibility-error'
| 'no-eligible'
| 'blocked'
| 'ready'
/** Side-effect-free verdict of the decision pipeline. */
export interface AutoUpdateDecision {
enabled: boolean
currentVersion: string
config: AutoUpdateConfig
withinWindow: boolean
eligibleTarget: EligibleTarget | null
preflight: PreflightResult | null
outcome: DecisionOutcome
reason: string
}
export interface AutoUpdateStatus extends AutoUpdateConfig {
currentVersion: string
withinWindow: boolean
eligibleTarget: EligibleTarget | null
lastAttemptAt: string | null
lastResult: string | null
lastError: string | null
consecutiveFailures: number
autoDisabledReason: string | null
}
/**
* Decision + safety layer for automatic updates of the NOMAD application itself.
*
* It does NOT recreate containers that remains the sidecar's job. This service
* decides *whether* an update should run right now (opt-in, in-window, an eligible
* minor/patch release exists past its cool-off, pre-flight checks pass) and, if so,
* drives the existing {@link SystemUpdateService.requestUpdate} with an explicit,
* eligibility-vetted image tag.
*
* The window/pre-flight helpers are intentionally generic so a future PR can reuse
* them to auto-update installed apps (driving DockerService.updateContainer instead).
*/
@inject()
export class AutoUpdateService {
constructor(
private dockerService: DockerService,
private downloadService: DownloadService,
private systemService: SystemService,
private systemUpdateService: SystemUpdateService,
private containerRegistryService: ContainerRegistryService
) {}
/** In-process cache of the last successful releases fetch (per-process). */
private static releasesCache: { releases: GithubRelease[]; at: number } | null = null
/** Timestamp of the last failed fetch, for short-lived negative caching. */
private static releasesFailureAt = 0
/** Read user-configurable settings, applying defaults. */
async getConfig(): Promise<AutoUpdateConfig> {
const [enabled, windowStart, windowEnd, cooloffHours] = await Promise.all([
KVStore.getValue('autoUpdate.enabled'),
KVStore.getValue('autoUpdate.windowStart'),
KVStore.getValue('autoUpdate.windowEnd'),
KVStore.getValue('autoUpdate.cooloffHours'),
])
const parsedCooloff = Number(cooloffHours)
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,
}
}
/**
* Determine whether `now` falls inside the configured update window. The window
* is interpreted in the container's local time (set via the TZ env var). Windows
* that wrap past midnight (start > end, e.g. 22:00-02:00) are handled.
*/
isWithinWindow(config: AutoUpdateConfig, now: DateTime = DateTime.now()): boolean {
const start = this.parseMinutes(config.windowStart)
const end = this.parseMinutes(config.windowEnd)
if (start === null || end === null) return false
const current = now.hour * 60 + now.minute
if (start === end) return false // zero-length window
if (start < end) {
return current >= start && current < end
}
// Wraps midnight
return current >= start || current < end
}
private parseMinutes(hhmm: string): number | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm)
if (!match) return null
return Number(match[1]) * 60 + Number(match[2])
}
/**
* Find the newest release that is safe to auto-apply: same major version as the
* running build (major bumps are deliberately left for manual update), strictly
* newer than current, and published at least `cooloffHours` ago. Prereleases and
* drafts are ignored auto-update never rides early access.
*
* Returns null when nothing qualifies (e.g. only a major bump is newer, or the
* newest eligible release is still inside its cool-off window).
*/
async getEligibleTarget(config: AutoUpdateConfig): Promise<EligibleTarget | null> {
const releases = await this.fetchReleases()
return this.selectEligibleTarget(
releases,
SystemService.getAppVersion(),
config.cooloffHours,
DateTime.now()
)
}
/**
* Fetch the published GitHub releases for the NOMAD repo, cached in-process.
* A successful result is reused for {@link RELEASES_CACHE_TTL_MS} so repeated
* status-page loads don't each hit (and risk rate-limiting) the unauthenticated
* GitHub API. A recent failure is negatively cached for {@link RELEASES_FAILURE_TTL_MS}
* so back-to-back calls while offline don't each block on the request timeout.
*/
async fetchReleases(): Promise<GithubRelease[]> {
const now = Date.now()
const cached = AutoUpdateService.releasesCache
if (cached && now - cached.at < RELEASES_CACHE_TTL_MS) {
return cached.releases
}
if (now - AutoUpdateService.releasesFailureAt < RELEASES_FAILURE_TTL_MS) {
throw new Error('GitHub releases fetch recently failed; backing off')
}
try {
const response = await axios.get(RELEASES_URL, {
headers: { Accept: 'application/vnd.github+json' },
timeout: 5000,
})
if (!Array.isArray(response.data)) {
throw new Error('Unexpected response from GitHub releases API')
}
AutoUpdateService.releasesCache = { releases: response.data, at: now }
return response.data
} catch (error) {
AutoUpdateService.releasesFailureAt = now
throw error
}
}
/**
* Pure selection of the newest auto-applicable release from a release list.
* Extracted so the dry-run command and tests can drive it with fixtures.
* Same major as `currentVersion`, strictly newer, published on/before
* `now - cooloffHours`, prereleases/drafts excluded. Returns null for dev
* builds or when nothing qualifies.
*/
selectEligibleTarget(
releases: GithubRelease[],
currentVersion: string,
cooloffHours: number,
now: DateTime
): EligibleTarget | null {
if (currentVersion === 'dev' || currentVersion === '0.0.0') {
return null
}
const currentMajor = parseMajorVersion(currentVersion)
const cutoff = now.minus({ hours: cooloffHours })
const candidates = releases
.filter((r) => r && !r.draft && !r.prerelease && r.tag_name && r.published_at)
.map((r) => ({
version: String(r.tag_name).replace(/^v/, '').trim(),
publishedAt: String(r.published_at),
}))
.filter((r) => SEMVER_TAG.test(r.version))
.filter((r) => parseMajorVersion(r.version) === currentMajor)
.filter((r) => isNewerVersion(r.version, currentVersion))
.filter((r) => DateTime.fromISO(r.publishedAt) <= cutoff)
.sort((a, b) => (isNewerVersion(a.version, b.version) ? -1 : 1))
const best = candidates[0]
if (!best) return null
return {
version: best.version,
tag: `v${best.version}`,
publishedAt: best.publishedAt,
}
}
/**
* Pre-flight checks that gate an auto-update. `skip` blockers are transient
* (retry next window, no penalty); `failure` blockers count toward the backoff
* that eventually auto-disables auto-update.
*/
async runPreflight(targetTag: string): Promise<PreflightResult> {
const blockers: Blocker[] = []
// 1. Sidecar must be present to perform the update.
if (!this.systemUpdateService.isSidecarAvailable()) {
blockers.push({ reason: 'Update sidecar is not available', severity: 'failure' })
}
// 2. No system update already running.
const updateStatus = this.systemUpdateService.getUpdateStatus()
if (updateStatus && !['idle', 'complete', 'error'].includes(updateStatus.stage)) {
blockers.push({
reason: `A system update is already in progress (stage: ${updateStatus.stage})`,
severity: 'skip',
})
}
// 3. No content/model downloads in progress.
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) {
logger.warn(`[AutoUpdateService] Could not check active downloads: ${error.message}`)
}
// 4. No app (container) install/update in progress.
try {
const installing = await Service.query().whereNot('installation_status', 'idle')
if (installing.length > 0) {
blockers.push({
reason: `${installing.length} app install/update(s) in progress`,
severity: 'skip',
})
}
} catch (error) {
logger.warn(`[AutoUpdateService] Could not check app installations: ${error.message}`)
}
// 5. Sufficient host storage for the new image.
const diskBlocker = await this.checkDiskSpace(targetTag)
if (diskBlocker) blockers.push(diskBlocker)
return { ok: blockers.length === 0, blockers }
}
/** Returns a disk blocker if free space is insufficient, otherwise null. */
private async checkDiskSpace(targetTag: string): Promise<Blocker | null> {
try {
const hostArch = await this.getHostArch()
const parsed = this.containerRegistryService.parseImageReference(
`${NOMAD_IMAGE_REPO}:${targetTag}`
)
const imageSize = await this.containerRegistryService.getImageDownloadSize(
parsed,
targetTag,
hostArch
)
const required = imageSize !== null ? imageSize * DISK_SAFETY_FACTOR : MIN_FREE_BYTES
const free = await this.getFreeBytes()
if (free === null) {
logger.warn('[AutoUpdateService] Could not determine free disk space; skipping disk check')
return null
}
if (free < required) {
return {
reason: `Insufficient disk space: ${this.gib(free)} free, ${this.gib(required)} required`,
severity: 'failure',
}
}
return null
} catch (error) {
logger.warn(`[AutoUpdateService] Disk space check failed: ${error.message}`)
return null
}
}
/** Free bytes on the root filesystem (best-effort, falls back to max available). */
private async getFreeBytes(): Promise<number | null> {
const info = await this.systemService.getSystemInfo()
if (!info?.fsSize?.length) return null
const root = info.fsSize.find((f) => f.mount === '/')
if (root) return root.available
return Math.max(...info.fsSize.map((f) => f.available))
}
private gib(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`
}
/** Map the Docker daemon's architecture string to OCI naming (amd64/arm64/...). */
private async getHostArch(): Promise<string> {
try {
const info = await this.dockerService.docker.info()
const arch = info.Architecture || ''
const archMap: Record<string, string> = {
x86_64: 'amd64',
aarch64: 'arm64',
armv7l: 'arm',
amd64: 'amd64',
arm64: 'arm64',
}
return archMap[arch] || arch.toLowerCase()
} catch {
return 'amd64'
}
}
/**
* Side-effect-free core of the decision pipeline. Resolves the effective config
* (settings, overridable), checks the window, finds an eligible target, and runs
* pre-flight returning a verdict WITHOUT requesting an update or mutating any
* persisted state. Both {@link attempt} (production) and {@link dryRun} (testing)
* are built on this so a dry run faithfully reflects what a real run would do.
*/
async evaluate(overrides: EvaluateOverrides = {}): Promise<AutoUpdateDecision> {
const baseConfig = await this.getConfig()
const config: AutoUpdateConfig = {
enabled: overrides.forceEnabled ?? baseConfig.enabled,
windowStart: overrides.windowStart ?? baseConfig.windowStart,
windowEnd: overrides.windowEnd ?? baseConfig.windowEnd,
cooloffHours: overrides.cooloffHours ?? baseConfig.cooloffHours,
}
const now = overrides.now ?? DateTime.now()
const currentVersion = overrides.currentVersion ?? SystemService.getAppVersion()
const base = {
enabled: config.enabled,
currentVersion,
config,
withinWindow: false,
eligibleTarget: null as EligibleTarget | null,
preflight: null as PreflightResult | null,
}
if (!config.enabled) {
return { ...base, outcome: 'disabled', reason: 'Auto-update is disabled' }
}
const withinWindow = this.isWithinWindow(config, now)
if (!withinWindow) {
return {
...base,
outcome: 'outside-window',
reason: `Outside update window (${config.windowStart}-${config.windowEnd})`,
}
}
let eligibleTarget: EligibleTarget | null
try {
const releases = overrides.releases ?? (await this.fetchReleases())
eligibleTarget = this.selectEligibleTarget(releases, currentVersion, config.cooloffHours, now)
} catch (error) {
return {
...base,
withinWindow,
outcome: 'eligibility-error',
reason: `Failed to determine eligible version: ${error.message}`,
}
}
if (!eligibleTarget) {
return {
...base,
withinWindow,
outcome: 'no-eligible',
reason: 'No eligible minor/patch update available (or still in cool-off)',
}
}
const preflight = overrides.fakePreflight
? overrides.fakePreflight
: overrides.skipPreflight
? { ok: true, blockers: [] }
: await this.runPreflight(eligibleTarget.tag)
if (!preflight.ok) {
const summary = preflight.blockers.map((b) => b.reason).join('; ')
return {
...base,
withinWindow,
eligibleTarget,
preflight,
outcome: 'blocked',
reason: `Pre-flight blocked: ${summary}`,
}
}
return {
...base,
withinWindow,
eligibleTarget,
preflight,
outcome: 'ready',
reason: `Ready to update to ${eligibleTarget.tag}`,
}
}
/**
* Run the full decision pipeline WITHOUT requesting an update or recording any
* state. Accepts the same injectable overrides as {@link evaluate}, so callers can
* simulate any scenario (a given current version, a canned release list, a fixed
* clock, a forced window) and see exactly what a real run would decide.
*/
async dryRun(overrides: EvaluateOverrides = {}): Promise<AutoUpdateDecision> {
return this.evaluate(overrides)
}
/**
* The entry point invoked by AutoUpdateJob. Evaluates the decision pipeline and,
* when everything passes, requests the update with the vetted tag recording the
* outcome to the KVStore (for the UI) and applying failure backoff.
*/
async attempt(): Promise<{ updated: boolean; reason: string }> {
const decision = await this.evaluate()
switch (decision.outcome) {
case 'disabled':
return { updated: false, reason: decision.reason }
case 'outside-window':
case 'no-eligible':
// A failed release lookup is transient (offline-first appliances are
// routinely without connectivity) — treat as a skip so it never trips the
// backoff that auto-disables the feature. Only real update-request failures
// (the `ready` case below) count toward MAX_CONSECUTIVE_FAILURES.
case 'eligibility-error':
await this.recordSkip(decision.reason)
return { updated: false, reason: decision.reason }
case 'blocked': {
const hasFailure = decision.preflight!.blockers.some((b) => b.severity === 'failure')
if (hasFailure) {
await this.recordFailure(decision.reason)
} else {
await this.recordSkip(decision.reason)
}
return { updated: false, reason: decision.reason }
}
case 'ready': {
const target = decision.eligibleTarget!
const result = await this.systemUpdateService.requestUpdate({
targetTag: target.tag,
requester: 'auto-update',
})
if (result.success) {
await this.recordSuccess(target)
logger.info(`[AutoUpdateService] Auto-update requested: ${target.tag}`)
return { updated: true, reason: `Update requested: ${target.tag}` }
}
await this.recordFailure(`Update request failed: ${result.message}`)
return { updated: false, reason: result.message }
}
}
}
// --- Outcome recording -----------------------------------------------------
private async recordSuccess(target: EligibleTarget): Promise<void> {
await KVStore.setValue('autoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('autoUpdate.lastResult', `Update requested: ${target.tag}`)
await KVStore.clearValue('autoUpdate.lastError')
await KVStore.setValue('autoUpdate.consecutiveFailures', '0')
}
private async recordSkip(reason: string): Promise<void> {
await KVStore.setValue('autoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('autoUpdate.lastResult', reason)
logger.info(`[AutoUpdateService] Skipped: ${reason}`)
}
private async recordFailure(reason: string): Promise<void> {
await KVStore.setValue('autoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('autoUpdate.lastResult', reason)
await KVStore.setValue('autoUpdate.lastError', reason)
const prior = Number(await KVStore.getValue('autoUpdate.consecutiveFailures')) || 0
const failures = prior + 1
await KVStore.setValue('autoUpdate.consecutiveFailures', String(failures))
logger.error(`[AutoUpdateService] Failure ${failures}/${MAX_CONSECUTIVE_FAILURES}: ${reason}`)
if (failures >= MAX_CONSECUTIVE_FAILURES) {
await KVStore.setValue('autoUpdate.enabled', false)
await KVStore.setValue(
'autoUpdate.autoDisabledReason',
`Auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
)
logger.error(
`[AutoUpdateService] Auto-update auto-disabled after ${failures} consecutive failures`
)
}
}
/** Full state snapshot for the settings UI. */
async getStatus(): Promise<AutoUpdateStatus> {
const config = await this.getConfig()
const currentVersion = SystemService.getAppVersion()
let eligibleTarget: EligibleTarget | null = null
try {
eligibleTarget = await this.getEligibleTarget(config)
} catch (error) {
logger.warn(`[AutoUpdateService] getStatus eligibility lookup failed: ${error.message}`)
}
const [lastAttemptAt, lastResult, lastError, consecutiveFailures, autoDisabledReason] =
await Promise.all([
KVStore.getValue('autoUpdate.lastAttemptAt'),
KVStore.getValue('autoUpdate.lastResult'),
KVStore.getValue('autoUpdate.lastError'),
KVStore.getValue('autoUpdate.consecutiveFailures'),
KVStore.getValue('autoUpdate.autoDisabledReason'),
])
return {
...config,
currentVersion,
withinWindow: this.isWithinWindow(config),
eligibleTarget,
lastAttemptAt: lastAttemptAt || null,
lastResult: lastResult || null,
lastError: lastError || null,
consecutiveFailures: Number(consecutiveFailures) || 0,
autoDisabledReason: autoDisabledReason || null,
}
}
}

View File

@ -200,6 +200,66 @@ export class ContainerRegistryService {
}
}
/**
* Estimate the compressed download size (in bytes) of an image tag for the
* given host architecture by summing its layer sizes from the manifest.
*
* Resolves a multi-arch manifest list/index down to the platform-specific
* manifest before summing `layers[].size`. Returns null on any failure so
* callers can fall back to a conservative fixed threshold rather than
* silently skipping a disk pre-flight check.
*/
async getImageDownloadSize(
parsed: ParsedImageReference,
tag: string,
hostArch: string
): Promise<number | null> {
try {
const token = await this.getToken(parsed.registry, parsed.fullName)
const manifestAccept = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
].join(', ')
const fetchManifest = async (ref: string) =>
this.fetchWithRetry(`https://${parsed.registry}/v2/${parsed.fullName}/manifests/${ref}`, {
headers: { Authorization: `Bearer ${token}`, Accept: manifestAccept },
})
const topRes = await fetchManifest(tag)
if (!topRes.ok) return null
let manifest = (await topRes.json()) as {
mediaType?: string
layers?: Array<{ size?: number }>
manifests?: Array<{ digest?: string; platform?: { architecture?: string } }>
}
// Multi-arch manifest list/index — resolve to the host-arch child manifest.
if (manifest.manifests?.length) {
const match =
manifest.manifests.find((m) => m.platform?.architecture === hostArch) ||
manifest.manifests[0]
if (!match?.digest) return null
const childRes = await fetchManifest(match.digest)
if (!childRes.ok) return null
manifest = (await childRes.json()) as { layers?: Array<{ size?: number }> }
}
if (!manifest.layers?.length) return null
return manifest.layers.reduce((total, layer) => total + (layer.size || 0), 0)
} catch (error) {
logger.warn(
`[ContainerRegistryService] Failed to get image size for ${parsed.fullName}:${tag}: ${error.message}`
)
return null
}
}
/**
* Extract the source repository URL from an image's OCI labels.
* Uses the standardized `org.opencontainers.image.source` label.

View File

@ -832,6 +832,12 @@ export class SystemService {
if (key === 'ai.assistantCustomName') {
invalidateAssistantNameCache()
}
// Re-enabling auto-update after a backoff-driven auto-disable clears the
// failure state so it gets a fresh start instead of immediately re-tripping.
if (key === 'autoUpdate.enabled' && (value === true || value === 'true')) {
await KVStore.setValue('autoUpdate.consecutiveFailures', '0')
await KVStore.clearValue('autoUpdate.autoDisabledReason')
}
}
/**

View File

@ -18,9 +18,18 @@ export class SystemUpdateService {
private static LOG_FILE = join(SystemUpdateService.SHARED_DIR, 'update-log')
/**
* Requests a system update by creating a request file that the sidecar will detect
* Requests a system update by creating a request file that the sidecar will detect.
*
* @param options.targetTag - Explicit Docker image tag to install (e.g. "v1.33.2").
* When omitted, falls back to the cached `system.latestVersion` (manual-update
* behavior). Auto-update passes an eligibility-vetted tag here, which may differ
* from `system.latestVersion` when the newest release is a major bump.
* @param options.requester - Identifier recorded in the request file for auditing.
*/
async requestUpdate(): Promise<{ success: boolean; message: string }> {
async requestUpdate(options?: {
targetTag?: string
requester?: string
}): Promise<{ success: boolean; message: string }> {
try {
const currentStatus = this.getUpdateStatus()
if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) {
@ -30,13 +39,18 @@ export class SystemUpdateService {
}
}
// Determine the Docker image tag to install.
const latestVersion = await KVStore.getValue('system.latestVersion')
// Determine the Docker image tag to install. Prefer an explicit caller-supplied
// tag; otherwise use the cached latest version.
let targetTag = options?.targetTag
if (!targetTag) {
const latestVersion = await KVStore.getValue('system.latestVersion')
targetTag = latestVersion ? `v${latestVersion}` : 'latest'
}
const requestData = {
requested_at: new Date().toISOString(),
requester: 'admin-api',
target_tag: latestVersion ? `v${latestVersion}` : 'latest',
requester: options?.requester ?? 'admin-api',
target_tag: targetTag,
}
await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2))

View File

@ -1,5 +1,6 @@
import vine from "@vinejs/vine";
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
import type { KVStoreKey } from "../../types/kv_store.js";
export const getSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
@ -8,4 +9,31 @@ export const getSettingSchema = vine.compile(vine.object({
export const updateSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
value: vine.any().optional(),
}))
}))
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
/**
* Validate the *value* for keys that have format constraints beyond the generic
* enum/any check (the generic validator only constrains the key). Returns an
* error message string when invalid, or null when the value is acceptable.
*/
export function validateSettingValue(key: KVStoreKey, value: unknown): string | null {
switch (key) {
case 'autoUpdate.windowStart':
case 'autoUpdate.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': {
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
}
default:
return null
}
}

View File

@ -0,0 +1,302 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { readFile } from 'node:fs/promises'
/**
* Exercise the auto-update decision pipeline WITHOUT ever triggering a real update.
*
* # Prove the core logic deterministically (no network/DB/Docker):
* node ace auto-update:dry-run --scenarios
*
* # Simulate "what would happen if I were running 1.32.0 right now"
* # against the live GitHub releases feed and real pre-flight checks:
* node ace auto-update:dry-run --current=1.32.0 --force-enabled
*
* # Fully offline simulation with a canned release list + fixed clock:
* node ace auto-update:dry-run --current=1.32.0 --force-enabled \
* --releases-file=./fixtures/releases.json --now=2026-06-04T21:00:00Z \
* --window-start=20:00 --window-end=23:00 --skip-preflight
*/
export default class AutoUpdateDryRun extends BaseCommand {
static commandName = 'auto-update:dry-run'
static description = 'Dry-run the auto-update decision pipeline (never triggers an update)'
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
declare scenarios: boolean
@flags.string({ description: 'Simulate this currently-running version (e.g. 1.32.0)' })
declare current: string
@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: 'Simulate the clock at this ISO timestamp' })
declare now: string
@flags.string({ description: 'Path to a JSON file with a GitHub releases array (offline)' })
declare releasesFile: string
@flags.boolean({ description: 'Bypass Docker/disk/queue pre-flight checks' })
declare skipPreflight: boolean
static options: CommandOptions = {
startApp: true,
}
async run() {
const { DateTime } = await import('luxon')
const { DockerService } = await import('#services/docker_service')
const { DownloadService } = await import('#services/download_service')
const { SystemService } = await import('#services/system_service')
const { SystemUpdateService } = await import('#services/system_update_service')
const { ContainerRegistryService } = await import('#services/container_registry_service')
const { QueueService } = await import('#services/queue_service')
const { AutoUpdateService } = await import('#services/auto_update_service')
const dockerService = new DockerService()
const svc = new AutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new SystemUpdateService(),
new ContainerRegistryService()
)
if (this.scenarios) {
const ok = this.runScenarios(svc, DateTime)
if (!ok) {
this.exitCode = 1
}
return
}
// --- Live / simulated single dry run ------------------------------------
const overrides: Record<string, any> = {}
if (this.current) overrides.currentVersion = this.current
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.skipPreflight) overrides.skipPreflight = true
if (this.now) overrides.now = DateTime.fromISO(this.now)
if (this.releasesFile) {
const raw = await readFile(this.releasesFile, 'utf-8')
overrides.releases = JSON.parse(raw)
}
this.logger.info('Running auto-update dry run (no update will be triggered)...')
const decision = await svc.dryRun(overrides)
this.logger.log('')
this.logger.log(` Current version : ${decision.currentVersion}`)
this.logger.log(` Enabled : ${decision.enabled}`)
this.logger.log(
` Window : ${decision.config.windowStart}-${decision.config.windowEnd} ` +
`(currently ${decision.withinWindow ? 'inside' : 'outside'})`
)
this.logger.log(` Cool-off hours : ${decision.config.cooloffHours}`)
this.logger.log(
` Eligible target : ${decision.eligibleTarget ? decision.eligibleTarget.tag + ' (published ' + decision.eligibleTarget.publishedAt + ')' : '—'}`
)
if (decision.preflight) {
if (decision.preflight.ok) {
this.logger.log(` Pre-flight : ok`)
} else {
this.logger.log(` Pre-flight : BLOCKED`)
for (const b of decision.preflight.blockers) {
this.logger.log(` - [${b.severity}] ${b.reason}`)
}
}
} else {
this.logger.log(` Pre-flight : (not reached)`)
}
this.logger.log('')
const verdict =
decision.outcome === 'ready'
? `WOULD UPDATE → ${decision.eligibleTarget!.tag}`
: `WOULD NOT UPDATE (${decision.outcome}): ${decision.reason}`
if (decision.outcome === 'ready') {
this.logger.success(verdict)
} else {
this.logger.info(verdict)
}
}
/**
* Deterministic acceptance suite over the pure decision helpers no network,
* DB, or Docker. Proves every branch reviewers care about.
*/
private runScenarios(svc: any, DateTime: any): boolean {
const NOW = '2026-06-04T12:00:00Z'
const now = DateTime.fromISO(NOW)
const daysAgo = (d: number) => now.minus({ days: d }).toISO()
const hoursAgo = (h: number) => now.minus({ hours: h }).toISO()
const rel = (tag: string, published: string, extra: object = {}) => ({
tag_name: tag,
published_at: published,
...extra,
})
type EligCase = {
name: string
releases: any[]
current: string
cooloff: number
expect: string | null
}
const eligibility: EligCase[] = [
{
name: 'only a major bump is newer → none (major requires manual)',
releases: [rel('v2.0.0', daysAgo(10))],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'same-major minor newer but inside cool-off → none',
releases: [rel('v1.33.0', hoursAgo(10))],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'same-major patch past cool-off → selected',
releases: [rel('v1.32.1', daysAgo(5))],
current: '1.32.0',
cooloff: 72,
expect: '1.32.1',
},
{
name: 'mixed: newest same-major past cool-off wins; major/in-cooloff/prerelease ignored',
releases: [
rel('v2.0.0', daysAgo(30)),
rel('v1.34.0', hoursAgo(5)),
rel('v1.33.2', daysAgo(4)),
rel('v1.33.5', daysAgo(1), { prerelease: true }),
rel('v1.33.0', daysAgo(8)),
],
current: '1.32.9',
cooloff: 72,
expect: '1.33.2',
},
{
name: 'draft releases ignored',
releases: [rel('v1.33.0', daysAgo(5), { draft: true })],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'malformed tag with injection chars → ignored (M2)',
releases: [rel('v1.33.0|; e reboot', daysAgo(10))],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'dev build never updates',
releases: [rel('v1.33.0', daysAgo(10))],
current: 'dev',
cooloff: 72,
expect: null,
},
{
name: 'cool-off of 0 applies immediately',
releases: [rel('v1.32.1', hoursAgo(1))],
current: '1.32.0',
cooloff: 0,
expect: '1.32.1',
},
]
type WinCase = { name: string; start: string; end: string; at: string; expect: boolean }
const at = (hhmm: string) => `2026-06-04T${hhmm}:00`
const windows: WinCase[] = [
{
name: 'normal 20:00-23:00 @ 21:00 → in',
start: '20:00',
end: '23:00',
at: at('21:00'),
expect: true,
},
{
name: 'normal 20:00-23:00 @ 19:00 → out',
start: '20:00',
end: '23:00',
at: at('19:00'),
expect: false,
},
{
name: 'wrap 22:00-02:00 @ 23:00 → in',
start: '22:00',
end: '02:00',
at: at('23:00'),
expect: true,
},
{
name: 'wrap 22:00-02:00 @ 01:00 → in',
start: '22:00',
end: '02:00',
at: at('01:00'),
expect: true,
},
{
name: 'wrap 22:00-02:00 @ 12:00 → out',
start: '22:00',
end: '02:00',
at: at('12:00'),
expect: false,
},
]
let passed = 0
let failed = 0
this.logger.log('')
this.logger.log('Eligibility scenarios:')
for (const c of eligibility) {
const got = svc.selectEligibleTarget(c.releases, c.current, c.cooloff, now)
const gotVersion = got ? got.version : null
const ok = gotVersion === c.expect
this.report(ok, `${c.name} (expected ${c.expect ?? 'none'}, got ${gotVersion ?? 'none'})`)
ok ? passed++ : failed++
}
this.logger.log('')
this.logger.log('Window scenarios:')
for (const c of windows) {
const cfg = { enabled: true, windowStart: c.start, windowEnd: c.end, cooloffHours: 72 }
const got = svc.isWithinWindow(cfg, DateTime.fromISO(c.at))
const ok = got === c.expect
this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`)
ok ? passed++ : failed++
}
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
}
private report(ok: boolean, message: string) {
if (ok) {
this.logger.log(` ${this.colors.green('✓')} ${message}`)
} else {
this.logger.log(` ${this.colors.red('✗')} ${message}`)
}
}
}

View File

@ -9,6 +9,7 @@ import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
import { EmbedFileJob } from '#jobs/embed_file_job'
import { CheckUpdateJob } from '#jobs/check_update_job'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { AutoUpdateJob } from '#jobs/auto_update_job'
export default class QueueWork extends BaseCommand {
static commandName = 'queue:work'
@ -103,6 +104,7 @@ export default class QueueWork extends BaseCommand {
// Schedule nightly update checks (idempotent, will persist over restarts)
await CheckUpdateJob.scheduleNightly()
await CheckServiceUpdatesJob.scheduleNightly()
await AutoUpdateJob.schedule()
// Safety net: log unhandled rejections instead of crashing the worker process.
// Individual job errors are already caught by BullMQ; this catches anything that
@ -133,6 +135,7 @@ export default class QueueWork extends BaseCommand {
handlers.set(EmbedFileJob.key, new EmbedFileJob())
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
handlers.set(AutoUpdateJob.key, new AutoUpdateJob())
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue)
@ -141,6 +144,7 @@ export default class QueueWork extends BaseCommand {
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
queues.set(AutoUpdateJob.key, AutoUpdateJob.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'];
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'];

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import api from '~/lib/api'
import { AutoUpdateStatus } from '../../types/system'
export const useAutoUpdateStatus = () => {
return useQuery<AutoUpdateStatus | undefined>({
queryKey: ['auto-update-status'],
queryFn: () => api.getAutoUpdateStatus(),
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 { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import { AutoUpdateStatus, CheckLatestVersionResult, 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'
@ -542,6 +542,13 @@ class API {
})()
}
async getAutoUpdateStatus() {
return catchInternal(async () => {
const response = await this.client.get<AutoUpdateStatus>('/system/auto-update/status')
return response.data
})()
}
async healthCheck() {
return catchInternal(async () => {
const response = await this.client.get<{ status: string }>('/health', {

View File

@ -15,6 +15,7 @@ import Switch from '~/components/inputs/Switch'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
import { useSystemSetting } from '~/hooks/useSystemSetting'
import { useAutoUpdateStatus } from '~/hooks/useAutoUpdateStatus'
import { formatBytes } from '~/lib/util'
type Props = {
@ -257,6 +258,181 @@ function ContentUpdatesSection() {
)
}
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' },
]
function AutoUpdateSection() {
const { addNotification } = useNotifications()
const queryClient = useQueryClient()
const { data: status, isLoading } = useAutoUpdateStatus()
const [windowStart, setWindowStart] = useState('02:00')
const [windowEnd, setWindowEnd] = useState('05:00')
const [cooloff, setCooloff] = useState(72)
// Seed editable fields once the persisted status loads.
useEffect(() => {
if (status) {
setWindowStart(status.windowStart)
setWindowEnd(status.windowEnd)
setCooloff(status.cooloffHours)
}
}, [status?.windowStart, status?.windowEnd, status?.cooloffHours])
const saveMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: any }) => api.updateSetting(key, value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auto-update-status'] })
},
onError: () => {
addNotification({ type: 'error', message: 'Failed to update auto-update setting.' })
},
})
const enabled = status?.enabled ?? false
const autoDisabled = !!status?.autoDisabledReason
const handleToggle = (value: boolean) => {
saveMutation.mutate(
{ key: 'autoUpdate.enabled', value },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auto-update-status'] })
addNotification({
type: 'success',
message: value ? 'Automatic updates enabled.' : 'Automatic updates disabled.',
})
},
}
)
}
const handleSaveWindow = async () => {
try {
await api.updateSetting('autoUpdate.windowStart', windowStart)
await api.updateSetting('autoUpdate.windowEnd', windowEnd)
await api.updateSetting('autoUpdate.cooloffHours', String(cooloff))
queryClient.invalidateQueries({ queryKey: ['auto-update-status'] })
addNotification({ type: 'success', message: 'Auto-update schedule saved.' })
} catch {
addNotification({ type: 'error', message: 'Failed to save auto-update schedule.' })
}
}
return (
<>
<StyledSectionHeader title="Automatic 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 Updates Disabled"
message={status?.autoDisabledReason || 'Auto-update was disabled after repeated failures.'}
variant="bordered"
className="mb-4"
/>
)}
<Switch
checked={enabled}
onChange={handleToggle}
disabled={saveMutation.isPending || isLoading}
label="Enable Automatic Updates"
description="Automatically install minor and patch updates during your chosen window. Major versions always require a manual update due to their potentially breaking nature."
/>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
name="autoUpdateWindowStart"
label="Window Start"
type="time"
value={windowStart}
onChange={(e) => setWindowStart(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<Input
name="autoUpdateWindowEnd"
label="Window End"
type="time"
value={windowEnd}
onChange={(e) => setWindowEnd(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<div>
<label
htmlFor="autoUpdateCooloff"
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 release is published</p>
<select
id="autoUpdateCooloff"
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>
</div>
<div className="mt-4 flex justify-end">
<StyledButton
variant="primary"
size="sm"
onClick={handleSaveWindow}
disabled={!enabled}
>
Save Schedule
</StyledButton>
</div>
{enabled && status && (
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm space-y-1">
<p className="text-desert-stone-dark">
<span className="font-medium">Status: </span>
{status.eligibleTarget
? `Eligible update ready: ${status.eligibleTarget.version}`
: 'No eligible update — system is current or the latest release is a major version / still in cool-off.'}
</p>
<p className="text-desert-stone">
<span className="font-medium">Update window: </span>
{status.withinWindow ? 'Currently inside the window' : 'Currently outside the window'}
</p>
{status.lastResult && (
<p className="text-desert-stone">
<span className="font-medium">Last check: </span>
{status.lastResult}
{status.lastAttemptAt
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
: ''}
</p>
)}
{status.lastError && (
<p className="text-desert-red">
<span className="font-medium">Last error: </span>
{status.lastError}
</p>
)}
</div>
)}
</div>
</>
)
}
export default function SystemUpdatePage(props: { system: Props }) {
const { addNotification } = useNotifications()
@ -668,6 +844,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
description="Receive release candidate (RC) versions before they are officially released. Note: RC versions may contain bugs and are not recommended for environments where stability and data integrity are critical."
/>
</div>
<AutoUpdateSection />
<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

@ -187,6 +187,7 @@ router
router.post('/update', [SystemController, 'requestSystemUpdate'])
router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])
router.get('/update/logs', [SystemController, 'getSystemUpdateLogs'])
router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus'])
router.get('/settings', [SettingsController, 'getSetting'])
router.patch('/settings', [SettingsController, 'updateSetting'])
})

View File

@ -7,6 +7,15 @@ export const KV_STORE_SCHEMA = {
'system.updateAvailable': 'boolean',
'system.latestVersion': 'string',
'system.earlyAccess': 'boolean',
'autoUpdate.enabled': 'boolean',
'autoUpdate.windowStart': 'string',
'autoUpdate.windowEnd': 'string',
'autoUpdate.cooloffHours': 'string',
'autoUpdate.lastAttemptAt': 'string',
'autoUpdate.lastError': 'string',
'autoUpdate.lastResult': 'string',
'autoUpdate.consecutiveFailures': 'string',
'autoUpdate.autoDisabledReason': 'string',
'ui.hasVisitedEasySetup': 'boolean',
'ui.theme': 'string',
'ai.assistantCustomName': 'string',

View File

@ -85,4 +85,25 @@ export type CheckLatestVersionResult = {
currentVersion: string,
latestVersion: string,
message?: string
}
export type AutoUpdateEligibleTarget = {
version: string
tag: string
publishedAt: string
}
export type AutoUpdateStatus = {
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
currentVersion: string
withinWindow: boolean
eligibleTarget: AutoUpdateEligibleTarget | null
lastAttemptAt: string | null
lastResult: string | null
lastError: string | null
consecutiveFailures: number
autoDisabledReason: string | null
}