@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,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/jobs.js
|
|
3
|
+
* @description Job lifecycle service: creation, state transitions, and validation against the job spec
|
|
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 { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
17
|
+
|
|
18
|
+
function rowToJob(row){
|
|
19
|
+
if (!row){
|
|
20
|
+
return row;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
...row,
|
|
24
|
+
spec: JSON.parse(row.spec),
|
|
25
|
+
summary: row.summary ? JSON.parse(row.summary) : null
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A-00001.. for CI/CD jobs, M-00001.. for manual `thub run` jobs from a
|
|
30
|
+
// developer's own machine — separate series (each own counter row) rather
|
|
31
|
+
// than a shared counter with just a different letter, so e.g. A-00042
|
|
32
|
+
// doesn't imply 41 manual runs happened first.
|
|
33
|
+
const JOB_ID_PREFIXES = { ci: 'A', cli: 'M' };
|
|
34
|
+
|
|
35
|
+
function nextJobId(db, source){
|
|
36
|
+
const prefix = JOB_ID_PREFIXES[source];
|
|
37
|
+
if (!prefix){
|
|
38
|
+
throw new Error(`Unknown job source "${source}"`);
|
|
39
|
+
}
|
|
40
|
+
const counterName = `job_id_${prefix.toLowerCase()}`,
|
|
41
|
+
run = db.transaction(() => {
|
|
42
|
+
db.prepare('UPDATE counters SET value = value + 1 WHERE name = ?').run(counterName);
|
|
43
|
+
const { value } = db.prepare('SELECT value FROM counters WHERE name = ?').get(counterName);
|
|
44
|
+
return value;
|
|
45
|
+
}),
|
|
46
|
+
n = run();
|
|
47
|
+
return `${prefix}-${String(n).padStart(5, '0')}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createJobsService(db, { bus, events, registry, artifacts, config }){
|
|
51
|
+
function get(id){
|
|
52
|
+
return rowToJob(db.prepare('SELECT * FROM jobs WHERE id = ?').get(id));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function list({ state, source, agentId, resourceId, mine, limit = 50 } = {}){
|
|
56
|
+
let sql = 'SELECT * FROM jobs WHERE 1=1';
|
|
57
|
+
const params = [];
|
|
58
|
+
if (state){
|
|
59
|
+
sql += ' AND state = ?';
|
|
60
|
+
params.push(state);
|
|
61
|
+
}
|
|
62
|
+
if (source){
|
|
63
|
+
sql += ' AND source = ?';
|
|
64
|
+
params.push(source);
|
|
65
|
+
}
|
|
66
|
+
if (agentId && mine){
|
|
67
|
+
sql += ' AND agent_id = ?';
|
|
68
|
+
params.push(agentId);
|
|
69
|
+
}
|
|
70
|
+
if (resourceId){
|
|
71
|
+
sql += ' AND resource_id = ?';
|
|
72
|
+
params.push(resourceId);
|
|
73
|
+
}
|
|
74
|
+
sql += ' ORDER BY created_at DESC LIMIT ?';
|
|
75
|
+
params.push(limit);
|
|
76
|
+
return db.prepare(sql).all(...params).map(rowToJob);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function countQueuedForAgent(agentId){
|
|
80
|
+
return db
|
|
81
|
+
.prepare('SELECT COUNT(*) AS n FROM jobs WHERE agent_id = ? AND state IN (\'QUEUED\',\'ASSIGNED\')')
|
|
82
|
+
.get(agentId).n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Agent: POST /jobs (§6.1, §4.3). Rejects unsatisfiable label requests
|
|
86
|
+
// immediately (§5.4) instead of letting them starve in the queue.
|
|
87
|
+
function create({ agentId, source, spec: rawSpec }){
|
|
88
|
+
const { valid, spec, errors } = validateJobSpec(rawSpec);
|
|
89
|
+
if (!valid){
|
|
90
|
+
throw Object.assign(new Error(`Invalid job spec: ${errors.join('; ')}`), { status: 400 });
|
|
91
|
+
}
|
|
92
|
+
spec.source = source;
|
|
93
|
+
|
|
94
|
+
if (!registry.everSatisfiable(spec.target.type, spec.target.labels, spec.target.group)){
|
|
95
|
+
throw Object.assign(
|
|
96
|
+
new Error(
|
|
97
|
+
`No registered resource can ever satisfy type=${spec.target.type} labels=${spec.target.labels.join(',')}` +
|
|
98
|
+
(spec.target.group ? ` group=${spec.target.group}` : '')
|
|
99
|
+
),
|
|
100
|
+
{ status: 422 }
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (countQueuedForAgent(agentId) >= config.scheduler.maxQueuedPerAgent){
|
|
105
|
+
throw Object.assign(new Error('Too many queued jobs for this agent'), { status: 429 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const timeoutSec = Math.min(
|
|
109
|
+
spec.timeoutSec || config.jobs.defaultTimeoutSec,
|
|
110
|
+
config.jobs.maxTimeoutSec
|
|
111
|
+
),
|
|
112
|
+
id = nextJobId(db, source),
|
|
113
|
+
now = new Date().toISOString();
|
|
114
|
+
|
|
115
|
+
db.prepare(
|
|
116
|
+
`INSERT INTO jobs (id, agent_id, source, state, spec, priority, attempt, created_at)
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)`
|
|
118
|
+
).run(id, agentId, source, JOB_STATES.QUEUED, JSON.stringify({ ...spec, timeoutSec }), spec.priority, now);
|
|
119
|
+
|
|
120
|
+
events.record('job', id, 'job.created', { source, target: spec.target });
|
|
121
|
+
bus.emit('job.queued', { jobId: id });
|
|
122
|
+
return get(id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setState(id, state, extra = {}){
|
|
126
|
+
const job = get(id);
|
|
127
|
+
if (!job){
|
|
128
|
+
throw Object.assign(new Error('Unknown job'), { status: 404 });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fields = ['state = ?'],
|
|
132
|
+
params = [state],
|
|
133
|
+
now = new Date().toISOString();
|
|
134
|
+
|
|
135
|
+
if (state === JOB_STATES.ASSIGNED){
|
|
136
|
+
fields.push('resource_id = ?', 'assigned_at = ?');
|
|
137
|
+
params.push(extra.resourceId, now);
|
|
138
|
+
}
|
|
139
|
+
if (state === JOB_STATES.RUNNING && !job.started_at){
|
|
140
|
+
fields.push('started_at = ?');
|
|
141
|
+
params.push(now);
|
|
142
|
+
}
|
|
143
|
+
if (TERMINAL_JOB_STATES.has(state)){
|
|
144
|
+
fields.push('finished_at = ?');
|
|
145
|
+
params.push(now);
|
|
146
|
+
}
|
|
147
|
+
if (extra.message !== undefined){
|
|
148
|
+
fields.push('message = ?');
|
|
149
|
+
params.push(extra.message);
|
|
150
|
+
}
|
|
151
|
+
if (extra.exitCode !== undefined){
|
|
152
|
+
fields.push('exit_code = ?');
|
|
153
|
+
params.push(extra.exitCode);
|
|
154
|
+
}
|
|
155
|
+
if (extra.summary !== undefined){
|
|
156
|
+
fields.push('summary = ?');
|
|
157
|
+
params.push(JSON.stringify(extra.summary));
|
|
158
|
+
}
|
|
159
|
+
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);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
params.push(id);
|
|
166
|
+
db.prepare(`UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`).run(...params);
|
|
167
|
+
|
|
168
|
+
events.record('job', id, 'job.state', { from: job.state, to: state, ...extra });
|
|
169
|
+
bus.emit('job.state', { jobId: id, state, resourceId: job.resource_id, ...extra });
|
|
170
|
+
|
|
171
|
+
if (state === JOB_STATES.QUEUED){
|
|
172
|
+
bus.emit('job.queued', { jobId: id });
|
|
173
|
+
}
|
|
174
|
+
if (TERMINAL_JOB_STATES.has(state)){
|
|
175
|
+
bus.emit('job.finished', { jobId: id, state });
|
|
176
|
+
// LOST means the resource is presumed OUT_OF_SERVICE (that's why the
|
|
177
|
+
// job went LOST) — it only comes back via a real heartbeat (§4.1),
|
|
178
|
+
// not by virtue of its job ending.
|
|
179
|
+
if (job.resource_id && state !== JOB_STATES.LOST){
|
|
180
|
+
registry.markIdleAfterJob(job.resource_id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return get(id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Agent: POST /jobs/:id/cancel
|
|
187
|
+
function cancel(id, { agentId, isAdmin }){
|
|
188
|
+
const job = get(id);
|
|
189
|
+
if (!job){
|
|
190
|
+
throw Object.assign(new Error('Unknown job'), { status: 404 });
|
|
191
|
+
}
|
|
192
|
+
if (!isAdmin && job.agent_id !== agentId){
|
|
193
|
+
throw Object.assign(new Error('Not your job'), { status: 403 });
|
|
194
|
+
}
|
|
195
|
+
if (!ACTIVE_JOB_STATES.has(job.state)){
|
|
196
|
+
return job;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (job.resource_id){
|
|
200
|
+
bus.emit('command', { resourceId: job.resource_id, command: 'cancel-job', jobId: id });
|
|
201
|
+
}
|
|
202
|
+
return setState(id, JOB_STATES.CANCELED);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Resource: POST /jobs/:id/result — final verdict from the test runner.
|
|
206
|
+
function applyResult(id, resourceId, { state, exitCode, summary }){
|
|
207
|
+
const job = get(id);
|
|
208
|
+
if (!job || job.resource_id !== resourceId){
|
|
209
|
+
throw Object.assign(new Error('Job not assigned to this resource'), { status: 403 });
|
|
210
|
+
}
|
|
211
|
+
if (![JOB_STATES.PASSED, JOB_STATES.FAILED, JOB_STATES.ERROR].includes(state)){
|
|
212
|
+
throw Object.assign(new Error('Invalid result state'), { status: 400 });
|
|
213
|
+
}
|
|
214
|
+
return setState(id, state, { exitCode, summary });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Called by the sweeper for ASSIGNED jobs whose accept deadline passed,
|
|
218
|
+
// and by the timeout checker for PREPARING/RUNNING jobs.
|
|
219
|
+
function requeueUnacked(id){
|
|
220
|
+
return setState(id, JOB_STATES.QUEUED);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function markLost(id){
|
|
224
|
+
const job = get(id);
|
|
225
|
+
if (job.state === JOB_STATES.LOST || TERMINAL_JOB_STATES.has(job.state)){
|
|
226
|
+
return job;
|
|
227
|
+
}
|
|
228
|
+
const spec = job.spec;
|
|
229
|
+
setState(id, JOB_STATES.LOST);
|
|
230
|
+
if (config.scheduler.requeueOnLost && job.attempt < 2){
|
|
231
|
+
return setState(id, JOB_STATES.QUEUED);
|
|
232
|
+
}
|
|
233
|
+
return get(id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function checkTimeouts(){
|
|
237
|
+
const now = Date.now(),
|
|
238
|
+
active = db
|
|
239
|
+
.prepare(
|
|
240
|
+
'SELECT * FROM jobs WHERE state IN (\'PREPARING\',\'RUNNING\') AND started_at IS NOT NULL'
|
|
241
|
+
)
|
|
242
|
+
.all()
|
|
243
|
+
.map(rowToJob);
|
|
244
|
+
for (const job of active){
|
|
245
|
+
const timeoutSec = job.spec.timeoutSec || config.jobs.defaultTimeoutSec,
|
|
246
|
+
startedAt = new Date(job.started_at).getTime();
|
|
247
|
+
if (now - startedAt > timeoutSec * 1000){
|
|
248
|
+
if (job.resource_id){
|
|
249
|
+
bus.emit('command', { resourceId: job.resource_id, command: 'cancel-job', jobId: job.id });
|
|
250
|
+
}
|
|
251
|
+
setState(job.id, JOB_STATES.TIMEOUT);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// QUEUED jobs also expire against their own timeout so they don't wait forever (§5.4).
|
|
256
|
+
const queued = db.prepare('SELECT * FROM jobs WHERE state = \'QUEUED\'').all().map(rowToJob);
|
|
257
|
+
for (const job of queued){
|
|
258
|
+
const timeoutSec = job.spec.timeoutSec || config.jobs.defaultTimeoutSec,
|
|
259
|
+
createdAt = new Date(job.created_at).getTime();
|
|
260
|
+
if (now - createdAt > timeoutSec * 1000){
|
|
261
|
+
setState(job.id, JOB_STATES.TIMEOUT);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function checkAssignAcks(){
|
|
267
|
+
const cutoff = new Date(Date.now() - config.scheduler.assignAckTimeoutSec * 1000).toISOString(),
|
|
268
|
+
stale = db
|
|
269
|
+
.prepare('SELECT * FROM jobs WHERE state = \'ASSIGNED\' AND assigned_at <= ?')
|
|
270
|
+
.all(cutoff)
|
|
271
|
+
.map(rowToJob);
|
|
272
|
+
for (const job of stale){
|
|
273
|
+
requeueUnacked(job.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Coordinator restart reconciliation (§15): ASSIGNED -> QUEUED immediately;
|
|
278
|
+
// PREPARING/RUNNING keep their state and wait for the Client's next heartbeat.
|
|
279
|
+
function reconcileOnStartup(){
|
|
280
|
+
const stuck = db.prepare('SELECT id FROM jobs WHERE state = \'ASSIGNED\'').all();
|
|
281
|
+
for (const { id }of stuck){
|
|
282
|
+
requeueUnacked(id);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Admin: "reset the queue" — cancel every job that's currently queued,
|
|
287
|
+
// assigned or running. Each cancel() already notifies the resource
|
|
288
|
+
// holding a job (if any) via the usual cancel-job command.
|
|
289
|
+
function resetQueue(){
|
|
290
|
+
const placeholders = [...ACTIVE_JOB_STATES].map(() => '?').join(','),
|
|
291
|
+
active = db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders})`).all(...ACTIVE_JOB_STATES);
|
|
292
|
+
for (const { id }of active){
|
|
293
|
+
cancel(id, { isAdmin: true });
|
|
294
|
+
}
|
|
295
|
+
return active.length;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Admin: "clean the queue" — permanently delete finished jobs (and their
|
|
299
|
+
// logs/artifacts, on disk and in the DB), for when the history itself,
|
|
300
|
+
// not just active work, needs clearing out. Unlike the nightly retention
|
|
301
|
+
// sweep (§9), this runs on demand and isn't limited to old jobs.
|
|
302
|
+
function cleanHistory(){
|
|
303
|
+
const placeholders = [...TERMINAL_JOB_STATES].map(() => '?').join(','),
|
|
304
|
+
rows = db.prepare(`SELECT id FROM jobs WHERE state IN (${placeholders})`).all(...TERMINAL_JOB_STATES),
|
|
305
|
+
ids = rows.map((r) => r.id);
|
|
306
|
+
if (ids.length === 0){
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tx = db.transaction(() => {
|
|
311
|
+
for (const id of ids){
|
|
312
|
+
artifacts.deleteJobArtifacts(id);
|
|
313
|
+
db.prepare('DELETE FROM artifacts WHERE job_id = ?').run(id);
|
|
314
|
+
db.prepare('DELETE FROM job_logs WHERE job_id = ?').run(id);
|
|
315
|
+
db.prepare('DELETE FROM events WHERE entity = \'job\' AND entity_id = ?').run(id);
|
|
316
|
+
db.prepare('DELETE FROM jobs WHERE id = ?').run(id);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
tx();
|
|
320
|
+
events.record('job', 'bulk', 'jobs.cleaned', { count: ids.length });
|
|
321
|
+
return ids.length;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A Client re-registering (fresh process, §5.1) is a definitive signal
|
|
325
|
+
// that whatever the previous process was doing is abandoned — mark any
|
|
326
|
+
// job still pointing at that resource LOST instead of leaving it
|
|
327
|
+
// orphaned until the heartbeat sweeper eventually notices.
|
|
328
|
+
bus.on('resource.reregistered', ({ resourceId }) => {
|
|
329
|
+
const placeholders = [...ACTIVE_JOB_STATES].map(() => '?').join(','),
|
|
330
|
+
stale = db
|
|
331
|
+
.prepare(`SELECT id FROM jobs WHERE resource_id = ? AND state IN (${placeholders})`)
|
|
332
|
+
.all(resourceId, ...ACTIVE_JOB_STATES);
|
|
333
|
+
for (const { id }of stale){
|
|
334
|
+
markLost(id);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
get,
|
|
340
|
+
list,
|
|
341
|
+
create,
|
|
342
|
+
setState,
|
|
343
|
+
cancel,
|
|
344
|
+
applyResult,
|
|
345
|
+
requeueUnacked,
|
|
346
|
+
markLost,
|
|
347
|
+
checkTimeouts,
|
|
348
|
+
checkAssignAcks,
|
|
349
|
+
reconcileOnStartup,
|
|
350
|
+
resetQueue,
|
|
351
|
+
cleanHistory
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = { createJobsService };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/src/services/logs.js
|
|
3
|
+
* @description Job log service: stores batched log lines with a per-job sequence and fans them out (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
|
+
// §3.1 Log service: accepts batched log lines, assigns a monotonically
|
|
17
|
+
// increasing seq per job, stores them, and fans them out over the bus for
|
|
18
|
+
// SSE subscribers (§6.4). Retention is handled separately (§9).
|
|
19
|
+
function createLogsService(db, { bus }){
|
|
20
|
+
const insert = db.prepare(
|
|
21
|
+
'INSERT INTO job_logs (job_id, seq, ts, stream, line) VALUES (?, ?, ?, ?, ?)'
|
|
22
|
+
),
|
|
23
|
+
nextSeqStmt = db.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM job_logs WHERE job_id = ?');
|
|
24
|
+
|
|
25
|
+
function appendBatch(jobId, lines){
|
|
26
|
+
const tx = db.transaction((batch) => {
|
|
27
|
+
let seq = nextSeqStmt.get(jobId).maxSeq;
|
|
28
|
+
const inserted = [];
|
|
29
|
+
for (const line of batch){
|
|
30
|
+
seq += 1;
|
|
31
|
+
const ts = line.ts || new Date().toISOString();
|
|
32
|
+
insert.run(jobId, seq, ts, line.stream || 'runner', line.line);
|
|
33
|
+
inserted.push({ jobId, seq, ts, stream: line.stream || 'runner', line: line.line });
|
|
34
|
+
}
|
|
35
|
+
return inserted;
|
|
36
|
+
}),
|
|
37
|
+
inserted = tx(lines);
|
|
38
|
+
for (const entry of inserted){
|
|
39
|
+
bus.emit('job.log', entry);
|
|
40
|
+
}
|
|
41
|
+
return inserted;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listSince(jobId, afterSeq = 0, limit = 500){
|
|
45
|
+
return db
|
|
46
|
+
.prepare('SELECT * FROM job_logs WHERE job_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?')
|
|
47
|
+
.all(jobId, afterSeq, limit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Used when a job finishes: fold job_logs into a flat console.log artifact.
|
|
51
|
+
function renderConsoleLog(jobId){
|
|
52
|
+
const rows = db
|
|
53
|
+
.prepare('SELECT * FROM job_logs WHERE job_id = ? ORDER BY seq ASC')
|
|
54
|
+
.all(jobId);
|
|
55
|
+
return rows.map((r) => `[${r.ts}] [${r.stream}] ${r.line}`).join('\n') + (rows.length ? '\n' : '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function purgeOlderThan(days){
|
|
59
|
+
const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString();
|
|
60
|
+
db.prepare(
|
|
61
|
+
`DELETE FROM job_logs WHERE job_id IN (
|
|
62
|
+
SELECT id FROM jobs WHERE finished_at IS NOT NULL AND finished_at <= ?
|
|
63
|
+
)`
|
|
64
|
+
).run(cutoff);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { appendBatch, listSince, renderConsoleLog, purgeOlderThan };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { createLogsService };
|