@andrian.yablonskyy/thub-coordinator 1.0.16 → 1.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
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": {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file packages/coordinator/public/js/remove-resource.js
3
+ * @description Dashboard: warning modal for removing a resource that is running a job (stop job, then remove)
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
+ (function (){
17
+ const modalEl = document.getElementById('remove-resource-modal');
18
+ if (!modalEl){
19
+ return;
20
+ }
21
+ const field = (name) => modalEl.querySelector(`[data-field="${name}"]`);
22
+
23
+ document.querySelectorAll('[data-remove-resource]').forEach((btn) => {
24
+ btn.addEventListener('click', () => {
25
+ const { removeResource: id, removeName: name, removeJob: job, removeReturn: returnTo } = btn.dataset;
26
+ field('name').textContent = name;
27
+ field('job').textContent = job;
28
+ field('job').href = `/jobs/${encodeURIComponent(job)}`;
29
+ field('job-text').textContent = job;
30
+ field('form').action = `/resources/${encodeURIComponent(id)}/remove`;
31
+ field('return').value = returnTo || '/resources';
32
+
33
+ // Clicked from inside a resource card: close that first — Bootstrap
34
+ // doesn't stack modals.
35
+ const card = btn.closest('.modal');
36
+ if (card){
37
+ card.addEventListener('hidden.bs.modal', () => bootstrap.Modal.getOrCreateInstance(modalEl).show(), { once: true });
38
+ bootstrap.Modal.getInstance(card)?.hide();
39
+ }
40
+ else {
41
+ bootstrap.Modal.getOrCreateInstance(modalEl).show();
42
+ }
43
+ });
44
+ });
45
+ })();
package/src/api/admin.js CHANGED
@@ -60,6 +60,21 @@ function createAdminRouter({ services }){
60
60
  res.json({ resourceToken: token });
61
61
  });
62
62
 
63
+ // Removes a resource; 409 while it runs a job unless ?stopJob=1, which
64
+ // cancels the job and removes the resource once the Client confirms
65
+ // (`pending: true`). Queued jobs pinned to it are canceled on removal.
66
+ router.delete('/resources/:id', (req, res, next) => {
67
+ try {
68
+ const { resource, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
69
+ stopJob: req.query.stopJob === '1'
70
+ });
71
+ res.json({ removed: resource.id, pending, stoppedJob, canceledJobs });
72
+ }
73
+ catch (err){
74
+ next(err);
75
+ }
76
+ });
77
+
63
78
  // Cancels every QUEUED/ASSIGNED/PREPARING/RUNNING job (§13.1).
