Fold the sentinel into the scraper and drop the sentinel service (BL-0046)
The stall watchdog now runs inside the scraper (ScraperService.checkStalls, every 30 s from the work loop): same rule as before, one checkpoint-safe restart, quarantine on the second stall, missing checkpoint is no evidence, and paused time no longer counts. Stalls are recorded as scraper incidents (failure.kind 'stall'). The private /api/recovery endpoint is gone. Dropped with the service: reader/scraper-unreachable incidents (supervisor and Docker handle crashes; the health page shows "Scraper: unreachable"), duplicate job-failure incidents and the unused /metrics endpoint. The stack is now reader + scraper (+ optional peer-gateway). The supervisor test moved to runtime/test (npm run test:runtime stays). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88555d4780
commit
b2bb34174c
37 files changed
+163
-311
No files matched your search
@@ -17,8 +17,6 @@
|
|||||||
!discovery/**
|
!discovery/**
|
||||||
!runtime/
|
!runtime/
|
||||||
!runtime/**
|
!runtime/**
|
||||||
!sentinel/
|
|
||||||
!sentinel/**
|
|
||||||
!scraper/
|
!scraper/
|
||||||
!scraper/**
|
!scraper/**
|
||||||
!peering/
|
!peering/
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ READER_PORT=8080
|
|||||||
LIBRARY_PATH=./library
|
LIBRARY_PATH=./library
|
||||||
DATA_PATH=./data
|
DATA_PATH=./data
|
||||||
SCRAPER_DATA_PATH=./data/scraper
|
SCRAPER_DATA_PATH=./data/scraper
|
||||||
SENTINEL_DATA_PATH=./data/sentinel
|
|
||||||
SCRAPER_TOKEN_PATH=./.secrets/scraper_token
|
SCRAPER_TOKEN_PATH=./.secrets/scraper_token
|
||||||
# Built-in backups of DATA_PATH (docs/backup.md): an existing folder, ideally on another
|
# Built-in backups of DATA_PATH (docs/backup.md): an existing folder, ideally on another
|
||||||
# disk/NAS, writable by uid 1000. Unset = backups off (./backup is then mounted unused).
|
# disk/NAS, writable by uid 1000. Unset = backups off (./backup is then mounted unused).
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ COPY discovery ./discovery
|
|||||||
COPY tools ./tools
|
COPY tools ./tools
|
||||||
COPY scraper ./scraper
|
COPY scraper ./scraper
|
||||||
COPY peering ./peering
|
COPY peering ./peering
|
||||||
COPY sentinel ./sentinel
|
|
||||||
COPY runtime ./runtime
|
COPY runtime ./runtime
|
||||||
RUN mkdir -p /library /data && chown node:node /data /library
|
RUN mkdir -p /library /data && chown node:node /data /library
|
||||||
ENV PORT=8080 LIBRARY=/library DATA=/data TZ=Europe/Berlin
|
ENV PORT=8080 LIBRARY=/library DATA=/data TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Open http://localhost:8080. The first account you create becomes the admin. Try
|
|||||||
node tools/make-dummy.js library
|
node tools/make-dummy.js library
|
||||||
```
|
```
|
||||||
|
|
||||||
To run the reader, managed scraper and Sentinel together, use `npm run start:all`. Admins can
|
To run the reader and managed scraper together, use `npm run start:all`. Admins can
|
||||||
then manage the persistent download queue, progress, pauses, retries, and rate limits
|
then manage the persistent download queue, progress, pauses, retries, and rate limits
|
||||||
under **Settings → Admin → Scraper**. The reader continues working if the scraper is stopped.
|
under **Settings → Admin → Scraper**. The reader continues working if the scraper is stopped.
|
||||||
**Settings → Admin → Collections & metadata** manages grouped tiles, reading order and relationships;
|
**Settings → Admin → Collections & metadata** manages grouped tiles, reading order and relationships;
|
||||||
@@ -44,7 +44,7 @@ docker compose up -d --build
|
|||||||
```
|
```
|
||||||
|
|
||||||
Stop existing native writers before using their data in containers. `docker-compose.yml`
|
Stop existing native writers before using their data in containers. `docker-compose.yml`
|
||||||
runs reader, scraper and Sentinel with explicit persistent bind mounts (`library/`,
|
runs reader and scraper with explicit persistent bind mounts (`library/`,
|
||||||
`data/`), file-backed secrets and only a loopback reader port. No named data volume is
|
`data/`), file-backed secrets and only a loopback reader port. No named data volume is
|
||||||
used. See [Compose deployment](docs/compose-deployment.md) for local development, VM
|
used. See [Compose deployment](docs/compose-deployment.md) for local development, VM
|
||||||
recommendations, cutover, image releases and backup/rollback. The local Docker stack
|
recommendations, cutover, image releases and backup/rollback. The local Docker stack
|
||||||
@@ -74,7 +74,6 @@ see administration; personal settings do not poll scraper services. See
|
|||||||
| `SCRAPER_URL` | `http://127.0.0.1:8091` | Private scraper daemon URL |
|
| `SCRAPER_URL` | `http://127.0.0.1:8091` | Private scraper daemon URL |
|
||||||
| `SCRAPER_TOKEN` | empty | Shared daemon token; required when it listens beyond loopback |
|
| `SCRAPER_TOKEN` | empty | Shared daemon token; required when it listens beyond loopback |
|
||||||
| `SCRAPER_TOKEN_FILE` | empty | Alternative secret file (used in Compose) |
|
| `SCRAPER_TOKEN_FILE` | empty | Alternative secret file (used in Compose) |
|
||||||
| `SENTINEL_URL` | `http://127.0.0.1:8092` | Private observation service URL |
|
|
||||||
| `BACKUP` | – | Existing folder for built-in backups of `DATA`; unset = off. Compose: set `BACKUP_PATH`. Schedule and retention under Settings → Service health ([backup](docs/backup.md)) |
|
| `BACKUP` | – | Existing folder for built-in backups of `DATA`; unset = off. Compose: set `BACKUP_PATH`. Schedule and retention under Settings → Service health ([backup](docs/backup.md)) |
|
||||||
|
|
||||||
Serve it behind a reverse proxy with HTTPS if it is reachable from the internet. All pages use relative URLs, so a sub-path like `/manga/` works.
|
Serve it behind a reverse proxy with HTTPS if it is reachable from the internet. All pages use relative URLs, so a sub-path like `/manga/` works.
|
||||||
|
|||||||
@@ -11,6 +11,3 @@ services:
|
|||||||
scraper:
|
scraper:
|
||||||
volumes:
|
volumes:
|
||||||
- ./scraper:/app/scraper:ro
|
- ./scraper:/app/scraper:ro
|
||||||
sentinel:
|
|
||||||
volumes:
|
|
||||||
- ./sentinel:/app/sentinel:ro
|
|
||||||
@@ -57,7 +57,6 @@ services:
|
|||||||
READING_QUIZ_UNLOCK_MINUTES: ${READING_QUIZ_UNLOCK_MINUTES:-60}
|
READING_QUIZ_UNLOCK_MINUTES: ${READING_QUIZ_UNLOCK_MINUTES:-60}
|
||||||
READING_QUIZ_CATALOG_PATH: ${READING_QUIZ_CATALOG_PATH:-}
|
READING_QUIZ_CATALOG_PATH: ${READING_QUIZ_CATALOG_PATH:-}
|
||||||
SCRAPER_URL: http://scraper:8091
|
SCRAPER_URL: http://scraper:8091
|
||||||
SENTINEL_URL: http://sentinel:8092
|
|
||||||
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
|
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
|
||||||
networks: [backend, egress]
|
networks: [backend, egress]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -101,31 +100,6 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
sentinel:
|
|
||||||
<<: *app
|
|
||||||
command: [node, runtime/launch.js, sentinel]
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: ${SENTINEL_DATA_PATH:-./data/sentinel}
|
|
||||||
target: /data/sentinel
|
|
||||||
bind: {create_host_path: false}
|
|
||||||
environment:
|
|
||||||
DATA: /data
|
|
||||||
SENTINEL_DATA: /data/sentinel
|
|
||||||
SENTINEL_HOST: 0.0.0.0
|
|
||||||
SENTINEL_PORT: "8092"
|
|
||||||
READER_URL: http://reader:8080
|
|
||||||
SCRAPER_URL: http://scraper:8091
|
|
||||||
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
|
|
||||||
TZ: ${TZ:-Europe/Berlin}
|
|
||||||
networks: [backend]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD, node, tools/healthcheck.js, sentinel]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
start_period: 60s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
peer-gateway:
|
peer-gateway:
|
||||||
<<: *app
|
<<: *app
|
||||||
profiles: [peering]
|
profiles: [peering]
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
MangaCircus is a self-hosted reader for manga, comics and webtoons. The reader owns
|
MangaCircus is a self-hosted reader for manga, comics and webtoons. The reader owns
|
||||||
accounts/progress and a separate collection catalog. The scraper owns the persistent
|
accounts/progress and a separate collection catalog. The scraper owns the persistent
|
||||||
queue and chapter publication. Sentinel observes health/progress and requests bounded
|
queue and chapter publication, and its built-in stall watchdog requeues a stuck job
|
||||||
checkpoint recovery. Separate supervisors isolate service failures. All content remains
|
once at a checkpoint. Separate supervisors isolate service failures. All content remains
|
||||||
a plain folder tree; there is no database server or embedded autonomous LLM.
|
a plain folder tree; there is no database server or embedded autonomous LLM.
|
||||||
|
|
||||||
Related documents:
|
Related documents:
|
||||||
@@ -48,12 +48,12 @@ Related documents:
|
|||||||
|
|
||||||
## 3. Components
|
## 3. Components
|
||||||
|
|
||||||
Runtime topology (2026-10-05): native `tools/start-all.js` launches reader, scraper and
|
Runtime topology (2026-10-22): native `tools/start-all.js` launches reader and scraper
|
||||||
Sentinel independently. In Compose, each container runs `runtime/launch.js <service>`
|
independently. In Compose, each container runs `runtime/launch.js <service>`
|
||||||
and exactly one service child. A failure does not stop unrelated services. Exactly one
|
and exactly one service child. A failure does not stop unrelated services. Exactly one
|
||||||
reader and queue-owning scraper may write each shared state tree; do not scale replicas.
|
reader and queue-owning scraper may write each shared state tree; do not scale replicas.
|
||||||
The catalog and relation store is reader-owned `data/catalog.json`. Sentinel writes only
|
The catalog and relation store is reader-owned `data/catalog.json`. No service has a
|
||||||
`data/sentinel/`; it has no Docker socket or arbitrary code-repair authority.
|
Docker socket or arbitrary code-repair authority.
|
||||||
|
|
||||||
The admin browser never talks to the scraper daemon directly. Admin-only reader
|
The admin browser never talks to the scraper daemon directly. Admin-only reader
|
||||||
routes proxy commands to the daemon's private API, so its shared token stays on the
|
routes proxy commands to the daemon's private API, so its shared token stays on the
|
||||||
@@ -375,7 +375,7 @@ docker compose up -d --build
|
|||||||
|----------|--------------|-----|
|
|----------|--------------|-----|
|
||||||
| Index the folder tree in place | Import into a DB / managed storage | Stays readable by any viewer and writable by any tool. Nothing to keep in sync |
|
| Index the folder tree in place | Import into a DB / managed storage | Stays readable by any viewer and writable by any tool. Nothing to keep in sync |
|
||||||
| No dependencies, no framework, no build | Express, React, bundler | Minimal code, maximum longevity, runs anywhere Node runs |
|
| No dependencies, no framework, no build | Express, React, bundler | Minimal code, maximum longevity, runs anywhere Node runs |
|
||||||
| Built-in backup in the reader, queue paused, no container stop | Host job that stops the stack; backup in Sentinel | Only the reader sees all of `/data` and the in-memory `db.json`; the scraper's pause API gives a consistent point without downtime |
|
| Built-in backup in the reader, queue paused, no container stop | Host job that stops the stack; backup in a separate service | Only the reader sees all of `/data` and the in-memory `db.json`; the scraper's pause API gives a consistent point without downtime |
|
||||||
| JSON file for user data | SQLite (`node:sqlite` is still experimental in Node 22/24), Postgres | Zero dependencies, trivial backup, enough for the target size. Easy to swap: all access goes through the `db` object |
|
| JSON file for user data | SQLite (`node:sqlite` is still experimental in Node 22/24), Postgres | Zero dependencies, trivial backup, enough for the target size. Easy to swap: all access goes through the `db` object |
|
||||||
| Page roles as file-name tags | Page lists in JSON | Visible in any file browser, survives copying, no second source of truth. Unknown tags are harmless |
|
| Page roles as file-name tags | Page lists in JSON | Visible in any file browser, survives copying, no second source of truth. Unknown tags are harmless |
|
||||||
| Image sizes read on the server | Measure in the browser after load | Layout before load: exact scroll restore, zoom anchoring, auto-detected double pages, no layout jumps |
|
| Image sizes read on the server | Measure in the browser after load | Layout before load: exact scroll restore, zoom anchoring, auto-detected double pages, no layout jumps |
|
||||||
|
|||||||
+11
-11
@@ -28,11 +28,10 @@ target private.
|
|||||||
### Why the reader owns it
|
### Why the reader owns it
|
||||||
|
|
||||||
The reader is the only service that mounts all of `/data` (the scraper sees only
|
The reader is the only service that mounts all of `/data` (the scraper sees only
|
||||||
`/data/scraper`, Sentinel only `/data/sentinel`), it holds `db.json` in memory and owns
|
`/data/scraper`), it holds `db.json` in memory and owns its atomic writer, it already
|
||||||
its atomic writer, it already talks to the scraper's private control API with the
|
talks to the scraper's private control API with the shared token, and the admin
|
||||||
shared token, and the admin settings UI lives there. Running it in Sentinel would need
|
settings UI lives there. Running it in the scraper would need a full read-write `/data`
|
||||||
a full read-write `/data` mount there and break its "writes only `data/sentinel/`"
|
mount there (see [architecture.md §3](architecture.md#3-components)).
|
||||||
contract (see [architecture.md §3](architecture.md#3-components)).
|
|
||||||
|
|
||||||
## Settings and status
|
## Settings and status
|
||||||
|
|
||||||
@@ -67,9 +66,10 @@ POST /api/admin/backup {command:"run"} -> 409 when off or already running
|
|||||||
- `db.json` is serialized from the reader's in-memory state at that moment.
|
- `db.json` is serialized from the reader's in-memory state at that moment.
|
||||||
- `scraper/queue.json` is stored with `paused: true` (status `paused`), so a
|
- `scraper/queue.json` is stored with `paused: true` (status `paused`), so a
|
||||||
restored copy never starts downloading by itself. The live file is not touched.
|
restored copy never starts downloading by itself. The live file is not touched.
|
||||||
- `scan-cache.json`, `library-index.json`, `catalog.json`, peering, Sentinel state,
|
- `scan-cache.json`, `library-index.json`, `catalog.json`, peering, logs and
|
||||||
logs and everything else under `DATA` is copied as found; all of them are
|
everything else under `DATA` is copied as found; all of them are written by
|
||||||
written by atomic rename, so each file is complete.
|
atomic rename, so each file is complete. A leftover `sentinel/` folder from
|
||||||
|
before 2026-10-22 is copied too; it is unused and can be deleted.
|
||||||
- Left out: `runtime*.json`, `*.lock` (`daemon.lock`, `supervisor.lock`), `*.pid`,
|
- Left out: `runtime*.json`, `*.lock` (`daemon.lock`, `supervisor.lock`), `*.pid`,
|
||||||
`*.tmp`, symlinks, and the `BACKUP` folder itself if it lies inside `DATA`.
|
`*.tmp`, symlinks, and the `BACKUP` folder itself if it lies inside `DATA`.
|
||||||
- `.circus-backup.json` at the archive root records when and how it was made.
|
- `.circus-backup.json` at the archive root records when and how it was made.
|
||||||
@@ -93,8 +93,8 @@ plus those seconds.
|
|||||||
Restoring replaces the user data with the state of the archive. Reading progress
|
Restoring replaces the user data with the state of the archive. Reading progress
|
||||||
and queue changes made after that backup are lost.
|
and queue changes made after that backup are lost.
|
||||||
|
|
||||||
1. Stop the stack (or at least reader, scraper and Sentinel):
|
1. Stop the stack (or at least reader and scraper):
|
||||||
`docker compose stop reader scraper sentinel`.
|
`docker compose stop reader scraper`.
|
||||||
2. Move the current data folder aside instead of deleting it:
|
2. Move the current data folder aside instead of deleting it:
|
||||||
`mv /srv/mangacircus/data /srv/mangacircus/data.before-restore`.
|
`mv /srv/mangacircus/data /srv/mangacircus/data.before-restore`.
|
||||||
3. Unpack the archive into an empty data folder owned by uid 1000:
|
3. Unpack the archive into an empty data folder owned by uid 1000:
|
||||||
@@ -107,7 +107,7 @@ and queue changes made after that backup are lost.
|
|||||||
4. Start the reader first: `docker compose up -d --no-build reader`. Check `/health`,
|
4. Start the reader first: `docker compose up -d --no-build reader`. Check `/health`,
|
||||||
log in, check progress and library. The reader rebuilds a missing or stale library
|
log in, check progress and library. The reader rebuilds a missing or stale library
|
||||||
index by itself.
|
index by itself.
|
||||||
5. Start `scraper sentinel`. The restored queue is paused: check it under
|
5. Start `scraper`. The restored queue is paused: check it under
|
||||||
Settings → Scraper and resume it when it looks right.
|
Settings → Scraper and resume it when it looks right.
|
||||||
|
|
||||||
The scraper token is not in `DATA`; Compose keeps it under `.secrets/` (or the
|
The scraper token is not in `DATA`; Compose keeps it under `.secrets/` (or the
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ membership edits. It should preserve rejected decisions and report uncertainty.
|
|||||||
It must not turn a shared-universe link into an automatic collection or an unquestioned
|
It must not turn a shared-universe link into an automatic collection or an unquestioned
|
||||||
reading order. No embedded LLM, API credentials, automatic research loop, or autonomous
|
reading order. No embedded LLM, API credentials, automatic research loop, or autonomous
|
||||||
catalog approval is enabled in this release. That optional capability belongs behind
|
catalog approval is enabled in this release. That optional capability belongs behind
|
||||||
this same evidence/review boundary, not inside adapters or Sentinel.
|
this same evidence/review boundary, not inside adapters or the scraper's stall watchdog.
|
||||||
|
|
||||||
## Persistence and API contract
|
## Persistence and API contract
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -29,6 +29,6 @@ Public catalog chapter counts are often absent; the UI says “Chapters unknown
|
|||||||
|
|
||||||
## Deployment and QA
|
## Deployment and QA
|
||||||
|
|
||||||
The Docker image now copies `greatest/` as app code; no new Compose service, volume, port, secret, or migration is needed. Rebuild/recreate **reader only** to pick up catalog/UI changes. The scraper and sentinel do not need restarting. Existing `data/db.json` accepts the new `greatestRatings` object without migration.
|
The Docker image now copies `greatest/` as app code; no new Compose service, volume, port, secret, or migration is needed. Rebuild/recreate **reader only** to pick up catalog/UI changes. The scraper does not need restarting. Existing `data/db.json` accepts the new `greatestRatings` object without migration.
|
||||||
|
|
||||||
Run `npm run test:reader` for catalog/API tests and `node tools/test-greatest-ui.js` for disposable headless-browser QA. The UI fixture blocks remote covers and never contacts the scraper. Manual catalog edits should preserve stable IDs once anyone has voted; changing a title's ID strands its votes.
|
Run `npm run test:reader` for catalog/API tests and `node tools/test-greatest-ui.js` for disposable headless-browser QA. The UI fixture blocks remote covers and never contacts the scraper. Manual catalog edits should preserve stable IDs once anyone has voted; changing a title's ID strands its votes.
|
||||||
+1
-1
@@ -58,7 +58,7 @@ past one instance for the same usage-counter directory.
|
|||||||
Tailscale TCP forward is recommended for the pilot; the default loopback binding
|
Tailscale TCP forward is recommended for the pilot; the default loopback binding
|
||||||
is not remotely reachable. Do not expose port 8093 on the public internet merely
|
is not remotely reachable. Do not expose port 8093 on the public internet merely
|
||||||
to make pairing work. Use a stable tailnet DNS name, not a changing IP. Keep
|
to make pairing work. Use a stable tailnet DNS name, not a changing IP. Keep
|
||||||
scraper (8091) and Sentinel (8092) private.
|
the scraper (8091) private.
|
||||||
|
|
||||||
The `PEER_BIND`, `PEER_PORT`, `PEER_DATA_PATH`, and `PEER_USAGE_PATH` Compose variables
|
The `PEER_BIND`, `PEER_PORT`, `PEER_DATA_PATH`, and `PEER_USAGE_PATH` Compose variables
|
||||||
control the bind and persistent paths. The gateway's internal port remains 8093.
|
control the bind and persistent paths. The gateway's internal port remains 8093.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ hours**; that page is read-only.
|
|||||||
Set these deployment variables in the Compose `.env` file next to
|
Set these deployment variables in the Compose `.env` file next to
|
||||||
`docker-compose.yml`, then recreate **only the reader**. The `reader` service
|
`docker-compose.yml`, then recreate **only the reader**. The `reader` service
|
||||||
passes them through to the container. `READER_IMAGE` selects a reader-only
|
passes them through to the container. `READER_IMAGE` selects a reader-only
|
||||||
release while `APP_IMAGE` continues to select the scraper and Sentinel image.
|
release while `APP_IMAGE` continues to select the scraper image.
|
||||||
Leaving `READING_WINDOWS_JSON` empty disables the limiter. Day keys
|
Leaving `READING_WINDOWS_JSON` empty disables the limiter. Day keys
|
||||||
are `0` Sunday through `6` Saturday. Each day has any number of `[start,end]`
|
are `0` Sunday through `6` Saturday. Each day has any number of `[start,end]`
|
||||||
intervals; an end earlier than its start carries into the next day. `24:00` is
|
intervals; an end earlier than its start carries into the next day. `24:00` is
|
||||||
|
|||||||
+29
-29
@@ -4,16 +4,15 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Browser → Reader ──private API──→ Scraper → library (atomic chapter publication)
|
Browser → Reader ──private API──→ Scraper → library (atomic chapter publication)
|
||||||
│ ↑
|
└─ stall watchdog: bounded checkpoint recovery only
|
||||||
└──health──→ Sentinel ───┘ bounded checkpoint recovery only
|
|
||||||
|
|
||||||
Supervisor(reader) Supervisor(scraper) Supervisor(sentinel)
|
Supervisor(reader) Supervisor(scraper)
|
||||||
↑
|
↑
|
||||||
Coding agent: inspect incidents → reproduce → patch → test → release
|
Coding agent: inspect incidents → reproduce → patch → test → release
|
||||||
```
|
```
|
||||||
|
|
||||||
The reader is the only writer of users/progress and the catalog. The scraper is the
|
The reader is the only writer of users/progress and the catalog. The scraper is the
|
||||||
only queue owner and content publisher. Sentinel owns only observations and incidents.
|
only queue owner and content publisher, and it records its own failures and incidents.
|
||||||
Each service's supervisor owns only that child process. The coding agent is an
|
Each service's supervisor owns only that child process. The coding agent is an
|
||||||
**escalation/maintenance operator**, not a necessary part of normal downloading.
|
**escalation/maintenance operator**, not a necessary part of normal downloading.
|
||||||
The recurring Codex check is an additional backstop, not the runtime watchdog.
|
The recurring Codex check is an additional backstop, not the runtime watchdog.
|
||||||
@@ -28,18 +27,18 @@ The recurring Codex check is an additional backstop, not the runtime watchdog.
|
|||||||
| Parser/local adapter failure | Quarantine job, keep partial data, continue other titles | Two different affected jobs in an hour pause source as systemic failure |
|
| Parser/local adapter failure | Quarantine job, keep partial data, continue other titles | Two different affected jobs in an hour pause source as systemic failure |
|
||||||
| Storage/permission failure | Pause all downloads and report | No automatic retry |
|
| Storage/permission failure | Pause all downloads and report | No automatic retry |
|
||||||
| HTTP 401/403/429 or access challenge | Pause affected source lane | No automatic retry/bypass |
|
| HTTP 401/403/429 or access challenge | Pause affected source lane | No automatic retry/bypass |
|
||||||
| Active job with no progress for 15 minutes | Sentinel requests cooperative checkpoint-safe retry | One stall recovery; next stall quarantined |
|
| Active job with no progress for 15 minutes | Scraper stall watchdog stops it at its next checkpoint and requeues it | One stall recovery; next stall quarantined |
|
||||||
|
|
||||||
Sentinel only treats an active job as stalled when its checkpoint can be read.
|
The stall watchdog runs inside the scraper (`ScraperService.checkStalls`, every 30 s
|
||||||
The scraper resolves source-specific work folders (including MangaPill's
|
from the work loop). A job is stalled when its checkpoint fingerprint (completed
|
||||||
`mangapill-` prefix) before reporting progress; a missing checkpoint is not
|
chapters/pages) has not moved for 15 minutes, or for one chapter delay plus three
|
||||||
evidence that downloads stopped. An operator retry resets the job's stall
|
minutes if that is longer. Time spent paused does not count, and a deliberately
|
||||||
recovery count without deleting its partial download.
|
paused queue is not a stall. It only treats a job as stalled when its checkpoint can
|
||||||
|
be read: the scraper resolves source-specific work folders (including MangaPill's
|
||||||
The daemon remains authoritative: a recovery request includes the observed progress
|
`mangapill-` prefix), and a missing checkpoint is not evidence that downloads stopped.
|
||||||
fingerprint and is rejected if the job has moved, paused, or is no longer stale.
|
Each stall records an incident with `failure.kind: 'stall'` and an `events.log` entry.
|
||||||
Sentinel also observes completed/page counts; a deliberately paused queue is not a
|
An operator retry resets the job's stall recovery count without deleting its partial
|
||||||
stall. Timeouts bound source fetches to 60 seconds and metadata calls to 30 seconds.
|
download. Timeouts bound source fetches to 60 seconds and metadata calls to 30 seconds.
|
||||||
Publication remains one complete chapter at a time, on the same filesystem.
|
Publication remains one complete chapter at a time, on the same filesystem.
|
||||||
No recovery action raises pacing, creates another worker for the same source,
|
No recovery action raises pacing, creates another worker for the same source,
|
||||||
changes identities or deletes partial images.
|
changes identities or deletes partial images.
|
||||||
@@ -62,14 +61,14 @@ use an explicit operator retry after fixing their cause.
|
|||||||
|
|
||||||
## Observability
|
## Observability
|
||||||
|
|
||||||
**Settings → Service health** shows Sentinel status, active checkpoints, failed jobs
|
**Settings → Service health** shows scraper reachability ("Scraper: unreachable" when
|
||||||
and incident history. **Settings → Scraper** retains queue, pacing and discovery controls.
|
the reader cannot reach it), active checkpoints, failed jobs and the scraper's incident
|
||||||
|
history. Process crashes are handled by the supervisor and Docker restart policy, not
|
||||||
|
recorded as incidents. **Settings → Scraper** retains queue, pacing and discovery controls.
|
||||||
Service errors are classified rather than buried in a single historical `lastError`.
|
Service errors are classified rather than buried in a single historical `lastError`.
|
||||||
`npm run check` performs read-only checks. Sentinel's private, token-protected `/metrics`
|
`npm run check` performs read-only checks of reader and scraper.
|
||||||
exports reader-up, scraper-up and open-incident gauges for a future Prometheus collector.
|
|
||||||
It is not a public endpoint.
|
|
||||||
|
|
||||||
Scraper status is independent of expensive library scans. Concurrent admin/Sentinel
|
Scraper status is independent of expensive library scans. Concurrent admin/health
|
||||||
probes share one background inventory refresh and receive the previous summary
|
probes share one background inventory refresh and receive the previous summary
|
||||||
immediately. The dashboard summary refreshes at most once per minute unless a
|
immediately. The dashboard summary refreshes at most once per minute unless a
|
||||||
publication or metadata change invalidates it; duplicate-sensitive decisions still
|
publication or metadata change invalidates it; duplicate-sensitive decisions still
|
||||||
@@ -82,22 +81,23 @@ may temporarily show no library targets, but queue and rate controls remain usab
|
|||||||
Storage:
|
Storage:
|
||||||
|
|
||||||
- `data/catalog.json`, `data/collection-covers/`: reader catalog and covers.
|
- `data/catalog.json`, `data/collection-covers/`: reader catalog and covers.
|
||||||
- `data/scraper/queue.json`, `events.log`: queue, circuits, failures and incidents.
|
- `data/scraper/queue.json`, `events.log`: queue, circuits, failures and incidents
|
||||||
- `data/sentinel/state.json`: Sentinel incidents and last observation.
|
(including stalls).
|
||||||
- `data/runtime-reader.json`, `data/scraper/runtime.json`, `data/sentinel/runtime.json`:
|
- `data/runtime-reader.json`, `data/scraper/runtime.json`: supervisor status and
|
||||||
supervisor status and persisted crash budget.
|
persisted crash budget.
|
||||||
|
- `data/sentinel/`: only on installs from before 2026-10-22; unused, safe to delete.
|
||||||
- `data/scraper/supervisor.lock`, `daemon.lock`: native process ownership; transient.
|
- `data/scraper/supervisor.lock`, `daemon.lock`: native process ownership; transient.
|
||||||
|
|
||||||
Scraper/Sentinel/supervisor emit structured logs. Queue and Sentinel histories retain
|
Scraper and supervisor emit structured logs. The queue history retains
|
||||||
200 incidents; the UI shows a bounded subset. Docker logs are rotated. Native Windows
|
200 incidents; the UI shows a bounded subset. Docker logs are rotated. Native Windows
|
||||||
supervisor logs remain append-only: archive them during stopped maintenance if needed.
|
supervisor logs remain append-only: archive them during stopped maintenance if needed.
|
||||||
Never export user databases, tokens, cookies, signed query strings or unredacted environments.
|
Never export user databases, tokens, cookies, signed query strings or unredacted environments.
|
||||||
|
|
||||||
## What self-repair intentionally does not mean
|
## What self-repair intentionally does not mean
|
||||||
|
|
||||||
Sentinel cannot execute a shell, patch adapters, query image sources, access a Docker
|
The stall watchdog cannot execute a shell, patch adapters, access a Docker socket,
|
||||||
socket, or issue unrestricted restart commands. It cannot repair broken markup or
|
or restart processes; it only requeues or quarantines a job. Nothing at runtime
|
||||||
solve source challenges. The agent handles those escalations in the repository:
|
repairs broken markup or solves source challenges. The agent handles those escalations in the repository:
|
||||||
|
|
||||||
1. Inspect one bounded incident and relevant checkpoint; preserve the active lane.
|
1. Inspect one bounded incident and relevant checkpoint; preserve the active lane.
|
||||||
2. Add a fixture reproducing the failure, patch the smallest adapter/logic surface.
|
2. Add a fixture reproducing the failure, patch the smallest adapter/logic surface.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ npm run start:scraper # daemon only
|
|||||||
```
|
```
|
||||||
|
|
||||||
These commands are for native development only. The current Windows installation
|
These commands are for native development only. The current Windows installation
|
||||||
runs the three-service Docker Compose stack; the old Scheduled Task is disabled.
|
runs the Docker Compose stack; the old Scheduled Task is disabled.
|
||||||
Never start a native supervisor or daemon against its mounted state. Its observed
|
Never start a native supervisor or daemon against its mounted state. Its observed
|
||||||
ports, logs, persisted settings, restart procedure, and recovery snapshot are recorded in
|
ports, logs, persisted settings, restart procedure, and recovery snapshot are recorded in
|
||||||
[`local-deployment.md`](local-deployment.md).
|
[`local-deployment.md`](local-deployment.md).
|
||||||
|
|||||||
+2
-3
@@ -9,11 +9,10 @@
|
|||||||
"start:scraper": "node scraper/daemon.js",
|
"start:scraper": "node scraper/daemon.js",
|
||||||
"start:peer": "node peering/gateway.js",
|
"start:peer": "node peering/gateway.js",
|
||||||
"start:all": "node tools/start-all.js",
|
"start:all": "node tools/start-all.js",
|
||||||
"start:sentinel": "node sentinel/daemon.js",
|
|
||||||
"check": "node tools/check.js",
|
"check": "node tools/check.js",
|
||||||
"check:compose": "node tools/compose-control.js check",
|
"check:compose": "node tools/compose-control.js check",
|
||||||
"test:runtime": "node --test sentinel/test/*.test.js",
|
"test:runtime": "node --test runtime/test/*.test.js",
|
||||||
"test:compose": "node tools/test-compose.js",
|
"test:compose":"node tools/test-compose.js",
|
||||||
"test:ui": "node tools/test-compose.js --settings-ui --queue-ui --collections-ui --reading-ui",
|
"test:ui": "node tools/test-compose.js --settings-ui --queue-ui --collections-ui --reading-ui",
|
||||||
"test:direction-ui": "node tools/test-compose.js --direction-ui",
|
"test:direction-ui": "node tools/test-compose.js --direction-ui",
|
||||||
"test:discovery-ui": "node tools/test-compose.js --discovery-ui",
|
"test:discovery-ui": "node tools/test-compose.js --discovery-ui",
|
||||||
|
|||||||
+2
-3
@@ -13,8 +13,7 @@
|
|||||||
backup.draw(health.backup);
|
backup.draw(health.backup);
|
||||||
main.textContent = '';
|
main.textContent = '';
|
||||||
main.append(h('section', { class: 'card' },
|
main.append(h('section', { class: 'card' },
|
||||||
h('p', {}, `Reader: ${health.reader.ok ? 'healthy' : 'scan error'} · Sentinel: ${health.sentinel.status} · Scraper: ${scraper.status || 'unavailable'}`),
|
h('p', {}, `Reader: ${health.reader.ok ? 'healthy' : 'scan error'} · Scraper: ${scraper.status || 'unreachable'}`),
|
||||||
h('p', { class: 'muted small' }, health.sentinel.checkedAt ? `Last independent check: ${new Date(health.sentinel.checkedAt).toLocaleString()}` : 'Sentinel has not reported yet.'),
|
|
||||||
h('a', { class: 'btn', href: 'settings.html#admin/scraper' }, 'Queue & hoster settings')));
|
h('a', { class: 'btn', href: 'settings.html#admin/scraper' }, 'Queue & hoster settings')));
|
||||||
for (const entry of scraper.active || []) main.append(h('section', { class: 'card' }, h('h2', {}, entry.source),
|
for (const entry of scraper.active || []) main.append(h('section', { class: 'card' }, h('h2', {}, entry.source),
|
||||||
h('p', {}, `${entry.current.title} · ${entry.job?.completed || 0}/${entry.job?.total || '?'} chapters`),
|
h('p', {}, `${entry.current.title} · ${entry.job?.completed || 0}/${entry.job?.total || '?'} chapters`),
|
||||||
@@ -27,7 +26,7 @@
|
|||||||
if (item.failure?.kind === 'access' && !confirm('Has the source access problem been resolved? This manually authorizes one retry.')) return;
|
if (item.failure?.kind === 'access' && !confirm('Has the source access problem been resolved? This manually authorizes one retry.')) return;
|
||||||
await api('api/admin/scraper', { command: 'control', action: item.status === 'unavailable' ? 'inspect' : 'retry', id: item.id }); load();
|
await api('api/admin/scraper', { command: 'control', action: item.status === 'unavailable' ? 'inspect' : 'retry', id: item.id }); load();
|
||||||
} }, item.status === 'unavailable' ? 'Inspect source' : 'Retry'), h('button', { class: 'btn', onclick: async () => { await api('api/admin/scraper', { command: 'control', action: 'skip', id: item.id }); load(); } }, 'Skip job')))) : h('p', { class: 'muted' }, 'No failed jobs.')));
|
} }, item.status === 'unavailable' ? 'Inspect source' : 'Retry'), h('button', { class: 'btn', onclick: async () => { await api('api/admin/scraper', { command: 'control', action: 'skip', id: item.id }); load(); } }, 'Skip job')))) : h('p', { class: 'muted' }, 'No failed jobs.')));
|
||||||
const incidents = [...(health.sentinel.incidents || []), ...(scraper.incidents || [])];
|
const incidents = scraper.incidents || [];
|
||||||
main.append(h('details', { class: 'card', open: incidents.some(i => i.state === 'open') }, h('summary', {}, 'Incident & recovery history'), incidents.slice(-50).map(i =>
|
main.append(h('details', { class: 'card', open: incidents.some(i => i.state === 'open') }, h('summary', {}, 'Incident & recovery history'), incidents.slice(-50).map(i =>
|
||||||
h('p', {}, `${i.at} · ${i.state} · ${i.message || i.failure?.message || ''}${i.resolution ? ' · ' + i.resolution : ''}`))));
|
h('p', {}, `${i.at} · ${i.state} · ${i.message || i.failure?.message || ''}${i.resolution ? ' · ' + i.resolution : ''}`))));
|
||||||
} catch (e) { main.textContent = ''; main.append(h('p', { class: 'error', role: 'alert' }, e.message)); }
|
} catch (e) { main.textContent = ''; main.append(h('p', { class: 'error', role: 'alert' }, e.message)); }
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ const fs = require('fs'), path = require('path'), { spawn } = require('child_pro
|
|||||||
const SPECS = {
|
const SPECS = {
|
||||||
reader: { script: 'server.js', port: () => process.env.PORT || 8080, file: () => path.join(process.env.DATA || 'data', 'runtime-reader.json') },
|
reader: { script: 'server.js', port: () => process.env.PORT || 8080, file: () => path.join(process.env.DATA || 'data', 'runtime-reader.json') },
|
||||||
scraper: { script: 'scraper/daemon.js', port: () => process.env.SCRAPER_PORT || 8091, file: () => path.join(process.env.SCRAPER_DATA || path.join(process.env.DATA || 'data', 'scraper'), 'runtime.json') },
|
scraper: { script: 'scraper/daemon.js', port: () => process.env.SCRAPER_PORT || 8091, file: () => path.join(process.env.SCRAPER_DATA || path.join(process.env.DATA || 'data', 'scraper'), 'runtime.json') },
|
||||||
sentinel: { script: 'sentinel/daemon.js', port: () => process.env.SENTINEL_PORT || 8092, file: () => path.join(process.env.SENTINEL_DATA || path.join(process.env.DATA || 'data', 'sentinel'), 'runtime.json') },
|
|
||||||
};
|
};
|
||||||
class Supervisor {
|
class Supervisor {
|
||||||
constructor(name, options = {}) {
|
constructor(name, options = {}) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const test = require('node:test'), assert = require('node:assert/strict');
|
const test = require('node:test'), assert = require('node:assert/strict');
|
||||||
const fs = require('fs'), os = require('os'), path = require('path'), { EventEmitter } = require('events');
|
const fs = require('fs'), os = require('os'), path = require('path'), { EventEmitter } = require('events');
|
||||||
const { Supervisor } = require('../../runtime/supervisor');
|
const { Supervisor } = require('../supervisor');
|
||||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
test('supervisor isolates a child crash and persists its bounded restart budget', async () => {
|
test('supervisor isolates a child crash and persists its bounded restart budget', async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-supervisor-')), file = path.join(dir, 'runtime.json');
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-supervisor-')), file = path.join(dir, 'runtime.json');
|
||||||
@@ -22,7 +22,7 @@ Runtime recovery is documented in `../docs/resilience.md`. Request-level 5xx ret
|
|||||||
remain limited to 30/60 seconds; after those are exhausted the daemon can resume the
|
remain limited to 30/60 seconds; after those are exhausted the daemon can resume the
|
||||||
same checkpoint once after a five-minute source cooldown. No automatic access-error
|
same checkpoint once after a five-minute source cooldown. No automatic access-error
|
||||||
retry is permitted. Unavailable titles and parser failures are job-scoped; repeated
|
retry is permitted. Unavailable titles and parser failures are job-scoped; repeated
|
||||||
independent parser failures stop the source lane. Sentinel never changes adapters.
|
independent parser failures stop the source lane. The stall watchdog never changes adapters.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
public metadata -> cached series metadata
|
public metadata -> cached series metadata
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ async function body(req) {
|
|||||||
if (url.pathname === '/api/queue/clear-completed') { const result = await service.clearCompleted(String(input.source || '')); return json(res, 200, { ...result, state: await service.publicState() }); }
|
if (url.pathname === '/api/queue/clear-completed') { const result = await service.clearCompleted(String(input.source || '')); return json(res, 200, { ...result, state: await service.publicState() }); }
|
||||||
if (url.pathname === '/api/queue/reorder') { await service.reorder(input.ids); return json(res, 200, await service.publicState()); }
|
if (url.pathname === '/api/queue/reorder') { await service.reorder(input.ids); return json(res, 200, await service.publicState()); }
|
||||||
if (url.pathname === '/api/control') { await service.control(String(input.action || ''), input.id); return json(res, 200, await service.publicState()); }
|
if (url.pathname === '/api/control') { await service.control(String(input.action || ''), input.id); return json(res, 200, await service.publicState()); }
|
||||||
if (url.pathname === '/api/recovery') return json(res, 200, await service.recover(input));
|
|
||||||
if (url.pathname === '/api/settings') { await service.updateSettings(input); return json(res, 200, await service.publicState()); }
|
if (url.pathname === '/api/settings') { await service.updateSettings(input); return json(res, 200, await service.publicState()); }
|
||||||
if (url.pathname === '/api/discovery/run') { await service.enqueueDiscoveryChecks('manual'); return json(res, 200, await service.publicState()); }
|
if (url.pathname === '/api/discovery/run') { await service.enqueueDiscoveryChecks('manual'); return json(res, 200, await service.publicState()); }
|
||||||
if (url.pathname === '/api/library/auto-update') { await service.setMangaAutoUpdate(String(input.id || ''), !!input.enabled); return json(res, 200, await service.publicState()); }
|
if (url.pathname === '/api/library/auto-update') { await service.setMangaAutoUpdate(String(input.id || ''), !!input.enabled); return json(res, 200, await service.publicState()); }
|
||||||
|
|||||||
+31
-14
@@ -124,6 +124,9 @@ class ScraperService {
|
|||||||
this.resolveAdapter = options.adapterFor || adapterFor;
|
this.resolveAdapter = options.adapterFor || adapterFor;
|
||||||
this.adapterList = options.adapters || adapters;
|
this.adapterList = options.adapters || adapters;
|
||||||
this.retryDelayMs = options.retryDelayMs ?? 300000;
|
this.retryDelayMs = options.retryDelayMs ?? 300000;
|
||||||
|
this.stallMs = options.stallMs ?? 900000;
|
||||||
|
this.stallObservations = new Map();
|
||||||
|
this.stallCheckedAt = 0;
|
||||||
this.runtimeHeartbeat = Date.now();
|
this.runtimeHeartbeat = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,22 +741,32 @@ class ScraperService {
|
|||||||
throw new Error('Unknown control action');
|
throw new Error('Unknown control action');
|
||||||
}
|
}
|
||||||
|
|
||||||
async recover(input) {
|
// Stall watchdog: an active job whose checkpoint has not moved for stallMs (or one
|
||||||
const item = this.state.queue.find(entry => entry.id === input.id);
|
// chapter delay + 3 min, if longer) is stopped at its next checkpoint and requeued
|
||||||
if (!item) throw new Error('Job not found');
|
// once; a second stall quarantines it. A missing checkpoint proves nothing, and time
|
||||||
// Compare observations so a stale watcher cannot disturb a progressing worker.
|
// spent paused does not count.
|
||||||
if (Object.values(this.state.currentIds).includes(item.id)) {
|
async checkStalls(now = Date.now()) {
|
||||||
|
const activeIds = new Set(Object.values(this.state.currentIds || {}));
|
||||||
|
for (const id of this.stallObservations.keys()) if (!activeIds.has(id)) this.stallObservations.delete(id);
|
||||||
|
for (const item of this.state.queue) {
|
||||||
|
if (!activeIds.has(item.id)) continue;
|
||||||
const job = await this.jobState(item);
|
const job = await this.jobState(item);
|
||||||
if (!job) return { changed: false };
|
if (!job) { this.stallObservations.delete(item.id); continue; }
|
||||||
if (progressKey(job) !== input.progressKey || this.state.paused || item.stopReason) return { changed: false };
|
const key = progressKey(job), prior = this.stallObservations.get(item.id);
|
||||||
if (Date.now() - Date.parse(job?.updated || item.started) < 900000) return { changed: false };
|
if (!prior || prior.key !== key) { this.stallObservations.set(item.id, { key, at: now }); continue; }
|
||||||
if ((item.stallRecoveries || 0) >= 1) {
|
if (this.state.paused || this.state.sourcePaused[item.source] || item.stopReason) { prior.at = now; continue; }
|
||||||
item.stopReason = 'quarantined';
|
if (now - prior.at < Math.max(this.stallMs, Number(job.rate?.chapterMaxMs || 0) + 180000)) continue;
|
||||||
} else { item.stallRecoveries = (item.stallRecoveries || 0) + 1; item.stopReason = 'recovering'; }
|
this.stallObservations.delete(item.id);
|
||||||
await this.event('warning', `Sentinel requested recovery at the next checkpoint for ${item.title}`, { id: item.id, source: item.source });
|
const minutes = Math.round((now - prior.at) / 60000), quarantine = (item.stallRecoveries || 0) >= 1;
|
||||||
return { changed: true };
|
const failure = { kind: 'stall', scope: 'job', retryable: !quarantine, message: quarantine
|
||||||
|
? `No progress for ${minutes} minutes after a restart; quarantined`
|
||||||
|
: `No progress for ${minutes} minutes; restarting from the last checkpoint` };
|
||||||
|
if (quarantine) { item.stopReason = 'quarantined'; item.failure = failure; item.error = failure.message; }
|
||||||
|
else { item.stallRecoveries = (item.stallRecoveries || 0) + 1; item.stopReason = 'recovering'; }
|
||||||
|
this.state.incidents.push({ id: crypto.randomUUID(), at: new Date(now).toISOString(), jobId: item.id, source: item.source, title: item.title, failure, state: 'open' });
|
||||||
|
this.state.incidents = this.state.incidents.slice(-200);
|
||||||
|
await this.event('warning', `${item.title}: ${failure.message}`, { id: item.id, title: item.title, source: item.source });
|
||||||
}
|
}
|
||||||
return { changed: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recomputeStatus() {
|
recomputeStatus() {
|
||||||
@@ -876,6 +889,10 @@ class ScraperService {
|
|||||||
const prior = this.state.status;
|
const prior = this.state.status;
|
||||||
this.recomputeStatus();
|
this.recomputeStatus();
|
||||||
if (prior !== this.state.status) await this.persist();
|
if (prior !== this.state.status) await this.persist();
|
||||||
|
if (Date.now() - this.stallCheckedAt >= 30000) {
|
||||||
|
this.stallCheckedAt = Date.now();
|
||||||
|
await this.checkStalls().catch(error => console.error('[scraper] stall check failed:', safeMessage(error)));
|
||||||
|
}
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,45 @@ test('status and watchdog read the checkpoint for each source layout', async t =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('daemon refuses recovery when the checkpoint cannot be read', async () => {
|
function stallService(job) {
|
||||||
const service = Object.create(ScraperService.prototype);
|
const service = new ScraperService({ stallMs: 100 });
|
||||||
service.state = { paused: false, currentIds: { mangapill: 'job' },
|
service.state = { paused: false, sourcePaused: {}, currentIds: { a: 'job' }, incidents: [],
|
||||||
queue: [{ id: 'job', source: 'mangapill', status: 'downloading' }] };
|
queue: [{ id: 'job', source: 'a', title: 'Manga', status: 'downloading' }] };
|
||||||
service.jobState = async () => null;
|
service.jobState = async () => job.value;
|
||||||
const result = await service.recover({ id: 'job', progressKey: '[0,0,"",0,""]' });
|
service.event = async () => {};
|
||||||
assert.deepEqual(result, { changed: false });
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stall check leaves progressing and deliberately paused jobs alone', async () => {
|
||||||
|
const job = { value: { completed: 0, current: { downloadedPages: 1 } } };
|
||||||
|
const service = stallService(job), item = service.state.queue[0];
|
||||||
|
await service.checkStalls(0);
|
||||||
|
job.value = { completed: 0, current: { downloadedPages: 2 } };
|
||||||
|
await service.checkStalls(500000);
|
||||||
|
service.state.paused = true;
|
||||||
|
await service.checkStalls(1000000);
|
||||||
|
assert.equal(item.stopReason, undefined);
|
||||||
|
// Paused time does not count: the clock restarts on resume.
|
||||||
|
service.state.paused = false;
|
||||||
|
await service.checkStalls(1000050);
|
||||||
|
assert.equal(item.stopReason, undefined);
|
||||||
|
await service.checkStalls(1190000);
|
||||||
|
assert.equal(item.stopReason, 'recovering');
|
||||||
|
assert.equal(item.stallRecoveries, 1);
|
||||||
|
assert.equal(service.state.incidents[0].failure.kind, 'stall');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second stall quarantines the job', async () => {
|
||||||
|
const service = stallService({ value: { completed: 2, total: 5 } }), item = service.state.queue[0];
|
||||||
|
item.stallRecoveries = 1;
|
||||||
|
await service.checkStalls(0); await service.checkStalls(1000000);
|
||||||
|
assert.equal(item.stopReason, 'quarantined');
|
||||||
|
assert.equal(item.failure.kind, 'stall');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing checkpoint does not trigger a false stall recovery', async () => {
|
||||||
|
const service = stallService({ value: null });
|
||||||
|
for (const now of [0, 1000000, 2000000]) await service.checkStalls(now);
|
||||||
assert.equal(service.state.queue[0].stopReason, undefined);
|
assert.equal(service.state.queue[0].stopReason, undefined);
|
||||||
|
assert.equal(service.state.incidents.length, 0);
|
||||||
});
|
});
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
'use strict';
|
|
||||||
const fs = require('fs'), path = require('path'), http = require('http');
|
|
||||||
const { secret } = require('../runtime/config');
|
|
||||||
const { Monitor } = require('./monitor');
|
|
||||||
const root = path.resolve(process.env.SENTINEL_DATA || path.join(process.env.DATA || 'data', 'sentinel'));
|
|
||||||
fs.mkdirSync(root, { recursive: true });
|
|
||||||
const token = secret('SCRAPER_TOKEN');
|
|
||||||
const host = process.env.SENTINEL_HOST || '127.0.0.1', port = +(process.env.SENTINEL_PORT || 8092);
|
|
||||||
if (!['127.0.0.1', 'localhost', '::1'].includes(host) && !token) throw new Error('SCRAPER_TOKEN is required for non-loopback Sentinel');
|
|
||||||
const reader = process.env.READER_URL || 'http://127.0.0.1:8080';
|
|
||||||
const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091';
|
|
||||||
const file = path.join(root, 'state.json');
|
|
||||||
let saved = {};
|
|
||||||
if (fs.existsSync(file)) saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
const monitor = new Monitor({ incidents: saved.incidents || [] });
|
|
||||||
const request = async (base, endpoint, body) => {
|
|
||||||
const response = await fetch(base + endpoint, { headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
|
|
||||||
signal: AbortSignal.timeout(8000), ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
|
|
||||||
if (!response.ok) throw new Error(`Service HTTP ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
let stopped = false, timer, lastTick = Date.now();
|
|
||||||
async function tick() {
|
|
||||||
try {
|
|
||||||
const results = await Promise.allSettled([request(reader, '/health'), request(scraper, '/api/state')]);
|
|
||||||
const state = await monitor.observe(results[0].status === 'fulfilled' ? results[0].value : null,
|
|
||||||
results[1].status === 'fulfilled' ? results[1].value : null, input => request(scraper, '/api/recovery', input));
|
|
||||||
fs.writeFileSync(file + '.tmp', JSON.stringify(state, null, 2) + '\n'); fs.renameSync(file + '.tmp', file);
|
|
||||||
lastTick = Date.now();
|
|
||||||
for (const incident of state.incidents.filter(i => i.state === 'open' && !i.logged)) {
|
|
||||||
console.log(JSON.stringify({ ...incident, targetService: incident.service, service: 'sentinel', level: 'warning' })); incident.logged = true;
|
|
||||||
}
|
|
||||||
} catch (error) { console.error('[sentinel]', error.message); }
|
|
||||||
if (!stopped) timer = setTimeout(tick, 30000);
|
|
||||||
}
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
const endpoint = new URL(req.url, 'http://localhost').pathname;
|
|
||||||
let code = 200, value;
|
|
||||||
if (endpoint === '/health') { code = Date.now() - lastTick < 120000 ? 200 : 503; value = { ok: code === 200, service: 'sentinel', checkedAt: monitor.state.checkedAt }; }
|
|
||||||
else if (token && req.headers.authorization !== `Bearer ${token}`) { code = 401; value = { error: 'Unauthorized' }; }
|
|
||||||
else if (endpoint === '/api/state' && req.method === 'GET') value = monitor.state;
|
|
||||||
else if (endpoint === '/metrics' && req.method === 'GET') {
|
|
||||||
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4' });
|
|
||||||
return res.end(`mangacircus_reader_up ${Number(!!monitor.state.services.reader)}\nmangacircus_scraper_up ${Number(!!monitor.state.services.scraper)}\nmangacircus_open_incidents ${monitor.incidents.filter(i => i.state === 'open').length}\n`);
|
|
||||||
} else { code = 404; value = { error: 'Not found' }; }
|
|
||||||
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
|
|
||||||
});
|
|
||||||
server.listen(port, host, () => console.log(JSON.stringify({ service: 'sentinel', event: 'started', port })));
|
|
||||||
tick();
|
|
||||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { stopped = true; clearTimeout(timer); server.close(() => process.exit(0)); });
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
const { progressKey } = require('../scraper/lib/failures');
|
|
||||||
|
|
||||||
// No source requests, filesystem checkpoint edits, shell execution or Docker socket.
|
|
||||||
class Monitor {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.now = options.now || Date.now;
|
|
||||||
this.stallMs = options.stallMs || 900000;
|
|
||||||
this.observations = new Map();
|
|
||||||
this.incidents = options.incidents || [];
|
|
||||||
this.state = { status: 'starting', services: {}, incidents: this.incidents };
|
|
||||||
}
|
|
||||||
incident(key, message, extra = {}) {
|
|
||||||
let incident = this.incidents.find(i => i.key === key && i.state === 'open');
|
|
||||||
if (!incident) {
|
|
||||||
incident = { key, message, state: 'open', at: new Date(this.now()).toISOString(), ...extra };
|
|
||||||
this.incidents.push(incident);
|
|
||||||
if (this.incidents.length > 200) this.incidents.splice(0, this.incidents.length - 200);
|
|
||||||
}
|
|
||||||
return incident;
|
|
||||||
}
|
|
||||||
resolve(key) {
|
|
||||||
for (const i of this.incidents) if (i.key === key && i.state === 'open') { i.state = 'resolved'; i.resolvedAt = new Date(this.now()).toISOString(); }
|
|
||||||
}
|
|
||||||
async observe(reader, scraper, recover) {
|
|
||||||
const now = this.now();
|
|
||||||
this.state.checkedAt = new Date(now).toISOString();
|
|
||||||
this.state.services = { reader: !!reader?.ok, scraper: !!scraper };
|
|
||||||
if (reader?.ready === false) this.incident('reader:scan', 'Reader is alive but its library scan failed; inspect storage and scan diagnostics', { service: 'reader' });
|
|
||||||
else if (reader?.ok) this.resolve('reader:scan');
|
|
||||||
for (const [service, healthy] of Object.entries(this.state.services)) {
|
|
||||||
if (healthy) this.resolve(`service:${service}`);
|
|
||||||
else this.incident(`service:${service}`, `${service} is unreachable or unhealthy; inspect its supervisor`, { service });
|
|
||||||
}
|
|
||||||
if (!scraper) { this.state.status = 'degraded'; return this.state; }
|
|
||||||
const ids = new Set();
|
|
||||||
for (const entry of scraper.active || []) {
|
|
||||||
const id = entry.current.id; ids.add(id);
|
|
||||||
// Without a checkpoint, a null progress key stays constant forever and
|
|
||||||
// cannot prove a stall. Do not quarantine a live worker on that basis.
|
|
||||||
if (!entry.job) { this.observations.delete(id); this.resolve(`stall:${id}`); continue; }
|
|
||||||
const key = progressKey(entry.job), prior = this.observations.get(id);
|
|
||||||
if (!prior || prior.key !== key) { this.observations.set(id, { key, at: now }); this.resolve(`stall:${id}`); continue; }
|
|
||||||
if (scraper.paused || scraper.sourcePaused?.[entry.source]) continue;
|
|
||||||
const allowance = Math.max(this.stallMs, Number(entry.job?.rate?.chapterMaxMs || 0) + 180000);
|
|
||||||
if (now - prior.at < allowance) continue;
|
|
||||||
const incident = this.incident(`stall:${id}`, 'Job progress has stopped; recovery is limited to one checkpoint-safe attempt', { jobId: id, source: entry.source, title: entry.current.title });
|
|
||||||
if (!incident.attempted && recover) {
|
|
||||||
// Persist attempted in the daemon via stopReason/stallRecoveries; monitor records audit too.
|
|
||||||
incident.attempted = true;
|
|
||||||
try { incident.recovery = await recover({ id, progressKey: key }); }
|
|
||||||
catch { incident.recovery = { changed: false, error: 'Recovery API unavailable' }; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const id of this.observations.keys()) if (!ids.has(id)) { this.observations.delete(id); this.resolve(`stall:${id}`); }
|
|
||||||
const failures = (scraper.queue || []).filter(item => ['error', 'quarantined'].includes(item.status));
|
|
||||||
for (const item of failures) this.incident(`job:${item.id}`, item.failure?.message || item.error || 'Job needs attention', { jobId: item.id, source: item.source, kind: item.failure?.kind || 'unknown' });
|
|
||||||
for (const i of this.incidents) if (i.key.startsWith('job:') && !failures.some(item => i.key === `job:${item.id}`)) this.resolve(i.key);
|
|
||||||
this.state.sourcePaused = scraper.sourcePaused || {};
|
|
||||||
this.state.active = (scraper.active || []).map(e => ({ source: e.source, id: e.current.id, title: e.current.title, completed: e.job?.completed, total: e.job?.total, updated: e.job?.updated }));
|
|
||||||
this.state.status = this.incidents.some(i => i.state === 'open') ? 'attention' : scraper.paused ? 'paused' : 'healthy';
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = { Monitor };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
const test = require('node:test'), assert = require('node:assert/strict');
|
|
||||||
const { Monitor } = require('../monitor');
|
|
||||||
test('Sentinel does not disturb progressing or deliberately paused jobs', async () => {
|
|
||||||
let now = 0, recoveries = 0;
|
|
||||||
const monitor = new Monitor({ now: () => now, stallMs: 100 });
|
|
||||||
const state = { paused: false, active: [{ source: 'a', current: { id: 'job', title: 'Manga' }, job: { completed: 0, current: { downloadedPages: 1 } } }], queue: [] };
|
|
||||||
const recover = async () => { recoveries++; return { changed: true }; };
|
|
||||||
await monitor.observe({ ok: true }, state, recover);
|
|
||||||
now = 500000; state.active[0].job.current.downloadedPages = 2;
|
|
||||||
await monitor.observe({ ok: true }, state, recover);
|
|
||||||
now = 1000000; state.paused = true;
|
|
||||||
await monitor.observe({ ok: true }, state, recover);
|
|
||||||
assert.equal(recoveries, 0);
|
|
||||||
state.paused = false; await monitor.observe({ ok: true }, state, recover); await monitor.observe({ ok: true }, state, recover);
|
|
||||||
assert.equal(recoveries, 1);
|
|
||||||
state.active[0].job.completed = 1; await monitor.observe({ ok: true }, state, recover);
|
|
||||||
assert.equal(monitor.incidents.find(i => i.key === 'stall:job').state, 'resolved');
|
|
||||||
});
|
|
||||||
test('Sentinel reports service failures without restart loops and resolves recovery', async () => {
|
|
||||||
const monitor = new Monitor();
|
|
||||||
await monitor.observe(null, null); await monitor.observe(null, null);
|
|
||||||
assert.equal(monitor.incidents.length, 2);
|
|
||||||
await monitor.observe({ ok: true }, { active: [], queue: [], paused: false });
|
|
||||||
assert.equal(monitor.state.status, 'healthy'); assert.ok(monitor.incidents.every(i => i.state === 'resolved'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing checkpoint does not trigger a false stall recovery', async () => {
|
|
||||||
let now = 0, recoveries = 0;
|
|
||||||
const monitor = new Monitor({ now: () => now, stallMs: 100 });
|
|
||||||
const state = { paused: false, active: [{ source: 'mangapill', current: { id: 'job', title: 'Manga' }, job: null }], queue: [] };
|
|
||||||
for (const advance of [0, 1000000, 2000000]) {
|
|
||||||
now = advance;
|
|
||||||
await monitor.observe({ ok: true }, state, async () => { recoveries++; return { changed: true }; });
|
|
||||||
}
|
|
||||||
assert.equal(recoveries, 0);
|
|
||||||
assert.equal(monitor.incidents.some(item => item.key === 'stall:job'), false);
|
|
||||||
});
|
|
||||||
@@ -39,7 +39,6 @@ const FULL_SCAN_MINUTES = +(process.env.FULL_SCAN_INTERVAL || 1440);
|
|||||||
const SECURE_COOKIE = process.env.COOKIE_SECURE === '1';
|
const SECURE_COOKIE = process.env.COOKIE_SECURE === '1';
|
||||||
const SCRAPER_URL = String(process.env.SCRAPER_URL || 'http://127.0.0.1:8091').replace(/\/$/, '');
|
const SCRAPER_URL = String(process.env.SCRAPER_URL || 'http://127.0.0.1:8091').replace(/\/$/, '');
|
||||||
const SCRAPER_TOKEN = secret('SCRAPER_TOKEN');
|
const SCRAPER_TOKEN = secret('SCRAPER_TOKEN');
|
||||||
const SENTINEL_URL = String(process.env.SENTINEL_URL || 'http://127.0.0.1:8092').replace(/\/$/, '');
|
|
||||||
const PEER_DATA = path.resolve(process.env.PEER_DATA || path.join(DATA, 'peering'));
|
const PEER_DATA = path.resolve(process.env.PEER_DATA || path.join(DATA, 'peering'));
|
||||||
const peerStore = new PeeringState(PEER_DATA);
|
const peerStore = new PeeringState(PEER_DATA);
|
||||||
const discoveryStore = new DiscoveryStore(DATA);
|
const discoveryStore = new DiscoveryStore(DATA);
|
||||||
@@ -381,9 +380,7 @@ const routes = {
|
|||||||
},
|
},
|
||||||
'GET /api/admin/health': async ({ u }) => {
|
'GET /api/admin/health': async ({ u }) => {
|
||||||
admin(u);
|
admin(u);
|
||||||
const sentinel = await fetch(SENTINEL_URL + '/api/state', { headers: SCRAPER_TOKEN ? { authorization: `Bearer ${SCRAPER_TOKEN}` } : {}, signal: AbortSignal.timeout(4000) })
|
return { reader: { ok: !scanState.error, uptime: Math.round(process.uptime()), scan: scanState }, backup: backup.publicState() };
|
||||||
.then(async r => { if (!r.ok) throw new Error(); return r.json(); }).catch(() => ({ status: 'unreachable' }));
|
|
||||||
return { reader: { ok: !scanState.error, uptime: Math.round(process.uptime()), scan: scanState }, sentinel, backup: backup.publicState() };
|
|
||||||
},
|
},
|
||||||
'GET /api/admin/backup': ({ u }) => { admin(u); return backup.publicState(); },
|
'GET /api/admin/backup': ({ u }) => { admin(u); return backup.publicState(); },
|
||||||
'POST /api/admin/backup': ({ u, b }) => {
|
'POST /api/admin/backup': ({ u, b }) => {
|
||||||
|
|||||||
@@ -24,23 +24,22 @@ test('three services integrate collections, auth, custom covers and private heal
|
|||||||
fs.writeFileSync(path.join(library, name, 'cover.png'), png);
|
fs.writeFileSync(path.join(library, name, 'cover.png'), png);
|
||||||
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, tags: ['Fantasy'], status: 'ongoing' }));
|
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, tags: ['Fantasy'], status: 'ongoing' }));
|
||||||
}
|
}
|
||||||
const [rp, sp, wp] = await Promise.all([port(), port(), port()]);
|
const [rp, sp] = await Promise.all([port(), port()]);
|
||||||
const base = `http://127.0.0.1:${rp}`, scraper = `http://127.0.0.1:${sp}`, sentinel = `http://127.0.0.1:${wp}`;
|
const base = `http://127.0.0.1:${rp}`, scraper = `http://127.0.0.1:${sp}`;
|
||||||
const token = 'integration-test-only';
|
const token = 'integration-test-only';
|
||||||
const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'), SENTINEL_DATA: path.join(data, 'sentinel'),
|
const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'),
|
||||||
PORT: String(rp), SCRAPER_PORT: String(sp), SENTINEL_PORT: String(wp), SCRAPER_HOST: '127.0.0.1', SENTINEL_HOST: '127.0.0.1',
|
PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1',
|
||||||
READER_URL: base, SCRAPER_URL: scraper, SENTINEL_URL: sentinel, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0' };
|
READER_URL: base, SCRAPER_URL: scraper, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0' };
|
||||||
for (const script of ['server.js', 'scraper/daemon.js', 'sentinel/daemon.js']) {
|
for (const script of ['server.js', 'scraper/daemon.js']) {
|
||||||
const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child);
|
const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child);
|
||||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString()));
|
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString()));
|
||||||
}
|
}
|
||||||
for (const url of [base, scraper, sentinel]) {
|
for (const url of [base, scraper]) {
|
||||||
let ready = false;
|
let ready = false;
|
||||||
for (let n = 0; n < 80 && !ready; n++) { ready = await fetch(url + '/health').then(r => r.ok).catch(() => false); if (!ready) await delay(100); }
|
for (let n = 0; n < 80 && !ready; n++) { ready = await fetch(url + '/health').then(r => r.ok).catch(() => false); if (!ready) await delay(100); }
|
||||||
assert.ok(ready, output.join(''));
|
assert.ok(ready, output.join(''));
|
||||||
}
|
}
|
||||||
assert.equal((await fetch(scraper + '/api/state')).status, 401);
|
assert.equal((await fetch(scraper + '/api/state')).status, 401);
|
||||||
assert.equal((await fetch(sentinel + '/api/state')).status, 401);
|
|
||||||
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'tester', password: 'local-test-password' }) });
|
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'tester', password: 'local-test-password' }) });
|
||||||
assert.equal(signup.status, 200); const cookie = signup.headers.get('set-cookie').split(';')[0];
|
assert.equal(signup.status, 200); const cookie = signup.headers.get('set-cookie').split(';')[0];
|
||||||
const request = (endpoint, body) => fetch(base + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
|
const request = (endpoint, body) => fetch(base + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
|
||||||
@@ -121,7 +120,7 @@ test('three services integrate collections, auth, custom covers and private heal
|
|||||||
const cover = await (await request('/api/admin/collection-cover', { id: 'demo', revision, base64: png.toString('base64') })).json(); revision = cover.revision;
|
const cover = await (await request('/api/admin/collection-cover', { id: 'demo', revision, base64: png.toString('base64') })).json(); revision = cover.revision;
|
||||||
const image = await request('/api/collection-cover?id=demo'); assert.equal(image.status, 200); assert.deepEqual(Buffer.from(await image.arrayBuffer()), png);
|
const image = await request('/api/collection-cover?id=demo'); assert.equal(image.status, 200); assert.deepEqual(Buffer.from(await image.arrayBuffer()), png);
|
||||||
}
|
}
|
||||||
const health = await (await request('/api/admin/health')).json(); assert.ok(health.reader); assert.ok(health.sentinel);
|
const health = await (await request('/api/admin/health')).json(); assert.ok(health.reader); assert.equal(health.sentinel, undefined);
|
||||||
assert.equal((await request('/api/admin/scraper')).status, 200);
|
assert.equal((await request('/api/admin/scraper')).status, 200);
|
||||||
// Exercise the actual authenticated visit route and cross-chapter lease races.
|
// Exercise the actual authenticated visit route and cross-chapter lease races.
|
||||||
assert.equal((await request('/api/settings', { readingClock: true })).status, 200);
|
assert.equal((await request('/api/settings', { readingClock: true })).status, 200);
|
||||||
|
|||||||
+3
-3
@@ -11,9 +11,9 @@ for (const [index, name] of ['Moon Garden', 'Moon Garden Before the Rain', 'Iron
|
|||||||
fs.writeFileSync(path.join(folder, '001.png'), png); fs.writeFileSync(path.join(library, name, 'cover.png'), png);
|
fs.writeFileSync(path.join(folder, '001.png'), png); fs.writeFileSync(path.join(library, name, 'cover.png'), png);
|
||||||
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
|
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
|
||||||
}
|
}
|
||||||
const env = { ...process.env, DATA: data, LIBRARY: library, PORT: '8180', SCRAPER_PORT: '8191', SENTINEL_PORT: '8192',
|
const env = { ...process.env, DATA: data, LIBRARY: library, PORT: '8180', SCRAPER_PORT: '8191',
|
||||||
READER_URL: 'http://127.0.0.1:8180', SCRAPER_URL: 'http://127.0.0.1:8191', SENTINEL_URL: 'http://127.0.0.1:8192',
|
READER_URL: 'http://127.0.0.1:8180', SCRAPER_URL: 'http://127.0.0.1:8191',
|
||||||
SCRAPER_DATA: path.join(data, 'scraper'), SENTINEL_DATA: path.join(data, 'sentinel'), COOKIE_SECURE: '0', SCAN_INTERVAL: '0', SCRAPER_TOKEN: 'fixture', SCRAPER_TOKEN_FILE: '' };
|
SCRAPER_DATA: path.join(data, 'scraper'), COOKIE_SECURE: '0', SCAN_INTERVAL: '0', SCRAPER_TOKEN: 'fixture', SCRAPER_TOKEN_FILE: '' };
|
||||||
const child = spawn(process.execPath, ['tools/start-all.js'], { cwd: path.resolve(__dirname, '..'), env, stdio: 'inherit', windowsHide: true });
|
const child = spawn(process.execPath, ['tools/start-all.js'], { cwd: path.resolve(__dirname, '..'), env, stdio: 'inherit', windowsHide: true });
|
||||||
console.log('Isolated UI fixture at http://127.0.0.1:8180 · first account is test admin');
|
console.log('Isolated UI fixture at http://127.0.0.1:8180 · first account is test admin');
|
||||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill('SIGTERM'));
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill('SIGTERM'));
|
||||||
|
|||||||
+1
-2
@@ -4,9 +4,8 @@ const { secret } = require('../runtime/config');
|
|||||||
const token = secret('SCRAPER_TOKEN');
|
const token = secret('SCRAPER_TOKEN');
|
||||||
const root = process.env.READER_URL || 'http://127.0.0.1:8080';
|
const root = process.env.READER_URL || 'http://127.0.0.1:8080';
|
||||||
const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091';
|
const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091';
|
||||||
const sentinel = process.env.SENTINEL_URL || 'http://127.0.0.1:8092';
|
|
||||||
(async () => {
|
(async () => {
|
||||||
for (const [name, base, endpoint] of [['reader', root, '/health'], ['scraper', scraper, '/api/state'], ['sentinel', sentinel, '/api/state']]) {
|
for (const [name, base, endpoint] of [['reader', root, '/health'], ['scraper', scraper, '/api/state']]) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(base + endpoint, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10000) });
|
const response = await fetch(base + endpoint, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10000) });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const ports = { reader: process.env.PORT || 8080, scraper: process.env.SCRAPER_PORT || 8091, sentinel: process.env.SENTINEL_PORT || 8092 };
|
const ports = { reader: process.env.PORT || 8080, scraper: process.env.SCRAPER_PORT || 8091 };
|
||||||
const service = process.argv[2] || 'reader';
|
const service = process.argv[2] || 'reader';
|
||||||
fetch(`http://127.0.0.1:${ports[service]}/health`, { signal: AbortSignal.timeout(4000) })
|
fetch(`http://127.0.0.1:${ports[service]}/health`, { signal: AbortSignal.timeout(4000) })
|
||||||
.then(async r => { const body = await r.json(); if (!r.ok || !body.ok) process.exitCode = 1; }).catch(() => { process.exitCode = 1; });
|
.then(async r => { const body = await r.json(); if (!r.ok || !body.ok) process.exitCode = 1; }).catch(() => { process.exitCode = 1; });
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
for (const relative of ['data', 'data/scraper', 'data/sentinel', 'library', 'backup', '.secrets']) fs.mkdirSync(path.join(root, relative), { recursive: true });
|
for (const relative of ['data', 'data/scraper','library', 'backup', '.secrets']) fs.mkdirSync(path.join(root, relative), { recursive: true });
|
||||||
const file = path.join(root, '.secrets', 'scraper_token');
|
const file = path.join(root, '.secrets', 'scraper_token');
|
||||||
if (!fs.existsSync(file)) fs.writeFileSync(file, crypto.randomBytes(32).toString('hex') + '\n', { flag: 'wx', mode: 0o600 });
|
if (!fs.existsSync(file)) fs.writeFileSync(file, crypto.randomBytes(32).toString('hex') + '\n', { flag: 'wx', mode: 0o600 });
|
||||||
if (!fs.existsSync(path.join(root, '.env'))) fs.copyFileSync(path.join(root, '.env.example'), path.join(root, '.env'));
|
if (!fs.existsSync(path.join(root, '.env'))) fs.copyFileSync(path.join(root, '.env.example'), path.join(root, '.env'));
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ function copy(source, relative) {
|
|||||||
}
|
}
|
||||||
for (const name of ['db.json', 'scan-cache.json', 'catalog.json', 'collection-covers', 'runtime-reader.json']) copy(path.join(data, name), path.join('data', name));
|
for (const name of ['db.json', 'scan-cache.json', 'catalog.json', 'collection-covers', 'runtime-reader.json']) copy(path.join(data, name), path.join('data', name));
|
||||||
for (const name of ['queue.json', 'runtime.json', 'daemon.lock', 'supervisor.lock']) copy(path.join(data, 'scraper', name), path.join('data', 'scraper', name));
|
for (const name of ['queue.json', 'runtime.json', 'daemon.lock', 'supervisor.lock']) copy(path.join(data, 'scraper', name), path.join('data', 'scraper', name));
|
||||||
for (const name of ['state.json', 'runtime.json']) copy(path.join(data, 'sentinel', name), path.join('data', 'sentinel', name));
|
|
||||||
for (const name of fs.readdirSync(library)) {
|
for (const name of fs.readdirSync(library)) {
|
||||||
copy(path.join(library, name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'), path.join('library', name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'));
|
copy(path.join(library, name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'), path.join('library', name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ if (fs.existsSync(lock)) {
|
|||||||
catch (e) { if (e.code !== 'ESRCH') throw e; fs.unlinkSync(lock); }
|
catch (e) { if (e.code !== 'ESRCH') throw e; fs.unlinkSync(lock); }
|
||||||
}
|
}
|
||||||
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
||||||
const supervisors = ['reader', 'scraper', 'sentinel'].map(name => new Supervisor(name, { root }));
|
const supervisors = ['reader', 'scraper'].map(name => new Supervisor(name, { root }));
|
||||||
supervisors.forEach(service => service.start());
|
supervisors.forEach(service => service.start());
|
||||||
const keepalive = setInterval(() => {}, 60000);
|
const keepalive = setInterval(() => {}, 60000);
|
||||||
let closing = false;
|
let closing = false;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const project = 'mc-qa-' + crypto.randomBytes(3).toString('hex'), port = process
|
|||||||
const library = path.join(dir, 'library'), data = path.join(dir, 'data'), backupDir = path.join(dir, 'backup'), tokenFile = path.join(dir, 'token');
|
const library = path.join(dir, 'library'), data = path.join(dir, 'data'), backupDir = path.join(dir, 'backup'), tokenFile = path.join(dir, 'token');
|
||||||
const directionQa = process.argv.includes('--direction-ui');
|
const directionQa = process.argv.includes('--direction-ui');
|
||||||
const peerGatewayQa = process.argv.includes('--peer-gateway');
|
const peerGatewayQa = process.argv.includes('--peer-gateway');
|
||||||
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(path.join(data, 'sentinel')); fs.mkdirSync(backupDir);
|
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(backupDir);
|
||||||
if (peerGatewayQa) {
|
if (peerGatewayQa) {
|
||||||
const peerData = path.join(data, 'peering');
|
const peerData = path.join(data, 'peering');
|
||||||
const peerUsage = path.join(data, 'peering-usage');
|
const peerUsage = path.join(data, 'peering-usage');
|
||||||
@@ -47,7 +47,7 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
|
|||||||
...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}),
|
...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}),
|
||||||
status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
|
status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
|
||||||
}
|
}
|
||||||
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), SENTINEL_DATA_PATH: path.join(data, 'sentinel'),
|
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'),
|
||||||
SCRAPER_TOKEN_PATH: tokenFile, BACKUP_PATH: backupDir, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
SCRAPER_TOKEN_PATH: tokenFile, BACKUP_PATH: backupDir, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
||||||
READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
||||||
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
|
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
|
||||||
@@ -201,12 +201,12 @@ let keep = false;
|
|||||||
}
|
}
|
||||||
const settingsJs = await (await fetch(url + '/settings.js')).text();
|
const settingsJs = await (await fetch(url + '/settings.js')).text();
|
||||||
assert.ok(!settingsJs.includes('Start it with: npm run start:scraper'));
|
assert.ok(!settingsJs.includes('Start it with: npm run start:scraper'));
|
||||||
await until(async () => { const h = await (await api('/api/admin/health')).json(); return h.sentinel.services?.reader && h.sentinel.services?.scraper; }, 'Internal Sentinel networking failed');
|
await until(async () => (await (await api('/api/admin/scraper')).json()).available, 'Internal scraper networking failed');
|
||||||
const mounts = JSON.parse(await docker('config', '--format', 'json'));
|
const mounts = JSON.parse(await docker('config', '--format', 'json'));
|
||||||
assert.ok(mounts.services.reader.volumes.find(v => v.target === '/library').read_only);
|
assert.ok(mounts.services.reader.volumes.find(v => v.target === '/library').read_only);
|
||||||
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel.ports?.length);
|
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel);
|
||||||
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "Promise.all(['http://scraper:8091/api/state','http://sentinel:8092/api/state'].map(u=>fetch(u).then(r=>r.status))).then(v=>console.log(JSON.stringify(v)))"));
|
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "fetch('http://scraper:8091/api/state').then(r=>console.log(r.status))"));
|
||||||
assert.deepEqual(denied, [401, 401]);
|
assert.equal(denied, 401);
|
||||||
// Deliberate fixture-only child crash: its supervisor must recover just the reader.
|
// Deliberate fixture-only child crash: its supervisor must recover just the reader.
|
||||||
const before = JSON.parse(fs.readFileSync(path.join(data, 'runtime-reader.json'))).pid;
|
const before = JSON.parse(fs.readFileSync(path.join(data, 'runtime-reader.json'))).pid;
|
||||||
await docker('exec', '-T', 'reader', 'node', '-e', "const fs=require('fs');process.kill(JSON.parse(fs.readFileSync('/data/runtime-reader.json')).pid,'SIGKILL')");
|
await docker('exec', '-T', 'reader', 'node', '-e', "const fs=require('fs');process.kill(JSON.parse(fs.readFileSync('/data/runtime-reader.json')).pid,'SIGKILL')");
|
||||||
@@ -247,11 +247,11 @@ let keep = false;
|
|||||||
assert.equal(login.status, 200);
|
assert.equal(login.status, 200);
|
||||||
await require('./test-direction-ui')(url, login.headers.get('set-cookie').split(';')[0]);
|
await require('./test-direction-ui')(url, login.headers.get('set-cookie').split(';')[0]);
|
||||||
}
|
}
|
||||||
console.log('PASS: isolated Compose auth, collections, read-only reader, private APIs, Sentinel networking and child crash recovery');
|
console.log('PASS: isolated Compose auth, collections, read-only reader, private APIs, scraper networking and child crash recovery');
|
||||||
keep = process.argv.includes('--keep');
|
keep = process.argv.includes('--keep');
|
||||||
if (keep) {
|
if (keep) {
|
||||||
const envFile = path.join(dir, 'qa.env');
|
const envFile = path.join(dir, 'qa.env');
|
||||||
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'BACKUP_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
|
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'BACKUP_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
|
||||||
...(readingGateQa ? ['READING_WINDOWS_JSON', 'READING_TIMEZONE', 'READING_QUIZ_UNLOCK_MINUTES', 'READING_QUIZ_CATALOG_PATH'] : [])]
|
...(readingGateQa ? ['READING_WINDOWS_JSON', 'READING_TIMEZONE', 'READING_QUIZ_UNLOCK_MINUTES', 'READING_QUIZ_CATALOG_PATH'] : [])]
|
||||||
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[k]}'` : env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
|
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[k]}'` : env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
|
||||||
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
|
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ async function until(check, message, attempts = 120) {
|
|||||||
}
|
}
|
||||||
async function setup(label, readerPort, peerPort) {
|
async function setup(label, readerPort, peerPort) {
|
||||||
const root = path.join(TEST_ROOT, label), library = path.join(root, 'library'), data = path.join(root, 'data');
|
const root = path.join(TEST_ROOT, label), library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||||
const scraper = path.join(data, 'scraper'), sentinel = path.join(data, 'sentinel'), peerData = path.join(data, 'peering');
|
const scraper = path.join(data, 'scraper'), peerData = path.join(data, 'peering');
|
||||||
const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token');
|
const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token');
|
||||||
for (const dir of [library, scraper, sentinel, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
|
for (const dir of [library, scraper, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
|
||||||
await fsp.writeFile(path.join(scraper, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
|
await fsp.writeFile(path.join(scraper, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
|
||||||
await fsp.writeFile(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
|
await fsp.writeFile(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
|
||||||
const png = new Canvas(180, 270, [55, 105, 150]).png();
|
const png = new Canvas(180, 270, [55, 105, 150]).png();
|
||||||
@@ -71,7 +71,7 @@ async function setup(label, readerPort, peerPort) {
|
|||||||
}
|
}
|
||||||
new PeeringState(peerData).generateIdentity(label === 'a' ? 'PeerA' : 'PeerB');
|
new PeeringState(peerData).generateIdentity(label === 'a' ? 'PeerA' : 'PeerB');
|
||||||
const env = { ...process.env, APP_IMAGE, VCS_REF: 'peer-redesign-test', LIBRARY_PATH: library, DATA_PATH: data,
|
const env = { ...process.env, APP_IMAGE, VCS_REF: 'peer-redesign-test', LIBRARY_PATH: library, DATA_PATH: data,
|
||||||
SCRAPER_DATA_PATH: scraper, SENTINEL_DATA_PATH: sentinel, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
|
SCRAPER_DATA_PATH: scraper, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
|
||||||
SCRAPER_TOKEN_PATH: token, READER_PORT: String(readerPort), READER_BIND: '127.0.0.1',
|
SCRAPER_TOKEN_PATH: token, READER_PORT: String(readerPort), READER_BIND: '127.0.0.1',
|
||||||
PEER_PORT: String(peerPort), PEER_BIND: '127.0.0.1', COOKIE_SECURE: '0', TZ: 'UTC' };
|
PEER_PORT: String(peerPort), PEER_BIND: '127.0.0.1', COOKIE_SECURE: '0', TZ: 'UTC' };
|
||||||
return { label, root, library, data, peerData, manga, readerPort, peerPort, env, url: `http://127.0.0.1:${readerPort}` };
|
return { label, root, library, data, peerData, manga, readerPort, peerPort, env, url: `http://127.0.0.1:${readerPort}` };
|
||||||
@@ -244,7 +244,7 @@ async function main() {
|
|||||||
assert.deepEqual(peerQueueItems[0].workIdentity, catalog.data.items[0].workIdentity,
|
assert.deepEqual(peerQueueItems[0].workIdentity, catalog.data.items[0].workIdentity,
|
||||||
'The resolved work identity is persisted on the queued peer transfer');
|
'The resolved work identity is persisted on the queued peer transfer');
|
||||||
const volumes = await Promise.all(started.map(({ project, env }) => docker(project, env, 'ps', '--format', '{{.Service}}')));
|
const volumes = await Promise.all(started.map(({ project, env }) => docker(project, env, 'ps', '--format', '{{.Service}}')));
|
||||||
for (const services of volumes) for (const name of ['reader', 'scraper', 'sentinel', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
|
for (const services of volumes) for (const name of ['reader', 'scraper', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
|
||||||
console.log('PASS: isolated Compose A↔B pairing, catalog-only access, cover proxy, grant update, browser details/bulk queue, one paused queue writer, and admin-only API/UI boundary.');
|
console.log('PASS: isolated Compose A↔B pairing, catalog-only access, cover proxy, grant update, browser details/bulk queue, one paused queue writer, and admin-only API/UI boundary.');
|
||||||
console.log(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
|
console.log(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
|
||||||
succeeded = true;
|
succeeded = true;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ This is a discovery and curation feature, not a new image scraper, metadata auth
|
|||||||
|
|
||||||
Current MangaCircus constraints remain: plain Node and browser JavaScript, Docker Compose, a folder-tree library, JSON state rather than a database, one reader owner of account state, one scraper owner of the queue and library publication, and source-specific slow request pacing. See [`docs/architecture.md`](../docs/architecture.md), [`docs/greatest.md`](../docs/greatest.md), and [`docs/scraper-service.md`](../docs/scraper-service.md).
|
Current MangaCircus constraints remain: plain Node and browser JavaScript, Docker Compose, a folder-tree library, JSON state rather than a database, one reader owner of account state, one scraper owner of the queue and library publication, and source-specific slow request pacing. See [`docs/architecture.md`](../docs/architecture.md), [`docs/greatest.md`](../docs/greatest.md), and [`docs/scraper-service.md`](../docs/scraper-service.md).
|
||||||
|
|
||||||
MVP lives in the **reader service** as a bounded asynchronous job and a reader-owned JSON store. Do not add a permanently running fourth service at first. If real LLM or metadata latency later makes reader isolation necessary, move the job behind the same API to a one-off/optional worker container using the existing image; do not change the data contract or add a database merely to do that. Sentinel observes availability but does not judge recommendations, call an LLM, or repair research claims.
|
MVP lives in the **reader service** as a bounded asynchronous job and a reader-owned JSON store. Do not add a permanently running third service at first. If real LLM or metadata latency later makes reader isolation necessary, move the job behind the same API to a one-off/optional worker container using the existing image; do not change the data contract or add a database merely to do that. Service health reports availability but does not judge recommendations, call an LLM, or repair research claims.
|
||||||
|
|
||||||
Explicit non-goals for the first release:
|
Explicit non-goals for the first release:
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ Queueing remains through `POST /api/admin/scraper` with `command:"enqueue"` and
|
|||||||
- One in-process run lock plus a persisted run ID prevents overlap across browser clicks and restarts. A stale interrupted run is marked interrupted on startup; keep the previous successful feed. Publish only after validation completes.
|
- One in-process run lock plus a persisted run ID prevents overlap across browser clicks and restarts. A stale interrupted run is marked interrupted on startup; keep the previous successful feed. Publish only after validation completes.
|
||||||
- Preserve source errors and diagnostics with bounded log size. Do not store sensitive prompts or external response bodies indefinitely. Provider blocks/challenges require user attention, not automated retries or evasions.
|
- Preserve source errors and diagnostics with bounded log size. Do not store sensitive prompts or external response bodies indefinitely. Provider blocks/challenges require user attention, not automated retries or evasions.
|
||||||
- Nightly scheduling comes after the manual path has passed acceptance. Make local-time schedule configurable, persist last-success time, skip duplicate same-day runs after restart, and allow a manual run without disturbing scraper nightly updates. A missed night can run once after startup, never a catch-up storm.
|
- Nightly scheduling comes after the manual path has passed acceptance. Make local-time schedule configurable, persist last-success time, skip duplicate same-day runs after restart, and allow a manual run without disturbing scraper nightly updates. A missed night can run once after startup, never a catch-up storm.
|
||||||
- The feature is optional: reader, library, Greatest, scraper, and Sentinel continue normally if metadata or LLM access is absent.
|
- The feature is optional: reader, library, Greatest, and scraper continue normally if metadata or LLM access is absent.
|
||||||
|
|
||||||
## 8. Phased implementation and acceptance
|
## 8. Phased implementation and acceptance
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ Add one configurable model adapter, schema validation, evidence-bound output, op
|
|||||||
|
|
||||||
### Phase D — scheduling and operations
|
### Phase D — scheduling and operations
|
||||||
|
|
||||||
Add nightly schedule only after manual runs are stable. Expose last success/error and a `discovery_run_failed` incident to admin observability, but do not give Sentinel authority to rewrite recommendations or restart the scraper. Add backup/restore instructions for `data/discovery.json` and cache behavior. If runs measurably harm reader latency, move the job into an optional one-off Compose worker with one-writer handoff, not an always-on autonomous service.
|
Add nightly schedule only after manual runs are stable. Expose last success/error and a `discovery_run_failed` incident to admin observability, but do not give any watchdog authority to rewrite recommendations or restart the scraper. Add backup/restore instructions for `data/discovery.json` and cache behavior. If runs measurably harm reader latency, move the job into an optional one-off Compose worker with one-writer handoff, not an always-on autonomous service.
|
||||||
|
|
||||||
### Suggested implementation map
|
### Suggested implementation map
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Suggested NoDB files (names are design targets, not existing files): reader-owne
|
|||||||
3. **Read-only publisher:** optional gateway, bounded approved-only catalog/manifest/blob endpoints, quota enforcement, read-only mounts. Test that unshared, partially downloaded, deleted, and revoked content cannot be listed or fetched—even by guessed IDs or stale manifests. Verify no user data fields or raw source URLs appear on the wire.
|
3. **Read-only publisher:** optional gateway, bounded approved-only catalog/manifest/blob endpoints, quota enforcement, read-only mounts. Test that unshared, partially downloaded, deleted, and revoked content cannot be listed or fetched—even by guessed IDs or stale manifests. Verify no user data fields or raw source URLs appear on the wire.
|
||||||
4. **Consumer browsing:** local-server proxy and reader UI with peer status, search, details, selection preview, and queue permission checks. No browser-to-peer credentials or direct requests.
|
4. **Consumer browsing:** local-server proxy and reader UI with peer status, search, details, selection preview, and queue permission checks. No browser-to-peer credentials or direct requests.
|
||||||
5. **Scraper transfer lane:** chapter/page checkpoints, range resume, hashes, local throttles, source-aware deduplication, and existing atomic chapter publication. Test corruption, interrupted transfer, stale revision, out-of-space, repeated queue submission, and preservation of current jobs.
|
5. **Scraper transfer lane:** chapter/page checkpoints, range resume, hashes, local throttles, source-aware deduplication, and existing atomic chapter publication. Test corruption, interrupted transfer, stale revision, out-of-space, repeated queue submission, and preservation of current jobs.
|
||||||
6. **Observability and operations:** per-peer health, bytes, throttling, grant expiry warnings, queue stalls, and safe retry controls. Sentinel may report a dead gateway or stuck transfer but must not renew trust, broaden a share, or bypass a quota. Document backup/restore and key-rotation runbooks.
|
6. **Observability and operations:** per-peer health, bytes, throttling, grant expiry warnings, queue stalls, and safe retry controls. Service health may report a dead gateway or stuck transfer but must not renew trust, broaden a share, or bypass a quota. Document backup/restore and key-rotation runbooks.
|
||||||
7. **Local then VM pilot:** three local Compose instances with distinct data directories; pair A↔B and A↔C, exercise every direction combination, and transfer a few admin-approved test chapters. Verify disabling B does not affect C, no account/progress exchange, and quota isolation. Only then deploy the optional service on the VM's private network. Leave existing reader/scraper ports and active downloads undisturbed during the pilot.
|
7. **Local then VM pilot:** three local Compose instances with distinct data directories; pair A↔B and A↔C, exercise every direction combination, and transfer a few admin-approved test chapters. Verify disabling B does not affect C, no account/progress exchange, and quota isolation. Only then deploy the optional service on the VM's private network. Leave existing reader/scraper ports and active downloads undisturbed during the pilot.
|
||||||
|
|
||||||
**Ship gate:** a fresh unpaired server sees nothing; a paired server with serving off or no granted manga sees an empty catalog; an expired/revoked peer cannot list or fetch; invitations cannot be reused; disabling one direction or peer leaves unrelated peers intact; quotas survive restart; a receiver can resume and publish one chapter without duplicates; and its browser never receives the grant token or any private key. The feature remains disabled by default until those gates pass.
|
**Ship gate:** a fresh unpaired server sees nothing; a paired server with serving off or no granted manga sees an empty catalog; an expired/revoked peer cannot list or fetch; invitations cannot be reused; disabling one direction or peer leaves unrelated peers intact; quotas survive restart; a receiver can resume and publish one chapter without duplicates; and its browser never receives the grant token or any private key. The feature remains disabled by default until those gates pass.
|
||||||
|
|||||||
Reference in new issue
Block a user