@andrian.yablonskyy/thub-coordinator 1.0.23 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/thub-admin.js CHANGED
@@ -32,8 +32,9 @@ const { loadConfig } = require('../src/config'),
32
32
  { createAdminUsersService } = require('../src/services/admin-users'),
33
33
  { createArtifactsService } = require('../src/services/artifacts'),
34
34
  { createJobsService } = require('../src/services/jobs'),
35
+ { createCleanupService } = require('../src/services/cleanup'),
35
36
  { generateToken } = require('../src/services/tokens'),
36
- { PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin } = require('@andrian.yablonskyy/thub-common'),
37
+ { PACKAGES, fetchLatestVersion, isNewer, isValidVersion, npmBin, formatDateTime, parseDateTime } = require('@andrian.yablonskyy/thub-common'),
37
38
  { spawnSync } = require('node:child_process'),
38
39
  { version: installedVersion } = require('../package.json');
39
40
 
@@ -44,7 +45,10 @@ function usage(){
44
45
  thub-admin join-key generate
45
46
  thub-admin resource maintenance <resourceId> --on|--off
46
47
  thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
47
- thub-admin jobs clean --yes Permanently delete finished jobs, logs and artifacts
48
+ thub-admin jobs clean --yes [--before "dd/mm/yyyy HH:MM:SS"]
49
+ Permanently delete finished jobs (with logs and artifacts) and
50
+ history from before then (local time; default: now), then
51
+ compact the database file
48
52
  thub-admin group add <name> [--comment <text>]
49
53
  thub-admin group list
50
54
  thub-admin group remove <groupId>
@@ -192,8 +196,17 @@ function main(){
192
196
  console.error('This permanently deletes finished jobs and their logs/artifacts. Re-run with --yes to confirm.');
193
197
  process.exit(4);
194
198
  }
195
- const deleted = jobs.cleanHistory();
196
- console.log(`Deleted ${deleted} finished job(s) and their logs/artifacts.`);
199
+ const before = flags.before ? parseDateTime(flags.before) : new Date();
200
+ if (!before){
201
+ console.error(`--before "${flags.before}" isn't a date/time in the dd/mm/yyyy HH:MM:SS format`);
202
+ process.exit(4);
203
+ }
204
+ const r = createCleanupService(db, { jobs, config }).cleanup({ before }),
205
+ mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
206
+ console.log(
207
+ `Deleted ${r.jobs} finished job(s) and ${r.events} history record(s) from before ${formatDateTime(r.before)}. ` +
208
+ `Database: ${mb(r.bytesBefore)} -> ${mb(r.bytesAfter)}.`
209
+ );
197
210
  return;
198
211
  }
199
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "TestHub Coordinator — job queue, resource registry, scheduler, heartbeat monitor, log/artifact store and web dashboard",
5
5
  "main": "src/server.js",
6
6
  "engines": {
package/src/api/admin.js CHANGED
@@ -82,9 +82,15 @@ function createAdminRouter({ services }){
82
82
  });
83
83
 
84
84
  // Permanently deletes finished jobs and their logs/artifacts (§13.1).
85
+ // Database cleanup (§9): optional `before` (ISO 8601; default now) —
86
+ // finished jobs that ended before it, older history, then VACUUM.
85
87
  router.post('/jobs/clean-history', (req, res) => {
86
- const deleted = services.jobs.cleanHistory();
87
- res.json({ deleted });
88
+ const before = req.body?.before ? new Date(req.body.before) : new Date();
89
+ if (Number.isNaN(before.getTime())){
90
+ return res.status(400).json({ error: 'before must be an ISO 8601 date/time' });
91
+ }
92
+ const r = services.cleanup.cleanup({ before });
93
+ res.json({ deleted: r.jobs, events: r.events, before: r.before, bytesBefore: r.bytesBefore, bytesAfter: r.bytesAfter });
88
94
  });
89
95
 
90
96
  // Resource groups (§13.1) — membership itself is declared by each
package/src/config.js CHANGED
@@ -15,7 +15,8 @@
15
15
 
16
16
  const fs = require('node:fs'),
17
17
  os = require('node:os'),
18
- path = require('node:path');
18
+ path = require('node:path'),
19
+ { JOB_RETENTION } = require('./services/cleanup');
19
20
 
20
21
  // Defaults mirror README.md §13.
