fix: preserve deferred automation state

Persist detection and deferral telemetry when file-atomic admission cannot fit any candidate.

Derive queue-limited status from the remaining capacity and the next configured destination group while preserving unlimited and disabled states.

Verify that a restored manual preview remains byte-identical through the real resume UI and IPC path, and align the public Active description with the runtime contract.
This commit is contained in:
Sucukdeluxe
2026-08-26 14:21:09 +02:00
parent 362d1dd2cb
commit 4b3ad68c07
3 changed files with 103 additions and 11 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ Folder monitoring watches for new files while the application is running. Config
7. Select the destination hosts and decide whether matching jobs start automatically.
8. Enable monitoring and save the settings.
The status card summarizes the complete automation state. **Inactive** means monitoring is disabled or has no usable path. **Active** means the watcher and reconciliation are running. **Paused** means the persistent manual pause is in effect. **Queue limit reached** means matching files are being deferred until capacity becomes available. **Folder disconnected** means the configured folder is currently missing or unreadable and will be checked again. **Error** reports another monitoring failure. The card also shows reachability, current queue use, today's counters, the latest detected file, reconciliation times, and the latest error when one exists.
The status card summarizes the complete automation state. **Inactive** means monitoring is disabled or has no usable path. **Active** means monitoring is enabled and configured and no higher-priority paused, disconnected, error, or queue-limit state currently applies. **Paused** means the persistent manual pause is in effect. **Queue limit reached** means matching files are being deferred until capacity becomes available. **Folder disconnected** means the configured folder is currently missing or unreadable and will be checked again. **Error** reports another monitoring failure. The card also shows reachability, current queue use, today's counters, the latest detected file, reconciliation times, and the latest error when one exists.
**Test folder monitoring** performs a read-only full scan with the current folder, filter, subfolder, destination, size-limit, processed-file, and queue-limit rules. It reports aggregate counts without changing the queue, selected files, telemetry, history, logs, source files, settings, or the one-time existing-file option.
+28 -9
View File
@@ -522,6 +522,14 @@ function createAutomationStatusSnapshot() {
const normalized = window.AutomationControl.normalizeAutomationSettings(folderSettings);
const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
const availableSlots = normalized.queueLimitJobs === 0 ? null : Math.max(0, normalized.queueLimitJobs - currentJobCount);
const configuredTargetCount = new Set((Array.isArray(folderSettings.hosters) ? folderSettings.hosters : [])
.map(value => String(value || '').trim())
.filter(Boolean)).size;
const queueLimited = folderSettings.enabled === true
&& String(folderSettings.folderPath || '').trim().length > 0
&& normalized.queueLimitJobs !== 0
&& configuredTargetCount > 0
&& availableSlots < configuredTargetCount;
const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry);
const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || '');
const startedAt = automationTimestamp(automationRuntimeStatus.startedAt) || automationRuntimeStartedAt;
@@ -538,7 +546,7 @@ function createAutomationStatusSnapshot() {
queueLimitJobs: normalized.queueLimitJobs,
currentJobCount,
availableSlots,
queueLimited: normalized.queueLimitJobs !== 0 && availableSlots === 0,
queueLimited,
telemetry,
error,
startedAt,
@@ -740,8 +748,25 @@ async function applyAutomationEvaluation(evaluation) {
const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath));
const admittedFiles = candidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path)));
const deferredFiles = candidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path)));
const telemetryDelta = {
detected: evaluation.summary.found,
queued: admittedFiles.length,
skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable,
deferred: deferredFiles.length,
lastDetectedName: admittedFiles.at(-1)?.name || ''
};
if (admittedFiles.length === 0) {
return freezeAutomationValue({ admittedFiles: [], deferredFiles, paused, dryRun: false });
const telemetryResult = await persistAutomationTelemetry(telemetryDelta);
return freezeAutomationValue({
ok: telemetryResult.warning === '',
error: null,
warning: telemetryResult.warning || null,
admittedFiles: [],
deferredFiles,
paused,
dryRun: false,
plannedJobs: 0
});
}
const newJobs = admittedFiles.flatMap(file => file.eligibleHosters.map(hoster => createAutomationPreviewJob(file, hoster)));
queueJobs.push(...newJobs);
@@ -793,13 +818,7 @@ async function applyAutomationEvaluation(evaluation) {
return freezeAutomationValue({ ok: false, error: result.error, warning: null, admittedFiles: [], deferredFiles, paused: /pausiert/i.test(result.error), dryRun: false });
}
}
const telemetryResult = await persistAutomationTelemetry({
detected: evaluation.summary.found,
queued: admittedFiles.length,
skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable,
deferred: deferredFiles.length,
lastDetectedName: admittedFiles.at(-1)?.name || ''
});
const telemetryResult = await persistAutomationTelemetry(telemetryDelta);
return freezeAutomationValue({
ok: telemetryResult.warning === '',
error: null,
+74 -1
View File
@@ -846,6 +846,49 @@ contextBridge.exposeInMainWorld('api', {
queueLimited: statusSnapshot.queueLimited,
frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry)
};
configureAtomicState(14998);
config.globalSettings.folderMonitor.autoStart = true;
config.globalSettings.folderMonitor.telemetry = {
dateKey: new Date().toLocaleDateString('en-CA'),
detected: 0,
queued: 0,
skipped: 0,
deferred: 0
};
window.api.configureAutomationProbe({ paused: false, runtimeStatus: { running: true, reachable: true, folderPath: 'C:\\watch' } });
const zeroAdmissionFile = { path: 'C:\\watch\\four-targets.mkv', name: 'four-targets.mkv', size: 1024 * 1024, mtimeMs: 1 };
const zeroAdmissionEvaluation = await evaluateAutomationCandidates([zeroAdmissionFile], { dryRun: false, trigger: 'watcher' });
const zeroAdmissionResult = await applyAutomationEvaluation(zeroAdmissionEvaluation);
const zeroAdmissionProbe = await window.api.getAutomationProbeState();
const limitedSnapshot = createAutomationStatusSnapshot();
config.globalSettings.folderMonitor.queueLimitJobs = 0;
const unlimitedSnapshot = createAutomationStatusSnapshot();
config.globalSettings.folderMonitor.queueLimitJobs = 15000;
config.globalSettings.folderMonitor.enabled = false;
const disabledSnapshot = createAutomationStatusSnapshot();
config.globalSettings.folderMonitor.enabled = true;
const zeroAdmission = {
evaluatedAdmitted: zeroAdmissionEvaluation.admittedFiles.map(file => file.name),
evaluatedDeferred: zeroAdmissionEvaluation.deferredFiles.map(file => file.name),
appliedAdmitted: zeroAdmissionResult.admittedFiles.map(file => file.name),
appliedDeferred: zeroAdmissionResult.deferredFiles.map(file => file.name),
telemetry: {
detected: config.globalSettings.folderMonitor.telemetry.detected,
queued: config.globalSettings.folderMonitor.telemetry.queued,
skipped: config.globalSettings.folderMonitor.telemetry.skipped,
deferred: config.globalSettings.folderMonitor.telemetry.deferred
},
telemetrySaves: zeroAdmissionProbe.mutationCalls.filter(call => call[0] === 'settings').length,
mainAdmissions: zeroAdmissionProbe.mutationCalls.filter(call => call[0] === 'start' || call[0] === 'inject').length,
status: {
state: limitedSnapshot.state,
currentJobCount: limitedSnapshot.currentJobCount,
availableSlots: limitedSnapshot.availableSlots,
queueLimited: limitedSnapshot.queueLimited
},
unlimited: { availableSlots: unlimitedSnapshot.availableSlots, queueLimited: unlimitedSnapshot.queueLimited },
disabled: { state: disabledSnapshot.state, queueLimited: disabledSnapshot.queueLimited }
};
const stressStartedAt = performance.now();
const stressQueue = Array.from({ length: 14996 }, (_, index) => ({
id: 'stress-queue-' + index,
@@ -1759,7 +1802,7 @@ contextBridge.exposeInMainWorld('api', {
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length
};
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
})()`;
const automationControlCenterScript = `(async () => {
const waitFor = async predicate => {
@@ -1884,6 +1927,13 @@ contextBridge.exposeInMainWorld('api', {
{ id: 'ui-preview', file: 'C:\\\\ui-preview.mkv', fileName: 'ui-preview.mkv', hoster: 'doodstream.com', status: 'preview', bytesTotal: 1 },
{ id: 'ui-error', file: 'C:\\\\ui-error.mkv', fileName: 'ui-error.mkv', hoster: 'doodstream.com', status: 'error', bytesTotal: 1 }
];
config.globalSettings.pendingQueue = buildPersistedQueueState();
queueJobs = [];
selectedFiles = [];
selectedUploadHosters = [];
rebuildJobIndex();
restoreQueueStateFromConfig();
const restoredPreviewBeforeResume = JSON.stringify(queueJobs.find(job => job.id === 'ui-preview'));
uploadSidebarFilter = 'all';
queueSearchQuery = '';
queueHosterFilter = '';
@@ -1914,6 +1964,7 @@ contextBridge.exposeInMainWorld('api', {
pauseButton?.click();
await new Promise(resolve => setTimeout(resolve, 0));
const afterResumeProbe = await window.api.getAutomationProbeState();
const restoredPreviewAfterResume = queueJobs.find(job => job.id === 'ui-preview');
const resumedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
selectedJobIds.clear();
selectedJobIds.add('ui-preview');
@@ -1932,6 +1983,11 @@ contextBridge.exposeInMainWorld('api', {
calls: afterPauseProbe.mutationCalls.filter(call => call[0] === 'resume' || call[0] === 'pause').map(call => call[0]),
resumedLabel,
startDisabledAfterResume,
restoredPreviewPresent: Boolean(restoredPreviewAfterResume),
restoredPreviewStatus: restoredPreviewAfterResume?.status || null,
restoredPreviewByteIdentical: JSON.stringify(restoredPreviewAfterResume) === restoredPreviewBeforeResume,
resumeStartCalls: afterResumeProbe.mutationCalls.filter(call => call[0] === 'start').length,
resumeAddCalls: afterResumeProbe.mutationCalls.filter(call => call[0] === 'inject').length,
pausedLabel,
pausedLabelEnglish,
configPaused: config.globalSettings.folderMonitor.paused
@@ -2527,6 +2583,18 @@ app.whenReady().then(async () => {
queueLimited: true,
frozen: true
});
assert.deepEqual(result.automationPipeline.zeroAdmission, {
evaluatedAdmitted: [],
evaluatedDeferred: ['four-targets.mkv'],
appliedAdmitted: [],
appliedDeferred: ['four-targets.mkv'],
telemetry: { detected: 1, queued: 0, skipped: 0, deferred: 1 },
telemetrySaves: 1,
mainAdmissions: 0,
status: { state: 'queue-limited', currentJobCount: 14998, availableSlots: 2, queueLimited: true },
unlimited: { availableSlots: null, queueLimited: false },
disabled: { state: 'inactive', queueLimited: false }
});
assert.equal(result.automationPipeline.stress.candidateCount, 15000);
assert.equal(result.automationPipeline.stress.currentJobCount, 14996);
assert.equal(result.automationPipeline.stress.plannedJobs, 4);
@@ -2884,6 +2952,11 @@ app.whenReady().then(async () => {
reuploadSelectedBtn: false,
retryFailedBtn: false
},
restoredPreviewPresent: true,
restoredPreviewStatus: 'preview',
restoredPreviewByteIdentical: true,
resumeStartCalls: 0,
resumeAddCalls: 0,
pausedLabel: 'Fortsetzen',
pausedLabelEnglish: 'Resume',
configPaused: true