From 9bc872bc009adca2dd6a9768a5d549a635806c8f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:51:26 +0200 Subject: [PATCH] feat: add automation control policies --- lib/automation-control.js | 178 ++++++++++++++++++ renderer/index.html | 1 + scripts/verify-public-release.mjs | 2 + tests/automation-control.test.js | 259 ++++++++++++++++++++++++++ tests/public-release-verifier.test.js | 2 +- 5 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 lib/automation-control.js create mode 100644 tests/automation-control.test.js diff --git a/lib/automation-control.js b/lib/automation-control.js new file mode 100644 index 0000000..f3e67ab --- /dev/null +++ b/lib/automation-control.js @@ -0,0 +1,178 @@ +(function initAutomationControl(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.AutomationControl = api; +})(typeof window !== 'undefined' ? window : globalThis, function createAutomationControl() { + const capacityStatuses = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']); + const allowedIntervals = new Set([1, 5, 15, 30, 60]); + + function asObject(value) { + return value && typeof value === 'object' ? value : {}; + } + + function asArray(value) { + return Array.isArray(value) ? value : []; + } + + function normalizeAutomationSettings(value = {}) { + const settings = asObject(value); + const rawLimit = Number(settings.queueLimitJobs); + const queueLimitJobs = Number.isFinite(rawLimit) && rawLimit >= 0 ? Math.floor(rawLimit) : 15000; + const rawInterval = Number(settings.reconcileIntervalMinutes); + return { + queueLimitJobs, + reconcileIntervalMinutes: allowedIntervals.has(rawInterval) ? rawInterval : 5, + paused: settings.paused === true, + pausedAt: settings.paused === true && Number.isFinite(Number(settings.pausedAt)) ? Number(settings.pausedAt) : null + }; + } + + function countAutomaticQueueJobs(queueJobs) { + return asArray(queueJobs).reduce((count, job) => count + (capacityStatuses.has(job?.status) ? 1 : 0), 0); + } + + function planAtomicAdmissions(input = {}) { + const value = asObject(input); + const ordered = [...asArray(value.candidates)].sort((left, right) => + (Number(left?.mtimeMs) || 0) - (Number(right?.mtimeMs) || 0) + || String(left?.path).localeCompare(String(right?.path)) + ); + const rawCurrentJobCount = Number(value.currentJobCount); + const currentJobCount = Number.isFinite(rawCurrentJobCount) ? Math.max(0, Math.floor(rawCurrentJobCount)) : 0; + const rawQueueLimitJobs = value.queueLimitJobs === undefined ? 15000 : Number(value.queueLimitJobs); + const queueLimitJobs = Number.isFinite(rawQueueLimitJobs) && rawQueueLimitJobs >= 0 ? Math.floor(rawQueueLimitJobs) : 15000; + let available = queueLimitJobs === 0 ? Number.POSITIVE_INFINITY : Math.max(0, queueLimitJobs - currentJobCount); + const admittedPaths = []; + const deferredPaths = []; + let plannedJobs = 0; + for (const candidate of ordered) { + const required = Math.max(0, Math.floor(Number(candidate?.eligibleJobCount) || 0)); + if (required > 0 && required <= available) { + admittedPaths.push(candidate?.path); + available -= required; + plannedJobs += required; + } else if (required > 0) { + deferredPaths.push(candidate?.path); + } + } + return { + admittedPaths, + deferredPaths, + currentJobCount, + plannedJobs, + availableSlots: Number.isFinite(available) ? available : null + }; + } + + function localDateKey(nowMs) { + const date = new Date(nowMs); + return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0')].join('-'); + } + + function emptyTelemetry(dateKey) { + return { + dateKey, + detected: 0, + queued: 0, + skipped: 0, + deferred: 0, + lastDetectedName: '', + lastDetectedAt: null, + lastError: '', + lastErrorAt: null + }; + } + + function rollDailyTelemetry(value = {}, nowMs = Date.now()) { + const telemetry = asObject(value); + const dateKey = localDateKey(nowMs); + if (telemetry.dateKey !== dateKey) return emptyTelemetry(dateKey); + return { + ...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) + }; + } + + function applyTelemetryDelta(value, delta = {}, nowMs = Date.now()) { + 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); + } + if (changes.lastDetectedName) { + next.lastDetectedName = String(changes.lastDetectedName); + next.lastDetectedAt = nowMs; + } + if (Object.prototype.hasOwnProperty.call(changes, 'lastError')) { + next.lastError = String(changes.lastError || ''); + next.lastErrorAt = next.lastError ? nowMs : null; + } + return next; + } + + function deriveAutomationState(value = {}) { + const state = asObject(value); + if (state.paused === true) return 'paused'; + if (state.enabled !== true || !state.folderPath) return 'inactive'; + if (state.reachable === false) return 'disconnected'; + if (state.error) return 'error'; + if (state.queueLimited === true) return 'queue-limited'; + return 'active'; + } + + function normalizePath(value) { + return String(value || '').replace(/\\/g, '/').toLowerCase(); + } + + function baseName(value) { + return String(value || '').split(/[\\/]/).pop().toLowerCase(); + } + + function classifyProcessedCandidates(input = {}) { + const value = asObject(input); + const candidates = asArray(value.candidates); + const historyRows = asArray(value.historyRows); + const uploadLogRows = asArray(value.uploadLogRows); + const exactPaths = new Set(asArray(value.queuePaths).map(normalizePath).filter(Boolean)); + for (const row of historyRows) { + const exact = normalizePath(row?.path || row?.file); + if (exact) exactPaths.add(exact); + } + const evidenceNames = new Set(); + for (const row of [...historyRows, ...uploadLogRows]) { + const name = baseName(row?.fileName || row?.filename || row?.name || row?.path || row?.file); + if (name) evidenceNames.add(name); + } + const nameCounts = new Map(); + for (const candidate of candidates) { + const name = baseName(candidate?.name || candidate?.path); + nameCounts.set(name, (nameCounts.get(name) || 0) + 1); + } + const processedPaths = []; + const ambiguousPaths = []; + const unprocessedPaths = []; + for (const candidate of candidates) { + const exact = normalizePath(candidate?.path); + const name = baseName(candidate?.name || candidate?.path); + if ((exact && exactPaths.has(exact)) || (evidenceNames.has(name) && nameCounts.get(name) === 1)) processedPaths.push(candidate?.path); + else if (evidenceNames.has(name) && nameCounts.get(name) > 1) ambiguousPaths.push(candidate?.path); + else unprocessedPaths.push(candidate?.path); + } + return { processedPaths, ambiguousPaths, unprocessedPaths }; + } + + return { + normalizeAutomationSettings, + countAutomaticQueueJobs, + planAtomicAdmissions, + rollDailyTelemetry, + applyTelemetryDelta, + deriveAutomationState, + classifyProcessedCandidates + }; +}); diff --git a/renderer/index.html b/renderer/index.html index f7da179..7d430eb 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -713,6 +713,7 @@ + diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index de4b9cc..4ed1434 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -19,6 +19,7 @@ const sourceFiles = [ `${publicActionsDir}/workflows/ci.yml`, 'lib/account-auth.js', 'lib/account-rotation.js', + 'lib/automation-control.js', 'lib/backup-crypto.js', 'lib/clouddrop-upload.js', 'lib/coalesced-set.js', @@ -96,6 +97,7 @@ const sourceFiles = [ 'tests/account-auth.test.js', 'tests/account-rotation.test.js', 'tests/account-status.test.js', + 'tests/automation-control.test.js', 'tests/auto-resume.test.js', 'tests/backup-crypto.test.js', 'tests/byse-reject-recovery.test.js', diff --git a/tests/automation-control.test.js b/tests/automation-control.test.js new file mode 100644 index 0000000..378eb08 --- /dev/null +++ b/tests/automation-control.test.js @@ -0,0 +1,259 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + normalizeAutomationSettings, + countAutomaticQueueJobs, + planAtomicAdmissions, + rollDailyTelemetry, + applyTelemetryDelta, + deriveAutomationState, + classifyProcessedCandidates +} = require('../lib/automation-control'); + +test('automation defaults use 15000 jobs and a five minute reconciliation interval', () => { + assert.deepEqual(normalizeAutomationSettings({}), { + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5, + paused: false, + pausedAt: null + }); +}); + +test('automation settings normalize invalid limits intervals and pause timestamps', () => { + assert.deepEqual(normalizeAutomationSettings({ + queueLimitJobs: -1, + reconcileIntervalMinutes: 10, + paused: true, + pausedAt: '1700' + }), { + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5, + paused: true, + pausedAt: 1700 + }); + assert.deepEqual(normalizeAutomationSettings({ + queueLimitJobs: 42.9, + reconcileIntervalMinutes: '15', + pausedAt: 1700 + }), { + queueLimitJobs: 42, + reconcileIntervalMinutes: 15, + paused: false, + pausedAt: null + }); + assert.deepEqual(normalizeAutomationSettings(null), { + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5, + paused: false, + pausedAt: null + }); +}); + +test('capacity counts only executable and running queue jobs', () => { + const statuses = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped']; + assert.equal(countAutomaticQueueJobs(statuses.map((status, index) => ({ id: String(index), status }))), 5); + assert.equal(countAutomaticQueueJobs(null), 0); +}); + +test('admission keeps every eligible host job for a file atomic', () => { + const plan = planAtomicAdmissions({ + candidates: [ + { path: 'C:\\watch\\a.mkv', mtimeMs: 1000, eligibleJobCount: 4 }, + { path: 'C:\\watch\\b.mkv', mtimeMs: 2000, eligibleJobCount: 2 } + ], + currentJobCount: 14997, + queueLimitJobs: 15000 + }); + assert.deepEqual(plan.admittedPaths, ['C:\\watch\\b.mkv']); + assert.deepEqual(plan.deferredPaths, ['C:\\watch\\a.mkv']); + assert.equal(plan.plannedJobs, 2); + assert.equal(plan.availableSlots, 1); +}); + +test('admission uses stable mtime and path ordering without mutating candidates', () => { + const candidates = [ + { path: 'b', mtimeMs: 10, eligibleJobCount: 1 }, + { path: 'c', mtimeMs: 5, eligibleJobCount: 1 }, + { path: 'a', mtimeMs: 10, eligibleJobCount: 1 } + ]; + const snapshot = structuredClone(candidates); + const plan = planAtomicAdmissions({ candidates, currentJobCount: 0, queueLimitJobs: 2 }); + assert.deepEqual(plan.admittedPaths, ['c', 'a']); + assert.deepEqual(plan.deferredPaths, ['b']); + assert.deepEqual(candidates, snapshot); +}); + +test('unlimited admission accepts all eligible files', () => { + const plan = planAtomicAdmissions({ + candidates: [{ path: 'a', mtimeMs: 1, eligibleJobCount: 20000 }], + currentJobCount: 50000, + queueLimitJobs: 0 + }); + assert.deepEqual(plan.admittedPaths, ['a']); + assert.deepEqual(plan.deferredPaths, []); + assert.equal(plan.availableSlots, null); +}); + +test('admission tolerates malformed candidates and numeric inputs', () => { + assert.deepEqual(planAtomicAdmissions({ candidates: null, currentJobCount: -4, queueLimitJobs: '3' }), { + admittedPaths: [], + deferredPaths: [], + currentJobCount: 0, + plannedJobs: 0, + availableSlots: 3 + }); + assert.deepEqual(planAtomicAdmissions(null), { + admittedPaths: [], + deferredPaths: [], + currentJobCount: 0, + plannedJobs: 0, + availableSlots: 15000 + }); +}); + +test('daily telemetry resets atomically on the local calendar day boundary', () => { + const before = { dateKey: '2026-08-25', detected: 8, queued: 4, skipped: 2, deferred: 1 }; + const now = new Date(2026, 7, 26, 0, 0, 1).getTime(); + assert.deepEqual(rollDailyTelemetry(before, now), { + dateKey: '2026-08-26', + detected: 0, + queued: 0, + skipped: 0, + deferred: 0, + lastDetectedName: '', + lastDetectedAt: null, + lastError: '', + lastErrorAt: null + }); +}); + +test('daily telemetry normalizes malformed same-day counters without mutating input', () => { + const now = new Date(2026, 7, 26, 12, 0, 0).getTime(); + const telemetry = { dateKey: '2026-08-26', detected: -2, queued: '3', lastError: 'network' }; + const snapshot = structuredClone(telemetry); + assert.deepEqual(rollDailyTelemetry(telemetry, now), { + dateKey: '2026-08-26', + detected: 0, + queued: 3, + skipped: 0, + deferred: 0, + lastDetectedName: '', + lastDetectedAt: null, + lastError: 'network', + lastErrorAt: null + }); + assert.deepEqual(telemetry, snapshot); + assert.equal(rollDailyTelemetry(null, now).dateKey, '2026-08-26'); +}); + +test('telemetry deltas increment counters and update event details immutably', () => { + const now = new Date(2026, 7, 26, 13, 14, 15).getTime(); + const telemetry = { + dateKey: '2026-08-26', + detected: 1, + queued: 2, + skipped: 3, + deferred: 4, + lastDetectedName: '', + lastDetectedAt: null, + lastError: 'old', + lastErrorAt: 10 + }; + const result = applyTelemetryDelta(telemetry, { + detected: 2, + queued: 3, + skipped: -1, + deferred: '2', + lastDetectedName: 'episode.mkv', + lastError: '' + }, now); + assert.deepEqual(result, { + dateKey: '2026-08-26', + detected: 3, + queued: 5, + skipped: 3, + deferred: 6, + lastDetectedName: 'episode.mkv', + lastDetectedAt: now, + lastError: '', + lastErrorAt: null + }); + assert.equal(telemetry.detected, 1); + assert.equal(telemetry.lastError, 'old'); +}); + +test('pause has higher display priority than disconnect error and queue limit', () => { + assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused'); +}); + +test('automation state follows inactive disconnected error queue-limited and active priority', () => { + assert.equal(deriveAutomationState({ enabled: false, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'inactive'); + assert.equal(deriveAutomationState({ enabled: true, folderPath: '', reachable: false, error: 'x', queueLimited: true }), 'inactive'); + assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'disconnected'); + assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, error: 'x', queueLimited: true }), 'error'); + assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, queueLimited: true }), 'queue-limited'); + assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true }), 'active'); + assert.equal(deriveAutomationState(null), 'inactive'); +}); + +test('exact queue and history paths mark candidates processed case-insensitively', () => { + const result = classifyProcessedCandidates({ + candidates: [ + { path: 'C:\\Watch\\queue.mkv' }, + { path: 'C:\\Watch\\history.mkv' }, + { path: 'C:\\Watch\\new.mkv' } + ], + queuePaths: ['c:/watch/QUEUE.mkv'], + historyRows: [{ file: 'c:/watch/HISTORY.mkv' }], + uploadLogRows: [] + }); + assert.deepEqual(result, { + processedPaths: ['C:\\Watch\\queue.mkv', 'C:\\Watch\\history.mkv'], + ambiguousPaths: [], + unprocessedPaths: ['C:\\Watch\\new.mkv'] + }); +}); + +test('unique basename evidence marks one candidate processed', () => { + const result = classifyProcessedCandidates({ + candidates: [{ path: 'C:\\watch\\unique.mkv' }, { path: 'C:\\watch\\other.mkv' }], + uploadLogRows: [{ filename: 'UNIQUE.MKV' }] + }); + assert.deepEqual(result.processedPaths, ['C:\\watch\\unique.mkv']); + assert.deepEqual(result.unprocessedPaths, ['C:\\watch\\other.mkv']); +}); + +test('ambiguous same-name log evidence never marks a candidate processed', () => { + const result = classifyProcessedCandidates({ + candidates: [ + { path: 'C:\\one\\episode.mkv', name: 'episode.mkv' }, + { path: 'D:\\two\\episode.mkv', name: 'episode.mkv' } + ], + queuePaths: [], + historyRows: [], + uploadLogRows: [{ fileName: 'episode.mkv', hoster: 'doodstream.com' }] + }); + assert.deepEqual(result.processedPaths, []); + assert.deepEqual(result.ambiguousPaths.sort(), ['C:\\one\\episode.mkv', 'D:\\two\\episode.mkv'].sort()); +}); + +test('processed classification tolerates malformed collections and does not mutate candidates', () => { + const candidates = [{ path: 'C:\\watch\\episode.mkv', name: 'episode.mkv' }]; + const snapshot = structuredClone(candidates); + assert.deepEqual(classifyProcessedCandidates({ + candidates, + queuePaths: null, + historyRows: null, + uploadLogRows: null + }), { + processedPaths: [], + ambiguousPaths: [], + unprocessedPaths: ['C:\\watch\\episode.mkv'] + }); + assert.deepEqual(candidates, snapshot); + assert.deepEqual(classifyProcessedCandidates(null), { + processedPaths: [], + ambiguousPaths: [], + unprocessedPaths: [] + }); +}); diff --git a/tests/public-release-verifier.test.js b/tests/public-release-verifier.test.js index e08e720..d0dc599 100644 --- a/tests/public-release-verifier.test.js +++ b/tests/public-release-verifier.test.js @@ -45,7 +45,7 @@ test('public release verifier accepts only the exact source manifest and target assert.equal(baseline.status, 0, baseline.stderr); assert.equal( baseline.stdout, - `public-release-source-ok files=157 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n` + `public-release-source-ok files=159 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n` ); fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');