21
22
  const DEFAULTS = {
@@ -47,7 +48,12 @@ const DEFAULTS = {
47
48
  },
48
49
  retention: {
49
50
  logRetentionDays: 14,
50
- artifactRetentionDays: 30
51
+ artifactRetentionDays: 30,
52
+ // How long a finished job (with its logs, artifacts and events) is kept
53
+ // before the hourly retention task wipes it (services/cleanup.js):
54
+ // 1w | 2w | 1m | 3m | 6m | forever. `forever` keeps everything until a
55
+ // manual cleanup (dashboard Clean up database / thub-admin jobs clean).
56
+ jobRetention: 'forever'
51
57
  },
52
58
  artifacts: {
53
59
  maxUploadMb: 512,
@@ -116,6 +122,13 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
116
122
  config.clientJoinKey = process.env.THUB_CLIENT_JOIN_KEY;
117
123
  }
118
124
 
125
+ if (!Object.hasOwn(JOB_RETENTION, config.retention.jobRetention)){
126
+ throw new Error(
127
+ `retention.jobRetention must be one of: ${Object.keys(JOB_RETENTION).join(', ')} ` +
128
+ `(got ${JSON.stringify(config.retention.jobRetention)}) in ${candidate || 'the config'}`
129
+ );
130
+ }
131
+
119
132
  config.configPath = candidate || null;
120
133
  config.dataDir = path.resolve(config.dataDir);
121
134
 
package/src/server.js CHANGED
@@ -34,6 +34,7 @@ const path = require('node:path'),
34
34
  { createArtifactsService } = require('./services/artifacts'),
35
35
  { createCommandsService } = require('./services/commands'),
36
36
  { createRetentionService } = require('./services/retention'),
37
+ { createCleanupService } = require('./services/cleanup'),
37
38
  { createUpdatesService } = require('./services/updates'),
38
39
 
39
40
  { createAgentRouter } = require('./api/agent'),
@@ -55,10 +56,12 @@ function buildServices(config){
55
56
  scheduler = createScheduler(db, { bus, events, registry, config }),
56
57
  heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config }),
57
58
  retention = createRetentionService(db, { bus, logs, artifacts, config }),
59
+ cleanup = createCleanupService(db, { jobs, config }),
58
60
  updates = createUpdatesService({ config });
59
61
 
60
62
  jobs.reconcileOnStartup();
61
63
  updates.start();
64
+ cleanup.start();
62
65
 
