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:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-22 22:58:03 +02:00
1 parent 88555d4780
commit b2bb34174c
37 files changed
+163 -311

No files matched your search

+1 -2
View File
@@ -4,9 +4,8 @@ const { secret } = require('../runtime/config');
const token = secret('SCRAPER_TOKEN');
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 sentinel = process.env.SENTINEL_URL || 'http://127.0.0.1:8092';
(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 {
const response = await fetch(base + endpoint, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
+1 -1
View File
@@ -1,5 +1,5 @@
'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';
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; });
+1 -1
View File
@@ -2,7 +2,7 @@
'use strict';
const fs = require('fs'), path = require('path'), crypto = require('crypto');
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');
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'));
-1
View File
@@ -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 ['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)) {
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
View File
@@ -12,7 +12,7 @@ if (fs.existsSync(lock)) {
catch (e) { if (e.code !== 'ESRCH') throw e; fs.unlinkSync(lock); }
}
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());
const keepalive = setInterval(() => {}, 60000);
let closing = false;
+8 -8
View File
@@ -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 directionQa = process.argv.includes('--direction-ui');
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) {
const peerData = path.join(data, 'peering');
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' } } : {}),
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',
READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
...(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();
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'));
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);
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)))"));
assert.deepEqual(denied, [401, 401]);
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel);
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "fetch('http://scraper:8091/api/state').then(r=>console.log(r.status))"));
assert.equal(denied, 401);
// 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;
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);
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');
if (keep) {
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'] : [])]
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[k]}'` : env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
+4 -4
View File
@@ -50,9 +50,9 @@ async function until(check, message, attempts = 120) {
}
async function setup(label, readerPort, peerPort) {
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');
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(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
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');
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',
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}` };
@@ -244,7 +244,7 @@ async function main() {
assert.deepEqual(peerQueueItems[0].workIdentity, catalog.data.items[0].workIdentity,
'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}}')));
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(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
succeeded = true;