@andrian.yablonskyy/thub-coordinator 1.0.0

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.
Files changed (53) hide show
  1. package/README.md +157 -0
  2. package/bin/thub-admin.js +190 -0
  3. package/config.json +30 -0
  4. package/eslint.config.js +115 -0
  5. package/package.json +32 -0
  6. package/public/css/thub.css +6 -0
  7. package/public/js/copy-to-clipboard.js +36 -0
  8. package/public/js/log-viewer.js +76 -0
  9. package/public/js/profile-theme.js +28 -0
  10. package/public/js/theme.js +51 -0
  11. package/public/js/tooltips.js +18 -0
  12. package/src/api/admin.js +112 -0
  13. package/src/api/agent.js +110 -0
  14. package/src/api/resource.js +211 -0
  15. package/src/api/sse.js +96 -0
  16. package/src/auth.js +94 -0
  17. package/src/config.js +110 -0
  18. package/src/db/index.js +61 -0
  19. package/src/db/migrations/001_init.sql +104 -0
  20. package/src/db/migrations/003_split_job_id_counters.sql +5 -0
  21. package/src/db/migrations/004_add_client_id.sql +8 -0
  22. package/src/db/migrations/005_add_groups.sql +14 -0
  23. package/src/db/migrations/006_events_allow_group.sql +20 -0
  24. package/src/db/migrations/007_add_user_profile.sql +10 -0
  25. package/src/server.js +179 -0
  26. package/src/services/admin-users.js +174 -0
  27. package/src/services/agents.js +56 -0
  28. package/src/services/artifacts.js +149 -0
  29. package/src/services/bus.js +23 -0
  30. package/src/services/commands.js +43 -0
  31. package/src/services/events.js +37 -0
  32. package/src/services/groups.js +79 -0
  33. package/src/services/heartbeat.js +56 -0
  34. package/src/services/jobs.js +355 -0
  35. package/src/services/logs.js +70 -0
  36. package/src/services/registry.js +347 -0
  37. package/src/services/retention.js +44 -0
  38. package/src/services/scheduler.js +101 -0
  39. package/src/services/tokens.js +28 -0
  40. package/src/web/routes.js +343 -0
  41. package/test/admin-users.test.js +127 -0
  42. package/test/scheduler.test.js +375 -0
  43. package/views/admin/agents.pug +53 -0
  44. package/views/groups/list.pug +94 -0
  45. package/views/index.pug +37 -0
  46. package/views/jobs/list.pug +45 -0
  47. package/views/jobs/show.pug +42 -0
  48. package/views/layout.pug +73 -0
  49. package/views/login.pug +16 -0
  50. package/views/mixins/log-viewer.pug +14 -0
  51. package/views/mixins/status-badge.pug +7 -0
  52. package/views/profile.pug +77 -0
  53. package/views/resources/list.pug +65 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/agents.js