63
66
  const sweepInterval = setInterval(() => {
64
67
  jobs.checkAssignAcks();
@@ -81,6 +84,7 @@ function buildServices(config){
81
84
  scheduler,
82
85
  heartbeatMonitor,
83
86
  retention,
87
+ cleanup,
84
88
  updates
85
89
  };
86
90
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/cleanup.js
3
+ * @description Database cleanup: wipe finished jobs (logs, artifacts, events) and old events before a cutoff, then
4
+ * VACUUM to shrink the file — on demand from the dashboard, and hourly per retention.jobRetention (README §9)
5
+ *
6
+ * @author Andrian Yablonskyy
7
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
8
+ *
9
+ * This file is part of TestHub and is proprietary and confidential.
10
+ * Unauthorized copying, modification, distribution, or use of this file,
11
+ * via any medium, is strictly prohibited without prior written permission
12
+ * from AdSystem.PRO.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('node:fs');
18
+
19
+ // retention.jobRetention values: how long a finished job is kept. `forever`
20
+ // means only a manual cleanup (dashboard / POST /admin/jobs/clean-history)
21
+ // removes jobs. Months are calendar months.
22
+ const JOB_RETENTION = {
23
+ '1w': { days: 7, label: '1 week' },
24
+ '2w': { days: 14, label: '2 weeks' },
25
+ '1m': { months: 1, label: '1 month' },
26
+ '3m': { months: 3, label: '3 months' },
27
+ '6m': { months: 6, label: '6 months' },
28
+ forever: { label: 'indefinitely' }
29
+ },
30
+ PRUNE_INTERVAL_MS = 3600 * 1000,
31
+ FIRST_PRUNE_DELAY_MS = 60 * 1000,
32
+ // VACUUM rewrites the whole file and blocks meanwhile — the automatic
33
+ // task does it at most this often; a manual cleanup always does.
34
+ AUTO_VACUUM_MIN_INTERVAL_MS = 24 * 3600 * 1000;
35
+
36
+ // `now` minus `amount` ({days} or {months}), in UTC so the server's own
37
+ // time zone and its DST changes don't shift the cutoff by an hour.
38
+ function subtract(now, { days, months }){
39
+ const d = new Date(now);
40
+ if (months){
41
+ d.setUTCMonth(d.getUTCMonth() - months);
42
+ }
43
+ if (days){
44
+ d.setUTCDate(d.getUTCDate() - days);
45
+ }
46
+ return d;
47
+ }
48
+
49
+ // Cutoff for a retention setting: jobs that finished before it go. null for
50
+ // `forever`. Throws on an unknown setting (config.js validates it at load).
51
+ function retentionCutoff(setting, now = new Date()){
52
+ const def = JOB_RETENTION[setting];
53
+ if (!def){
54
+ throw new Error(`retention.jobRetention must be one of: ${Object.keys(JOB_RETENTION).join(', ')} (got "${setting}")`);
55
+ }
56
+ return def.days || def.months ? subtract(now, def) : null;
57
+ }
58
+
59
+ function createCleanupService(db, { jobs, config, now = () => new Date() }){
60
+ let lastVacuumAt = 0,
61
+ timer = null,
62
+ firstRun = null;
63
+
64
+ // thub.db plus its WAL — what the database takes on disk.
65
+ function databaseBytes(){
66
+ return [config.dbPath, `${config.dbPath}-wal`].reduce((sum, file) => {
67
+ try {
68
+ return sum + fs.statSync(file).size;
69
+ }
70
+ catch {
71
+ return sum;
72
+ }
73
+ }, 0);
74
+ }
75
+
76
+ function vacuum(){
77
+ db.pragma('wal_checkpoint(TRUNCATE)');
78
+ db.exec('VACUUM');
79
+ db.pragma('wal_checkpoint(TRUNCATE)');
80
+ lastVacuumAt = Date.now();
81
+ }
82
+
83
+ // Wipe everything older than `before` (a Date; default now): finished
84
+ // jobs that ended before it, with their logs, artifacts and events, and
85
+ // the other audit events (resource/agent/group) logged before it. Events
86
+ // of jobs that stay (still active, or newer) are kept. `vacuum`: true,
87
+ // false, or 'auto' (only if something was deleted and the last VACUUM is
88
+ // over a day old). Returns what was done, with the size before/after.
89
+ function cleanup({ before = now(), vacuum: doVacuum = true } = {}){
90
+ const cutoff = before.toISOString(),
91
+ bytesBefore = config.dbPath ? databaseBytes() : 0,
92
+ deletedJobs = jobs.purgeFinishedBefore(cutoff),
93
+ deletedEvents = db.prepare(
94
+ `DELETE FROM events WHERE ts < ?
95
+ AND NOT (entity = 'job' AND entity_id IN (SELECT id FROM jobs))
96
+ AND NOT (entity = 'job' AND entity_id = 'bulk' AND type = 'jobs.cleaned')`
97
+ ).run(cutoff).changes,
98
+ vacuumed = doVacuum === true ||
99
+ (doVacuum === 'auto' && deletedJobs + deletedEvents > 0 && Date.now() - lastVacuumAt >= AUTO_VACUUM_MIN_INTERVAL_MS);
100
+ if (vacuumed){
101
+ vacuum();
102
+ }
103
+ return { before: cutoff, jobs: deletedJobs, events: deletedEvents, vacuumed, bytesBefore, bytesAfter: config.dbPath ? databaseBytes() : 0 };
104
+ }
105
+
106
+ // The retention task: hourly (first run a minute after start), wipes jobs
107
+ // older than retention.jobRetention. Does nothing for `forever`.
108
+ function prune(){
109
+ const cutoff = retentionCutoff(config.retention.jobRetention, now());
110
+ if (!cutoff){
111
+ return null;
112
+ }
113
+ const result = cleanup({ before: cutoff, vacuum: 'auto' });
114
+ if (result.jobs || result.events){
115
+ console.log(
116
+ `Retention (${JOB_RETENTION[config.retention.jobRetention].label}): removed ${result.jobs} job(s) and ${result.events} event(s) ` +
117
+ `from before ${result.before}${result.vacuumed ? `; database ${result.bytesBefore} -> ${result.bytesAfter} bytes` : ''}`
118
+ );
119
+ }
120
+ return result;
121
+ }
122
+
123
+ function safePrune(){
124
+ try {
125
+ prune();
126
+ }
127
+ catch (err){
128
+ console.error(`Retention cleanup failed: ${err.message}`);
129
+ }
130
+ }
131
+
132
+ function start(){
133
+ firstRun = setTimeout(safePrune, FIRST_PRUNE_DELAY_MS);
134
+ firstRun.unref?.();
135
+ timer = setInterval(safePrune, PRUNE_INTERVAL_MS);
136
+ timer.unref?.();
137
+ }
138
+
139
+ function stop(){
140
+ clearTimeout(firstRun);
141
+ clearInterval(timer);
142
+ }
143
+
144
+ return { cleanup, prune, databaseBytes, start, stop };
145
+ }
146
+
147
+ module.exports = { createCleanupService, retentionCutoff, JOB_RETENTION };
@@ -145,7 +145,20 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
145
145
  spec.target.client = resource.id;
146
146
  }
147
147
 
148
- if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group, spec.target.client)){
148
+ // A job-supplied Docker image needs an SW Client that opted in — say so
149
+ // specifically rather than the generic "no resource can satisfy".
150
+ const needs = { jobImage: Boolean(spec.firmware.image) },
151
+ { type, labels, group, client } = spec.target;
152
+ if (needs.jobImage && registry.everSatisfiable(type, labels, group, client) && !registry.everSatisfiable(type, labels, group, client, needs)){
153
+ throw Object.assign(
154
+ new Error(
155
+ `No matching SW Client runs job-supplied Docker images (${spec.firmware.image}) — enable it on a Client ` +
156
+ 'with "sw": { "allowJobImages": true } in its config, or pass a firmware URL instead'
157
+ ),
158
+ { status: 422 }
159
+ );
160
+ }
161
+ if (!registry.everSatisfiable(type, labels, group, client, needs)){
149
162
  throw Object.assign(
150
163
  new Error(
151
164
  `No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
@@ -416,24 +429,38 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
416
429
  // not just active work, needs clearing out. Unlike the nightly retention
417
430
  // sweep (§9), this runs on demand and isn't limited to old jobs.
418
431
  function cleanHistory(){
432
+ return purgeFinishedBefore(null);
433
+ }
434
+
435
+ // Finished jobs that ended before `beforeIso` (null: all of them), with
436
+ // their logs, artifacts (files too) and events — manual cleanup and the
437
+ // job retention task (services/cleanup.js). Active jobs are never touched.
438
+ function purgeFinishedBefore(beforeIso){
419
439
  const placeholders = [...TERMINAL_JOB_STATES].map(() => '?').join(','),
420
- rows = db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders})`).all(...TERMINAL_JOB_STATES),
440
+ rows = beforeIso
441
+ ? db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders}) AND COALESCE(finished_at, created_at) < ?`)
442
+ .all(...TERMINAL_JOB_STATES, beforeIso)
443
+ : db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders})`).all(...TERMINAL_JOB_STATES),
421
444
  ids = rows.map((r) => r.id);
