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:
@@ -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.
|
7. Select the destination hosts and decide whether matching jobs start automatically.
|
||||||
8. Enable monitoring and save the settings.
|
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.
|
**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
@@ -522,6 +522,14 @@ function createAutomationStatusSnapshot() {
|
|||||||
const normalized = window.AutomationControl.normalizeAutomationSettings(folderSettings);
|
const normalized = window.AutomationControl.normalizeAutomationSettings(folderSettings);
|
||||||
const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
|
const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
|
||||||
const availableSlots = normalized.queueLimitJobs === 0 ? null : Math.max(0, normalized.queueLimitJobs - currentJobCount);
|
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 telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry);
|
||||||
const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || '');
|
const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || '');
|
||||||
const startedAt = automationTimestamp(automationRuntimeStatus.startedAt) || automationRuntimeStartedAt;
|
const startedAt = automationTimestamp(automationRuntimeStatus.startedAt) || automationRuntimeStartedAt;
|
||||||
@@ -538,7 +546,7 @@ function createAutomationStatusSnapshot() {
|
|||||||
queueLimitJobs: normalized.queueLimitJobs,
|
queueLimitJobs: normalized.queueLimitJobs,
|
||||||
currentJobCount,
|
currentJobCount,
|
||||||
availableSlots,
|
availableSlots,
|
||||||
queueLimited: normalized.queueLimitJobs !== 0 && availableSlots === 0,
|
queueLimited,
|
||||||
telemetry,
|
telemetry,
|
||||||
error,
|
error,
|
||||||
startedAt,
|
startedAt,
|
||||||
@@ -740,8 +748,25 @@ async function applyAutomationEvaluation(evaluation) {
|
|||||||
const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath));
|
const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath));
|
||||||
const admittedFiles = candidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path)));
|
const admittedFiles = candidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path)));
|
||||||
const deferredFiles = candidates.filter(candidate => deferredPaths.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) {
|
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)));
|
const newJobs = admittedFiles.flatMap(file => file.eligibleHosters.map(hoster => createAutomationPreviewJob(file, hoster)));
|
||||||
queueJobs.push(...newJobs);
|
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 });
|
return freezeAutomationValue({ ok: false, error: result.error, warning: null, admittedFiles: [], deferredFiles, paused: /pausiert/i.test(result.error), dryRun: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const telemetryResult = await persistAutomationTelemetry({
|
const telemetryResult = await persistAutomationTelemetry(telemetryDelta);
|
||||||
detected: evaluation.summary.found,
|
|
||||||
queued: admittedFiles.length,
|
|
||||||
skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable,
|
|
||||||
deferred: deferredFiles.length,
|
|
||||||
lastDetectedName: admittedFiles.at(-1)?.name || ''
|
|
||||||
});
|
|
||||||
return freezeAutomationValue({
|
return freezeAutomationValue({
|
||||||
ok: telemetryResult.warning === '',
|
ok: telemetryResult.warning === '',
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -846,6 +846,49 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
queueLimited: statusSnapshot.queueLimited,
|
queueLimited: statusSnapshot.queueLimited,
|
||||||
frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry)
|
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 stressStartedAt = performance.now();
|
||||||
const stressQueue = Array.from({ length: 14996 }, (_, index) => ({
|
const stressQueue = Array.from({ length: 14996 }, (_, index) => ({
|
||||||
id: 'stress-queue-' + index,
|
id: 'stress-queue-' + index,
|
||||||
@@ -1759,7 +1802,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
||||||
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').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 automationControlCenterScript = `(async () => {
|
||||||
const waitFor = async predicate => {
|
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-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 }
|
{ 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';
|
uploadSidebarFilter = 'all';
|
||||||
queueSearchQuery = '';
|
queueSearchQuery = '';
|
||||||
queueHosterFilter = '';
|
queueHosterFilter = '';
|
||||||
@@ -1914,6 +1964,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
pauseButton?.click();
|
pauseButton?.click();
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
const afterResumeProbe = await window.api.getAutomationProbeState();
|
const afterResumeProbe = await window.api.getAutomationProbeState();
|
||||||
|
const restoredPreviewAfterResume = queueJobs.find(job => job.id === 'ui-preview');
|
||||||
const resumedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
|
const resumedLabel = document.getElementById('automationPauseResumeBtn')?.textContent.trim() || null;
|
||||||
selectedJobIds.clear();
|
selectedJobIds.clear();
|
||||||
selectedJobIds.add('ui-preview');
|
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]),
|
calls: afterPauseProbe.mutationCalls.filter(call => call[0] === 'resume' || call[0] === 'pause').map(call => call[0]),
|
||||||
resumedLabel,
|
resumedLabel,
|
||||||
startDisabledAfterResume,
|
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,
|
pausedLabel,
|
||||||
pausedLabelEnglish,
|
pausedLabelEnglish,
|
||||||
configPaused: config.globalSettings.folderMonitor.paused
|
configPaused: config.globalSettings.folderMonitor.paused
|
||||||
@@ -2527,6 +2583,18 @@ app.whenReady().then(async () => {
|
|||||||
queueLimited: true,
|
queueLimited: true,
|
||||||
frozen: 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.candidateCount, 15000);
|
||||||
assert.equal(result.automationPipeline.stress.currentJobCount, 14996);
|
assert.equal(result.automationPipeline.stress.currentJobCount, 14996);
|
||||||
assert.equal(result.automationPipeline.stress.plannedJobs, 4);
|
assert.equal(result.automationPipeline.stress.plannedJobs, 4);
|
||||||
@@ -2884,6 +2952,11 @@ app.whenReady().then(async () => {
|
|||||||
reuploadSelectedBtn: false,
|
reuploadSelectedBtn: false,
|
||||||
retryFailedBtn: false
|
retryFailedBtn: false
|
||||||
},
|
},
|
||||||
|
restoredPreviewPresent: true,
|
||||||
|
restoredPreviewStatus: 'preview',
|
||||||
|
restoredPreviewByteIdentical: true,
|
||||||
|
resumeStartCalls: 0,
|
||||||
|
resumeAddCalls: 0,
|
||||||
pausedLabel: 'Fortsetzen',
|
pausedLabel: 'Fortsetzen',
|
||||||
pausedLabelEnglish: 'Resume',
|
pausedLabelEnglish: 'Resume',
|
||||||
configPaused: true
|
configPaused: true
|
||||||
|
|||||||
Reference in New Issue
Block a user