diff --git a/lib/queue-prune.js b/lib/queue-prune.js
new file mode 100644
index 0000000..81352f4
--- /dev/null
+++ b/lib/queue-prune.js
@@ -0,0 +1,59 @@
+// Queue auto-prune logic. Extracted from renderer/app.js handleBatchDone so
+// the algorithm can be unit-tested without needing a DOM or the renderer's
+// module-level state (queueJobs, _jobIndexById).
+//
+// Loaded both as a CommonJS module (Node tests) and as a browser global
+// (renderer/app.js via index.html script tag) so the same single
+// implementation backs both runtime and tests — no drift between them.
+//
+// Behaviour: when the number of terminal-status jobs (done / skipped /
+// error / aborted) in the queue exceeds `limit`, drop the oldest terminal
+// jobs (insertion order) until we're back at the limit. Non-terminal jobs
+// (queued / preview / uploading / retrying / getting-server) are always
+// kept — those are work the user can still act on. Without this cap a
+// long session accumulates thousands of done rows and every render becomes
+// O(N) on a perpetually-growing N.
+
+(function (root) {
+ 'use strict';
+
+ const TERMINAL_STATUSES = new Set(['done', 'skipped', 'error', 'aborted']);
+
+ /**
+ * Compute which jobs to keep vs drop, given a queue and a terminal-jobs cap.
+ * @param {Array<{id: string, status: string}>} jobs the current queue
+ * @param {number} limit max terminal jobs to keep
+ * @returns {null | { kept: Array, dropped: Array }} null when nothing changed
+ */
+ function pruneOldestTerminalJobs(jobs, limit) {
+ if (!Array.isArray(jobs) || jobs.length === 0) return null;
+ if (!Number.isFinite(limit) || limit < 0) return null;
+
+ // Walk once, record indices of terminal jobs in insertion order.
+ const terminalIdxs = [];
+ for (let i = 0; i < jobs.length; i++) {
+ const j = jobs[i];
+ if (j && TERMINAL_STATUSES.has(j.status)) terminalIdxs.push(i);
+ }
+ if (terminalIdxs.length <= limit) return null;
+
+ const dropCount = terminalIdxs.length - limit;
+ const dropSet = new Set(terminalIdxs.slice(0, dropCount));
+
+ const kept = [];
+ const dropped = [];
+ for (let i = 0; i < jobs.length; i++) {
+ if (dropSet.has(i)) dropped.push(jobs[i]);
+ else kept.push(jobs[i]);
+ }
+ return { kept, dropped };
+ }
+
+ const api = { pruneOldestTerminalJobs, TERMINAL_STATUSES };
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ } else if (root) {
+ root.QueuePrune = api;
+ }
+})(typeof window !== 'undefined' ? window : this);
diff --git a/renderer/app.js b/renderer/app.js
index 634af35..7127eaa 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -2023,34 +2023,23 @@ function handleBatchDone(summary) {
queueJobs = nextJobs;
renderQueueTable();
} else {
- // Auto-prune for the default (removeOnDone=false) too: cap done/skipped
- // jobs at the most recent N so the queue can't grow unbounded across
- // long sessions. Without this, sortQueueJobs / _computeQueueStats /
- // renderQueueTable all become O(N) on a forever-growing N and the UI
- // starts visibly lagging once the queue passes a few thousand entries.
- // 500 most-recent terminal jobs is enough for "see what just happened"
- // while stopping the runaway growth.
+ // Auto-prune for the default (removeOnDone=false) too: cap terminal
+ // jobs (done/skipped/error/aborted) at the most recent N so the queue
+ // can't grow unbounded across long sessions. The algorithm lives in
+ // lib/queue-prune.js (same impl Node-tested, see tests/queue-prune.test.js)
+ // and the result tells us which jobs to drop so we can clean up the
+ // index + selection in one pass.
const TERMINAL_KEEP_LIMIT = 500;
- const terminalIdxs = [];
- for (let i = 0; i < queueJobs.length; i++) {
- const s = queueJobs[i].status;
- if (s === 'done' || s === 'skipped' || s === 'error' || s === 'aborted') terminalIdxs.push(i);
- }
- if (terminalIdxs.length > TERMINAL_KEEP_LIMIT) {
- const dropCount = terminalIdxs.length - TERMINAL_KEEP_LIMIT;
- // terminalIdxs is in insertion order → first `dropCount` are the
- // oldest. Remove those, keep everything else.
- const dropSet = new Set(terminalIdxs.slice(0, dropCount));
- const nextJobs = [];
- for (let i = 0; i < queueJobs.length; i++) {
- if (dropSet.has(i)) {
- removeJobFromIndex(queueJobs[i]);
- selectedJobIds.delete(queueJobs[i].id);
- } else {
- nextJobs.push(queueJobs[i]);
- }
+ // Optional-chain so the renderer still works if the prune script fails
+ // to load (e.g. file:// path issues during dev) — falls back to no-prune
+ // rather than crashing on every batch-done.
+ const result = window.QueuePrune?.pruneOldestTerminalJobs(queueJobs, TERMINAL_KEEP_LIMIT);
+ if (result) {
+ for (const j of result.dropped) {
+ removeJobFromIndex(j);
+ selectedJobIds.delete(j.id);
}
- queueJobs = nextJobs;
+ queueJobs = result.kept;
renderQueueTable();
}
}
diff --git a/renderer/index.html b/renderer/index.html
index 0d1d425..a9a2ae9 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -330,6 +330,7 @@
+