422
445
  if (ids.length === 0){
423
446
  return 0;
424
447
  }
425
448
 
426
- const tx = db.transaction(() => {
427
- for (const id of ids){
428
- artifacts.deleteJobArtifacts(id);
429
- db.prepare('DELETE FROM artifacts WHERE job_id = ?').run(id);
430
- db.prepare('DELETE FROM job_logs WHERE job_id = ?').run(id);
431
- db.prepare('DELETE FROM events WHERE entity = \'job\' AND entity_id = ?').run(id);
432
- db.prepare('DELETE FROM jobs WHERE id = ?').run(id);
433
- }
434
- });
449
+ const deleteArtifacts = db.prepare('DELETE FROM artifacts WHERE job_id = ?'),
450
+ deleteLogs = db.prepare('DELETE FROM job_logs WHERE job_id = ?'),
451
+ deleteEvents = db.prepare('DELETE FROM events WHERE entity = \'job\' AND entity_id = ?'),
452
+ deleteJob = db.prepare('DELETE FROM jobs WHERE id = ?'),
453
+ tx = db.transaction(() => {
454
+ for (const id of ids){
455
+ artifacts.deleteJobArtifacts(id);
456
+ deleteArtifacts.run(id);
457
+ deleteLogs.run(id);
458
+ deleteEvents.run(id);
459
+ deleteJob.run(id);
460
+ }
461
+ });
435
462
  tx();
436
- events.record('job', 'bulk', 'jobs.cleaned', { count: ids.length });
463
+ events.record('job', 'bulk', 'jobs.cleaned', { count: ids.length, before: beforeIso });
437
464
  return ids.length;
438
465
  }
439
466
 
@@ -470,7 +497,8 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
470
497
  reconcileOnStartup,
471
498
  resetQueue,
472
499
  countActiveAndFinished,
473
- cleanHistory
500
+ cleanHistory,
501
+ purgeFinishedBefore
474
502
  };
475
503
  }
476
504
 
@@ -78,6 +78,7 @@ function sanitizeCapabilities(caps){
78
78
  image: str(sw.image),
79
79
  registry: str(sw.registry),
80
80
  allowDockerHub: sw.allowDockerHub === true,
81
+ allowJobImages: sw.allowJobImages === true,
81
82
  cpus: num(sw.cpus),
82
83
  memory: str(sw.memory, 16)
83
84
  }
@@ -468,10 +469,13 @@ function createRegistryService(db, { bus, events }){
468
469
  );
469
470
  }
470
471
 
471
- function matchesTarget(r, labels, groupId, resourceId){
472
+ // `needs.jobImage`: the job brings its own Docker image (firmware.image),
473
+ // which only SW Clients that opted in (sw.allowJobImages) will run.
474
+ function matchesTarget(r, labels, groupId, resourceId, needs = {}){
472
475
  return labels.every((l) => r.labels.includes(l)) &&
473
476
  (!groupId || r.group_ids.includes(groupId)) &&
474
- (!resourceId || r.id === resourceId);
477
+ (!resourceId || r.id === resourceId) &&
478
+ (!needs.jobImage || r.capabilities?.sw?.allowJobImages === true);
475
479
  }
476
480
 
477
481
  // Admin: remove a resource from the registry (dashboard "Remove"). Refused
@@ -509,17 +513,17 @@ function createRegistryService(db, { bus, events }){
509
513
  return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
510
514
  }
511
515
 
512
- function findIdleCandidates(type, labels, groupId, resourceId){
516
+ function findIdleCandidates(type, labels, groupId, resourceId, needs){
513
517
  const rows = db
514
518
  .prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
515
519
  .all(RESOURCE_STATES.IDLE, type)
516
520
  .map(rowToResource);
517
- return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
521
+ return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId, needs));
518
522
  }
519
523
 
