Merge remote-tracking branch 'origin/main' into 2025-07-01.eizus.failed-download-retry

# Conflicts:
#	admin/app/services/download_service.ts
This commit is contained in:
eizus 2026-07-01 15:50:14 -04:00
commit 7d6450d6bf
200 changed files with 24616 additions and 3298 deletions

View File

@ -26,6 +26,8 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- name: Sync tags
run: git fetch --tags --force
- name: semantic-release
uses: cycjimmy/semantic-release-action@v6
id: semver

View File

@ -30,7 +30,14 @@ We are committed to providing a welcoming environment for everyone. Disrespectfu
## Before You Start
**Open an issue first.** Before writing any code, please [open an issue](../../issues/new) to discuss your proposed change. This helps avoid duplicate work and ensures your contribution aligns with the project's direction.
**Open an issue first.** Before writing any code for a non-trivial change, you must [open an issue](../../issues/new) to discuss your proposed change. This helps avoid duplicate work and ensures your contribution aligns with the project's direction. **Pull requests submitted without a corresponding issue may be closed at the maintainers' discretion.**
**Trivial fixes are exempt** and may be submitted directly as a PR. Examples:
- Typo and grammar corrections
- Documentation clarifications
- Small one-line bug fixes with an obvious cause
If you're not sure whether your change qualifies as trivial, open an issue first.
When opening an issue:
- Use a clear, descriptive title
@ -149,7 +156,7 @@ This project uses [Semantic Versioning](https://semver.org/). Versions are manag
2. Open a pull request against the `dev` branch of this repository
3. In the PR description:
- Summarize what your changes do and why
- Reference the related issue (e.g., `Closes #123`)
- Reference the related issue (e.g., `Closes #123`) — required for non-trivial changes
- Note any relevant testing steps or environment details
4. Be responsive to feedback — maintainers may request changes. Pull requests with no activity for an extended period may be closed.

View File

@ -1,7 +1,15 @@
FROM node:22-slim AS base
# Install bash & curl for entrypoint script compatibility, graphicsmagick for pdf2pic, and vips-dev & build-base for sharp
RUN apt-get update && apt-get install -y bash curl graphicsmagick libvips-dev build-essential
RUN apt-get update && apt-get install -y \
bash \
curl \
openssl \
graphicsmagick \
libvips-dev \
build-essential \
pciutils \
&& rm -rf /var/lib/apt/lists/*
# All deps stage
FROM base AS deps
@ -27,6 +35,31 @@ FROM base
ARG VERSION=dev
ARG BUILD_DATE
ARG VCS_REF
ARG TARGETARCH
# go-pmtiles (regional map extracts). Pinned so the CLI's stdout format stays
# in sync with parseDryRunOutput().
ARG PMTILES_VERSION=1.30.2
# Upstream releases don't ship a checksums file, so pin per-arch SHA256 here.
# When bumping PMTILES_VERSION, regenerate these with:
# curl -fsSL <release-url> | sha256sum
ARG PMTILES_SHA256_AMD64=2cd3aa18868297fc88425038f794efdc0995e0275f4ca16fa496dd79e245a40c
ARG PMTILES_SHA256_ARM64=804cdf071834e1156af554c1a26cc42b56b9cde5a2db9c6e3653d16fb846d5fa
RUN set -eux; \
case "${TARGETARCH:-amd64}" in \
amd64) PMTILES_ARCH=x86_64; PMTILES_SHA256="${PMTILES_SHA256_AMD64}" ;; \
arm64) PMTILES_ARCH=arm64; PMTILES_SHA256="${PMTILES_SHA256_ARM64}" ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
TARBALL="go-pmtiles_${PMTILES_VERSION}_Linux_${PMTILES_ARCH}.tar.gz"; \
cd /tmp; \
curl -fsSL -o "$TARBALL" \
"https://github.com/protomaps/go-pmtiles/releases/download/v${PMTILES_VERSION}/${TARBALL}"; \
echo "${PMTILES_SHA256} ${TARBALL}" | sha256sum -c -; \
tar -xzf "$TARBALL" -C /usr/local/bin pmtiles; \
rm -f "$TARBALL"; \
chmod +x /usr/local/bin/pmtiles; \
/usr/local/bin/pmtiles version
# Labels
LABEL org.opencontainers.image.title="Project N.O.M.A.D" \
@ -43,13 +76,19 @@ ENV NODE_ENV=production
WORKDIR /app
COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=build /app/build /app
# Copy root package.json for version info
COPY package.json /app/version.json
# Generate version.json from the VERSION build-arg so the image tag is the
# single source of truth (previously copied root package.json, which drifted
# from the tag when semantic-release did not commit the bump back).
RUN echo "{\"version\":\"${VERSION}\"}" > /app/version.json
# Copy docs and README for access within the container
COPY admin/docs /app/docs
COPY README.md /app/README.md
# Empty Calibre library, seeded into storage/books on Calibre-Web install
# (see DockerService._runPreinstallActions__CalibreWeb)
COPY install/calibre-empty-library/metadata.db /app/assets/calibre/metadata.db
# Copy entrypoint script and ensure it's executable
COPY install/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

6
FAQ.md
View File

@ -20,7 +20,9 @@ Long answer: Custom storage paths, mount points, and external drives (like iSCSI
## Can I run NOMAD on MAC, WSL2, or a non-Debian-based Distro?
See [Why does NOMAD require a Debian-based OS?](#why-does-nomad-require-a-debian-based-os)
**WSL2 on Windows** is community-supported via the [WSL2 install guide](https://www.projectnomad.us/install/wsl2) — covers two install paths (native Docker and Docker Desktop) with all known gotchas documented and empirical performance numbers comparing WSL2 to bare-metal.
**macOS and other non-Debian Linux distros** aren't officially supported. See [Why does NOMAD require a Debian-based OS?](#why-does-nomad-require-a-debian-based-os) for details.
## Why does NOMAD require a Debian-based OS?
@ -28,7 +30,7 @@ Project N.O.M.A.D. is currently designed to run on Debian-based Linux distributi
Support for other operating systems will come in the future, but because our development resources are limited as a free and open-source project, we needed to prioritize our efforts and focus on a narrower set of supported platforms for the initial release. We chose Debian-based Linux as our starting point because it's widely used, easy to spin up, and provides a stable environment for running Docker containers.
Community members have provided guides for running N.O.M.A.D. on other platforms (e.g. WSL2, Mac, etc.) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running N.O.M.A.D. on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run N.O.M.A.D. on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise.
For Windows users, the [WSL2 install guide](https://www.projectnomad.us/install/wsl2) provides a community-supported path. Community members have also published guides for other platforms (e.g. macOS) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running N.O.M.A.D. on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run N.O.M.A.D. on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise.
## Can I run NOMAD on a Raspberry Pi or other ARM-based device?
Project N.O.M.A.D. is currently designed to run on x86-64 architecture, and we have not yet tested or optimized it for ARM-based devices like the Raspberry Pi (and have not published any official images for ARM architecture).

View File

@ -14,7 +14,7 @@
---
Project N.O.M.A.D. is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered—anytime, anywhere.
Project N.O.M.A.D. is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered anytime, anywhere.
## Installation & Quickstart
Project N.O.M.A.D. can be installed on any Debian-based operating system (we recommend Ubuntu). Installation is completely terminal-based, and all tools and resources are designed to be accessed through the browser, so there's no need for a desktop environment if you'd rather setup N.O.M.A.D. as a "server" and access it through other clients.
@ -32,7 +32,7 @@ sudo bash install_nomad.sh
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install).
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install). For Windows users, see the [WSL2 install guide](https://www.projectnomad.us/install/wsl2) — community-supported path covering native Docker and Docker Desktop install routes.
### Advanced Installation
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
@ -48,6 +48,8 @@ N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a col
- **Data Tools** — encryption, encoding, and analysis via [CyberChef](https://gchq.github.io/CyberChef/)
- **Notes** — local note-taking via [FlatNotes](https://github.com/dullage/flatnotes)
- **System Benchmark** — hardware scoring with a [community leaderboard](https://benchmark.projectnomad.us)
- **Supply Depot** — a one-click app catalog (PDF tools, file browser, e-book library, password manager, and more) plus the ability to run your own custom Docker containers
- **Automatic Updates** — opt-in, hands-off updates for the core software, installed apps, and offline content, on a schedule you control
- **Easy Setup Wizard** — guided first-time configuration with curated content collections
N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM library manager, and content explorer.
@ -59,18 +61,19 @@ N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM l
| Information Library | Kiwix | Offline Wikipedia, medical references, survival guides, ebooks |
| AI Assistant | Ollama + Qdrant | Built-in chat with document upload and semantic search |
| Education Platform | Kolibri | Khan Academy courses, progress tracking, multi-user support |
| Offline Maps | ProtoMaps | Downloadable regional maps with search and navigation |
| Offline Maps | ProtoMaps | Downloadable regional maps for offline viewing and search |
| Data Tools | CyberChef | Encryption, encoding, hashing, and data analysis |
| Notes | FlatNotes | Local note-taking with markdown support |
| System Benchmark | Built-in | Hardware scoring, Builder Tags, and community leaderboard |
| Supply Depot | Built-in | One-click app catalog + bring-your-own custom Docker containers |
## Device Requirements
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the
available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install.
At it's core, however, N.O.M.A.D. is still very lightweight. For a barebones installation of the management application itself, the following minimal specs are required:
At its core, however, N.O.M.A.D. is still very lightweight. For a barebones installation of the management application itself, the following minimal specs are required:
*Note: Project N.O.M.A.D. is not sponsored by any hardware manufacturer and is designed to be as hardware-agnostic as possible. The harware listed below is for example/comparison use only*
*Note: Project N.O.M.A.D. is not sponsored by any hardware manufacturer and is designed to be as hardware-agnostic as possible. The hardware listed below is for example/comparison use only*
#### Minimum Specs
- Processor: 2 GHz dual-core processor or better
@ -79,7 +82,7 @@ At it's core, however, N.O.M.A.D. is still very lightweight. For a barebones ins
- OS: Debian-based (Ubuntu recommended)
- Stable internet connection (required during install only)
To run LLM's and other included AI tools:
To run LLMs and other included AI tools:
#### Optimal Specs
- Processor: AMD Ryzen 7 or Intel Core i7 or better
@ -91,12 +94,12 @@ To run LLM's and other included AI tools:
**For detailed build recommendations at three price points ($150$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment
Again, Project N.O.M.A.D. itself is quite lightweight it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment
#### Running AI models on a different host
By default, N.O.M.A.D.'s installer will attempt to setup Ollama on the host when the AI Assistant is installed. However, if you would like to run the AI model on a different host, you can go to the settings of of the AI assistant and input a URL for either an ollama or OpenAI-compatible API server (such as LM Studio).
Note that if you use Ollama on a different host, you must start the server with this option `OLLAMA_HOST=0.0.0.0`.
Ollama is the preferred way to use the AI assistant as it has features such as model download that OpenAI API does not support. So when using LM Studio for example, you will have to use LM Studio to download models.
By default, N.O.M.A.D.'s installer will attempt to setup Ollama on the host when the AI Assistant is installed. However, if you would like to run the AI model on a different host, you can go to the settings of the AI assistant and input a URL for either an ollama or OpenAI-compatible API server (such as LM Studio).
Note that if you use Ollama on a different host, you must start the server with this option: `OLLAMA_HOST=0.0.0.0`.
Ollama is the preferred way to use the AI assistant, as it has features such as model download that OpenAI API does not support. So when using LM Studio, for example, you will have to use LM Studio to download models.
You are responsible for the setup of Ollama/OpenAI server on the other host.
## Frequently Asked Questions (FAQ)
@ -105,25 +108,62 @@ For answers to common questions about Project N.O.M.A.D., please see our [FAQ](F
## About Internet Usage & Privacy
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry.
To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace` and checks for a successful response.
To test internet connectivity, N.O.M.A.D. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond.
You can override the endpoint used for this check in two ways. The connectivity test URL can be configured from the UI under **Settings → Advanced** (stored locally on your instance), or you can set the `INTERNET_STATUS_TEST_URL` environment variable. When set, the environment variable always takes precedence over the UI-configured value. If neither is set, the built-in defaults above are used.
## About Security
By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed.
By design, Project N.O.M.A.D. is intended to be open and available without hurdles it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access its resources), you can block/open ports to control which services are exposed.
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support use cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
## 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
- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) - Get help, share your builds, and connect with other NOMAD users
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - See how your hardware stacks up against other NOMAD builds
- **Troubleshooting Guide:** [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Find solutions to common issues
- **FAQ:** [FAQ.md](FAQ.md) - Find answers to frequently asked questions
- **Community Add-Ons:** [admin/docs/community-add-ons.md](admin/docs/community-add-ons.md) - Third-party content packs built by the community
## License

View File

@ -1,6 +1,10 @@
PORT=8080
HOST=localhost
LOG_LEVEL=info
# Optional: override the URL used to test internet connectivity.
# Defaults to https://1.1.1.1/cdn-cgi/trace with fallbacks to hosts the app
# already contacts. Leave unset to use the defaults.
# INTERNET_STATUS_TEST_URL=https://1.1.1.1/cdn-cgi/trace
APP_KEY=some_random_key
NODE_ENV=development
SESSION_DRIVER=cookie
@ -12,6 +16,9 @@ DB_PASSWORD=password
DB_SSL=false
REDIS_HOST=localhost
REDIS_PORT=6379
# Optional: Redis logical database index (0-15). Defaults to 0 if unset.
# Set this when sharing a Redis instance across services to avoid key collisions.
# REDIS_DB=0
# Storage path for NOMAD content (ZIM files, maps, etc.)
# On Windows dev, use an absolute path like: C:/nomad-storage
# On Linux production, use: /opt/project-nomad/storage

View File

@ -55,6 +55,9 @@ export default defineConfig({
() => import('@adonisjs/transmit/transmit_provider'),
() => import('#providers/map_static_provider'),
() => import('#providers/kiwix_migration_provider'),
() => import('#providers/qdrant_restart_policy_provider'),
() => import('#providers/version_check_provider'),
() => import('#providers/gpu_passthrough_remediation_provider'),
],
/*
@ -106,6 +109,10 @@ export default defineConfig({
pattern: 'resources/views/**/*.edge',
reloadServer: false,
},
{
pattern: 'resources/geodata/**/*.geojson',
reloadServer: false,
},
{
pattern: 'public/**',
reloadServer: false,

View File

@ -5,6 +5,7 @@ import { runBenchmarkValidator, submitBenchmarkValidator } from '#validators/ben
import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
import type { BenchmarkType } from '../../types/benchmark.js'
import { randomUUID } from 'node:crypto'
import logger from '@adonisjs/core/services/logger'
@inject()
export default class BenchmarkController {
@ -52,9 +53,10 @@ export default class BenchmarkController {
result,
})
} catch (error) {
logger.error({ err: error }, '[BenchmarkController] Benchmark run failed')
return response.status(500).send({
success: false,
error: error.message,
error: 'An internal error occurred while running the benchmark.',
})
}
}
@ -181,9 +183,10 @@ export default class BenchmarkController {
} catch (error) {
// Pass through the status code from the service if available, otherwise default to 400
const statusCode = (error as any).statusCode || 400
logger.error({ err: error }, '[BenchmarkController] Benchmark submit failed')
return response.status(statusCode).send({
success: false,
error: error.message,
error: 'Failed to submit benchmark results.',
})
}
}

View File

@ -5,6 +5,7 @@ import { createSessionSchema, updateSessionSchema, addMessageSchema } from '#val
import KVStore from '#models/kv_store'
import { SystemService } from '#services/system_service'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import logger from '@adonisjs/core/services/logger'
@inject()
export default class ChatsController {
@ -45,8 +46,9 @@ export default class ChatsController {
const session = await this.chatService.createSession(data.title, data.model)
return response.status(201).json(session)
} catch (error) {
logger.error({ err: error }, '[ChatsController] Failed to create session')
return response.status(500).json({
error: error instanceof Error ? error.message : 'Failed to create session',
error: 'Failed to create session',
})
}
}
@ -56,8 +58,9 @@ export default class ChatsController {
const suggestions = await this.chatService.getChatSuggestions()
return response.status(200).json({ suggestions })
} catch (error) {
logger.error({ err: error }, '[ChatsController] Failed to get suggestions')
return response.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get suggestions',
error: 'Failed to get suggestions',
})
}
}
@ -69,8 +72,9 @@ export default class ChatsController {
const session = await this.chatService.updateSession(sessionId, data)
return session
} catch (error) {
logger.error({ err: error }, '[ChatsController] Failed to update session')
return response.status(500).json({
error: error instanceof Error ? error.message : 'Failed to update session',
error: 'Failed to update session',
})
}
}
@ -81,8 +85,9 @@ export default class ChatsController {
await this.chatService.deleteSession(sessionId)
return response.status(204)
} catch (error) {
logger.error({ err: error }, '[ChatsController] Failed to delete session')
return response.status(500).json({
error: error instanceof Error ? error.message : 'Failed to delete session',
error: 'Failed to delete session',
})
}
}
@ -94,8 +99,9 @@ export default class ChatsController {
const message = await this.chatService.addMessage(sessionId, data.role, data.content)
return response.status(201).json(message)
} catch (error) {
logger.error({ err: error }, '[ChatsController] Failed to add message')
return response.status(500).json({
error: error instanceof Error ? error.message : 'Failed to add message',
error: 'Failed to add message',
})
}
}
@ -105,8 +111,9 @@ export default class ChatsController {
const result = await this.chatService.deleteAllSessions()
return response.status(200).json(result)
} catch (error) {
logger.error({ err: error }, '[ChatsController] Failed to delete all sessions')
return response.status(500).json({
error: error instanceof Error ? error.message : 'Failed to delete all sessions',
error: 'Failed to delete all sessions',
})
}
}

View File

@ -4,6 +4,8 @@ import {
assertNotPrivateUrl,
downloadCollectionValidator,
filenameParamValidator,
mapExtractPreflightValidator,
mapExtractValidator,
remoteDownloadValidator,
remoteDownloadValidatorOptional,
} from '#validators/common'
@ -87,6 +89,28 @@ export default class MapsController {
}
}
async listCountries({}: HttpContext) {
return { countries: await this.mapService.listCountries() }
}
async listCountryGroups({}: HttpContext) {
return { groups: await this.mapService.listCountryGroups() }
}
async extractPreflight({ request }: HttpContext) {
const payload = await request.validateUsing(mapExtractPreflightValidator)
return await this.mapService.extractPreflight(payload)
}
async extractRegion({ request }: HttpContext) {
const payload = await request.validateUsing(mapExtractValidator)
const result = await this.mapService.extractRegion(payload)
return {
message: 'Extract started successfully',
...result,
}
}
async styles({ request, response }: HttpContext) {
// Automatically ensure base assets are present before generating styles
const baseAssetsExist = await this.mapService.ensureBaseAssets()
@ -137,9 +161,11 @@ export default class MapsController {
vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255),
longitude: vine.number(),
latitude: vine.number(),
longitude: vine.number().min(-180).max(180),
latitude: vine.number().min(-90).max(90),
color: vine.string().trim().maxLength(20).optional(),
notes: vine.string().trim().nullable().optional(),
marker_type: vine.string().trim().maxLength(20).optional(),
})
)
)
@ -148,6 +174,8 @@ export default class MapsController {
longitude: payload.longitude,
latitude: payload.latitude,
color: payload.color ?? 'orange',
notes: payload.notes ?? null,
marker_type: payload.marker_type ?? 'pin',
})
return marker
}
@ -163,11 +191,19 @@ export default class MapsController {
vine.object({
name: vine.string().trim().minLength(1).maxLength(255).optional(),
color: vine.string().trim().maxLength(20).optional(),
longitude: vine.number().min(-180).max(180).optional(),
latitude: vine.number().min(-90).max(90).optional(),
notes: vine.string().trim().nullable().optional(),
marker_type: vine.string().trim().maxLength(20).optional(),
})
)
)
if (payload.name !== undefined) marker.name = payload.name
if (payload.color !== undefined) marker.color = payload.color
if (payload.longitude !== undefined) marker.longitude = payload.longitude
if (payload.latitude !== undefined) marker.latitude = payload.latitude
if (payload.notes !== undefined) marker.notes = payload.notes
if (payload.marker_type !== undefined) marker.marker_type = payload.marker_type
await marker.save()
return marker
}

View File

@ -5,10 +5,11 @@ import { RagService } from '#services/rag_service'
import Service from '#models/service'
import KVStore from '#models/kv_store'
import { modelNameSchema } from '#validators/download'
import { chatSchema, getAvailableModelsSchema } from '#validators/ollama'
import { chatSchema, getAvailableModelsSchema, unloadChatModelsSchema } from '#validators/ollama'
import { assertNotCloudMetadataUrl } from '#validators/common'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { DEFAULT_QUERY_REWRITE_MODEL, RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import logger from '@adonisjs/core/services/logger'
type Message = { role: 'system' | 'user' | 'assistant'; content: string }
@ -33,6 +34,19 @@ export default class OllamaController {
})
}
/**
* Send Ollama `keep_alive: 0` hints to every currently-loaded chat model
* except the embedding model and (optionally) a target model to preserve.
* Used by the chat UI to enforce the "one chat model at a time" invariant
* on model-switch, session-switch, and page-load. Best-effort: a failure
* here should not block the calling flow.
*/
async unloadChatModels({ request, response }: HttpContext) {
const { targetModel } = await request.validateUsing(unloadChatModelsSchema)
const unloaded = await this.ollamaService.unloadAllChatModelsExcept(targetModel ?? null)
return response.status(200).json({ unloaded })
}
async chat({ request, response }: HttpContext) {
const reqData = await request.validateUsing(chatSchema)
@ -59,7 +73,7 @@ export default class OllamaController {
// Query rewriting for better RAG retrieval with manageable context
// Will return user's latest message if no rewriting is needed
const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages)
const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages, reqData.model)
logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`)
if (rewrittenQuery) {
@ -92,8 +106,17 @@ export default class OllamaController {
`[RAG] Injecting ${trimmedDocs.length}/${relevantDocs.length} results (model: ${reqData.model}, maxResults: ${maxResults}, maxTokens: ${maxTokens || 'unlimited'})`
)
// Label each context block with its source title when available (a neutral,
// honest provenance signal) but never the raw relevance score — nomic cosine
// scores for genuinely relevant passages sit ~0.4-0.6, and surfacing e.g.
// "42%" primes the model to distrust correct context. Scores stay in the logs
// above for debugging.
const contextText = trimmedDocs
.map((doc, idx) => `[Context ${idx + 1}] (Relevance: ${(doc.score * 100).toFixed(1)}%)\n${doc.text}`)
.map((doc, idx) => {
const title = doc.metadata?.full_title || doc.metadata?.article_title
const label = title ? `[Context ${idx + 1}${title}]` : `[Context ${idx + 1}]`
return `${label}\n${doc.text}`
})
.join('\n\n')
const systemMessage = {
@ -157,7 +180,7 @@ export default class OllamaController {
await this.chatService.addMessage(sessionId, 'assistant', fullContent)
const messageCount = await this.chatService.getMessageCount(sessionId)
if (messageCount <= 2 && userContent) {
this.chatService.generateTitle(sessionId, userContent, fullContent).catch((err) => {
this.chatService.generateTitle(sessionId, userContent, fullContent, reqData.model).catch((err) => {
logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)
})
}
@ -172,7 +195,7 @@ export default class OllamaController {
await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
const messageCount = await this.chatService.getMessageCount(sessionId)
if (messageCount <= 2 && userContent) {
this.chatService.generateTitle(sessionId, userContent, result.message.content).catch((err) => {
this.chatService.generateTitle(sessionId, userContent, result.message.content, reqData.model).catch((err) => {
logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)
})
}
@ -212,20 +235,29 @@ export default class OllamaController {
return response.status(404).send({ success: false, message: 'Ollama service record not found.' })
}
// Clear path: null or empty URL removes remote config and marks service as not installed
// Clear path: null or empty URL removes remote config. If a local nomad_ollama container
// still exists (user had previously installed AI Assistant locally), restart it and keep
// the service marked installed. Otherwise fall back to uninstalled.
if (!remoteUrl || remoteUrl.trim() === '') {
await KVStore.clearValue('ai.remoteOllamaUrl')
ollamaService.installed = false
const hasLocalContainer = await this._startLocalOllamaContainerIfExists()
ollamaService.installed = hasLocalContainer
ollamaService.installation_status = 'idle'
await ollamaService.save()
return { success: true, message: 'Remote Ollama configuration cleared.' }
return {
success: true,
message: hasLocalContainer
? 'Remote Ollama cleared. Local Ollama container restored.'
: 'Remote Ollama configuration cleared.',
}
}
// Validate URL format
if (!remoteUrl.startsWith('http')) {
try {
assertNotCloudMetadataUrl(remoteUrl)
} catch (err) {
return response.status(400).send({
success: false,
message: 'Invalid URL. Must start with http:// or https://',
message: err instanceof Error ? err.message : 'Invalid URL.',
})
}
@ -253,6 +285,10 @@ export default class OllamaController {
ollamaService.installation_status = 'idle'
await ollamaService.save()
// Stop the local nomad_ollama container (if running) so it doesn't compete with the
// remote host for GPU / port 11434. Preserves the container and its models volume.
await this._stopLocalOllamaContainer()
// Install Qdrant if not already installed (fire-and-forget)
const qdrantService = await Service.query().where('service_name', SERVICE_NAMES.QDRANT).first()
if (qdrantService && !qdrantService.installed) {
@ -270,6 +306,50 @@ export default class OllamaController {
return { success: true, message: 'Remote Ollama configured.' }
}
private async _stopLocalOllamaContainer(): Promise<void> {
try {
const containers = await this.dockerService.docker.listContainers({ all: true })
const ollamaContainer = containers.find((c) =>
c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)
)
if (!ollamaContainer || ollamaContainer.State !== 'running') {
return
}
await this.dockerService.docker.getContainer(ollamaContainer.Id).stop()
this.dockerService.invalidateServicesStatusCache()
logger.info('[OllamaController] Stopped local nomad_ollama (remote Ollama configured)')
} catch (error: any) {
logger.error(
{ err: error },
'[OllamaController] Failed to stop local nomad_ollama; remote Ollama is still active'
)
}
}
private async _startLocalOllamaContainerIfExists(): Promise<boolean> {
try {
const containers = await this.dockerService.docker.listContainers({ all: true })
const ollamaContainer = containers.find((c) =>
c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)
)
if (!ollamaContainer) {
return false
}
if (ollamaContainer.State !== 'running') {
await this.dockerService.docker.getContainer(ollamaContainer.Id).start()
this.dockerService.invalidateServicesStatusCache()
logger.info('[OllamaController] Started local nomad_ollama (remote Ollama cleared)')
}
return true
} catch (error: any) {
logger.error(
{ err: error },
'[OllamaController] Failed to start local nomad_ollama on remote clear'
)
return false
}
}
async deleteModel({ request }: HttpContext) {
const reqData = await request.validateUsing(modelNameSchema)
await this.ollamaService.deleteModel(reqData.model)
@ -312,17 +392,31 @@ export default class OllamaController {
}
private async rewriteQueryWithContext(
messages: Message[]
messages: Message[],
model: string
): Promise<string | null> {
const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')
try {
// Skip the entire RAG pipeline if there are no documents to search
const hasDocuments = await this.ragService.hasDocuments()
if (!hasDocuments) {
return null
}
// Get recent conversation history (last 6 messages for 3 turns)
const recentMessages = messages.slice(-6)
// Skip rewriting for short conversations. Rewriting adds latency with
// little RAG benefit until there is enough context to matter.
// Skip rewriting on the very first turn — with only one user message
// there is no prior context to fold in, so the rewrite would just echo
// the message back at the cost of an extra LLM round-trip. From the
// first follow-up onward we need the rewrite so the RAG query carries
// entities and topics from earlier turns ("the bars" → "Hershey's bars
// chocolate poisoning dog"); without it, embeddings match nothing and
// the assistant loses the thread.
const userMessages = recentMessages.filter(msg => msg.role === 'user')
if (userMessages.length <= 2) {
return userMessages[userMessages.length - 1]?.content || null
if (userMessages.length < 2) {
return lastUserMessage?.content || null
}
const conversationContext = recentMessages
@ -336,17 +430,8 @@ export default class OllamaController {
})
.join('\n')
const installedModels = await this.ollamaService.getModels(true)
const rewriteModelAvailable = installedModels?.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)
if (!rewriteModelAvailable) {
logger.warn(`[RAG] Query rewrite model "${DEFAULT_QUERY_REWRITE_MODEL}" not available. Skipping query rewriting.`)
const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')
return lastUserMessage?.content || null
}
// FUTURE ENHANCEMENT: allow the user to specify which model to use for rewriting
const response = await this.ollamaService.chat({
model: DEFAULT_QUERY_REWRITE_MODEL,
model,
messages: [
{
role: 'system',
@ -367,7 +452,6 @@ export default class OllamaController {
`[RAG] Query rewriting failed: ${error instanceof Error ? error.message : error}`
)
// Fallback to last user message if rewriting fails
const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')
return lastUserMessage?.content || null
}
}

View File

@ -1,11 +1,14 @@
import { RagService } from '#services/rag_service'
import { EmbedFileJob } from '#jobs/embed_file_job'
import KbRatioRegistry from '#models/kb_ratio_registry'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import app from '@adonisjs/core/services/app'
import { randomBytes } from 'node:crypto'
import { sanitizeFilename } from '../utils/fs.js'
import { deleteFileSchema, getJobStatusSchema } from '#validators/rag'
import { basename } from 'node:path'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag'
import logger from '@adonisjs/core/services/logger'
@inject()
export default class RagController {
@ -65,6 +68,11 @@ export default class RagController {
return response.status(200).json({ files })
}
public async getFileWarnings({ response }: HttpContext) {
const result = await this.ragService.computeFileWarnings()
return response.status(200).json(result)
}
public async deleteFile({ request, response }: HttpContext) {
const { source } = await request.validateUsing(deleteFileSchema)
const result = await this.ragService.deleteFileBySource(source)
@ -74,6 +82,21 @@ export default class RagController {
return response.status(200).json({ message: result.message })
}
public async embedFile({ request, response }: HttpContext) {
const { source, force } = await request.validateUsing(embedFileSchema)
const result = await this.ragService.embedSingleFile(source, force ?? false)
if (!result.success) {
const status = {
not_found: 404,
inflight: 409,
delete_failed: 500,
dispatch_failed: 500,
}[result.code]
return response.status(status).json({ error: result.message, code: result.code })
}
return response.status(202).json({ message: result.message })
}
public async getFailedJobs({ response }: HttpContext) {
const jobs = await EmbedFileJob.listFailedJobs()
return response.status(200).json(jobs)
@ -87,12 +110,83 @@ export default class RagController {
})
}
public async cancelAllJobs({ response }: HttpContext) {
const result = await EmbedFileJob.cancelAllJobs()
return response.status(200).json({
message: `Cancelled ${result.cancelled} job${result.cancelled !== 1 ? 's' : ''}${result.filesDeleted > 0 ? `, deleted ${result.filesDeleted} file${result.filesDeleted !== 1 ? 's' : ''}` : ''}.`,
...result,
})
}
public async policyPromptState({ response }: HttpContext) {
const result = await this.ragService.getPolicyPromptState()
return response.status(200).json(result)
}
public async scanAndSync({ response }: HttpContext) {
try {
const syncResult = await this.ragService.scanAndSyncStorage()
return response.status(200).json(syncResult)
} catch (error) {
return response.status(500).json({ error: 'Error scanning and syncing storage', details: error.message })
logger.error({ err: error }, '[RagController] Error scanning and syncing storage')
return response.status(500).json({ error: 'Error scanning and syncing storage' })
}
}
public async reembedAll({ response }: HttpContext) {
try {
const result = await this.ragService.reembedAll()
return response.status(200).json(result)
} catch (error) {
logger.error({ err: error }, '[RagController] Error during re-embed all')
return response.status(500).json({ error: 'Error during re-embed all' })
}
}
public async resetAndRebuild({ response }: HttpContext) {
try {
const result = await this.ragService.resetAndRebuild()
return response.status(200).json(result)
} catch (error) {
logger.error({ err: error }, '[RagController] Error during reset and rebuild')
return response.status(500).json({ error: 'Error during reset and rebuild' })
}
}
public async health({ response }: HttpContext) {
const result = await this.ragService.checkQdrantHealth()
return response.status(200).json(result)
}
public async estimateBatch({ request, response }: HttpContext) {
const { files } = await request.validateUsing(estimateBatchSchema)
// The registry matches on basename prefixes; if a caller passes a full path
// (e.g. /app/storage/zim/wikipedia_en_simple_…), strip directories first so
// patterns like `wikipedia_en_simple_` still match.
const normalized = files.map((f) => ({
filename: basename(f.filename),
sizeBytes: f.sizeBytes,
}))
const result = await KbRatioRegistry.estimateBatch(normalized)
return response.status(200).json(result)
}
public async getFileContent({ request, response }: HttpContext) {
const { source } = await request.validateUsing(fileSourceSchema)
const result = await this.ragService.readFileContent(source)
if (!result) {
return response.status(404).json({ error: 'File not found or not viewable' })
}
return response.status(200).json(result)
}
public async downloadFile({ request, response }: HttpContext) {
const { source } = await request.validateUsing(fileSourceSchema)
const filePath = await this.ragService.resolveDownloadPath(source)
if (!filePath) {
return response.status(404).json({ error: 'File not found' })
}
const fileName = filePath.split(/[/\\]/).at(-1) ?? 'download'
return response.attachment(filePath, fileName)
}
}

View File

@ -3,9 +3,10 @@ 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'
import env from '#start/env'
@inject()
export default class SettingsController {
@ -110,6 +111,19 @@ export default class SettingsController {
})
}
async advanced({ inertia }: HttpContext) {
// When the env var is set it always takes precedence over the stored value,
// so surface that to the UI to disable the field and explain the override.
const envOverride = Boolean(env.get('INTERNET_STATUS_TEST_URL')?.trim())
const internetStatusTestUrl = await KVStore.getValue('system.internetStatusTestUrl')
return inertia.render('settings/advanced', {
advanced: {
internetStatusTestUrl: internetStatusTestUrl ?? '',
internetStatusTestUrlEnvOverride: envOverride,
},
})
}
async getSetting({ request, response }: HttpContext) {
const { key } = await getSettingSchema.validate({ key: request.qs().key });
const value = await KVStore.getValue(key);
@ -118,6 +132,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

@ -0,0 +1,13 @@
import { SystemService } from '#services/system_service'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@inject()
export default class SupplyDepotController {
constructor(private systemService: SystemService) {}
async index({ inertia }: HttpContext) {
const services = await this.systemService.getServices({ installedOnly: false })
return inertia.render('supply-depot', { system: { services } })
}
}

View File

@ -2,10 +2,38 @@ 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 { 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'
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
import {
affectServiceValidator,
checkLatestVersionValidator,
customAppValidator,
deleteCustomAppValidator,
installServiceValidator,
preflightCustomValidator,
preflightValidator,
serviceLogsValidator,
subscribeToReleaseNotesValidator,
uninstallServiceValidator,
updateCustomAppValidator,
updateServiceValidator,
setServiceAutoUpdateValidator,
setServiceCustomUrlValidator,
normalizeCustomUrl,
} from '#validators/system'
import {
DEFAULT_CPUS,
DEFAULT_MEMORY_MB,
evaluateCustomApp,
} from '#services/custom_app_guard'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import Service from '#models/service'
@inject()
export default class SystemController {
@ -107,6 +135,82 @@ 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 getAppAutoUpdateStatus({ response }: HttpContext) {
// Constructed inline reusing already-injected singletons + the QueueService
// singleton (its constructor is private to prevent Redis connection leaks),
// mirroring getAutoUpdateStatus. Apps need no SystemUpdateService (no sidecar).
const appAutoUpdateService = new AppAutoUpdateService(
this.dockerService,
new DownloadService(QueueService.getInstance()),
this.systemService,
this.containerRegistryService
)
try {
const status = await appAutoUpdateService.getStatus()
response.send(status)
} catch (error) {
logger.error({ err: error }, '[SystemController] Failed to get app auto-update status')
response.status(500).send({ error: 'Failed to retrieve app auto-update status' })
}
}
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()
if (!service) {
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
}
service.auto_update_enabled = payload.enabled
// Re-enabling clears any prior self-disable so the app gets a fresh start.
if (payload.enabled) {
service.auto_update_consecutive_failures = 0
service.auto_update_disabled_reason = null
}
await service.save()
return response.send({ success: true, message: 'App auto-update preference updated' })
}
async subscribeToReleaseNotes({ request }: HttpContext) {
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
@ -144,7 +248,8 @@ export default class SystemController {
)
response.send({ versions: updates })
} catch (error) {
response.status(500).send({ error: `Failed to fetch versions: ${error.message}` })
logger.error({ err: error }, `[SystemController] Failed to fetch versions for ${serviceName}`)
response.status(500).send({ error: 'Failed to fetch available versions for this service.' })
}
}
@ -178,4 +283,525 @@ export default class SystemController {
return 'amd64'
}
}
/**
* Pre-install preflight check: reports port conflicts and resource warnings for a service.
* Results are advisory the UI shows warnings but allows the user to force-proceed.
*/
async preflightCheck({ request, response }: HttpContext) {
const payload = await request.validateUsing(preflightValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
}
// Extract host ports from container_config — the MySQL driver may return JSON columns
// as an already-parsed object rather than a string, so guard before calling JSON.parse.
const rawConfig = service.container_config
const config = rawConfig
? typeof rawConfig === 'object'
? rawConfig
: JSON.parse(rawConfig as string)
: null
const portBindings: Record<string, [{ HostPort: string }]> =
config?.HostConfig?.PortBindings ?? {}
const hostPorts = Object.values(portBindings)
.flat()
.map((b) => parseInt(b.HostPort, 10))
.filter((p) => !isNaN(p))
// Parse resource requirements from metadata (same object-guard as container_config)
let minMemoryMB = 256
let minDiskMB = 512
try {
const rawMeta = service.metadata
const meta = rawMeta
? typeof rawMeta === 'object'
? rawMeta
: JSON.parse(rawMeta as string)
: null
if (meta?.minMemoryMB) minMemoryMB = meta.minMemoryMB
if (meta?.minDiskMB) minDiskMB = meta.minDiskMB
} catch {}
const [{ conflicts: portConflicts }, resourceWarnings] = await Promise.all([
this.dockerService.checkPortConflicts(hostPorts),
this.systemService.checkResourceWarnings(minMemoryMB, minDiskMB),
])
return response.send({ portConflicts, resourceWarnings })
}
/** Return the next suggested host port for a custom app (8600+ range). */
async suggestCustomPort({ response }: HttpContext) {
const port = await this.systemService.getNextSuggestedCustomPort()
return response.send({ port })
}
/**
* Service-less preflight for the custom-app form: given host ports, volumes and an image,
* report port conflicts, host resource warnings, overridable guard warnings (risky bind
* mounts / untrusted or moving-tag images), and hard blocks (docker socket, system dirs,
* malformed image). Lets the form give live feedback before a Service record exists.
*/
async preflightCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(preflightCustomValidator)
const [{ conflicts }, resourceWarnings] = await Promise.all([
this.dockerService.checkPortConflicts(payload.ports ?? []),
this.systemService.checkResourceWarnings(256, 512),
])
// When editing, the app's own container legitimately holds its ports — don't flag those.
const portConflicts = payload.exclude_service
? conflicts.filter((c) => c.usedBy !== payload.exclude_service)
: conflicts
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
return response.send({
portConflicts,
resourceWarnings: [...resourceWarnings, ...guard.warnings],
blocked: guard.blocked,
})
}
/** Create and immediately begin installing a custom app container. */
async createCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(customAppValidator)
// Derive a stable service_name from the friendly name
const slug = payload.friendly_name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
const serviceName = `nomad_custom_${slug}`
const existing = await Service.query().where('service_name', serviceName).first()
if (existing) {
return response.status(409).send({
success: false,
message: `A custom app named "${payload.friendly_name}" already exists. Choose a different name.`,
})
}
// Reject duplicate host ports within the request — Docker would otherwise fail at
// start time with an opaque "port is already allocated" error.
const hostPorts = (payload.ports ?? []).map((p) => p.host)
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
if (duplicateHostPorts.length) {
return response.status(422).send({
success: false,
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
})
}
// Security guardrails: hard-block dangerous bind mounts / malformed images regardless of
// force; surface overridable warnings (risky paths, untrusted/moving-tag images) unless forced.
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
if (guard.blocked.length) {
return response.status(422).send({
success: false,
message: guard.blocked.join(' '),
blocked: guard.blocked,
})
}
if (!payload.force && guard.warnings.length) {
return response.status(409).send({
success: false,
message: guard.warnings.join(' '),
warnings: guard.warnings,
})
}
// Advisory preflight: surface port conflicts before creating the record so a failed
// install doesn't leave a phantom card. The user can re-submit with force=true to override.
if (!payload.force && hostPorts.length) {
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
if (conflicts.length) {
return response.status(409).send({
success: false,
message: `Port conflict: ${conflicts
.map((c) => `${c.port} (in use by ${c.usedBy})`)
.join(', ')}.`,
portConflicts: conflicts,
})
}
}
const { containerConfig, uiLocation } = this.buildCustomContainerConfig(payload)
await Service.create({
service_name: serviceName,
friendly_name: payload.friendly_name,
container_image: payload.image,
container_config: JSON.stringify(containerConfig),
ui_location: uiLocation,
icon: payload.icon || 'IconBrandDocker',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: true,
category: payload.category ?? 'custom',
depends_on: null,
})
const result = await this.dockerService.createContainerPreflight(serviceName)
if (result.success) {
return response.send({ success: true, message: result.message, service_name: serviceName })
}
return response.status(400).send({ success: false, message: result.message })
}
/** Delete a custom app: stop + remove its container, then delete the DB record. */
async deleteCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(deleteCustomAppValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
}
if (!service.is_custom) {
return response.status(403).send({ error: 'Only custom apps can be deleted.' })
}
await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false)
await service.delete()
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
}
/** Uninstall a curated catalog app: stop + remove its container (optionally its image) and
* return the card to the available catalog. App data under the storage path stays on disk,
* so a later reinstall picks it back up. Custom apps are removed via deleteCustomApp instead,
* which also drops their DB record. */
async uninstallService({ request, response }: HttpContext) {
const payload = await request.validateUsing(uninstallServiceValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
}
if (service.is_custom) {
return response.status(403).send({ error: 'Custom apps are removed via delete.' })
}
if (service.is_dependency_service) {
return response.status(403).send({ error: 'Dependency services cannot be uninstalled directly.' })
}
if (!service.installed) {
return response.status(409).send({ error: `Service ${payload.service_name} is not installed` })
}
const result = await this.dockerService.uninstallService(
payload.service_name,
payload.remove_image ?? false
)
if (!result.success) {
return response.status(500).send({ success: false, message: result.message })
}
return response.send({ success: true, message: result.message })
}
/** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a
* metadata change no container is touched. An empty/invalid value clears the override, after
* which the default host + port link is used again. */
async setServiceCustomUrl({ request, response }: HttpContext) {
const payload = await request.validateUsing(setServiceCustomUrlValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
// Hidden dependency services (e.g. Qdrant) aren't user-launchable, so they have no link to set.
if (service.is_dependency_service) {
return response.status(403).send({ success: false, message: 'This service cannot be configured.' })
}
// Reject a non-empty value that isn't a valid http(s) URL; an empty value clears the override.
const normalized = normalizeCustomUrl(payload.custom_url)
if (payload.custom_url && payload.custom_url.trim() && !normalized) {
return response.status(422).send({
success: false,
message: 'Custom URL must be a valid http(s) address (e.g. https://jellyfin.myhomelab.net).',
})
}
service.custom_url = normalized
await service.save()
return response.send({ success: true, custom_url: service.custom_url })
}
/** Re-pull a custom app's image and recreate its container in place (preserving volumes). */
async updateCustomApp_pullLatest({ request, response }: HttpContext) {
const payload = await request.validateUsing(installServiceValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
if (!service.is_custom) {
return response.status(403).send({ success: false, message: 'Only custom apps can be updated this way.' })
}
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name, {
forcePull: true,
})
if (result.success) {
return response.send({ success: true, message: result.message })
}
return response.status(400).send({ success: false, message: result.message })
}
/** Return the last N lines of a service container's logs. */
async getServiceLogs({ params, request, response }: HttpContext) {
// Scope to managed services only — otherwise any sibling container's logs (admin app,
// database) would be readable by name on this unauthenticated API surface.
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
}
const { tail } = await request.validateUsing(serviceLogsValidator)
const result = await this.dockerService.getContainerLogs(params.name, tail ?? 200)
if (!result.success) {
return response.status(404).send({ success: false, message: result.message })
}
return response.send({ success: true, logs: result.logs })
}
/** Return a one-shot CPU/memory usage snapshot for a running service container. */
async getServiceStats({ params, response }: HttpContext) {
// Scope to managed services only (see getServiceLogs).
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
}
const result = await this.dockerService.getContainerStats(params.name)
if (!result.success) {
return response.status(404).send({ success: false, message: result.message })
}
return response.send({ success: true, running: result.running ?? false, stats: result.stats ?? null })
}
/** Return an app's current configuration in the editable form-shape. */
async getCustomApp({ params, response }: HttpContext) {
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ error: `Service ${params.name} not found` })
}
// Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not.
if (service.is_dependency_service) {
return response.status(403).send({ error: 'This service cannot be edited.' })
}
return response.send({ success: true, app: this.parseCustomContainerConfig(service) })
}
/** Reconfigure an app: validate + guard, persist the new config, then recreate the container.
* Works for both custom apps and curated (pre-configured) apps. Editing a curated app marks it
* user-modified so the seeder stops overwriting the user's changes. */
async updateCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(updateCustomAppValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
// Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not.
if (service.is_dependency_service) {
return response.status(403).send({ success: false, message: 'This service cannot be edited.' })
}
// Reject duplicate host ports within the request.
const hostPorts = (payload.ports ?? []).map((p) => p.host)
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
if (duplicateHostPorts.length) {
return response.status(422).send({
success: false,
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
})
}
// Security guardrails (same posture as create).
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
if (guard.blocked.length) {
return response.status(422).send({ success: false, message: guard.blocked.join(' '), blocked: guard.blocked })
}
if (!payload.force && guard.warnings.length) {
return response.status(409).send({ success: false, message: guard.warnings.join(' '), warnings: guard.warnings })
}
// Port conflicts — but ignore ports already held by this app's own container.
if (!payload.force && hostPorts.length) {
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
const external = conflicts.filter((c) => c.usedBy !== payload.service_name)
if (external.length) {
return response.status(409).send({
success: false,
message: `Port conflict: ${external
.map((c) => `${c.port} (in use by ${c.usedBy})`)
.join(', ')}.`,
portConflicts: external,
})
}
}
// Merge the form fields into the app's existing config rather than rebuilding from scratch,
// so advanced settings a curated app ships with (GPU device requests, special env, etc.) are
// preserved across an edit.
// Preserve an explicit scheme (e.g. ui_location "https:8480") across an edit — otherwise a
// TLS-serving app's Open link would silently revert to http after any reconfigure.
const prevScheme = (service.ui_location || '').match(/^(https?):\d+$/)?.[1]
const { containerConfig, uiLocation } = this.mergeCustomContainerConfig(
service.container_config,
payload
)
service.friendly_name = payload.friendly_name
service.container_image = payload.image
service.container_config = JSON.stringify(containerConfig)
service.ui_location = prevScheme && uiLocation && /^\d+$/.test(uiLocation)
? `${prevScheme}:${uiLocation}`
: uiLocation
service.category = payload.category ?? service.category ?? 'custom'
if (payload.icon) service.icon = payload.icon
// Flag as user-modified so the seeder stops overwriting this app's config on future runs.
service.is_user_modified = true
await service.save()
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name)
if (result.success) {
return response.send({ success: true, message: result.message, service_name: payload.service_name })
}
return response.status(400).send({ success: false, message: result.message })
}
/**
* Build a Docker container config (HostConfig + ExposedPorts + Env) from custom-app form input,
* applying default resource caps. Shared by create and update so both stay in lockstep.
*/
private buildCustomContainerConfig(payload: {
ports?: { container: number; host: number }[]
volumes?: { host_path: string; container_path: string }[]
env?: string[]
memory_mb?: number
cpus?: number
}): { containerConfig: Record<string, any>; uiLocation: string | null } {
const portBindings: Record<string, [{ HostPort: string }]> = {}
const exposedPorts: Record<string, {}> = {}
for (const { container, host } of payload.ports ?? []) {
portBindings[`${container}/tcp`] = [{ HostPort: String(host) }]
exposedPorts[`${container}/tcp`] = {}
}
const binds = (payload.volumes ?? []).map(
({ host_path, container_path }) => `${host_path}:${container_path}`
)
// Resource caps so a runaway custom container can't starve the host. Memory is bytes;
// NanoCpus is CPUs × 1e9. Defaults are generous and user-overridable.
const memoryBytes = (payload.memory_mb ?? DEFAULT_MEMORY_MB) * 1024 * 1024
const nanoCpus = Math.round((payload.cpus ?? DEFAULT_CPUS) * 1e9)
const containerConfig: Record<string, any> = {
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: portBindings,
Memory: memoryBytes,
NanoCpus: nanoCpus,
...(binds.length ? { Binds: binds } : {}),
},
ExposedPorts: exposedPorts,
...(payload.env?.length ? { Env: payload.env } : {}),
}
const firstHostPort = payload.ports?.[0]?.host
const uiLocation = firstHostPort ? String(firstHostPort) : null
return { containerConfig, uiLocation }
}
/**
* Merge custom-app form input into an app's *existing* container config. Used by the edit path so
* editing a curated app only changes the fields exposed in the form (image/ports/volumes/env and,
* if supplied, resource caps) while preserving everything else it ships with (GPU DeviceRequests,
* User, custom HostConfig keys, etc.). Unlike buildCustomContainerConfig, resource caps are NOT
* defaulted here a curated app intentionally left uncapped stays uncapped unless the user sets one.
*/
private mergeCustomContainerConfig(
existingRaw: string | null,
payload: {
ports?: { container: number; host: number }[]
volumes?: { host_path: string; container_path: string }[]
env?: string[]
memory_mb?: number
cpus?: number
}
): { containerConfig: Record<string, any>; uiLocation: string | null } {
const parsed = existingRaw
? typeof existingRaw === 'object'
? existingRaw
: JSON.parse(existingRaw as string)
: {}
// Deep clone so we never mutate the parsed source.
const containerConfig: Record<string, any> = JSON.parse(JSON.stringify(parsed ?? {}))
containerConfig.HostConfig = containerConfig.HostConfig ?? {}
// Keep a restart policy if the existing config lacked one.
containerConfig.HostConfig.RestartPolicy =
containerConfig.HostConfig.RestartPolicy ?? { Name: 'unless-stopped' }
const portBindings: Record<string, [{ HostPort: string }]> = {}
const exposedPorts: Record<string, {}> = {}
for (const { container, host } of payload.ports ?? []) {
portBindings[`${container}/tcp`] = [{ HostPort: String(host) }]
exposedPorts[`${container}/tcp`] = {}
}
containerConfig.HostConfig.PortBindings = portBindings
containerConfig.ExposedPorts = exposedPorts
const binds = (payload.volumes ?? []).map(
({ host_path, container_path }) => `${host_path}:${container_path}`
)
if (binds.length) containerConfig.HostConfig.Binds = binds
else delete containerConfig.HostConfig.Binds
if (payload.env?.length) containerConfig.Env = payload.env
else delete containerConfig.Env
// Only touch resource caps when the user explicitly set them — preserve existing/uncapped otherwise.
if (payload.memory_mb != null) {
containerConfig.HostConfig.Memory = payload.memory_mb * 1024 * 1024
}
if (payload.cpus != null) {
containerConfig.HostConfig.NanoCpus = Math.round(payload.cpus * 1e9)
}
const firstHostPort = payload.ports?.[0]?.host
const uiLocation = firstHostPort ? String(firstHostPort) : null
return { containerConfig, uiLocation }
}
/** Inverse of buildCustomContainerConfig: turn a stored Service into the editable form-shape. */
private parseCustomContainerConfig(service: Service) {
const raw = service.container_config
const config = raw ? (typeof raw === 'object' ? raw : JSON.parse(raw as string)) : {}
const hostConfig = config?.HostConfig ?? {}
const ports = Object.entries(hostConfig.PortBindings ?? {}).map(([key, val]: [string, any]) => ({
container: Number.parseInt(key, 10),
host: Number.parseInt(val?.[0]?.HostPort, 10),
}))
const volumes = (hostConfig.Binds ?? []).map((bind: string) => {
const idx = bind.indexOf(':')
return { host_path: bind.slice(0, idx), container_path: bind.slice(idx + 1) }
})
return {
service_name: service.service_name,
friendly_name: service.friendly_name,
image: service.container_image,
category: service.category ?? 'custom',
icon: service.icon ?? 'IconBrandDocker',
ports,
volumes,
env: (config?.Env ?? []) as string[],
memory_mb: hostConfig.Memory ? Math.round(hostConfig.Memory / (1024 * 1024)) : undefined,
cpus: hostConfig.NanoCpus ? hostConfig.NanoCpus / 1e9 : undefined,
}
}
}

View File

@ -6,9 +6,14 @@ import {
remoteDownloadWithMetadataValidator,
selectWikipediaValidator,
} from '#validators/common'
import { listRemoteZimValidator } from '#validators/zim'
import { addCustomLibraryValidator, browseLibraryValidator, idParamValidator, listRemoteZimValidator } from '#validators/zim'
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import type { HttpContext } from '@adonisjs/core/http'
import { createWriteStream } from 'fs'
import { rename } from 'fs/promises'
import { join, resolve, sep } from 'path'
import { ZIM_STORAGE_PATH, ensureDirectoryExists, sanitizeFilename } from '../utils/fs.js'
@inject()
export default class ZimController {
@ -56,6 +61,14 @@ export default class ZimController {
}
}
async rescanLibrary({}: HttpContext) {
const result = await this.zimService.rescanLibrary()
return {
message: 'Kiwix library rescanned',
...result,
}
}
async delete({ request, response }: HttpContext) {
const payload = await request.validateUsing(filenameParamValidator)
@ -75,6 +88,87 @@ export default class ZimController {
}
}
async upload({ request, response }: HttpContext) {
let filename: string | null = null
let tmpPath: string | null = null
let uploadError: string | null = null
try {
const basePath = resolve(join(process.cwd(), ZIM_STORAGE_PATH))
await ensureDirectoryExists(basePath)
request.multipart.onFile('*', {}, async (part) => {
const clientName = part.filename || ''
if (!clientName.toLowerCase().endsWith('.zim')) {
part.resume()
uploadError = 'INVALID_TYPE'
return
}
const sanitized = sanitizeFilename(clientName)
const finalPath = resolve(join(basePath, sanitized))
if (!finalPath.startsWith(basePath + sep)) {
part.resume()
uploadError = 'INVALID_FILENAME'
return
}
const { access } = await import('fs/promises')
const exists = await access(finalPath).then(() => true).catch(() => false)
if (exists) {
part.resume()
uploadError = 'DUPLICATE_FILENAME'
return
}
filename = sanitized
tmpPath = finalPath + '.tmp'
const ws = createWriteStream(tmpPath)
await new Promise<void>((res, rej) => {
ws.on('error', rej)
ws.on('finish', res)
part.on('error', rej)
part.pipe(ws)
})
await rename(tmpPath, finalPath)
tmpPath = null
})
await request.multipart.process()
if (uploadError === 'INVALID_TYPE') {
return response.status(422).send({ message: 'Only .zim files are accepted' })
}
if (uploadError === 'INVALID_FILENAME') {
return response.status(422).send({ message: 'Invalid filename' })
}
if (uploadError === 'DUPLICATE_FILENAME') {
return response.status(409).send({ message: 'A ZIM file with that name already exists' })
}
if (!filename) {
return response.status(400).send({ message: 'No file received' })
}
const { added } = await this.zimService.registerLocalUpload(filename)
return response.status(201).send({
message: 'ZIM file uploaded and registered successfully',
filename,
added,
})
} catch (error) {
logger.error('[ZimController] Upload failed:', error)
if (tmpPath) {
const { unlink } = await import('fs/promises')
await unlink(tmpPath).catch(() => {})
}
return response.status(500).send({ message: 'Upload failed' })
}
}
// Wikipedia selector endpoints
async getWikipediaState({}: HttpContext) {
@ -85,4 +179,51 @@ export default class ZimController {
const payload = await request.validateUsing(selectWikipediaValidator)
return this.zimService.selectWikipedia(payload.optionId)
}
// Custom library endpoints
async listCustomLibraries({}: HttpContext) {
return this.zimService.listCustomLibraries()
}
async addCustomLibrary({ request, response }: HttpContext) {
const payload = await request.validateUsing(addCustomLibraryValidator)
assertNotPrivateUrl(payload.base_url)
try {
const source = await this.zimService.addCustomLibrary(payload.name, payload.base_url)
return { message: 'Custom library added', library: source }
} catch (error) {
if (error.message === 'Maximum of 10 custom libraries allowed') {
return response.status(400).send({ message: error.message })
}
throw error
}
}
async removeCustomLibrary({ request, response }: HttpContext) {
const payload = await request.validateUsing(idParamValidator)
try {
await this.zimService.removeCustomLibrary(payload.params.id)
return { message: 'Custom library removed' }
} catch (error) {
if (error.message === 'Custom library not found') {
return response.status(404).send({ message: error.message })
}
throw error
}
}
async browseLibrary({ request, response }: HttpContext) {
const payload = await request.validateUsing(browseLibraryValidator)
try {
return await this.zimService.browseLibraryUrl(payload.url)
} catch (error) {
if (error.message?.includes('loopback or link-local')) {
return response.status(400).send({ message: error.message })
}
return response.status(502).send({
message: 'Could not fetch directory listing from the provided URL',
})
}
}
}

View File

@ -0,0 +1,78 @@
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 { ContainerRegistryService } from '#services/container_registry_service'
import { AppAutoUpdateService } from '#services/app_auto_update_service'
import logger from '@adonisjs/core/services/logger'
/**
* Hourly job that evaluates whether any opted-in installed apps should auto-update
* right now and, if so, updates them. All gating (master switch, per-app opt-in,
* window, cool-off, pre-flight, per-app backoff) lives in {@link AppAutoUpdateService};
* 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 AutoUpdateJob}.
*/
export class AppAutoUpdateJob {
static get queue() {
return 'system'
}
static get key() {
return 'app-auto-update'
}
async handle(_job: Job) {
logger.info('[AppAutoUpdateJob] Evaluating app auto-updates...')
const dockerService = new DockerService()
const appAutoUpdateService = new AppAutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new ContainerRegistryService()
)
const result = await appAutoUpdateService.attempt()
logger.info(`[AppAutoUpdateJob] ${result.updated} updated: ${result.reason}`)
return result
}
static async schedule() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'hourly-app-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('[AppAutoUpdateJob] App 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(`[AppAutoUpdateJob] Dispatched ad-hoc app auto-update evaluation job ${job.id}`)
return job
}
}

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

@ -39,6 +39,14 @@ export class CheckServiceUpdatesJob {
const latestUpdate = updates.length > 0 ? updates[0].tag : null
// Stamp/clear the cool-off anchor only when the available version *changes*.
// Registry tags carry no publish date, so the auto-update cool-off is measured
// from when a version was first detected; leaving the timestamp untouched while
// the same version persists keeps the cool-off clock running.
if (latestUpdate !== service.available_update_version) {
service.available_update_first_seen_at = latestUpdate ? DateTime.now() : null
}
service.available_update_version = latestUpdate
service.update_checked_at = DateTime.now()
await service.save()
@ -95,7 +103,7 @@ export class CheckServiceUpdatesJob {
}
static async scheduleNightly() {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
@ -114,7 +122,7 @@ export class CheckServiceUpdatesJob {
}
static async dispatch() {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const job = await queue.add(

View File

@ -42,7 +42,7 @@ export class CheckUpdateJob {
}
static async scheduleNightly() {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
@ -61,7 +61,7 @@ export class CheckUpdateJob {
}
static async dispatch() {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const job = await queue.add(this.key, {}, {

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

@ -21,6 +21,25 @@ export class DownloadModelJob {
return createHash('sha256').update(modelName).digest('hex').slice(0, 16)
}
/** In-memory registry of abort controllers for active model download jobs */
static abortControllers: Map<string, AbortController> = new Map()
/**
* Redis key used to signal cancellation across processes. Uses a `model-cancel` prefix
* so it cannot collide with content download cancel signals (`nomad:download:cancel:*`).
*/
static cancelKey(jobId: string): string {
return `nomad:download:model-cancel:${jobId}`
}
/** Signal cancellation via Redis so the worker process can pick it up on its next poll tick */
static async signalCancel(jobId: string): Promise<void> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', { EX: 300 }) // 5 min TTL
}
async handle(job: Job) {
const { modelName } = job.data as DownloadModelJobParams
@ -41,55 +60,108 @@ export class DownloadModelJob {
`[DownloadModelJob] Ollama service is ready. Initiating download for ${modelName}`
)
// Services are ready, initiate the download with progress tracking
const result = await ollamaService.downloadModel(modelName, (progressPercent) => {
if (progressPercent) {
job.updateProgress(Math.floor(progressPercent)).catch((err) => {
if (err?.code !== -1) throw err
})
logger.info(
`[DownloadModelJob] Model ${modelName}: ${progressPercent}%`
)
// Register abort controller for this job — used both by in-process cancels (same process
// as the API server) and as the target of the Redis poll loop below.
const abortController = new AbortController()
DownloadModelJob.abortControllers.set(job.id!, abortController)
// Get Redis client for checking cancel signals from the API process
const queueService = QueueService.getInstance()
const cancelRedis = await queueService.getQueue(DownloadModelJob.queue).client
// Track whether cancellation was explicitly requested by the user. Only user-initiated
// cancels become UnrecoverableError — other failures (e.g., transient network errors)
// should still benefit from BullMQ's retry logic.
let userCancelled = false
// Poll Redis for cancel signal every 2s — independent of progress events so cancellation
// works even when the pull is mid-blob and not emitting progress updates.
let cancelPollInterval: ReturnType<typeof setInterval> | null = setInterval(async () => {
try {
const val = await cancelRedis.get(DownloadModelJob.cancelKey(job.id!))
if (val) {
await cancelRedis.del(DownloadModelJob.cancelKey(job.id!))
userCancelled = true
abortController.abort('user-cancel')
}
} catch {
// Redis errors are non-fatal; in-process AbortController covers same-process cancels
}
}, 2000)
// Store detailed progress in job data for clients to query
job.updateData({
...job.data,
status: 'downloading',
progress: progressPercent,
progress_timestamp: new Date().toISOString(),
}).catch((err) => {
if (err?.code !== -1) throw err
})
})
try {
// Services are ready, initiate the download with progress tracking
const result = await ollamaService.downloadModel(
modelName,
(progressPercent, bytes) => {
if (progressPercent) {
job.updateProgress(Math.floor(progressPercent)).catch((err) => {
if (err?.code !== -1) throw err
})
}
if (!result.success) {
logger.error(
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
// Store detailed progress in job data for clients to query
job.updateData({
...job.data,
status: 'downloading',
progress: progressPercent,
downloadedBytes: bytes?.downloadedBytes,
totalBytes: bytes?.totalBytes,
progress_timestamp: new Date().toISOString(),
}).catch((err) => {
if (err?.code !== -1) throw err
})
},
abortController.signal,
job.id!
)
// Don't retry errors that will never succeed (e.g., Ollama version too old)
if (result.retryable === false) {
throw new UnrecoverableError(result.message)
}
throw new Error(`Failed to initiate download for model: ${result.message}`)
}
logger.info(`[DownloadModelJob] Successfully completed download for model ${modelName}`)
return {
modelName,
message: result.message,
if (!result.success) {
logger.error(
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
)
// User-initiated cancel — must be unrecoverable to avoid the 40-attempt retry storm.
// The downloadModel() catch block returns retryable: false for cancels, so this branch
// catches both Ollama version mismatches (existing) AND user cancels (new).
if (result.retryable === false) {
throw new UnrecoverableError(result.message)
}
throw new Error(`Failed to initiate download for model: ${result.message}`)
}
logger.info(`[DownloadModelJob] Successfully completed download for model ${modelName}`)
return {
modelName,
message: result.message,
}
} catch (error: any) {
// Belt-and-suspenders: if downloadModel didn't recognize the cancel (e.g., the abort
// fired after the response stream completed but before our code returned), the cancel
// flag tells us this was a user action and should be unrecoverable.
if (userCancelled || abortController.signal.reason === 'user-cancel') {
if (!(error instanceof UnrecoverableError)) {
throw new UnrecoverableError(`Model download cancelled: ${error.message ?? error}`)
}
}
throw error
} finally {
if (cancelPollInterval !== null) {
clearInterval(cancelPollInterval)
cancelPollInterval = null
}
DownloadModelJob.abortControllers.delete(job.id!)
}
}
static async getByModelName(modelName: string): Promise<Job | undefined> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(modelName)
return await queue.getJob(jobId)
}
static async dispatch(params: DownloadModelJobParams) {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(params.modelName)

View File

@ -4,9 +4,11 @@ import { EmbedJobWithProgress } from '../../types/rag.js'
import { RagService } from '#services/rag_service'
import { DockerService } from '#services/docker_service'
import { OllamaService } from '#services/ollama_service'
import KbIngestState from '#models/kb_ingest_state'
import { createHash } from 'crypto'
import logger from '@adonisjs/core/services/logger'
import fs from 'node:fs/promises'
import { ZIM_BATCH_SIZE } from '../../constants/zim_extraction.js'
export interface EmbedFileJobParams {
filePath: string
@ -16,6 +18,11 @@ export interface EmbedFileJobParams {
batchOffset?: number // Current batch offset (for ZIM files)
totalArticles?: number // Total articles in ZIM (for progress tracking)
isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion)
// Running total of chunks embedded across prior batches in this dispatch chain.
// Carried forward so the final batch can persist an accurate `chunks_embedded`
// count via KbIngestState.markIndexed (see #933 — without this, only the last
// batch's chunk count was stored while Qdrant held the full set).
chunksSoFar?: number
}
export class EmbedFileJob {
@ -27,6 +34,12 @@ export class EmbedFileJob {
return 'embed-file'
}
// Delay between continuation batches when embedding runs CPU-only. Gives the OS
// scheduler a brief idle window so sshd / disk-collector / other services don't
// starve during long multi-batch ZIM ingestions. Skipped entirely when the
// embedding model is GPU-offloaded — see OllamaService.isEmbeddingGpuAccelerated().
static readonly CPU_BATCH_DELAY_MS = 1000
static getJobId(filePath: string): string {
return createHash('sha256').update(filePath).digest('hex').slice(0, 16)
}
@ -77,8 +90,16 @@ export class EmbedFileJob {
logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)
// Update progress starting
await this.safeUpdateProgress(job, 5)
// Anchor initial progress to where we are in the overall file. For a
// continuation batch midway through a multi-batch ZIM (e.g. offset 100k of
// 600k), the hardcoded 5 used to make the gauge briefly flash 0→5→real,
// which read as a backward jump. Fall back to 5 for single-batch files
// where totalArticles isn't set.
const initialPercent =
totalArticles && totalArticles > 0
? Math.min(99, Math.round(((batchOffset || 0) / totalArticles) * 100))
: 5
await this.safeUpdateProgress(job, initialPercent)
await job.updateData({
...job.data,
status: 'processing',
@ -87,9 +108,25 @@ export class EmbedFileJob {
logger.info(`[EmbedFileJob] Processing file: ${filePath}`)
// Progress callback: maps service-reported 0-100% into the 5-95% job range
// Progress callback. For multi-batch ZIM ingestions, scale the service-reported
// 0-100% (which is % through the current batch's chunks) into the overall-file
// frame so the UI gauge climbs monotonically across the many continuation jobs
// BullMQ creates per file. Without this, every new continuation jobId resets the
// gauge to ~5% and the user sees ingestion progress "jumping around" between
// each batch's local frame and the end-of-batch overall-file overwrite below.
//
// For single-batch files (uploaded PDFs, txts) totalArticles is undefined and
// we fall back to the original 5-95% per-job range, which is what the UI expects
// for a one-shot file with no continuations.
const onProgress = async (percent: number) => {
await this.safeUpdateProgress(job, Math.min(95, Math.round(5 + percent * 0.9)))
const useOverallFrame = totalArticles && totalArticles > 0
if (useOverallFrame) {
const articlesDone = (batchOffset || 0) + (percent / 100) * ZIM_BATCH_SIZE
const overallPercent = Math.min(99, Math.round((articlesDone / totalArticles) * 100))
await this.safeUpdateProgress(job, overallPercent)
} else {
await this.safeUpdateProgress(job, Math.min(95, Math.round(5 + percent * 0.9)))
}
}
// Process and embed the file
@ -114,18 +151,59 @@ export class EmbedFileJob {
`[EmbedFileJob] Batch complete. Dispatching next batch at offset ${nextOffset}`
)
// Dispatch next batch (not final yet)
// Pace continuation batches when embedding is CPU-bound. Sustained 100% CPU
// saturation across all cores during multi-batch ZIM ingestion can starve
// other services (sshd has been seen to lose responsiveness hard enough to
// require a power-cycle). When GPU-accelerated, embeddings stream through
// the GPU and CPUs stay free — no pacing needed.
const isGpuAccelerated = await ollamaService.isEmbeddingGpuAccelerated()
if (!isGpuAccelerated) {
logger.info(
`[EmbedFileJob] Embedding is CPU-only — pacing ${EmbedFileJob.CPU_BATCH_DELAY_MS}ms before dispatching next batch`
)
await new Promise((resolve) => setTimeout(resolve, EmbedFileJob.CPU_BATCH_DELAY_MS))
}
// Bail before re-populating the queue if this job was cancelled mid-batch.
// cancelAllJobs() obliterates the queue (including this active job), but a
// worker already inside handle() would otherwise dispatch its continuation
// afterwards and silently revive a cancelled ZIM ingestion. If our own job
// key is gone, the cancel happened — skip the dispatch. Mirrors the
// "tolerate external removal" handling in safeUpdateProgress above.
const stillQueued = await QueueService.getInstance()
.getQueue(EmbedFileJob.queue)
.getJob(job.id!)
if (!stillQueued) {
logger.info(
`[EmbedFileJob] Job ${fileName} was cancelled; skipping continuation dispatch`
)
return { success: false, cancelled: true, fileName, filePath }
}
// Dispatch next batch (not final yet). Carry forward the running
// chunk count so the final batch can persist an accurate total (#933).
const chunksSoFarNext = (job.data.chunksSoFar || 0) + (result.chunks || 0)
await EmbedFileJob.dispatch({
filePath,
fileName,
batchOffset: nextOffset,
totalArticles: totalArticles || result.totalArticles,
isFinalBatch: false, // Explicitly not final
chunksSoFar: chunksSoFarNext,
})
// Calculate progress based on articles processed
// Calculate progress based on articles processed.
//
// nextOffset counts entries passing our isArticleEntry() filter, but the
// denominator (totalArticles = archive.articleCount) uses libzim's
// narrower article definition. On ZIMs that pack one logical article as
// several sub-pages (e.g. iFixit), nextOffset outruns articleCount and a
// raw ratio overflows past 100%, which the UI pins at 99% for the entire
// tail so the file looks stuck (#903). Grow the denominator once we pass
// the reported count so the gauge keeps creeping forward monotonically,
// and never report 100% before the genuinely-final batch (handled below).
const progress = totalArticles
? Math.round((nextOffset / totalArticles) * 100)
? Math.min(99, Math.round((nextOffset / Math.max(totalArticles, nextOffset + ZIM_BATCH_SIZE)) * 100))
: 50
await this.safeUpdateProgress(job, progress)
@ -133,7 +211,7 @@ export class EmbedFileJob {
...job.data,
status: 'batch_completed',
lastBatchAt: Date.now(),
chunks: (job.data.chunks || 0) + (result.chunks || 0),
chunks: chunksSoFarNext,
})
return {
@ -147,8 +225,11 @@ export class EmbedFileJob {
}
}
// Final batch or non-batched file - mark as complete
const totalChunks = (job.data.chunks || 0) + (result.chunks || 0)
// Final batch or non-batched file - mark as complete.
// chunksSoFar carries the accumulated count from prior dispatched batches
// (each continuation passes it forward — see EmbedFileJobParams). For a
// non-batched file it is undefined and we just count this single result.
const totalChunks = (job.data.chunksSoFar || 0) + (result.chunks || 0)
await this.safeUpdateProgress(job, 100)
await job.updateData({
...job.data,
@ -157,6 +238,18 @@ export class EmbedFileJob {
chunks: totalChunks,
})
// Persist the post-job state so scanAndSyncStorage knows this file is done.
// BullMQ's :completed retention (50 jobs) ages out, so the state row is
// the only durable record of "this file finished embedding".
try {
await KbIngestState.markIndexed(filePath, totalChunks)
} catch (stateErr) {
logger.warn(
`[EmbedFileJob] Failed to persist ingest state for ${fileName}: %s`,
stateErr instanceof Error ? stateErr.message : String(stateErr)
)
}
const batchMsg = isZimBatch ? ` (final batch, total chunks: ${totalChunks})` : ''
logger.info(
`[EmbedFileJob] Successfully embedded ${result.chunks} chunks from file: ${fileName}${batchMsg}`
@ -170,73 +263,149 @@ export class EmbedFileJob {
message: `Successfully embedded ${result.chunks} chunks`,
}
} catch (error) {
logger.error(`[EmbedFileJob] Error embedding file ${fileName}:`, error)
// A chunk that still exceeds the model's context after OllamaService's truncate-and-retry is
// permanently oversized for this install (e.g. a model whose context is smaller than our safe
// cap). Re-embedding the whole file 30x re-processes everything and can never succeed — that is
// the "endless queue loop" / "api/embed for weeks" (#881/#944/#959). Mark it unrecoverable so
// BullMQ stops after one pass instead of storming.
let normalizedError = error
if (!(error instanceof UnrecoverableError) && OllamaService.isContextLengthError(error)) {
logger.warn(
`[EmbedFileJob] Context-length overflow persisted for ${fileName} after truncation; not retrying.`
)
normalizedError = new UnrecoverableError(
error instanceof Error ? error.message : 'Embedding input exceeds the model context length'
)
}
logger.error(`[EmbedFileJob] Error embedding file ${fileName}:`, normalizedError)
await job.updateData({
...job.data,
status: 'failed',
failedAt: Date.now(),
error: error instanceof Error ? error.message : 'Unknown error',
error: normalizedError instanceof Error ? normalizedError.message : 'Unknown error',
})
throw error
// Only persist `failed` for unrecoverable errors. Retryable errors get
// automatic BullMQ retries (30 attempts); marking state failed on every
// transient blip would suppress the retry-driven recovery path.
if (normalizedError instanceof UnrecoverableError) {
try {
await KbIngestState.markFailed(
filePath,
normalizedError instanceof Error ? normalizedError.message : 'Unknown error'
)
} catch (stateErr) {
logger.warn(
`[EmbedFileJob] Failed to persist failed state for ${fileName}: %s`,
stateErr instanceof Error ? stateErr.message : String(stateErr)
)
}
}
throw normalizedError
}
}
static async listActiveJobs(): Promise<EmbedJobWithProgress[]> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobs = await queue.getJobs(['waiting', 'active', 'delayed'])
return jobs.map((job) => ({
jobId: job.id!.toString(),
fileName: (job.data as EmbedFileJobParams).fileName,
filePath: (job.data as EmbedFileJobParams).filePath,
progress: typeof job.progress === 'number' ? job.progress : 0,
status: ((job.data as any).status as string) ?? 'waiting',
}))
return jobs.map((job) => {
const data = job.data as EmbedFileJobParams & {
status?: string
lastBatchAt?: number
startedAt?: number
chunks?: number
}
return {
jobId: job.id!.toString(),
fileName: data.fileName,
filePath: data.filePath,
progress: typeof job.progress === 'number' ? job.progress : 0,
status: data.status ?? 'waiting',
lastBatchAt: data.lastBatchAt,
startedAt: data.startedAt,
chunks: data.chunks,
}
})
}
static async getByFilePath(filePath: string): Promise<Job | undefined> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(filePath)
return await queue.getJob(jobId)
}
static async dispatch(params: EmbedFileJobParams) {
const queueService = new QueueService()
static async dispatch(params: EmbedFileJobParams, options?: { force?: boolean }) {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(params.filePath)
// Continuation batches (batchOffset > 0) must NOT reuse the deterministic
// per-file jobId. Two BullMQ dedupe paths would otherwise silently swallow them:
// 1) The parent batch's handle() calls dispatch() before returning, so the
// parent job is still `active` and locked — queue.add() with the same
// jobId returns the locked parent rather than enqueueing the new batch.
// 2) After the parent completes, its entry stays in `completed` (held by
// `removeOnComplete: { count: 50 }`), still tripping jobId dedupe.
// Letting BullMQ auto-generate a unique jobId for continuation batches stacks
// them as independent queue entries that each process via handle().
// Initial dispatches keep the deterministic jobId so re-triggering an install
// (UI re-click, sync rescan, etc.) is still idempotent.
// `force` skips the deterministic jobId for bulk callers (reembedAll /
// resetAndRebuild) where historical entries in :completed would otherwise
// silently swallow the new dispatch.
const isContinuation = !!(params.batchOffset && params.batchOffset > 0)
const force = !!options?.force
const initialJobId = this.getJobId(params.filePath)
const jobOptions: Parameters<typeof queue.add>[2] = {
attempts: 30,
backoff: {
type: 'fixed',
delay: 60000, // Check every 60 seconds for service readiness
},
removeOnComplete: { count: 50 }, // Keep last 50 completed jobs for history
removeOnFail: { count: 20 }, // Keep last 20 failed jobs for debugging
}
if (!isContinuation && !force) {
jobOptions.jobId = initialJobId
}
try {
const job = await queue.add(this.key, params, {
jobId,
attempts: 30,
backoff: {
type: 'fixed',
delay: 60000, // Check every 60 seconds for service readiness
},
removeOnComplete: { count: 50 }, // Keep last 50 completed jobs for history
removeOnFail: { count: 20 } // Keep last 20 failed jobs for debugging
})
const job = await queue.add(this.key, params, jobOptions)
logger.info(`[EmbedFileJob] Dispatched embedding job for file: ${params.fileName}`)
const label = isContinuation
? ` (continuation @ offset ${params.batchOffset})`
: force
? ' (forced re-dispatch)'
: ''
logger.info(
`[EmbedFileJob] Dispatched embedding job for file: ${params.fileName}${label}`
)
return {
job,
created: true,
jobId,
jobId: job.id ?? initialJobId,
message: `File queued for embedding: ${params.fileName}`,
}
} catch (error) {
if (error.message && error.message.includes('job already exists')) {
const existing = await queue.getJob(jobId)
if (
!isContinuation &&
!force &&
error.message &&
error.message.includes('job already exists')
) {
const existing = await queue.getJob(initialJobId)
logger.info(`[EmbedFileJob] Job already exists for file: ${params.fileName}`)
return {
job: existing,
created: false,
jobId,
jobId: initialJobId,
message: `Embedding job already exists for: ${params.fileName}`,
}
}
@ -245,7 +414,7 @@ export class EmbedFileJob {
}
static async listFailedJobs(): Promise<EmbedJobWithProgress[]> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
// Jobs that have failed at least once are in 'delayed' (retrying) or terminal 'failed' state.
// We identify them by job.data.status === 'failed' set in the catch block of handle().
@ -264,7 +433,7 @@ export class EmbedFileJob {
}
static async cleanupFailedJobs(): Promise<{ cleaned: number; filesDeleted: number }> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const allJobs = await queue.getJobs(['waiting', 'delayed', 'failed'])
const failedJobs = allJobs.filter((job) => (job.data as any).status === 'failed')
@ -290,6 +459,46 @@ export class EmbedFileJob {
return { cleaned, filesDeleted }
}
/** Unconditionally clear every embedding job regardless of state.
*
* cleanupFailedJobs only removes jobs explicitly tagged status === 'failed',
* which leaves stuck jobs (waiting / active / delayed / paused that never
* reached 'failed') unreachable from the UI the operator's only recourse was
* flushing Redis by hand. This wipes the whole queue, including a locked active
* job, via obliterate({ force: true }) (plain obliterate/job.remove throw on a
* locked job). It touches only Redis, so it is safe while Qdrant/Ollama are
* offline which is exactly when jobs pile up and wedge. */
static async cancelAllJobs(): Promise<{ cancelled: number; filesDeleted: number }> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobs = await queue.getJobs(['waiting', 'active', 'delayed', 'paused', 'failed'])
let filesDeleted = 0
for (const job of jobs) {
const filePath = (job.data as EmbedFileJobParams).filePath
// Same guard as cleanupFailedJobs: only delete user uploads, never ZIM
// library files or Nomad docs that live outside the uploads path.
if (filePath && filePath.includes(RagService.UPLOADS_STORAGE_PATH)) {
try {
await fs.unlink(filePath)
filesDeleted++
} catch {
// File may already be deleted — that's fine
}
}
}
const cancelled = jobs.length
// force: true removes the locked/active job too. An in-flight worker may keep
// running its current batch in memory; the self-exists guard in handle()
// prevents it from dispatching a continuation back into the cleared queue.
await queue.obliterate({ force: true })
logger.info(`[EmbedFileJob] Cancelled ${cancelled} jobs, deleted ${filesDeleted} files`)
return { cancelled, filesDeleted }
}
static async getStatus(filePath: string): Promise<{
exists: boolean
status?: string

View File

@ -53,7 +53,7 @@ export class RunBenchmarkJob {
}
static async dispatch(params: RunBenchmarkJobParams) {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
try {
@ -89,7 +89,7 @@ export class RunBenchmarkJob {
}
static async getJob(benchmarkId: string): Promise<Job | undefined> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
return await queue.getJob(benchmarkId)
}

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() {
@ -31,10 +60,10 @@ export class RunDownloadJob {
/** Signal cancellation via Redis so the worker process can pick it up */
static async signalCancel(jobId: string): Promise<void> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', 'EX', 300) // 5 min TTL
await client.set(this.cancelKey(jobId), '1', { EX: 300 }) // 5 min TTL
}
async handle(job: Job) {
@ -46,7 +75,7 @@ export class RunDownloadJob {
RunDownloadJob.abortControllers.set(job.id!, abortController)
// Get Redis client for checking cancel signals from the API process
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const cancelRedis = await queueService.getQueue(RunDownloadJob.queue).client
let lastKnownProgress: Pick<DownloadProgressData, 'downloadedBytes' | 'totalBytes'> = {
@ -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,16 +208,77 @@ 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) {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
// 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 (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 {
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 reconciling knowledge base for replaced file ${filepath}:`,
error
)
}
} else {
// 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)
}
}
}
}
} else if (filetype === 'map') {
@ -199,7 +324,7 @@ export class RunDownloadJob {
}
static async getByUrl(url: string): Promise<Job | undefined> {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(url)
return await queue.getJob(jobId)
@ -229,7 +354,7 @@ export class RunDownloadJob {
}
static async dispatch(params: RunDownloadJobParams) {
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(params.url)

View File

@ -0,0 +1,294 @@
import { Job, UnrecoverableError } from 'bullmq'
import { spawn, ChildProcess } from 'child_process'
import { createHash } from 'crypto'
import { readdir, stat } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { QueueService } from '#services/queue_service'
import logger from '@adonisjs/core/services/logger'
import { DownloadProgressData } from '../../types/downloads.js'
import { PMTILES_BINARY_PATH, buildPmtilesExtractArgs } from '../../constants/map_regions.js'
import { deleteFileIfExists } from '../utils/fs.js'
export interface RunExtractPmtilesJobParams {
sourceUrl: string
outputFilepath: string
/** Path to a GeoJSON FeatureCollection file passed to `pmtiles extract --region`. */
regionFilepath: string
maxzoom?: number
/** Hint for progress reporting; obtained from `pmtiles extract --dry-run` preflight */
estimatedBytes?: number
filetype: 'map'
title?: string
resourceMetadata?: {
resource_id: string
version: string
collection_ref: string | null
}
}
export class RunExtractPmtilesJob {
static get queue() {
return 'pmtiles-extract'
}
static get key() {
return 'run-pmtiles-extract'
}
/** In-memory registry of active child processes so in-process cancels can SIGTERM them */
static childProcesses: Map<string, ChildProcess> = new Map()
static getJobId(sourceUrl: string, regionFilepath: string, maxzoom?: number): string {
const payload = JSON.stringify({ sourceUrl, regionFilepath, maxzoom: maxzoom ?? null })
return createHash('sha256').update(payload).digest('hex').slice(0, 16)
}
/** Redis key used to signal cancellation across processes */
static cancelKey(jobId: string): string {
return `nomad:download:pmtiles-cancel:${jobId}`
}
static async signalCancel(jobId: string): Promise<void> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', { EX: 300 })
}
/** Awaits job.updateProgress and swallows BullMQ stale-job errors (code -1),
* which occur when the job was removed from Redis (e.g. cancelled) between
* the await being issued and the Redis write completing. Anything else
* re-throws so it's caught by the surrounding try rather than becoming an
* unhandled rejection. */
private async safeUpdateProgress(job: Job, progress: DownloadProgressData): Promise<void> {
try {
await job.updateProgress(progress)
} catch (err: any) {
if (err?.code !== -1) throw err
}
}
async handle(job: Job) {
const params = job.data as RunExtractPmtilesJobParams
const { sourceUrl, outputFilepath, regionFilepath, maxzoom, estimatedBytes } = params
logger.info(
`[RunExtractPmtilesJob] Starting extract: source=${sourceUrl} region=${regionFilepath} ` +
`maxzoom=${maxzoom ?? 'source-max'} out=${outputFilepath}`
)
const queueService = QueueService.getInstance()
const cancelRedis = await queueService.getQueue(RunExtractPmtilesJob.queue).client
let userCancelled = false
let proc: ChildProcess | null = null
let lastReportedBytes = -1
// One 2s tick polls the Redis cancel signal and reads file-size for progress. pmtiles
// writes incrementally but rewrites directories near the end so progress isn't strictly
// monotonic — we cap at 99% and skip emit when bytes are unchanged to avoid Redis chatter.
const tick = setInterval(async () => {
try {
const val = await cancelRedis.get(RunExtractPmtilesJob.cancelKey(job.id!))
if (val) {
await cancelRedis.del(RunExtractPmtilesJob.cancelKey(job.id!))
userCancelled = true
proc?.kill('SIGTERM')
}
} catch {
// Redis errors non-fatal — in-memory handle also covers same-process cancels
}
try {
const fileStat = await stat(outputFilepath)
const downloadedBytes = Number(fileStat.size)
if (downloadedBytes === lastReportedBytes) return
lastReportedBytes = downloadedBytes
const totalBytes = estimatedBytes ?? 0
const percent =
totalBytes > 0 ? Math.min(99, Math.floor((downloadedBytes / totalBytes) * 100)) : 0
await this.safeUpdateProgress(job, {
percent,
downloadedBytes,
totalBytes,
lastProgressTime: Date.now(),
} as DownloadProgressData)
} catch {
// File doesn't exist yet (subprocess still setting up)
}
}, 2000)
try {
const args = buildPmtilesExtractArgs({
sourceUrl,
outputFilepath,
regionFilepath,
maxzoom,
downloadThreads: 8,
overfetch: 0.2,
})
proc = spawn(PMTILES_BINARY_PATH, args, { stdio: ['ignore', 'pipe', 'pipe'] })
RunExtractPmtilesJob.childProcesses.set(job.id!, proc)
proc.stdout?.on('data', (chunk) => {
logger.debug(`[RunExtractPmtilesJob:${job.id}] ${chunk.toString().trimEnd()}`)
})
proc.stderr?.on('data', (chunk) => {
logger.debug(`[RunExtractPmtilesJob:${job.id}] ${chunk.toString().trimEnd()}`)
})
const exitCode: number = await new Promise((resolve, reject) => {
proc!.on('close', (code) => resolve(code ?? -1))
proc!.on('error', (err) => reject(err))
})
if (exitCode !== 0) {
await deleteFileIfExists(outputFilepath)
if (userCancelled) {
throw new UnrecoverableError(`Extract cancelled by user (exit ${exitCode})`)
}
throw new Error(`pmtiles extract exited with code ${exitCode}`)
}
// Final progress bump — tick caps at 99 so the UI doesn't flicker to 100 mid-extract
const finalStat = await stat(outputFilepath)
await this.safeUpdateProgress(job, {
percent: 100,
downloadedBytes: Number(finalStat.size),
totalBytes: estimatedBytes ?? Number(finalStat.size),
lastProgressTime: Date.now(),
} as DownloadProgressData)
// Reuse the HTTP download path's post-download hook so the file is registered and
// the previous version (if any) is deleted
await this.onComplete(params)
logger.info(
`[RunExtractPmtilesJob] Completed extract: out=${outputFilepath} size=${finalStat.size} bytes`
)
return { sourceUrl, outputFilepath }
} catch (error: any) {
if (userCancelled && !(error instanceof UnrecoverableError)) {
throw new UnrecoverableError(`Extract cancelled: ${error.message ?? error}`)
}
throw error
} finally {
clearInterval(tick)
RunExtractPmtilesJob.childProcesses.delete(job.id!)
}
}
private async onComplete(params: RunExtractPmtilesJobParams) {
if (!params.resourceMetadata) return
const [{ default: InstalledResource }, { DateTime }, fsUtils] = await Promise.all([
import('#models/installed_resource'),
import('luxon'),
import('../utils/fs.js'),
])
const fileStat = await fsUtils.getFileStatsIfExists(params.outputFilepath)
const existing = await InstalledResource.query()
.where('resource_id', params.resourceMetadata.resource_id)
.where('resource_type', 'map')
.first()
const oldFilePath = existing?.file_path ?? null
await InstalledResource.updateOrCreate(
{
resource_id: params.resourceMetadata.resource_id,
resource_type: 'map',
},
{
version: params.resourceMetadata.version,
collection_ref: params.resourceMetadata.collection_ref,
url: params.sourceUrl,
file_path: params.outputFilepath,
file_size_bytes: fileStat ? Number(fileStat.size) : null,
installed_at: DateTime.now(),
}
)
if (oldFilePath && oldFilePath !== params.outputFilepath) {
try {
await fsUtils.deleteFileIfExists(oldFilePath)
} catch (err) {
logger.warn(`[RunExtractPmtilesJob] Failed to delete old file ${oldFilePath}: ${err}`)
}
}
// Fallback: scan the pmtiles dir for orphans with the same resource_id that the DB
// lookup above didn't catch — e.g. a prior extract crashed before writing its
// InstalledResource row, or an earlier bug wrote a file without registering it.
// Matches both curated (`<id>_YYYY-MM.pmtiles`) and regional (`<id>_YYYYMMDD_zN.pmtiles`)
// naming — prefix-only so new filename formats don't silently miss.
const dir = dirname(params.outputFilepath)
const keepName = basename(params.outputFilepath)
const prefix = `${params.resourceMetadata.resource_id}_`
try {
const entries = await readdir(dir)
for (const entry of entries) {
if (entry === keepName || !entry.endsWith('.pmtiles')) continue
if (!entry.startsWith(prefix)) continue
const orphanPath = join(dir, entry)
if (orphanPath === oldFilePath) continue
try {
await fsUtils.deleteFileIfExists(orphanPath)
logger.info(`[RunExtractPmtilesJob] Pruned orphan pmtiles ${orphanPath}`)
} catch (err) {
logger.warn(`[RunExtractPmtilesJob] Failed to prune orphan ${orphanPath}: ${err}`)
}
}
} catch (err) {
logger.warn(`[RunExtractPmtilesJob] Directory scan for orphans failed: ${err}`)
}
}
static async getById(jobId: string): Promise<Job | undefined> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
return await queue.getJob(jobId)
}
static async dispatch(params: RunExtractPmtilesJobParams) {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(params.sourceUrl, params.regionFilepath, params.maxzoom)
const existing = await queue.getJob(jobId)
if (existing) {
const state = await existing.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return {
job: existing,
created: false,
message: `Extract job already exists for these params`,
}
}
// Stale (completed/failed) — remove so we can re-dispatch under the same deterministic id
try {
await existing.remove()
} catch {
// Already gone or locked — add() below will still report a meaningful error
}
}
// Fewer attempts than HTTP downloads — a failed extract usually means the source URL
// rotated or the CDN is throttling, and resuming mid-extract isn't supported by the CLI
const job = await queue.add(this.key, params, {
jobId,
attempts: 3,
backoff: { type: 'exponential', delay: 60000 },
removeOnComplete: true,
})
return {
job,
created: true,
message: `Dispatched pmtiles extract job`,
}
}
}

View File

@ -3,7 +3,21 @@ import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import compression from 'compression'
const compress = env.get('DISABLE_COMPRESSION') ? null : compression()
// Skip compression for Server-Sent Events. The compression library buffers
// response writes to determine encoding, which collapses per-token streaming
// into a single block delivered after generation completes (regression in
// v1.31.0-rc.2, reported in #781 by @toasterking).
const compress = env.get('DISABLE_COMPRESSION')
? null
: compression({
filter: (req: any, res: any) => {
const contentType = res.getHeader('Content-Type')
if (typeof contentType === 'string' && contentType.includes('text/event-stream')) {
return false
}
return compression.filter(req, res)
},
})
export default class CompressionMiddleware {
async handle({ request, response }: HttpContext, next: NextFn) {

View File

@ -18,7 +18,7 @@ export default class ChatMessage extends BaseModel {
@column()
declare content: string
@belongsTo(() => ChatSession, { foreignKey: 'id', localKey: 'session_id' })
@belongsTo(() => ChatSession, { foreignKey: 'session_id', localKey: 'id' })
declare session: BelongsTo<typeof ChatSession>
@column.dateTime({ autoCreate: true })

View File

@ -0,0 +1,24 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
export default class CustomLibrarySource extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column()
declare base_url: string
@column()
declare is_default: boolean
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

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

@ -0,0 +1,77 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import type { KbIngestStateValue } from '../../types/kb_ingest_state.js'
const LAST_ERROR_MAX_LEN = 1024
/**
* Tracks the per-file decision and outcome of AI knowledge-base ingestion.
*
* The row exists for any embeddable file the scanner has seen and is independent
* of `installed_resources` (which only covers curated downloads). Replaces the
* earlier "any chunks in qdrant ⇒ embedded" binary check, which conflated
* partially-stalled ingestions with fully-indexed files. See RFC #883.
*/
export default class KbIngestState extends BaseModel {
static table = 'kb_ingest_state'
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare file_path: string
@column()
declare state: KbIngestStateValue
@column()
declare chunks_embedded: number
@column()
declare last_error: string | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
static async getOrCreate(filePath: string): Promise<KbIngestState> {
return this.firstOrCreate(
{ file_path: filePath },
{ file_path: filePath, state: 'pending_decision', chunks_embedded: 0 }
)
}
static async markIndexed(filePath: string, chunksEmbedded: number): Promise<void> {
const row = await this.getOrCreate(filePath)
row.state = 'indexed'
row.chunks_embedded = chunksEmbedded
row.last_error = null
await row.save()
}
static async markFailed(filePath: string, errorMessage: string): Promise<void> {
const row = await this.getOrCreate(filePath)
row.state = 'failed'
row.last_error = errorMessage.slice(0, LAST_ERROR_MAX_LEN)
await row.save()
}
static async markBrowseOnly(filePath: string): Promise<void> {
const row = await this.getOrCreate(filePath)
row.state = 'browse_only'
await row.save()
}
static async markStalled(filePath: string): Promise<void> {
const row = await this.getOrCreate(filePath)
row.state = 'stalled'
await row.save()
}
static async remove(filePath: string): Promise<void> {
await this.query().where('file_path', filePath).delete()
}
}

View File

@ -0,0 +1,79 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import {
findChunksPerMb,
estimateChunkCount,
estimateBatch,
type BatchEstimate,
type BatchEstimateInput,
} from '../utils/kb_ratio_lookup.js'
/**
* Self-calibrating registry of `{filename-prefix → chunks_per_mb}` ratios used
* for disk-footprint and time-to-embed estimates surfaced in the KB panel.
*
* Migration seeds the registry with heuristic defaults from the RFC #883
* appendix; Phase 4 self-calibration will update rows in place as ZIMs finish
* ingesting and the real ratio becomes known. Lookup is longest-prefix-match
* (see `kb_ratio_lookup.ts`) so a specific entry (`wikipedia_en_simple_`)
* overrides a broader one (`wikipedia_en_`).
*/
export default class KbRatioRegistry extends BaseModel {
static table = 'kb_ratio_registry'
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare pattern: string
@column()
declare chunks_per_mb: number
@column()
declare sample_count: number
@column()
declare notes: string | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
/** Look up chunks_per_mb for a filename by longest-prefix match. */
static async lookup(filename: string): Promise<number | null> {
const rows = await this.all()
return findChunksPerMb(filename, rows)
}
/**
* Estimate total chunks for a file of the given size on disk.
*
* `ignoreCatchAll` excludes the empty-pattern fallback, returning `null` for
* filenames that only the catch-all would match. The partial_stall warning
* uses this so it never flags ZIMs the registry can't specifically
* characterize (e.g. PDF/link-out-heavy archives whose byte size wildly
* over-predicts embeddable chunks). See #913.
*/
static async estimateChunks(
filename: string,
fileSizeBytes: number,
opts: { ignoreCatchAll?: boolean } = {}
): Promise<number | null> {
const rows = await this.all()
return estimateChunkCount(filename, fileSizeBytes, rows, opts)
}
/**
* Aggregate an embedding-disk-cost estimate across a batch of files. Used by
* the curated-tier-change UI to show "you're about to add ~X GB of
* embeddings on top of the ZIM downloads" before the user commits.
*/
static async estimateBatch(files: BatchEstimateInput[]): Promise<BatchEstimate> {
const rows = await this.all()
return estimateBatch(files, rows)
}
}

View File

@ -59,9 +59,42 @@ export default class Service extends BaseModel {
@column()
declare ui_location: string | null
// User-set override for the launch ("Open") link (e.g. a reverse-proxy/local-DNS host like
// https://jellyfin.myhomelab.net). When null, the default host + port link derived from
// ui_location is used. Only affects user-facing links — never internal service-to-service URLs.
@column()
declare custom_url: string | null
@column()
declare metadata: string | null
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_custom: boolean
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_user_modified: boolean
@column()
declare category: string | null
// When true the service is sunset: hidden from the install catalog unless it is already
// installed (see SystemService.getServices). Lets a deprecated app stay manageable for users who
// still run it while keeping new users from installing it.
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_deprecated: boolean
@column()
declare source_repo: string | null
@ -71,6 +104,28 @@ export default class Service extends BaseModel {
@column.dateTime()
declare update_checked_at: DateTime | null
// Per-app opt-in for automatic updates. An app auto-updates only when both this
// and the global `appAutoUpdate.enabled` master switch are on.
@column({
serialize(value) {
return Boolean(value)
},
})
declare auto_update_enabled: boolean
// When the current `available_update_version` was first detected — the anchor for
// the auto-update cool-off (registry tags carry no publish timestamp).
@column.dateTime()
declare available_update_first_seen_at: DateTime | null
// Per-app auto-update failure backoff; at the threshold the app self-disables via
// `auto_update_disabled_reason` without affecting other apps.
@column()
declare auto_update_consecutive_failures: number
@column()
declare auto_update_disabled_reason: string | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime

View File

@ -0,0 +1,398 @@
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
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 { ContainerRegistryService } from '#services/container_registry_service'
import { isNewerVersion, parseMajorVersion } from '../utils/version.js'
import { isWithinWindow } from '../utils/update_window.js'
import {
checkImageDiskSpace,
type Blocker,
type PreflightResult,
} from '../utils/image_disk_preflight.js'
/**
* Defaults shared with the core auto-update. App auto-updates intentionally reuse
* the SAME window/cool-off settings (`autoUpdate.windowStart/windowEnd/cooloffHours`);
* only the enable flag (`appAutoUpdate.enabled`) is separate.
*/
const DEFAULT_WINDOW_START = '02:00'
const DEFAULT_WINDOW_END = '05:00'
const DEFAULT_COOLOFF_HOURS = 72
/** Per-app genuine failures before that app self-disables (others keep running). */
const MAX_CONSECUTIVE_FAILURES = 3
export interface AppAutoUpdateConfig {
/** Global master switch (`appAutoUpdate.enabled`). */
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
}
/** An installed app that should be auto-updated this run. */
export interface AppUpdateTarget {
service: Service
/** Exact registry tag to update to (the value in `available_update_version`). */
targetVersion: string
}
/** Per-app eligibility verdict (drives both selection and the status UI). */
export interface AppEligibility {
eligible: boolean
reason: string
cooloffRemainingHours: number | null
}
export interface AppAutoUpdateAppStatus {
service_name: string
friendly_name: string | null
auto_update_enabled: boolean
current_version: string
available_update_version: string | null
first_seen_at: string | null
eligible: boolean
reason: string
cooloff_remaining_hours: number | null
consecutive_failures: number
auto_disabled_reason: string | null
}
export interface AppAutoUpdateStatus extends AppAutoUpdateConfig {
withinWindow: boolean
lastAttemptAt: string | null
lastResult: string | null
apps: AppAutoUpdateAppStatus[]
}
/**
* Decision + safety layer for automatic updates of installed sibling apps (the
* containers NOMAD deploys via the Docker socket and manages in Supply Depot).
*
* This is the app-side counterpart to {@link AutoUpdateService} and intentionally
* reuses its generic window/disk pre-flight helpers. Unlike the core update, an
* app update needs no sidecar the admin container recreates its siblings directly
* via {@link DockerService.updateContainer} (in-process pull rename health-check
* rollback). Auto-update only decides *whether* each opted-in app should update now
* (master switch on + per-app toggle on + in window + an eligible minor/patch past
* its cool-off + pre-flight passes) and then drives the existing update path.
*
* Minor/patch-only is already guaranteed upstream by
* {@link ContainerRegistryService.getAvailableUpdates} (same-major filter); the
* major-version check here is defense-in-depth.
*/
@inject()
export class AppAutoUpdateService {
constructor(
private dockerService: DockerService,
private downloadService: DownloadService,
private systemService: SystemService,
private containerRegistryService: ContainerRegistryService
) {}
/** Read the global master switch plus the shared window/cool-off settings. */
async getConfig(): Promise<AppAutoUpdateConfig> {
const [enabled, windowStart, windowEnd, cooloffHours] = await Promise.all([
KVStore.getValue('appAutoUpdate.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,
}
}
/**
* Pure per-app eligibility verdict. An app is eligible when it has a detected
* update that is the same major (defense-in-depth), strictly newer, not self-
* disabled, and past its cool-off (measured from first-detected).
*/
appEligibility(service: Service, cooloffHours: number, now: DateTime): AppEligibility {
if (!service.available_update_version) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (service.auto_update_disabled_reason) {
return {
eligible: false,
reason: 'Auto-update disabled after repeated failures',
cooloffRemainingHours: null,
}
}
const currentTag = this.containerRegistryService.parseImageReference(
service.container_image
).tag
if (currentTag === 'latest') {
return {
eligible: false,
reason: 'Pinned to :latest — cannot version-check',
cooloffRemainingHours: null,
}
}
if (parseMajorVersion(service.available_update_version) !== parseMajorVersion(currentTag)) {
return {
eligible: false,
reason: 'Major version — manual update required',
cooloffRemainingHours: null,
}
}
if (!isNewerVersion(service.available_update_version, currentTag)) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (!service.available_update_first_seen_at) {
return { eligible: false, reason: 'Cool-off pending', cooloffRemainingHours: cooloffHours }
}
const ageHours = now.diff(service.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 → ${service.available_update_version}`,
cooloffRemainingHours: 0,
}
}
/** Installed, opted-in apps that are eligible to update right now. */
async getEligibleApps(config: AppAutoUpdateConfig, now: DateTime): Promise<AppUpdateTarget[]> {
const apps = await Service.query().where('installed', true).where('auto_update_enabled', true)
const targets: AppUpdateTarget[] = []
for (const service of apps) {
const verdict = this.appEligibility(service, config.cooloffHours, now)
if (verdict.eligible) {
targets.push({ service, targetVersion: service.available_update_version! })
}
}
return targets
}
/**
* Run-wide pre-flight checked once per attempt (independent of any single app):
* never auto-update while content/model downloads are running. 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) {
logger.warn(`[AppAutoUpdateService] Could not check active downloads: ${error.message}`)
}
return { ok: blockers.length === 0, blockers }
}
/** Per-app pre-flight: not already mid-operation (`skip`) and enough disk (`failure`). */
async runAppPreflight(target: AppUpdateTarget): Promise<PreflightResult> {
const blockers: Blocker[] = []
const service = target.service
if (service.installation_status !== 'idle') {
blockers.push({
reason: `App has an operation in progress (status: ${service.installation_status})`,
severity: 'skip',
})
}
const hostArch = await this.getHostArch()
const targetImage = `${this.imageBase(service.container_image)}:${target.targetVersion}`
const diskBlocker = await checkImageDiskSpace({
image: targetImage,
hostArch,
containerRegistryService: this.containerRegistryService,
systemService: this.systemService,
})
if (diskBlocker) blockers.push(diskBlocker)
return { ok: blockers.length === 0, blockers }
}
/**
* Entry point invoked by AppAutoUpdateJob. Gates on the master switch + window,
* then runs each eligible app through pre-flight and {@link DockerService.updateContainer}.
* A failing app self-disables after repeated failures without affecting the others.
*/
async attempt(): Promise<{ updated: number; reason: string }> {
const config = await this.getConfig()
const now = DateTime.now()
if (!config.enabled) {
return { updated: 0, reason: 'App 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 { updated: 0, reason }
}
const eligible = await this.getEligibleApps(config, now)
if (eligible.length === 0) {
const reason = 'No eligible app updates (all current, in cool-off, or major-only)'
await this.recordRun(reason)
return { updated: 0, reason }
}
const global = await this.runGlobalPreflight()
if (!global.ok) {
const reason = `Pre-flight blocked: ${global.blockers.map((b) => b.reason).join('; ')}`
await this.recordRun(reason)
return { updated: 0, reason }
}
let updated = 0
let failed = 0
let skipped = 0
for (const target of eligible) {
const name = target.service.service_name
const preflight = await this.runAppPreflight(target)
if (!preflight.ok) {
const summary = preflight.blockers.map((b) => b.reason).join('; ')
if (preflight.blockers.some((b) => b.severity === 'failure')) {
await this.recordAppFailure(target.service, summary)
failed++
} else {
logger.info(`[AppAutoUpdateService] Skipped ${name}: ${summary}`)
skipped++
}
continue
}
logger.info(`[AppAutoUpdateService] Updating ${name}${target.targetVersion}`)
const result = await this.dockerService.updateContainer(name, target.targetVersion)
if (result.success) {
await this.recordAppSuccess(target.service)
updated++
} else {
await this.recordAppFailure(target.service, result.message)
failed++
}
}
const reason = `${updated} updated, ${failed} failed, ${skipped} skipped`
await this.recordRun(reason)
logger.info(`[AppAutoUpdateService] Run complete: ${reason}`)
return { updated, reason }
}
/** Clear an app's failure backoff after a successful auto-update. */
private async recordAppSuccess(service: Service): Promise<void> {
// updateContainer already advanced container_image and cleared
// available_update_version on its own (fresh) row; here we only touch the
// backoff fields, so Lucid persists just those dirty columns.
service.auto_update_consecutive_failures = 0
service.auto_update_disabled_reason = null
await service.save()
}
/** Record an app failure and self-disable it once the threshold is reached. */
private async recordAppFailure(service: Service, reason: string): Promise<void> {
const failures = (service.auto_update_consecutive_failures || 0) + 1
service.auto_update_consecutive_failures = failures
if (failures >= MAX_CONSECUTIVE_FAILURES) {
service.auto_update_disabled_reason = `Auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
logger.error(
`[AppAutoUpdateService] ${service.service_name} auto-disabled after ${failures} failures`
)
}
await service.save()
logger.error(
`[AppAutoUpdateService] ${service.service_name} failure ${failures}/${MAX_CONSECUTIVE_FAILURES}: ${reason}`
)
}
/** Record the global last-attempt summary for the settings UI. */
private async recordRun(reason: string): Promise<void> {
await KVStore.setValue('appAutoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('appAutoUpdate.lastResult', reason)
}
/** Full state snapshot for the settings UI (opted-in apps + their eligibility). */
async getStatus(): Promise<AppAutoUpdateStatus> {
const config = await this.getConfig()
const now = DateTime.now()
const apps = await Service.query().where('installed', true).where('auto_update_enabled', true)
const appStatuses: AppAutoUpdateAppStatus[] = apps.map((service) => {
const verdict = this.appEligibility(service, config.cooloffHours, now)
return {
service_name: service.service_name,
friendly_name: service.friendly_name,
auto_update_enabled: service.auto_update_enabled,
current_version: this.containerRegistryService.parseImageReference(service.container_image)
.tag,
available_update_version: service.available_update_version,
first_seen_at: service.available_update_first_seen_at?.toISO() ?? null,
eligible: verdict.eligible,
reason: verdict.reason,
cooloff_remaining_hours: verdict.cooloffRemainingHours,
consecutive_failures: service.auto_update_consecutive_failures || 0,
auto_disabled_reason: service.auto_update_disabled_reason,
}
})
const [lastAttemptAt, lastResult] = await Promise.all([
KVStore.getValue('appAutoUpdate.lastAttemptAt'),
KVStore.getValue('appAutoUpdate.lastResult'),
])
return {
...config,
withinWindow: isWithinWindow(config.windowStart, config.windowEnd, now),
lastAttemptAt: lastAttemptAt || null,
lastResult: lastResult || null,
apps: appStatuses,
}
}
/** Strip the tag from an image reference, leaving "registry/namespace/repo". */
private imageBase(image: string): string {
return image.includes(':') ? image.substring(0, image.lastIndexOf(':')) : image
}
/** 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'
}
}
}

View File

@ -0,0 +1,580 @@
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'
import { isWithinWindow as isWithinWindowUtil } from '../utils/update_window.js'
import {
checkImageDiskSpace,
type Blocker,
type PreflightResult,
} from '../utils/image_disk_preflight.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
/** 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
}
// Pre-flight types/primitives are shared with AppAutoUpdateService; re-exported
// here for back-compat with existing imports of this module.
export type { Blocker, BlockerSeverity, PreflightResult } from '../utils/image_disk_preflight.js'
/** 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 {
return isWithinWindowUtil(config.windowStart, config.windowEnd, now)
}
/**
* 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> {
const hostArch = await this.getHostArch()
return checkImageDiskSpace({
image: `${NOMAD_IMAGE_REPO}:${targetTag}`,
hostArch,
containerRegistryService: this.containerRegistryService,
systemService: this.systemService,
})
}
/** 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

@ -317,6 +317,23 @@ export class BenchmarkService {
}
}
// Fallback: AMD discrete cards. si.graphics() returns empty inside Docker for AMD,
// the nvidia-smi path doesn't apply, and the APU regex only catches integrated parts.
// SystemService.getSystemInfo() already handles AMD via the marker file + Ollama log
// probe added in PR #804, so reuse that plumbing rather than duplicating it here.
if (!gpuModel) {
try {
const systemService = new (await import('./system_service.js')).SystemService(this.dockerService)
const sysInfo = await systemService.getSystemInfo()
const sysGpuModel = sysInfo?.graphics?.controllers?.[0]?.model
if (sysGpuModel) {
gpuModel = sysGpuModel
}
} catch (sysError: any) {
logger.warn(`[BenchmarkService] system_service AMD fallback failed: ${sysError.message}`)
}
}
return {
cpu_model: `${cpu.manufacturer} ${cpu.brand}`,
cpu_cores: cpu.physicalCores,
@ -597,8 +614,7 @@ export class BenchmarkService {
await this.dockerService.docker.getImage(SYSBENCH_IMAGE).inspect()
} catch {
this._updateStatus('starting', `Pulling sysbench image...`)
const pullStream = await this.dockerService.docker.pull(SYSBENCH_IMAGE)
await new Promise((resolve) => this.dockerService.docker.modem.followProgress(pullStream, resolve))
await this.dockerService.pullImage(SYSBENCH_IMAGE)
}
}

View File

@ -1,10 +1,11 @@
import ChatSession from '#models/chat_session'
import ChatMessage from '#models/chat_message'
import KVStore from '#models/kv_store'
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import { inject } from '@adonisjs/core'
import { OllamaService } from './ollama_service.js'
import { DEFAULT_QUERY_REWRITE_MODEL, SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { toTitleCase } from '../utils/misc.js'
@inject()
@ -36,17 +37,23 @@ export class ChatService {
return [] // If no models are available, return empty suggestions
}
// Larger models generally give "better" responses, so pick the largest one
const largestModel = models.reduce((prev, current) => {
return prev.size > current.size ? prev : current
})
// Prefer the user's selected chat model. Fall back to the smallest
// installed model — picking the largest by file size is unsafe: if any
// installed model exceeds available VRAM (e.g. llama3.1:405b on a 96 GB
// GPU), Ollama spends minutes trying to load it and the request 500s.
// Suggestions are short prompts that don't benefit from a flagship model.
const lastModel = await KVStore.getValue('chat.lastModel')
const preferred = lastModel ? models.find((m) => m.name === lastModel) : undefined
const chosen =
preferred ??
models.reduce((prev, current) => (prev.size < current.size ? prev : current))
if (!largestModel) {
if (!chosen) {
return []
}
const response = await this.ollamaService.chat({
model: largestModel.name,
model: chosen.name,
messages: [
{
role: 'user',
@ -232,29 +239,22 @@ export class ChatService {
}
}
async generateTitle(sessionId: number, userMessage: string, assistantMessage: string) {
async generateTitle(sessionId: number, userMessage: string, assistantMessage: string, model: string) {
try {
const models = await this.ollamaService.getModels()
const titleModelAvailable = models?.some((m) => m.name === DEFAULT_QUERY_REWRITE_MODEL)
let title: string
if (!titleModelAvailable) {
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
} else {
const response = await this.ollamaService.chat({
model: DEFAULT_QUERY_REWRITE_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPTS.title_generation },
{ role: 'user', content: userMessage },
{ role: 'assistant', content: assistantMessage },
],
})
const response = await this.ollamaService.chat({
model,
messages: [
{ role: 'system', content: SYSTEM_PROMPTS.title_generation },
{ role: 'user', content: userMessage },
{ role: 'assistant', content: assistantMessage },
],
})
title = response?.message?.content?.trim()
if (!title) {
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
}
title = response?.message?.content?.trim()
if (!title) {
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
}
await this.updateSession(sessionId, { title })

View File

@ -5,6 +5,9 @@ import { DateTime } from 'luxon'
import { join } from 'path'
import CollectionManifest from '#models/collection_manifest'
import InstalledResource from '#models/installed_resource'
import WikipediaSelection from '#models/wikipedia_selection'
import { QueueService } from './queue_service.js'
import { RunDownloadJob } from '#jobs/run_download_job'
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections'
import {
ensureDirectoryExists,
@ -98,10 +101,74 @@ export class CollectionManifestService {
const installedResources = await InstalledResource.query().where('resource_type', 'zim')
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
return spec.categories.map((category) => ({
...category,
installedTierSlug: this.getInstalledTierForCategory(category.tiers, installedMap),
}))
// In-flight ZIM download resource IDs from the BullMQ queue. Used to
// surface the user's tier intent immediately on submit, before any single
// file has finished downloading. Failed jobs are excluded so a stuck
// queue entry doesn't keep claiming the user's pick forever.
const inFlightIds = await this.getInFlightZimResourceIds()
return spec.categories.map((category) => {
const installedTierSlug = this.getInstalledTierForCategory(category.tiers, installedMap)
const downloadingTierSlug = this.getDownloadingTierForCategory(
category.tiers,
installedMap,
inFlightIds,
installedTierSlug
)
return { ...category, installedTierSlug, downloadingTierSlug }
})
}
private async getInFlightZimResourceIds(): Promise<Set<string>> {
const ids = new Set<string>()
try {
const queue = QueueService.getInstance().getQueue(RunDownloadJob.queue)
const jobs = await queue.getJobs(['waiting', 'active', 'delayed'])
for (const job of jobs) {
if (job.data?.filetype !== 'zim') continue
const resourceId = job.data?.resourceMetadata?.resource_id
if (typeof resourceId === 'string') ids.add(resourceId)
}
} catch (error) {
// Don't fail the whole categories endpoint if the queue is briefly
// unreachable — just report no in-flight downloads.
logger.warn('[CollectionManifestService] Could not read download queue:', error?.message || error)
}
return ids
}
/**
* Highest tier whose every resource is installed OR has an in-flight
* download. Returns undefined when there are no in-flight downloads for this
* category, or when the result would just duplicate installedTierSlug (i.e.
* everything that's downloading is already installed nothing new to show).
*/
getDownloadingTierForCategory(
tiers: SpecTier[],
installedMap: Map<string, InstalledResource>,
inFlightIds: Set<string>,
installedTierSlug: string | undefined
): string | undefined {
if (inFlightIds.size === 0) return undefined
// Cheap pre-check: any of this category's resources actually in flight?
const anyInFlight = tiers.some((tier) =>
CollectionManifestService.resolveTierResources(tier, tiers).some((r) => inFlightIds.has(r.id))
)
if (!anyInFlight) return undefined
const reversedTiers = [...tiers].reverse()
for (const tier of reversedTiers) {
const resolved = CollectionManifestService.resolveTierResources(tier, tiers)
if (resolved.length === 0) continue
const allAccountedFor = resolved.every(
(r) => installedMap.has(r.id) || inFlightIds.has(r.id)
)
if (allAccountedFor) {
return tier.slug === installedTierSlug ? undefined : tier.slug
}
}
return undefined
}
async getMapCollectionsWithStatus(): Promise<CollectionWithStatus[]> {
@ -218,10 +285,17 @@ export class CollectionManifestService {
const seenZimIds = new Set<string>()
// Only skip the single Wikipedia file tracked by WikipediaSelection — not every file
// starting with `wikipedia_en_`. Curated category tiers (e.g. Medicine → Comprehensive)
// ship Wikipedia-themed ZIMs like `wikipedia_en_medicine_maxi` that must reconcile
// normally; otherwise their InstalledResource row gets wiped on every restart and the
// tier detection silently downgrades.
const wikipediaSelection = await WikipediaSelection.query().first()
const managedWikipediaFilename = wikipediaSelection?.filename ?? null
for (const file of zimFiles) {
console.log(`Processing ZIM file: ${file.name}`)
// Skip Wikipedia files (managed by WikipediaSelection model)
if (file.name.startsWith('wikipedia_en_')) continue
if (managedWikipediaFilename && file.name === managedWikipediaFilename) continue
const parsed = CollectionManifestService.parseZimFilename(file.name)
console.log(`Parsed ZIM filename:`, parsed)

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,51 +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 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: response.data,
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: `Nomad API returned status ${error.response.status}`,
}
}
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 Nomad API: ${message}`,
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)
@ -105,10 +103,13 @@ export class CollectionUpdateService {
update.resource_type === 'zim' ? ZIM_MIME_TYPES : PMTILES_MIME_TYPES,
forceNew: true,
filetype: update.resource_type,
title: update.resource_id,
totalBytes: update.size_bytes,
resourceMetadata: {
resource_id: update.resource_id,
version: update.latest_version,
collection_ref: null,
auto: options?.auto ?? false,
},
})
@ -126,21 +127,42 @@ export class CollectionUpdateService {
async applyAllUpdates(
updates: ResourceUpdateInfo[]
): Promise<{ results: Array<{ resource_id: string; success: boolean; jobId?: string; error?: string }> }> {
const results: Array<{
resource_id: string
success: boolean
jobId?: string
error?: string
}> = []
for (const update of updates) {
const result = await this.applyUpdate(update)
results.push({ resource_id: update.resource_id, ...result })
}
const results = await Promise.all(
updates.map(async (update) => {
const result = await this.applyUpdate(update)
return { resource_id: update.resource_id, ...result }
})
)
return { results }
}
/**
* Fetch Content-Length for each update URL in parallel. HEAD failures are non-fatal
* the update row just renders without a size. Bounded to HEAD_TIMEOUT_MS so a slow
* mirror doesn't block the whole check.
*/
private async enrichWithSizes(updates: ResourceUpdateInfo[]): Promise<ResourceUpdateInfo[]> {
const HEAD_TIMEOUT_MS = 5000
return await Promise.all(
updates.map(async (update) => {
if (update.size_bytes) return update // Trust upstream if it already gave us one
try {
const head = await axios.head(update.download_url, {
timeout: HEAD_TIMEOUT_MS,
maxRedirects: 5,
validateStatus: (s) => s >= 200 && s < 400,
})
const len = Number(head.headers['content-length'])
return Number.isFinite(len) && len > 0 ? { ...update, size_bytes: len } : update
} catch {
return update
}
})
)
}
private buildFilename(update: ResourceUpdateInfo): string {
if (update.resource_type === 'zim') {
return `${update.resource_id}_${update.latest_version}.zim`

View File

@ -139,11 +139,17 @@ export class ContainerRegistryService {
allTags.push(...data.tags)
}
// Handle pagination via Link header
// Handle pagination via Link header. Per the OCI/Docker registry spec the next-page
// URL is relative (e.g. "/v2/<repo>/tags/list?last=<tag>&n=1000"), so it must be
// resolved against the registry origin before re-fetching — assigning the raw relative
// path to `url` makes fetch() throw "Failed to parse URL". This silently broke update
// checks for any repo with >1000 tags (e.g. ollama/ollama, filebrowser/filebrowser),
// which is the root cause of #945. new URL(relative, base) also passes absolute
// next-URLs through unchanged, so it's safe for registries that return those.
const linkHeader = response.headers.get('link')
if (linkHeader) {
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
url = match ? match[1] : ''
url = match ? new URL(match[1], `https://${parsed.registry}`).toString() : ''
} else {
url = ''
}
@ -200,6 +206,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

@ -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,308 @@
import { access, readFile, writeFile, mkdir } from 'fs/promises'
import { join, resolve } from 'path'
import { createHash } from 'crypto'
import { tmpdir } from 'os'
import logger from '@adonisjs/core/services/logger'
import type { Country, CountryCode, CountryGroup } from '../../types/maps.js'
interface NEFeature {
type: 'Feature'
properties: Record<string, any>
geometry: unknown
}
interface NEFeatureCollection {
type: 'FeatureCollection'
features: NEFeature[]
}
const COUNTRY_GEOJSON_PATH = join(
process.cwd(),
'resources',
'geodata',
'ne_50m_admin_0_countries.geojson'
)
// Natural Earth country polygons are land-only (no territorial waters), so a
// strict intersect leaves tiles fully over the ocean out of the extract —
// coastal cities render as grey off their coast. Inflate each polygon outward
// by ~11 km to pull in adjacent tiles without ballooning the extract size.
const REGION_BUFFER_DEGREES = 0.1
const GROUP_ORDER = [
'north-america',
'south-america',
'europe',
'africa',
'asia',
'oceania',
]
const GROUP_META: Record<string, { id: string; name: string; description: string }> = {
'North America': {
id: 'north-america',
name: 'North America',
description: 'All countries in North America and the Caribbean.',
},
'South America': {
id: 'south-america',
name: 'South America',
description: 'All countries in South America.',
},
Europe: {
id: 'europe',
name: 'Europe',
description: 'All countries in Europe.',
},
Africa: {
id: 'africa',
name: 'Africa',
description: 'All countries in Africa.',
},
Asia: {
id: 'asia',
name: 'Asia',
description: 'All countries in Asia.',
},
Oceania: {
id: 'oceania',
name: 'Oceania',
description: 'Australia, New Zealand, and Pacific island nations.',
},
}
export class CountriesService {
private static instance: CountriesService | null = null
private loadPromise: Promise<void> | null = null
private countries: Country[] = []
private byCode: Map<CountryCode, { country: Country; feature: NEFeature }> = new Map()
private groups: CountryGroup[] = []
static getInstance(): CountriesService {
if (!this.instance) {
this.instance = new CountriesService()
}
return this.instance
}
private async ensureLoaded(): Promise<void> {
if (this.byCode.size > 0) return
if (!this.loadPromise) {
this.loadPromise = this.load()
}
await this.loadPromise
}
private async load(): Promise<void> {
const raw = await readFile(COUNTRY_GEOJSON_PATH, 'utf8')
const fc = JSON.parse(raw) as NEFeatureCollection
// Natural Earth reuses a sovereign state's ISO_A2 for its dependencies
// (e.g. AU covers both Australia and Australian territories). Sort so the
// sovereign mainland wins the ISO-code slot, and skip any subsequent
// same-code dependency — otherwise the "AU" entry ends up being some tiny
// island territory.
const sortedFeatures = [...fc.features].sort((a, b) => typeRank(a) - typeRank(b))
const countries: Country[] = []
const byCode = new Map<CountryCode, { country: Country; feature: NEFeature }>()
const groupCodes: Record<string, CountryCode[]> = {}
for (const feature of sortedFeatures) {
const p = feature.properties
const code = resolveIso2(p)
if (!code) continue
if (byCode.has(code)) continue
const continent = typeof p.CONTINENT === 'string' ? p.CONTINENT : 'Other'
if (continent === 'Antarctica' || continent === 'Seven seas (open ocean)') continue
const country: Country = {
code,
code3: resolveIso3(p) ?? code,
name: typeof p.NAME === 'string' ? p.NAME : code,
continent,
subregion: typeof p.SUBREGION === 'string' ? p.SUBREGION : continent,
population: typeof p.POP_EST === 'number' ? p.POP_EST : 0,
}
countries.push(country)
byCode.set(code, { country, feature })
if (GROUP_META[continent]) {
const groupId = GROUP_META[continent].id
if (!groupCodes[groupId]) groupCodes[groupId] = []
groupCodes[groupId].push(code)
}
}
countries.sort((a, b) => a.name.localeCompare(b.name))
const groups: CountryGroup[] = GROUP_ORDER.flatMap((groupId) => {
const meta = Object.values(GROUP_META).find((m) => m.id === groupId)
if (!meta) return []
const codes = (groupCodes[groupId] ?? []).slice().sort()
if (codes.length === 0) return []
return [{ id: meta.id, name: meta.name, description: meta.description, countries: codes }]
})
this.countries = countries
this.byCode = byCode
this.groups = groups
logger.info(
`[CountriesService] Loaded ${countries.length} countries across ${groups.length} groups`
)
}
async list(): Promise<Country[]> {
await this.ensureLoaded()
return this.countries
}
async listGroups(): Promise<CountryGroup[]> {
await this.ensureLoaded()
return this.groups
}
/** Throws when a supplied code does not map to a known country. */
async resolveCodes(codes: CountryCode[]): Promise<CountryCode[]> {
await this.ensureLoaded()
const normalized = [...new Set(codes.map((c) => c.toUpperCase()))].sort()
const unknown = normalized.filter((c) => !this.byCode.has(c))
if (unknown.length > 0) {
throw new Error(`Unknown country code(s): ${unknown.join(', ')}`)
}
return normalized
}
/**
* Filename is keyed on a hash of the sorted ISO codes + buffer size so
* repeated calls with the same selection reuse the same path, and bumping
* the buffer auto-invalidates stale files.
*/
async writeRegionFile(codes: CountryCode[]): Promise<string> {
await this.ensureLoaded()
const resolved = await this.resolveCodes(codes)
const key = `b${REGION_BUFFER_DEGREES}:${resolved.join(',')}`
const hash = createHash('sha1').update(key).digest('hex').slice(0, 12)
const dir = resolve(tmpdir(), 'nomad-pmtiles-regions')
await mkdir(dir, { recursive: true })
const filepath = join(dir, `region-${hash}.geojson`)
try {
await access(filepath)
return filepath
} catch {}
const fc = {
type: 'FeatureCollection',
features: resolved.map((code) => {
const entry = this.byCode.get(code)!
return {
type: 'Feature',
properties: { iso: code, name: entry.country.name },
geometry: bufferGeometry(entry.feature.geometry, REGION_BUFFER_DEGREES),
}
}),
}
await writeFile(filepath, JSON.stringify(fc))
return filepath
}
}
function typeRank(f: NEFeature): number {
const t = typeof f.properties.TYPE === 'string' ? f.properties.TYPE : ''
if (t === 'Sovereign country') return 0
if (t === 'Country') return 1
if (t === 'Sovereignty') return 2
if (t === 'Disputed') return 3
if (t === 'Dependency') return 4
return 5
}
function resolveIso2(p: Record<string, any>): CountryCode | null {
// Natural Earth's ISO_A2 sometimes holds political escapes like "CN-TW" for
// Taiwan or "-99" for countries involved in disputes. Only accept clean
// 2-letter codes; fall back to ISO_A2_EH (which reliably has the real code).
const primary = typeof p.ISO_A2 === 'string' ? p.ISO_A2 : null
if (primary && /^[A-Z]{2}$/i.test(primary)) return primary.toUpperCase()
const fallback = typeof p.ISO_A2_EH === 'string' ? p.ISO_A2_EH : null
if (fallback && /^[A-Z]{2}$/i.test(fallback)) return fallback.toUpperCase()
return null
}
/**
* Inflate each polygon ring outward by `buffer` degrees via per-vertex
* averaged-normal offset. Not geodesically accurate but at small buffers
* (<= 0.2°) it's within a few percent of a proper geodesic buffer at
* country scale, which is plenty for tile-inclusion purposes.
*/
function bufferGeometry(geometry: unknown, buffer: number): unknown {
const geom = geometry as { type: string; coordinates: any }
if (geom?.type === 'Polygon') {
return { type: 'Polygon', coordinates: bufferPolygonRings(geom.coordinates, buffer) }
}
if (geom?.type === 'MultiPolygon') {
return {
type: 'MultiPolygon',
coordinates: geom.coordinates.map((poly: number[][][]) =>
bufferPolygonRings(poly, buffer)
),
}
}
return geometry
}
function bufferPolygonRings(rings: number[][][], buffer: number): number[][][] {
return rings.map((ring) => bufferRing(ring, buffer))
}
function bufferRing(ring: number[][], buffer: number): number[][] {
if (ring.length < 4) return ring
const sign = signedArea(ring) > 0 ? 1 : -1
const n = ring.length - 1
const out: number[][] = []
for (let i = 0; i < n; i++) {
const prev = ring[(i - 1 + n) % n]
const curr = ring[i]
const next = ring[(i + 1) % n]
const e1x = curr[0] - prev[0]
const e1y = curr[1] - prev[1]
const e2x = next[0] - curr[0]
const e2y = next[1] - curr[1]
const l1 = Math.hypot(e1x, e1y) || 1
const l2 = Math.hypot(e2x, e2y) || 1
const n1x = (e1y / l1) * sign
const n1y = (-e1x / l1) * sign
const n2x = (e2y / l2) * sign
const n2y = (-e2x / l2) * sign
const sumX = n1x + n2x
const sumY = n1y + n2y
const sl = Math.hypot(sumX, sumY) || 1
out.push([curr[0] + (sumX / sl) * buffer, curr[1] + (sumY / sl) * buffer])
}
out.push(out[0])
return out
}
function signedArea(ring: number[][]): number {
let a = 0
for (let i = 0; i < ring.length - 1; i++) {
a += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]
}
return a / 2
}
function resolveIso3(p: Record<string, any>): string | null {
const primary = typeof p.ISO_A3 === 'string' ? p.ISO_A3 : null
if (primary && primary !== '-99') return primary.toUpperCase()
const fallback = typeof p.ISO_A3_EH === 'string' ? p.ISO_A3_EH : null
if (fallback && fallback !== '-99') return fallback.toUpperCase()
const adm = typeof p.ADM0_A3 === 'string' ? p.ADM0_A3 : null
if (adm && adm !== '-99') return adm.toUpperCase()
return null
}

View File

@ -0,0 +1,167 @@
import { dirname, normalize } from 'node:path'
import env from '#start/env'
/**
* Security guardrails for user-defined ("custom app") containers.
*
* project-nomad runs containers as host siblings via the mounted Docker socket (DooD), so a
* misconfigured bind mount or image is a real host-takeover vector. The posture here is
* "guardrails with warnings": hard-block the genuinely catastrophic, warn-but-allow the merely
* risky so a trusted admin keeps their power without an easy foot-gun.
*/
export interface GuardEvaluation {
/** Hard rejections — the install cannot proceed until these are fixed. */
blocked: string[]
/** Advisory warnings — overridable via the "install anyway" force flag. */
warnings: string[]
}
/** Absolute host directories that must never be bind-mounted into a custom container. */
const SYSTEM_BLOCK_PREFIXES = ['/etc', '/proc', '/sys', '/boot', '/dev', '/run', '/var/run']
/** Registries we ship curated apps from; anything else is allowed but warned on. */
const TRUSTED_REGISTRIES = ['docker.io', 'registry-1.docker.io', 'ghcr.io', 'lscr.io', 'quay.io']
/** Resolve the managed storage root (where bind mounts are expected to live). */
export function getStorageRoot(): string {
return normalize(env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')).replace(/\/+$/, '')
}
/** Normalize an absolute path: collapse `..`/`.` segments and strip any trailing slash. */
function normalizeHostPath(p: string): string {
return normalize(p).replace(/\/+$/, '') || '/'
}
/** True when `child` equals `ancestor` or sits beneath it. */
function isWithin(child: string, ancestor: string): boolean {
return child === ancestor || child.startsWith(ancestor + '/')
}
/**
* Evaluate user-supplied bind mounts. Hard-blocks the Docker socket, core system directories,
* and any mount at or above project-nomad's own install tree (which would expose its code/data).
* Warns on any host path outside the managed storage root.
*/
export function evaluateBindMounts(
volumes: { host_path: string; container_path: string }[]
): GuardEvaluation {
const blocked: string[] = []
const warnings: string[] = []
const storageRoot = getStorageRoot()
// The install tree is the parent of the storage root (e.g. /opt/project-nomad). Mounting it —
// or any ancestor, up to and including `/` — would hand a container project-nomad's own files.
const installRoot = dirname(storageRoot)
for (const { host_path: hostPath, container_path: containerPath } of volumes) {
const host = normalizeHostPath(hostPath)
if (!hostPath.startsWith('/')) {
blocked.push(`Volume host path "${hostPath}" must be an absolute path.`)
continue
}
if (!containerPath.startsWith('/')) {
blocked.push(`Volume container path "${containerPath}" must be an absolute path.`)
continue
}
// A colon is Docker's bind delimiter (host:container:options). A path containing one would be
// re-split by Docker into a different mount than the one validated here — reject it outright so
// the checks below can't be bypassed by a parse-differential. (The validator blocks this too;
// this keeps the guard self-defending for any caller that skips validation.)
if (hostPath.includes(':') || containerPath.includes(':')) {
blocked.push(`Volume paths must not contain a colon (":"): "${hostPath}" → "${containerPath}".`)
continue
}
// The Docker socket is the most dangerous mount of all — full control of the host daemon.
if (host.endsWith('docker.sock') || /\/docker\.sock$/.test(host)) {
blocked.push(
`Mounting the Docker socket ("${hostPath}") is not allowed — it grants full host control.`
)
continue
}
// Core system directories.
if (host === '/' || SYSTEM_BLOCK_PREFIXES.some((p) => isWithin(host, p))) {
blocked.push(`Mounting system directory "${hostPath}" is not allowed.`)
continue
}
// At or above project-nomad's own install tree (covers `/`, `/opt`, `/opt/project-nomad`).
if (host === installRoot || isWithin(installRoot, host)) {
blocked.push(
`Mounting "${hostPath}" would expose project-nomad's own files and is not allowed.`
)
continue
}
// Anything outside the managed storage root is allowed but flagged.
if (!isWithin(host, storageRoot)) {
warnings.push(
`Volume "${hostPath}" is outside the managed storage root (${storageRoot}). Make sure you trust this image with access to that path.`
)
}
}
return { blocked, warnings }
}
/**
* Evaluate a Docker image reference. Hard-blocks malformed references; warns on moving tags
* (`:latest`/untagged) and images from registries outside the trusted set.
*/
export function evaluateImageReference(image: string): GuardEvaluation {
const blocked: string[] = []
const warnings: string[] = []
const ref = image.trim()
// Loose validity check: no whitespace/control chars, and a sane character set for an image ref.
if (!ref || /\s/.test(ref) || !/^[\w./:@-]+$/.test(ref)) {
blocked.push(`"${image}" is not a valid image reference.`)
return { blocked, warnings }
}
// Split off any digest, then any tag, to inspect the registry and tag.
const [nameAndTag] = ref.split('@')
const firstSegment = nameAndTag.split('/')[0]
const hasRegistryHost =
nameAndTag.includes('/') && (firstSegment.includes('.') || firstSegment.includes(':'))
const registry = hasRegistryHost ? firstSegment.split(':')[0] : 'docker.io'
if (!TRUSTED_REGISTRIES.includes(registry)) {
warnings.push(
`Image is from "${registry}", which is outside project-nomad's trusted registries. Only install images you trust.`
)
}
// Determine the tag (ignore a colon that's part of a registry host:port in the first segment).
const remainder = hasRegistryHost ? nameAndTag.slice(firstSegment.length + 1) : nameAndTag
const tag = remainder.includes(':') ? remainder.split(':').pop() : undefined
const hasDigest = ref.includes('@sha256:')
if (!hasDigest && (!tag || tag === 'latest')) {
warnings.push(
`Image "${image}" uses a moving tag (${tag ? ':latest' : 'no tag'}). Pin a specific version for reproducible installs.`
)
}
return { blocked, warnings }
}
/** Combine bind-mount and image evaluations into a single result. */
export function evaluateCustomApp(input: {
image?: string
volumes?: { host_path: string; container_path: string }[]
}): GuardEvaluation {
const bind = evaluateBindMounts(input.volumes ?? [])
const img = input.image ? evaluateImageReference(input.image) : { blocked: [], warnings: [] }
return {
blocked: [...bind.blocked, ...img.blocked],
warnings: [...bind.warnings, ...img.warnings],
}
}
/** Default resource caps applied to custom containers unless the user overrides them. */
export const DEFAULT_MEMORY_MB = 1024
export const DEFAULT_CPUS = 1

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,12 @@ export class DocsService {
'home': 1,
'getting-started': 2,
'use-cases': 3,
'faq': 4,
'about': 5,
'release-notes': 6,
'supply-depot-apps': 4,
'community-add-ons': 5,
'updates': 6,
'faq': 7,
'about': 8,
'release-notes': 9,
}
async getDocs() {
@ -91,6 +94,7 @@ export class DocsService {
private static readonly TITLE_OVERRIDES: Record<string, string> = {
'faq': 'FAQ',
'community-add-ons': 'Community Add-Ons',
}
private prettify(filename: string) {

View File

@ -1,10 +1,18 @@
import { inject } from '@adonisjs/core'
import { QueueService } from './queue_service.js'
import { RunDownloadJob } from '#jobs/run_download_job'
import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job'
import type { RunExtractPmtilesJobParams } from '#jobs/run_extract_pmtiles_job'
import { DownloadModelJob } from '#jobs/download_model_job'
import { DownloadJobWithProgress, DownloadProgressData, RunDownloadJobParams } from '../../types/downloads.js'
import type { Job, Queue } from 'bullmq'
import { normalize } from 'path'
import { deleteFileIfExists } from '../utils/fs.js'
import transmit from '@adonisjs/transmit/services/main'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
type FileJobState = 'waiting' | 'active' | 'delayed' | 'failed'
type TaggedJob = { job: Job; state: FileJobState }
@inject()
export class DownloadService {
@ -24,27 +32,32 @@ export class DownloadService {
return { percent: parseInt(String(progress), 10) || 0 }
}
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
// Get regular file download jobs (zim, map, etc.) — query each state separately so we can
// tag each job with its actual BullMQ state rather than guessing from progress data.
const queue = this.queueService.getQueue(RunDownloadJob.queue)
type FileJobState = 'waiting' | 'active' | 'delayed' | 'failed'
const [waitingJobs, activeJobs, delayedJobs, failedJobs] = await Promise.all([
/** Fetch all non-completed jobs from a queue, tagged with their current BullMQ state */
private async fetchJobsWithStates(queueName: string): Promise<TaggedJob[]> {
const queue = this.queueService.getQueue(queueName)
const [waiting, active, delayed, failed] = await Promise.all([
queue.getJobs(['waiting']),
queue.getJobs(['active']),
queue.getJobs(['delayed']),
queue.getJobs(['failed']),
])
const taggedFileJobs: Array<{ job: (typeof waitingJobs)[0]; state: FileJobState }> = [
...waitingJobs.map((j) => ({ job: j, state: 'waiting' as const })),
...activeJobs.map((j) => ({ job: j, state: 'active' as const })),
...delayedJobs.map((j) => ({ job: j, state: 'delayed' as const })),
...failedJobs.map((j) => ({ job: j, state: 'failed' as const })),
return [
...waiting.map((j) => ({ job: j, state: 'waiting' as const })),
...active.map((j) => ({ job: j, state: 'active' as const })),
...delayed.map((j) => ({ job: j, state: 'delayed' as const })),
...failed.map((j) => ({ job: j, state: 'failed' as const })),
]
}
const fileDownloads = taggedFileJobs.map(({ job, state }) => {
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const [fileTagged, extractTagged, modelJobs] = await Promise.all([
this.fetchJobsWithStates(RunDownloadJob.queue),
this.fetchJobsWithStates(RunExtractPmtilesJob.queue),
modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed']),
])
const fileDownloads = fileTagged.map(({ job, state }) => {
const parsed = this.parseProgress(job.progress)
return {
jobId: job.id!.toString(),
@ -61,26 +74,36 @@ export class DownloadService {
}
})
// Get Ollama model download jobs
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed'])
const extractDownloads = extractTagged.map(({ job, state }) => {
const parsed = this.parseProgress(job.progress)
return {
jobId: job.id!.toString(),
url: job.data.sourceUrl,
progress: parsed.percent,
filepath: normalize(job.data.outputFilepath),
filetype: job.data.filetype || 'map',
title: job.data.title || undefined,
downloadedBytes: parsed.downloadedBytes,
totalBytes: parsed.totalBytes || job.data.estimatedBytes || undefined,
lastProgressTime: parsed.lastProgressTime,
status: state,
failedReason: job.failedReason || undefined,
}
})
const modelDownloads = modelJobs.map((job) => ({
jobId: job.id!.toString(),
url: job.data.modelName || 'Unknown Model', // Use model name as url
url: job.data.modelName || 'Unknown Model',
progress: parseInt(job.progress.toString(), 10),
filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath
filepath: job.data.modelName || 'Unknown Model',
filetype: 'model',
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
failedReason: job.failedReason || undefined,
}))
const allDownloads = [...fileDownloads, ...modelDownloads]
// Filter by filetype if specified
const allDownloads = [...fileDownloads, ...extractDownloads, ...modelDownloads]
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
// Sort: active downloads first (by progress desc), then failed at the bottom
return filtered.sort((a, b) => {
if (a.status === 'failed' && b.status !== 'failed') return 1
if (a.status !== 'failed' && b.status === 'failed') return -1
@ -89,7 +112,11 @@ export class DownloadService {
}
async removeFailedJob(jobId: string): Promise<void> {
for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {
for (const queueName of [
RunDownloadJob.queue,
RunExtractPmtilesJob.queue,
DownloadModelJob.queue,
]) {
const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId)
if (job) {
@ -148,11 +175,60 @@ export class DownloadService {
const queue = this.queueService.getQueue(RunDownloadJob.queue)
const job = await queue.getJob(jobId)
if (!job) {
// Job already completed (removeOnComplete: true) or doesn't exist
return { success: true, message: 'Job not found (may have already completed)' }
if (job) {
return await this._cancelFileDownloadJob(jobId, job, queue)
}
const extractQueue = this.queueService.getQueue(RunExtractPmtilesJob.queue)
const extractJob = await extractQueue.getJob(jobId)
if (extractJob) {
return await this._cancelExtractJob(jobId, extractJob, extractQueue)
}
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const modelJob = await modelQueue.getJob(jobId)
if (modelJob) {
return await this._cancelModelDownloadJob(jobId, modelJob, modelQueue)
}
return { success: true, message: 'Job not found (may have already completed)' }
}
private async _cancelExtractJob(
jobId: string,
job: Job<RunExtractPmtilesJobParams>,
queue: Queue<RunExtractPmtilesJobParams>
): Promise<{ success: boolean; message: string }> {
const outputFilepath = job.data.outputFilepath
await RunExtractPmtilesJob.signalCancel(jobId)
// Same-process fallback when worker and API share a process
RunExtractPmtilesJob.childProcesses.get(jobId)?.kill('SIGTERM')
RunExtractPmtilesJob.childProcesses.delete(jobId)
await this._pollForTerminalState(job, jobId)
await this._removeJobWithLockFallback(job, queue, RunExtractPmtilesJob.queue, jobId)
if (outputFilepath) {
try {
await deleteFileIfExists(outputFilepath)
} catch {
// File may not exist yet (subprocess may not have opened it)
}
}
return { success: true, message: 'Extract cancelled and partial file deleted' }
}
/** Cancel a content download (zim, map, pmtiles, etc.) */
private async _cancelFileDownloadJob(
jobId: string,
job: any,
queue: any
): Promise<{ success: boolean; message: string }> {
const filepath = job.data.filepath
// Signal the worker process to abort the download via Redis
@ -162,45 +238,8 @@ export class DownloadService {
RunDownloadJob.abortControllers.get(jobId)?.abort('user-cancel')
RunDownloadJob.abortControllers.delete(jobId)
// Poll for terminal state (up to 4s at 250ms intervals) — cooperates with BullMQ's lifecycle
// instead of force-removing an active job and losing the worker's failure/cleanup path.
const POLL_INTERVAL_MS = 250
const POLL_TIMEOUT_MS = 4000
const deadline = Date.now() + POLL_TIMEOUT_MS
let reachedTerminal = false
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
try {
const state = await job.getState()
if (state === 'failed' || state === 'completed' || state === 'unknown') {
reachedTerminal = true
break
}
} catch {
reachedTerminal = true // getState() throws if job is already gone
break
}
}
if (!reachedTerminal) {
console.warn(`[DownloadService] cancelJob: job ${jobId} did not reach terminal state within timeout, removing anyway`)
}
// Remove the BullMQ job
try {
await job.remove()
} catch {
// Lock contention fallback: clear lock and retry once
try {
const client = await queue.client
await client.del(`bull:${RunDownloadJob.queue}:${jobId}:lock`)
const updatedJob = await queue.getJob(jobId)
if (updatedJob) await updatedJob.remove()
} catch {
// Best effort - job will be cleaned up on next dismiss attempt
}
}
await this._pollForTerminalState(job, jobId)
await this._removeJobWithLockFallback(job, queue, RunDownloadJob.queue, jobId)
// Delete the partial file from disk
if (filepath) {
@ -229,4 +268,87 @@ export class DownloadService {
return { success: true, message: 'Download cancelled and partial file deleted' }
}
/** Cancel an Ollama model download — mirrors the file cancel pattern but skips file cleanup */
private async _cancelModelDownloadJob(
jobId: string,
job: any,
queue: any
): Promise<{ success: boolean; message: string }> {
const modelName: string = job.data?.modelName ?? 'unknown'
// Signal the worker process to abort the pull via Redis
await DownloadModelJob.signalCancel(jobId)
// Also try in-memory abort (works if worker is in same process)
DownloadModelJob.abortControllers.get(jobId)?.abort('user-cancel')
DownloadModelJob.abortControllers.delete(jobId)
await this._pollForTerminalState(job, jobId)
await this._removeJobWithLockFallback(job, queue, DownloadModelJob.queue, jobId)
// Broadcast a cancelled event so the frontend hook clears the entry. We use percent: -2
// (distinct from -1 = error) so the hook can route it to a 2s auto-clear instead of the
// 15s error display. The frontend ALSO removes the entry optimistically from the API
// response, so this is belt-and-suspenders for cases where the SSE arrives first.
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
model: modelName,
jobId,
percent: -2,
status: 'cancelled',
timestamp: new Date().toISOString(),
})
// Note on partial blob cleanup: Ollama manages model blobs internally at
// /root/.ollama/models/blobs/. We deliberately do NOT call /api/delete here — Ollama's
// expected behavior is to retain partial blobs so a re-pull resumes from where it left
// off. If the user wants to reclaim that space, they can re-pull and let it complete,
// or delete the partially-downloaded model from the AI Settings page.
return { success: true, message: 'Model download cancelled' }
}
/** Wait up to 4s (250ms intervals) for the job to reach a terminal state */
private async _pollForTerminalState(job: any, jobId: string): Promise<void> {
const POLL_INTERVAL_MS = 250
const POLL_TIMEOUT_MS = 4000
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
try {
const state = await job.getState()
if (state === 'failed' || state === 'completed' || state === 'unknown') {
return
}
} catch {
return // getState() throws if job is already gone
}
}
console.warn(
`[DownloadService] cancelJob: job ${jobId} did not reach terminal state within timeout, removing anyway`
)
}
/** Remove a BullMQ job, clearing a stale worker lock if the first attempt fails */
private async _removeJobWithLockFallback(
job: any,
queue: any,
queueName: string,
jobId: string
): Promise<void> {
try {
await job.remove()
} catch {
// Lock contention fallback: clear lock and retry once
try {
const client = await queue.client
await client.del(`bull:${queueName}:${jobId}:lock`)
const updatedJob = await queue.getJob(jobId)
if (updatedJob) await updatedJob.remove()
} catch {
// Best effort - job will be cleaned up on next dismiss attempt
}
}
}
}

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

@ -2,7 +2,7 @@ import { XMLBuilder, XMLParser } from 'fast-xml-parser'
import { readFile, writeFile, rename, readdir } from 'fs/promises'
import { join } from 'path'
import { Archive } from '@openzim/libzim'
import { KIWIX_LIBRARY_XML_PATH, ZIM_STORAGE_PATH, ensureDirectoryExists } from '../utils/fs.js'
import { KIWIX_LIBRARY_XML_PATH, ZIM_STORAGE_PATH, ensureDirectoryExists, isValidZimFile } from '../utils/fs.js'
import logger from '@adonisjs/core/services/logger'
import { randomUUID } from 'node:crypto'
@ -54,8 +54,12 @@ export class KiwixLibraryService {
*
* Returns null on any error so callers can fall back gracefully.
*/
private _readZimMetadata(zimFilePath: string): Partial<KiwixBook> | null {
private async _readZimMetadata(zimFilePath: string): Promise<Partial<KiwixBook> | null> {
try {
if (!(await isValidZimFile(zimFilePath))) {
logger.warn(`[KiwixLibraryService] Skipping invalid/corrupted ZIM file: ${zimFilePath}`)
return null
}
const archive = new Archive(zimFilePath)
const getMeta = (key: string): string | undefined => {
@ -183,7 +187,71 @@ export class KiwixLibraryService {
.filter((b) => b.id && b.path)
}
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<void> {
/**
* Returns the number of books currently listed in the library XML, or 0 if the
* file doesn't exist yet. Used to report a before/after delta on a manual rescan.
*/
async getBookCount(): Promise<number> {
try {
const content = await readFile(this.getLibraryFilePath(), 'utf-8')
return this._parseExistingBooks(content).length
} catch (err: any) {
if (err.code === 'ENOENT') return 0
throw err
}
}
/**
* True if the library XML parses and has a <library> root. A truncated or
* corrupt file (e.g. an interrupted write) fails this even though it exists,
* so the caller can rebuild rather than leave Kiwix serving a broken library.
* An empty-but-well-formed library is considered valid (nothing to repair).
*/
private _isValidLibraryXml(xmlContent: string): boolean {
try {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (name) => name === 'book',
})
const parsed = parser.parse(xmlContent)
return parsed?.library !== undefined && parsed?.library !== null
} catch {
return false
}
}
/**
* Boot-time safety net: if the library XML is missing or unparseable, rebuild
* it from the ZIM files on disk so Kiwix (running in library mode with
* --monitorLibrary) doesn't come up serving an empty/broken library with no
* path to recovery. This covers files lost or corrupted outside the normal
* download flow (storage relocation, interrupted write, manual deletion).
*
* Returns true if a rebuild was performed. Filesystem errors other than
* "not found" are surfaced rather than masked by a rebuild.
*/
async ensureLibraryXmlHealthy(): Promise<boolean> {
let content: string
try {
content = await readFile(this.getLibraryFilePath(), 'utf-8')
} catch (err: any) {
if (err?.code === 'ENOENT') {
logger.warn('[KiwixLibraryService] Library XML missing on startup; rebuilding from disk.')
await this.rebuildFromDisk()
return true
}
throw err
}
if (this._isValidLibraryXml(content)) return false
logger.warn('[KiwixLibraryService] Library XML present but invalid; rebuilding from disk.')
await this.rebuildFromDisk()
return true
}
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<number> {
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
await ensureDirectoryExists(dirPath)
@ -197,21 +265,27 @@ export class KiwixLibraryService {
const excludeSet = new Set(opts?.excludeFilenames ?? [])
const zimFiles = entries.filter((name) => name.endsWith('.zim') && !excludeSet.has(name))
const books: KiwixBook[] = zimFiles.map((filename) => {
const meta = this._readZimMetadata(join(dirPath, filename))
const books: KiwixBook[] = []
for (const filename of zimFiles) {
const meta = await this._readZimMetadata(join(dirPath, filename))
if (meta === null) {
logger.warn(`[KiwixLibraryService] Skipping unreadable ZIM file: ${filename}`)
continue
}
const containerPath = `${CONTAINER_DATA_PATH}/${filename}`
return {
books.push({
...meta,
// Override fields that must be derived locally, not from ZIM metadata
id: meta?.id ?? filename.slice(0, -4),
path: containerPath,
title: meta?.title ?? this._filenameToTitle(filename),
}
})
})
}
const xml = this._buildXml(books)
await this._atomicWrite(xml)
logger.info(`[KiwixLibraryService] Rebuilt library XML with ${books.length} book(s).`)
return books.length
}
async addBook(filename: string): Promise<void> {
@ -239,7 +313,12 @@ export class KiwixLibraryService {
}
const fullPath = join(process.cwd(), ZIM_STORAGE_PATH, zimFilename)
const meta = this._readZimMetadata(fullPath)
const meta = await this._readZimMetadata(fullPath)
if (meta === null) {
logger.error(`[KiwixLibraryService] Cannot add ${zimFilename}: file is invalid or corrupted.`)
return
}
existingBooks.push({
...meta,

View File

@ -16,10 +16,36 @@ import {
import { join, resolve, sep } from 'path'
import urlJoin from 'url-join'
import { RunDownloadJob } from '#jobs/run_download_job'
import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job'
import logger from '@adonisjs/core/services/logger'
import { assertNotPrivateUrl } from '#validators/common'
import InstalledResource from '#models/installed_resource'
import { CollectionManifestService } from './collection_manifest_service.js'
import { decideSupersededDeletion } from '../utils/superseded_resource.js'
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps.js'
import {
EXTRACT_DEFAULT_MAX_ZOOM,
EXTRACT_MAX_ZOOM,
EXTRACT_MIN_ZOOM,
PMTILES_BINARY_PATH,
WORLD_BASEMAP_FILENAME,
WORLD_BASEMAP_MAX_ZOOM,
WORLD_BASEMAP_SOURCE_NAME,
buildPmtilesExtractArgs,
} from '../../constants/map_regions.js'
import { CountriesService } from './countries_service.js'
import { execFile } from 'child_process'
import { createHash, randomBytes } from 'crypto'
import { tmpdir } from 'os'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const DRY_RUN_TIMEOUT_MS = 60_000
const DRY_RUN_MAX_BUFFER = 256 * 1024
// Real extract of z0-5 world tiles; generous to tolerate slow/metered links
// since a failure leaves the map grey for uncovered regions.
const WORLD_BASEMAP_EXTRACT_TIMEOUT_MS = 5 * 60_000
const PROTOMAPS_BUILDS_METADATA_URL = 'https://build-metadata.protomaps.dev/builds.json'
const PROTOMAPS_BUILD_BASE_URL = 'https://build.protomaps.com'
@ -52,10 +78,15 @@ export class MapService implements IMapService {
private readonly baseAssetsTarFile = 'base-assets.tar.gz'
private readonly baseDirPath = join(process.cwd(), this.mapStoragePath)
private baseAssetsExistCache: boolean | null = null
private worldBasemapReady = false
private worldBasemapInFlight: Promise<void> | null = null
async listRegions() {
const files = (await this.listAllMapStorageItems()).filter(
(item) => item.type === 'file' && item.name.endsWith('.pmtiles')
(item) =>
item.type === 'file' &&
item.name.endsWith('.pmtiles') &&
item.name !== WORLD_BASEMAP_FILENAME
)
return {
@ -119,6 +150,13 @@ export class MapService implements IMapService {
const downloadFilenames: string[] = []
for (const resource of toDownload) {
try {
assertNotPrivateUrl(resource.url)
} catch {
logger.warn(`[MapService] Blocked download from private/loopback URL: ${resource.url}`)
continue
}
const existing = await RunDownloadJob.getActiveByUrl(resource.url)
if (existing) {
logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)
@ -162,10 +200,18 @@ export class MapService implements IMapService {
const parsed = CollectionManifestService.parseMapFilename(filename)
if (!parsed) continue
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
const pmtilesDir = join(process.cwd(), this.mapStoragePath, 'pmtiles')
const filepath = join(pmtilesDir, filename)
const stats = await getFileStatsIfExists(filepath)
try {
// Capture the prior install for this resource_id before updateOrCreate
// overwrites it, so we know the old file to clean up (#634).
const prior = await InstalledResource.query()
.where('resource_id', parsed.resource_id)
.where('resource_type', 'map')
.first()
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'map' },
@ -178,6 +224,31 @@ export class MapService implements IMapService {
}
)
logger.info(`[MapService] Created InstalledResource entry for: ${parsed.resource_id}`)
// Remove the superseded prior version's pmtiles file if every safety
// rail passes (see decideSupersededDeletion). Maps have no library index,
// so a direct delete of the recorded old file is sufficient.
const decision = decideSupersededDeletion({
existing: prior ? { file_path: prior.file_path, version: prior.version } : null,
newFilePath: filepath,
newVersion: parsed.version,
newFileExists: !!stats,
storageBaseDir: pmtilesDir,
})
if (decision.delete && decision.path) {
try {
await deleteFileIfExists(decision.path)
logger.info(
`[MapService] Removed superseded ${parsed.resource_id} file: ${decision.path}`
)
} catch (err) {
logger.warn(`[MapService] Failed to remove superseded file ${decision.path}:`, err)
}
} else if (decision.reason !== 'first_install' && decision.reason !== 'same_file') {
logger.info(
`[MapService] Kept prior ${parsed.resource_id} file (reason: ${decision.reason})`
)
}
} catch (error) {
logger.error(`[MapService] Failed to create InstalledResource for ${filename}:`, error)
}
@ -244,6 +315,7 @@ export class MapService implements IMapService {
url: string
): Promise<{ filename: string; size: number } | { message: string }> {
try {
assertNotPrivateUrl(url)
const parsed = new URL(url)
if (!parsed.pathname.endsWith('.pmtiles')) {
throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)
@ -263,11 +335,12 @@ export class MapService implements IMapService {
}
const contentLength = response.headers['content-length']
const size = contentLength ? parseInt(contentLength, 10) : 0
const size = contentLength ? parseInt(contentLength.toString(), 10) : 0
return { filename, size }
} catch (error: any) {
return { message: `Preflight check failed: ${error.message}` }
logger.error({ err: error }, '[MapService] Preflight check failed for URL')
return { message: 'Preflight check failed. Please verify the URL is valid and accessible.' }
}
}
@ -317,11 +390,76 @@ export class MapService implements IMapService {
async ensureBaseAssets(): Promise<boolean> {
const exists = await this.checkBaseAssetsExist()
if (exists) {
return true
if (!exists) {
const downloaded = await this.downloadBaseAssets()
if (!downloaded) return false
}
return await this.downloadBaseAssets()
try {
await this.ensureWorldBasemap()
} catch (err) {
logger.warn(`[MapService] World basemap setup failed, continuing without it: ${err}`)
}
return true
}
/**
* Extract a low-zoom global basemap once so the map isn't grey outside a
* regional extract's polygon. Cheap (~15 MB, a handful of HTTP range
* requests) and layered underneath regional sources at render time.
*
* Memoizes success in-process, and de-duplicates concurrent callers via a
* shared in-flight promise so two simultaneous `/maps` requests on a cold
* start don't both launch `pmtiles extract` against the same output path.
*/
private async ensureWorldBasemap(): Promise<void> {
if (this.worldBasemapReady) return
if (this.worldBasemapInFlight) return this.worldBasemapInFlight
this.worldBasemapInFlight = this._setupWorldBasemap().finally(() => {
this.worldBasemapInFlight = null
})
return this.worldBasemapInFlight
}
private async _setupWorldBasemap(): Promise<void> {
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const filepath = resolve(join(basePath, WORLD_BASEMAP_FILENAME))
if (!filepath.startsWith(basePath + sep)) {
throw new Error('Invalid world basemap path')
}
await ensureDirectoryExists(basePath)
const existing = await getFileStatsIfExists(filepath)
if (existing && Number(existing.size) > 0) {
this.worldBasemapReady = true
return
}
const info = await this.getGlobalMapInfo()
const args = buildPmtilesExtractArgs({
sourceUrl: info.url,
outputFilepath: filepath,
maxzoom: WORLD_BASEMAP_MAX_ZOOM,
downloadThreads: 4,
})
logger.info(
`[MapService] Extracting world basemap (z0-${WORLD_BASEMAP_MAX_ZOOM}) from ${info.url}`
)
try {
await execFileAsync(PMTILES_BINARY_PATH, args, {
timeout: WORLD_BASEMAP_EXTRACT_TIMEOUT_MS,
maxBuffer: DRY_RUN_MAX_BUFFER,
})
this.worldBasemapReady = true
} catch (err: any) {
await deleteFileIfExists(filepath)
throw new Error(
`pmtiles extract for world basemap failed: ${err.message}. stderr: ${err.stderr ?? ''}`
)
}
}
private async checkBaseAssetsExist(useCache: boolean = true): Promise<boolean> {
@ -353,27 +491,76 @@ export class MapService implements IMapService {
return await listDirectoryContentsRecursive(this.baseDirPath)
}
/**
* Compare two map-file versions (YYYY-MM, or null for an undated legacy file). Returns >0 if
* `a` is newer than `b`, <0 if older, 0 if equal. A dated build is always newer than an undated
* legacy file; two dated builds compare lexicographically (correct for zero-padded YYYY-MM).
*/
private static compareMapVersions(a: string | null, b: string | null): number {
if (a === b) return 0
if (a === null) return -1
if (b === null) return 1
return a < b ? -1 : 1
}
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
const sources: BaseStylesFile['sources'][] = []
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
// World basemap goes first so its layers render underneath regional extracts.
// Only emitted when ensureWorldBasemap() succeeded — otherwise the style would
// reference a file that doesn't exist and produce 404s on every tile request.
if (this.worldBasemapReady) {
const worldSource: BaseStylesFile['sources'] = {}
worldSource[WORLD_BASEMAP_SOURCE_NAME] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://${urlJoin(baseUrl, WORLD_BASEMAP_FILENAME)}`,
}
sources.push(worldSource)
}
// Dedupe by region name, keeping only the newest file per region. The source name is the
// date-stripped region (e.g. both "washington.pmtiles" and "washington_2025-12.pmtiles" map
// to "washington"). Emitting both produces duplicate source keys and duplicate layer ids,
// which MapLibre rejects outright — blanking the ENTIRE map, not just that region. Old copies
// linger when a newer curated version installs (#634), so guard against it here so the style
// stays valid even if cleanup hasn't run. A dated build beats an undated legacy file; between
// two dated builds the later YYYY-MM wins (lexicographic compare is correct for that format).
const bestByRegion = new Map<string, { region: FileEntry; version: string | null }>()
for (const region of regions) {
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
// Strip .pmtiles and date suffix (e.g. "alaska_2025-12" -> "alaska") for stable source names
const parsed = CollectionManifestService.parseMapFilename(region.name)
const regionName = parsed ? parsed.resource_id : region.name.replace('.pmtiles', '')
const source: BaseStylesFile['sources'] = {}
const sourceUrl = urlJoin(baseUrl, region.name)
source[regionName] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://${sourceUrl}`,
const version = parsed?.version ?? null
const existing = bestByRegion.get(regionName)
if (!existing || MapService.compareMapVersions(version, existing.version) > 0) {
if (existing) {
logger.warn(
`[MapService] Duplicate map region "${regionName}": using "${region.name}" over "${existing.region.name}" (keeping newest)`
)
}
bestByRegion.set(regionName, { region, version })
} else {
logger.warn(
`[MapService] Duplicate map region "${regionName}": skipping "${region.name}" in favor of "${existing.region.name}" (keeping newest)`
)
}
sources.push(source)
}
}
for (const [regionName, { region }] of bestByRegion) {
const source: BaseStylesFile['sources'] = {}
const sourceUrl = urlJoin(baseUrl, region.name)
source[regionName] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://${sourceUrl}`,
}
sources.push(source)
}
return sources
}
@ -479,12 +666,206 @@ export class MapService implements IMapService {
}
}
async listCountries(): Promise<Country[]> {
return CountriesService.getInstance().list()
}
async listCountryGroups(): Promise<CountryGroup[]> {
return CountriesService.getInstance().listGroups()
}
async extractPreflight(params: {
countries: CountryCode[]
maxzoom?: number
}): Promise<MapExtractPreflight> {
this.validateMaxzoom(params.maxzoom)
const countries = await CountriesService.getInstance().resolveCodes(params.countries)
const regionFilepath = await CountriesService.getInstance().writeRegionFile(countries)
const info = await this.getGlobalMapInfo()
return this.runDryRun(info, regionFilepath, params.maxzoom)
}
private async runDryRun(
info: { url: string; date: string; key: string },
regionFilepath: string,
maxzoom?: number
): Promise<MapExtractPreflight> {
const dryRunOutput = join(tmpdir(), `pmtiles-dry-run-${randomBytes(6).toString('hex')}.pmtiles`)
const args = buildPmtilesExtractArgs({
sourceUrl: info.url,
outputFilepath: dryRunOutput,
regionFilepath,
maxzoom,
dryRun: true,
})
let stdout = ''
let stderr = ''
try {
const result = await execFileAsync(PMTILES_BINARY_PATH, args, {
timeout: DRY_RUN_TIMEOUT_MS,
maxBuffer: DRY_RUN_MAX_BUFFER,
})
stdout = result.stdout
stderr = result.stderr
} catch (err: any) {
throw new Error(
`pmtiles extract --dry-run failed: ${err.message}. stderr: ${err.stderr ?? ''}`
)
}
const parsed = this.parseDryRunOutput(stdout + '\n' + stderr)
return {
tiles: parsed.tiles,
bytes: parsed.bytes,
source: { url: info.url, date: info.date, key: info.key },
}
}
async extractRegion(params: {
countries: CountryCode[]
maxzoom?: number
label?: string
estimatedBytes?: number
}): Promise<{ filename: string; jobId?: string }> {
this.validateMaxzoom(params.maxzoom)
const countriesService = CountriesService.getInstance()
const countries = await countriesService.resolveCodes(params.countries)
const regionFilepath = await countriesService.writeRegionFile(countries)
const maxzoom = params.maxzoom ?? EXTRACT_DEFAULT_MAX_ZOOM
const [baseAssetsExist, info, groups] = await Promise.all([
this.ensureBaseAssets(),
this.getGlobalMapInfo(),
countriesService.listGroups(),
])
if (!baseAssetsExist) {
throw new Error(
'Base map assets are missing and could not be downloaded. Please check your connection and try again.'
)
}
const groupMatch = findExactGroupMatch(countries, groups)
const slug = this.buildRegionSlug(countries, groupMatch)
const dateSlug = info.key.replace('.pmtiles', '')
const filename = `${slug}_${dateSlug}_z${maxzoom}.pmtiles`
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const filepath = resolve(join(basePath, filename))
if (!filepath.startsWith(basePath + sep)) {
throw new Error('Invalid filename')
}
let estimatedBytes = params.estimatedBytes ?? 0
if (estimatedBytes === 0) {
try {
const preflight = await this.runDryRun(info, regionFilepath, maxzoom)
estimatedBytes = preflight.bytes
} catch (err) {
logger.warn(`[MapService] extractRegion preflight failed, proceeding without estimate: ${err}`)
}
}
const title = params.label ?? this.buildRegionTitle(countries, groupMatch)
const result = await RunExtractPmtilesJob.dispatch({
sourceUrl: info.url,
outputFilepath: filepath,
regionFilepath,
maxzoom,
estimatedBytes,
filetype: 'map',
title,
resourceMetadata: {
resource_id: slug,
version: dateSlug,
collection_ref: null,
},
})
if (!result.job) {
throw new Error('Failed to dispatch extract job')
}
logger.info(
`[MapService] Dispatched extract job ${result.job.id} for ${filename} ` +
`(countries=[${countries.join(',')}] maxzoom=${maxzoom} est=${estimatedBytes} bytes)`
)
return {
filename,
jobId: result.job.id,
}
}
private buildRegionSlug(countries: CountryCode[], groupMatch: CountryGroup | null): string {
if (groupMatch) return groupMatch.id
if (countries.length === 1) return countries[0].toLowerCase()
const hash = createHash('sha1').update(countries.join(',')).digest('hex').slice(0, 8)
return `custom-${hash}`
}
private buildRegionTitle(countries: CountryCode[], groupMatch: CountryGroup | null): string {
if (groupMatch) return groupMatch.name
if (countries.length === 1) return countries[0]
if (countries.length <= 3) return countries.join(', ')
return `${countries.slice(0, 2).join(', ')} +${countries.length - 2} more`
}
private validateMaxzoom(maxzoom: number | undefined): void {
if (typeof maxzoom !== 'number') return
if (
!Number.isInteger(maxzoom) ||
maxzoom < EXTRACT_MIN_ZOOM ||
maxzoom > EXTRACT_MAX_ZOOM
) {
throw new Error(
`maxzoom must be an integer in [${EXTRACT_MIN_ZOOM}, ${EXTRACT_MAX_ZOOM}]`
)
}
}
// go-pmtiles output format isn't stable across versions — parse loosely and
// fall back to zeros. The extract can still proceed without an estimate.
private parseDryRunOutput(output: string): { tiles: number; bytes: number } {
let bytes = 0
let tiles = 0
const byteLine = output.match(/archive\s+size\s+of\s+([\d,.]+)\s*(B|KB|MB|GB|TB|bytes?)?/i)
if (byteLine) {
const raw = parseFloat(byteLine[1].replace(/,/g, ''))
const unit = (byteLine[2] ?? 'B').toUpperCase()
const multipliers: Record<string, number> = {
B: 1,
BYTE: 1,
BYTES: 1,
KB: 1_000,
MB: 1_000_000,
GB: 1_000_000_000,
TB: 1_000_000_000_000,
}
bytes = Math.round(raw * (multipliers[unit] ?? 1))
}
const tileLine = output.match(/(?:tiles\s+to\s+extract|tiles)[^\d]*([\d,]+)/i)
if (tileLine) {
tiles = parseInt(tileLine[1].replace(/,/g, ''), 10) || 0
}
return { tiles, bytes }
}
async delete(file: string): Promise<void> {
let fileName = file
if (!fileName.endsWith('.pmtiles')) {
fileName += '.pmtiles'
}
if (fileName === WORLD_BASEMAP_FILENAME) {
throw new Error('The world basemap cannot be deleted')
}
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const fullPath = resolve(join(basePath, fileName))
@ -563,3 +944,16 @@ export class MapService implements IMapService {
return baseUrl
}
}
function findExactGroupMatch(
countries: CountryCode[],
groups: CountryGroup[]
): CountryGroup | null {
return (
groups.find(
(g) =>
g.countries.length === countries.length &&
g.countries.every((c, i) => c === countries[i])
) ?? null
)
}

View File

@ -3,7 +3,7 @@ import OpenAI from 'openai'
import type { ChatCompletionChunk, ChatCompletionMessageParam } from 'openai/resources/chat/completions.js'
import type { Stream } from 'openai/streaming.js'
import { NomadOllamaModel } from '../../types/ollama.js'
import { FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js'
import { EMBEDDING_MODEL_NAME, FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js'
import fs from 'node:fs/promises'
import path from 'node:path'
import logger from '@adonisjs/core/services/logger'
@ -53,6 +53,7 @@ export class OllamaService {
private baseUrl: string | null = null
private initPromise: Promise<void> | null = null
private isOllamaNative: boolean | null = null
private activeDownloads: Map<string, Promise<{ success: boolean; message: string; retryable?: boolean }>> = new Map()
constructor() {}
@ -91,10 +92,46 @@ export class OllamaService {
/**
* Downloads a model from Ollama with progress tracking. Only works with Ollama backends.
* Use dispatchModelDownload() for background job processing where possible.
*
* @param signal Optional AbortSignal when triggered, the underlying axios stream is cancelled
* and the method returns a non-retryable failure so callers can mark the job
* unrecoverable in BullMQ and avoid the 40-attempt retry storm.
* @param jobId Optional BullMQ job id included in progress broadcasts so the frontend can
* correlate Transmit events to a cancellable job.
*/
async downloadModel(
model: string,
progressCallback?: (percent: number) => void
progressCallback?: (
percent: number,
bytes?: { downloadedBytes: number; totalBytes: number }
) => void,
signal?: AbortSignal,
jobId?: string
): Promise<{ success: boolean; message: string; retryable?: boolean }> {
// Deduplicate concurrent downloads of the same model
const existing = this.activeDownloads.get(model)
if (existing) {
logger.info(`[OllamaService] Download already in progress for "${model}", waiting on existing download.`)
return existing
}
const downloadPromise = this._doDownloadModel(model, progressCallback, signal, jobId)
this.activeDownloads.set(model, downloadPromise)
try {
return await downloadPromise
} finally {
this.activeDownloads.delete(model)
}
}
private async _doDownloadModel(
model: string,
progressCallback?: (
percent: number,
bytes?: { downloadedBytes: number; totalBytes: number }
) => void,
signal?: AbortSignal,
jobId?: string
): Promise<{ success: boolean; message: string; retryable?: boolean }> {
await this._ensureDependencies()
if (!this.baseUrl) {
@ -121,15 +158,45 @@ export class OllamaService {
}
}
// Stream pull via Ollama native API
// Stream pull via Ollama native API. axios supports `signal` natively for AbortController
// integration — when triggered, the request errors with code 'ERR_CANCELED' which we detect
// in the catch block below to return a non-retryable cancel result.
const pullResponse = await axios.post(
`${this.baseUrl}/api/pull`,
{ model, stream: true },
{ responseType: 'stream', timeout: 0 }
{ responseType: 'stream', timeout: 0, signal }
)
// Ollama's pull API reports progress per-digest (each blob). A single model can contain
// multiple blobs (weights, tokenizer, template, etc.) and each is reported in turn.
// Aggregate across all digests so the UI shows a single monotonically-increasing total,
// matching the behavior of the content download progress (Active Downloads section).
const digestProgress = new Map<string, { completed: number; total: number }>()
// Throttle broadcasts to once per BROADCAST_THROTTLE_MS — Ollama can emit hundreds of
// progress events per second for fast connections, which would flood the Transmit SSE
// channel and cause jittery speed calculations on the frontend.
const BROADCAST_THROTTLE_MS = 500
let lastBroadcastAt = 0
await new Promise<void>((resolve, reject) => {
let buffer = ''
// If the abort fires after headers are received but mid-stream, axios's signal handling
// destroys the stream which surfaces as an 'error' event — wire the signal listener so
// the promise rejects promptly with a recognizable cancel reason.
const onAbort = () => {
const err: any = new Error('Download cancelled')
err.code = 'ERR_CANCELED'
pullResponse.data.destroy(err)
}
if (signal) {
if (signal.aborted) {
onAbort()
return
}
signal.addEventListener('abort', onAbort, { once: true })
}
pullResponse.data.on('data', (chunk: Buffer) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
@ -138,23 +205,74 @@ export class OllamaService {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line)
if (parsed.completed && parsed.total) {
const percent = parseFloat(((parsed.completed / parsed.total) * 100).toFixed(2))
this.broadcastDownloadProgress(model, percent)
if (progressCallback) progressCallback(percent)
if (parsed.completed && parsed.total && parsed.digest) {
// Update this digest's progress — take the max seen value so transient
// out-of-order updates don't make the aggregate jump backwards.
const existing = digestProgress.get(parsed.digest)
digestProgress.set(parsed.digest, {
completed: Math.max(existing?.completed ?? 0, parsed.completed),
total: Math.max(existing?.total ?? 0, parsed.total),
})
// Compute aggregate across all known blobs
let aggCompleted = 0
let aggTotal = 0
for (const { completed, total } of digestProgress.values()) {
aggCompleted += completed
aggTotal += total
}
const percent = aggTotal > 0
? parseFloat(((aggCompleted / aggTotal) * 100).toFixed(2))
: 0
// Throttle broadcasts. Always call the progressCallback though — the worker
// uses it to update job state in Redis, which should reflect the latest view.
const now = Date.now()
if (now - lastBroadcastAt >= BROADCAST_THROTTLE_MS) {
lastBroadcastAt = now
this.broadcastDownloadProgress(model, percent, jobId, {
downloadedBytes: aggCompleted,
totalBytes: aggTotal,
})
}
if (progressCallback) {
progressCallback(percent, {
downloadedBytes: aggCompleted,
totalBytes: aggTotal,
})
}
}
} catch {
// ignore parse errors on partial lines
}
}
})
pullResponse.data.on('end', resolve)
pullResponse.data.on('error', reject)
pullResponse.data.on('end', () => {
if (signal) signal.removeEventListener('abort', onAbort)
resolve()
})
pullResponse.data.on('error', (err: any) => {
if (signal) signal.removeEventListener('abort', onAbort)
reject(err)
})
})
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
return { success: true, message: 'Model downloaded successfully.' }
} catch (error) {
// Detect axios cancel (signal-triggered abort). Don't broadcast an error event for
// user-initiated cancels — the cancel handler in DownloadService already broadcasts
// a cancelled state. Returning retryable: false prevents BullMQ retries.
const isCancelled =
axios.isCancel(error) ||
(error as any)?.code === 'ERR_CANCELED' ||
(error as any)?.name === 'CanceledError'
if (isCancelled) {
logger.info(`[OllamaService] Model "${model}" download cancelled by user.`)
return { success: false, message: 'Download cancelled', retryable: false }
}
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(
`[OllamaService] Failed to download model "${model}": ${errorMessage}`
@ -351,9 +469,55 @@ export class OllamaService {
}
}
/**
* Hard char cap per embed input, applied as a runtime safety net regardless of
* which backend path runs. The chunker in RagService caps at MAX_SAFE_TOKENS=1600
* (3200 chars at the conservative 2 chars/token estimate), but dense technical
* content has been observed to slip past on multi-batch ZIM ingestion (#881).
*
* 4000 chars 10002000 tokens depending on density, which keeps us comfortably
* under nomic-embed-text:v1.5's default 2048-token context even on the OpenAI-compat
* fallback path (which can't pass `truncate:true`/`num_ctx` to the model).
*/
public static readonly EMBED_MAX_INPUT_CHARS = 4000
/**
* Aggressive 2048-safe character cap, applied only on a context-length retry. nomic-embed-text:v1.5
* defaults to a 2048-token context, and on the OpenAI-compat fallback path (or an older Ollama that
* ignores num_ctx for embeddings) we cannot widen it at request time. 2000 chars stays under 2048
* tokens even for the densest content (~1 char/token code/markup), so an oversized chunk gets
* truncated-and-kept instead of silently dropped from Qdrant and the embed job stops re-embedding
* the whole file 30x on the one bad chunk (#881).
*/
public static readonly EMBED_CONTEXT_SAFE_CHARS = 2000
/**
* True if the error is the model rejecting input that exceeds its context window
* ("input length exceeds the context length"). Matches both the native /api/embed axios error
* shape and the OpenAI-compat BadRequestError. Drives the truncate-and-retry here and the
* non-retryable classification in EmbedFileJob (#881).
*/
public static isContextLengthError(err: unknown): boolean {
const parts: string[] = []
if (err instanceof Error && err.message) parts.push(err.message)
const anyErr = err as any
const data = anyErr?.response?.data
if (data) parts.push(typeof data === 'string' ? data : JSON.stringify(data))
if (anyErr?.error) parts.push(typeof anyErr.error === 'string' ? anyErr.error : JSON.stringify(anyErr.error))
const haystack = parts.join(' ').toLowerCase()
return (
(haystack.includes('context length') && haystack.includes('exceed')) ||
haystack.includes('input length exceeds')
)
}
/**
* Generate embeddings for the given input strings.
* Tries the Ollama native /api/embed endpoint first, falls back to /v1/embeddings.
*
* If the first attempt fails because a chunk exceeds the model's context window, retries once
* with an aggressive 2048-safe truncation (EMBED_CONTEXT_SAFE_CHARS) so the chunk is embedded
* (start-of-chunk) rather than silently dropped from Qdrant (#881).
*/
public async embed(model: string, input: string[]): Promise<{ embeddings: number[][] }> {
await this._ensureDependencies()
@ -361,11 +525,52 @@ export class OllamaService {
throw new Error('AI service is not initialized.')
}
const cap = (arr: string[], max: number) => arr.map((s) => (s.length > max ? s.slice(0, max) : s))
// Generous pre-cap (#881): fine for the native path (num_ctx=8192) but can still exceed a
// 2048-context fallback on dense content. The context-length retry below is the hard backstop.
const safeInput = cap(input, OllamaService.EMBED_MAX_INPUT_CHARS)
try {
// Prefer Ollama native endpoint (supports batch input natively)
return await this._embedWithFallback(model, safeInput)
} catch (err) {
if (!OllamaService.isContextLengthError(err)) throw err
// One or more chunks exceeded the model's context even after the pre-cap — typically an
// older Ollama that ignores num_ctx for embeddings, or the OpenAI-compat fallback path.
// Retry once, truncated hard enough to fit a 2048-token context at any density, so the
// chunk is embedded (truncated) instead of dropped and the job doesn't storm.
const hardCapped = cap(input, OllamaService.EMBED_CONTEXT_SAFE_CHARS)
const reduced = hardCapped.reduce((n, s, i) => (s.length < safeInput[i].length ? n + 1 : n), 0)
logger.warn(
'[OllamaService] embed: context-length overflow; retrying %d/%d inputs hard-capped at %d chars',
reduced,
input.length,
OllamaService.EMBED_CONTEXT_SAFE_CHARS
)
return await this._embedWithFallback(model, hardCapped)
}
}
/**
* Single embed attempt: native /api/embed first, then the OpenAI-compat /v1/embeddings fallback.
* Both paths request num_ctx/truncate (Ollama's OpenAI-compat shim forwards them). A context-length
* error from the native path is re-thrown rather than falling back, because the fallback has a
* smaller effective context and would only fail the same way the caller (embed) retries it
* truncated instead.
*/
private async _embedWithFallback(model: string, input: string[]): Promise<{ embeddings: number[][] }> {
try {
// Pass num_ctx explicitly so we don't depend on the embedding model's modelfile defaults.
// Some installs ship nomic-embed-text:v1.5 with num_ctx=2048; 8192 matches its RoPE-extrapolated
// max. truncate:true is a server-side net for any chunk that still overshoots.
const response = await axios.post(
`${this.baseUrl}/api/embed`,
{ model, input },
{
model,
input,
truncate: true,
options: { num_ctx: 8192 },
},
{ timeout: 60000 }
)
// Some backends (e.g. LM Studio) return HTTP 200 for unknown endpoints with an incompatible
@ -374,16 +579,134 @@ export class OllamaService {
throw new Error('Invalid /api/embed response — missing embeddings array')
}
return { embeddings: response.data.embeddings }
} catch {
// Fall back to OpenAI-compatible /v1/embeddings
// Explicitly request float format — some backends (e.g. LM Studio) don't reliably
// implement the base64 encoding the OpenAI SDK requests by default.
logger.info('[OllamaService] /api/embed unavailable, falling back to /v1/embeddings')
const results = await this.openai.embeddings.create({ model, input, encoding_format: 'float' })
} catch (err) {
// Let context-length errors bubble so embed() can retry with a smaller cap; the fallback
// endpoint (smaller effective context, no num_ctx honored on older Ollama) can't help here.
if (OllamaService.isContextLengthError(err)) throw err
// Log the original error so we know *why* we fell back. Earlier bare catches here masked
// recurring failures for months (#369, #670, #881).
logger.warn(
'[OllamaService] /api/embed failed, falling back to /v1/embeddings: %s',
err instanceof Error ? err.message : String(err)
)
// Fall back to OpenAI-compatible /v1/embeddings. Explicitly request float format — some
// backends (e.g. LM Studio) don't reliably implement the base64 the OpenAI SDK defaults to.
// truncate/num_ctx are forwarded by Ollama's OpenAI-compat shim; the SDK types omit them,
// hence the cast. We only ever talk to a local Ollama here, not real OpenAI.
const results = await this.openai!.embeddings.create({
model,
input,
encoding_format: 'float',
truncate: true,
options: { num_ctx: 8192 },
} as any)
return { embeddings: results.data.map((e) => e.embedding as number[]) }
}
}
/**
* Returns true if Ollama is currently running an embedding model with non-zero VRAM
* (i.e., GPU-offloaded). Returns false if the model is running CPU-only OR if it's
* not currently loaded OR if /api/ps is unreachable.
*
* Used by EmbedFileJob to pace continuation batches when the embedding model is
* CPU-bound sustained 100% CPU on a multi-batch ZIM ingestion can starve other
* services (sshd, etc.) hard enough to require a power-cycle. AMD ROCm installs
* hit this today because Ollama's ROCm build doesn't accelerate nomic-bert; on
* NVIDIA, nomic-embed-text runs at 100% GPU and pacing is unnecessary.
*
* Only the Ollama-native endpoint is supported backends that expose
* `/v1/embeddings` (LM Studio, llama.cpp) don't surface placement info.
*/
public async isEmbeddingGpuAccelerated(): Promise<boolean> {
await this._ensureDependencies()
if (!this.baseUrl) return false
try {
const response = await axios.get(`${this.baseUrl}/api/ps`, { timeout: 5000 })
const models: Array<{ name?: string; size_vram?: number }> = response.data?.models ?? []
// Match any loaded model whose name signals it's an embedding model.
// nomic-embed-text, mxbai-embed-large, snowflake-arctic-embed, etc. all follow this convention.
return models.some(
(m) => m.name?.toLowerCase().includes('embed') && (m.size_vram ?? 0) > 0
)
} catch (err: any) {
// /api/ps unreachable (Ollama down, non-native backend, etc.) — fail closed: assume CPU,
// which means we'll pace. Better to over-pace than risk box-killing CPU saturation.
logger.warn(
`[OllamaService] Could not check embedding placement via /api/ps: ${err?.message ?? err}`
)
return false
}
}
/**
* Enforces the "at most one chat model resident in VRAM" invariant by firing
* `keep_alive: 0` against every currently-loaded model except (a) the
* embedding model (always exempt) and (b) `targetModel` (the one we want
* loaded next leaving it alone preserves a hot model when the target is
* already loaded).
*
* Best-effort: queries `/api/ps` and POSTs unload hints in parallel. Network
* or Ollama errors are swallowed and logged neither chat nor page-load
* should fail just because the unload housekeeping didn't go through.
*
* Returns the list of model names that were sent the unload hint, so the
* caller (and tests) can confirm what actually happened.
*
* Pass `targetModel: null` to unload every chat model (used for the future
* "free up VRAM" path; not exposed yet but the helper supports it).
*
* Note that `keep_alive: 0` is a post-completion hint, not a force-kill
* Ollama defers eviction until the runner is idle, so in-flight inference
* on the same model is never interrupted. See the design doc for the race
* analysis behind this.
*/
public async unloadAllChatModelsExcept(targetModel: string | null): Promise<string[]> {
await this._ensureDependencies()
if (!this.baseUrl) return []
let loadedModels: string[] = []
try {
const response = await axios.get(`${this.baseUrl}/api/ps`, { timeout: 5000 })
loadedModels = (response.data?.models ?? [])
.map((m: { name?: string }) => m.name)
.filter((name: unknown): name is string => typeof name === 'string')
} catch (err: any) {
logger.warn(
`[OllamaService] unloadAllChatModelsExcept: /api/ps unreachable, skipping unload sweep: ${err?.message ?? err}`
)
return []
}
const toUnload = loadedModels.filter(
(name) => name !== EMBEDDING_MODEL_NAME && name !== targetModel
)
await Promise.all(
toUnload.map(async (modelName) => {
try {
await axios.post(
`${this.baseUrl}/api/generate`,
{ model: modelName, prompt: '', keep_alive: 0 },
{ timeout: 10000 }
)
} catch (err: any) {
logger.warn(
`[OllamaService] Failed to send unload hint for ${modelName}: ${err?.message ?? err}`
)
}
})
)
if (toUnload.length > 0) {
logger.info(
`[OllamaService] Sent unload hint for ${toUnload.length} chat model(s): ${toUnload.join(', ')}`
)
}
return toUnload
}
public async getModels(includeEmbeddings = false): Promise<NomadInstalledModel[]> {
await this._ensureDependencies()
if (!this.baseUrl) {
@ -628,10 +951,19 @@ export class OllamaService {
})
}
private broadcastDownloadProgress(model: string, percent: number) {
private broadcastDownloadProgress(
model: string,
percent: number,
jobId?: string,
bytes?: { downloadedBytes: number; totalBytes: number }
) {
// Conditional spread on jobId/bytes — Transmit's Broadcastable type rejects fields whose
// value is `undefined`, so we omit each key entirely when its value isn't available.
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
model,
percent,
...(jobId ? { jobId } : {}),
...(bytes ? { downloadedBytes: bytes.downloadedBytes, totalBytes: bytes.totalBytes } : {}),
timestamp: new Date().toISOString(),
})
logger.info(`[OllamaService] Download progress for model "${model}": ${percent}%`)

View File

@ -1,9 +1,25 @@
import { Queue } from 'bullmq'
import queueConfig from '#config/queue'
// Process-wide singleton. Instantiating a fresh QueueService per dispatch /
// status lookup leaks connections, and under sustained job churn (e.g.
// multi-batch ZIM ingestion enqueueing a continuation every few seconds) it
// saturates Redis's maxclients within hours. All queues additionally reuse the
// single shared ioredis instance exported from #config/queue (#885).
export class QueueService {
private queues: Map<string, Queue> = new Map()
private static _instance: QueueService | null = null
private constructor() {}
static getInstance(): QueueService {
if (!QueueService._instance) {
QueueService._instance = new QueueService()
}
return QueueService._instance
}
getQueue(name: string): Queue {
if (!this.queues.has(name)) {
const queue = new Queue(name, {
@ -18,5 +34,6 @@ export class QueueService {
for (const queue of this.queues.values()) {
await queue.close()
}
this.queues.clear()
}
}

File diff suppressed because it is too large Load Diff

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'
@ -12,6 +13,7 @@ import {
} from '../../types/system.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import path, { join } from 'node:path'
import { getAllFilesystems, getFile } from '../utils/fs.js'
import axios from 'axios'
@ -34,29 +36,54 @@ export class SystemService {
}
async getInternetStatus(): Promise<boolean> {
const DEFAULT_TEST_URL = 'https://1.1.1.1/cdn-cgi/trace'
// Primary endpoint stays Cloudflare's privacy-respecting utility endpoint.
// The fallbacks are hosts the application already contacts elsewhere
// (GitHub API for update checks, the Project N.O.M.A.D. API for release-note
// subscriptions), so no new third-party services are introduced. They exist
// to avoid false "offline" reports on networks that block 1.1.1.1.
const DEFAULT_TEST_URLS = [
'https://1.1.1.1/cdn-cgi/trace',
'https://api.github.com',
'https://api.projectnomad.us',
]
const MAX_ATTEMPTS = 3
let testUrl = DEFAULT_TEST_URL
let customTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()
let testUrls = DEFAULT_TEST_URLS
// check that customTestUrl is a valid URL, if provided
// Resolve the test endpoint in priority order: the INTERNET_STATUS_TEST_URL
// env var always wins (legacy override for operators who intentionally point
// connectivity checks at a specific endpoint), then the UI-configurable value
// stored in KVStore, and finally the built-in defaults.
const envTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()
const kvTestUrl = (await KVStore.getValue('system.internetStatusTestUrl'))?.trim()
const customTestUrl = envTestUrl || kvTestUrl
// If a custom test URL is provided and valid, use it exclusively.
if (customTestUrl && customTestUrl !== '') {
try {
new URL(customTestUrl)
testUrl = customTestUrl
testUrls = [customTestUrl]
} catch (error) {
logger.warn(
`Invalid INTERNET_STATUS_TEST_URL: ${customTestUrl}. Falling back to default URL.`
`Invalid internet status test URL: ${customTestUrl}. Falling back to default URLs.`
)
}
}
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const res = await axios.get(testUrl, { timeout: 5000 })
return res.status === 200
// Probe all test endpoints in parallel and resolve as soon as the first one
// responds. Any HTTP response (including non-2xx) means we reached the
// internet, so accept all status codes rather than requiring a strict 200.
await Promise.any(
testUrls.map((testUrl) => {
logger.debug(`[SystemService] Checking internet connectivity via: ${testUrl}`)
return axios.get(testUrl, { timeout: 5000, validateStatus: () => true })
})
)
return true
} catch (error) {
// Promise.any only rejects (with an AggregateError) when every endpoint failed.
logger.warn(
`Internet status check attempt ${attempt}/${MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : error}`
)
@ -72,6 +99,89 @@ export class SystemService {
return false
}
/**
* Probe Ollama startup logs for the canonical "inference compute" line that records
* which compute backend was selected. This catches silent CPU fallback (e.g. when
* /dev/kfd is mounted but ROCm initialization fails, or NVML dies after an update)
* which the older nvidia-smi exec probe could not detect.
*
* Returns the parsed library, GPU model name, and VRAM in MiB, or null when:
* - the Ollama container is not running
* - the line has not been emitted (Ollama still starting up)
* - logs show CPU-only operation (no GPU detected)
*/
async getOllamaInferenceComputeFromLogs(): Promise<{
library: 'CUDA' | 'ROCm'
name: string
vramMiB: number
} | null> {
try {
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!ollamaContainer) return null
const container = this.dockerService.docker.getContainer(ollamaContainer.Id)
// Read logs only from the first 5 minutes after container start. The
// "inference compute" line is written once during Ollama's GPU discovery
// phase, within seconds of startup. Using tail:N here is fragile: under
// active embedding workloads we've seen >1000 lines/min, which pushes the
// line past any reasonable tail in minutes. Pinning to the startup window
// is bounded (~5 min of logs regardless of container uptime) and never
// ages out.
//
// Fall back to the previous tail:500 strategy if StartedAt is missing or
// unparseable — we can't construct a since/until window without it, but
// tail:500 is still useful when the container just started and the line
// is still recent.
const inspect = await container.inspect()
const startedAtRaw = inspect?.State?.StartedAt
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : NaN
const hasValidStartedAt = Number.isFinite(startedAtMs) && startedAtMs > 0
const logsOpts: { stdout: true; stderr: true; follow: false; since?: number; until?: number; tail?: number } = {
stdout: true,
stderr: true,
follow: false,
}
if (hasValidStartedAt) {
const startedAtSec = Math.floor(startedAtMs / 1000)
logsOpts.since = startedAtSec
logsOpts.until = startedAtSec + 300 // 5-minute window
} else {
logger.warn(
`[SystemService] nomad_ollama State.StartedAt missing or invalid (${startedAtRaw ?? 'undefined'}); falling back to tail:500 for inference-compute probe`
)
logsOpts.tail = 500
}
const buf = (await container.logs(logsOpts)) as unknown as Buffer
const logs = buf.toString('utf8')
const lines = logs.split('\n').filter((l) => l.includes('msg="inference compute"'))
if (lines.length === 0) return null
const lastLine = lines[lines.length - 1]
const libraryMatch = lastLine.match(/library=(CUDA|ROCm)/)
if (!libraryMatch) return null
const descMatch = lastLine.match(/description="([^"]+)"/)
const totalMatch = lastLine.match(/total="([0-9.]+)\s*GiB"/)
return {
library: libraryMatch[1] as 'CUDA' | 'ROCm',
name:
descMatch?.[1] ||
(libraryMatch[1] === 'CUDA' ? 'NVIDIA GPU' : 'AMD GPU'),
vramMiB: totalMatch ? Math.round(Number.parseFloat(totalMatch[1]) * 1024) : 0,
}
} catch (error) {
logger.warn(
`[SystemService] Failed to probe Ollama logs for inference compute line: ${error instanceof Error ? error.message : error}`
)
return null
}
}
async getNvidiaSmiInfo(): Promise<
| Array<{ vendor: string; model: string; vram: number }>
| { error: string }
@ -218,15 +328,26 @@ export class SystemService {
'installed',
'installation_status',
'ui_location',
'custom_url',
'friendly_name',
'description',
'icon',
'powered_by',
'display_order',
'container_image',
'available_update_version'
'available_update_version',
'auto_update_enabled',
'is_custom',
'is_user_modified',
'is_deprecated',
'category'
)
.where('is_dependency_service', false)
// Deprecated/sunset apps stay visible only while still installed, so the user can manage and
// uninstall them — they never reappear in the install catalog once removed.
.where((q) => {
q.where('is_deprecated', false).orWhere('installed', true)
})
if (installedOnly) {
query.where('installed', true)
}
@ -250,10 +371,16 @@ export class SystemService {
installation_status: service.installation_status,
status: status ? status.status : 'unknown',
ui_location: service.ui_location || '',
custom_url: service.custom_url,
powered_by: service.powered_by,
display_order: service.display_order,
container_image: service.container_image,
available_update_version: service.available_update_version,
auto_update_enabled: service.auto_update_enabled,
is_custom: service.is_custom,
is_user_modified: service.is_user_modified,
is_deprecated: service.is_deprecated,
category: service.category,
})
}
@ -317,10 +444,14 @@ export class SystemService {
logger.error('Error reading disk info file:', error)
}
// GPU health tracking — detect when host has NVIDIA GPU but Ollama can't access it
// GPU health tracking — detect when host has a GPU runtime but Ollama can't access it.
// Primary probe: parse Ollama's "inference compute" startup log line for both NVIDIA
// and AMD. Secondary probe (NVIDIA only): nvidia-smi exec, retained as a fallback for
// hardware enrichment when log parsing has not yet captured a startup line.
let gpuHealth: GpuHealthStatus = {
status: 'no_gpu',
hasNvidiaRuntime: false,
hasRocmRuntime: false,
ollamaGpuAccessible: false,
}
@ -339,28 +470,85 @@ export class SystemService {
os.kernel = dockerInfo.KernelVersion
}
// If si.graphics() returned no controllers (common inside Docker),
// fall back to nvidia runtime + nvidia-smi detection
if (!graphics.controllers || graphics.controllers.length === 0) {
// si.graphics() in the admin container uses lspci (pciutils ships in
// the image for AMD detection). lspci has no real VRAM info for
// discrete GPUs, so systeminformation parses the first PCI memory
// Region (BAR0, typically 1-32 MiB) as `vram`. nvidia-smi / ROCm
// tooling enrichment also can't run since neither is in the admin
// image. No real dGPU has under 256 MiB, so any discrete-GPU controller
// below that threshold needs the probes below to give us real data.
// Applies to both NVIDIA and AMD; Intel iGPUs are exempt because their
// shared-system-memory VRAM reading via lspci can legitimately be small.
const DGPU_BOGUS_VRAM_THRESHOLD_MIB = 256
const isDiscreteGpuVendor = (vendor: string) =>
/nvidia|advanced micro devices|amd|ati/i.test(vendor)
const isBogusDgpuVram = (c: { vendor?: string; vram?: number | null }) =>
isDiscreteGpuVendor(c.vendor || '') &&
typeof c.vram === 'number' &&
c.vram < DGPU_BOGUS_VRAM_THRESHOLD_MIB
// Clear the bogus value up front. If a probe replaces the entry below
// we get the real VRAM; if no probe succeeds (Ollama not installed,
// passthrough_failed) the UI falls back to "N/A" instead of showing
// "1 MB" / "32 MB". The lspci model/vendor strings stay since they're
// still useful for identifying the card.
const hasLspciBogusDgpuVram = (graphics.controllers || []).some(isBogusDgpuVram)
if (hasLspciBogusDgpuVram) {
for (const c of graphics.controllers) {
if (isBogusDgpuVram(c)) c.vram = null
}
}
// Run the probes when controllers are empty (common inside Docker) or
// when lspci gave us bogus discrete-GPU BAR0 values that need replacing.
if (
!graphics.controllers ||
graphics.controllers.length === 0 ||
hasLspciBogusDgpuVram
) {
const runtimes = dockerInfo.Runtimes || {}
if ('nvidia' in runtimes) {
gpuHealth.hasNvidiaRuntime = true
const nvidiaInfo = await this.getNvidiaSmiInfo()
if (Array.isArray(nvidiaInfo)) {
graphics.controllers = nvidiaInfo.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false, // assume false here, we don't actually use this field for our purposes.
}))
gpuHealth.hasNvidiaRuntime = 'nvidia' in runtimes
// AMD doesn't register a Docker runtime. Detection sources, in priority order:
// 1. KV 'gpu.type' (set by DockerService._detectGPUType after first Ollama install)
// 2. Marker file at /app/storage/.nomad-gpu-type (written by install_nomad.sh)
// The marker file matters because the System page should reflect AMD presence
// even before AI Assistant has been installed for the first time.
let savedGpuType: string | null | undefined = await KVStore.getValue('gpu.type') as string | undefined
if (!savedGpuType) {
try {
savedGpuType = (await readFile('/app/storage/.nomad-gpu-type', 'utf8')).trim()
} catch {}
}
const amdEnabledRaw = await KVStore.getValue('ai.amdGpuAcceleration')
const amdAccelerationEnabled = String(amdEnabledRaw) !== 'false'
gpuHealth.hasRocmRuntime = savedGpuType === 'amd' && amdAccelerationEnabled
if (gpuHealth.hasNvidiaRuntime || gpuHealth.hasRocmRuntime) {
gpuHealth.gpuVendor = gpuHealth.hasNvidiaRuntime ? 'nvidia' : 'amd'
// Primary probe: Ollama log parsing — works for both vendors and catches silent fallback
const logInfo = await this.getOllamaInferenceComputeFromLogs()
if (logInfo) {
graphics.controllers = [
{
model: logInfo.name,
vendor: logInfo.library === 'CUDA' ? 'NVIDIA' : 'AMD',
bus: '',
vram: logInfo.vramMiB,
vramDynamic: false,
},
]
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
// No local Ollama container — check if a remote Ollama URL is configured
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
} else if (gpuHealth.hasNvidiaRuntime) {
// NVIDIA secondary path: nvidia-smi exec preserves prior behavior when
// the log parser hasn't seen a startup line yet (e.g. log rotation,
// very fresh container). Distinguishes "no Ollama container" from
// "container exists but GPU broken".
const nvidiaInfo = await this.getNvidiaSmiInfo()
if (Array.isArray(nvidiaInfo)) {
graphics.controllers = nvidiaInfo.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
@ -369,25 +557,66 @@ export class SystemService {
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'ollama_not_installed'
}
} else {
gpuHealth.status = 'ollama_not_installed'
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`
)
}
}
} else {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
// AMD path: no nvidia-smi equivalent worth running — log parser is authoritative.
// Distinguish "Ollama not running" from "Ollama running but no GPU log line".
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaRunning = containers.some((c) =>
c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)
)
if (!ollamaRunning) {
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'ollama_not_installed'
}
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`
'AMD GPU detected but Ollama logs show no ROCm initialization — passthrough or HSA override may have failed'
)
}
}
@ -640,6 +869,31 @@ 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')
}
// Re-enabling the global app auto-update master switch clears every app's
// per-app failure backoff so previously self-disabled apps get a fresh start.
if (key === 'appAutoUpdate.enabled' && (value === true || value === 'true')) {
await Service.query().update({
auto_update_consecutive_failures: 0,
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,
})
}
}
/**
@ -742,4 +996,83 @@ export class SystemService {
}
})
}
/**
* Check whether the host has enough free memory and disk to comfortably run an app.
* Returns an array of human-readable warning strings; an empty array means no concerns.
* These are advisory only the caller decides whether to block or warn.
*/
async checkResourceWarnings(minMemoryMB: number, minDiskMB: number): Promise<string[]> {
const warnings: string[] = []
try {
const mem = await si.mem()
const availableMB = Math.floor(mem.available / 1024 / 1024)
if (availableMB < minMemoryMB) {
warnings.push(
`Low memory: ${availableMB} MB available, this app recommends at least ${minMemoryMB} MB free.`
)
}
} catch (err: any) {
logger.warn(`[SystemService] checkResourceWarnings mem check failed: ${err.message}`)
}
try {
const storagePath = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
const fsSizes = await si.fsSize()
// Find the filesystem whose mount point is the longest prefix of storagePath
const fs = fsSizes
.filter((f) => storagePath.startsWith(f.mount))
.sort((a, b) => b.mount.length - a.mount.length)[0]
if (fs) {
const availableDiskMB = Math.floor((fs.size - fs.used) / 1024 / 1024)
if (availableDiskMB < minDiskMB) {
warnings.push(
`Low disk space: ${availableDiskMB} MB available on ${fs.mount}, this app recommends at least ${minDiskMB} MB free.`
)
}
}
} catch (err: any) {
logger.warn(`[SystemService] checkResourceWarnings disk check failed: ${err.message}`)
}
return warnings
}
/**
* Return the next suggested host port for a custom app in the 8600+ range.
* Looks at existing custom service records and all Docker container port bindings.
*/
async getNextSuggestedCustomPort(): Promise<number> {
const CUSTOM_PORT_START = 8600
const occupied = new Set<number>()
try {
// Ports used by existing custom services in the DB
const customServices = await Service.query().where('is_custom', true)
for (const svc of customServices) {
const config = svc.container_config ? JSON.parse(svc.container_config) : null
const bindings = config?.HostConfig?.PortBindings ?? {}
for (const binding of Object.values(bindings) as any[]) {
const port = parseInt(binding?.[0]?.HostPort, 10)
if (!isNaN(port)) occupied.add(port)
}
}
// Ports used by any running Docker container in the 8600+ range
const containers = await this.dockerService.docker.listContainers({ all: true })
for (const c of containers) {
for (const p of c.Ports) {
if (p.PublicPort && p.PublicPort >= CUSTOM_PORT_START) occupied.add(p.PublicPort)
}
}
} catch (err: any) {
logger.warn(`[SystemService] getNextSuggestedCustomPort probe failed: ${err.message}`)
}
let candidate = CUSTOM_PORT_START
while (occupied.has(candidate)) candidate += 10
return candidate
}
}

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))
@ -47,10 +61,10 @@ export class SystemUpdateService {
message: 'System update initiated. The admin container will restart during the process.',
}
} catch (error) {
logger.error('[SystemUpdateService]: Failed to request system update:', error)
logger.error({ err: error }, '[SystemUpdateService] Failed to request system update')
return {
success: false,
message: `Failed to request update: ${error.message}`,
message: 'Failed to request system update. Check server logs for details.',
}
}
}

View File

@ -5,6 +5,7 @@ import logger from '@adonisjs/core/services/logger'
import { ExtractZIMChunkingStrategy, ExtractZIMContentOptions, ZIMContentChunk, ZIMArchiveMetadata } from '../../types/zim.js'
import { randomUUID } from 'node:crypto'
import { access } from 'node:fs/promises'
import { isValidZimFile } from '../utils/fs.js'
export class ZIMExtractionService {
@ -39,7 +40,10 @@ export class ZIMExtractionService {
* @param filePath - Path to the ZIM file
* @param opts - Options including maxArticles, strategy, onProgress, startOffset, and batchSize
*/
async extractZIMContent(filePath: string, opts: ExtractZIMContentOptions = {}): Promise<ZIMContentChunk[]> {
async extractZIMContent(
filePath: string,
opts: ExtractZIMContentOptions = {}
): Promise<{ chunks: ZIMContentChunk[]; totalArticles: number }> {
try {
logger.info(`[ZIMExtractionService]: Processing ZIM file at path: ${filePath}`)
@ -51,7 +55,13 @@ export class ZIMExtractionService {
logger.error(`[ZIMExtractionService]: ZIM file not accessible: ${filePath}`)
throw new Error(`ZIM file not found or not accessible: ${filePath}`)
}
// Validate ZIM magic number before opening with native library.
// A corrupted file causes a native C++ abort that cannot be caught by JS.
if (!(await isValidZimFile(filePath))) {
throw new Error(`ZIM file is invalid or corrupted: ${filePath}`)
}
const archive = new Archive(filePath)
// Extract archive-level metadata once
@ -154,7 +164,7 @@ export class ZIMExtractionService {
textPreview: c.text.substring(0, 100)
})))
logger.debug("Total structured sections extracted:", toReturn.length)
return toReturn
return { chunks: toReturn, totalArticles: archive.articleCount }
} catch (error) {
logger.error('Error processing ZIM file:', error)
throw error
@ -209,7 +219,10 @@ export class ZIMExtractionService {
const sections: Array<{ heading: string; text: string; level: number }> = [];
let currentSection = { heading: 'Introduction', content: [] as string[], level: 2 };
$('body').children().each((_, element) => {
// Walk the full DOM rather than only direct children of <body>. Modern ZIMs (Devdocs,
// Wikipedia, FreeCodeCamp, etc.) wrap article content in a container div, which under
// .children() would be a single non-heading/non-paragraph element and yield zero sections.
$('body').find('h2, h3, h4, p, ul, ol, dl, table').each((_, element) => {
const $el = $(element);
const tagName = element.tagName?.toLowerCase();
@ -246,6 +259,20 @@ export class ZIMExtractionService {
});
}
// Fallback: if the selector walk produced no sections but the body has meaningful
// text (unusual structure, minimal markup), emit one section with the full body text
// so the article still contributes to the knowledge base.
if (sections.length === 0) {
const bodyText = $('body').text().replace(/\s+/g, ' ').trim();
if (bodyText.length > 0) {
sections.push({
heading: title || 'Content',
text: bodyText,
level: 2,
});
}
}
return {
title,
sections,

View File

@ -4,8 +4,11 @@ import {
RemoteZimFileEntry,
} from '../../types/zim.js'
import axios from 'axios'
import * as cheerio from 'cheerio'
import { XMLParser } from 'fast-xml-parser'
import { isRawListRemoteZimFilesResponse, isRawRemoteZimFileEntry } from '../../util/zim.js'
import { findReplacedWikipediaFiles } from '../utils/zim_filename.js'
import { decideSupersededDeletion } from '../utils/superseded_resource.js'
import logger from '@adonisjs/core/services/logger'
import { DockerService } from './docker_service.js'
import { inject } from '@adonisjs/core'
@ -22,11 +25,14 @@ import vine from '@vinejs/vine'
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
import WikipediaSelection from '#models/wikipedia_selection'
import InstalledResource from '#models/installed_resource'
import CollectionManifest from '#models/collection_manifest'
import { RunDownloadJob } from '#jobs/run_download_job'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { CollectionManifestService } from './collection_manifest_service.js'
import { KiwixLibraryService } from './kiwix_library_service.js'
import type { CategoryWithStatus } from '../../types/collections.js'
import CustomLibrarySource from '#models/custom_library_source'
import { assertNotPrivateUrl } from '#validators/common'
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json'
@ -40,7 +46,21 @@ export class ZimService {
await ensureDirectoryExists(dirPath)
const all = await listDirectoryContents(dirPath)
const files = all.filter((item) => item.name.endsWith('.zim'))
const zimEntries = all.filter((item) => item.name.endsWith('.zim'))
const files = await Promise.all(
zimEntries.map(async (entry) => {
const filePath = entry.type === 'file' ? entry.key : join(dirPath, entry.name)
const stats = await getFileStatsIfExists(filePath)
return {
...entry,
title: null,
summary: null,
author: null,
size_bytes: stats ? Number(stats.size) : null,
}
})
)
return {
files,
@ -57,84 +77,105 @@ export class ZimService {
query?: string
}): Promise<ListRemoteZimFilesResponse> {
const LIBRARY_BASE_URL = 'https://browse.library.kiwix.org/catalog/v2/entries'
// Kiwix returns pages of content unaware of what the user has installed locally. When
// the installed set is large, a single 12-item Kiwix page can come back with everything
// already installed → 0 post-filter items → frontend deadlock (#731). Accumulate across
// upstream pages so we return a useful batch. Bounded by MAX_KIWIX_FETCHES so a heavily
// saturated install doesn't hang a single request; the frontend scroll loop + auto-fetch
// effect handle continuation.
const KIWIX_PAGE_SIZE = 60
const MAX_KIWIX_FETCHES = 5
const res = await axios.get(LIBRARY_BASE_URL, {
params: {
start: start,
count: count,
lang: 'eng',
...(query ? { q: query } : {}),
},
responseType: 'text',
})
const data = res.data
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
textNodeName: '#text',
})
const result = parser.parse(data)
if (!isRawListRemoteZimFilesResponse(result)) {
throw new Error('Invalid response format from remote library')
}
const entries = result.feed.entry
? Array.isArray(result.feed.entry)
? result.feed.entry
: [result.feed.entry]
: []
const filtered = entries.filter((entry: any) => {
return isRawRemoteZimFileEntry(entry)
})
const mapped: (RemoteZimFileEntry | null)[] = filtered.map((entry: RawRemoteZimFileEntry) => {
const downloadLink = entry.link.find((link: any) => {
return (
typeof link === 'object' &&
'rel' in link &&
'length' in link &&
'href' in link &&
'type' in link &&
link.type === 'application/x-zim'
)
})
if (!downloadLink) {
return null
}
// downloadLink['href'] will end with .meta4, we need to remove that to get the actual download URL
const download_url = downloadLink['href'].substring(0, downloadLink['href'].length - 6)
const file_name = download_url.split('/').pop() || `${entry.title}.zim`
const sizeBytes = parseInt(downloadLink['length'], 10)
return {
id: entry.id,
title: entry.title,
updated: entry.updated,
summary: entry.summary,
size_bytes: sizeBytes || 0,
download_url: download_url,
author: entry.author.name,
file_name: file_name,
}
})
// Filter out any null entries (those without a valid download link)
// or files that already exist in the local storage
// Snapshot locally-installed files once — the filesystem won't change mid-request.
const existing = await this.list()
const existingKeys = new Set(existing.files.map((file) => file.name))
const withoutExisting = mapped.filter(
(entry): entry is RemoteZimFileEntry => entry !== null && !existingKeys.has(entry.file_name)
)
const accumulated: RemoteZimFileEntry[] = []
const seenIds = new Set<string>()
let currentStart = start
let totalResults = 0
for (let i = 0; i < MAX_KIWIX_FETCHES; i++) {
const res = await axios.get(LIBRARY_BASE_URL, {
params: {
start: currentStart,
count: KIWIX_PAGE_SIZE,
lang: 'eng',
...(query ? { q: query } : {}),
},
responseType: 'text',
})
const parsed = parser.parse(res.data)
if (!isRawListRemoteZimFilesResponse(parsed)) {
throw new Error('Invalid response format from remote library')
}
totalResults = parsed.feed.totalResults
const rawEntries = parsed.feed.entry
? Array.isArray(parsed.feed.entry)
? parsed.feed.entry
: [parsed.feed.entry]
: []
// Empty upstream response — bail even if totalResults suggests more (transient Kiwix
// hiccup or totalResults drift between pages). Prevents a pointless spin.
if (rawEntries.length === 0) break
// Advance by actual returned count, not requested count. Short pages at the tail
// would otherwise cause us to skip entries on the next fetch.
currentStart += rawEntries.length
for (const raw of rawEntries) {
if (!isRawRemoteZimFileEntry(raw)) continue
const entry = raw as RawRemoteZimFileEntry
const downloadLink = entry.link.find(
(link: any) =>
typeof link === 'object' &&
'rel' in link &&
'length' in link &&
'href' in link &&
'type' in link &&
link.type === 'application/x-zim'
)
if (!downloadLink) continue
// downloadLink['href'] ends with .meta4; strip that to get the actual .zim URL.
const download_url = downloadLink['href'].substring(0, downloadLink['href'].length - 6)
const file_name = download_url.split('/').pop() || `${entry.title}.zim`
if (existingKeys.has(file_name)) continue
if (seenIds.has(entry.id)) continue
seenIds.add(entry.id)
const sizeBytes = parseInt(downloadLink['length'], 10)
accumulated.push({
id: entry.id,
title: entry.title,
updated: entry.updated,
summary: entry.summary,
size_bytes: sizeBytes || 0,
download_url,
author: entry.author.name,
file_name,
})
}
if (accumulated.length >= count) break
if (currentStart >= totalResults) break
}
return {
items: withoutExisting,
has_more: result.feed.totalResults > start,
total_count: result.feed.totalResults,
items: accumulated,
has_more: currentStart < totalResults,
total_count: totalResults,
next_start: currentStart,
}
}
@ -276,7 +317,7 @@ export class ZimService {
if (restart) {
// Check if there are any remaining ZIM download jobs before restarting
const { QueueService } = await import('./queue_service.js')
const queueService = new QueueService()
const queueService = QueueService.getInstance()
const queue = queueService.getQueue('downloads')
// Get all active and waiting jobs
@ -319,6 +360,8 @@ export class ZimService {
}
// Create InstalledResource entries for downloaded files
const zimStorageDir = join(process.cwd(), ZIM_STORAGE_PATH)
let removedSupersededZim = false
for (const url of urls) {
// Skip Wikipedia files (managed separately)
if (url.includes('wikipedia_en_')) continue
@ -329,10 +372,17 @@ export class ZimService {
const parsed = CollectionManifestService.parseZimFilename(filename)
if (!parsed) continue
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
const filepath = join(zimStorageDir, filename)
const stats = await getFileStatsIfExists(filepath)
try {
// Capture the prior install for this resource_id BEFORE updateOrCreate
// overwrites it, so we know the old file path to clean up (#634).
const prior = await InstalledResource.query()
.where('resource_id', parsed.resource_id)
.where('resource_type', 'zim')
.first()
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'zim' },
@ -345,10 +395,170 @@ export class ZimService {
}
)
logger.info(`[ZimService] Created InstalledResource entry for: ${parsed.resource_id}`)
// Remove the superseded prior version's file if (and only if) every
// safety rail passes — see decideSupersededDeletion. The InstalledResource
// row already points at the new file, so we delete the old file directly
// (NOT via this.delete(), which would drop the row by resource_id).
const decision = decideSupersededDeletion({
existing: prior ? { file_path: prior.file_path, version: prior.version } : null,
newFilePath: filepath,
newVersion: parsed.version,
newFileExists: !!stats,
storageBaseDir: zimStorageDir,
})
if (decision.delete && decision.path) {
try {
await deleteFileIfExists(decision.path)
removedSupersededZim = true
logger.info(
`[ZimService] Removed superseded ${parsed.resource_id} file: ${decision.path}`
)
} catch (err) {
logger.warn(`[ZimService] Failed to remove superseded file ${decision.path}:`, err)
}
} else if (decision.reason !== 'first_install' && decision.reason !== 'same_file') {
logger.info(
`[ZimService] Kept prior ${parsed.resource_id} file (reason: ${decision.reason})`
)
}
} catch (error) {
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
}
}
// If we removed any superseded ZIM, rebuild the Kiwix library so its XML no
// longer references the deleted file. The earlier rebuild in this flow ran
// while both versions were still on disk.
if (removedSupersededZim) {
try {
await new KiwixLibraryService().rebuildFromDisk()
logger.info('[ZimService] Rebuilt Kiwix library after removing superseded ZIM(s).')
} catch (err) {
logger.error('[ZimService] Failed to rebuild Kiwix library after cleanup:', err)
}
}
}
/**
* Rebuilds the kiwix library XML from whatever ZIM files are currently on disk.
*
* This is the manual counterpart to the automatic rebuilds that run after a
* download or delete. It exists for the sideload case: a user copies a .zim file
* onto the box (USB, SSH, network share) outside the download flow, and kiwix has
* no way to discover it without regenerating the library index.
*
* In library mode (--monitorLibrary) kiwix-serve hot-reloads the XML on its own, so
* no restart is needed. Only legacy glob-mode containers are restarted to pick up
* the change. Returns the book count before and after plus the number added.
*/
async rescanLibrary(): Promise<{ before: number; after: number; added: number }> {
const kiwixLibraryService = new KiwixLibraryService()
const before = await kiwixLibraryService.getBookCount()
const after = await kiwixLibraryService.rebuildFromDisk()
const isLegacy = await this.dockerService.isKiwixOnLegacyConfig()
if (isLegacy) {
logger.info('[ZimService] Kiwix in legacy mode — restarting container after rescan.')
await this.dockerService
.affectContainer(SERVICE_NAMES.KIWIX, 'restart')
.catch((error) => {
logger.error('[ZimService] Failed to restart KIWIX container after rescan:', error)
})
}
return { before, after, added: Math.max(0, after - before) }
}
async registerLocalUpload(filename: string): Promise<{ added: number }> {
let added = 0
try {
const result = await this.rescanLibrary()
added = result.added
} catch (err) {
logger.error('[ZimService] Failed to rebuild kiwix library after local upload:', err)
}
const parsed = CollectionManifestService.parseZimFilename(filename)
if (parsed) {
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
const stats = await getFileStatsIfExists(filepath)
try {
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'zim' },
{
version: parsed.version,
url: `local-upload://${filename}`,
file_path: filepath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
} catch (error) {
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
}
}
// If the uploaded file matches a known Wikipedia option, mark it as installed
try {
const manifest = await CollectionManifest.find('wikipedia')
if (manifest) {
const spec = manifest.spec_data as { options: Array<{ id: string; url: string | null }> }
const matchedOption = spec.options.find(
(opt) => opt.url && opt.url.split('/').pop() === filename
)
if (matchedOption && matchedOption.url) {
const existing = await WikipediaSelection.query().first()
if (existing) {
existing.option_id = matchedOption.id
existing.url = matchedOption.url
existing.filename = filename
existing.status = 'installed'
await existing.save()
} else {
await WikipediaSelection.create({
option_id: matchedOption.id,
url: matchedOption.url,
filename,
status: 'installed',
})
}
logger.info(`[ZimService] Marked Wikipedia option '${matchedOption.id}' as installed from local upload`)
// Remove any other wikipedia_en_*.zim files, same as the download flow
const allFiles = await this.list()
const staleWikipediaFiles = allFiles.files.filter(
(f) => f.name.startsWith('wikipedia_en_') && f.name !== filename
)
for (const stale of staleWikipediaFiles) {
try {
await this.delete(stale.name)
logger.info(`[ZimService] Deleted stale Wikipedia file after upload: ${stale.name}`)
} catch (err) {
logger.warn(`[ZimService] Could not delete stale Wikipedia file: ${stale.name}`, err)
}
}
}
}
} catch (error) {
logger.error(`[ZimService] Failed to update WikipediaSelection for ${filename}:`, error)
}
const ollamaUrl = await this.dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) {
try {
const { EmbedFileJob } = await import('#jobs/embed_file_job')
await EmbedFileJob.dispatch({
fileName: filename,
filePath: join(process.cwd(), ZIM_STORAGE_PATH, filename),
})
} catch (error) {
logger.error(`[ZimService] EmbedFileJob dispatch failed after local upload:`, error)
}
}
return { added }
}
async delete(file: string): Promise<void> {
@ -387,6 +597,21 @@ export class ZimService {
.delete()
logger.info(`[ZimService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
}
// If this file was the active Wikipedia selection, clear the selection
try {
const selection = await WikipediaSelection.query().first()
if (selection && selection.filename === fileName) {
selection.option_id = 'none'
selection.status = 'none'
selection.filename = null
selection.url = null
await selection.save()
logger.info(`[ZimService] Cleared WikipediaSelection after deleting ${fileName}`)
}
} catch (error) {
logger.error(`[ZimService] Failed to clear WikipediaSelection after deleting ${fileName}:`, error)
}
}
// Wikipedia selector methods
@ -552,40 +777,192 @@ export class ZimService {
}
async onWikipediaDownloadComplete(url: string, success: boolean): Promise<void> {
const filename = url.split('/').pop() || ''
const selection = await this.getWikipediaSelection()
if (!selection || selection.url !== url) {
logger.warn(`[ZimService] Wikipedia download complete callback for unknown URL: ${url}`)
return
// Determine which Wikipedia option this file belongs to by matching filename
let matchedOptionId: string | null = null
try {
const options = await this.getWikipediaOptions()
for (const opt of options) {
if (opt.url && opt.url.split('/').pop() === filename) {
matchedOptionId = opt.id
break
}
}
} catch {
// If we can't fetch options, try to continue with existing selection
}
if (success) {
// Update status to installed
selection.status = 'installed'
await selection.save()
// Update or create the selection record
// Match by filename (not URL) so mirror downloads are recognized
if (selection) {
selection.option_id = matchedOptionId || selection.option_id
selection.url = url
selection.filename = filename
selection.status = 'installed'
await selection.save()
} else {
await WikipediaSelection.create({
option_id: matchedOptionId || 'unknown',
url: url,
filename: filename,
status: 'installed',
})
}
logger.info(`[ZimService] Wikipedia download completed successfully: ${selection.filename}`)
logger.info(`[ZimService] Wikipedia download completed successfully: ${filename}`)
// Delete the old Wikipedia file if it exists and is different
// We need to find what was previously installed
// Delete prior versions of THIS specific Wikipedia variant only.
// Earlier logic deleted anything starting with `wikipedia_en_`, which silently
// wiped distinct corpora the user had installed independently (issue #884).
const existingFiles = await this.list()
const wikipediaFiles = existingFiles.files.filter((f) =>
f.name.startsWith('wikipedia_en_') && f.name !== selection.filename
const wikipediaFiles = findReplacedWikipediaFiles(
filename,
existingFiles.files.map((f) => f.name)
)
for (const oldFile of wikipediaFiles) {
try {
await this.delete(oldFile.name)
logger.info(`[ZimService] Deleted old Wikipedia file: ${oldFile.name}`)
await this.delete(oldFile)
logger.info(`[ZimService] Deleted old Wikipedia file: ${oldFile}`)
} catch (error) {
logger.warn(`[ZimService] Could not delete old Wikipedia file: ${oldFile.name}`, error)
logger.warn(`[ZimService] Could not delete old Wikipedia file: ${oldFile}`, error)
}
}
} else {
// Download failed - keep the selection record but mark as failed
selection.status = 'failed'
await selection.save()
logger.error(`[ZimService] Wikipedia download failed for: ${selection.filename}`)
// Download failed - update selection if it matches this file
if (selection && (!selection.filename || selection.filename === filename)) {
selection.status = 'failed'
await selection.save()
logger.error(`[ZimService] Wikipedia download failed for: ${filename}`)
} else {
logger.error(`[ZimService] Wikipedia download failed for: ${filename} (no matching selection)`)
}
}
}
// Custom library source management
async listCustomLibraries(): Promise<CustomLibrarySource[]> {
return CustomLibrarySource.all()
}
async addCustomLibrary(name: string, baseUrl: string): Promise<CustomLibrarySource> {
const count = await CustomLibrarySource.query().count('* as total')
const total = Number(count[0].$extras.total)
if (total >= 10) {
throw new Error('Maximum of 10 custom libraries allowed')
}
// Ensure URL ends with /
const normalizedUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'
return CustomLibrarySource.create({
name,
base_url: normalizedUrl,
})
}
async removeCustomLibrary(id: number): Promise<void> {
const source = await CustomLibrarySource.find(id)
if (!source) {
throw new Error('Custom library not found')
}
if (source.is_default) {
throw new Error('Cannot remove a built-in mirror')
}
await source.delete()
}
async browseLibraryUrl(url: string): Promise<{
directories: { name: string; url: string }[]
files: { name: string; url: string; size_bytes: number | null }[]
}> {
assertNotPrivateUrl(url)
const normalizedUrl = url.endsWith('/') ? url : url + '/'
const res = await axios.get(normalizedUrl, {
responseType: 'text',
timeout: 15000,
headers: {
'Accept': 'text/html',
},
})
const html: string = res.data
const directories: { name: string; url: string }[] = []
const files: { name: string; url: string; size_bytes: number | null }[] = []
const $ = cheerio.load(html)
$('a').each((_, el) => {
const href = el.attribs?.href
if (!href || href === '../' || href === './' || href === '/' || href.startsWith('?') || href.startsWith('#')) {
return
}
if (href.startsWith('/') || href.startsWith('http://') || href.startsWith('https://')) {
return
}
if (href.endsWith('/')) {
const dirName = decodeURIComponent(href.replace(/\/$/, ''))
directories.push({
name: dirName,
url: new URL(href, normalizedUrl).toString(),
})
return
}
if (href.endsWith('.zim')) {
const fileName = decodeURIComponent(href)
// Apache/Nginx autoindex put the date + size in the text node directly
// following </a> within a <pre>. Walk forward across text siblings until
// we find a parseable size token.
let trailingText = ''
let sibling = el.next
while (sibling && sibling.type === 'text') {
trailingText += sibling.data
if (/\n/.test(sibling.data)) break
sibling = sibling.next
}
files.push({
name: fileName,
url: new URL(href, normalizedUrl).toString(),
size_bytes: this._parseListingSize(trailingText),
})
}
})
directories.sort((a, b) => a.name.localeCompare(b.name))
files.sort((a, b) => a.name.localeCompare(b.name))
return { directories, files }
}
/**
* Parse a directory-listing size token out of the text that follows an anchor.
* Apache renders e.g. ` 2024-01-15 10:30 5.1G`; Nginx renders raw bytes.
* Returns bytes or null if no size token is found.
*/
private _parseListingSize(text: string): number | null {
// Skip the date/time columns; grab the last numeric token (with optional suffix)
// before a newline. Matches `5.1G`, `5368709120`, `1.2T`, etc.
const sizeMatch = /([\d.]+\s*[KMGT]?B?|\d+)\s*$/i.exec(text.split('\n')[0].trim())
if (!sizeMatch) return null
const sizeStr = sizeMatch[1].replace(/\s|B$/gi, '')
const num = parseFloat(sizeStr)
if (isNaN(num)) return null
if (/^\d+$/.test(sizeStr)) return num
const suffix = sizeStr.slice(-1).toUpperCase()
const multipliers: Record<string, number> = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
return multipliers[suffix] ? Math.round(num * multipliers[suffix]) : 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

@ -6,6 +6,7 @@ import axios from 'axios'
import { Transform } from 'stream'
import { deleteFileIfExists, ensureDirectoryExists, getFileStatsIfExists } from './fs.js'
import { createWriteStream } from 'fs'
import { rename } from 'fs/promises'
import path from 'path'
/**
@ -27,13 +28,16 @@ export async function doResumableDownload({
const dirname = path.dirname(filepath)
await ensureDirectoryExists(dirname)
// Check if partial file exists for resume
// Stage download to a .tmp file so consumers (e.g. Kiwix) never see a partial file
const tempPath = filepath + '.tmp'
// Check if partial .tmp file exists for resume
let startByte = 0
let appendMode = false
const existingStats = await getFileStatsIfExists(filepath)
const existingStats = await getFileStatsIfExists(tempPath)
if (existingStats && !forceNew) {
startByte = existingStats.size
startByte = Number(existingStats.size)
appendMode = true
}
@ -43,8 +47,15 @@ export async function doResumableDownload({
timeout,
})
const contentType = headResponse.headers['content-type'] || ''
const totalBytes = parseInt(headResponse.headers['content-length'] || '0')
// Some upstream hosts (notably download.kiwix.org for .zim files) don't set a
// Content-Type header at all. Per RFC 7231 §3.1.1.5, "if no Content-Type is
// provided" the recipient may treat it as application/octet-stream — which is
// already in every binary-content allowlist we use (ZIM, PMTILES, base assets).
// Without this default, the validator below throws `MIME type is not allowed`
// and breaks all downloads from kiwix's primary host (#848).
const contentType =
headResponse.headers['content-type']?.toString() || 'application/octet-stream'
const totalBytes = parseInt(headResponse.headers['content-length']?.toString() || '0', 10)
const supportsRangeRequests = headResponse.headers['accept-ranges'] === 'bytes'
// If allowedMimeTypes is provided, check content type
@ -55,14 +66,24 @@ export async function doResumableDownload({
}
}
// If file is already complete and not forcing overwrite just return filepath
if (startByte === totalBytes && totalBytes > 0 && !forceNew) {
// If final file already exists at correct size, return early (idempotent)
const finalFileStats = await getFileStatsIfExists(filepath)
if (finalFileStats && Number(finalFileStats.size) === totalBytes && totalBytes > 0 && !forceNew) {
return filepath
}
// If server doesn't support range requests and we have a partial file, delete it
// If .tmp file is already at correct size (complete but never renamed), just rename it
if (startByte === totalBytes && totalBytes > 0 && !forceNew) {
await rename(tempPath, filepath)
if (onComplete) {
await onComplete(url, filepath)
}
return filepath
}
// If server doesn't support range requests and we have a partial .tmp file, delete it
if (!supportsRangeRequests && startByte > 0) {
await deleteFileIfExists(filepath)
await deleteFileIfExists(tempPath)
startByte = 0
appendMode = false
}
@ -72,17 +93,29 @@ export async function doResumableDownload({
headers.Range = `bytes=${startByte}-`
}
const response = await axios.get(url, {
responseType: 'stream',
headers,
signal,
timeout,
})
const fetchStream = (hdrs: Record<string, string>) =>
axios.get(url, { responseType: 'stream', headers: hdrs, signal, timeout })
let response = await fetchStream(headers)
if (response.status !== 200 && response.status !== 206) {
throw new Error(`Failed to download: HTTP ${response.status}`)
}
// If we requested a range but the server returned 200 (ignored the Range header),
// appending would corrupt the .tmp file — delete it and restart from byte 0.
if (headers.Range && response.status === 200) {
response.data.destroy()
await deleteFileIfExists(tempPath)
startByte = 0
appendMode = false
delete headers.Range
response = await fetchStream(headers)
if (response.status !== 200 && response.status !== 206) {
throw new Error(`Failed to download: HTTP ${response.status}`)
}
}
return new Promise((resolve, reject) => {
let downloadedBytes = startByte
let lastProgressTime = Date.now()
@ -131,11 +164,10 @@ export async function doResumableDownload({
},
})
const writeStream = createWriteStream(filepath, {
const writeStream = createWriteStream(tempPath, {
flags: appendMode ? 'a' : 'w',
})
// Handle errors and cleanup
const cleanup = (error?: Error) => {
clearStallTimer()
progressStream.destroy()
@ -149,7 +181,6 @@ export async function doResumableDownload({
response.data.on('error', cleanup)
progressStream.on('error', cleanup)
writeStream.on('error', cleanup)
writeStream.on('error', cleanup)
signal?.addEventListener('abort', () => {
cleanup(new Error('Download aborted'))
@ -157,6 +188,20 @@ export async function doResumableDownload({
writeStream.on('finish', async () => {
clearStallTimer()
try {
// Atomically move the completed .tmp file to the final path
await rename(tempPath, filepath)
} catch (renameError) {
// A parallel job may have completed the same file first — treat as success
// if the destination already exists at the expected size.
const existing = await getFileStatsIfExists(filepath)
if (existing && Number(existing.size) === totalBytes && totalBytes > 0) {
// fall through to resolve
} else {
reject(renameError)
return
}
}
if (onProgress) {
onProgress({
downloadedBytes,
@ -207,7 +252,7 @@ export async function doResumableDownloadWithRetry({
})
return result // return on success
} catch (error) {
} catch (error: any) {
attempt++
lastError = error as Error

View File

@ -1,4 +1,4 @@
import { mkdir, readdir, readFile, stat, unlink } from 'fs/promises'
import { mkdir, open, readdir, readFile, stat, unlink } from 'fs/promises'
import path, { join } from 'path'
import { FileEntry } from '../../types/files.js'
import { createReadStream } from 'fs'
@ -6,6 +6,22 @@ import { LSBlockDevice, NomadDiskInfoRaw } from '../../types/system.js'
export const ZIM_STORAGE_PATH = '/storage/zim'
export const KIWIX_LIBRARY_XML_PATH = '/storage/zim/kiwix-library.xml'
export const BOOKS_STORAGE_PATH = '/storage/books'
// Shared media root (Jellyfin reads it as /media; File Browser shows it as "media"). Per-type
// subfolders are pre-created on Jellyfin install — see _runPreinstallActions__Jellyfin.
export const MEDIA_STORAGE_PATH = '/storage/media'
export const JELLYFIN_MEDIA_SUBFOLDERS = ['Movies', 'TV Shows', 'Music', 'Photos']
// Empty Calibre library bundled into the admin image (see install/calibre-empty-library/).
// Seeded into storage/books on Calibre-Web install so it doesn't dead-end at db config.
export const CALIBRE_EMPTY_LIBRARY_ASSET_PATH = 'assets/calibre/metadata.db'
// Vaultwarden's /data volume. A self-signed TLS cert is generated here on install so the
// web vault has the secure context (HTTPS) it requires — see _runPreinstallActions__Vaultwarden.
export const VAULTWARDEN_STORAGE_PATH = '/storage/vaultwarden'
// MeshCore Web's working dir. On install a self-signed cert (certs/) and an SSL nginx config
// (nginx-ssl.conf) are generated here, then bind-mounted into the container so the static client is
// served over HTTPS — required for its Web Bluetooth/Serial connections. See
// _runPreinstallActions__MeshCoreWeb.
export const MESHCORE_WEB_STORAGE_PATH = '/storage/meshcore-web'
export async function listDirectoryContents(path: string): Promise<FileEntry[]> {
const entries = await readdir(path, { withFileTypes: true })
@ -99,6 +115,28 @@ export async function getFileStatsIfExists(
}
}
/**
* Validates that a file has the ZIM magic number (0x44D495A).
* Must be called before passing a file to @openzim/libzim Archive,
* because a corrupted ZIM causes a native C++ abort that cannot be
* caught by JS try/catch.
*/
export async function isValidZimFile(filePath: string): Promise<boolean> {
let fh
try {
fh = await open(filePath, 'r')
const buf = Buffer.alloc(4)
const { bytesRead } = await fh.read(buf, 0, 4, 0)
if (bytesRead < 4) return false
// ZIM magic number: 72 17 32 04 (little-endian 0x044D4953)
return buf[0] === 0x5a && buf[1] === 0x49 && buf[2] === 0x4d && buf[3] === 0x04
} catch {
return false
} finally {
await fh?.close()
}
}
export async function deleteFileIfExists(path: string): Promise<void> {
try {
await unlink(path)

View File

@ -0,0 +1,83 @@
import logger from '@adonisjs/core/services/logger'
import type { ContainerRegistryService } from '#services/container_registry_service'
import type { SystemService } from '#services/system_service'
/**
* Shared pre-flight primitives for update flows (core app + installed apps).
* Kept framework-light (plain functions + injected service instances) so both
* {@link AutoUpdateService} and {@link AppAutoUpdateService} reuse one implementation.
*/
export type BlockerSeverity = 'skip' | 'failure'
export interface Blocker {
reason: string
severity: BlockerSeverity
}
export interface PreflightResult {
ok: boolean
blockers: Blocker[]
}
/** Require free space >= imageSize * factor to cover decompressed layers + headroom. */
export const DISK_SAFETY_FACTOR = 2
/** Conservative fallback when the registry image size can't be determined. */
export const MIN_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GiB
/** Free bytes on the root filesystem (best-effort, falls back to max available). */
export async function getFreeBytes(systemService: SystemService): Promise<number | null> {
const info = await 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))
}
function gib(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`
}
/**
* Returns a `failure` disk blocker if free space is insufficient for the given
* image reference, otherwise null. Mirrors the core update's behavior: estimate
* the image's compressed download size from the registry manifest, require
* `size * DISK_SAFETY_FACTOR` (or {@link MIN_FREE_BYTES} when size is unknown),
* and never block on transient lookup errors (returns null on failure).
*
* @param image Full image reference INCLUDING tag (e.g. "ollama/ollama:0.23.2").
*/
export async function checkImageDiskSpace(params: {
image: string
hostArch: string
containerRegistryService: ContainerRegistryService
systemService: SystemService
}): Promise<Blocker | null> {
const { image, hostArch, containerRegistryService, systemService } = params
try {
const parsed = containerRegistryService.parseImageReference(image)
const imageSize = await containerRegistryService.getImageDownloadSize(
parsed,
parsed.tag,
hostArch
)
const required = imageSize !== null ? imageSize * DISK_SAFETY_FACTOR : MIN_FREE_BYTES
const free = await getFreeBytes(systemService)
if (free === null) {
logger.warn('[ImageDiskPreflight] Could not determine free disk space; skipping disk check')
return null
}
if (free < required) {
return {
reason: `Insufficient disk space: ${gib(free)} free, ${gib(required)} required`,
severity: 'failure',
}
}
return null
} catch (error) {
logger.warn(`[ImageDiskPreflight] Disk space check failed: ${error.message}`)
return null
}
}

View File

@ -0,0 +1,70 @@
import type { KbIngestStateValue } from '../../types/kb_ingest_state.js'
/**
* Decision returned by `decideScanAction` describing what scanAndSyncStorage
* should do for one file given its current state row (if any), whether Qdrant
* already has chunks for it, and the global ingest policy.
*
* - `skip` file is in a settled state (already indexed, deliberately not
* indexed, or in a manual-recovery state); no auto-dispatch.
* - `dispatch` file needs to be (re-)embedded; an EmbedFileJob should be
* dispatched. `createStateRow` indicates whether a new state row needs to
* be created before dispatch (i.e. first time the scanner has seen it).
* - `backfill_indexed` Qdrant has chunks but no state row exists yet
* (pre-RFC install, or new admin instance pointed at an existing Qdrant
* volume). Create a row in `indexed` state without re-embedding.
* - `create_pending` Manual mode: record that we've seen the file but
* don't dispatch. Frontend surfaces a per-card "Index" affordance.
*/
export type ScanAction =
| { kind: 'skip' }
| { kind: 'dispatch'; createStateRow: boolean }
| { kind: 'backfill_indexed' }
| { kind: 'create_pending' }
export interface KbIngestStateRow {
state: KbIngestStateValue
}
/**
* Global auto-index policy stored at KV `rag.defaultIngestPolicy`. Unset is
* treated as `Always` so existing installs keep their current behavior until
* the user opts into Manual mode through the KB panel.
*/
export type IngestPolicy = 'Always' | 'Manual'
/**
* Decide what scanAndSyncStorage should do for a single embeddable file.
*
* Replaces the earlier `!sourcesInQdrant.has(filePath)` binary check, which
* couldn't tell a fully-indexed file from a stalled mid-batch ingestion, and
* couldn't honor a user's "browse only" choice. The state row is now the
* authoritative answer; Qdrant chunk presence is corroborating evidence.
*/
export function decideScanAction(
stateRow: KbIngestStateRow | null,
hasChunksInQdrant: boolean,
policy: IngestPolicy = 'Always'
): ScanAction {
if (!stateRow) {
if (hasChunksInQdrant) return { kind: 'backfill_indexed' }
return policy === 'Always'
? { kind: 'dispatch', createStateRow: true }
: { kind: 'create_pending' }
}
switch (stateRow.state) {
case 'indexed':
return hasChunksInQdrant ? { kind: 'skip' } : { kind: 'dispatch', createStateRow: false }
case 'pending_decision':
// Manual mode: file is waiting for the user to opt in via per-card Index.
// Always mode: treat as "user-equivalent of auto-index" and dispatch.
return policy === 'Always'
? { kind: 'dispatch', createStateRow: false }
: { kind: 'skip' }
case 'browse_only':
case 'failed':
case 'stalled':
return { kind: 'skip' }
}
}

View File

@ -0,0 +1,50 @@
/**
* Visual status assigned to an in-flight (or stuck) embedding job, used to
* pick the colored status pill in the KB Processing Queue. See RFC #883 §5.
*
* - `waiting` queued, no batch has started yet
* - `healthy` last batch < 2 minutes ago
* - `slow` last batch 2-5 minutes ago (CPU-paced multi-batch ingestion
* falls into this band; not necessarily a problem)
* - `stalled` last batch > 5 minutes ago (likely a real problem)
* - `failed` job recorded a failed status
*/
export type JobHealthStatus = 'waiting' | 'healthy' | 'slow' | 'stalled' | 'failed'
export interface JobHealthInput {
/** BullMQ job.data.status — set by EmbedFileJob.handle on transitions. */
status: string
/** 0-100. 0 means no work observed yet on this job-row. */
progress: number
/** ms epoch of the last completed batch. Multi-batch ZIMs update this on
* every continuation; single-batch jobs leave it unset until completion. */
lastBatchAt?: number
/** ms epoch of the first batch start. Used as a fallback "last activity"
* signal for jobs that haven't yet completed their first batch. */
startedAt?: number
/** Current ms epoch. Injected for testability. */
now: number
}
const SLOW_THRESHOLD_MS = 2 * 60 * 1000
const STALLED_THRESHOLD_MS = 5 * 60 * 1000
export function computeJobHealth(input: JobHealthInput): JobHealthStatus {
if (input.status === 'failed') return 'failed'
// No progress recorded and no activity timestamps — job is still queued.
if (
input.progress === 0 &&
input.lastBatchAt === undefined &&
input.startedAt === undefined
) {
return 'waiting'
}
const lastActivity = input.lastBatchAt ?? input.startedAt ?? input.now
const stalenessMs = input.now - lastActivity
if (stalenessMs > STALLED_THRESHOLD_MS) return 'stalled'
if (stalenessMs > SLOW_THRESHOLD_MS) return 'slow'
return 'healthy'
}

View File

@ -0,0 +1,109 @@
export interface RatioRow {
pattern: string
chunks_per_mb: number
}
/**
* Bytes of on-disk storage one embedded chunk consumes inside Qdrant.
*
* Rough composition for our pipeline:
* - vector: 768 dims × float32 = 3,072 B
* - chunk text payload: ~3,000 B (target 1,500 tokens × 2 chars/token)
* - source/metadata payload + Qdrant indexes: ~2,000 B
*
* Used for surfacing pre-ingest disk-cost estimates; the actual figure
* varies with collection params and will be replaced by self-calibration
* (RFC #883 Phase 4) once we have real measurements.
*/
export const BYTES_PER_CHUNK_ON_DISK = 8_000
export interface BatchEstimateInput {
filename: string
sizeBytes: number
}
export interface BatchEstimate {
totalChunks: number
totalBytes: number
hasUnknown: boolean
}
/**
* Aggregate an embedding-disk-cost estimate across a batch of files (curated
* tier add, multi-upload, sync preview, etc). `hasUnknown` is true when at
* least one file did not match any registry row the totals only include
* matched files, so callers should annotate "estimate excludes unknown files"
* when surfacing the figure.
*/
export function estimateBatch(
files: BatchEstimateInput[],
rows: RatioRow[]
): BatchEstimate {
let totalChunks = 0
let hasUnknown = false
for (const f of files) {
const chunks = estimateChunkCount(f.filename, f.sizeBytes, rows)
if (chunks === null) {
hasUnknown = true
} else {
totalChunks += chunks
}
}
return {
totalChunks,
totalBytes: totalChunks * BYTES_PER_CHUNK_ON_DISK,
hasUnknown,
}
}
/**
* Pick the chunks_per_mb estimate for a filename by longest-prefix match.
*
* Patterns are filename prefixes (`devdocs_`, `wikipedia_en_simple_`, ...).
* The longest matching prefix wins, so a specific entry (`wikipedia_en_simple_`)
* overrides the broader fallback (`wikipedia_en_`). An empty-string pattern in
* the registry serves as a catch-all that matches every input.
*
* Returns `null` if no row matches and no empty-string fallback is present
* caller decides whether to surface "unknown" or use its own default.
*
* `ignoreCatchAll` excludes the empty-string catch-all row from matching, so a
* filename that only the fallback would have matched returns `null` instead.
* Callers that need a *specific* expectation (e.g. the partial_stall warning,
* which must not fire on atypical ZIMs the registry can't actually characterize)
* pass this; rough aggregate estimates (disk cost) leave it off. See #913.
*/
export function findChunksPerMb(
filename: string,
rows: RatioRow[],
opts: { ignoreCatchAll?: boolean } = {}
): number | null {
let best: RatioRow | null = null
for (const row of rows) {
if (opts.ignoreCatchAll && row.pattern === '') continue
if (!filename.startsWith(row.pattern)) continue
if (best === null || row.pattern.length > best.pattern.length) {
best = row
}
}
return best === null ? null : best.chunks_per_mb
}
/**
* Estimate the number of embedding chunks a ZIM-style file will produce given
* its size on disk in bytes. Returns `null` when the registry has nothing to
* match against. Caller is responsible for converting the estimate into either
* a disk-footprint estimate (chunks × bytes-per-chunk in Qdrant) or a time
* estimate (chunks ÷ chunks-per-minute-on-this-hardware).
*/
export function estimateChunkCount(
filename: string,
fileSizeBytes: number,
rows: RatioRow[],
opts: { ignoreCatchAll?: boolean } = {}
): number | null {
const ratio = findChunksPerMb(filename, rows, opts)
if (ratio === null) return null
const megabytes = fileSizeBytes / (1024 * 1024)
return Math.round(ratio * megabytes)
}

View File

@ -0,0 +1,70 @@
/**
* Conditional warnings surfaced on Stored Files rows in the KB panel.
* See RFC #883 §6 these warnings appear ONLY when their triggering condition
* is met, never on healthy files, to keep the panel silent in the common case.
*
* - `zero_chunks` a non-trivial file produced 0 embedding chunks. Common
* cause: video-only or image-only ZIMs that the pipeline
* completes "successfully" with no extractable text.
* AI Assistant cannot reference this content.
* - `partial_stall` the file has embedded chunks but well below the count
* expected from the ratio registry. Likely a mid-batch
* stall (which the binary "any chunks ⇒ embedded" check
* used to mask). Surfaces a Retry affordance.
*/
import type { FileWarning } from '../../types/rag.js'
export type { FileWarning }
/** Files smaller than this are too small to flag as suspicious zero-chunk
* cases a 5 KB upload that produces 0 chunks is much more likely to be a
* legitimate edge case (placeholder file) than the gigabyte-scale video ZIM
* problem this warning targets. */
export const ZERO_CHUNKS_MIN_SIZE_BYTES = 100 * 1024 * 1024 // 100 MB
/** Fraction of expected chunks below which we consider a file partially
* stalled. 0.5 (50%) matches the threshold described in RFC #883 §6 Warning B. */
export const PARTIAL_STALL_RATIO_THRESHOLD = 0.5
export interface WarningInputs {
/** Source file size on disk in bytes. */
fileSizeBytes: number
/** Distinct chunks present in Qdrant for this source. */
chunksInQdrant: number
/** Best estimate of chunks the file should produce, from the ratio
* registry. `null` when no registry pattern matches and no fallback is
* configured Warning B is suppressed in that case (we'd rather be silent
* than wrong). */
expectedChunks: number | null
}
export function decideWarnings(inputs: WarningInputs): FileWarning[] {
const warnings: FileWarning[] = []
// Warning A: file is large but produced nothing. Almost always a video-only
// or image-only ZIM; AI Assistant literally cannot reference this content.
if (
inputs.chunksInQdrant === 0 &&
inputs.fileSizeBytes > ZERO_CHUNKS_MIN_SIZE_BYTES
) {
warnings.push({ kind: 'zero_chunks', fileSizeBytes: inputs.fileSizeBytes })
}
// Warning B: chunks present but far below expectation. Suppresses when we
// have no expectation (registry miss) since the comparison would be
// meaningless and we'd rather under-warn than mislead.
if (
inputs.expectedChunks !== null &&
inputs.expectedChunks > 0 &&
inputs.chunksInQdrant > 0 &&
inputs.chunksInQdrant < inputs.expectedChunks * PARTIAL_STALL_RATIO_THRESHOLD
) {
warnings.push({
kind: 'partial_stall',
chunksEmbedded: inputs.chunksInQdrant,
chunksExpected: inputs.expectedChunks,
})
}
return warnings
}

View File

@ -0,0 +1,72 @@
import { resolve, sep } from 'node:path'
/**
* Decides whether a curated resource's PREVIOUSLY-installed file should be
* deleted now that a newer version has been downloaded (issue #634 old map
* and ZIM versions accumulated on disk indefinitely because only Wikipedia had
* version cleanup).
*
* This is intentionally a pure function so every safety rail is unit-testable
* without touching the DB or filesystem. The caller looks up the prior
* `InstalledResource` row, records the new version, then asks this whether the
* old file is safe to remove.
*
* Safety rails (a "delete" decision requires ALL of these):
* - There was a prior install for this exact resource_id (`existing` non-null).
* Untracked / sideloaded files have no row and are therefore never touched.
* - The old file path actually differs from the new one (a genuine version
* swap, not a re-download of the same file).
* - The new file is confirmed present on disk we never remove the old copy
* before the replacement is verified.
* - The new version is strictly newer than the recorded one, so a re-install
* or downgrade can't wipe a newer file.
* - The old path resolves to within the resource's storage directory, so a
* malformed DB value can't direct a delete outside the content store.
*/
export interface SupersededInputs {
/** Prior InstalledResource row for this resource_id, or null on first install. */
existing: { file_path: string; version: string } | null
/** Absolute path of the newly downloaded file. */
newFilePath: string
/** Version of the newly downloaded file (e.g. "2026-05"). */
newVersion: string
/** Whether the new file is confirmed present on disk. */
newFileExists: boolean
/** Absolute storage directory the old file must live under to be eligible. */
storageBaseDir: string
}
export type SupersededReason =
| 'first_install'
| 'same_file'
| 'new_file_missing'
| 'not_newer'
| 'outside_storage'
| 'superseded'
export interface SupersededDecision {
delete: boolean
/** Resolved old path to delete — set only when `delete` is true. */
path?: string
reason: SupersededReason
}
export function decideSupersededDeletion(inputs: SupersededInputs): SupersededDecision {
const { existing, newFilePath, newVersion, newFileExists, storageBaseDir } = inputs
if (!existing) return { delete: false, reason: 'first_install' }
if (existing.file_path === newFilePath) return { delete: false, reason: 'same_file' }
if (!newFileExists) return { delete: false, reason: 'new_file_missing' }
// Versions are zero-padded date strings (YYYY-MM / YYYY-MM-DD), so a lexical
// compare orders them correctly. Require strictly newer.
if (!(newVersion > existing.version)) return { delete: false, reason: 'not_newer' }
const resolvedOld = resolve(existing.file_path)
const base = resolve(storageBaseDir)
if (resolvedOld !== base && !resolvedOld.startsWith(base + sep)) {
return { delete: false, reason: 'outside_storage' }
}
return { delete: true, path: resolvedOld, reason: 'superseded' }
}

View File

@ -0,0 +1,35 @@
import { DateTime } from 'luxon'
/**
* Shared update-window helpers used by both the core auto-update
* ({@link AutoUpdateService}) and the per-app auto-update ({@link AppAutoUpdateService}).
*
* 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 supported.
*/
/** Parse an "HH:MM" 24-hour string into minutes-since-midnight, or null if malformed. */
export function parseWindowMinutes(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])
}
/** Whether `now` falls inside the [windowStart, windowEnd) window (handles midnight wrap). */
export function isWithinWindow(
windowStart: string,
windowEnd: string,
now: DateTime = DateTime.now()
): boolean {
const start = parseWindowMinutes(windowStart)
const end = parseWindowMinutes(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
}

View File

@ -0,0 +1,26 @@
/**
* Strip the trailing `_YYYY-MM(-DD).zim` date suffix from a Kiwix-style ZIM
* filename so different release dates of the same variant share a stem
* (e.g., `wikipedia_en_all_nopic`) while distinct corpora keep distinct stems
* (`wikipedia_en_simple_all_nopic`, `wikipedia_en_medicine_nopic`, etc.).
*/
export function zimFilenameStem(name: string): string {
return name.replace(/_\d{4}-\d{2}(?:-\d{2})?\.zim$/i, '')
}
/**
* Of the existing files, return only those that are prior-version replacements
* of `currentFilename` same Wikipedia variant stem, different release. Used
* by the post-download cleanup to avoid deleting unrelated Wikipedia corpora
* the user has installed independently (issue #884).
*/
export function findReplacedWikipediaFiles(
currentFilename: string,
existingNames: string[]
): string[] {
const currentStem = zimFilenameStem(currentFilename)
return existingNames.filter(
(n) =>
n.startsWith('wikipedia_en_') && n !== currentFilename && zimFilenameStem(n) === currentStem
)
}

View File

@ -1,4 +1,5 @@
import vine from '@vinejs/vine'
import ipaddr from 'ipaddr.js'
/**
* Checks whether a URL points to a loopback or link-local address.
@ -13,22 +14,100 @@ import vine from '@vinejs/vine'
*/
export function assertNotPrivateUrl(urlString: string): void {
const parsed = new URL(urlString)
const hostname = parsed.hostname.toLowerCase()
const blockedPatterns = [
/^localhost$/,
/^127\.\d+\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
/^\[::1\]$/,
/^\[?fe80:/i, // IPv6 link-local
/^\[::ffff:/i, // IPv4-mapped IPv6 (e.g. [::ffff:7f00:1] = 127.0.0.1)
/^\[::\]$/, // IPv6 all-zeros (equivalent to 0.0.0.0)
]
// Normalize the host before classifying it:
// - lowercase for the `localhost` comparison
// - strip the surrounding brackets `URL.hostname` leaves on IPv6 literals
// (`http://[::1]/` → `::1`)
// - strip any trailing root dot(s): `localhost.` and `127.0.0.1.` resolve to
// the same target as the dotless form, so they must not slip past the
// checks below (#911).
const hostname = parsed.hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.+$/, '')
if (blockedPatterns.some((re) => re.test(hostname))) {
if (hostname === 'localhost') {
throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)
}
// Anything that isn't a literal IP (DNS names, bare LAN hostnames like
// `nomad3`, external FQDNs) is allowed — LAN appliances need them, and DNS
// rebinding is a fetch-time concern outside this guard's scope. Classifying
// literal addresses with ipaddr.js (rather than a regex list) catches
// alternate encodings and normalizes address ranges correctly (#922).
if (!ipaddr.isValid(hostname)) return
let addr = ipaddr.parse(hostname)
if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) {
// e.g. ::ffff:127.0.0.1 — classify by the embedded IPv4 so a mapped
// loopback/link-local is blocked while a mapped public IP is allowed.
addr = (addr as ipaddr.IPv6).toIPv4Address()
}
const range = addr.range()
if (range === 'loopback' || range === 'linkLocal' || range === 'unspecified') {
throw new Error(
`Download URL must not point to a loopback or link-local address: ${addr.toNormalizedString()}`
)
}
}
/**
* Narrower SSRF guard for "remote service" URLs the user points NOMAD at
* (e.g. an OpenAI-compatible endpoint like LM Studio, llama.cpp, vLLM, or a
* sibling Ollama container). Unlike `assertNotPrivateUrl`, this intentionally
* ALLOWS loopback, link-local-ish, and RFC1918 hosts because the legitimate
* target is frequently on the same host or LAN (host.docker.internal,
* the docker bridge gateway, or a LAN IP).
*
* It blocks only:
* - the cloud instance-metadata IP (169.254.169.254), to avoid leaking
* IAM creds on a misconfigured cloud VM
* - non-HTTP schemes (file:, gopher:, etc.)
*/
// Canonical cloud instance-metadata addresses. AWS, GCP, Azure, DigitalOcean,
// Oracle Cloud, and Alibaba all expose IMDS at 169.254.169.254 over IPv4;
// AWS additionally exposes it at fd00:ec2::254 over IPv6.
// Compared after `ipaddr.toNormalizedString()`, which expands IPv6 to its
// fully-zero-padded form (e.g. `fd00:ec2::254` → `fd00:ec2:0:0:0:0:0:254`).
const BLOCKED_METADATA_IPV4 = new Set(['169.254.169.254'])
const BLOCKED_METADATA_IPV6 = new Set([
ipaddr.parse('fd00:ec2::254').toNormalizedString(),
])
export function assertNotCloudMetadataUrl(urlString: string): void {
const parsed = new URL(urlString)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`URL must use http or https scheme: ${parsed.protocol}`)
}
// Node's WHATWG URL parser keeps the brackets on IPv6 literals
// (`http://[::1]/` → hostname `[::1]`), so strip them before parsing.
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
// If the hostname isn't an IP literal it's a DNS name; allow it. (DNS
// rebinding is out of scope here — would require resolving and re-checking
// at fetch time.)
if (!ipaddr.isValid(hostname)) return
let addr = ipaddr.parse(hostname)
// Unwrap IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254, ::ffff:a9fe:a9fe,
// and the fully-expanded 0:0:0:0:0:ffff:a9fe:a9fe) so the IPv4 check below
// sees the embedded address.
if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) {
addr = (addr as ipaddr.IPv6).toIPv4Address()
}
const canonical = addr.toNormalizedString()
const blocked =
addr.kind() === 'ipv4' ? BLOCKED_METADATA_IPV4 : BLOCKED_METADATA_IPV6
if (blocked.has(canonical)) {
throw new Error(`URL must not point to the cloud instance metadata endpoint: ${canonical}`)
}
}
export const remoteDownloadValidator = vine.compile(
@ -100,6 +179,7 @@ const resourceUpdateInfoBase = vine.object({
installed_version: vine.string().trim(),
latest_version: vine.string().trim().minLength(1),
download_url: vine.string().url({ require_tld: false }).trim(),
size_bytes: vine.number().positive().optional(),
})
export const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase)
@ -111,3 +191,31 @@ export const applyAllContentUpdatesValidator = vine.compile(
.minLength(1),
})
)
// --- Map extract (regional pmtiles download) ---
// ISO 3166-1 alpha-2, 2 letters. Loose regex; CountriesService.resolveCodes
// does the authoritative check against the polygon dataset.
const countryCodeSchema = vine
.string()
.trim()
.toUpperCase()
.regex(/^[A-Z]{2}$/)
const countriesArraySchema = vine.array(countryCodeSchema).minLength(1).maxLength(300)
export const mapExtractPreflightValidator = vine.compile(
vine.object({
countries: countriesArraySchema.clone(),
maxzoom: vine.number().min(0).max(15).optional(),
})
)
export const mapExtractValidator = vine.compile(
vine.object({
countries: countriesArraySchema.clone(),
maxzoom: vine.number().min(0).max(15).optional(),
label: vine.string().trim().minLength(1).maxLength(64).optional(),
estimatedBytes: vine.number().min(0).optional(),
})
)

View File

@ -14,6 +14,12 @@ export const chatSchema = vine.compile(
})
)
export const unloadChatModelsSchema = vine.compile(
vine.object({
targetModel: vine.string().trim().minLength(1).nullable().optional(),
})
)
export const getAvailableModelsSchema = vine.compile(
vine.object({
sort: vine.enum(['pulls', 'name'] as const).optional(),

View File

@ -11,3 +11,30 @@ export const deleteFileSchema = vine.compile(
source: vine.string(),
})
)
export const embedFileSchema = vine.compile(
vine.object({
source: vine.string().minLength(1),
force: vine.boolean().optional(),
})
)
export const fileSourceSchema = vine.compile(
vine.object({
source: vine.string().minLength(1),
})
)
export const estimateBatchSchema = vine.compile(
vine.object({
files: vine
.array(
vine.object({
filename: vine.string().minLength(1).maxLength(255),
sizeBytes: vine.number().min(0),
})
)
.minLength(1)
.maxLength(500),
})
)

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,60 @@ 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':
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 '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 'system.internetStatusTestUrl': {
// Empty clears the setting (reverts to env var / built-in defaults).
if (value === '' || value === undefined || value === null) {
return null
}
if (typeof value !== 'string') {
return 'Test URL must be a string.'
}
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Test URL must use http or https.'
}
} catch {
return 'Test URL must be a valid URL (e.g. "https://example.com").'
}
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

@ -31,3 +31,142 @@ export const updateServiceValidator = vine.compile(
target_version: vine.string().trim(),
})
)
export const preflightValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
})
)
// Toggle per-app automatic updates (opt-in). The global master switch lives in
// the KVStore (`appAutoUpdate.enabled`) and flows through the settings endpoint.
export const setServiceAutoUpdateValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
enabled: vine.boolean(),
})
)
// Shared sub-schema for a volume bind mapping. A colon is Docker's bind delimiter
// (host:container:options) — forbid it in either field so a path can't smuggle in an
// extra segment that the guard reads as safe but Docker re-parses as a different mount.
const volumeSchema = vine.object({
host_path: vine.string().trim().regex(/^[^:]+$/),
container_path: vine.string().trim().regex(/^[^:]+$/),
})
// Environment variables must be KEY=value (value may be empty), matching Docker's Env format.
const envVarSchema = vine.string().trim().regex(/^[A-Za-z_][A-Za-z0-9_]*=[\s\S]*$/)
// Service-less preflight for the custom-app form: evaluates ports, volumes and image together.
export const preflightCustomValidator = vine.compile(
vine.object({
image: vine.string().trim().optional(),
ports: vine.array(vine.number().min(1).max(65535)).optional(),
volumes: vine.array(volumeSchema).optional(),
// When editing, ignore port conflicts caused by this app's own running container.
exclude_service: vine.string().trim().optional(),
})
)
export const customAppValidator = vine.compile(
vine.object({
friendly_name: vine.string().trim().minLength(1).maxLength(100),
image: vine.string().trim().minLength(1),
ports: vine
.array(
vine.object({
container: vine.number().min(1).max(65535),
host: vine.number().min(1024).max(65535),
})
)
.optional(),
volumes: vine.array(volumeSchema).optional(),
env: vine.array(envVarSchema).optional(),
category: vine
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
.optional(),
icon: vine.string().trim().optional(),
// Optional resource caps (advanced). Default caps are applied when omitted.
memory_mb: vine.number().min(64).optional(),
cpus: vine.number().min(0.1).max(64).optional(),
// When true, bypass advisory preflight (port conflicts / guard warnings) and install anyway.
force: vine.boolean().optional(),
})
)
// Set or clear an app's custom launch URL. A null/empty value clears the override; a non-empty
// value is normalized + validated to a http(s) URL by normalizeCustomUrl in the controller.
export const setServiceCustomUrlValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
custom_url: vine.string().trim().nullable(),
})
)
/**
* Normalize a user-supplied custom app URL (backend twin of the inertia helper in
* lib/navigation.ts). Accepts a bare host or a full URL; prepends http:// when no scheme is
* present. Returns the normalized href, or null when empty (clears the override) or not a valid
* http(s) URL. Restricting to http/https blocks javascript:/data: from ever being stored.
*/
export function normalizeCustomUrl(input: string | null | undefined): string | null {
const trimmed = (input ?? '').trim()
if (!trimmed) return null
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.href
} catch {
return null
}
}
export const deleteCustomAppValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
// When true, also remove the backing Docker image (best-effort).
remove_image: vine.boolean().optional(),
})
)
export const uninstallServiceValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
// When true, also remove the backing Docker image (best-effort).
remove_image: vine.boolean().optional(),
})
)
export const serviceLogsValidator = vine.compile(
vine.object({
tail: vine.number().min(1).max(2000).optional(),
})
)
// Reconfigure an existing custom app: the create shape plus the target service_name.
export const updateCustomAppValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
friendly_name: vine.string().trim().minLength(1).maxLength(100),
image: vine.string().trim().minLength(1),
ports: vine
.array(
vine.object({
container: vine.number().min(1).max(65535),
host: vine.number().min(1024).max(65535),
})
)
.optional(),
volumes: vine.array(volumeSchema).optional(),
env: vine.array(envVarSchema).optional(),
category: vine
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
.optional(),
icon: vine.string().trim().optional(),
memory_mb: vine.number().min(64).optional(),
cpus: vine.number().min(0.1).max(64).optional(),
force: vine.boolean().optional(),
})
)

View File

@ -7,3 +7,30 @@ export const listRemoteZimValidator = vine.compile(
query: vine.string().optional(),
})
)
export const addCustomLibraryValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(100),
base_url: vine
.string()
.url({ require_tld: false })
.trim(),
})
)
export const browseLibraryValidator = vine.compile(
vine.object({
url: vine
.string()
.url({ require_tld: false })
.trim(),
})
)
export const idParamValidator = vine.compile(
vine.object({
params: vine.object({
id: vine.number(),
}),
})
)

View File

@ -0,0 +1,227 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
/**
* Exercise the app auto-update decision logic WITHOUT ever triggering a real update.
*
* # Prove the per-app eligibility + window logic deterministically (no DB/Docker):
* node ace app-auto-update:dry-run --scenarios
*
* # Show, against the live DB, which opted-in apps WOULD update right now:
* node ace app-auto-update:dry-run
*/
export default class AppAutoUpdateDryRun extends BaseCommand {
static commandName = 'app-auto-update:dry-run'
static description = 'Dry-run the app auto-update decision logic (never triggers an update)'
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
declare scenarios: 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 { ContainerRegistryService } = await import('#services/container_registry_service')
const { QueueService } = await import('#services/queue_service')
const { AppAutoUpdateService } = await import('#services/app_auto_update_service')
const { isWithinWindow } = await import('../../app/utils/update_window.js')
const dockerService = new DockerService()
const svc = new AppAutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new ContainerRegistryService()
)
if (this.scenarios) {
const ok = this.runScenarios(svc, DateTime, isWithinWindow)
if (!ok) this.exitCode = 1
return
}
// --- Live read-only snapshot (no update triggered) ----------------------
const status = await svc.getStatus()
this.logger.log('')
this.logger.log(` Master switch : ${status.enabled ? 'enabled' : 'disabled'}`)
this.logger.log(
` Window : ${status.windowStart}-${status.windowEnd} ` +
`(currently ${status.withinWindow ? 'inside' : 'outside'})`
)
this.logger.log(` Cool-off hours : ${status.cooloffHours}`)
this.logger.log('')
if (status.apps.length === 0) {
this.logger.info('No apps are opted into auto-update.')
return
}
this.logger.log('Opted-in apps:')
for (const app of status.apps) {
const tag = app.eligible ? this.colors.green('WOULD UPDATE') : this.colors.dim('skip')
this.logger.log(
` ${tag} ${app.friendly_name || app.service_name}: ${app.current_version}` +
`${app.available_update_version ? ' → ' + app.available_update_version : ''}${app.reason}`
)
}
}
/**
* Deterministic acceptance suite over the pure decision helpers no DB or Docker.
* Uses ContainerRegistryService.parseImageReference (pure) via appEligibility.
*/
private runScenarios(svc: any, DateTime: any, isWithinWindow: any): boolean {
const now = DateTime.fromISO('2026-06-04T12:00:00Z')
const daysAgo = (d: number) => now.minus({ days: d })
const hoursAgo = (h: number) => now.minus({ hours: h })
const mk = (o: Record<string, any>) => ({
service_name: 'nomad_test',
container_image: 'ollama/ollama:0.18.1',
available_update_version: null,
available_update_first_seen_at: null,
auto_update_disabled_reason: null,
...o,
})
type Case = { name: string; service: any; cooloff: number; expect: boolean }
const cases: Case[] = [
{ name: 'no update → not eligible', service: mk({}), cooloff: 72, expect: false },
{
name: 'major bump → not eligible',
service: mk({
available_update_version: '1.0.0',
available_update_first_seen_at: daysAgo(10),
}),
cooloff: 72,
expect: false,
},
{
name: 'minor newer inside cool-off → not eligible',
service: mk({
available_update_version: '0.19.0',
available_update_first_seen_at: hoursAgo(10),
}),
cooloff: 72,
expect: false,
},
{
name: 'minor newer past cool-off → eligible',
service: mk({
available_update_version: '0.19.0',
available_update_first_seen_at: daysAgo(5),
}),
cooloff: 72,
expect: true,
},
{
name: 'null first-seen → not eligible',
service: mk({ available_update_version: '0.19.0', available_update_first_seen_at: null }),
cooloff: 72,
expect: false,
},
{
name: 'self-disabled → not eligible',
service: mk({
available_update_version: '0.19.0',
available_update_first_seen_at: daysAgo(30),
auto_update_disabled_reason: 'disabled',
}),
cooloff: 72,
expect: false,
},
{
name: ':latest pinned → not eligible',
service: mk({
container_image: 'foo/bar:latest',
available_update_version: '1.2.3',
available_update_first_seen_at: daysAgo(30),
}),
cooloff: 72,
expect: false,
},
{
name: 'cool-off 0 applies immediately',
service: mk({
available_update_version: '0.18.2',
available_update_first_seen_at: hoursAgo(1),
}),
cooloff: 0,
expect: true,
},
]
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 @ 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 cases) {
const got = svc.appEligibility(c.service, c.cooloff, now).eligible
const ok = got === c.expect
this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`)
ok ? passed++ : failed++
}
this.logger.log('')
this.logger.log('Window scenarios:')
for (const c of windows) {
const got = isWithinWindow(c.start, c.end, 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

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

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

@ -3,11 +3,15 @@ import type { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import { RunDownloadJob } from '#jobs/run_download_job'
import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job'
import { DownloadModelJob } from '#jobs/download_model_job'
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'
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'
@ -89,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) => {
@ -102,6 +136,9 @@ export default class QueueWork extends BaseCommand {
// Schedule nightly update checks (idempotent, will persist over restarts)
await CheckUpdateJob.scheduleNightly()
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
@ -126,18 +163,26 @@ export default class QueueWork extends BaseCommand {
const queues = new Map<string, string>()
handlers.set(RunDownloadJob.key, new RunDownloadJob())
handlers.set(RunExtractPmtilesJob.key, new RunExtractPmtilesJob())
handlers.set(DownloadModelJob.key, new DownloadModelJob())
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
handlers.set(EmbedFileJob.key, new EmbedFileJob())
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
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)
queues.set(DownloadModelJob.key, DownloadModelJob.queue)
queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
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]
}
@ -149,6 +194,9 @@ export default class QueueWork extends BaseCommand {
private getConcurrencyForQueue(queueName: string): number {
const concurrencyMap: Record<string, number> = {
[RunDownloadJob.queue]: 3,
// pmtiles extract hits the Protomaps CDN with many parallel range reads per job;
// cap concurrency at 2 so a second extract doesn't starve the first.
[RunExtractPmtilesJob.queue]: 2,
[DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads
[RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results
[EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive

View File

@ -41,7 +41,7 @@ const bodyParserConfig = defineConfig({
*/
autoProcess: true,
convertEmptyStringsToNull: true,
processManually: [],
processManually: ['/api/zim/upload'],
/**
* Maximum limit of data to parse including all files

View File

@ -18,7 +18,14 @@ const loggerConfig = defineConfig({
targets:
targets()
.pushIf(!app.inProduction, targets.pretty())
// Production: write JSON to both the persisted log file (for Debug
// Info bundle export) AND stdout (so `docker logs nomad_admin` and
// any external log aggregator can see runtime telemetry — RAG
// retrieval scores, query rewrites, etc.). Writing to fd 1 via
// pino/file is the standard way to do this; without it, prod
// installs are effectively running blind from a debugger's POV.
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log", mkdir: true }))
.pushIf(app.inProduction, targets.file({ destination: 1 }))
.toArray(),
},
},

View File

@ -1,10 +1,42 @@
import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import { Redis } from 'ioredis'
// BullMQ treats a plain `{host, port}` connection object as a recipe: every
// Queue / Worker instantiates its own ioredis client from it, and script
// commands executed against those clients can spawn further short-lived
// connections. Under sustained ZIM ingestion this leaked ~1 client/sec until
// Redis maxclients was exhausted (#885). Passing a single shared ioredis
// instance instead gives BullMQ a pool to reuse — Workers still duplicate it
// once for their blocking client, which is expected and bounded.
// `maxRetriesPerRequest: null` is mandatory for connections shared with BullMQ.
const sharedConnection = new Redis({
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT') ?? 6379,
db: env.get('REDIS_DB') ?? 0,
maxRetriesPerRequest: null,
// Don't open the socket at module import time. Importing this file (during
// `node ace migration:run`, `db:seed`, `queue:work`, or HTTP boot) otherwise
// races Docker's network/DNS lifecycle: on a fresh `up` the `redis` name is
// not yet resolvable (EAI_AGAIN), and on a recreate the embedded DNS briefly
// serves the previous container's IP (ECONNREFUSED to the stale address).
// Lazy-connecting defers the first dial until BullMQ actually needs Redis —
// after the entrypoint has confirmed it is reachable — so each (re)connect
// re-resolves the current IP instead of hammering a stale one.
lazyConnect: true,
// Bounded, backing-off retry so a transient outage doesn't busy-loop.
retryStrategy: (times) => Math.min(times * 200, 2000),
})
// Without an `error` listener ioredis logs the raw "[ioredis] Unhandled error
// event" lines and, on some Node versions, an EventEmitter `error` with no
// listener can crash the process. Route them through the app logger instead.
sharedConnection.on('error', (err) => {
logger.error({ err }, 'Shared Redis connection error')
})
const queueConfig = {
connection: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT') ?? 6379,
},
connection: sharedConnection,
}
export default queueConfig

View File

@ -8,6 +8,7 @@ export default defineConfig({
driver: redis({
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
db: env.get('REDIS_DB') ?? 0,
keyPrefix: 'transmit:',
})
}

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'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'system.internetStatusTestUrl', '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,32 @@
export const PMTILES_BINARY_PATH = '/usr/local/bin/pmtiles'
// Clamp these so a user can't ask for nonsense that never extracts
export const EXTRACT_MIN_ZOOM = 0
export const EXTRACT_MAX_ZOOM = 15
export const EXTRACT_DEFAULT_MAX_ZOOM = 15
// Low-zoom global fallback extracted once during base-asset setup (~15 MB). Layered
// underneath regional extracts so the map isn't grey outside a region's polygon.
export const WORLD_BASEMAP_FILENAME = 'world.pmtiles'
export const WORLD_BASEMAP_MAX_ZOOM = 5
export const WORLD_BASEMAP_SOURCE_NAME = 'world'
export interface PmtilesExtractArgOptions {
sourceUrl: string
outputFilepath: string
regionFilepath?: string
maxzoom?: number
dryRun?: boolean
downloadThreads?: number
overfetch?: number
}
export function buildPmtilesExtractArgs(opts: PmtilesExtractArgOptions): string[] {
const args = ['extract', opts.sourceUrl, opts.outputFilepath]
if (opts.regionFilepath) args.push(`--region=${opts.regionFilepath}`)
if (typeof opts.maxzoom === 'number') args.push(`--maxzoom=${opts.maxzoom}`)
if (opts.dryRun) args.push('--dry-run')
if (typeof opts.downloadThreads === 'number') args.push(`--download-threads=${opts.downloadThreads}`)
if (typeof opts.overfetch === 'number') args.push(`--overfetch=${opts.overfetch}`)
return args
}

View File

@ -64,6 +64,8 @@ export const FALLBACK_RECOMMENDED_OLLAMA_MODELS: NomadOllamaModel[] = [
export const DEFAULT_QUERY_REWRITE_MODEL = 'qwen2.5:3b' // default to qwen2.5 for query rewriting with good balance of text task performance and resource usage
export const EMBEDDING_MODEL_NAME = 'nomic-embed-text:v1.5'
/**
* Adaptive RAG context limits based on model size.
* Smaller models get overwhelmed with too much context, so we cap it.
@ -84,18 +86,27 @@ export const SYSTEM_PROMPTS = {
- Use tables when presenting structured data.
`,
rag_context: (context: string) => `
You have access to relevant information from the knowledge base. This context has been retrieved based on semantic similarity to the user's question.
Information has been retrieved from the NOMAD knowledge base that MAY be relevant to the
user's question. It was selected by automated similarity search, which is imperfect some
or all of it may be unrelated to what the user actually asked.
[Knowledge Base Context]
${context}
IMPORTANT INSTRUCTIONS:
1. If the user's question is directly related to the context above, use this information to provide accurate, detailed answers.
2. Always cite or reference the context when using it (e.g., "According to the information available..." or "Based on the knowledge base...").
3. If the context is only partially relevant, combine it with your general knowledge but be clear about what comes from the knowledge base.
4. If the context is not relevant to the user's question, you can respond using your general knowledge without forcing the context into your answer. Do not mention the context if it's not relevant.
5. Never fabricate information that isn't in the context or your training data.
6. If you're unsure or you don't have enough information to answer the user's question, acknowledge the limitations.
HOW TO ANSWER:
1. First, silently judge whether the context genuinely addresses the user's question. Use
it ONLY when it really contains relevant information. Do not force a connection that
isn't there: poetic, narrative, tangential, or topically-unrelated passages are NOT
relevant just because they share a word with the question ignore them.
2. When the context is relevant, base your answer on it and answer directly and specifically.
3. When the context does not actually address the question, ignore it completely and answer
from your own general knowledge. Do this silently do not mention the knowledge base,
the context, or the fact that it lacked an answer, and do not apologize.
4. Never narrate your retrieval or reasoning process. Do not write "according to Context 1",
"the context is unrelated, but", "I couldn't find specific context", or similar. Just
give the answer as if you simply knew it.
5. Do not fabricate specifics (numbers, names, procedures) that are neither supported by
genuinely relevant context nor part of your reliable knowledge.
Format your response using markdown for readability.
`,

View File

@ -5,4 +5,17 @@ export const SERVICE_NAMES = {
CYBERCHEF: 'nomad_cyberchef',
FLATNOTES: 'nomad_flatnotes',
KOLIBRI: 'nomad_kolibri',
KOLIBRI_GEN2: 'nomad_kolibri_2',
// Supply Depot — curated catalog (ports 84008499)
STIRLING_PDF: 'nomad_stirling_pdf',
FILEBROWSER: 'nomad_filebrowser',
CALIBREWEB: 'nomad_calibreweb',
IT_TOOLS: 'nomad_it_tools',
EXCALIDRAW: 'nomad_excalidraw',
MESHTASTIC_WEB: 'nomad_meshtastic_web',
MESHTASTICD: 'nomad_meshtasticd',
MESHCORE_WEB: 'nomad_meshcore_web',
HOMEBOX: 'nomad_homebox',
VAULTWARDEN: 'nomad_vaultwarden',
JELLYFIN: 'nomad_jellyfin',
}

View File

@ -0,0 +1,29 @@
import { SERVICE_NAMES } from './service_names.js'
// In-app docs page (admin/docs/supply-depot-apps.md) served at /docs/supply-depot-apps.
export const SUPPLY_DEPOT_DOC_PAGE = 'supply-depot-apps'
// Maps a Supply Depot service to its section anchor on that page. Only services listed here get a
// "Docs" item in the Manage dropdown, so the link never points at a section that doesn't exist yet.
// Each anchor MUST match the heading id set in the .md file (e.g. `## Vaultwarden {% #vaultwarden %}`).
// Add an entry here the moment that app's section is written.
export const SUPPLY_DEPOT_DOC_ANCHORS: Record<string, string> = {
[SERVICE_NAMES.STIRLING_PDF]: 'stirling-pdf',
[SERVICE_NAMES.FILEBROWSER]: 'file-browser',
[SERVICE_NAMES.CALIBREWEB]: 'calibre-web',
[SERVICE_NAMES.IT_TOOLS]: 'it-tools',
[SERVICE_NAMES.EXCALIDRAW]: 'excalidraw',
[SERVICE_NAMES.HOMEBOX]: 'homebox',
[SERVICE_NAMES.VAULTWARDEN]: 'vaultwarden',
[SERVICE_NAMES.JELLYFIN]: 'jellyfin',
[SERVICE_NAMES.MESHTASTIC_WEB]: 'meshtastic-web',
[SERVICE_NAMES.KOLIBRI]: 'kolibri',
[SERVICE_NAMES.KOLIBRI_GEN2]: 'kolibri',
[SERVICE_NAMES.MESHCORE_WEB]: 'meshcore-web',
}
// Returns the in-app docs link for a service, or null if it has no documentation section.
export function getSupplyDepotDocLink(serviceName: string): string | null {
const anchor = SUPPLY_DEPOT_DOC_ANCHORS[serviceName]
return anchor ? `/docs/${SUPPLY_DEPOT_DOC_PAGE}#${anchor}` : null
}

View File

@ -0,0 +1,34 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('is_custom').notNullable().defaultTo(false)
table.string('category').nullable()
})
// Backfill categories for existing curated services
this.defer(async (db) => {
const updates: Array<{ service_name: string; category: string }> = [
{ service_name: 'nomad_kiwix_server', category: 'education' },
{ service_name: 'nomad_kolibri', category: 'education' },
{ service_name: 'nomad_ollama', category: 'ai' },
{ service_name: 'nomad_cyberchef', category: 'utility' },
{ service_name: 'nomad_flatnotes', category: 'productivity' },
]
for (const { service_name, category } of updates) {
await db.from('services').where('service_name', service_name).update({ category })
}
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_custom')
table.dropColumn('category')
})
}
}

View File

@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Set when a user edits a curated (non-custom) app. Tells the seeder to stop
// overwriting that service's container_config on subsequent runs, so the user's
// customizations (e.g. a changed port) survive reboots and upgrades.
table.boolean('is_user_modified').notNullable().defaultTo(false)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_user_modified')
})
}
}

View File

@ -0,0 +1,28 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Per-app opt-in for automatic updates (gated additionally by the global
// `appAutoUpdate.enabled` master switch). Default off — auto-update is opt-in.
table.boolean('auto_update_enabled').notNullable().defaultTo(false)
// Cool-off anchor: when the currently-available update was first detected.
// Registry tags carry no publish date, so cool-off is measured from first-seen.
table.timestamp('available_update_first_seen_at').nullable()
// Per-app failure backoff so one flapping app self-disables without affecting others.
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('auto_update_enabled')
table.dropColumn('available_update_first_seen_at')
table.dropColumn('auto_update_consecutive_failures')
table.dropColumn('auto_update_disabled_reason')
})
}
}

View File

@ -0,0 +1,42 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'custom_library_sources'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
table.string('name', 100).notNullable()
table.string('base_url', 2048).notNullable()
table.boolean('is_default').notNullable().defaultTo(false)
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').notNullable()
})
// Seed default Kiwix mirrors
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
const defaults = [
{ name: 'Debian CDN (Global)', base_url: 'https://cdimage.debian.org/mirror/kiwix.org/zim/' },
{ name: 'Your.org (US)', base_url: 'https://ftpmirror.your.org/pub/kiwix/zim/' },
{ name: 'FAU Erlangen (DE)', base_url: 'https://ftp.fau.de/kiwix/zim/' },
{ name: 'Dotsrc (DK)', base_url: 'https://mirrors.dotsrc.org/kiwix/zim/' },
{ name: 'MirrorService (UK)', base_url: 'https://www.mirrorservice.org/sites/download.kiwix.org/zim/' },
]
for (const d of defaults) {
await this.defer(async (db) => {
await db.table(this.tableName).insert({
name: d.name,
base_url: d.base_url,
is_default: true,
created_at: now,
updated_at: now,
})
})
}
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,26 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'kb_ingest_state'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
// utf8mb4 caps an indexed varchar at 768 chars (3072 byte InnoDB key limit);
// 512 leaves headroom and is plenty for any NOMAD-managed file path.
table.string('file_path', 512).notNullable().unique()
table
.enum('state', ['pending_decision', 'indexed', 'browse_only', 'failed', 'stalled'])
.notNullable()
.defaultTo('pending_decision')
table.integer('chunks_embedded').notNullable().defaultTo(0)
table.text('last_error').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,64 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import { DateTime } from 'luxon'
const SEED_ROWS: Array<{ pattern: string; chunks_per_mb: number; notes: string }> = [
// Dense technical reference — every paragraph carries content
{ pattern: 'devdocs_', chunks_per_mb: 1100, notes: 'Heuristic seed: dense API references' },
// Encyclopedia prose — Simple English & general Wikipedia variants
{
pattern: 'wikipedia_en_simple_',
chunks_per_mb: 270,
notes: 'Heuristic seed: Simple English Wikipedia',
},
{
pattern: 'wikipedia_en_',
chunks_per_mb: 270,
notes: 'Heuristic seed: general Wikipedia variants',
},
// Sparse text, image-heavy
{ pattern: 'ifixit_', chunks_per_mb: 50, notes: 'Heuristic seed: image-heavy repair guides' },
// Q&A pages — moderate density, mostly short answers
{
pattern: 'cooking.stackexchange.com_',
chunks_per_mb: 200,
notes: 'Heuristic seed: Stack Exchange Q&A',
},
// Video-only ZIMs produce zero text chunks. Listing these explicitly keeps
// the cost estimator from spinning up "indexing in progress" UI for content
// that has no embeddable text whatsoever.
{ pattern: 'lrnselfreliance_', chunks_per_mb: 0, notes: 'Heuristic seed: video-only ZIM' },
{ pattern: 'ted_', chunks_per_mb: 0, notes: 'Heuristic seed: video-only ZIM' },
{ pattern: 'freedom-of-religion_', chunks_per_mb: 0, notes: 'Heuristic seed: video-only ZIM' },
// Empty-pattern fallback — every filename startsWith('') is true. The lookup
// picks the longest matching pattern, so this only fires for ZIMs that match
// none of the above (medium prose density).
{ pattern: '', chunks_per_mb: 100, notes: 'Heuristic fallback' },
]
export default class extends BaseSchema {
protected tableName = 'kb_ratio_registry'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
table.string('pattern', 255).notNullable().unique()
table.integer('chunks_per_mb').unsigned().notNullable()
// 0 = heuristic seed, >0 = number of observed ZIMs that have updated this entry.
// Phase 4 self-calibration increments this on each successful ingestion.
table.integer('sample_count').notNullable().defaultTo(0)
table.text('notes').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').notNullable()
})
const now = DateTime.utc().toSQL({ includeOffset: false }) as string
const rows = SEED_ROWS.map((row) => ({ ...row, created_at: now, updated_at: now }))
this.defer(async (db) => {
await db.table(this.tableName).multiInsert(rows)
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

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,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// User-set override for an app's launch ("Open") link, used when the instance sits behind a
// reverse proxy or local DNS (e.g. https://jellyfin.myhomelab.net). When null, the default
// host + port link (derived from ui_location) is used. Stored separately from ui_location so
// the default is always recoverable, and deliberately NOT synced by the service seeder so a
// curated app's override survives reseeds/upgrades.
table.string('custom_url').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('custom_url')
})
}
}

View File

@ -0,0 +1,43 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import { SERVICE_NAMES } from '../../constants/service_names.js'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
// Generic deprecation flag (reusable for future sunsets): a deprecated service is hidden from
// the install catalog unless it is already installed — see SystemService.getServices().
this.schema.alterTable(this.tableName, (table) => {
table.boolean('is_deprecated').notNullable().defaultTo(false)
})
// Sunset the legacy treehouses/kolibri:0.12.8 entry, replaced by the learningequality Gen 2
// entry seeded as `nomad_kolibri_2`. The seeder is additive + sync-existing and never deletes,
// so without this step every existing deployment keeps an orphaned `nomad_kolibri` row and can
// still install the dead 6-year-old image. Conditional handling keeps it data-safe:
this.defer(async (db) => {
// Never installed → just an orphaned catalog row; drop it outright.
await db
.from(this.tableName)
.where('service_name', SERVICE_NAMES.KOLIBRI)
.where('installed', false)
.delete()
// Currently installed → a running 0.12.8 container holds port 8300 + a bind mount. Keep the
// row (it's Nomad's only handle to open/stop/uninstall that container) but flag it deprecated
// so it shows a "Legacy" badge and drops out of the catalog once the user uninstalls it.
await db
.from(this.tableName)
.where('service_name', SERVICE_NAMES.KOLIBRI)
.where('installed', true)
.update({ is_deprecated: true })
})
}
async down() {
// Note: the legacy-row deletion in up() is a one-way data change and is not restored here.
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_deprecated')
})
}
}

View File

@ -5,16 +5,31 @@ import env from '#start/env'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
type ServiceSeedRecord = Omit<
ModelAttributes<Service>,
| 'created_at'
| 'updated_at'
| 'id'
| 'available_update_version'
| 'update_checked_at'
| 'metadata'
| 'is_user_modified'
| 'is_deprecated'
| 'custom_url'
| 'auto_update_enabled'
| 'available_update_first_seen_at'
| 'auto_update_consecutive_failures'
| 'auto_update_disabled_reason'
> & { metadata?: string | null }
export default class ServiceSeeder extends BaseSeeder {
// Use environment variable with fallback to production default
private static NOMAD_STORAGE_ABS_PATH = env.get(
'NOMAD_STORAGE_PATH',
'/opt/project-nomad/storage'
)
private static DEFAULT_SERVICES: Omit<
ModelAttributes<Service>,
'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'
>[] = [
private static DEFAULT_SERVICES: ServiceSeedRecord[] = [
// ── Core / original services ──────────────────────────────────────────────
{
service_name: SERVICE_NAMES.KIWIX,
friendly_name: 'Information Library',
@ -38,13 +53,15 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'education',
depends_on: null,
},
{
service_name: SERVICE_NAMES.QDRANT,
friendly_name: 'Qdrant Vector Database',
powered_by: null,
display_order: 100, // Dependency service, not shown directly
display_order: 100,
description: 'Vector database for storing and searching embeddings',
icon: 'IconRobot',
container_image: 'qdrant/qdrant:v1.16',
@ -57,11 +74,15 @@ export default class ServiceSeeder extends BaseSeeder {
PortBindings: { '6333/tcp': [{ HostPort: '6333' }], '6334/tcp': [{ HostPort: '6334' }] },
},
ExposedPorts: { '6333/tcp': {}, '6334/tcp': {} },
// Disable anonymous telemetry — NOMAD is offline-first
Env: ['QDRANT__TELEMETRY_DISABLED=true'],
}),
ui_location: '6333',
installed: false,
installation_status: 'idle',
is_dependency_service: true,
is_custom: false,
category: null,
depends_on: null,
},
{
@ -71,7 +92,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 3,
description: 'Local AI chat that runs entirely on your hardware - no internet required',
icon: 'IconWand',
container_image: 'ollama/ollama:0.18.1',
container_image: 'ollama/ollama:0.24.0',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve',
container_config: JSON.stringify({
@ -86,6 +107,8 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'ai',
depends_on: SERVICE_NAMES.QDRANT,
},
{
@ -95,7 +118,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 11,
description: 'Swiss Army knife for data encoding, encryption, and analysis',
icon: 'IconChefHat',
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
container_image: 'ghcr.io/gchq/cyberchef:10.24.0',
source_repo: 'https://github.com/gchq/CyberChef',
container_command: null,
container_config: JSON.stringify({
@ -109,6 +132,8 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'utility',
depends_on: null,
},
{
@ -134,42 +159,417 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.KOLIBRI,
friendly_name: 'Education Platform',
// "Kolibri Gen 2" — the upstream-official learningequality image replacing the ~6-year-old
// community treehouses/kolibri:0.12.8. This is a distinct catalog entry (own service_name,
// volume, and ports), not an in-place upgrade: the new image uses a different repo, mounts at
// /kolibri instead of /root/.kolibri, and crosses 7 minor versions of Kolibri's own data
// schema. Existing 0.12.8 installs are sunset via the deprecate-legacy-kolibri migration and
// keep running on 8300 until uninstalled; content is re-imported into the fresh Gen 2 install.
service_name: SERVICE_NAMES.KOLIBRI_GEN2,
friendly_name: 'Education Platform (Gen 2)',
powered_by: 'Kolibri',
display_order: 2,
description: 'Interactive learning platform with video courses and exercises',
icon: 'IconSchool',
container_image: 'treehouses/kolibri:0.12.8',
container_image: 'learningequality/kolibri:0.19.4',
source_repo: 'https://github.com/learningequality/kolibri',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8080/tcp': [{ HostPort: '8300' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`],
// 8080 = web UI. 8311 = zip-content server (interactive exercises / HTML5 apps), served
// from a separate "alternate origin" the browser connects to DIRECTLY. KOLIBRI_ZIP_CONTENT_PORT
// sets the port Kolibri both LISTENS on inside the container AND advertises in content URLs,
// so the internal port, the published host port, and that env value must all be identical
// (8311) — otherwise content URLs point at a host port that doesn't route to the listener
// and every content page fails with ERR_CONNECTION_REFUSED. The image's default 8081 is
// unused here. The image refuses to start without /kolibri mounted (KOLIBRI_HOME = /kolibri).
PortBindings: { '8080/tcp': [{ HostPort: '8310' }], '8311/tcp': [{ HostPort: '8311' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri-gen2:/kolibri`],
},
ExposedPorts: { '8080/tcp': {} },
ExposedPorts: { '8080/tcp': {}, '8311/tcp': {} },
Env: ['KOLIBRI_ZIP_CONTENT_PORT=8311'],
}),
ui_location: '8300',
ui_location: '8310',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'education',
depends_on: null,
},
// ── Supply Depot — curated catalog (ports 84008499) ─────────────────────
{
service_name: SERVICE_NAMES.STIRLING_PDF,
friendly_name: 'Stirling PDF',
powered_by: 'Stirling-Tools',
display_order: 20,
description: 'Locally-hosted PDF manipulation tool — merge, split, compress, convert, and more',
icon: 'IconFileDescription',
container_image: 'ghcr.io/stirling-tools/s-pdf:2.13.1',
source_repo: 'https://github.com/Stirling-Tools/Stirling-PDF',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8080/tcp': [{ HostPort: '8400' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/configs:/configs`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/logs:/logs`,
],
},
ExposedPorts: { '8080/tcp': {} },
// Stirling v2 ignores the old v1 `DOCKER_ENABLE_SECURITY` flag and ships with
// `security.enableLogin: true` in settings.yml, so it boots behind a login wall.
// For a single-user offline appliance we open it straight to the tools. Users who
// want a login can flip this to `true` via Manage > Edit (env overrides settings.yml).
Env: ['SECURITY_ENABLELOGIN=false', 'LANGS=en_GB'],
}),
ui_location: '8400',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.FILEBROWSER,
friendly_name: 'File Browser',
powered_by: 'FileBrowser',
display_order: 21,
description: 'Web-based file manager — browse, upload, download, and organize files on your device',
icon: 'IconFolderOpen',
container_image: 'filebrowser/filebrowser:v2',
source_repo: 'https://github.com/filebrowser/filebrowser',
// Browsable root is storage/filebrowser/files (persistent, so files created at the top level
// survive updates), with the user-facing content folders mounted in beneath it. We deliberately
// do NOT mount the sensitive/app-internal folders (vaultwarden, ollama, qdrant, logs, other
// apps' config + *.db). They simply aren't present in the container, so they can't be browsed,
// downloaded, or deleted — this is the guardrail. FileBrowser's own rules feature would need a
// wrapper script / imported config shipped into the container, which the catalog model doesn't
// support, so mount selection is how we scope visibility. To expose another content folder,
// add a `${STORAGE}/<folder>:/srv/<folder>` bind here.
// The DB lives in storage/filebrowser/db (mounted at /db, a SIBLING of the root, not under it)
// so FileBrowser's own .filebrowser.db never shows up in the user's file listing. User: root so
// it can read/write folders owned by other UIDs.
container_command: '--root /srv --database /db/.filebrowser.db',
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8410' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/filebrowser/files:/srv`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/filebrowser/db:/db`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/books:/srv/books`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/maps:/srv/maps`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/media:/srv/media`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kb_uploads:/srv/kb_uploads`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/zim:/srv/zim`,
],
},
ExposedPorts: { '80/tcp': {} },
// Without an initial password FileBrowser generates a random one and prints it only to
// the container logs, which a non-technical user can't reach. Seed a known admin/nomad
// login on first run instead (only applies when the DB doesn't exist yet); the docs tell
// users to change it. FB_NOAUTH / --noauth don't work on this image (v2.63.x), so a login
// stays, which is the safer default anyway for a read/write/delete file manager.
// NOTE: FB_PASSWORD must be a bcrypt hash, not plaintext. The value below is the hash of
// "nomad" (generated via `filebrowser hash nomad`). Login is admin / nomad.
Env: [
'FB_USERNAME=admin',
'FB_PASSWORD=$2a$10$Dvu3XTiLxvPTzvdOKu6y6.AmadN6Zt0ddLwK.8MQ.RCIQWunWBQXa',
],
User: 'root'
}),
ui_location: '8410',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'utility',
depends_on: null,
},
{
service_name: SERVICE_NAMES.CALIBREWEB,
friendly_name: 'Calibre Web',
powered_by: 'Calibre-Web',
display_order: 22,
description: 'Web-based e-book reader and library manager for your Calibre collection',
icon: 'IconBook',
container_image: 'linuxserver/calibre-web:0.6.26-ls386',
source_repo: 'https://github.com/janeczku/calibre-web',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8083/tcp': [{ HostPort: '8420' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/calibreweb/config:/config`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/books:/books`,
],
},
ExposedPorts: { '8083/tcp': {} },
Env: ['PUID=1000', 'PGID=1000'],
}),
ui_location: '8420',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'media',
depends_on: null,
metadata: JSON.stringify({ minMemoryMB: 512, minDiskMB: 5120 }),
},
{
service_name: SERVICE_NAMES.IT_TOOLS,
friendly_name: 'IT Tools',
powered_by: 'IT-Tools',
display_order: 23,
description: 'Collection of handy utilities for developers — UUID, hash, encoding, formatters, and more',
icon: 'IconTool',
container_image: 'ghcr.io/corentinth/it-tools:2024.10.22-7ca5933',
source_repo: 'https://github.com/CorentinTh/it-tools',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8430' }] },
},
ExposedPorts: { '80/tcp': {} },
}),
ui_location: '8430',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'utility',
depends_on: null,
},
{
service_name: SERVICE_NAMES.EXCALIDRAW,
friendly_name: 'Excalidraw',
powered_by: 'Excalidraw',
display_order: 24,
description: 'Virtual whiteboard for sketching hand-drawn-style diagrams — works fully offline',
icon: 'IconPencil',
container_image: 'excalidraw/excalidraw:sha-4bfc5bb',
source_repo: 'https://github.com/excalidraw/excalidraw',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8440' }] },
},
ExposedPorts: { '80/tcp': {} },
}),
ui_location: '8440',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.MESHTASTIC_WEB,
friendly_name: 'Meshtastic Web',
powered_by: 'Meshtastic',
display_order: 30,
description: 'Browser-based client for managing Meshtastic mesh radio devices',
icon: 'IconWifi',
container_image: 'ghcr.io/meshtastic/web:2.7.1',
source_repo: 'https://github.com/meshtastic/web',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
// meshtastic/web serves on 8080 inside the container, not 80.
PortBindings: { '8080/tcp': [{ HostPort: '8450' }] },
},
ExposedPorts: { '8080/tcp': {} },
}),
ui_location: '8450',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'networking',
depends_on: null,
},
{
service_name: SERVICE_NAMES.MESHCORE_WEB,
friendly_name: 'MeshCore Web',
powered_by: 'MeshCore',
display_order: 32,
description: 'Browser-based client for MeshCore mesh radio devices',
icon: 'IconAntenna',
// aXistem's prebuilt image of Liam Cottle's MeshCore web client (MeshCore is a sibling LoRa
// mesh project to Meshtastic).
container_image: 'ghcr.io/axistem-dev/meshcore-web:v1.45.0',
source_repo: 'https://github.com/aXistem-dev/meshcore-web',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
// The image is stock nginx:alpine serving the Flutter build over HTTP on 80. MeshCore's
// client reaches a radio via Web Bluetooth / Web Serial, which browsers only permit from a
// secure (HTTPS) context — so we serve it over HTTPS. _runPreinstallActions__MeshCoreWeb
// writes a self-signed cert + an SSL server config into storage/meshcore-web; we bind both
// in (the config over the image's default.conf) and publish 443. The https: prefix on
// ui_location builds an https:// Open link (one-time cert warning, same as Vaultwarden).
PortBindings: { '443/tcp': [{ HostPort: '8500' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/nginx-ssl.conf:/etc/nginx/conf.d/default.conf:ro`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/certs:/certs:ro`,
],
},
ExposedPorts: { '443/tcp': {} },
}),
ui_location: 'https:8500',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'networking',
depends_on: null,
},
{
service_name: SERVICE_NAMES.HOMEBOX,
friendly_name: 'Homebox',
powered_by: 'Homebox',
display_order: 25,
description: 'Home inventory and asset management — track everything you own',
icon: 'IconBox',
// Maintained fork. The original hay-kot/homebox was archived June 2024;
// sysadminsmedia is the official continuation (drop-in: same 7745 port + /data volume,
// migrates an existing DB forward, telemetry off by default).
container_image: 'ghcr.io/sysadminsmedia/homebox:0.26.2',
source_repo: 'https://github.com/sysadminsmedia/homebox',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '7745/tcp': [{ HostPort: '8470' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/homebox:/data`],
},
ExposedPorts: { '7745/tcp': {} },
}),
ui_location: '8470',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.VAULTWARDEN,
friendly_name: 'Vaultwarden',
powered_by: 'Vaultwarden',
display_order: 26,
description: 'Lightweight Bitwarden-compatible password manager server — secure your credentials offline',
icon: 'IconShieldLock',
container_image: 'vaultwarden/server:1.36.0',
source_repo: 'https://github.com/dani-garcia/vaultwarden',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8480' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/vaultwarden:/data`],
},
ExposedPorts: { '80/tcp': {} },
// ROCKET_TLS points at the self-signed cert generated on install by
// DockerService._runPreinstallActions__Vaultwarden. Vaultwarden's web vault needs a secure
// context (HTTPS) or it refuses to register/unlock, so it ships HTTPS-on-by-default.
Env: [
'WEBSOCKET_ENABLED=true',
'ROCKET_TLS={certs="/data/cert.pem",key="/data/key.pem"}',
],
}),
// https: prefix tells getServiceLink to build an https:// Open link on this port.
ui_location: 'https:8480',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'security',
depends_on: null,
metadata: JSON.stringify({ minMemoryMB: 256, minDiskMB: 512 }),
},
{
service_name: SERVICE_NAMES.JELLYFIN,
friendly_name: 'Jellyfin',
powered_by: 'Jellyfin',
display_order: 27,
description: 'Open-source media server — stream your video, music, and photo libraries',
icon: 'IconMovie',
container_image: 'jellyfin/jellyfin:10.11.11',
source_repo: 'https://github.com/jellyfin/jellyfin',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8096/tcp': [{ HostPort: '8490' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/config:/config`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/cache:/cache`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/media:/media`,
],
},
ExposedPorts: { '8096/tcp': {} },
}),
ui_location: '8490',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'media',
depends_on: null,
metadata: JSON.stringify({ minMemoryMB: 2048, minDiskMB: 20480 }),
},
]
async run() {
const existingServices = await Service.query().select('service_name')
const existingServiceNames = new Set(existingServices.map((service) => service.service_name))
const existingServices = await Service.query().select([
'service_name',
'is_custom',
'is_user_modified',
])
const existingServiceMap = new Map(existingServices.map((s) => [s.service_name, s]))
const newServices = ServiceSeeder.DEFAULT_SERVICES.filter(
(service) => !existingServiceNames.has(service.service_name)
(service) => !existingServiceMap.has(service.service_name)
)
await Service.createMany([...newServices])
if (newServices.length > 0) {
await Service.createMany([...newServices])
}
// Keep curated services in sync with the catalog. Custom services are user-defined and must
// never be overwritten. User-modified curated services (a user edited their config) are
// likewise left alone so the edit survives reboots. ui_location is synced too so a catalog
// change to an app's link/scheme/port (e.g. Vaultwarden moving to https:8480, or a corrected
// internal port) reaches existing non-modified installs on update, not just fresh ones.
for (const service of ServiceSeeder.DEFAULT_SERVICES) {
const existing = existingServiceMap.get(service.service_name)
if (existing && !existing.is_custom && !existing.is_user_modified) {
await Service.query().where('service_name', service.service_name).update({
container_config: service.container_config,
container_command: service.container_command ?? null,
metadata: (service as any).metadata ?? null,
category: service.category,
ui_location: service.ui_location,
})
}
}
}
}

View File

@ -148,6 +148,15 @@ ZIM files provide offline Wikipedia, books, and other content via Kiwix.
| POST | `/api/maps/download-collection` | Download an entire collection by slug (async) |
| DELETE | `/api/maps/:filename` | Delete a local map file |
### Map Markers
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/maps/markers` | List map markers |
| POST | `/api/maps/markers` | Add map marker (body: {"name": "Test Marker", "notes": "Example note", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) |
| PATCH | `/api/maps/markers/{id}` | Update a map marker (body: {"name": "Test Marker", "notes": "Example note", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) fields that don't change can be omitted|
| DELETE | `/api/maps/markers/{id}` | Delete a map marker |
---
## Downloads

View File

@ -0,0 +1,48 @@
# Community Add-Ons
Project N.O.M.A.D. ships with a curated set of built-in tools and content, but the community has started building add-ons that extend the platform with specialized offline content packs. These are third-party projects, not maintained by the N.O.M.A.D. team. Install them at your own discretion, and please direct any bugs or feature requests to the add-on's own repository.
Have you built a NOMAD add-on? Open an issue on the [Project N.O.M.A.D. GitHub repository](https://github.com/Crosstalk-Solutions/project-nomad/issues/new) or send us a note through the [contact form on projectnomad.us](https://www.projectnomad.us/contact), and we'll review it for inclusion on this page.
---
## ZIM Content Packs
ZIM content packs drop additional offline reference material into your existing Kiwix library. They typically ship with an `install.sh` script that downloads source material, builds a ZIM file with `zimwriterfs`, and registers it with your running Kiwix container.
### U.S. Military Field Manuals
**Repository:** [github.com/jrsphoto/ZIM-military-field-manuals](https://github.com/jrsphoto/ZIM-military-field-manuals)
Roughly 180 public-domain U.S. military field manuals covering field medicine, survival, combat first aid, map reading, and more. Built into a searchable ZIM that drops into your Kiwix library.
Final ZIM size is around 2 GB. The builder downloads about 2 GB of source PDFs from archive.org during the build.
### W3Schools Programming Archive
**Repository:** [github.com/kennethbrewer3/ZIM-w3schools-offline](https://github.com/kennethbrewer3/ZIM-w3schools-offline)
A full offline copy of the W3Schools programming tutorials, covering HTML, CSS, JavaScript, Python, SQL, and more. Good for learning to code, looking up syntax, or teaching programming in an environment without internet.
Final ZIM size is around 700 MB. The builder downloads about 6 GB of source files from a GitHub mirror during the build.
---
## Installing a Community Add-On
Each add-on has its own install instructions, but most ZIM packs follow the same shape:
1. Clone the add-on's repository onto your NOMAD host over SSH.
2. Check the README for required build dependencies. Most need `git`, `python3`, `unzip`, and `zim-tools`.
3. Run the included `install.sh` with a `--deploy` flag, pointing it at your Kiwix library path (`/opt/project-nomad/storage/zim`) and your Kiwix container name (`nomad_kiwix_server`).
4. The script builds the ZIM, copies it into your Kiwix library, registers it with Kiwix, and restarts the Kiwix container.
Once the script finishes, the new content will appear in your Information Library the next time you load it.
Expect the initial build to take anywhere from a few minutes to an hour or more depending on the add-on's size and your host's CPU.
---
## A Note on Support
These add-ons are community-built and community-maintained. If something goes wrong with an install script or the content inside a ZIM, please open an issue on the add-on's own repository rather than Project N.O.M.A.D.'s. We're happy to help if the issue is with NOMAD itself, for example if Kiwix isn't picking up a new ZIM after an install, but we can't maintain or support third-party content.

View File

@ -73,7 +73,7 @@ This helps you balance content coverage against storage usage.
2. Type your question or request
3. The AI responds in conversational style
The AI must be installed first — enable it during Easy Setup or install it from the [Apps](/settings/apps) page.
The AI must be installed first — enable it during Easy Setup or install it from the [Supply Depot](/supply-depot) page.
### How do I upload documents to the Knowledge Base?
1. Go to **[Knowledge Base →](/knowledge-base)**
@ -104,7 +104,7 @@ The Early Access Channel lets you opt in to receive release candidate builds wit
2. Refresh the page (Ctrl+R or Cmd+R)
3. Go back to the Command Center and try again
4. Check Settings → System to see if the service is running
5. Try restarting the service (Stop, then Start in Apps manager)
5. Try restarting the service (Stop, then Start in the Supply Depot)
### Maps show a gray/blank area
@ -114,6 +114,18 @@ The Maps feature requires downloaded map data. If you see a blank area:
3. Wait for downloads to complete
4. Return to Maps and refresh
### ERROR: Failed to load the XML library file '/data/kiwix-library.xml'
This usually means the Information Library service started before its Kiwix library index was fully initialized.
Try this recovery flow:
1. Go to **[Supply Depot](/supply-depot)**
2. Stop **Information Library (Kiwix)**
3. Wait 10-15 seconds, then start it again
4. If the error persists, run **Force Reinstall** for Information Library from the same page
After restart/reinstall completes, refresh the Information Library page.
### AI responses are slow
Local AI requires significant computing power. To improve speed:
@ -128,7 +140,7 @@ N.O.M.A.D. automatically detects NVIDIA GPUs when the NVIDIA Container Toolkit i
1. **Install an NVIDIA GPU** in your server (if not already present)
2. **Install the NVIDIA Container Toolkit** on the host — follow the [official installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
3. **Reinstall the AI Assistant** — Go to [Apps](/settings/apps), find AI Assistant, and click **Force Reinstall**
3. **Reinstall the AI Assistant** — Go to [Supply Depot](/supply-depot), find AI Assistant, and click **Force Reinstall**
N.O.M.A.D. will detect the GPU during installation and configure the AI to use it automatically. You'll see "NVIDIA container runtime detected" in the installation progress.
@ -139,7 +151,7 @@ N.O.M.A.D. will detect the GPU during installation and configure the AI to use i
When you add or swap a GPU, N.O.M.A.D. needs to reconfigure the AI container to use it:
1. Make sure the **NVIDIA Container Toolkit** is installed on the host
2. Go to **[Apps](/settings/apps)**
2. Go to **[Supply Depot](/supply-depot)**
3. Find the **AI Assistant** and click **Force Reinstall**
Force Reinstall recreates the AI container with GPU support enabled. Without this step, the AI continues to run on CPU only.
@ -151,7 +163,7 @@ N.O.M.A.D. checks whether your GPU is actually accessible inside the AI containe
### AI Chat not available
The AI Chat page requires the AI Assistant to be installed first:
1. Go to **[Apps](/settings/apps)**
1. Go to **[Supply Depot](/supply-depot)**
2. Install the **AI Assistant**
3. Wait for the installation to complete
4. The AI Chat will then be accessible from the home screen or [Chat](/chat)
@ -159,7 +171,7 @@ The AI Chat page requires the AI Assistant to be installed first:
### Knowledge Base upload stuck
If a document upload appears stuck in the Knowledge Base:
1. Check that the AI Assistant is running in **Settings → Apps**
1. Check that the AI Assistant is running in **Settings → Supply Depot**
2. Large documents take time to process — wait a few minutes
3. Try uploading a smaller document to verify the system is working
4. Check **Settings → System** for any error messages
@ -178,7 +190,7 @@ If submission fails, check the error message for details.
The service might still be starting up. Wait 1-2 minutes and try again.
If the problem persists:
1. Go to **Settings → Apps**
1. Go to **Settings → Supply Depot**
2. Find the problematic service
3. Click **Restart**
4. Wait 30 seconds, then try again
@ -221,12 +233,17 @@ Yes, while you have internet access. Updates include:
- Security improvements
- Performance enhancements
### Can N.O.M.A.D. update itself automatically?
Yes. N.O.M.A.D. can keep its software, its installed apps, and its content current on its own. Automatic updates are **opt-in and off by default** — you turn on what you want from **Settings → Updates** (and, for apps, a per-app toggle in the Supply Depot). They only run inside a time window you choose, after safety checks, and never apply major version jumps automatically. See the **[Updates guide](/docs/updates)** for a full walkthrough.
### How do I update content (Wikipedia, etc.)?
Content updates are separate from software updates:
1. Go to **Settings → Content Manager** or **Content Explorer**
2. Check for newer versions of your installed content
3. Download updated versions as needed
You can also turn on **automatic content updates** so installed Wikipedia/ZIM libraries and map regions refresh on their own overnight — see the [Updates guide](/docs/updates).
Tip: New Wikipedia snapshots are released approximately monthly.
### What happens if an update fails?

Some files were not shown because too many files have changed in this diff Show More