@andrian.yablonskyy/thub-coordinator 1.0.38 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/thub-admin.js CHANGED
@@ -30,7 +30,6 @@ const { loadConfig } = require('../src/config'),
30
30
  { createAgentsService } = require('../src/services/agents'),
31
31
  { createGroupsService } = require('../src/services/groups'),
32
32
  { createAdminUsersService } = require('../src/services/admin-users'),
33
- { createArtifactsService } = require('../src/services/artifacts'),
34
33
  { createJobsService } = require('../src/services/jobs'),
35
34
  { createCleanupService } = require('../src/services/cleanup'),
36
35
  { generateToken } = require('../src/services/tokens'),
@@ -46,7 +45,7 @@ function usage(){
46
45
  thub-admin resource maintenance <resourceId> --on|--off
47
46
  thub-admin jobs reset --yes Cancel every queued/assigned/preparing/running job
48
47
  thub-admin jobs clean --yes [--before "dd/mm/yyyy HH:MM:SS"]
49
- Permanently delete finished jobs (with logs and artifacts) and
48
+ Permanently delete finished jobs (with their logs) and
50
49
  history from before then (local time; default: now), then
51
50
  compact the database file
52
51
  thub-admin group add <name> [--comment <text>]
@@ -134,8 +133,7 @@ function main(){
134
133
  registry = createRegistryService(db, { bus, events }),
135
134
  agents = createAgentsService(db, { events }),
136
135
  adminUsers = createAdminUsersService(db),
137
- artifacts = createArtifactsService(db, { config }),
138
- jobs = createJobsService(db, { bus, events, registry, artifacts, config }),
136
+ jobs = createJobsService(db, { bus, events, registry, config }),
139
137
  groups = createGroupsService(db, { events, registry });
140
138
 
141
139
  if (cmd === 'create-admin'){
@@ -193,7 +191,7 @@ function main(){
193
191
  if (cmd === 'jobs' && sub === 'clean'){
194
192
  const { flags } = parseFlags(rest);
195
193
  if (!flags.yes){
196
- console.error('This permanently deletes finished jobs and their logs/artifacts. Re-run with --yes to confirm.');
194
+ console.error('This permanently deletes finished jobs and their logs. Re-run with --yes to confirm.');
197
195
  process.exit(4);
198
196
  }
199
197
  const before = flags.before ? parseDateTime(flags.before) : new Date();
package/config.json CHANGED
@@ -20,11 +20,6 @@
20
20
  "maxTimeoutSec": 14400
21
21
  },
22
22
  "retention": {
23
- "logRetentionDays": 14,
24
- "artifactRetentionDays": 30
25
- },
26
- "artifacts": {
27
- "maxUploadMb": 512,
28
- "linkTtlHours": 168
23
+ "jobRetention": "forever"
29
24
  }
30
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-coordinator",
3
- "version": "1.0.38",
3
+ "version": "1.0.39",
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": {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/public/js/log-viewer.js
3
- * @description Dashboard: streams or fetches a job's log output into the log viewer pane
3
+ * @description Dashboard: a job's log in the log viewer pane — loaded from its end, earlier lines as you scroll up, live lines streamed
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -21,7 +21,11 @@
21
21
  const jobId = pre.dataset.jobId,
22
22
  jobActive = pre.dataset.jobActive === 'true',
23
23
  filter = document.getElementById('stream-filter'),
24
- lines = [],
24
+ info = document.getElementById('log-info'),
25
+ loadAllButton = document.getElementById('log-load-all'),
26
+ PAGE = 1000, // lines per page (routes.js LOG_PAGE)
27
+ PAGE_ALL = 5000, // per request when loading everything
28
+ NEAR_TOP_PX = 150,
25
29
  // Same dd/mm/yyyy HH:MM:SS, 24-hour format and profile time zone as
26
30
  // every server-rendered date (thub-common formatDateTime).
27
31
  timeFormat = new Intl.DateTimeFormat('en-GB', {
@@ -35,6 +39,16 @@
35
39
  hourCycle: 'h23'
36
40
  });
37
41
 
42
+ // What's loaded, oldest first: a window ending at the log's end. `firstSeq`
43
+ // is where the next earlier page ends; `total` counts every line the job
44
+ // has (loaded or not), live lines included.
45
+ let lines = [],
46
+ firstSeq = null,
47
+ hasMore = false,
48
+ total = 0,
49
+ lastSeq = 0, // newest log line we have (for the live stream to follow on)
50
+ loading = false;
51
+
38
52
  function fmtTime(iso){
39
53
  const d = new Date(iso);
40
54
  if (Number.isNaN(d.getTime())){
@@ -44,8 +58,28 @@
44
58
  return `${p.day}/${p.month}/${p.year} ${p.hour}:${p.minute}:${p.second}`;
45
59
  }
46
60
 
47
- // Search (views/mixins/log-viewer.pug): case-insensitive, literal text.
48
- // Matches are <mark>ed; `current` is the one the arrows step to.
61
+ const number = (n) => n.toLocaleString('en-GB');
62
+
63
+ function updateInfo(){
64
+ const logLines = lines.filter((l) => l.seq !== undefined).length;
65
+ if (loading){
66
+ info.textContent = 'Loading earlier lines…';
67
+ }
68
+ else if (!total){
69
+ info.textContent = jobActive ? 'No output yet.' : 'This job produced no output.';
70
+ }
71
+ else if (hasMore){
72
+ info.textContent = `Showing the last ${number(logLines)} of ${number(total)} lines — scroll up for earlier ones.`;
73
+ }
74
+ else {
75
+ info.textContent = `All ${number(total)} lines.`;
76
+ }
77
+ loadAllButton.hidden = !hasMore || loading;
78
+ }
79
+
80
+ // Search (views/mixins/log-viewer.pug): case-insensitive, literal text,
81
+ // over the loaded lines. Matches are <mark>ed; `current` is the one the
82
+ // arrows step to.
49
83
  const search = document.getElementById('log-search'),
50
84
  onlyMatching = document.getElementById('log-search-only'),
51
85
  count = document.getElementById('log-search-count'),
@@ -62,7 +96,7 @@
62
96
  function showCurrent(scroll){
63
97
  hits.forEach((el, i) => el.classList.toggle('current', i === current));
64
98
  if (current >= 0){
65
- count.textContent = `${current + 1} / ${hits.length}`;
99
+ count.textContent = `${current + 1} / ${hits.length}${hasMore ? ' loaded' : ''}`;
66
100
  if (scroll){
67
101
  const el = hits[current];
68
102
  pre.scrollTop = el.offsetTop - pre.clientHeight / 2;
@@ -78,22 +112,42 @@
78
112
  showCurrent(true);
79
113
  }
80
114
 
81
- // `live`: re-rendered for a newly streamed line — keep the reader's place
82
- // while searching; without a search, follow the end of the log as before.
83
- function render({ live = false } = {}){
115
+ // `mode` says what changed, and so where the view should stay:
116
+ // reset — first load, a new search or filter: the end (or first match)
117
+ // live — a streamed line: follow the end only if the reader is there
118
+ // prepend — earlier lines loaded above: keep the same lines in view
119
+ function render(mode = 'reset'){
84
120
  const wanted = filter.value,
85
121
  query = search.value.trim(),
122
+ atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 30,
123
+ fromBottom = pre.scrollHeight - pre.scrollTop,
124
+ scrollTop = pre.scrollTop,
125
+ hitsBefore = hits.length,
86
126
  shown = lines
87
127
  .filter((l) => !wanted || l.stream === wanted)
88
128
  .map((l) => `[${fmtTime(l.ts)}] [${l.stream}] ${l.line}`);
89
129
 
130
+ function place(){
131
+ if (mode === 'prepend'){
132
+ pre.scrollTop = pre.scrollHeight - fromBottom;
133
+ }
134
+ else if (mode === 'reset' || atBottom){
135
+ pre.scrollTop = pre.scrollHeight;
136
+ }
137
+ else {
138
+ pre.scrollTop = scrollTop;
139
+ }
140
+ }
141
+
142
+ updateInfo();
90
143
  if (!query){
91
144
  pre.textContent = shown.join('\n');
92
145
  hits = [];
93
146
  current = -1;
94
147
  count.hidden = true;
95
148
  prev.disabled = next.disabled = true;
96
- pre.scrollTop = pre.scrollHeight;
149
+ place();
150
+ fillIfShort();
97
151
  return;
98
152
  }
99
153
 
@@ -112,7 +166,6 @@
112
166
  out.push(html + escapeHtml(text.slice(last)));
113
167
  }
114
168
  }
115
- const scrollTop = pre.scrollTop;
116
169
  pre.innerHTML = out.join('\n');
117
170
  hits = [...pre.querySelectorAll('mark.thub-log-hit')];
118
171
  count.hidden = false;
@@ -120,18 +173,23 @@
120
173
  prev.disabled = next.disabled = hits.length < 2;
121
174
  if (!hits.length){
122
175
  current = -1;
123
- count.textContent = 'No matches';
176
+ count.textContent = hasMore ? 'No matches in loaded lines' : 'No matches';
177
+ place();
178
+ fillIfShort();
124
179
  return;
125
180
  }
126
- if (live){
127
- current = Math.min(Math.max(current, 0), hits.length - 1);
128
- showCurrent(false);
129
- pre.scrollTop = scrollTop;
130
- }
131
- else {
181
+ if (mode === 'reset'){
132
182
  current = 0;
133
183
  showCurrent(true);
134
184
  }
185
+ else {
186
+ // Earlier lines add their matches in front: the same match moves down.
187
+ current = mode === 'prepend' ? current + (hits.length - hitsBefore) : current;
188
+ current = Math.min(Math.max(current, 0), hits.length - 1);
189
+ showCurrent(false);
190
+ place();
191
+ }
192
+ fillIfShort();
135
193
  }
136
194
 
137
195
  // Streamed lines can arrive in bursts: render at most once per frame.
@@ -142,10 +200,75 @@
142
200
  queued = true;
143
201
  requestAnimationFrame(() => {
144
202
  queued = false;
145
- render({ live: true });
203
+ render('live');
146
204
  });
147
205
  }
148
206
 
207
+ // --- loading pages ------------------------------------------------------
208
+ async function fetchPage(before, limit){
209
+ const qs = new URLSearchParams({ limit: String(limit) });
210
+ if (before){
211
+ qs.set('before', String(before));
212
+ }
213
+ const res = await fetch(`/jobs/${encodeURIComponent(jobId)}/logs?${qs}`, { cache: 'no-store' });
214
+ if (!res.ok){
215
+ throw new Error(`HTTP ${res.status}`);
216
+ }
217
+ return res.json();
218
+ }
219
+
220
+ function absorb(page, where){
221
+ const fetched = page.lines;
222
+ if (fetched.length){
223
+ firstSeq = fetched[0].seq;
224
+ }
225
+ lines = where === 'prepend' ? [...fetched, ...lines] : fetched;
226
+ if (where !== 'prepend' && fetched.length){
227
+ lastSeq = fetched.at(-1).seq;
228
+ }
229
+ hasMore = page.hasMore;
230
+ total = Math.max(total, page.total);
231
+ }
232
+
233
+ async function loadEarlier(limit = PAGE){
234
+ if (loading || !hasMore){
235
+ return;
236
+ }
237
+ loading = true;
238
+ updateInfo();
239
+ try {
240
+ absorb(await fetchPage(firstSeq, limit), 'prepend');
241
+ }
242
+ catch {
243
+ info.textContent = 'Couldn’t load earlier lines — scroll up to try again.';
244
+ loading = false;
245
+ return;
246
+ }
247
+ loading = false;
248
+ render('prepend');
249
+ }
250
+
251
+ async function loadAll(){
252
+ while (hasMore && !loading){
253
+ await loadEarlier(PAGE_ALL);
254
+ }
255
+ }
256
+
257
+ // A filter or "only matching lines" can leave too little to scroll: keep
258
+ // loading earlier pages until there's something to scroll, or nothing left.
259
+ function fillIfShort(){
260
+ if (hasMore && !loading && pre.scrollHeight <= pre.clientHeight + NEAR_TOP_PX){
261
+ loadEarlier();
262
+ }
263
+ }
264
+
265
+ pre.addEventListener('scroll', () => {
266
+ if (pre.scrollTop < NEAR_TOP_PX){
267
+ loadEarlier();
268
+ }
269
+ }, { passive: true });
270
+ loadAllButton.addEventListener('click', loadAll);
271
+
149
272
  let typing;
150
273
  search.addEventListener('input', () => {
151
274
  clearTimeout(typing);
@@ -167,43 +290,52 @@
167
290
  onlyMatching.addEventListener('change', () => render());
168
291
  filter.addEventListener('change', () => render());
169
292
 
170
- // Already finished when the page loaded: the output will never change
171
- // again, so just fetch it once — no EventSource, no live connection, no
172
- // reload. (Opening a stream here used to trigger an auto-reload once it
173
- // immediately got the job's "end" event, which reloaded the page, which
174
- // re-opened the stream, which reloaded again — an infinite loop for
175
- // anyone viewing a finished job.)
176
- if (!jobActive){
177
- fetch(`/jobs/${jobId}/logs`)
178
- .then((r) => r.json())
179
- .then(({ lines: fetched }) => {
180
- lines.push(...fetched);
181
- render();
182
- })
183
- .catch(() => {});
184
- return;
185
- }
293
+ // --- start: the end of the log, then (active job) live lines after it ----
294
+ (async () => {
295
+ try {
296
+ absorb(await fetchPage(null, PAGE), 'replace');
297
+ }
298
+ catch {
299
+ info.textContent = 'Couldn’t load the log — reload the page to try again.';
300
+ return;
301
+ }
302
+ render();
186
303
 
187
- const source = new EventSource(`/jobs/${jobId}/stream`);
188
- source.addEventListener('log', (e) => {
189
- lines.push(JSON.parse(e.data));
190
- renderLive();
191
- });
192
- source.addEventListener('state', (e) => {
193
- const { state, resource } = JSON.parse(e.data);
194
- lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
195
- renderLive();
196
- });
197
- source.addEventListener('end', (e) => {
198
- const { state } = JSON.parse(e.data);
199
- lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
200
- renderLive();
201
- source.close();
202
- // The job was active when we opened this page and just finished —
203
- // reload once to pick up the now-final badge/artifacts list.
204
- setTimeout(() => window.location.reload(), 1500);
205
- });
206
- source.onerror = () => {
207
- // EventSource auto-reconnects; nothing to do here.
208
- };
304
+ // Already finished when the page loaded: the output will never change
305
+ // again — no EventSource, no live connection. (Opening a stream here used
306
+ // to reload the page on its immediate "end" event, forever.)
307
+ if (!jobActive){
308
+ return;
309
+ }
310
+ // Live lines start right after what's loaded; on a reconnect the
311
+ // browser resumes from the last one it got (Last-Event-ID).
312
+ const source = new EventSource(`/jobs/${encodeURIComponent(jobId)}/stream?after=${lastSeq}`);
313
+ source.addEventListener('log', (e) => {
314
+ const seq = Number(e.lastEventId) || undefined;
315
+ if (seq && seq <= lastSeq){
316
+ return; // already have it
317
+ }
318
+ lastSeq = seq ?? lastSeq;
319
+ lines.push({ ...JSON.parse(e.data), seq });
320
+ total += 1;
321
+ renderLive();
322
+ });
323
+ source.addEventListener('state', (e) => {
324
+ const { state, resource } = JSON.parse(e.data);
325
+ lines.push({ ts: new Date().toISOString(), stream: 'state', line: `-> ${state}${resource ? ' on ' + resource : ''}` });
326
+ renderLive();
327
+ });
328
+ source.addEventListener('end', (e) => {
329
+ const { state } = JSON.parse(e.data);
330
+ lines.push({ ts: new Date().toISOString(), stream: 'state', line: `job finished: ${state}` });
331
+ renderLive();
332
+ source.close();
333
+ // The job was active when we opened this page and just finished —
334
+ // reload once to pick up the now-final badge and details.
335
+ setTimeout(() => window.location.reload(), 1500);
336
+ });
337
+ source.onerror = () => {
338
+ // EventSource auto-reconnects; nothing to do here.
339
+ };
340
+ })();
209
341
  })();
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file scripts/install-default-config.js
3
3
  * @description npm postinstall: on a real global install, creates the Coordinator's directory layout
4
- * (~/.config/thub, ~/.thub and its artifacts/avatars/work subdirs) and, if it
4
+ * (~/.config/thub, ~/.thub and its avatars/work subdirs) and, if it
5
5
  * doesn't already exist, ~/.config/thub/coordinator.json with every default option and
6
6
  * a freshly generated sessionSecret (README §12, §13)
7
7
  *
@@ -52,7 +52,7 @@ function resolveTargetUser(){
52
52
  }
53
53
 
54
54
  // Default layout (README §13): config under ~/.config/thub, runtime data
55
- // (SQLite DB, uploaded artifacts, avatars, work files) under ~/.thub.
55
+ // (SQLite DB, avatars, work files) under ~/.thub.
56
56
  // An existing coordinator.json's own dataDir wins over the default, so a
57
57
  // re-install prepares (and the systemd unit allows writes to) the
58
58
  // directory the Coordinator will really use.
@@ -69,7 +69,7 @@ function targetPaths(user){
69
69
  // Written by the dashboard's "Update app" button, watched by
70
70
  // thub-coordinator-update.path (README §10.2).
71
71
  updateRequestFile: path.join(dataDir, 'update-request.json'),
72
- dataSubdirs: ['artifacts', 'avatars', 'work'].map((d) => path.join(dataDir, d))
72
+ dataSubdirs: ['avatars', 'work'].map((d) => path.join(dataDir, d))
73
73
  };
74
74
  }
75
75
 
package/src/api/admin.js CHANGED
@@ -134,7 +134,7 @@ function createAdminRouter({ services }){
134
134
  res.json({ canceled });
135
135
  });
136
136
 
137
- // Permanently deletes finished jobs and their logs/artifacts (§13.1).
137
+ // Permanently deletes finished jobs and their logs (§13.1).
138
138
  // Database cleanup (§9): optional `before` (ISO 8601; default now) —
139
139
  // finished jobs that ended before it, older history, then VACUUM.
140
140
  router.post('/jobs/clean-history', (req, res) => {
package/src/api/agent.js CHANGED
@@ -80,19 +80,13 @@ function createAgentRouter({ services, config }){
80
80
  });
81
81
 
82
82
  router.get('/jobs/:id/logs/stream', auth, (req, res) => {
83
- attachJobStream(req, res, { jobId: req.params.id, services, config });
83
+ attachJobStream(req, res, { jobId: req.params.id, services });
84
84
  });
85
85
 
86
+ // No artifacts are stored any more (README §9); an Agent older than that
87
+ // still asks after a finished job's `thub status`, so answer "none".
86
88
  router.get('/jobs/:id/artifacts', auth, (req, res) => {
87
- const artifacts = services.artifacts.listForJob(req.params.id).map((a) => ({
88
- id: a.id,
89
- name: a.name,
90
- size: a.size,
91
- sha256: a.sha256,
92
- contentType: a.content_type,
93
- url: services.artifacts.signedUrl(a)
94
- }));
95
- res.json({ artifacts });
89
+ res.json({ artifacts: [] });
96
90
  });
97
91
 
98
92
  router.get('/resources', auth, (req, res) => {
@@ -14,12 +14,9 @@
14
14
  'use strict';
15
15
 
16
16
  const express = require('express'),
17
- multer = require('multer'),
18
17
  { requireRole, requireJoinKey, appVersion } = require('../auth'),
19
18
  { JOB_STATES } = require('@andrian.yablonskyy/thub-common');
20
19
 
21
- const upload = multer({ dest: require('node:os').tmpdir() });
22
-
23
20
  function requireOwnResource(req, res, next){
24
21
  if (req.resource.id !== req.params.id){
25
22
  return res.status(403).json({ error: 'Token does not match resource' });
@@ -211,14 +208,12 @@ function createResourceRouter({ services, config }){
211
208
  }
212
209
  });
213
210
 
214
- router.post('/jobs/:id/artifacts', auth, requireOwnJob(services), upload.any(), (req, res, next) => {
215
- try {
216
- const stored = services.artifacts.storeUploaded(req.params.id, req.files || []);
217
- res.status(201).json({ artifacts: stored.map((a) => ({ id: a.id, name: a.name, size: a.size })) });
218
- }
219
- catch (err){
220
- next(err);
221
- }
211
+ // Artifacts are no longer stored on the Coordinator (README §9). A Client
212
+ // older than that still uploads its results after each job and would end
213
+ // the job as ERROR on a 404, so accept the upload and discard it unread.
214
+ router.post('/jobs/:id/artifacts', auth, requireOwnJob(services), (req, res) => {
215
+ req.resume();
216
+ req.on('end', () => res.status(201).json({ artifacts: [] }));
222
217
  });
223
218
 
224
219
  router.post('/jobs/:id/result', auth, requireOwnJob(services), (req, res, next) => {
package/src/api/sse.js CHANGED
@@ -15,11 +15,12 @@
15
15
 
16
16
  const { TERMINAL_JOB_STATES, JOB_STATES } = require('@andrian.yablonskyy/thub-common');
17
17
 
18
- const WAITING_CHECK_MS = 15_000;
18
+ const WAITING_CHECK_MS = 15_000,
19
+ REPLAY_PAGE = 2000;
19
20
 
20
21
  // §6.4: log / state / end events, resumable via Last-Event-ID (§7: "the
21
22
  // Agent reconnects with exponential backoff and resumes from the last seq").
22
- function attachJobStream(req, res, { jobId, services, config }){
23
+ function attachJobStream(req, res, { jobId, services }){
23
24
  const { jobs, logs, registry, bus } = services,
24
25
 
25
26
  job = jobs.get(jobId);
@@ -47,17 +48,19 @@ function attachJobStream(req, res, { jobId, services, config }){
47
48
  res.write(`data: ${JSON.stringify(data)}\n\n`);
48
49
  }
49
50
 
50
- for (const line of logs.listSince(jobId, afterSeq)){
51
- writeEvent('log', { ts: line.ts, stream: line.stream, line: line.line }, line.seq);
52
- }
53
-
54
- function artifactsUrl(){
55
- return `${config.publicUrl}/jobs/${jobId}#artifacts`;
51
+ // Replay everything after `afterSeq`, in pages (listSince stops at 500 a
52
+ // call — replaying one page used to skip lines 501.. of a longer log). All
53
+ // synchronous, so no live line can slip in between replay and subscribing.
54
+ for (let seq = afterSeq, page; (page = logs.listSince(jobId, seq, REPLAY_PAGE)).length;){
55
+ for (const line of page){
56
+ writeEvent('log', { ts: line.ts, stream: line.stream, line: line.line }, line.seq);
57
+ }
58
+ seq = page.at(-1).seq;
56
59
  }
57
60
 
58
61
  const current = jobs.get(jobId);
59
62
  if (TERMINAL_JOB_STATES.has(current.state)){
60
- writeEvent('end', { state: current.state, artifactsUrl: artifactsUrl() });
63
+ writeEvent('end', { state: current.state });
61
64
  return res.end();
62
65
  }
63
66
 
@@ -97,7 +100,7 @@ function attachJobStream(req, res, { jobId, services, config }){
97
100
  if (evt.jobId !== jobId){
98
101
  return;
99
102
  }
100
- writeEvent('end', { state: evt.state, artifactsUrl: artifactsUrl() });
103
+ writeEvent('end', { state: evt.state });
101
104
  cleanup();
102
105
  res.end();
103
106
  };
package/src/config.js CHANGED
@@ -28,6 +28,12 @@ const DEFAULTS = {
28
28
  trustProxy: 'loopback',
29
29
  dataDir: path.join(process.cwd(), '.data'),
30
30
  sessionSecret: 'dev-only-change-me',
31
+ session: {
32
+ // The dashboard session cookie's Secure flag (server.js cookieSecure):
33
+ // "auto" = Secure whenever publicUrl is https:// (else per request:
34
+ // Secure only on a connection that is itself HTTPS); true / false force it.
35
+ secureCookie: 'auto'
36
+ },
31
37
  // Shared secret Clients present to self-register (see api/resource.js).
32
38
  // null disables auto-registration entirely — set it explicitly to turn it on.
33
39
  clientJoinKey: null,
@@ -46,19 +52,16 @@ const DEFAULTS = {
46
52
  defaultTimeoutSec: 1800,
47
53
  maxTimeoutSec: 14400
48
54
  },
55
+ // logRetentionDays, artifactRetentionDays and the `artifacts` section of
56
+ // older configs are no longer read: a job's log lines live as long as the
57
+ // job, and the Coordinator stores no artifacts (README §9).
49
58
  retention: {
50
- logRetentionDays: 14,
51
- artifactRetentionDays: 30,
52
- // How long a finished job (with its logs, artifacts and events) is kept
59
+ // How long a finished job (with its logs and events) is kept
53
60
  // before the hourly retention task wipes it (services/cleanup.js):
54
61
  // 1w | 2w | 1m | 3m | 6m | forever. `forever` keeps everything until a
55
62
  // manual cleanup (dashboard Clean up database / thub-admin jobs clean).
56
63
  jobRetention: 'forever'
57
64
  },
58
- artifacts: {
59
- maxUploadMb: 512,
60
- linkTtlHours: 168
61
- },
62
65
  // New-version check of the Coordinator/Agent/Client packages (README
63
66
  // §10.2), every 15 minutes so a new Coordinator's "Update app" button
64
67
  // shows up promptly. 0 disables the periodic check; registry null = the
@@ -122,6 +125,12 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
122
125
  config.clientJoinKey = process.env.THUB_CLIENT_JOIN_KEY;
123
126
  }
124
127
 
128
+ if (![true, false, 'auto'].includes(config.session.secureCookie)){
129
+ throw new Error(
130
+ `session.secureCookie must be "auto", true or false (got ${JSON.stringify(config.session.secureCookie)}) in ${candidate || 'the config'}`
131
+ );
132
+ }
133
+
125
134
  if (!Object.hasOwn(JOB_RETENTION, config.retention.jobRetention)){
126
135
  throw new Error(
127
136
  `retention.jobRetention must be one of: ${Object.keys(JOB_RETENTION).join(', ')} ` +
@@ -136,12 +145,10 @@ function loadConfig(configPath = process.env.THUB_COORDINATOR_CONFIG){
136
145
  config.host = host;
137
146
  config.port = Number(port);
138
147
  config.dbPath = path.join(config.dataDir, 'thub.db');
139
- config.artifactsDir = path.join(config.dataDir, 'artifacts');
140
148
  config.workDir = path.join(config.dataDir, 'work');
141
149
  config.avatarsDir = path.join(config.dataDir, 'avatars');
142
150
 
143
151
  ensureWritableDir(config.dataDir, config.configPath);
144
- ensureWritableDir(config.artifactsDir, config.configPath);
145
152
  ensureWritableDir(config.avatarsDir, config.configPath);
146
153
 
147
154
  return config;
@@ -0,0 +1,10 @@
1
+ -- Dashboard sessions (services/session-store.js), instead of express-session's
2
+ -- in-memory store: they survive a restart, and expired ones are pruned.
3
+ -- `expires` is epoch milliseconds (the session cookie's own expiry).
4
+ CREATE TABLE sessions (
5
+ sid TEXT PRIMARY KEY,
6
+ sess TEXT NOT NULL,
7
+ expires INTEGER NOT NULL
8
+ );
9
+
10
+ CREATE INDEX idx_sessions_expires ON sessions(expires);
@@ -0,0 +1,4 @@
1
+ -- Job artifacts are no longer uploaded to or stored on the Coordinator
2
+ -- (README §9): Clients keep a job's files only in its workspace, which they
3
+ -- delete when the job ends. server.js removes <dataDir>/artifacts on startup.
4
+ DROP TABLE IF EXISTS artifacts;
@@ -170,7 +170,7 @@ function seedHistory(services, agentId, resourceIds){
170
170
  // stay IDLE instead of going OUT_OF_SERVICE) and play out any job the
171
171
  // scheduler assigns them — accept, PREPARING, RUNNING with streamed log
172
172
  // lines, then a PASSED (mostly) or FAILED result — through the same
173
- // services the Client API uses, so SSE, durations and artifacts all work.
173
+ // services the Client API uses, so SSE and durations all work.
174
174
  function startVirtualClients(services, config){
175
175
  const { registry, jobs, logs, bus } = services,
176
176
  intervalMs = config.heartbeat.intervalSec * 1000,