520
- function everSatisfiable(type, labels, groupId, resourceId){
524
+ function everSatisfiable(type, labels, groupId, resourceId, needs){
521
525
  const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
522
- return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
526
+ return rows.some((r) => matchesTarget(r, labels, groupId, resourceId, needs));
523
527
  }
524
528
 
525
529
  return {
@@ -43,7 +43,7 @@ function createScheduler(db, { bus, events, registry, config }){
43
43
  for (const job of queued){
44
44
  const { type, labels = [], group, client } = job.spec.target,
45
45
  candidates = registry
46
- .findIdleCandidates(type, labels, group, client)
46
+ .findIdleCandidates(type, labels, group, client, { jobImage: Boolean(job.spec.firmware?.image) })
47
47
  .filter((r) => !claimedResourceIds.has(r.id));
48
48
  if (candidates.length === 0){
49
49
  continue;
package/src/web/routes.js CHANGED
@@ -20,7 +20,10 @@ const fs = require('node:fs'),
20
20
  multer = require('multer'),
21
21
  { requireAdminSession, requireAdminRole } = require('../auth'),
22
22
  { attachJobStream } = require('../api/sse'),
23
- { RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, isNewer, compareVersions, formatDateTime } = require('@andrian.yablonskyy/thub-common'),
23
+ {
24
+ RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, isNewer, compareVersions, formatDateTime, parseDateTime
25
+ } = require('@andrian.yablonskyy/thub-common'),
26
+ { retentionCutoff, JOB_RETENTION } = require('../services/cleanup'),
24
27
  listPrefs = require('../services/list-prefs');
25
28
 
26
29
  // Resources page sort keys (list-prefs.js) -> comparators. Empty values
@@ -92,6 +95,31 @@ function createWebRouter({ services, config }){
92
95
  return { prefs, urlFor, pageSizes: listPrefs.PAGE_SIZES };
93
96
  }
94
97
 
98
+ // What the Clean up database modal shows: current size, the oldest
99
+ // finished job, the retention setting, and quick-pick cutoffs — all
100
+ // formatted in the user's time zone, like the cutoff field itself.
101
+ function cleanupInfo(req){
102
+ const tz = req.session.user.timezone || 'UTC',
103
+ fmt = (d) => formatDateTime(d, { timeZone: tz }),
104
+ now = new Date(),
105
+ oldest = services.db.prepare(
106
+ `SELECT MIN(COALESCE(finished_at, created_at)) AS at FROM jobs
107
+ WHERE state IN (${[...TERMINAL_JOB_STATES].map(() => '?').join(',')})`
108
+ ).get(...TERMINAL_JOB_STATES).at,
109
+ retention = config.retention.jobRetention;
110
+ return {
111
+ size: `${(services.cleanup.databaseBytes() / 1024 / 1024).toFixed(1)} MB`,
112
+ oldestFinished: oldest ? fmt(oldest) : null,
113
+ now: fmt(now),
114
+ retention,
115
+ retentionLabel: JOB_RETENTION[retention]?.label,
116
+ presets: [
117
+ { label: 'Everything (now)', value: fmt(now) },
118
+ ...['1w', '1m', '3m', '6m'].map((k) => ({ label: `Older than ${JOB_RETENTION[k].label}`, value: fmt(retentionCutoff(k, now)) }))
119
+ ]
120
+ };
121
+ }
122
+
95
123
  // The job each resource is on (if any), for the resource card's Cancel.
96
124
  function withActiveJobs(resources){
97
125
  return resources.map((r) => ({ ...r, activeJob: services.jobs.activeForResource(r.id) }));
@@ -507,8 +535,9 @@ function createWebRouter({ services, config }){
507
535
  // A row's Cancel comes back to this exact view (filters, sort, page).
508
536
  returnTo: pageUrl(),
509
537
  canManage: req.session.user.role === 'admin',
510
- // For the Reset queue / Clean history confirmation modals.
511
- jobCounts: req.session.user.role === 'admin' ? services.jobs.countActiveAndFinished() : null
538
+ // For the Reset queue / Clean up database confirmation modals.
539
+ jobCounts: req.session.user.role === 'admin' ? services.jobs.countActiveAndFinished() : null,
540
+ cleanupInfo: req.session.user.role === 'admin' ? cleanupInfo(req) : null
512
541
  });
513
542
  });
514
543
 
@@ -521,9 +550,30 @@ function createWebRouter({ services, config }){
521
550
  res.redirect('/jobs');
522
551
  });
523
552
 
553
+ // "Clean up database" (§9): everything before the given time — finished
554
+ // jobs with their logs/artifacts, and older history — then VACUUM. The
555
+ // time is dd/mm/yyyy HH:MM:SS in the user's own time zone.
524
556
  router.post('/jobs/clean-history', requireAdminRole, (req, res) => {
525
- const deleted = services.jobs.cleanHistory();
526
- flash(req, 'warning', `Deleted ${deleted} finished job(s) and their logs/artifacts.`);
557
+ const tz = req.session.user.timezone || 'UTC',
558
+ text = String(req.body.before || '').trim(),
559
+ before = text ? parseDateTime(text, { timeZone: tz }) : new Date();
560
+ if (!before){
561
+ flash(req, 'danger', `"${text}" isn't a date/time in the dd/mm/yyyy HH:MM:SS format — nothing was deleted.`);
562
+ return res.redirect('/jobs');
563
+ }
564
+ try {
565
+ const r = services.cleanup.cleanup({ before }),
566
+ mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
567
+ flash(
568
+ req,
569
+ 'warning',
570
+ `Deleted ${r.jobs} finished job(s) and ${r.events} history record(s) from before ` +
571
+ `${formatDateTime(r.before, { timeZone: tz })}. Database: ${mb(r.bytesBefore)} → ${mb(r.bytesAfter)}.`
572
+ );
573
+ }
574
+ catch (err){
575
+ flash(req, 'danger', `Cleanup failed: ${err.message}`);
576
+ }
527
577
  res.redirect('/jobs');
528
578
  });
529
579
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file packages/coordinator/test/cleanup.test.js
3
+ * @description Tests: database cleanup before a cutoff, job retention settings, and the retention task
4
+ *
5
+ * @author Andrian Yablonskyy
6
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
7
+ *
8
+ * This file is part of TestHub and is proprietary and confidential.
9
+ * Unauthorized copying, modification, distribution, or use of this file,
10
+ * via any medium, is strictly prohibited without prior written permission
11
+ * from AdSystem.PRO.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const test = require('node:test'),
17
+ assert = require('node:assert/strict'),
18
+ fs = require('node:fs'),
19
+ os = require('node:os'),
20
+ path = require('node:path'),
21
+ { EventEmitter } = require('node:events'),
22
+ { openDb } = require('../src/db'),
23
+ { createEventsService } = require('../src/services/events'),
24
+ { createRegistryService } = require('../src/services/registry'),
25
+ { createAgentsService } = require('../src/services/agents'),
26
+ { createArtifactsService } = require('../src/services/artifacts'),
27
+ { createJobsService } = require('../src/services/jobs'),
28
+ { createCleanupService, retentionCutoff } = require('../src/services/cleanup'),
29
+ { JOB_STATES } = require('@andrian.yablonskyy/thub-common');
30
+
31
+ const NOW = new Date('2026-09-28T12:00:00Z');
32
+
33
+ function setup(jobRetention = 'forever'){
34
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-cleanup-')),
35
+ config = {
36
+ dataDir,
37
+ dbPath: path.join(dataDir, 'thub.db'),
38
+ artifactsDir: path.join(dataDir, 'artifacts'),
39
+ artifacts: { linkTtlHours: 1 },
40
+ sessionSecret: 's',
41
+ publicUrl: 'http://x',
42
+ scheduler: { maxQueuedPerAgent: 1000 },
43
+ jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 },
44
+ retention: { jobRetention }
45
+ },
46
+ db = openDb(config.dbPath),
47
+ bus = new EventEmitter(),
48
+ events = createEventsService(db),
49
+ registry = createRegistryService(db, { bus, events }),
50
+ { agent } = createAgentsService(db, { events }).create({ name: 'ci', kind: 'ci' }),
51
+ artifacts = createArtifactsService(db, { config }),
52
+ jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
53
+ cleanup = createCleanupService(db, { jobs, config, now: () => NOW });
54
+ registry.registerAuto({ clientId: 'c', name: 'lab', type: 'sw', labels: [] });
55
+
56
+ // A job that finished at `finishedAt` (or is still queued, if null), with a log line.
57
+ function job(finishedAt){
58
+ const spec = { target: { type: 'sw' }, firmware: { url: 'https://x/a' }, tests: { url: 'https://x/t' } },
59
+ j = jobs.create({ agentId: agent.id, source: 'cli', spec });
60
+ db.prepare('INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, 1, ?, \'runner\', ?)').run(j.id, NOW.toISOString(), 'x'.repeat(2000));
61
+ if (finishedAt){
62
+ jobs.setState(j.id, JOB_STATES.PASSED);
63
+ db.prepare('UPDATE jobs SET finished_at = ? WHERE id = ?').run(finishedAt, j.id);
64
+ }
65
+ return j.id;
66
+ }
67
+ const exists = (id) => !!jobs.get(id),
68
+ eventAt = (entity, ts) => db.prepare('INSERT INTO events (ts, entity, entity_id, type) VALUES (?, ?, \'x\', \'t\')').run(ts, entity).lastInsertRowid;
69
+ return { db, jobs, cleanup, job, exists, eventAt };
70
+ }
71
+
72
+ test('retentionCutoff: days, calendar months, and forever', () => {
73
+ assert.equal(retentionCutoff('1w', NOW).toISOString(), '2026-09-21T12:00:00.000Z');
74
+ assert.equal(retentionCutoff('2w', NOW).toISOString(), '2026-09-14T12:00:00.000Z');
75
+ assert.equal(retentionCutoff('1m', NOW).toISOString(), '2026-08-28T12:00:00.000Z');
76
+ assert.equal(retentionCutoff('6m', NOW).toISOString(), '2026-03-28T12:00:00.000Z');
77
+ assert.equal(retentionCutoff('forever', NOW), null);
78
+ assert.throws(() => retentionCutoff('1y', NOW), /must be one of/);
79
+ });
80
+
81
+ test('cleanup wipes finished jobs and history before the cutoff, keeps the rest, and compacts the file', () => {
82
+ const { db, cleanup, job, exists, eventAt } = setup(),
83
+ old = Array.from({ length: 40 }, () => job('2026-01-10T00:00:00Z')),
84
+ recent = job('2026-09-27T00:00:00Z'),
85
+ active = job(null),
86
+ oldResourceEvent = eventAt('resource', '2026-01-01T00:00:00Z'),
87
+ newResourceEvent = eventAt('resource', '2026-09-27T00:00:00Z'),
88
+
89
+ r = cleanup.cleanup({ before: new Date('2026-06-01T00:00:00Z') });
90
+ assert.equal(r.jobs, 40);
91
+ assert.ok(old.every((id) => !exists(id)));
92
+ assert.ok(exists(recent) && exists(active));
93
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM job_logs').get().n, 2); // recent + active only
94
+ const eventIds = db.prepare('SELECT id FROM events').all().map((e) => e.id);
95
+ assert.ok(!eventIds.includes(oldResourceEvent) && eventIds.includes(newResourceEvent));
96
+ assert.equal(r.vacuumed, true);
97
+ assert.ok(r.bytesAfter < r.bytesBefore, `${r.bytesAfter} < ${r.bytesBefore}`);
98
+ });
99
+
100
+ test('an old but still active job is never wiped, nor its events', () => {
101
+ const { db, cleanup, job, exists } = setup(),
102
+ active = job(null);
103
+ db.prepare('UPDATE jobs SET created_at = ? WHERE id = ?').run('2020-01-01T00:00:00Z', active);
104
+ db.prepare('UPDATE events SET ts = ? WHERE entity_id = ?').run('2020-01-01T00:00:00Z', active);
105
+ cleanup.cleanup({ before: NOW });
106
+ assert.ok(exists(active));
107
+ assert.ok(db.prepare('SELECT COUNT(*) AS n FROM events WHERE entity_id = ?').get(active).n > 0);
108
+ });
109
+
110
+ test('the retention task wipes per jobRetention, and does nothing for forever', () => {
111
+ const kept = setup('forever'),
112
+ id = kept.job('2025-01-01T00:00:00Z');
113
+ assert.equal(kept.cleanup.prune(), null);
114
+ assert.ok(kept.exists(id));
115
+
116
+ const pruned = setup('1m'),
117
+ tooOld = pruned.job('2026-08-01T00:00:00Z'),
118
+ young = pruned.job('2026-09-01T00:00:00Z'),
119
+ r = pruned.cleanup.prune();
120
+ assert.equal(r.before, '2026-08-28T12:00:00.000Z');
121
+ assert.ok(!pruned.exists(tooOld) && pruned.exists(young));
122
+ assert.equal(r.vacuumed, true); // first automatic run: compacts
123
+ pruned.job('2026-08-02T00:00:00Z');
124
+ assert.equal(pruned.cleanup.prune().vacuumed, false); // at most once a day
125
+ });
@@ -525,6 +525,28 @@ test('a removed Client re-registers as a fresh resource under the same name', ()
525
525
  assert.equal(again.name, 'lab-back');
526
526
  });
