fix: harden automation reconciliation and pause lifecycle
Keep full folder scans recoverable under duplicate protection and classify every discovered file once before automatic admission. Separate manual previews from automatic capacity limits, serialize renderer intake, normalize automation counters and intervals, and make Main authoritative for runtime timestamps. Enforce persistent pause across startup, import, close recovery, resume failures, and reconciliation while preserving read-only paused scans and exactly one activation reconciliation. Restore failed asynchronous rotation chunks, complete automation localization and unlimited queue accessibility, and tighten hidden integration cleanup coverage.
This commit is contained in:
@@ -25,13 +25,22 @@
|
||||
return Math.max(1, Math.floor(number));
|
||||
}
|
||||
|
||||
function normalizeReconcileInterval(value) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && allowedIntervals.has(value) ? value : 5;
|
||||
}
|
||||
|
||||
function normalizeCounter(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return 0;
|
||||
return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(number));
|
||||
}
|
||||
|
||||
function normalizeAutomationSettings(value = {}) {
|
||||
const settings = asObject(value);
|
||||
const queueLimitJobs = normalizeQueueLimit(settings.queueLimitJobs);
|
||||
const rawInterval = Number(settings.reconcileIntervalMinutes);
|
||||
return {
|
||||
queueLimitJobs,
|
||||
reconcileIntervalMinutes: allowedIntervals.has(rawInterval) ? rawInterval : 5,
|
||||
reconcileIntervalMinutes: normalizeReconcileInterval(settings.reconcileIntervalMinutes),
|
||||
paused: settings.paused === true,
|
||||
pausedAt: settings.paused === true && Number.isFinite(Number(settings.pausedAt)) ? Number(settings.pausedAt) : null
|
||||
};
|
||||
@@ -100,10 +109,10 @@
|
||||
...emptyTelemetry(dateKey),
|
||||
...telemetry,
|
||||
dateKey,
|
||||
detected: Math.max(0, Number(telemetry.detected) || 0),
|
||||
queued: Math.max(0, Number(telemetry.queued) || 0),
|
||||
skipped: Math.max(0, Number(telemetry.skipped) || 0),
|
||||
deferred: Math.max(0, Number(telemetry.deferred) || 0)
|
||||
detected: normalizeCounter(telemetry.detected),
|
||||
queued: normalizeCounter(telemetry.queued),
|
||||
skipped: normalizeCounter(telemetry.skipped),
|
||||
deferred: normalizeCounter(telemetry.deferred)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +120,7 @@
|
||||
const changes = asObject(delta);
|
||||
const next = rollDailyTelemetry(value, nowMs);
|
||||
for (const key of ['detected', 'queued', 'skipped', 'deferred']) {
|
||||
next[key] += Math.max(0, Number(changes[key]) || 0);
|
||||
next[key] = Math.min(Number.MAX_SAFE_INTEGER, next[key] + normalizeCounter(changes[key]));
|
||||
}
|
||||
if (changes.lastDetectedName) {
|
||||
next.lastDetectedName = String(changes.lastDetectedName);
|
||||
|
||||
+42
-9
@@ -3,6 +3,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
const { walkFolderAsync } = require('./file-discovery');
|
||||
const { normalizeAutomationSettings } = require('./automation-control');
|
||||
|
||||
class FolderMonitor extends EventEmitter {
|
||||
constructor({
|
||||
@@ -44,6 +45,9 @@ class FolderMonitor extends EventEmitter {
|
||||
this._lastScanAt = null;
|
||||
this._lastScanTrigger = '';
|
||||
this._lastError = '';
|
||||
this._startedAt = null;
|
||||
this._nextReconcileAt = null;
|
||||
this._reconcileIntervalMs = 5 * 60 * 1000;
|
||||
this._generation = 0;
|
||||
}
|
||||
|
||||
@@ -55,6 +59,24 @@ class FolderMonitor extends EventEmitter {
|
||||
return this._start(settings, false);
|
||||
}
|
||||
|
||||
configure(settings) {
|
||||
const folderPath = String(settings?.folderPath || '').trim();
|
||||
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
|
||||
const reconcileIntervalMinutes = normalizeAutomationSettings(settings).reconcileIntervalMinutes;
|
||||
const watcher = this._invalidateLifecycle(true);
|
||||
if (watcher) {
|
||||
try {
|
||||
Promise.resolve(watcher.close()).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
this._seenFiles = new Set();
|
||||
this._settings = { ...settings, folderPath, reconcileIntervalMinutes };
|
||||
this._reachable = null;
|
||||
this._lastError = '';
|
||||
this._emitStatus(this._generation);
|
||||
return { includesExisting: false, paused: true };
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._deactivate({ clearSeen: true, paused: false });
|
||||
}
|
||||
@@ -67,7 +89,9 @@ class FolderMonitor extends EventEmitter {
|
||||
scanning: this._scanning,
|
||||
folderPath: this._settings ? this._settings.folderPath : '',
|
||||
seenCount: this._seenFiles.size,
|
||||
startedAt: this._startedAt,
|
||||
lastScanAt: this._lastScanAt,
|
||||
nextReconcileAt: this._nextReconcileAt,
|
||||
lastScanTrigger: this._lastScanTrigger,
|
||||
error: this._lastError
|
||||
});
|
||||
@@ -106,19 +130,22 @@ class FolderMonitor extends EventEmitter {
|
||||
if (changed) this._emitStatus(this._generation);
|
||||
}
|
||||
|
||||
async resume(settings = this._settings) {
|
||||
async resume(settings = this._settings, options = {}) {
|
||||
if (!settings) throw new Error('Keine Ordnerkonfiguration vorhanden');
|
||||
this._start(settings, true);
|
||||
if (options.reconcile === false) return { reconciled: false };
|
||||
return this.scan({ emitFiles: true, trigger: 'resume' });
|
||||
}
|
||||
|
||||
_start(settings, preserveSeen) {
|
||||
this._deactivate({ clearSeen: !preserveSeen, paused: false });
|
||||
this._settings = settings;
|
||||
const reconcileIntervalMinutes = normalizeAutomationSettings(settings).reconcileIntervalMinutes;
|
||||
this._settings = { ...settings, reconcileIntervalMinutes };
|
||||
this._paused = false;
|
||||
this._reachable = null;
|
||||
this._lastError = '';
|
||||
|
||||
settings = this._settings;
|
||||
const folderPath = String(settings.folderPath || '').trim();
|
||||
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
|
||||
|
||||
@@ -158,10 +185,12 @@ class FolderMonitor extends EventEmitter {
|
||||
this._emitStatus(generation);
|
||||
this._emitEvent('error', [error], generation);
|
||||
});
|
||||
const intervalMinutes = Number(settings.reconcileIntervalMinutes) || 5;
|
||||
this._reconcileIntervalMs = reconcileIntervalMinutes * 60 * 1000;
|
||||
this._startedAt = this._now();
|
||||
this._nextReconcileAt = this._startedAt + this._reconcileIntervalMs;
|
||||
this._reconcileTimer = this._setInterval(
|
||||
() => this._reconcile(generation).catch((error) => this._publishBackgroundError(error, generation)),
|
||||
intervalMinutes * 60 * 1000
|
||||
this._reconcileIntervalMs
|
||||
);
|
||||
this._emitStatus(generation);
|
||||
return { includesExisting: includeInitial };
|
||||
@@ -194,6 +223,8 @@ class FolderMonitor extends EventEmitter {
|
||||
}
|
||||
this._batchSeenReservations.clear();
|
||||
this._paused = paused;
|
||||
this._startedAt = null;
|
||||
this._nextReconcileAt = null;
|
||||
this._scanning = false;
|
||||
this._followUpRequested = false;
|
||||
this._followUpOptions = null;
|
||||
@@ -280,9 +311,8 @@ class FolderMonitor extends EventEmitter {
|
||||
try {
|
||||
const files = await this._discoverFiles(settings, generation);
|
||||
if (!this._isCurrent(generation)) return this._cancelledResult(trigger);
|
||||
const emittedFiles = files.filter((file) => this._acceptPath(file.path));
|
||||
if (emittedFiles.length > 0) {
|
||||
const listenerError = this._emitEvent('new-files', [emittedFiles.map((file) => file.path)], generation);
|
||||
if (files.length > 0) {
|
||||
const listenerError = this._emitEvent('new-files', [files], generation);
|
||||
if (listenerError) return this._finishProductiveError(listenerError, generation, trigger, true);
|
||||
}
|
||||
if (!this._isCurrent(generation)) return this._cancelledResult(trigger);
|
||||
@@ -325,7 +355,7 @@ class FolderMonitor extends EventEmitter {
|
||||
for (const descriptor of discovered) {
|
||||
if (!this._isCurrent(generation)) return [];
|
||||
if (!settings.recursive && this._isNestedPath(descriptor.path, settings.folderPath)) continue;
|
||||
if (!this._classifyPath(descriptor.path, settings).allowed) continue;
|
||||
const classification = this._classifyPath(descriptor.path, settings);
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0;
|
||||
@@ -335,7 +365,9 @@ class FolderMonitor extends EventEmitter {
|
||||
path: descriptor.path,
|
||||
name: descriptor.name || path.basename(descriptor.path),
|
||||
size: Number(descriptor.size) || 0,
|
||||
mtimeMs
|
||||
mtimeMs,
|
||||
filterMatched: classification.allowed,
|
||||
filterReason: classification.reason
|
||||
}));
|
||||
}
|
||||
return files;
|
||||
@@ -353,6 +385,7 @@ class FolderMonitor extends EventEmitter {
|
||||
|
||||
async _reconcile(generation) {
|
||||
if (!this._acceptCallback(generation)) return;
|
||||
this._nextReconcileAt = this._now() + this._reconcileIntervalMs;
|
||||
const trigger = this._reachable === false ? 'reconnect' : 'interval';
|
||||
await this.scan({ emitFiles: true, trigger });
|
||||
}
|
||||
|
||||
+54
-1
@@ -105,8 +105,61 @@ function createUploadAuditWriter(options) {
|
||||
return createInternalLogWriter({ ...options, fileName: 'upload-audit.log' });
|
||||
}
|
||||
|
||||
function createBufferedInternalLogFlusher(options) {
|
||||
const source = options && typeof options === 'object' ? options : {};
|
||||
const buffer = source.buffer;
|
||||
const writer = source.writer;
|
||||
const schedule = typeof source.schedule === 'function' ? source.schedule : setImmediate;
|
||||
const reportError = typeof source.reportError === 'function' ? source.reportError : () => {};
|
||||
let writing = false;
|
||||
|
||||
if (!Array.isArray(buffer) || !writer || typeof writer.append !== 'function' || typeof writer.flushSync !== 'function') {
|
||||
throw new TypeError('createBufferedInternalLogFlusher requires buffer and writer');
|
||||
}
|
||||
|
||||
function restoreChunk(chunk) {
|
||||
for (let end = chunk.length; end > 0;) {
|
||||
const start = Math.max(0, end - 1024);
|
||||
buffer.splice(0, 0, ...chunk.slice(start, end));
|
||||
end = start;
|
||||
}
|
||||
}
|
||||
|
||||
async function flush(label) {
|
||||
if (writing || buffer.length === 0) return null;
|
||||
const chunk = buffer.splice(0);
|
||||
writing = true;
|
||||
let written = false;
|
||||
try {
|
||||
written = await writer.append(chunk.join(''), label);
|
||||
} catch (error) {
|
||||
reportError(label, error);
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
if (!written) {
|
||||
restoreChunk(chunk);
|
||||
return false;
|
||||
}
|
||||
if (buffer.length > 0) {
|
||||
try {
|
||||
schedule(() => { void flush(label); });
|
||||
} catch (error) {
|
||||
reportError(label, error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
flush,
|
||||
flushSync: label => writer.flushSync(buffer, label),
|
||||
isWriting: () => writing
|
||||
};
|
||||
}
|
||||
|
||||
function getLogOpenDirectory(targetPath, fallbackDirectory, pathApi = nodePath) {
|
||||
return typeof targetPath === 'string' && targetPath ? pathApi.dirname(targetPath) : fallbackDirectory;
|
||||
}
|
||||
|
||||
module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory };
|
||||
module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, createBufferedInternalLogFlusher, getLogOpenDirectory };
|
||||
|
||||
Reference in New Issue
Block a user