64
79
  router.post('/jobs/reset-queue', (req, res) => {
65
80
  const canceled = services.jobs.resetQueue();
package/src/api/agent.js CHANGED
@@ -28,7 +28,11 @@ function createAgentRouter({ services, config }){
28
28
 
29
29
  router.post('/jobs', auth, (req, res, next) => {
30
30
  try {
31
- const job = services.jobs.create({ agentId: req.agent.id, source: req.body.source || 'cli', spec: req.body });
31
+ // The job source (A-/M- id series, default priority) is the agent
32
+ // token's admin-assigned kind alone — never taken from the request,
33
+ // so it can't be spoofed or mis-detected on the Agent side.
34
+ const source = req.agent.kind,
35
+ job = services.jobs.create({ agentId: req.agent.id, source, spec: req.body });
32
36
  res.status(201).json({ jobId: job.id, state: job.state, webUrl: `${config.publicUrl}/jobs/${job.id}` });
33
37
  }
34
38
  catch (err){
@@ -42,7 +46,10 @@ function createAgentRouter({ services, config }){
42
46
  return res.status(404).json({ error: 'Unknown job' });
43
47
  }
44
48
  const resource = job.resource_id ? services.registry.get(job.resource_id) : null;
45
- res.json({ ...job, resource: resource ? { id: resource.id, name: resource.name } : null });
49
+ res.json({
50
+ ...job,
51
+ resource: resource ? { id: resource.id, name: resource.name } : job.resource_name ? { id: null, name: job.resource_name } : null
52
+ });
46
53
  });
47
54
 
48
55
  router.get('/jobs', auth, (req, res) => {
@@ -103,6 +103,12 @@ function createResourceRouter({ services, config }){
103
103
  if (updateTo){
104
104
  commands.push({ command: 'self-update', version: updateTo });
105
105
  }
106
+ // Removal pending (jobs.removeResource): once the Client reports its
107
+ // job stopped, finish it. This response still goes out; the next
108
+ // heartbeat gets 401, the token being gone with the resource.
109
+ if (req.resource.remove_requested_at && !activeJobId){
110
+ services.jobs.completeRemoval(req.params.id, { by: 'removal-confirmed' });
111
+ }
106
112
  res.json({ serverTime: new Date().toISOString(), commands });
107
113
  }
108
114
  catch (err){
@@ -0,0 +1,4 @@
1
+ -- Name of the resource a job ran on, kept once that resource is removed
2
+ -- from the dashboard (registry.remove): jobs.resource_id is then NULLed
3
+ -- (it's a foreign key), and this is what the job's history shows instead.
4
+ ALTER TABLE jobs ADD COLUMN resource_name TEXT;
@@ -0,0 +1,5 @@
1
+ -- Set when an admin removes a resource that is running a job: the job is
2
+ -- canceled right away, and the resource itself is deleted once its Client
3
+ -- confirms the job stopped (next heartbeat with no active job) or after a
4
+ -- timeout (heartbeat.js). Meanwhile the scheduler never assigns it work.
5
+ ALTER TABLE resources ADD COLUMN remove_requested_at TEXT;
@@ -0,0 +1,9 @@
1
+ -- How long a finished job ran (README §9): seconds from started_at (RUNNING)
2
+ -- to finished_at, computed once when it reaches a terminal state
3
+ -- (jobs.setState) and shown instead of the finish time. NULL for a job
4
+ -- that never ran (e.g. canceled while queued). finished_at itself stays:
5
+ -- log/artifact retention is keyed on it. Backfilled for existing jobs.
6
+ ALTER TABLE jobs ADD COLUMN duration_sec INTEGER;
7
+ UPDATE jobs
8
+ SET duration_sec = MAX(0, CAST(ROUND((julianday(finished_at) - julianday(started_at)) * 86400) AS INTEGER))
9
+ WHERE finished_at IS NOT NULL AND started_at IS NOT NULL;
@@ -47,10 +47,31 @@ function createHeartbeatMonitor(db, { bus, events, registry, jobs, config }){
47
47
  }
48
48
  }
49
49
 
50
- const timer = setInterval(sweepOnce, config.heartbeat.sweepIntervalSec * 1000);
50
+ // Resources an admin removed while they ran a job (jobs.removeResource):
51
+ // normally deleted by the heartbeat route once the Client reports the job
52
+ // stopped; this covers a Client that's gone (OUT_OF_SERVICE) or never
53
+ // confirms — after the time a cancel plus the runner's kill grace needs.
54
+ function sweepRemovals(){
55
+ const timeoutMs = Math.max(60, 6 * config.heartbeat.intervalSec) * 1000;
56
+ for (const r of registry.pendingRemovals()){
57
+ if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || Date.now() - new Date(r.remove_requested_at).getTime() >= timeoutMs){
58
+ try {
59
+ jobs.completeRemoval(r.id, { by: 'removal-timeout' });
60
+ }
61
+ catch (err){
62
+ console.error(`removing ${r.name}: ${err.message}`);
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ const timer = setInterval(() => {
69
+ sweepOnce();
70
+ sweepRemovals();
71
+ }, config.heartbeat.sweepIntervalSec * 1000);
51
72
  timer.unref?.();
52
73
 
53
- return { sweepOnce, stop: () => clearInterval(timer) };
74
+ return { sweepOnce, sweepRemovals, stop: () => clearInterval(timer) };
54
75
  }
55
76
 
56
77
  module.exports = { createHeartbeatMonitor };
@@ -30,7 +30,10 @@ function rowToJob(row){
30
30
  // developer's own machine — separate series (each own counter row) rather
31
31
  // than a shared counter with just a different letter, so e.g. A-00042
32
32
  // doesn't imply 41 manual runs happened first.
33
- const JOB_ID_PREFIXES = { ci: 'A', cli: 'M' };
33
+ const JOB_ID_PREFIXES = { ci: 'A', cli: 'M' },
34
+ // Default priority when the spec doesn't set one (§7.1): a developer's
35
+ // manual run jumps ahead of CI's queue.
36
+ DEFAULT_PRIORITY = { ci: 50, cli: 60 };
34
37
 
35
38
  function nextJobId(db, source){
36
39
  const prefix = JOB_ID_PREFIXES[source];
@@ -85,17 +88,30 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
85
88
  // Agent: POST /jobs (§6.1, §4.3). Rejects unsatisfiable label requests
86
89
  // immediately (§5.4) instead of letting them starve in the queue.
87
90
  function create({ agentId, source, spec: rawSpec }){
88
- const { valid, spec, errors } = validateJobSpec(rawSpec);
91
+ const { valid, spec, errors } = validateJobSpec(
92
+ rawSpec?.priority === undefined ? { ...rawSpec, priority: DEFAULT_PRIORITY[source] } : rawSpec
93
+ );
89
94
  if (!valid){
90
95
  throw Object.assign(new Error(`Invalid job spec: ${errors.join('; ')}`), { status: 400 });
91
96
  }
92
97
  spec.source = source;
93
98
 
94
- if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group)){
99
+ // `target.client` (thub run --client) accepts a resource id or name;
100
+ // store the id so the job stays pinned to the same Client across renames.
101
+ if (spec.target.client){
102
+ const resource = registry.get(spec.target.client) || registry.getByName(spec.target.client);
103
+ if (!resource){
104
+ throw Object.assign(new Error(`Unknown client "${spec.target.client}"`), { status: 422 });
105
+ }
106
+ spec.target.client = resource.id;
107
+ }
108
+
109
+ if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group, spec.target.client)){
95
110
  throw Object.assign(
96
111
  new Error(
97
112
  `No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
98
- (spec.target.group ? ` group=${spec.target.group}` : '')
113
+ (spec.target.group ? ` group=${spec.target.group}` : '') +
114
+ (spec.target.client ? ` client=${spec.target.client}` : '')
99
115
  ),
100
116
  { status: 422 }
101
117
  );
@@ -141,8 +157,10 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
141
157
  params.push(now);
142
158
  }
143
159
  if (TERMINAL_JOB_STATES.has(state)){
144
- fields.push('finished_at = ?');
145
- params.push(now);
160
+ // Duration is computed once, here, and stored — never derived at
161
+ // display time. Measured from started_at (RUNNING), like the timeout.
162
+ fields.push('finished_at = ?', 'duration_sec = ?');
163
+ params.push(now, job.started_at ? Math.max(0, Math.round((Date.parse(now) - Date.parse(job.started_at)) / 1000)) : null);
146
164
  }
147
165
  if (extra.message !== undefined){
148
166
  fields.push('message = ?');
@@ -157,9 +175,11 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
157
175
  params.push(JSON.stringify(extra.summary));
158
176
  }
159
177
  if (state === JOB_STATES.QUEUED){
160
- // Requeue: drop the previous assignment so the scheduler treats it fresh.
161
- fields.push('resource_id = ?', 'assigned_at = ?', 'attempt = attempt + 1');
162
- params.push(null, null);
178
+ // Requeue: drop the previous assignment (and its start time, so the
179
+ // next attempt's duration/timeout count from its own start) so the
180
+ // scheduler treats it fresh.
181
+ fields.push('resource_id = ?', 'assigned_at = ?', 'started_at = ?', 'attempt = attempt + 1');
182
+ params.push(null, null, null);
163
183
  }
164
184
 
165
185
  params.push(id);
@@ -202,6 +222,44 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
202
222
  return setState(id, JOB_STATES.CANCELED);
203
223
  }
204
224
 
225
+ // Queued jobs pinned to a resource (`target.client`) can never run once it's
226
+ // removed, so they're canceled rather than left in the queue forever.
227
+ function cancelPinnedTo(resourceId, message){
228
+ const queued = db.prepare('SELECT id, spec FROM jobs WHERE state = ?').all(JOB_STATES.QUEUED)
229
+ .filter((row) => JSON.parse(row.spec).target?.client === resourceId);
230
+ for (const { id }of queued){
231
+ setState(id, JOB_STATES.CANCELED, { message });
232
+ }
233
+ return queued.map((row) => row.id);
234
+ }
235
+
236
+ // Admin "Remove" (dashboard, DELETE /admin/resources/:id). A resource
237
+ // that's running a job needs `stopJob`: the job is canceled at once, but
238
+ // the Client only hears that in its next heartbeat's commands — so the
239
+ // resource (and with it the Client's token) is deleted only after that
240
+ // (completeRemoval, from the heartbeat route or the sweeper's timeout).
241
+ function removeResource(resourceId, { by, stopJob = false } = {}){
242
+ const r = registry.get(resourceId);
243
+ if (!r){
244
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
245
+ }
246
+ const job = activeForResource(resourceId);
247
+ if (job && !stopJob){
248
+ throw Object.assign(new Error(`${r.name} is running job ${job.id}`), { status: 409, jobId: job.id });
249
+ }
250
+ if (job){
251
+ cancel(job.id, { isAdmin: true });
252
+ registry.requestRemoval(resourceId, { by });
253
+ return { resource: r, pending: true, stoppedJob: job.id, canceledJobs: [] };
254
+ }
255
+ return { resource: r, pending: false, stoppedJob: null, canceledJobs: completeRemoval(resourceId, { by }) };
256
+ }
257
+
258
+ function completeRemoval(resourceId, { by } = {}){
259
+ const r = registry.remove(resourceId, { by });
260
+ return cancelPinnedTo(resourceId, `Client ${r.name} was removed`);
261
+ }
262
+
205
263
  // The job a resource is working on (ASSIGNED through RUNNING), if any —
206
264
  // for the resource card's Cancel button.
207
265
  function activeForResource(resourceId){
@@ -351,6 +409,9 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
351
409
  create,
352
410
  setState,
353
411
  cancel,
412
+ cancelPinnedTo,
413
+ removeResource,
414
+ completeRemoval,
354
415
  activeForResource,
355
416
  applyResult,
356
417
  requeueUnacked,
@@ -14,7 +14,7 @@
14
14
  'use strict';
15
15
 
16
16
  const { v4: uuid } = require('uuid'),
17
- { RESOURCE_STATES, BUSY_SOURCES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
17
+ { RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions } = require('@andrian.yablonskyy/thub-common'),
18
18
  { generateToken, hashToken } = require('./tokens');
19
19
 
20
20
  function rowToResource(row){
@@ -468,21 +468,58 @@ function createRegistryService(db, { bus, events }){
468
468
  );
469
469
  }
470
470
 
471
- function findIdleCandidates(type, labels, groupId){
471
+ function matchesTarget(r, labels, groupId, resourceId){
472
+ return labels.every((l) => r.labels.includes(l)) &&
473
+ (!groupId || r.group_ids.includes(groupId)) &&
474
+ (!resourceId || r.id === resourceId);
475
+ }
476
+
477
+ // Admin: remove a resource from the registry (dashboard "Remove"). Refused
478
+ // while it holds an active job — cancel that first. Its finished jobs stay,
479
+ // keeping the resource's name (jobs.resource_name) in place of the id.
480
+ // A Client that's still running reappears when it next re-registers.
481
+ function remove(resourceId, { by } = {}){
482
+ const r = get(resourceId);
483
+ if (!r){
484
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
485
+ }
486
+ const placeholders = [...ACTIVE_JOB_STATES].map(() => '?').join(','),
487
+ active = db
488
+ .prepare(`SELECT id FROM jobs WHERE resource_id = ? AND state IN (${placeholders}) LIMIT 1`)
489
+ .get(resourceId, ...ACTIVE_JOB_STATES);
490
+ if (active){
491
+ throw Object.assign(new Error(`${r.name} is working on job ${active.id} — cancel it first`), { status: 409 });
492
+ }
493
+ db.transaction(() => {
494
+ db.prepare('UPDATE jobs SET resource_name = ?, resource_id = NULL WHERE resource_id = ?').run(r.name, resourceId);
495
+ db.prepare('DELETE FROM resources WHERE id = ?').run(resourceId);
496
+ })();
497
+ events.record('resource', resourceId, 'resource.removed', { name: r.name, by });
498
+ return r;
499
+ }
500
+
501
+ // Step one of removing a resource that's running a job (jobs.removeResource):
502
+ // it takes no new work from here on and is deleted once the job stopped.
503
+ function requestRemoval(resourceId, { by } = {}){
504
+ db.prepare('UPDATE resources SET remove_requested_at = ? WHERE id = ?').run(new Date().toISOString(), resourceId);
505
+ events.record('resource', resourceId, 'resource.removal_requested', { by });
506
+ }
507
+
508
+ function pendingRemovals(){
509
+ return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
510
+ }
511
+
512
+ function findIdleCandidates(type, labels, groupId, resourceId){
472
513
  const rows = db
473
- .prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
514
+ .prepare('SELECT * FROM resources WHERE status = ? AND type = ? AND remove_requested_at IS NULL')
474
515
  .all(RESOURCE_STATES.IDLE, type)
475
516
  .map(rowToResource);
476
- return rows.filter(
477
- (r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
478
- );
517
+ return rows.filter((r) => matchesTarget(r, labels, groupId, resourceId));
479
518
  }
480
519
 
481
- function everSatisfiable(type, labels, groupId){
520
+ function everSatisfiable(type, labels, groupId, resourceId){
482
521
  const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
483
- return rows.some(
484
- (r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
485
- );
522
+ return rows.some((r) => matchesTarget(r, labels, groupId, resourceId));
486
523
  }
487
524
 
488
525
  return {
@@ -502,6 +539,9 @@ function createRegistryService(db, { bus, events }){
502
539
  requestUpdateAll,
503
540
  pendingUpdate,
504
541
  assignToJob,
542
+ remove,
543
+ requestRemoval,
544
+ pendingRemovals,
505
545
  findIdleCandidates,
506
546
  everSatisfiable
507
547
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/src/services/scheduler.js
3
- * @description Scheduler: matches queued jobs to idle resources by type/labels/group (README §5.4)
3
+ * @description Scheduler: matches queued jobs to idle resources by type/labels/group/client (README §5.4)
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -34,21 +34,28 @@ function createScheduler(db, { bus, events, registry, config }){
34
34
  .map(rowToJob),
35
35
 
36
36
  assignments = [],
37
- claimedResourceIds = new Set();
37
+ claimedResourceIds = new Set(),
38
+ // Resources that some queued job is pinned to (`target.client`) —
39
+ // an unpinned job only takes one of these when nothing else fits,
40
+ // so it doesn't jump the pinned job's per-client queue.
41
+ reservedResourceIds = new Set(queued.map((j) => j.spec.target.client).filter(Boolean));
38
42
 
39
43
  for (const job of queued){
40
- const candidates = registry
41
- .findIdleCandidates(job.spec.target.type, job.spec.target.labels || [], job.spec.target.group)
42
- .filter((r) => !claimedResourceIds.has(r.id));
44
+ const { type, labels = [], group, client } = job.spec.target,
45
+ candidates = registry
46
+ .findIdleCandidates(type, labels, group, client)
47
+ .filter((r) => !claimedResourceIds.has(r.id));
43
48
  if (candidates.length === 0){
44
49
  continue;
45
50
  }
46
51
 
47
- // Least-recently-used: spread wear across benches (§5.4).
52
+ // Unreserved first, then least-recently-used: spread wear across benches (§5.4).
48
53
  candidates.sort((a, b) => {
49
- const ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
54
+ const ra = reservedResourceIds.has(a.id) ? 1 : 0,
55
+ rb = reservedResourceIds.has(b.id) ? 1 : 0,
56
+ ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
50
57
  tb = b.last_job_finished_at ? new Date(b.last_job_finished_at).getTime() : 0;
51
- return ta - tb;
58
+ return ra - rb || ta - tb;
52
59
  });
53
60
  const resource = candidates[0];
54
61
  claimedResourceIds.add(resource.id);
package/src/web/routes.js CHANGED
@@ -284,6 +284,26 @@ function createWebRouter({ services, config }){
284
284
  res.redirect(returnTo(req, '/resources'));
285
285
  });
286
286
 
287
+ router.post('/resources/:id/remove', requireAdminRole, (req, res) => {
288
+ try {
289
+ const { resource: r, pending, stoppedJob, canceledJobs } = services.jobs.removeResource(req.params.id, {
290
+ by: req.session.user.username,
291
+ stopJob: req.body.stopJob === '1'
292
+ });
293
+ flash(
294
+ req,
295
+ 'warning',
296
+ pending
297
+ ? `Stopped job ${stoppedJob} on ${r.name}; ${r.name} is removed as soon as its Client confirms the job ended.`
298
+ : `Removed ${r.name}.` + (canceledJobs.length ? ` Canceled job(s) queued for it: ${canceledJobs.join(', ')}.` : '')
299
+ );
300
+ }
301
+ catch (err){
302
+ flash(req, 'danger', err.message);
303
+ }
304
+ res.redirect(returnTo(req, '/resources'));
305
+ });
306
+
287
307
  router.post('/resources/:id/rotate-token', requireAdminRole, (req, res) => {
288
308
  const { generateToken, hashToken } = require('../services/tokens');
289
309
  const token = generateToken('res');
@@ -395,13 +415,19 @@ function createWebRouter({ services, config }){
395
415
  const filters = { state: req.query.state || undefined, source: req.query.source || undefined },
396
416
  jobs = services.jobs.list({ ...filters, limit: 200 }).map((j) => ({
397
417
  ...j,
398
- resource: j.resource_id ? services.registry.get(j.resource_id) : null
418
+ resource: j.resource_id ? services.registry.get(j.resource_id) : null,
419
+ active: ACTIVE_JOB_STATES.has(j.state)
399
420
  }));
400
421
  res.render('jobs/list', {
401
422
  title: 'Jobs',
402
423
  active: 'jobs',
403
424
  jobs,
404
425
  filters,
426
+ // Keeps the list's filters across a row's Cancel.
427
+ filterQuery: (() => {
428
+ const q = new URLSearchParams(Object.entries(filters).filter(([, v]) => v)).toString();
429
+ return q ? `?${q}` : '';
430
+ })(),
405
431
  canManage: req.session.user.role === 'admin'
406
432
  });
407
433
  });
@@ -433,15 +459,23 @@ function createWebRouter({ services, config }){
433
459
  title: job.id,
434
460
  active: 'jobs',
435
461
  job,
462
+ pinnedClient: job.spec.target.client ? services.registry.get(job.spec.target.client) : null,
436
463
  artifacts,
437
464
  jobActive,
438
465
  canCancel: req.session.user.role === 'admin' && jobActive
439
466
  });
440
467
  });
441
468
 
469
+ // From the job page or a row on /jobs (returnTo says which).
442
470
  router.post('/jobs/:id/cancel', requireAdminRole, (req, res) => {
443
- services.jobs.cancel(req.params.id, { isAdmin: true });
444
- res.redirect(`/jobs/${req.params.id}`);
471
+ try {
472
+ services.jobs.cancel(req.params.id, { isAdmin: true });
473
+ flash(req, 'warning', `Canceled job ${req.params.id}.`);
474
+ }
475
+ catch (err){
476
+ flash(req, 'danger', err.message);
477
+ }
478
+ res.redirect(returnTo(req, `/jobs/${req.params.id}`));
445
479
  });
446
480
 
447
481
  // Plain, one-shot fetch — used by the log viewer for a job that's
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/test/scheduler.test.js
3
- * @description Tests: scheduler, registry, groups, job id allocation, and admin queue operations
3
+ * @description Tests: scheduler, registry, groups, client pinning, job id allocation, and admin queue operations
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -157,6 +157,34 @@ test('job ids are split by source: A-##### for ci, M-##### for cli', () => {
157
157
  assert.equal(Number(ciJob2.id.slice(2)), Number(ciJob.id.slice(2)) + 1);
158
158
  });
159
159
 
160
+ test('default priority follows the job source; an explicit one is kept', () => {
161
+ const { agents, registry, jobs } = buildTestServices(),
162
+ { agent } = agents.create({ name: 'ci', kind: 'ci' });
163
+ registerResource(registry, { name: 'lab-sw-01', type: 'sw' });
164
+
165
+ assert.equal(jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec() }).priority, 50);
166
+ assert.equal(jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }).priority, 60);
167
+ assert.equal(jobs.create({ agentId: agent.id, source: 'ci', spec: makeSpec({ priority: 90 }) }).priority, 90);
168
+ });
169
+
170
+ test('duration is stored once when a job finishes, from its start; none if it never ran', () => {
171
+ const { registry, agents, jobs, scheduler, db } = buildTestServices(),
172
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
173
+ r = registerResource(registry, { name: 'lab-sw-01', type: 'sw' });
174
+ registry.heartbeat(r.id, { state: 'idle' });
175
+
176
+ const ran = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
177
+ scheduler.runPass();
178
+ jobs.setState(ran.id, JOB_STATES.RUNNING);
179
+ db.prepare('UPDATE jobs SET started_at = ? WHERE id = ?').run(new Date(Date.now() - 125e3).toISOString(), ran.id);
180
+ jobs.setState(ran.id, JOB_STATES.PASSED);
181
+ assert.equal(jobs.get(ran.id).duration_sec, 125);
182
+
183
+ const never = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
184
+ jobs.cancel(never.id, { isAdmin: true });
185
+ assert.equal(jobs.get(never.id).duration_sec, null);
186
+ });
187
+
160
188
  test('admin resetQueue cancels every active job and leaves finished ones alone', () => {
161
189
  const { registry, agents, jobs, scheduler } = buildTestServices(),
162
190
  { agent } = agents.create({ name: 'ci', kind: 'ci' }),
@@ -349,6 +377,154 @@ test('a job with target.group is rejected (422) when no resource is ever a membe
349
377
  );
350
378
  });
351
379
 
380
+ test('a job with target.client (by name) waits for that client even when another matching one is idle', () => {
381
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
382
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
383
+ pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
384
+ other = registerResource(registry, { name: 'lab-other', type: 'sw' });
385
+ registry.heartbeat(other.id, { state: 'idle' }); // pinned is still REGISTERED, not IDLE
386
+
387
+ const job = jobs.create({
388
+ agentId: agent.id,
389
+ source: 'cli',
390
+ spec: makeSpec({ target: { type: 'sw', labels: [], client: 'lab-pinned' } })
391
+ });
392
+ assert.equal(job.spec.target.client, pinned.id); // name resolved to the stable resource id
393
+
394
+ scheduler.runPass();
395
+ assert.equal(jobs.get(job.id).state, JOB_STATES.QUEUED); // not grabbed by lab-other
396
+
397
+ registry.heartbeat(pinned.id, { state: 'idle' });
398
+ scheduler.runPass();
399
+ const assigned = jobs.get(job.id);
400
+ assert.equal(assigned.state, JOB_STATES.ASSIGNED);
401
+ assert.equal(assigned.resource_id, pinned.id);
402
+ });
403
+
404
+ test('an unpinned job prefers a resource no queued job is pinned to', () => {
405
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
406
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
407
+ pinned = registerResource(registry, { name: 'lab-pinned', type: 'sw' }),
408
+ other = registerResource(registry, { name: 'lab-other', type: 'sw' });
409
+ registry.heartbeat(pinned.id, { state: 'idle' });
410
+ registry.heartbeat(other.id, { state: 'idle' });
411
+
412
+ // Unpinned job has higher priority, so it's considered first; it must not
413
+ // take lab-pinned (which is also least-recently-used) away from the pinned job.
414
+ const unpinned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ priority: 90 }) }),
415
+ pinnedJob = jobs.create({
416
+ agentId: agent.id,
417
+ source: 'cli',
418
+ spec: makeSpec({ target: { type: 'sw', labels: [], client: pinned.id }, priority: 10 })
419
+ });
420
+ scheduler.runPass();
421
+
422
+ assert.equal(jobs.get(unpinned.id).resource_id, other.id);
423
+ assert.equal(jobs.get(pinnedJob.id).resource_id, pinned.id);
424
+ });
425
+
426
+ test('a job with target.client is rejected (422) when the client is unknown or cannot satisfy the target', () => {
427
+ const { registry, agents, jobs } = buildTestServices(),
428
+ { agent } = agents.create({ name: 'ci', kind: 'ci' });
429
+ registerResource(registry, { name: 'lab-sw', type: 'sw', labels: ['board:a'] });
430
+
431
+ assert.throws(
432
+ () => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: 'nope' } }) }),
433
+ /Unknown client "nope"/
434
+ );
435
+ assert.throws(
436
+ () => jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'hw', client: 'lab-sw' } }) }),
437
+ /No registered resource/
438
+ );
439
+ assert.throws(
440
+ () =>
441
+ jobs.create({
442
+ agentId: agent.id,
443
+ source: 'cli',
444
+ spec: makeSpec({ target: { type: 'sw', labels: ['board:b'], client: 'lab-sw' } })
445
+ }),
446
+ /No registered resource/
447
+ );
448
+ });
449
+
450
+ test('removing a resource keeps its job history by name and cancels jobs pinned to it', () => {
451
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
452
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
453
+ r = registerResource(registry, { name: 'lab-old', type: 'sw' });
454
+ registry.heartbeat(r.id, { state: 'idle' });
455
+
456
+ const done = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
457
+ scheduler.runPass();
458
+ assert.throws(() => registry.remove(r.id), /working on job/); // ASSIGNED: refused
459
+ jobs.setState(done.id, JOB_STATES.PASSED);
460
+ registry.heartbeat(r.id, { state: 'busy' }); // keep it from taking the pinned job below
461
+
462
+ const pinned = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec({ target: { type: 'sw', client: r.id } }) });
463
+ registry.remove(r.id);
464
+ assert.deepEqual(jobs.cancelPinnedTo(r.id, 'removed'), [pinned.id]);
465
+
466
+ assert.equal(registry.get(r.id), undefined);
467
+ const kept = jobs.get(done.id);
468
+ assert.equal(kept.resource_id, null);
469
+ assert.equal(kept.resource_name, 'lab-old');
470
+ assert.equal(jobs.get(pinned.id).state, JOB_STATES.CANCELED);
471
+ assert.throws(() => registry.remove(r.id), /Unknown resource/);
472
+ });
473
+
474
+ test('removing a resource running a job: stops the job now, removes once the Client confirms', () => {
475
+ const { registry, agents, jobs, scheduler } = buildTestServices(),
476
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
477
+ r = registerResource(registry, { name: 'lab-busy', type: 'sw' });
478
+ registry.heartbeat(r.id, { state: 'idle' });
479
+ const running = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
480
+ scheduler.runPass();
481
+ jobs.setState(running.id, JOB_STATES.RUNNING);
482
+
483
+ assert.throws(() => jobs.removeResource(r.id), /running job/); // needs stopJob
484
+
485
+ const res = jobs.removeResource(r.id, { stopJob: true });
486
+ assert.equal(res.pending, true);
487
+ assert.equal(res.stoppedJob, running.id);
488
+ assert.equal(jobs.get(running.id).state, JOB_STATES.CANCELED);
489
+ assert.ok(registry.get(r.id).remove_requested_at); // still there: the Client hasn't heard yet
490
+
491
+ // Pending removal takes no new work, even once it reports idle.
492
+ registry.heartbeat(r.id, { state: 'idle' });
493
+ const next = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
494
+ scheduler.runPass();
495
+ assert.equal(jobs.get(next.id).state, JOB_STATES.QUEUED);
496
+
497
+ jobs.completeRemoval(r.id); // what the heartbeat route does on the Client's "no active job"
498
+ assert.equal(registry.get(r.id), undefined);
499
+ assert.equal(jobs.get(running.id).resource_name, 'lab-busy');
500
+ });
501
+
502
+ test('the sweeper finishes a pending removal whose Client never confirms', () => {
503
+ const { registry, agents, jobs, scheduler, heartbeatMonitor, db } = buildTestServices(),
504
+ { agent } = agents.create({ name: 'ci', kind: 'ci' }),
505
+ r = registerResource(registry, { name: 'lab-silent', type: 'sw' });
506
+ registry.heartbeat(r.id, { state: 'idle' });
507
+ jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
508
+ scheduler.runPass();
509
+ jobs.removeResource(r.id, { stopJob: true });
510
+
511
+ heartbeatMonitor.sweepRemovals();
512
+ assert.ok(registry.get(r.id)); // not timed out yet
513
+
514
+ db.prepare('UPDATE resources SET remove_requested_at = ? WHERE id = ?').run(new Date(Date.now() - 3600e3).toISOString(), r.id);
515
+ heartbeatMonitor.sweepRemovals();
516
+ assert.equal(registry.get(r.id), undefined);
517
+ });
518
+
519
+ test('a removed Client re-registers as a fresh resource under the same name', () => {
520
+ const { registry } = buildTestServices(),
521
+ r = registerResource(registry, { name: 'lab-back', type: 'sw', clientId: 'c-back' });
522
+ registry.remove(r.id);
523
+ const again = registerResource(registry, { name: 'lab-back', type: 'sw', clientId: 'c-back' });
524
+ assert.notEqual(again.id, r.id);
525
+ assert.equal(again.name, 'lab-back');
526
+ });
527
+
352
528
  test('a resource can belong to several groups at once', () => {
353
529
  const { registry, groups } = buildTestServices(),
354
530
  g1 = groups.create({ name: 'g1' }),
package/views/index.pug CHANGED
@@ -30,7 +30,7 @@ block content
30
30
  .card-body
31
31
  h5.card-title= r.name
32
32
  p.card-text.mb-1
33
- +resourceBadge(r.status)
33
+ +resourceBadge(r.status, r.remove_requested_at)
34
34
  |
35
35
  = ' ' + r.type.toUpperCase()
36
36
  if r.busy_source
@@ -41,5 +41,9 @@ block content
41
41
  each r in resources
42
42
  +resourceCard(r, groupsById, '/')
43
43
 
44
+ if user && user.role === 'admin'
45
+ +removeResourceModal()
46
+
44
47
  block scripts
45
48
  script(src="/js/resource-card.js")
49
+ script(src="/js/remove-resource.js")
@@ -32,7 +32,9 @@ block content
32
32
  th State
33
33
  th Resource
34
34
  th Created
35
- th Finished
35
+ th Duration
36
+ if canManage
37
+ th
36
38
  tbody
37
39
  each j in jobs
38
40
  tr
@@ -40,6 +42,18 @@ block content
40
42
  td.text-uppercase= j.source
41
43
  td= j.spec.user || '—'
42
44
  td: +jobBadge(j.state)
43
- td= j.resource ? j.resource.name : '—'
45
+ td= j.resource ? j.resource.name : j.resource_name ? `${j.resource_name} (removed)` : '—'
44
46
  td= fmtDate(j.created_at)
45
- td= fmtDate(j.finished_at)
47
+ td(title=j.finished_at ? `Finished ${fmtDate(j.finished_at)}` : null)= j.duration_sec != null ? fmtDuration(j.duration_sec * 1000) : '—'
48
+ if canManage
49
+ td.text-end
50
+ if j.active
51
+ form.d-inline(
52
+ method="post"
53
+ action=`/jobs/${j.id}/cancel`
54
+ data-confirm=`Cancel job ${j.id}?`
55
+ onsubmit="return confirm(this.dataset.confirm)"
56
+ )
57
+ input(type="hidden" name="returnTo" value=`/jobs${filterQuery}`)
58
+ button.btn.btn-sm.btn-outline-danger(type="submit" aria-label="Cancel" title="Cancel this job")
59
+ i.bi.bi-x-circle
@@ -9,7 +9,7 @@ block content
9
9
  |
10
10
  +jobBadge(job.state)
11
11
  if canCancel
12
- form(method="post" action=`/jobs/${job.id}/cancel`)
12
+ form(method="post" action=`/jobs/${job.id}/cancel` onsubmit=`return confirm('Cancel job ${job.id}?')`)
13
13
  button.btn.btn-outline-danger.btn-sm(type="submit") Cancel
14
14
 
15
15
  dl.row
@@ -19,11 +19,17 @@ block content
19
19
  dt.col-sm-2 User
20
20
  dd.col-sm-10= job.spec.user
21
21
  dt.col-sm-2 Resource
22
- dd.col-sm-10= job.resource ? job.resource.name : '—'
22
+ dd.col-sm-10= job.resource ? job.resource.name : job.resource_name ? `${job.resource_name} (removed)` : '—'
23
23
  dt.col-sm-2 Target
24
24
  dd.col-sm-10= `${job.spec.target.type} — ${(job.spec.target.labels || []).join(', ')}`
25
+ if job.spec.target.client
26
+ dt.col-sm-2 Client
27
+ dd.col-sm-10= pinnedClient ? pinnedClient.name : job.spec.target.client
25
28
  dt.col-sm-2 Created
26
29
  dd.col-sm-10= fmtDate(job.created_at)
30
+ if job.duration_sec != null
31
+ dt.col-sm-2 Duration
32
+ dd.col-sm-10(title=`Finished ${fmtDate(job.finished_at)}`)= fmtDuration(job.duration_sec * 1000)
27
33
  if job.message
28
34
  dt.col-sm-2 Message
29
35
  dd.col-sm-10= job.message
@@ -32,6 +32,82 @@ mixin resourceActions(r, returnTo)
32
32
  )
33
33
  i.bi.bi-arrow-repeat
34
34
  +updateButton(`/resources/${r.id}/update`, r.update_to, returnTo)
35
+ +removeResourceButton(r, returnTo)
36
+
37
+ //- Remove: a plain confirm when the resource is idle; with a running job,
38
+ //- the shared warning modal (+removeResourceModal) instead, which stops the
39
+ //- job first (stopJob=1). Disabled once a removal is already pending.
40
+ mixin removeResourceButton(r, returnTo)
41
+ if r.remove_requested_at
42
+ span.d-inline-block.ms-1(tabindex="0" data-bs-toggle="tooltip" data-bs-title="Removal pending — waiting for the Client to confirm its job stopped")
43
+ button.btn.btn-sm.btn-outline-danger(type="button" disabled style="pointer-events: none;" aria-label="Remove")
44
+ i.bi.bi-hourglass-split
45
+ else if r.activeJob
46
+ button.btn.btn-sm.btn-outline-danger.ms-1(
47
+ type="button"
48
+ aria-label="Remove"
49
+ data-bs-toggle="tooltip"
50
+ data-bs-title="Remove this resource from the list — it's running a job"
51
+ data-remove-resource=r.id
52
+ data-remove-name=r.name
53
+ data-remove-job=r.activeJob.id
54
+ data-remove-return=returnTo
55
+ )
56
+ i.bi.bi-trash
57
+ else
58
+ form.d-inline.ms-1(
59
+ method="post"
60
+ action=`/resources/${r.id}/remove`
61
+ data-confirm=`Remove ${r.name} from the list? Its job history is kept. A Client that's still running shows up again when it restarts and re-registers.`
62
+ onsubmit="return confirm(this.dataset.confirm)"
63
+ )
64
+ input(type="hidden" name="returnTo" value=returnTo)
65
+ button.btn.btn-sm.btn-outline-danger(
66
+ type="submit"
67
+ aria-label="Remove"
68
+ data-bs-toggle="tooltip"
69
+ data-bs-title="Remove this resource from the list"
70
+ )
71
+ i.bi.bi-trash
72
+
73
+ //- One per page (admins): filled in by public/js/remove-resource.js from
74
+ //- the clicked Remove button's data-remove-* attributes.
75
+ mixin removeResourceModal()
76
+ .modal.fade#remove-resource-modal(tabindex="-1" aria-labelledby="remove-resource-title" aria-hidden="true")
77
+ .modal-dialog.modal-dialog-centered
78
+ .modal-content
79
+ .modal-header.text-bg-warning
80
+ h5.modal-title#remove-resource-title
81
+ i.bi.bi-exclamation-triangle-fill.me-2
82
+ | Client is running a job
83
+ button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
84
+ .modal-body
85
+ p
86
+ strong(data-field="name")
87
+ | is running job
88
+ |
89
+ a(data-field="job")
90
+ | .
91
+ p.mb-2 Removing it will:
92
+ ul.mb-2
93
+ li
94
+ | stop job
95
+ |
96
+ strong(data-field="job-text")
97
+ | immediately (it ends as
98
+ |
99
+ code CANCELED
100
+ | ), then
101
+ li remove the resource as soon as its Client confirms the job stopped (at most about a minute if the Client doesn't answer).
102
+ p.small.text-body-secondary.mb-0 Its job history is kept. A Client that's still running shows up again when it restarts and re-registers.
103
+ .modal-footer
104
+ button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Keep it
105
+ form(method="post" data-field="form")
106
+ input(type="hidden" name="stopJob" value="1")
107
+ input(type="hidden" name="returnTo" data-field="return")
108
+ button.btn.btn-danger(type="submit")
109
+ i.bi.bi-stop-circle.me-1
110
+ | Stop job and remove
35
111
 
36
112
  //- One udev device line: path (or udev index / ST-Link serial), extra
37
113
  //- detail, and whether the node was present when the Client registered.
@@ -156,7 +232,7 @@ mixin resourceCard(r, groupsById, returnTo)
156
232
  dt.col-sm-4 Type
157
233
  dd.col-sm-8.text-uppercase= r.type
158
234
  dt.col-sm-4 Status
159
- dd.col-sm-8: +resourceBadge(r.status)
235
+ dd.col-sm-8: +resourceBadge(r.status, r.remove_requested_at)
160
236
  +uptimeRows(r)
161
237
  dt.col-sm-4 Client version
162
238
  dd.col-sm-8: +versionCell(r.client_version, updates.latest.client, r.update_to)
@@ -1,6 +1,13 @@
1
- mixin resourceBadge(status)
1
+ //- `removing`: the resource's remove_requested_at — removal is pending
2
+ //- until its Client confirms its stopped job ended (jobs.removeResource).
3
+ mixin resourceBadge(status, removing)
2
4
  - const map = { IDLE: 'success', BUSY: 'primary', OUT_OF_SERVICE: 'danger', MAINTENANCE: 'secondary', REGISTERED: 'secondary' }
3
5
  span.badge(class=`text-bg-${map[status] || 'secondary'}`)= status
6
+ if removing
7
+ span.badge.text-bg-warning.ms-1(
8
+ data-bs-toggle="tooltip"
9
+ data-bs-title="Its job was stopped; removed as soon as the Client confirms"
10
+ ) REMOVING
4
11
 
5
12
  mixin jobBadge(state)
6
13
  - const map = { QUEUED: 'secondary', ASSIGNED: 'info', PREPARING: 'info', RUNNING: 'primary', PASSED: 'success', FAILED: 'danger', ERROR: 'danger', TIMEOUT: 'warning', CANCELED: 'secondary', LOST: 'danger' }
@@ -30,7 +30,7 @@ block content
30
30
  td= r.name
31
31
  td.text-uppercase= r.type
32
32
  td: +versionCell(r.client_version, updates.latest.client, r.update_to)
33
- td: +resourceBadge(r.status)
33
+ td: +resourceBadge(r.status, r.remove_requested_at)
34
34
  td
35
35
  each l in r.labels
36
36
  span.badge.text-bg-light.border.me-1= l
@@ -49,5 +49,9 @@ block content
49
49
  each r in resources
50
50
  +resourceCard(r, groupsById, '/resources')
51
51
 
52
+ if user && user.role === 'admin'
53
+ +removeResourceModal()
54
+
52
55
  block scripts
53
56
  script(src="/js/resource-card.js")
57
+ script(src="/js/remove-resource.js")