527
527
 
528
+ test('a job-supplied Docker image only goes to SW Clients that allow it; otherwise 422', () => {
529
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
530
+ { agent } = agents.create({ name: 'dev', kind: 'cli' }),
531
+ imageJob = { target: { type: 'sw', labels: [] }, firmware: { image: 'alpine' }, tests: { url: 'https://x/t.tar.gz' } },
532
+ strict = registerResource(registry, { name: 'sw-strict', type: 'sw' });
533
+ registry.heartbeat(strict.id, { state: 'idle' });
534
+
535
+ assert.throws(() => jobs.create({ agentId: agent.id, source: 'cli', spec: imageJob }), /allowJobImages/);
536
+
537
+ const { resourceId } = registry.registerAuto({
538
+ clientId: 'c-open', name: 'sw-open', type: 'sw', labels: [], capabilities: { sw: { image: 'emu', allowJobImages: true } }
539
+ });
540
+ registry.heartbeat(resourceId, { state: 'idle' });
541
+ const job = jobs.create({ agentId: agent.id, source: 'cli', spec: imageJob });
542
+ scheduler.runPass();
543
+ assert.equal(jobs.get(job.id).resource_id, resourceId); // not sw-strict, though it's idle too
544
+
545
+ const fileJob = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
546
+ scheduler.runPass();
547
+ assert.equal(jobs.get(fileJob.id).resource_id, strict.id); // firmware URLs still go anywhere
548
+ });
549
+
528
550
  test('a resource can belong to several groups at once', () => {
529
551
  const { registry, groups } = buildTestServices(),
530
552
  g1 = groups.create({ name: 'g1' }),
@@ -9,7 +9,7 @@ block content
9
9
  if canManage
10
10
  .btn-group
11
11
  button.btn.btn-sm.btn-outline-warning(type="button" data-bs-toggle="modal" data-bs-target="#reset-queue-modal") Reset queue
12
- button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean history
12
+ button.btn.btn-sm.btn-outline-danger.ms-1(type="button" data-bs-toggle="modal" data-bs-target="#clean-history-modal") Clean up database
13
13
 
14
14
  form.row.g-2.mb-3(method="get")
15
15
  .col-auto
@@ -90,22 +90,57 @@ block content
90
90
 
91
91
  +confirmModal({
92
92
  id: 'clean-history-modal',
93
+ formId: 'cleanup-form',
93
94
  tone: 'danger',
94
95
  icon: 'bi-trash3-fill',
95
- title: 'Clean job history?',
96
+ title: 'Clean up the database?',
96
97
  action: '/jobs/clean-history',
97
- confirmLabel: 'Delete finished jobs',
98
- confirmIcon: 'bi-trash3',
99
- disabled: !jobCounts.finished
98
+ confirmLabel: 'Delete and compact',
99
+ confirmIcon: 'bi-trash3'
100
100
  })
101
- if jobCounts.finished
102
- p
103
- | This permanently deletes
101
+ p.small.text-body-secondary
102
+ | Database size:
103
+ |
104
+ strong= cleanupInfo.size
105
+ | . Finished jobs:
106
+ |
107
+ strong= jobCounts.finished
108
+ if cleanupInfo.oldestFinished
109
+ | (oldest from #{cleanupInfo.oldestFinished})
110
+ | .
111
+ label.form-label(for="cleanup-before") Delete everything before
112
+ input.form-control#cleanup-before(
113
+ type="text"
114
+ name="before"
115
+ form="cleanup-form"
116
+ value=cleanupInfo.now
117
+ required
118
+ pattern="\\s*\\d{1,2}/\\d{1,2}/\\d{4}( +\\d{1,2}:\\d{2}(:\\d{2})?)?\\s*"
119
+ placeholder="dd/mm/yyyy HH:MM:SS"
120
+ aria-describedby="cleanup-before-help"
121
+ )
122
+ .form-text#cleanup-before-help
123
+ | dd/mm/yyyy HH:MM:SS, 24-hour, in your time zone (#{userTimeZone}).
124
+ .d-flex.flex-wrap.gap-1.mt-2.mb-3
125
+ each preset in cleanupInfo.presets
126
+ button.btn.btn-sm.btn-outline-secondary(
127
+ type="button"
128
+ data-value=preset.value
129
+ onclick="document.getElementById('cleanup-before').value = this.dataset.value"
130
+ )= preset.label
131
+ p
132
+ | Deletes finished jobs that ended before then, with their logs and artifacts (files too), and the resource, agent and group history logged before then — then compacts the database file.
133
+ |
134
+ strong This cannot be undone.
135
+ | Active jobs are never touched.
136
+ p.small.text-body-secondary.mb-0
137
+ i.bi.bi-clock-history.me-1
138
+ if cleanupInfo.retention === 'forever'
139
+ | Automatic cleanup is off: finished jobs are kept indefinitely. Set
104
140
  |
105
- strong= `${jobCounts.finished} finished job${jobCounts.finished === 1 ? '' : 's'}`
106
- | together with their logs and artifacts (database rows and files on disk).
107
- p.mb-0
108
- strong This cannot be undone.
109
- | Active jobs are not touched.
110
- else
111
- p.mb-0 There are no finished jobs — nothing to delete.
141
+ code retention.jobRetention
142
+ | in coordinator.json (1w, 2w, 1m, 3m, 6m) to wipe older ones every hour.
143
+ else
144
+ | Automatic cleanup: finished jobs are kept for #{cleanupInfo.retentionLabel} (
145
+ code retention.jobRetention
146
+ | ) and older ones are wiped every hour.
@@ -3,7 +3,8 @@
3
3
  //- data-bs-target="#<opts.id>" (no JS needed). The block is the body text.
4
4
  //- opts: id, title, action, confirmLabel, and optional tone (Bootstrap
5
5
  //- color, default danger), icon / confirmIcon (bootstrap-icons classes),
6
- //- disabled (nothing to do — the confirm button is disabled).
6
+ //- disabled (nothing to do — the confirm button is disabled), formId (id of
7
+ //- the footer form, so inputs in the body can join it with form=<formId>).
7
8
  mixin confirmModal(opts)
8
9
  - const tone = opts.tone || 'danger'
9
10
  .modal.fade(id=opts.id tabindex="-1" aria-labelledby=`${opts.id}-title` aria-hidden="true")
@@ -19,7 +20,7 @@ mixin confirmModal(opts)
19
20
  block
20
21
  .modal-footer
21
22
  button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Keep
22
- form(method="post" action=opts.action)
23
+ form(method="post" action=opts.action id=opts.formId)
23
24
  button.btn(type="submit" class=`btn-${tone}` disabled=opts.disabled)
24
25
  if opts.confirmIcon
25
26
  i.bi.me-1(class=opts.confirmIcon)