3
+ * @description Agent (CI/CD and developer) identity management: create, list, tokens
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 { v4: uuid } = require('uuid'),
17
+ { generateToken, hashToken } = require('./tokens');
18
+
19
+ function createAgentsService(db, { events }){
20
+ function get(id){
21
+ return db.prepare('SELECT * FROM agents WHERE id = ?').get(id);
22
+ }
23
+
24
+ function getByTokenHash(tokenHash){
25
+ return db
26
+ .prepare('SELECT * FROM agents WHERE token_hash = ? AND revoked_at IS NULL')
27
+ .get(tokenHash);
28
+ }
29
+
30
+ function list(){
31
+ return db.prepare('SELECT id, name, kind, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
32
+ }
33
+
34
+ function create({ name, kind }){
35
+ const id = `agt_${uuid()}`,
36
+ token = generateToken('agt');
37
+ db.prepare(
38
+ 'INSERT INTO agents (id, name, kind, token_hash, created_at) VALUES (?, ?, ?, ?, ?)'
39
+ ).run(id, name, kind, hashToken(token), new Date().toISOString());
40
+ events.record('agent', id, 'agent.created', { name, kind });
41
+ return { agent: get(id), token };
42
+ }
43
+
44
+ function revoke(id){
45
+ db.prepare('UPDATE agents SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
46
+ events.record('agent', id, 'agent.revoked', {});
47
+ }
48
+
49
+ function touchLastUsed(id){
50
+ db.prepare('UPDATE agents SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), id);
51
+ }
52
+
53
+ return { get, getByTokenHash, list, create, revoke, touchLastUsed };
54
+ }
55
+
56
+ module.exports = { createAgentsService };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/artifacts.js
3
+ * @description Job artifact storage: saves uploaded files and generates download links
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 fs = require('node:fs'),
17
+ path = require('node:path'),
18
+ crypto = require('node:crypto');
19
+
20
+ // §3.1 / §12: stores uploaded result files under artifacts/<jobId>/ and
21
+ // generates HMAC-signed, time-limited download links (no server-side
22
+ // session needed for the Agent/CI to fetch them).
23
+ function createArtifactsService(db, { config }){
24
+ const secret = config.sessionSecret;
25
+
26
+ function jobDir(jobId){
27
+ const dir = path.join(config.artifactsDir, jobId);
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ return dir;
30
+ }
31
+
32
+ function sha256File(filePath){
33
+ const hash = crypto.createHash('sha256');
34
+ hash.update(fs.readFileSync(filePath));
35
+ return hash.digest('hex');
36
+ }
37
+
38
+ // `files` are multer file objects already written to a temp path.
39
+ function storeUploaded(jobId, files){
40
+ const dir = jobDir(jobId),
41
+ stored = [];
42
+ for (const file of files){
43
+ const dest = path.join(dir, path.basename(file.originalname));
44
+ fs.renameSync(file.path, dest);
45
+ const sha256 = sha256File(dest),
46
+ size = fs.statSync(dest).size,
47
+ info = db
48
+ .prepare(
49
+ `INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
50
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
51
+ )
52
+ .run(jobId, file.originalname, dest, size, sha256, file.mimetype, new Date().toISOString());
53
+ stored.push(get(info.lastInsertRowid));
54
+ }
55
+ return stored;
56
+ }
57
+
58
+ function storeGenerated(jobId, name, buffer, contentType){
59
+ const dir = jobDir(jobId),
60
+ dest = path.join(dir, name);
61
+ fs.writeFileSync(dest, buffer);
62
+ const info = db
63
+ .prepare(
64
+ `INSERT INTO artifacts (job_id, name, path, size, sha256, content_type, created_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
66
+ )
67
+ .run(jobId, name, dest, buffer.length, sha256File(dest), contentType, new Date().toISOString());
68
+ return get(info.lastInsertRowid);
69
+ }
70
+
71
+ function get(id){
72
+ return db.prepare('SELECT * FROM artifacts WHERE id = ?').get(id);
73
+ }
74
+
75
+ function listForJob(jobId){
76
+ return db.prepare('SELECT * FROM artifacts WHERE job_id = ? ORDER BY id ASC').all(jobId);
77
+ }
78
+
79
+ function sign(artifactId, ttlHours = config.artifacts.linkTtlHours){
80
+ const expiresAt = Date.now() + ttlHours * 3600 * 1000,
81
+ payload = `${artifactId}.${expiresAt}`,
82
+ mac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
83
+ return `${payload}.${mac}`;
84
+ }
85
+
86
+ function verify(token){
87
+ const [artifactId, expiresAt, mac] = String(token).split('.');
88
+ if (!artifactId || !expiresAt || !mac){
89
+ throw invalidLink();
90
+ }
91
+ const payload = `${artifactId}.${expiresAt}`,
92
+ expectedMac = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
93
+ if (!crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expectedMac))){
94
+ throw invalidLink();
95
+ }
96
+ if (Date.now() > Number(expiresAt)){
97
+ throw invalidLink('Link expired');
98
+ }
99
+ const artifact = get(Number(artifactId));
100
+ if (!artifact){
101
+ throw invalidLink();
102
+ }
103
+ return artifact;
104
+ }
105
+
106
+ function invalidLink(message = 'Invalid or expired link'){
107
+ return Object.assign(new Error(message), { status: 403 });
108
+ }
109
+
110
+ function signedUrl(artifact){
111
+ return `${config.publicUrl}/artifacts/download/${sign(artifact.id)}`;
112
+ }
113
+
114
+ // Removes a job's artifact files from disk (not the DB rows — the
115
+ // caller, jobs.js's cleanHistory, deletes those as part of the same
116
+ // transaction that also deletes the job itself).
117
+ function deleteJobArtifacts(jobId){
118
+ fs.rmSync(path.join(config.artifactsDir, jobId), { recursive: true, force: true });
119
+ }
120
+
121
+ function purgeOlderThan(days){
122
+ const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString(),
123
+ stale = db
124
+ .prepare(
125
+ `SELECT a.* FROM artifacts a JOIN jobs j ON j.id = a.job_id
126
+ WHERE j.finished_at IS NOT NULL AND j.finished_at <= ?`
127
+ )
128
+ .all(cutoff);
129
+ for (const artifact of stale){
130
+ fs.rmSync(artifact.path, { force: true });
131
+ db.prepare('DELETE FROM artifacts WHERE id = ?').run(artifact.id);
132
+ }
133
+ }
134
+
135
+ return {
136
+ storeUploaded,
137
+ storeGenerated,
138
+ get,
139
+ listForJob,
140
+ sign,
141
+ verify,
142
+ signedUrl,
143
+ purgeOlderThan,
144
+ deleteJobArtifacts,
145
+ jobDir
146
+ };
147
+ }
148
+
149
+ module.exports = { createArtifactsService };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/bus.js
3
+ * @description In-process event bus used to fan out log/state changes to SSE subscribers (README §3.1)
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 { EventEmitter } = require('node:events');
17
+
18
+ // §3.1: "An in-process EventEmitter. The Coordinator is a single process
19
+ // by design; SQLite plus one process is enough for tens of runners and
20
+ // hundreds of jobs per day."
21
+ class Bus extends EventEmitter{}
22
+
23
+ module.exports = { bus: new Bus() };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/commands.js
3
+ * @description In-memory per-resource command queue delivered via the heartbeat response (README §5.1)
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
+ // §5.1: "The commands[] field in the heartbeat response is how the
17
+ // Coordinator talks to a Client without an inbound connection." Pending
18
+ // commands are cheap and short-lived, so an in-memory queue per resource
19
+ // is enough — consistent with the single-process design (§3.1).
20
+ function createCommandsService({ bus }){
21
+ const pending = new Map(); // resourceId -> [{command, jobId?, ...}]
22
+
23
+ bus.on('command', ({ resourceId, ...command }) => {
24
+ if (!pending.has(resourceId)){
25
+ pending.set(resourceId, []);
26
+ }
27
+ pending.get(resourceId).push(command);
28
+ });
29
+
30
+ function drain(resourceId){
31
+ const commands = pending.get(resourceId) || [];
32
+ pending.delete(resourceId);
33
+ return commands;
34
+ }
35
+
36
+ function push(resourceId, command){
37
+ bus.emit('command', { resourceId, ...command });
38
+ }
39
+
40
+ return { drain, push };
41
+ }
42
+
43
+ module.exports = { createCommandsService };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/events.js
3
+ * @description Audit log service: records every state change to the events table (README §12)
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
+ // §12 Audit: "All state changes are written to the events table and
17
+ // visible on the dashboard."
18
+ function createEventsService(db){
19
+ const insert = db.prepare(
20
+ 'INSERT INTO events (ts, entity, entity_id, type, data) VALUES (?, ?, ?, ?, ?)'
21
+ );
22
+
23
+ function record(entity, entityId, type, data){
24
+ insert.run(new Date().toISOString(), entity, entityId, type, data ? JSON.stringify(data) : null);
25
+ }
26
+
27
+ function recent(limit = 50){
28
+ return db
29
+ .prepare('SELECT * FROM events ORDER BY id DESC LIMIT ?')
30
+ .all(limit)
31
+ .map((e) => ({ ...e, data: e.data ? JSON.parse(e.data) : null }));
32
+ }
33
+
34
+ return { record, recent };
35
+ }
36
+
37
+ module.exports = { createEventsService };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/groups.js
3
+ * @description Resource group management: create/list/update/remove groups and their membership (README §13.1)
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 { v4: uuid } = require('uuid');
17
+
18
+ // Admin-managed resource groups (README §13.1) — a job can be constrained
19
+ // to run only on resources that are members of a given group
20
+ // (`thub run --group <id>`, job-spec.schema.js's `target.group`).
21
+ // Membership itself lives on the resource (registry.js's `group_ids`,
22
+ // declared by the Client's own config), not here — this service only
23
+ // owns the groups' own identity (id/name/comment).
24
+ function createGroupsService(db, { events, registry }){
25
+ function get(id){
26
+ return db.prepare('SELECT * FROM groups WHERE id = ?').get(id);
27
+ }
28
+
29
+ function list(){
30
+ return db.prepare('SELECT * FROM groups ORDER BY name ASC').all();
31
+ }
32
+
33
+ function create({ name, comment }){
34
+ const id = uuid();
35
+ db.prepare('INSERT INTO groups (id, name, comment, created_at) VALUES (?, ?, ?, ?)').run(
36
+ id,
37
+ name,
38
+ comment || null,
39
+ new Date().toISOString()
40
+ );
41
+ events.record('group', id, 'group.created', { name, comment });
42
+ return get(id);
43
+ }
44
+
45
+ function update(id, { name, comment }){
46
+ const group = get(id);
47
+ if (!group){
48
+ throw Object.assign(new Error('Unknown group'), { status: 404 });
49
+ }
50
+ db.prepare('UPDATE groups SET name = ?, comment = ? WHERE id = ?').run(
51
+ name ?? group.name,
52
+ comment !== undefined ? comment : group.comment,
53
+ id
54
+ );
55
+ events.record('group', id, 'group.updated', { name, comment });
56
+ return get(id);
57
+ }
58
+
59
+ // Deletes the group and strips it out of every resource's membership
60
+ // list — a resource whose config still lists this id just stops
61
+ // matching on it, rather than being left with a dangling reference.
62
+ function remove(id){
63
+ const group = get(id);
64
+ if (!group){
65
+ return;
66
+ }
67
+ for (const resource of registry.list()){
68
+ if (resource.group_ids.includes(id)){
69
+ registry.setGroups(resource.id, resource.group_ids.filter((g) => g !== id));
70
+ }
71
+ }
72
+ db.prepare('DELETE FROM groups WHERE id = ?').run(id);
73
+ events.record('group', id, 'group.deleted', { name: group.name });
74
+ }
75
+
76
+ return { get, list, create, update, remove };
77
+ }
78
+
79
+ module.exports = { createGroupsService };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file packages/coordinator/src/services/heartbeat.js
3
+ * @description Heartbeat sweeper: marks silent resources OUT_OF_SERVICE and requeues their jobs
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 { RESOURCE_STATES, ACTIVE_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
17
+
18
+ // §5.1 sweeper: runs every sweepIntervalSec, marks resources OUT_OF_SERVICE
19
+ // after missedLimit missed heartbeats and moves any active job to LOST.
20
+ function createHeartbeatMonitor(db, { bus, events, registry, jobs, config }){
21
+ function sweepOnce(){
22
+ const { intervalSec, missedLimit } = config.heartbeat,
23
+ cutoff = new Date(Date.now() - missedLimit * intervalSec * 1000).toISOString(),
24
+
25
+ stale = db
26
+ .prepare(
27
+ `SELECT * FROM resources
28
+ WHERE status NOT IN (?, ?)
29
+ AND last_heartbeat_at IS NOT NULL
30
+ AND last_heartbeat_at <= ?`
31
+ )
32
+ .all(RESOURCE_STATES.OUT_OF_SERVICE, RESOURCE_STATES.MAINTENANCE, cutoff);
33
+
34
+ for (const resource of stale){
35
+ registry.markOutOfService(resource.id);
36
+
37
+ const activeJob = db
38
+ .prepare(
39
+ `SELECT id, state FROM jobs WHERE resource_id = ? AND state IN (${[...ACTIVE_JOB_STATES]
40
+ .map(() => '?')
41
+ .join(',')})`
42
+ )
43
+ .get(resource.id, ...ACTIVE_JOB_STATES);
44
+ if (activeJob){
45
+ jobs.markLost(activeJob.id);
46
+ }
47
+ }
48
+ }
49
+
50
+ const timer = setInterval(sweepOnce, config.heartbeat.sweepIntervalSec * 1000);
51
+ timer.unref?.();
52
+
53
+ return { sweepOnce, stop: () => clearInterval(timer) };
54
+ }
55
+
56
+ module.exports = { createHeartbeatMonitor };