@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.
- package/README.md +157 -0
- package/bin/thub-admin.js +190 -0
- package/config.json +30 -0
- package/eslint.config.js +115 -0
- package/package.json +32 -0
- package/public/css/thub.css +6 -0
- package/public/js/copy-to-clipboard.js +36 -0
- package/public/js/log-viewer.js +76 -0
- package/public/js/profile-theme.js +28 -0
- package/public/js/theme.js +51 -0
- package/public/js/tooltips.js +18 -0
- package/src/api/admin.js +112 -0
- package/src/api/agent.js +110 -0
- package/src/api/resource.js +211 -0
- package/src/api/sse.js +96 -0
- package/src/auth.js +94 -0
- package/src/config.js +110 -0
- package/src/db/index.js +61 -0
- package/src/db/migrations/001_init.sql +104 -0
- package/src/db/migrations/003_split_job_id_counters.sql +5 -0
- package/src/db/migrations/004_add_client_id.sql +8 -0
- package/src/db/migrations/005_add_groups.sql +14 -0
- package/src/db/migrations/006_events_allow_group.sql +20 -0
- package/src/db/migrations/007_add_user_profile.sql +10 -0
- package/src/server.js +179 -0
- package/src/services/admin-users.js +174 -0
- package/src/services/agents.js +56 -0
- package/src/services/artifacts.js +149 -0
- package/src/services/bus.js +23 -0
- package/src/services/commands.js +43 -0
- package/src/services/events.js +37 -0
- package/src/services/groups.js +79 -0
- package/src/services/heartbeat.js +56 -0
- package/src/services/jobs.js +355 -0
- package/src/services/logs.js +70 -0
- package/src/services/registry.js +347 -0
- package/src/services/retention.js +44 -0
- package/src/services/scheduler.js +101 -0
- package/src/services/tokens.js +28 -0
- package/src/web/routes.js +343 -0
- package/test/admin-users.test.js +127 -0
- package/test/scheduler.test.js +375 -0
- package/views/admin/agents.pug +53 -0
- package/views/groups/list.pug +94 -0
- package/views/index.pug +37 -0
- package/views/jobs/list.pug +45 -0
- package/views/jobs/show.pug +42 -0
- package/views/layout.pug +73 -0
- package/views/login.pug +16 -0
- package/views/mixins/log-viewer.pug +14 -0
- package/views/mixins/status-badge.pug +7 -0
- package/views/profile.pug +77 -0
- package/views/resources/list.pug +65 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/registry.js
|
|
3
|
+
* @description Resource registry: registration, identity matching by clientId, and lookup for scheduling (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
|
+
const { v4: uuid } = require('uuid'),
|
|
17
|
+
{ RESOURCE_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common'),
|
|
18
|
+
{ generateToken, hashToken } = require('./tokens');
|
|
19
|
+
|
|
20
|
+
function rowToResource(row){
|
|
21
|
+
if (!row){
|
|
22
|
+
return row;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
...row,
|
|
26
|
+
labels: JSON.parse(row.labels || '[]'),
|
|
27
|
+
group_ids: JSON.parse(row.group_ids || '[]'),
|
|
28
|
+
host_info: row.host_info ? JSON.parse(row.host_info) : null
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createRegistryService(db, { bus, events }){
|
|
33
|
+
function get(id){
|
|
34
|
+
return rowToResource(db.prepare('SELECT * FROM resources WHERE id = ?').get(id));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getByName(name){
|
|
38
|
+
return rowToResource(db.prepare('SELECT * FROM resources WHERE name = ?').get(name));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getByClientId(clientId){
|
|
42
|
+
return rowToResource(db.prepare('SELECT * FROM resources WHERE client_id = ?').get(clientId));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getByTokenHash(tokenHash){
|
|
46
|
+
return rowToResource(db.prepare('SELECT * FROM resources WHERE token_hash = ?').get(tokenHash));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function list({ status, type } = {}){
|
|
50
|
+
let sql = 'SELECT * FROM resources WHERE 1=1';
|
|
51
|
+
const params = [];
|
|
52
|
+
if (status){
|
|
53
|
+
sql += ' AND status = ?';
|
|
54
|
+
params.push(status);
|
|
55
|
+
}
|
|
56
|
+
if (type){
|
|
57
|
+
sql += ' AND type = ?';
|
|
58
|
+
params.push(type);
|
|
59
|
+
}
|
|
60
|
+
sql += ' ORDER BY name ASC';
|
|
61
|
+
return db.prepare(sql).all(...params).map(rowToResource);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Self-service registration: gated by the shared client join key (checked
|
|
65
|
+
// by the caller), not by an admin having pre-created the resource.
|
|
66
|
+
//
|
|
67
|
+
// Identity is `clientId` — a UUID the Client generates once and persists
|
|
68
|
+
// in its own .client-id file (§5.1/§8.6) — not `name`. That's what makes
|
|
69
|
+
// this a genuine update-in-place rather than a rename-creates-a-new-
|
|
70
|
+
// resource operation: name, type, labels and host_info are all fully
|
|
71
|
+
// overwritten (not merged) from what the Client declares *this* time,
|
|
72
|
+
// since the Client is the source of truth for its own current identity/
|
|
73
|
+
// capabilities, and status resets to REGISTERED (clearing any stale
|
|
74
|
+
// BUSY/OUT_OF_SERVICE left over from a crashed previous process) unless
|
|
75
|
+
// an admin had explicitly put it in MAINTENANCE, which re-registering
|
|
76
|
+
// doesn't override.
|
|
77
|
+
//
|
|
78
|
+
// A resource created before clientId existed (client_id IS NULL) is
|
|
79
|
+
// adopted by name the first time its Client presents one, rather than
|
|
80
|
+
// rejected as a name conflict. A name already claimed by a *different*,
|
|
81
|
+
// still-live clientId is a real conflict (409) — but if that claim is
|
|
82
|
+
// OUT_OF_SERVICE (no heartbeat in a while — e.g. its .client-id file was
|
|
83
|
+
// lost or its host is gone for good), it's abandoned, not squatted, and
|
|
84
|
+
// this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
|
|
85
|
+
// MAINTENANCE, REGISTERED-but-just-created) means a process might still
|
|
86
|
+
// be actively using that identity, so it stays a hard conflict.
|
|
87
|
+
function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo }){
|
|
88
|
+
const resourceToken = generateToken('res');
|
|
89
|
+
let existing = getByClientId(clientId);
|
|
90
|
+
|
|
91
|
+
if (!existing){
|
|
92
|
+
const nameOwner = getByName(name);
|
|
93
|
+
if (nameOwner){
|
|
94
|
+
const abandoned = nameOwner.status === RESOURCE_STATES.OUT_OF_SERVICE;
|
|
95
|
+
if (nameOwner.client_id && nameOwner.client_id !== clientId && !abandoned){
|
|
96
|
+
throw Object.assign(
|
|
97
|
+
new Error(
|
|
98
|
+
`Resource name "${name}" is already registered by a different client (status: ${nameOwner.status})`
|
|
99
|
+
),
|
|
100
|
+
{ status: 409 }
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
existing = nameOwner; // legacy row, same client, or a reclaimed abandoned one
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (existing.name !== name){
|
|
107
|
+
const nameOwner = getByName(name);
|
|
108
|
+
if (nameOwner && nameOwner.id !== existing.id){
|
|
109
|
+
if (nameOwner.status !== RESOURCE_STATES.OUT_OF_SERVICE){
|
|
110
|
+
throw Object.assign(
|
|
111
|
+
new Error(`Resource name "${name}" is already used by another client (status: ${nameOwner.status})`),
|
|
112
|
+
{ status: 409 }
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
// nameOwner is a different, abandoned row squatting on the name
|
|
116
|
+
// `existing` wants to rename into — free it (not delete: keeps its
|
|
117
|
+
// job history intact) instead of hitting the UNIQUE constraint.
|
|
118
|
+
db.prepare('UPDATE resources SET name = ? WHERE id = ?').run(`${nameOwner.name}__stale-${nameOwner.id}`, nameOwner.id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (existing){
|
|
123
|
+
// A fresh process registering is a definitive signal that whatever
|
|
124
|
+
// the previous process was doing is abandoned — tell jobs.js (via
|
|
125
|
+
// the bus, to avoid a registry<->jobs circular dependency) so any
|
|
126
|
+
// job still pointing at this resource is marked LOST now, instead
|
|
127
|
+
// of sitting orphaned until the heartbeat sweeper eventually notices.
|
|
128
|
+
bus.emit('resource.reregistered', { resourceId: existing.id });
|
|
129
|
+
|
|
130
|
+
const nextStatus =
|
|
131
|
+
existing.status === RESOURCE_STATES.MAINTENANCE ? RESOURCE_STATES.MAINTENANCE : RESOURCE_STATES.REGISTERED;
|
|
132
|
+
|
|
133
|
+
db.prepare(
|
|
134
|
+
`UPDATE resources
|
|
135
|
+
SET token_hash = ?, client_id = ?, name = ?, type = ?, labels = ?, group_ids = ?, host_info = ?,
|
|
136
|
+
status = ?, busy_source = NULL, busy_reason = NULL
|
|
137
|
+
WHERE id = ?`
|
|
138
|
+
).run(
|
|
139
|
+
hashToken(resourceToken),
|
|
140
|
+
clientId,
|
|
141
|
+
name,
|
|
142
|
+
type,
|
|
143
|
+
JSON.stringify(labels),
|
|
144
|
+
JSON.stringify(groups),
|
|
145
|
+
JSON.stringify(hostInfo || {}),
|
|
146
|
+
nextStatus,
|
|
147
|
+
existing.id
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
events.record('resource', existing.id, 'resource.registered', {
|
|
151
|
+
hostInfo,
|
|
152
|
+
name,
|
|
153
|
+
type,
|
|
154
|
+
labels,
|
|
155
|
+
groups,
|
|
156
|
+
reregistered: true
|
|
157
|
+
});
|
|
158
|
+
return { resourceId: existing.id, resourceToken };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const id = `res_${uuid()}`;
|
|
162
|
+
db.prepare(
|
|
163
|
+
`INSERT INTO resources (id, name, type, status, labels, group_ids, host_info, token_hash, client_id, created_at)
|
|
164
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
165
|
+
).run(
|
|
166
|
+
id,
|
|
167
|
+
name,
|
|
168
|
+
type,
|
|
169
|
+
RESOURCE_STATES.REGISTERED,
|
|
170
|
+
JSON.stringify(labels),
|
|
171
|
+
JSON.stringify(groups),
|
|
172
|
+
JSON.stringify(hostInfo || {}),
|
|
173
|
+
hashToken(resourceToken),
|
|
174
|
+
clientId,
|
|
175
|
+
new Date().toISOString()
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
events.record('resource', id, 'resource.registered', { name, type, hostInfo, groups, reregistered: false });
|
|
179
|
+
return { resourceId: id, resourceToken };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function setGroups(resourceId, groupIds){
|
|
183
|
+
db.prepare('UPDATE resources SET group_ids = ? WHERE id = ?').run(JSON.stringify(groupIds), resourceId);
|
|
184
|
+
return get(resourceId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Reconcile status from a heartbeat's self-reported state (§5.1).
|
|
188
|
+
function heartbeat(resourceId, { state, activeJobId, localLock, metrics } = {}){
|
|
189
|
+
const resource = get(resourceId);
|
|
190
|
+
if (!resource){
|
|
191
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let nextStatus = resource.status,
|
|
195
|
+
busySource = resource.busy_source,
|
|
196
|
+
busyReason = resource.busy_reason;
|
|
197
|
+
|
|
198
|
+
if (localLock){
|
|
199
|
+
nextStatus = RESOURCE_STATES.BUSY;
|
|
200
|
+
busySource = BUSY_SOURCES.LOCAL;
|
|
201
|
+
}
|
|
202
|
+
else if (state === 'busy' && activeJobId){
|
|
203
|
+
nextStatus = RESOURCE_STATES.BUSY;
|
|
204
|
+
// busy_source stays whatever the scheduler assigned.
|
|
205
|
+
}
|
|
206
|
+
else if (
|
|
207
|
+
resource.status === RESOURCE_STATES.OUT_OF_SERVICE ||
|
|
208
|
+
resource.status === RESOURCE_STATES.REGISTERED
|
|
209
|
+
){
|
|
210
|
+
nextStatus = RESOURCE_STATES.IDLE;
|
|
211
|
+
busySource = null;
|
|
212
|
+
busyReason = null;
|
|
213
|
+
}
|
|
214
|
+
else if (resource.status !== RESOURCE_STATES.MAINTENANCE && state === 'idle'){
|
|
215
|
+
nextStatus = RESOURCE_STATES.IDLE;
|
|
216
|
+
busySource = null;
|
|
217
|
+
busyReason = null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
db.prepare(
|
|
221
|
+
`UPDATE resources
|
|
222
|
+
SET last_heartbeat_at = ?, status = ?, busy_source = ?, busy_reason = ?
|
|
223
|
+
WHERE id = ?`
|
|
224
|
+
).run(new Date().toISOString(), nextStatus, busySource, busyReason, resourceId);
|
|
225
|
+
|
|
226
|
+
if (resource.status !== nextStatus){
|
|
227
|
+
events.record('resource', resourceId, 'resource.status_changed', {
|
|
228
|
+
from: resource.status,
|
|
229
|
+
to: nextStatus
|
|
230
|
+
});
|
|
231
|
+
if (nextStatus === RESOURCE_STATES.IDLE){
|
|
232
|
+
bus.emit('resource.idle', { resourceId });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { resource: get(resourceId), statusChanged: resource.status !== nextStatus };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// §8.4 local lock / unlock via the Client's Unix socket -> daemon -> Coordinator.
|
|
240
|
+
function setLocalLock(resourceId, { locked, reason }){
|
|
241
|
+
const resource = get(resourceId);
|
|
242
|
+
if (!resource){
|
|
243
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (locked){
|
|
247
|
+
if (resource.status === RESOURCE_STATES.BUSY && resource.busy_source !== BUSY_SOURCES.LOCAL){
|
|
248
|
+
throw Object.assign(new Error('Resource is running a job'), { status: 409 });
|
|
249
|
+
}
|
|
250
|
+
db.prepare('UPDATE resources SET status = ?, busy_source = ?, busy_reason = ? WHERE id = ?').run(
|
|
251
|
+
RESOURCE_STATES.BUSY,
|
|
252
|
+
BUSY_SOURCES.LOCAL,
|
|
253
|
+
reason || null,
|
|
254
|
+
resourceId
|
|
255
|
+
);
|
|
256
|
+
events.record('resource', resourceId, 'resource.locked', { reason });
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
db.prepare('UPDATE resources SET status = ?, busy_source = NULL, busy_reason = NULL WHERE id = ?').run(
|
|
260
|
+
RESOURCE_STATES.IDLE,
|
|
261
|
+
resourceId
|
|
262
|
+
);
|
|
263
|
+
events.record('resource', resourceId, 'resource.unlocked', {});
|
|
264
|
+
bus.emit('resource.idle', { resourceId });
|
|
265
|
+
}
|
|
266
|
+
return get(resourceId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function markOutOfService(resourceId){
|
|
270
|
+
const resource = get(resourceId);
|
|
271
|
+
if (!resource || resource.status === RESOURCE_STATES.OUT_OF_SERVICE){
|
|
272
|
+
return resource;
|
|
273
|
+
}
|
|
274
|
+
db.prepare('UPDATE resources SET status = ? WHERE id = ?').run(RESOURCE_STATES.OUT_OF_SERVICE, resourceId);
|
|
275
|
+
events.record('resource', resourceId, 'resource.oos', {});
|
|
276
|
+
return get(resourceId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function markIdleAfterJob(resourceId){
|
|
280
|
+
db.prepare(
|
|
281
|
+
`UPDATE resources
|
|
282
|
+
SET status = ?, busy_source = NULL, busy_reason = NULL, last_job_finished_at = ?
|
|
283
|
+
WHERE id = ? AND status != ?`
|
|
284
|
+
).run(RESOURCE_STATES.IDLE, new Date().toISOString(), resourceId, RESOURCE_STATES.MAINTENANCE);
|
|
285
|
+
bus.emit('resource.idle', { resourceId });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function setMaintenance(resourceId, enabled){
|
|
289
|
+
const resource = get(resourceId);
|
|
290
|
+
if (!resource){
|
|
291
|
+
throw Object.assign(new Error('Unknown resource'), { status: 404 });
|
|
292
|
+
}
|
|
293
|
+
const status = enabled ? RESOURCE_STATES.MAINTENANCE : RESOURCE_STATES.IDLE;
|
|
294
|
+
db.prepare('UPDATE resources SET status = ? WHERE id = ?').run(status, resourceId);
|
|
295
|
+
events.record('resource', resourceId, enabled ? 'resource.maintenance_on' : 'resource.maintenance_off', {});
|
|
296
|
+
if (!enabled){
|
|
297
|
+
bus.emit('resource.idle', { resourceId });
|
|
298
|
+
}
|
|
299
|
+
return get(resourceId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Used by the scheduler under a single write transaction.
|
|
303
|
+
function assignToJob(resourceId, busySource){
|
|
304
|
+
db.prepare('UPDATE resources SET status = ?, busy_source = ? WHERE id = ?').run(
|
|
305
|
+
RESOURCE_STATES.BUSY,
|
|
306
|
+
busySource,
|
|
307
|
+
resourceId
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function findIdleCandidates(type, labels, groupId){
|
|
312
|
+
const rows = db
|
|
313
|
+
.prepare('SELECT * FROM resources WHERE status = ? AND type = ?')
|
|
314
|
+
.all(RESOURCE_STATES.IDLE, type)
|
|
315
|
+
.map(rowToResource);
|
|
316
|
+
return rows.filter(
|
|
317
|
+
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function everSatisfiable(type, labels, groupId){
|
|
322
|
+
const rows = db.prepare('SELECT * FROM resources WHERE type = ?').all(type).map(rowToResource);
|
|
323
|
+
return rows.some(
|
|
324
|
+
(r) => labels.every((l) => r.labels.includes(l)) && (!groupId || r.group_ids.includes(groupId))
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
get,
|
|
330
|
+
getByName,
|
|
331
|
+
getByClientId,
|
|
332
|
+
getByTokenHash,
|
|
333
|
+
list,
|
|
334
|
+
registerAuto,
|
|
335
|
+
setGroups,
|
|
336
|
+
heartbeat,
|
|
337
|
+
setLocalLock,
|
|
338
|
+
markOutOfService,
|
|
339
|
+
markIdleAfterJob,
|
|
340
|
+
setMaintenance,
|
|
341
|
+
assignToJob,
|
|
342
|
+
findIdleCandidates,
|
|
343
|
+
everSatisfiable
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = { createRegistryService };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/retention.js
|
|
3
|
+
* @description Deletes finished jobs' logs/artifacts past their retention window (README §9)
|
|
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 path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const DAY_MS = 86400 * 1000;
|
|
19
|
+
|
|
20
|
+
// §9: fold job_logs into a console.log artifact when a job finishes, purge
|
|
21
|
+
// old logs/artifacts on the configured retention windows, and take a nightly
|
|
22
|
+
// consistent backup via VACUUM INTO.
|
|
23
|
+
function createRetentionService(db, { bus, logs, artifacts, config }){
|
|
24
|
+
bus.on('job.finished', ({ jobId }) => {
|
|
25
|
+
const text = logs.renderConsoleLog(jobId);
|
|
26
|
+
if (text){
|
|
27
|
+
artifacts.storeGenerated(jobId, 'console.log', Buffer.from(text, 'utf8'), 'text/plain');
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function runDaily(){
|
|
32
|
+
logs.purgeOlderThan(config.retention.logRetentionDays);
|
|
33
|
+
artifacts.purgeOlderThan(config.retention.artifactRetentionDays);
|
|
34
|
+
const backupPath = path.join(config.dataDir, `backup-${new Date().toISOString().slice(0, 10)}.db`);
|
|
35
|
+
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, '\'\'')}'`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const timer = setInterval(runDaily, DAY_MS);
|
|
39
|
+
timer.unref?.();
|
|
40
|
+
|
|
41
|
+
return { runDaily, stop: () => clearInterval(timer) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { createRetentionService };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/scheduler.js
|
|
3
|
+
* @description Scheduler: matches queued jobs to idle resources by type/labels/group (README §5.4)
|
|
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 { JOB_STATES, BUSY_SOURCES } = require('@andrian.yablonskyy/thub-common');
|
|
17
|
+
|
|
18
|
+
function rowToJob(row){
|
|
19
|
+
return { ...row, spec: JSON.parse(row.spec) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// §5.4: a single SQLite write transaction picks the oldest highest-priority
|
|
23
|
+
// queued job for each idle, label-matching resource. Running it inside
|
|
24
|
+
// BEGIN IMMEDIATE plus better-sqlite3's synchronous execution is what makes
|
|
25
|
+
// double-assignment impossible.
|
|
26
|
+
function createScheduler(db, { bus, events, registry, config }){
|
|
27
|
+
let scheduled = false;
|
|
28
|
+
|
|
29
|
+
function runPass(){
|
|
30
|
+
const tx = db.transaction(() => {
|
|
31
|
+
const queued = db
|
|
32
|
+
.prepare('SELECT * FROM jobs WHERE state = \'QUEUED\' ORDER BY priority DESC, created_at ASC')
|
|
33
|
+
.all()
|
|
34
|
+
.map(rowToJob),
|
|
35
|
+
|
|
36
|
+
assignments = [],
|
|
37
|
+
claimedResourceIds = new Set();
|
|
38
|
+
|
|
39
|
+
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));
|
|
43
|
+
if (candidates.length === 0){
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Least-recently-used: spread wear across benches (§5.4).
|
|
48
|
+
candidates.sort((a, b) => {
|
|
49
|
+
const ta = a.last_job_finished_at ? new Date(a.last_job_finished_at).getTime() : 0,
|
|
50
|
+
tb = b.last_job_finished_at ? new Date(b.last_job_finished_at).getTime() : 0;
|
|
51
|
+
return ta - tb;
|
|
52
|
+
});
|
|
53
|
+
const resource = candidates[0];
|
|
54
|
+
claimedResourceIds.add(resource.id);
|
|
55
|
+
|
|
56
|
+
const busySource = job.source === 'ci' ? BUSY_SOURCES.CI : BUSY_SOURCES.CLI,
|
|
57
|
+
now = new Date().toISOString();
|
|
58
|
+
|
|
59
|
+
db.prepare('UPDATE jobs SET state = ?, resource_id = ?, assigned_at = ? WHERE id = ?').run(
|
|
60
|
+
JOB_STATES.ASSIGNED,
|
|
61
|
+
resource.id,
|
|
62
|
+
now,
|
|
63
|
+
job.id
|
|
64
|
+
);
|
|
65
|
+
registry.assignToJob(resource.id, busySource);
|
|
66
|
+
events.record('job', job.id, 'job.state', { from: JOB_STATES.QUEUED, to: JOB_STATES.ASSIGNED, resourceId: resource.id });
|
|
67
|
+
|
|
68
|
+
assignments.push({ jobId: job.id, resourceId: resource.id });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return assignments;
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
assignments = tx();
|
|
75
|
+
for (const { jobId, resourceId }of assignments){
|
|
76
|
+
bus.emit('job.assigned', { jobId, resourceId });
|
|
77
|
+
}
|
|
78
|
+
return assignments;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function scheduleSoon(){
|
|
82
|
+
if (scheduled){
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
scheduled = true;
|
|
86
|
+
setImmediate(() => {
|
|
87
|
+
scheduled = false;
|
|
88
|
+
runPass();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
bus.on('job.queued', scheduleSoon);
|
|
93
|
+
bus.on('resource.idle', scheduleSoon);
|
|
94
|
+
|
|
95
|
+
const tickInterval = setInterval(runPass, (config.scheduler.tickIntervalSec || 10) * 1000);
|
|
96
|
+
tickInterval.unref?.();
|
|
97
|
+
|
|
98
|
+
return { runPass, stop: () => clearInterval(tickInterval) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { createScheduler };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/tokens.js
|
|
3
|
+
* @description Generates and hashes bearer tokens (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
|
+
const crypto = require('node:crypto');
|
|
17
|
+
|
|
18
|
+
// §12: tokens are 256-bit random, stored as SHA-256 hashes, shown once.
|
|
19
|
+
function generateToken(prefix){
|
|
20
|
+
const raw = crypto.randomBytes(32).toString('base64url');
|
|
21
|
+
return prefix ? `${prefix}_${raw}` : raw;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hashToken(token){
|
|
25
|
+
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { generateToken, hashToken };
|