commit 2c4cae92a912e4952bcbf6fd2dcf6119b1523320
parent 2b2db4d40d286b825ed500f7314f92eaf995c063
Author: finwo <finwo@pm.me>
Date: Wed, 23 Sep 2026 00:01:38 +0200
Show what each worker machine can do
Diffstat:
21 files changed, 468 insertions(+), 18 deletions(-)
diff --git a/assets/style.css b/assets/style.css
@@ -85,6 +85,8 @@ tbody tr:hover { background: #1b1f23; }
.badge.skipped { background: var(--skipped); }
.badge.cancelled { background: var(--cancelled); }
.badge.pending, .badge.queued { background: var(--queued); }
+.badge.online { background: var(--success); }
+.badge.offline { background: var(--skipped); }
.panel {
background: var(--panel);
diff --git a/deploy/hub/conductor-worker.md b/deploy/hub/conductor-worker.md
@@ -11,8 +11,8 @@ Source and issues: https://github.com/finwo/conductor
| Tag | What it is |
| ---------------- | ------------------------------------------ |
| `latest` | the most recent release |
-| `0.5.0` | an exact version |
-| `0.5` | the latest patch of that minor series |
+| `0.6.0` | an exact version |
+| `0.6` | the latest patch of that minor series |
| `main` | the tip of the default branch |
Built for `linux/amd64`, `linux/arm64` and `linux/riscv64`.
diff --git a/deploy/hub/conductor.md b/deploy/hub/conductor.md
@@ -10,8 +10,8 @@ Source and issues: https://github.com/finwo/conductor
| Tag | What it is |
| ---------------- | ------------------------------------------ |
| `latest` | the most recent release |
-| `0.5.0` | an exact version |
-| `0.5` | the latest patch of that minor series |
+| `0.6.0` | an exact version |
+| `0.6` | the latest patch of that minor series |
| `main` | the tip of the default branch |
Built for `linux/amd64`, `linux/arm64` and `linux/riscv64`.
diff --git a/docs/deployment.md b/docs/deployment.md
@@ -224,6 +224,10 @@ Logs go by age alone. Sweeping a log deletes the bytes and leaves the task,
so a job stays explainable after its output is gone, and the interface can
tell an expired log apart from one that was never written.
+The same sweep forgets machines that have stopped claiming, after
+`retention.worker_agent_keep_hours` (default 24, `0` keeps them for ever).
+A machine that comes back reappears on its next claim.
+
### What a first sweep will do
Upgrading applies these defaults to everything already stored. On an
diff --git a/docs/worker.md b/docs/worker.md
@@ -61,7 +61,7 @@ node src/worker/agent.js --config worker.json
| Key | Default | Description |
| ---------------- | -------------------- | ----------------------------------------------- |
| `conductor_url` | `http://127.0.0.1:8080` | Base URL of the conductor. |
-| `name` | hostname | Shown against tasks this worker ran. |
+| `name` | hostname | Identifies this machine to the conductor. |
| `token` | none | The worker token, inline. |
| `token_file` | none | Path to a file holding the token. Preferred. |
| `arches` | `[]` | Architectures offered. Empty means no `arch` tasks, only tasks that declare none. |
@@ -76,9 +76,14 @@ is deliberate: that is a property of the build and is decided by the
pipeline or the project, not by the machine that happens to run it.
Every key can also be set from the environment: `CONDUCTOR_URL`,
-`CONDUCTOR_WORKER_TOKEN`, `CONDUCTOR_WORKER_TOKEN_FILE`,
-`CONDUCTOR_WORKER_ARCHES`, `CONDUCTOR_WORKER_CONCURRENCY`,
-`CONDUCTOR_WORKER_DOCKER`, and so on. The environment wins over the file.
+`CONDUCTOR_WORKER_NAME`, `CONDUCTOR_WORKER_TOKEN`,
+`CONDUCTOR_WORKER_TOKEN_FILE`, `CONDUCTOR_WORKER_ARCHES`,
+`CONDUCTOR_WORKER_CONCURRENCY`, `CONDUCTOR_WORKER_DOCKER`, and so on. The
+environment wins over the file.
+
+In a container the default hostname is the container id, which changes on
+every restart. Set `CONDUCTOR_WORKER_NAME` so a machine keeps one name:
+under Nomad, `CONDUCTOR_WORKER_NAME = "${attr.unique.hostname}"`.
Features
--------
@@ -88,6 +93,10 @@ resources a task asking for it should receive. The conductor only ever learns
the name. It will not send a task to a worker that does not advertise every
feature the task's `requires` lists.
+The names a machine advertises are recorded against its worker token and
+shown on that worker's page, so you can see what each machine offers without
+logging into it. A machine that stops claiming is forgotten after a day.
+
```json
"features": {
"sign-key": {
diff --git a/migrations/mysql/002_worker_agents.sql b/migrations/mysql/002_worker_agents.sql
@@ -0,0 +1,19 @@
+-- 002_worker_agents.sql - capabilities per machine behind a worker token
+--
+-- Several hosts may share one token. Only feature names are stored.
+
+CREATE TABLE worker_agents (
+ id VARCHAR(64) NOT NULL PRIMARY KEY,
+ token_id VARCHAR(64) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ arches TEXT NOT NULL,
+ features TEXT NOT NULL,
+ first_seen_at BIGINT NOT NULL,
+ last_seen_at BIGINT NOT NULL,
+ last_ip VARCHAR(64),
+ UNIQUE KEY idx_worker_agents_identity (token_id, name),
+ KEY idx_worker_agents_sweep (last_seen_at),
+ CONSTRAINT fk_worker_agents_token FOREIGN KEY (token_id) REFERENCES worker_tokens(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE INDEX idx_tasks_worker ON tasks (worker_token_id, claimed_at);
diff --git a/migrations/postgres/002_worker_agents.sql b/migrations/postgres/002_worker_agents.sql
@@ -0,0 +1,18 @@
+-- 002_worker_agents.sql - capabilities per machine behind a worker token
+--
+-- Several hosts may share one token. Only feature names are stored.
+
+CREATE TABLE worker_agents (
+ id TEXT NOT NULL PRIMARY KEY,
+ token_id TEXT NOT NULL REFERENCES worker_tokens(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ arches TEXT NOT NULL,
+ features TEXT NOT NULL,
+ first_seen_at BIGINT NOT NULL,
+ last_seen_at BIGINT NOT NULL,
+ last_ip TEXT
+);
+
+CREATE UNIQUE INDEX idx_worker_agents_identity ON worker_agents (token_id, name);
+CREATE INDEX idx_worker_agents_sweep ON worker_agents (last_seen_at);
+CREATE INDEX idx_tasks_worker ON tasks (worker_token_id, claimed_at);
diff --git a/migrations/sqlite/002_worker_agents.sql b/migrations/sqlite/002_worker_agents.sql
@@ -0,0 +1,18 @@
+-- 002_worker_agents.sql - capabilities per machine behind a worker token
+--
+-- Several hosts may share one token. Only feature names are stored.
+
+CREATE TABLE worker_agents (
+ id TEXT NOT NULL PRIMARY KEY,
+ token_id TEXT NOT NULL REFERENCES worker_tokens(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ arches TEXT NOT NULL,
+ features TEXT NOT NULL,
+ first_seen_at INTEGER NOT NULL,
+ last_seen_at INTEGER NOT NULL,
+ last_ip TEXT
+);
+
+CREATE UNIQUE INDEX idx_worker_agents_identity ON worker_agents (token_id, name);
+CREATE INDEX idx_worker_agents_sweep ON worker_agents (last_seen_at);
+CREATE INDEX idx_tasks_worker ON tasks (worker_token_id, claimed_at);
diff --git a/package.json b/package.json
@@ -1,6 +1,6 @@
{
"name": "conductor",
- "version": "0.5.0",
+ "version": "0.6.0",
"description": "Stand-alone CI system: in-repo pipelines, containerised jobs, pull-based workers",
"type": "module",
"main": "src/conductor/index.js",
diff --git a/src/conductor/retention.js b/src/conductor/retention.js
@@ -23,7 +23,8 @@
// row points at something that is gone, which is a broken download. The
// cheaper failure is the one to choose.
-const DAY = 24 * 60 * 60 * 1000;
+const HOUR = 60 * 60 * 1000;
+const DAY = 24 * HOUR;
// A project's own setting, the server default when it has none. Zero is a
// real answer meaning keep forever, so only null and undefined fall
@@ -171,12 +172,23 @@ export function createRetention({ cfg, db, storage, logs, logger = {} }) {
return { deleted, bytes };
}
+ // A machine that has stopped claiming. Zero keeps them forever.
+ async function sweepWorkerAgents(now) {
+ const hours = defaults.worker_agent_keep_hours;
+ if (!hours) return 0;
+ const res = await db.run(
+ 'DELETE FROM worker_agents WHERE last_seen_at < {cutoff}',
+ { cutoff: now - hours * HOUR }
+ );
+ return res.changes ?? 0;
+ }
+
return {
// One pass over everything. Returns what it did, which the tests read
// and the log line summarises.
async sweep({ now = Date.now(), batch = defaults.batch } = {}) {
const total = {
- artifacts: 0, artifactBytes: 0, logs: 0, logBytes: 0,
+ artifacts: 0, artifactBytes: 0, logs: 0, logBytes: 0, agents: 0,
};
const expired = await sweepExpiredArtifacts(now, batch);
@@ -199,9 +211,12 @@ export function createRetention({ cfg, db, storage, logs, logger = {} }) {
total.logBytes += l.bytes;
}
- if (total.artifacts > 0 || total.logs > 0) {
+ total.agents = await sweepWorkerAgents(now);
+
+ if (total.artifacts > 0 || total.logs > 0 || total.agents > 0) {
logger.info?.(
- `retention: removed ${total.artifacts} artifact(s) and ${total.logs} log(s), ` +
+ `retention: removed ${total.artifacts} artifact(s), ${total.logs} log(s) and ` +
+ `${total.agents} worker record(s), ` +
`freeing ${Math.round((total.artifactBytes + total.logBytes) / 1024)} KiB`
);
}
diff --git a/src/conductor/routes/workers.js b/src/conductor/routes/workers.js
@@ -76,6 +76,11 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag
const features = splitList(body.features);
const name = typeof body.name === 'string' ? body.name.slice(0, 255) : req.worker.name;
+ // Recorded even when idle, and never allowed to fail a claim.
+ await workerTokens
+ .recordAgent({ tokenId: req.worker.id, name, arches, features, ip: req.ip })
+ .catch((e) => req.log.warn(`could not record worker agent: ${e.message}`));
+
const task = await scheduler.claim({
arches,
features,
diff --git a/src/conductor/ui/pages.js b/src/conductor/ui/pages.js
@@ -446,7 +446,7 @@ export function workersTable(tokens, user, query = {}) {
${tokens.rows.length === 0
? html`<tr><td colspan="6" class="muted">No workers registered.</td></tr>`
: tokens.rows.map((t) => html`<tr>
- <td>${t.name}</td>
+ <td><a href="/workers/${t.id}">${t.name}</a></td>
<td class="muted">${t.owner_id === null ? 'shared' : (t.owner_id === user.id ? 'yours' : t.owner_id)}</td>
<td>${t.enabled === 1 ? badge('success') : badge('skipped')}</td>
<td class="muted">${t.last_seen_at ? ago(t.last_seen_at) : 'never'}</td>
@@ -464,6 +464,79 @@ export function workersTable(tokens, user, query = {}) {
</div>`;
}
+// A token is a credential, not a machine, so the page lists both.
+export function workerPage({ token, agents, tasks, user, staleAfter, query = {} }) {
+ const scope = token.owner_id === null
+ ? 'shared'
+ : (token.owner_id === user.id ? 'yours' : token.owner_id);
+
+ return html`
+ <div class="panel">
+ <h2>${token.name}</h2>
+ <div class="row">
+ ${field('state', token.enabled === 1 ? badge('success') : badge('skipped'))}
+ ${field('scope', scope)}
+ ${field('machines', `${agents.length}`)}
+ ${field('last seen', token.last_seen_at ? ago(token.last_seen_at) : 'never')}
+ ${field('address', html`<span class="mono">${token.last_ip ?? '-'}</span>`)}
+ ${field('registered', ago(token.created_at))}
+ </div>
+ </div>
+
+ <h3>machines</h3>
+ ${workerAgentsTable(agents, staleAfter)}
+
+ <h3>recent tasks</h3>
+ ${workerTasksTable(token, tasks, query)}
+ `;
+}
+
+function workerAgentsTable(agents, staleAfter) {
+ const now = Date.now();
+ return html`<table>
+ <thead><tr>
+ <th>machine</th><th>state</th><th>features</th><th>arches</th><th>last claim</th><th>address</th>
+ </tr></thead>
+ <tbody>
+ ${agents.length === 0
+ ? html`<tr><td colspan="6" class="muted">No machine has claimed with this token yet.</td></tr>`
+ : agents.map((a) => html`<tr>
+ <td>${a.name}</td>
+ <td>${now - a.last_seen_at > staleAfter ? badge('offline') : badge('online')}</td>
+ <td class="muted">${a.features.length === 0 ? 'none' : a.features.join(', ')}</td>
+ <td class="muted">${a.arches.length === 0 ? 'any' : a.arches.join(', ')}</td>
+ <td class="muted">${ago(a.last_seen_at)}</td>
+ <td class="muted mono">${a.last_ip ?? ''}</td>
+ </tr>`)}
+ </tbody>
+ </table>`;
+}
+
+// Final attempts only: a requeue clears the worker off the task.
+function workerTasksTable(token, tasks, query) {
+ return html`<div id="worker-tasks">
+ <table class="tasks">
+ <thead><tr>
+ <th>task</th><th>state</th><th>machine</th><th>arch</th><th>claimed</th><th>duration</th><th>exit</th>
+ </tr></thead>
+ <tbody>
+ ${tasks.rows.length === 0
+ ? html`<tr><td colspan="7" class="muted">This worker has not run anything yet.</td></tr>`
+ : tasks.rows.map((t) => html`<tr>
+ <td><a href="/tasks/${t.id}">${t.name}</a></td>
+ <td>${badge(t.state)}</td>
+ <td class="muted">${t.worker_name ?? ''}</td>
+ <td class="muted">${t.arch ?? ''}</td>
+ <td class="muted">${t.claimed_at ? ago(t.claimed_at) : ''}</td>
+ <td class="muted num">${duration(t.started_at, t.finished_at)}</td>
+ <td class="muted num">${t.exit_code ?? ''}</td>
+ </tr>`)}
+ </tbody>
+ </table>
+ ${pager(tasks, (to) => pageHref(`/workers/${token.id}`, query, to))}
+ </div>`;
+}
+
// --- users ---
export function usersPage({ users, localLogin, error, query = {} }) {
diff --git a/src/conductor/ui/routes.js b/src/conductor/ui/routes.js
@@ -13,7 +13,7 @@ import { toHtml, html } from './html.js';
import { layout } from './layout.js';
import {
jobsPage, jobsTable, jobPage, tasksTable, taskPage, taskLog, loginPage,
- projectsPage, projectPage, variablesTable, workersPage, workersTable,
+ projectsPage, projectPage, variablesTable, workersPage, workersTable, workerPage,
usersPage, usersTable,
} from './pages.js';
import { canManageProject, canViewProject, VISIBILITIES } from '../../lib/projects.js';
@@ -626,6 +626,40 @@ export default async function uiRoutes(fastify, services) {
});
});
+ fastify.get('/workers/:id', async (req, reply) => {
+ const user = await required(req, reply);
+ if (!user) return reply;
+
+ const token = await workerTokens.get(req.params.id);
+ if (!token || !canManageWorker(user, token)) {
+ reply.code(404);
+ return page(reply, { title: 'not found', user, active: 'workers', body: html`<p class="error">No such worker.</p>` });
+ }
+
+ const tasks = await paginate(db, {
+ select: 'id, name, state, arch, worker_name, exit_code, claimed_at, started_at, finished_at',
+ from: 'tasks',
+ where: 'worker_token_id = {worker}',
+ order: 'claimed_at DESC',
+ params: { worker: token.id },
+ page: pageOf(req.query),
+ });
+
+ return page(reply, {
+ title: token.name,
+ user,
+ active: 'workers',
+ body: workerPage({
+ token,
+ agents: await workerTokens.agentsFor(token.id),
+ tasks,
+ user,
+ staleAfter: cfg.scheduler.heartbeat_timeout * 1000,
+ query: req.query,
+ }),
+ });
+ });
+
fastify.post('/workers', async (req, reply) => {
if (!fromUi(req, reply)) return reply;
const user = await required(req, reply);
diff --git a/src/lib/config.js b/src/lib/config.js
@@ -91,6 +91,8 @@ const DEFAULTS = {
// Logs go purely by age, and are the reason this exists at all: they
// are written for every job, read for almost none, and never stop.
log_keep_days: 14,
+ // A machine that stops claiming for this long is forgotten.
+ worker_agent_keep_hours: 24,
// How often to sweep. Deleting is not urgent, and a sweep walks every
// project, so this is deliberately not the scheduler's interval.
sweep_interval: 3600,
@@ -113,6 +115,7 @@ const ENV_MAP = [
['CONDUCTOR_RETENTION_ARTIFACT_JOBS', 'retention.artifact_keep_jobs', toInt],
['CONDUCTOR_RETENTION_ARTIFACT_DAYS', 'retention.artifact_keep_days', toInt],
['CONDUCTOR_RETENTION_LOG_DAYS', 'retention.log_keep_days', toInt],
+ ['CONDUCTOR_RETENTION_WORKER_AGENT_HOURS', 'retention.worker_agent_keep_hours', toInt],
['CONDUCTOR_RETENTION_SWEEP_INTERVAL','retention.sweep_interval', toInt],
['CONDUCTOR_HOST', 'server.host', String],
['CONDUCTOR_PORT', 'server.port', toInt],
@@ -265,7 +268,7 @@ function validate(cfg) {
// Retention deletes things, so a nonsensical value here is worth
// refusing at startup rather than discovering from missing artifacts.
- for (const key of ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days']) {
+ for (const key of ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days', 'worker_agent_keep_hours']) {
const value = cfg.retention[key];
if (!Number.isInteger(value) || value < 0) {
errors.push(`retention.${key} must be a whole number of zero or more, got ${JSON.stringify(value)}`);
diff --git a/src/lib/ids.js b/src/lib/ids.js
@@ -36,6 +36,7 @@ export const newProjectId = () => randomId(16);
export const newArtifactId = () => randomId(24);
export const newUserId = () => randomId(16);
export const newWorkerTokenId = () => randomId(16);
+export const newWorkerAgentId = () => randomId(16);
// Task ids carry no structure at all. A worker holding one cannot tell which
// job or project it belongs to, which is the point: a worker is given a
diff --git a/src/lib/workers.js b/src/lib/workers.js
@@ -8,10 +8,22 @@
// from that user's projects. A token with no owner is shared and can run
// anything, which is how an administrator provides general capacity. That
// distinction is enforced in the scheduler, not here.
+//
+// A token is not a machine: several hosts may share one, so what each
+// advertises is kept per reported name in worker_agents.
-import { newToken, hashToken, newWorkerTokenId } from './ids.js';
+import { newToken, hashToken, newWorkerTokenId, newWorkerAgentId } from './ids.js';
import { paginate, windowOf } from './page.js';
+function parseList(value) {
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
export function createWorkerTokens({ db }) {
return {
async create(name, { ownerId = null } = {}) {
@@ -52,6 +64,50 @@ export function createWorkerTokens({ db }) {
return { id: row.id, name: row.name, owner_id: row.owner_id };
},
+ // Upsert syntax is not portable, so read and write share a transaction.
+ async recordAgent({ tokenId, name, arches = [], features = [], ip = null }) {
+ const label = typeof name === 'string' ? name.trim().slice(0, 255) : '';
+ if (!tokenId || !label) return;
+
+ const now = Date.now();
+ const values = { arches: JSON.stringify(arches), features: JSON.stringify(features) };
+
+ await db.transaction(async (tx) => {
+ const row = await tx.get(
+ 'SELECT id FROM worker_agents WHERE token_id = {token} AND name = {name}',
+ { token: tokenId, name: label }
+ );
+ if (row) {
+ await tx.run(
+ `UPDATE worker_agents
+ SET arches = {arches}, features = {features}, last_seen_at = {now}, last_ip = {ip}
+ WHERE id = {id}`,
+ { id: row.id, ...values, now, ip }
+ );
+ return;
+ }
+ await tx.run(
+ `INSERT INTO worker_agents
+ (id, token_id, name, arches, features, first_seen_at, last_seen_at, last_ip)
+ VALUES ({id}, {token}, {name}, {arches}, {features}, {now}, {now}, {ip})`,
+ { id: newWorkerAgentId(), token: tokenId, name: label, ...values, now, ip }
+ );
+ });
+ },
+
+ async agentsFor(tokenId) {
+ const rows = await db.all(
+ `SELECT id, name, arches, features, first_seen_at, last_seen_at, last_ip
+ FROM worker_agents WHERE token_id = {token} ORDER BY last_seen_at DESC`,
+ { token: tokenId }
+ );
+ return rows.map((row) => ({
+ ...row,
+ arches: parseList(row.arches),
+ features: parseList(row.features),
+ }));
+ },
+
async list() {
return db.all(
`SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip
diff --git a/test/conductor.test.js b/test/conductor.test.js
@@ -541,6 +541,40 @@ test('an unknown task is 404 by either path', async () => {
});
});
+test('a claim records what the machine advertises, even when there is no work', async () => {
+ await withHarness({}, async (h) => {
+ const idle = await h.claim({ name: 'builder-01', arches: ['x86_64'], features: ['unos-sign-keys'] });
+ assert.equal(idle.statusCode, 204, 'nothing is queued yet');
+
+ const agents = await h.services.workerTokens.agentsFor(h.worker.id);
+ assert.equal(agents.length, 1);
+ assert.equal(agents[0].name, 'builder-01');
+ assert.deepEqual(agents[0].features, ['unos-sign-keys']);
+ assert.deepEqual(agents[0].arches, ['x86_64']);
+ });
+});
+
+test('a machine that changes its features updates its row rather than adding one', async () => {
+ await withHarness({}, async (h) => {
+ await h.claim({ name: 'builder-01', features: ['dind'] });
+ await h.claim({ name: 'builder-01', features: ['dind', 'unos-sign-keys'] });
+
+ const agents = await h.services.workerTokens.agentsFor(h.worker.id);
+ assert.equal(agents.length, 1, 'one machine is one row');
+ assert.deepEqual(agents[0].features, ['dind', 'unos-sign-keys']);
+ });
+});
+
+test('machines sharing a token are recorded separately', async () => {
+ await withHarness({}, async (h) => {
+ await h.claim({ name: 'builder-01', features: ['unos-sign-keys'] });
+ await h.claim({ name: 'builder-02', features: [] });
+
+ const agents = await h.services.workerTokens.agentsFor(h.worker.id);
+ assert.deepEqual(agents.map((a) => a.name).sort(), ['builder-01', 'builder-02']);
+ });
+});
+
test('the worker api rejects missing and invalid tokens', async () => {
await withHarness({}, async (h) => {
const none = await h.app.inject({ method: 'POST', url: '/api/v1/tasks/claim' });
diff --git a/test/dialects.test.js b/test/dialects.test.js
@@ -22,6 +22,7 @@ import { paginate, paginateOffset, pageBlock } from '../src/lib/page.js';
import { createStorage } from '../src/lib/storage/index.js';
import { createLogStore } from '../src/lib/log.js';
import { createRetention } from '../src/conductor/retention.js';
+import { createWorkerTokens } from '../src/lib/workers.js';
const DAY = 24 * 60 * 60 * 1000;
const NOW = 1_800_000_000_000;
@@ -85,6 +86,7 @@ for (const dialect of ['postgres', 'mysql']) {
artifact_keep_jobs: 2,
artifact_keep_days: 7,
log_keep_days: 7,
+ worker_agent_keep_hours: 24,
sweep_interval: 3600,
batch: 2,
},
@@ -133,10 +135,38 @@ for (const dialect of ['postgres', 'mysql']) {
assert.deepEqual(tail.rows.map((r) => r.id), ['r5']);
assert.ok(!('next' in pageBlock({ url: 'https://x.test/j', ...tail })), 'that was the last page');
+ // The unique index is what makes a repeat claim update, not duplicate.
+ const workerTokens = createWorkerTokens({ db });
+ const token = await workerTokens.create('dialect-worker');
+
+ await workerTokens.recordAgent({ tokenId: token.id, name: 'builder-01', arches: ['x86_64'], features: ['dind'] });
+ await workerTokens.recordAgent({ tokenId: token.id, name: 'builder-01', arches: ['x86_64'], features: ['dind', 'sign'] });
+ await workerTokens.recordAgent({ tokenId: token.id, name: 'builder-02', arches: [], features: [] });
+
+ const agents = await workerTokens.agentsFor(token.id);
+ assert.deepEqual(agents.map((a) => a.name).sort(), ['builder-01', 'builder-02']);
+ assert.deepEqual(
+ agents.find((a) => a.name === 'builder-01').features,
+ ['dind', 'sign'],
+ 'the repeat claim should have updated in place'
+ );
+
+ await db.run(
+ "UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'builder-02'",
+ { then: NOW - 2 * DAY }
+ );
+ await db.run(
+ "UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'builder-01'",
+ { then: NOW - 1000 }
+ );
+
const retention = createRetention({ cfg, db, storage, logs, logger: {} });
const first = await retention.sweep({ now: NOW });
+ assert.equal(first.agents, 1, 'only the machine that stopped claiming should go');
+ assert.deepEqual((await workerTokens.agentsFor(token.id)).map((a) => a.name), ['builder-01']);
+
// Job 1 by its own deadline, jobs 2 and 3 by policy. Jobs 4 and 5
// are the last two, so they are kept whatever their age.
assert.equal(first.artifacts, 3, 'three artifacts should go on the first pass');
diff --git a/test/pagination.test.js b/test/pagination.test.js
@@ -52,6 +52,28 @@ async function seedJobs(h, count, { visibility = 'public' } = {}) {
h.seeded = base + count;
}
+// Tasks attributed to the harness worker, for the worker page's history.
+async function seedWorkerTasks(h, count) {
+ await seedJobs(h, 1);
+ const job = `seed${String((h.seeded ?? 1) - 1).padStart(4, '0')}`;
+ const now = Date.now();
+ for (let i = 0; i < count; i += 1) {
+ await h.services.db.run(
+ `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state,
+ timeout, worker_token_id, worker_name, claimed_at, created_at)
+ VALUES ({id}, {job}, {name}, 'build', 'alpine', '[]', '{}', 'success',
+ 3600, {worker}, 'builder-01', {claimed}, {claimed})`,
+ {
+ id: `wtask${String(i).padStart(4, '0')}`,
+ job,
+ name: `build-${i}`,
+ worker: h.worker.id,
+ claimed: now - i * 1000,
+ }
+ );
+ }
+}
+
// --- the helpers ---
test('a page number is read from the query, and nonsense reads as the first', () => {
@@ -245,6 +267,21 @@ test('the count behind the pager respects who is asking', async () => {
});
});
+test('a worker page pages its task history', async () => {
+ await withUi({}, async (h) => {
+ const { cookie } = await h.login();
+ await seedWorkerTasks(h, PER_PAGE + 3);
+
+ const first = await get(h, `/workers/${h.worker.id}`, { cookie });
+ assert.equal(first.statusCode, 200);
+ assert.equal((first.body.match(/href="\/tasks\/wtask/g) ?? []).length, PER_PAGE);
+ assert.match(first.body, new RegExp(`href="/workers/${h.worker.id}\\?page=2"`));
+
+ const second = await get(h, `/workers/${h.worker.id}?page=2`, { cookie });
+ assert.equal((second.body.match(/href="\/tasks\/wtask/g) ?? []).length, 3);
+ });
+});
+
test('the users and workers tables keep their swap target on the wrapper', async () => {
await withUi({}, async (h) => {
const { cookie } = await h.login();
diff --git a/test/retention.test.js b/test/retention.test.js
@@ -9,7 +9,8 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { startHarness } from './helpers/harness.js';
-const DAY = 24 * 60 * 60 * 1000;
+const HOUR = 60 * 60 * 1000;
+const DAY = 24 * HOUR;
const NOW = 1_800_000_000_000;
async function withHarness(options, fn) {
@@ -522,3 +523,35 @@ test('the api reports and accepts project retention', { skip: 'the JSON API was
assert.match(rejected.json().error, /zero or more/);
});
});
+
+test('a machine that has stopped claiming is forgotten', async () => {
+ await withHarness({ retention: { worker_agent_keep_hours: 24 } }, async (h) => {
+ const { workerTokens, db } = h.services;
+
+ await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'gone', features: ['dind'] });
+ await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'here', features: ['unos-sign-keys'] });
+ // recordAgent stamps the real clock, and NOW is not the real clock.
+ await db.run("UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'gone'", { then: NOW - 25 * HOUR });
+ await db.run("UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'here'", { then: NOW - 1 * HOUR });
+
+ const result = await h.services.retention.sweep({ now: NOW });
+
+ assert.equal(result.agents, 1);
+ const left = await workerTokens.agentsFor(h.worker.id);
+ assert.deepEqual(left.map((a) => a.name), ['here']);
+ });
+});
+
+test('zero keeps machines for ever', async () => {
+ await withHarness({ retention: { worker_agent_keep_hours: 0 } }, async (h) => {
+ const { workerTokens, db } = h.services;
+
+ await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'ancient', features: [] });
+ await db.run('UPDATE worker_agents SET last_seen_at = {then}', { then: NOW - 365 * DAY });
+
+ const result = await h.services.retention.sweep({ now: NOW });
+
+ assert.equal(result.agents, 0);
+ assert.equal((await workerTokens.agentsFor(h.worker.id)).length, 1);
+ });
+});
diff --git a/test/ui.test.js b/test/ui.test.js
@@ -234,6 +234,65 @@ test('registering a worker shows the token once and lists it', async () => {
});
});
+test('the worker page lists what each machine advertises', async () => {
+ await withUi({}, async (h) => {
+ const session = await signIn(h);
+ await h.claim({ name: 'builder-01', arches: ['x86_64'], features: ['unos-sign-keys', 'dind'] });
+
+ const page = await get(h, `/workers/${h.worker.id}`, session);
+ assert.equal(page.statusCode, 200);
+ assert.match(page.body, /builder-01/);
+ assert.match(page.body, /unos-sign-keys, dind/);
+ assert.match(page.body, /x86_64/);
+ });
+});
+
+test('machines sharing a token are listed separately', async () => {
+ await withUi({}, async (h) => {
+ const session = await signIn(h);
+ await h.claim({ name: 'builder-01', features: ['unos-sign-keys'] });
+ await h.claim({ name: 'builder-02', features: [] });
+
+ const page = await get(h, `/workers/${h.worker.id}`, session);
+ assert.match(page.body, /builder-01/);
+ assert.match(page.body, /builder-02/);
+ assert.match(page.body, /machines/);
+ });
+});
+
+test('the workers list links to the worker page', async () => {
+ await withUi({}, async (h) => {
+ const session = await signIn(h);
+ const list = await get(h, '/workers', session);
+ assert.match(list.body, new RegExp(`href="/workers/${h.worker.id}"`));
+ });
+});
+
+test('a worker page is not served to someone who cannot manage it', async () => {
+ await withUi({}, async (h) => {
+ const admin = await signIn(h);
+ await h.app.inject({
+ method: 'POST', url: '/users', ...form(admin, { username: 'erin', password: 'erin-password', role: 'user' }),
+ });
+ const erin = await signIn(h, 'erin', 'erin-password');
+
+ const page = await get(h, `/workers/${h.worker.id}`, erin);
+ assert.equal(page.statusCode, 404);
+ assert.match(page.body, /No such worker/);
+ });
+});
+
+test('a feature name is escaped rather than rendered', async () => {
+ await withUi({}, async (h) => {
+ const session = await signIn(h);
+ await h.claim({ name: 'builder-01', features: ['<img src=x onerror=alert(1)>'] });
+
+ const page = await get(h, `/workers/${h.worker.id}`, session);
+ assert.ok(!page.body.includes('<img src=x'), 'a feature name must not reach the page as markup');
+ assert.match(page.body, /<img src=x/);
+ });
+});
+
test('an ordinary user cannot create shared capacity from the form', async () => {
await withUi({}, async (h) => {
const admin = await signIn(h);