@popcorn245/cornfield 0.8.0 → 0.10.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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * @module server/runner-api
3
+ *
4
+ * The runner's side of the pull and push transports (issue #81, spec 0005 D2, D3, D5). The engine is server/agent-runs.js, the contract is
5
+ * core/agent-contract.js, and docs/agents.md says how a runner uses these routes. A runner behind NAT asks cornfield for work, claims it, and
6
+ * reports on it; a runner that was pushed a request (the `agent.requested` hook event) reports the same way.
7
+ *
8
+ * GET /api/agent-requests?state=queued the queued requests (the contract's request JSON), oldest first, at most `QUEUE_LIMIT`
9
+ * POST /api/agent-requests/<runId>/claim claims one; a second claim of it is 409. Answers the request and the run
10
+ * GET /api/runs/<runId> { state, stopRequested, … } of a run this token holds: how a runner learns it was asked to stop
11
+ * POST /api/runs/<runId> the report JSON ({ state, note?, url?, tokens?, cost? }), checked by the contract's `validateReport`
12
+ *
13
+ * **These routes take a runner token (`Authorization: Bearer cf_nt_…`) and nothing else,** as the review API takes a review token
14
+ * (server/review-api.js): they run before the sign-in guard (a runner has no cookie), read no cookie (a cookie is never a substitute, and a
15
+ * request that sends only one is 401), need no same-origin check (a browser never adds an `Authorization` header itself), and keep the `Host`
16
+ * check in front of every route. **The three scopes are kept apart both ways:** a review or an agent token here is 403 whatever else is true
17
+ * of it, and a runner token on any other route (the review API, the agent API, the dashboard's routes, the page itself) is 403 too
18
+ * (`serveStrayRunner`, which `server.js` calls before anything else is looked at).
19
+ *
20
+ * What a runner token may do, and nothing more: read the queue, claim, and read and report on **the runs it holds**. A pull run is held by the
21
+ * token that claimed it; a push run by the first token to report on it (nothing else tells a push run apart: the hook went to a URL, not a
22
+ * token). A run asked for on the forge is nobody's: its reports are `cornfield-run` comments. A token never reads a request it did not claim,
23
+ * and never anything of cornfield's besides.
24
+ *
25
+ * **Token states, the anonymous flood ceiling and the rows in `actions` follow the review API.** Every call is a row (`runner-list`,
26
+ * `runner-claim`, `runner-run`, `runner-report`, and `runner-call` for a token on the wrong route), with the token's id and label and the
27
+ * run id in the request, and the status and error in the answer, **never the token and never a note's words**. Two bounds keep the table from
28
+ * growing without end: past `RUNNER_PER_MINUTE` calls a minute a token is 429 and nothing is written, and an empty queue read is written down
29
+ * at most once every `EMPTY_LIST_ROW_MS` per token (a runner polls; a poll that finds nothing says little).
30
+ *
31
+ * no token, or one that no row has 401 (`WWW-Authenticate: Bearer`), logged under `(no token)`; past `ANONYMOUS_PER_MINUTE` a minute 429 and no row
32
+ * a review or an agent token 403, under its owner
33
+ * a token that is revoked or expired 403, under its owner
34
+ * a live runner token whatever the engine answers: 200, or 400, 403, 404, 409, 429
35
+ *
36
+ * **The token is not asked of the forge.** A runner has its own identity, and cornfield's forge sign-in of the token's owner may well have
37
+ * ended by the time a runner polls. What bounds a runner token is its scope, its expiry (90 days at most), that its owner revokes it in
38
+ * the dashboard at once, and that **an owner who stops maintaining the repo loses it**: whenever cornfield learns that (their sign-in, the
39
+ * 15-minute re-check, any question it puts to the forge with their token) all their API tokens are revoked (`auth.demoted`), and each call
40
+ * here also refuses, and revokes, when the owner's newest sign-in says they are not a maintainer (`auth.knownPermission`). No sign-in on
41
+ * record is no answer: the token goes on. (Review and agent tokens, which act as their owner on the forge, are checked against the forge on
42
+ * every call.)
43
+ *
44
+ * Nothing in an answer is a credential: the request is the contract's (validated again on the way out), and a run shows its state.
45
+ */
46
+
47
+ import { readBody } from './proposals.js';
48
+ import { ANONYMOUS_PER_MINUTE, NO_TOKEN_LOGIN } from './review-api.js';
49
+ import { bearerOf, findToken, scopeOfShape, touchToken } from './review-tokens.js';
50
+ import { QUEUE_LIMIT } from './agent-runs.js';
51
+ import { refusal } from './submit-review.js';
52
+ import { Refused, countAttempt } from './throttle.js';
53
+ import { ForgeError, isMaintainer } from './forge.js';
54
+
55
+ export const REQUESTS_PATH = '/api/agent-requests';
56
+ /** Calls one runner token may make in a minute: a runner polls, so this is well over a person's ceiling */
57
+ export const RUNNER_PER_MINUTE = 120;
58
+ /** An empty queue read is written down at most this often per token */
59
+ export const EMPTY_LIST_ROW_MS = 10 * 60_000;
60
+ /** A report's body, in bytes: a note, a link and two numbers, with room */
61
+ export const REPORT_LIMIT = 16 * 1024;
62
+ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
63
+ const RUN_ID = '(run-[0-9A-Za-z]{26})';
64
+ const CLAIM = new RegExp(`^/api/agent-requests/${RUN_ID}/claim$`);
65
+ const RUN = new RegExp(`^/api/runs/${RUN_ID}$`);
66
+
67
+ function send(res, status, body, headers = {}) {
68
+ res.writeHead(status, { ...HEADERS, ...headers });
69
+ res.end(JSON.stringify(body));
70
+ }
71
+
72
+ /** The routes of this module: the action each is audited as, the run it names, and the methods it answers. Null for any other path. */
73
+ export function runnerRoute(reqPath) {
74
+ let m;
75
+ if (reqPath === REQUESTS_PATH) return { action: 'runner-list', runId: null, allow: ['GET', 'HEAD'] };
76
+ if ((m = CLAIM.exec(reqPath))) return { action: 'runner-claim', runId: m[1], allow: ['POST'] };
77
+ if ((m = RUN.exec(reqPath))) return { action: 'runner-run', runId: m[1], allow: ['GET', 'HEAD', 'POST'] };
78
+ return null;
79
+ }
80
+
81
+ /** Whether a path is one that takes a bearer token of some scope (so a runner token on it is answered by that route, not as a stray) */
82
+ export const takesTokens = (reqPath, { reviews }) => reqPath === reviews || reqPath === REQUESTS_PATH || runnerRoute(reqPath) !== null;
83
+
84
+ /** The scope of a bearer token as the request presents it, by its shape alone (the row decides what it is worth) */
85
+ export const presentedScope = (req) => scopeOfShape(bearerOf(req.headers.authorization));
86
+
87
+ /** @type {WeakMap<object, Map<number, number>>} when each token's empty queue read was last written down */
88
+ const emptyReads = new WeakMap();
89
+
90
+ function emptyReadDue(auth, id, at) {
91
+ let m = emptyReads.get(auth);
92
+ if (!m) emptyReads.set(auth, (m = new Map()));
93
+ for (const [k, when] of m) if (at - when >= EMPTY_LIST_ROW_MS) m.delete(k);
94
+ if (m.has(id)) return false;
95
+ m.set(id, at);
96
+ return true;
97
+ }
98
+
99
+ /** What to answer for an error out of the engine: a refusal in words, or a plain 500 that says nothing of what went wrong */
100
+ function answerFor(err) {
101
+ if (err instanceof Refused || err instanceof ForgeError) return refusal(err);
102
+ console.error(`[cornfield] a runner call failed: ${err instanceof Error ? err.stack || err.message : err}`);
103
+ return { status: 500, body: { error: 'Something went wrong in cornfield; the log says what.', code: 'unavailable' }, headers: {} };
104
+ }
105
+
106
+ /** @typedef {{ config: any, auth: any, runs: import('./agent-runs.js').Runs | null, now?: () => number }} RunnerContext */
107
+
108
+ /**
109
+ * The runner routes. The caller has found the path to be one of `runnerRoute`'s; this runs before the sign-in guard.
110
+ * @param {import('node:http').IncomingMessage} req
111
+ * @param {import('node:http').ServerResponse} res
112
+ * @param {NonNullable<ReturnType<typeof runnerRoute>>} route
113
+ * @param {URL} url
114
+ * @param {RunnerContext} ctx
115
+ */
116
+ export async function serveRunnerCall(req, res, route, url, ctx) {
117
+ const { config, auth, runs } = ctx;
118
+ const now = ctx.now ?? Date.now;
119
+ const method = req.method || 'GET';
120
+ if (!route.allow.includes(method)) {
121
+ req.resume();
122
+ return send(res, 405, { ok: false, error: 'Method not allowed.', code: 'invalid' }, { Allow: route.allow.join(', ') });
123
+ }
124
+ if (!auth.enabled || !runs) {
125
+ req.resume();
126
+ return send(res, 403, { ok: false, error: 'Runner tokens need sign-in and a forge: set them up first (docs/sign-in.md, docs/config.md#forge).', code: 'forbidden' });
127
+ }
128
+ const repo = config.forge.repo;
129
+ const audit = async (login, request, status, answer, target = repo) => {
130
+ try {
131
+ await auth.record({ login, action: route.action, target, request: { via: 'api', ...(route.runId ? { runId: route.runId } : {}), ...request }, status, answer });
132
+ } catch (err) {
133
+ console.error(`[cornfield] a runner call could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
134
+ }
135
+ };
136
+ const refuse = async (login, request, status, body, headers = {}) => {
137
+ await audit(login, request, status, { error: body.error, code: body.code });
138
+ return send(res, status, { ok: false, ...body }, headers);
139
+ };
140
+
141
+ const presented = bearerOf(req.headers.authorization);
142
+ const db = presented ? await auth.store() : null;
143
+ const found = presented && db ? findToken(db, presented, now(), 'runner') : null;
144
+ if (!found) {
145
+ req.resume();
146
+ const flood = countAttempt(ctx, NO_TOKEN_LOGIN, now(), ANONYMOUS_PER_MINUTE);
147
+ if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
148
+ return refuse(NO_TOKEN_LOGIN, { reason: presented ? 'unknown-token' : 'no-token' }, 401,
149
+ { error: 'A runner token is needed: send "Authorization: Bearer cf_nt_…". A token is made in the dashboard; a sign-in is not taken in its place.', code: 'unauthorized' },
150
+ { 'WWW-Authenticate': 'Bearer realm="cornfield"' });
151
+ }
152
+
153
+ const { row, state } = found;
154
+ const token = { id: row.id, label: row.label };
155
+ if (state !== 'active') {
156
+ req.resume();
157
+ const flood = countAttempt(ctx, row.user_id, now());
158
+ if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
159
+ const wrong = state === 'wrong-scope';
160
+ return refuse(row.login, { reason: wrong ? 'wrong-scope' : state, token, ...(wrong ? { scope: row.scope } : {}) }, 403, {
161
+ error: wrong ? `This is ${row.scope === 'agent' ? 'an' : 'a'} ${row.scope} token. Runners take a runner token; make one in the dashboard.`
162
+ : state === 'revoked' ? 'This runner token was revoked; make a new one in the dashboard.' : 'This runner token has expired; make a new one in the dashboard.',
163
+ code: 'forbidden',
164
+ });
165
+ }
166
+ const flood = countAttempt(ctx, `runner|${row.id}`, now(), RUNNER_PER_MINUTE);
167
+ if (flood) {
168
+ req.resume();
169
+ return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
170
+ }
171
+ // The cheap guard: what cornfield last heard of the owner's permission (their newest sign-in's). A runner needs no live sign-in, so no sign-in
172
+ // at all is no answer; one that says they no longer maintain the repo refuses the call and revokes their tokens there and then
173
+ const known = await auth.knownPermission(row.user_id);
174
+ if (known !== null && !isMaintainer(known)) {
175
+ req.resume();
176
+ await auth.demoted(row.user_id, row.login, known);
177
+ return refuse(row.login, { reason: 'owner-not-maintainer', token }, 403, { error: `This runner token's owner, ${row.login}, does not maintain ${repo} any more, so it is revoked.`, code: 'forbidden' });
178
+ }
179
+ touchToken(/** @type {import('node:sqlite').DatabaseSync} */ (db), row.id, now());
180
+ const who = row.login;
181
+
182
+ try {
183
+ if (route.action === 'runner-list') {
184
+ req.resume();
185
+ if (url.searchParams.get('state') !== 'queued') throw new Refused(400, 'The only listing is ?state=queued: the requests waiting for a runner.', 'invalid');
186
+ const requests = await runs.queued();
187
+ if (requests.length || emptyReadDue(auth, row.id, now())) await audit(who, { token }, 200, { ok: true, count: requests.length });
188
+ return send(res, 200, { ok: true, requests, limit: QUEUE_LIMIT });
189
+ }
190
+ if (route.action === 'runner-claim') {
191
+ req.resume();
192
+ const claimed = await runs.claim(token, route.runId);
193
+ await audit(who, { token }, 200, { ok: true, claimed: true }, `${repo}#${claimed.row.target}`);
194
+ return send(res, 200, { ok: true, request: claimed.request, run: claimed.run });
195
+ }
196
+ if (method === 'POST') {
197
+ let body;
198
+ let problem = null;
199
+ try {
200
+ const read = await readBody(req, REPORT_LIMIT);
201
+ if (read.tooLarge) problem = new Refused(413, `A report is at most ${REPORT_LIMIT / 1024} KB.`, 'invalid');
202
+ else {
203
+ try {
204
+ body = JSON.parse(read.text);
205
+ } catch {
206
+ // Not the parser's words: they quote the body, which the audit row would keep (a credential pasted in by mistake)
207
+ problem = new Refused(400, 'The body is not valid JSON.', 'invalid');
208
+ }
209
+ }
210
+ } catch {
211
+ problem = new Refused(400, 'The report could not be read.', 'invalid');
212
+ }
213
+ const shape = body && typeof body === 'object' && !Array.isArray(body) ? { ...(typeof body.state === 'string' && body.state.length <= 20 ? { state: body.state } : {}), ...(typeof body.note === 'string' ? { noteChars: body.note.length } : {}) } : {};
214
+ try {
215
+ if (problem) throw problem;
216
+ const done = await runs.report(token, route.runId, body);
217
+ await audit(who, { token, ...shape }, 200, { ok: true, state: done.row.state }, `${repo}#${done.row.target}`);
218
+ return send(res, 200, { ok: true, run: done.run });
219
+ } catch (err) {
220
+ const out = answerFor(err);
221
+ await audit(who, { token, ...shape }, out.status, { error: out.body.error, code: out.body.code });
222
+ return send(res, out.status, { ok: false, ...out.body }, out.headers);
223
+ }
224
+ }
225
+ req.resume();
226
+ const view = await runs.runnerGet(token, route.runId);
227
+ await audit(who, { token }, 200, { ok: true, state: view.state });
228
+ return send(res, 200, { ok: true, run: view });
229
+ } catch (err) {
230
+ const out = answerFor(err);
231
+ await audit(who, { token }, out.status, { error: out.body.error, code: out.body.code });
232
+ return send(res, out.status, { ok: false, ...out.body }, out.headers);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * A runner token on a route that is not a runner's: 403, whatever the route would have said (even a path that does not exist). Called by
238
+ * `server.js` for any request that presents something shaped like a runner token on a path outside `takesTokens`, before the guard, the proxies
239
+ * and the page. A token that is a live runner's is counted under its own id, anything else under the anonymous ceiling; either way one row.
240
+ * @param {import('node:http').IncomingMessage} req
241
+ * @param {import('node:http').ServerResponse} res
242
+ * @param {string} reqPath
243
+ * @param {RunnerContext} ctx
244
+ */
245
+ export async function serveStrayRunner(req, res, reqPath, ctx) {
246
+ const { config, auth } = ctx;
247
+ const now = ctx.now ?? Date.now;
248
+ req.resume();
249
+ const body = { ok: false, error: 'A runner token is good for the runner routes only (docs/agents.md#pull): listing, claiming and reporting on runs. Nothing else of cornfield takes it.', code: 'forbidden' };
250
+ const presented = bearerOf(req.headers.authorization);
251
+ let owner = null;
252
+ let token;
253
+ if (presented && auth.enabled) {
254
+ const found = findToken(await auth.store(), presented, now(), 'runner');
255
+ if (found && found.state !== 'wrong-scope') { owner = found.row.login; token = { id: found.row.id, label: found.row.label }; }
256
+ }
257
+ const flood = owner === null ? countAttempt(ctx, NO_TOKEN_LOGIN, now(), ANONYMOUS_PER_MINUTE) : countAttempt(ctx, `runner|${token.id}`, now(), RUNNER_PER_MINUTE);
258
+ if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
259
+ if (auth.enabled) {
260
+ try {
261
+ await auth.record({
262
+ login: owner ?? NO_TOKEN_LOGIN, action: 'runner-call', target: config.forge?.repo ?? '',
263
+ request: { via: 'api', reason: 'wrong-route', method: String(req.method).slice(0, 10), path: reqPath.slice(0, 80), ...(token ? { token } : {}) }, status: 403, answer: { error: body.error, code: body.code },
264
+ });
265
+ } catch (err) {
266
+ console.error(`[cornfield] a runner call could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
267
+ }
268
+ }
269
+ return send(res, 403, body);
270
+ }
package/server/server.js CHANGED
@@ -22,7 +22,13 @@
22
22
  * POST /api/forge/release-drafts the release notes kept as a DRAFT release on the forge (never published, no tag made)
23
23
  * POST /api/reviews Approve or Send back with a review token (`Authorization: Bearer cf_rt_…`), no cookie: the one
24
24
  * route that answers before the sign-in guard, and takes the token alone (server/review-api.js)
25
- * * /api/review-tokens… make, list and revoke review tokens: the signed-in maintainer only (server/review-api.js)
25
+ * * /api/review-tokens…, /api/agent-tokens…
26
+ * make, list and revoke review tokens, and agent tokens: the signed-in maintainer only (server/review-api.js)
27
+ * POST /api/agent-requests ask for an agent run with an agent token (`Authorization: Bearer cf_at_…`), no cookie: before the guard too (server/agent-api.js)
28
+ * GET /api/agent-requests, POST /api/agent-requests/<run>/claim, GET and POST /api/runs/<run>
29
+ * a runner's: list the queue, claim, read and report, with a runner token (`cf_nt_…`) and no cookie, before the guard
30
+ * too; a runner token on any other route is 403 (server/runner-api.js)
31
+ * * /api/agent-runs… the dashboard's runs: list, one, start, stop; the signed-in maintainer only (server/agent-api.js, server/agent-runs.js)
26
32
  * * /proposals… server/proposals.js (with sign-in, the open spec PRs too: server/proposal-pulls.js,
27
33
  * and a review is a comment on one: server/reviews.js)
28
34
  * * <proxy path>/… server/proxy.js, for each path the config's `proxy` names
@@ -42,11 +48,15 @@ import http from 'node:http';
42
48
  import path from 'node:path';
43
49
  import { createHash } from 'node:crypto';
44
50
  import { fileURLToPath } from 'node:url';
45
- import { browserSources, BUILTIN_SOURCES, specPollMs, ideaLabel } from '../core/config.js';
51
+ import { browserSources, BUILTIN_SOURCES, parseDuration, specPollMs, DEFAULTS, hookWants, ideaLabel } from '../core/config.js';
46
52
  import { CLOUD_FILE } from '../core/cloud.js';
47
53
  import { decodePath, inside, notFound, sendFile } from './files.js';
48
54
  import { serveProposals } from './proposals.js';
49
55
  import { serveForge } from './forge-routes.js';
56
+ import { hasAdapter } from './forge.js';
57
+ import { createRuns } from './agent-runs.js';
58
+ import { REQUESTS_PATH, serveAgentCall, serveRunRoutes } from './agent-api.js';
59
+ import { presentedScope, runnerRoute, serveRunnerCall, serveStrayRunner, takesTokens } from './runner-api.js';
50
60
  import { REVIEWS_PATH, serveReviewCall, serveTokenRoutes } from './review-api.js';
51
61
  import { proxyTo } from './proxy.js';
52
62
  import { HA_ENV, homeAssistantHouse } from '../plugins/house/home-assistant.js';
@@ -158,9 +168,20 @@ export function createCornfieldServer(config, opts = {}) {
158
168
  // What a restart left waiting: looked at again, in the background
159
169
  lander.resume().catch((err) => console.error(`[cornfield] pending landings could not be picked up: ${err instanceof Error ? err.message : err}`));
160
170
  }
161
- // Looks at the open spec PRs on a timer when a hook wants spec.ready (config `hookPoll`); started when the server listens
162
- const pollMs = hooks ? specPollMs(config) : null;
163
- const specPoll = pollMs ? createSpecPoll({ config, where, auth, hooks, intervalMs: pollMs, now: opts.now, timer: opts.specPollTimer }) : null;
171
+ // Agent runs (server/agent-runs.js) need a signed-in maintainer to ask as and a forge to ask on; the runs are followed on the spec poll's tick
172
+ const runs = config.forge && auth.enabled && hasAdapter(config.forge.type) ? createRuns({ config, where, auth, hooks, now: opts.now, onAsk: () => specPoll?.start() }) : null;
173
+ // Push and pull need the same two things a request on the forge does (a maintainer's sign-in to ask as, a forge to check against), and push needs the hook that delivers it
174
+ const transport = config.agents?.transport ?? 'forge';
175
+ if (transport !== 'forge' && !runs) throw new Error(`agents.transport is "${transport}", which needs sign-in and a forge to ask as a maintainer and to check them: set them up (docs/sign-in.md, docs/config.md#forge).`);
176
+ if (transport === 'push' && !hookWants(config, ['agent.requested'])) throw new Error('agents.transport is "push", so a hook must subscribe to agent.requested: add { url, events: ["agent.requested"] } to "hooks" (docs/config.md#hooks). Without one a request would go nowhere.');
177
+ runs?.resume().catch((err) => console.error(`[cornfield] agent runs could not be picked up: ${err instanceof Error ? err.message : err}`));
178
+ // Looks at the open spec PRs on a timer when a hook wants spec.ready (config `hookPoll`); started when the server listens. The same timer follows
179
+ // agent runs: it is started for them when there is a run to follow, or as one is asked for, and does nothing while there is none
180
+ const specMs = hooks ? specPollMs(config) : null;
181
+ const runsEvery = config.hookPoll === undefined ? DEFAULTS.hookPoll.specs : config.hookPoll.specs;
182
+ const pollMs = specMs ?? (runs && runsEvery !== false ? parseDuration(runsEvery) : null);
183
+ const specPoll = pollMs ? createSpecPoll({ config, where, auth, hooks, runs, specs: specMs !== null, intervalMs: pollMs, now: opts.now, timer: opts.specPollTimer }) : null;
184
+ let serverClosed = false;
164
185
  const proxies = Object.entries(config.proxy || {});
165
186
  const houseSource = config.sources.house && (config.sources.house === 'house' || config.sources.house.use === 'house') ? config.sources.house : null;
166
187
  const houseFile = houseSource && houseSource.options && houseSource.options.file ? inside(root, houseSource.options.file) : null;
@@ -198,8 +219,14 @@ export function createCornfieldServer(config, opts = {}) {
198
219
  else res.end();
199
220
  }
200
221
  });
201
- server.on('listening', () => specPoll?.start());
202
- server.on('close', () => { specPoll?.close(); lander?.close(); hooks?.close(); auth.close(); });
222
+ server.on('listening', () => {
223
+ if (specMs !== null) specPoll?.start();
224
+ else if (specPoll && runs) {
225
+ // Runs asked for before a restart are followed again from the start
226
+ runs.hasWork().then((work) => { if (work && !serverClosed) specPoll.start(); }).catch(() => {});
227
+ }
228
+ });
229
+ server.on('close', () => { serverClosed = true; specPoll?.close(); lander?.close(); hooks?.close(); auth.close(); });
203
230
  return server;
204
231
 
205
232
  async function answer(req, res) {
@@ -230,18 +257,27 @@ export function createCornfieldServer(config, opts = {}) {
230
257
  return res.end('400 Bad Request');
231
258
  }
232
259
 
260
+ // A runner token is good for the runner routes only: on any other route it is refused here, before anything else is looked at
261
+ if (presentedScope(req) === 'runner' && !takesTokens(reqPath, { reviews: REVIEWS_PATH })) return serveStrayRunner(req, res, reqPath, { config, auth, now: opts.now });
262
+
233
263
  if (reqPath === '/healthz') {
234
264
  req.resume();
235
265
  return sendJson(res, 200, { ok: true });
236
266
  }
237
267
  // The review API takes a token, not a session: it is the one route before the guard (and it reads no cookie)
238
268
  if (reqPath === REVIEWS_PATH) return serveReviewCall(req, res, { config, where, auth, hooks, lander, now: opts.now });
269
+ // …and so does asking for a run: an agent token, not a session
270
+ // …and a runner asks for work and reports on it with a runner token (the GET of the same path lists the queue)
271
+ const runner = runnerRoute(reqPath);
272
+ if (runner && !(reqPath === REQUESTS_PATH && req.method !== 'GET' && req.method !== 'HEAD')) return serveRunnerCall(req, res, runner, url, { config, auth, runs, now: opts.now });
273
+ if (reqPath === REQUESTS_PATH) return serveAgentCall(req, res, { config, auth, runs, now: opts.now });
239
274
  const gate = await auth.gate(req, res, reqPath, url);
240
275
  if (gate.done) return;
241
276
  const signedIn = gate.session ? { login: gate.session.login } : false;
242
277
 
243
278
  // Before the proxies: a repo's `/api/` proxy must not swallow the panel's own routes
244
279
  if (await serveTokenRoutes(req, res, reqPath, { config, where, auth, session: gate.session, now: opts.now, publicHost })) return;
280
+ if (await serveRunRoutes(req, res, reqPath, { config, auth, runs, session: gate.session, now: opts.now, publicHost })) return;
245
281
  if (await serveForge(req, res, reqPath, url, { config, where, session: gate.session, auth, hooks, lander, now: opts.now, publicHost })) return;
246
282
  for (const [prefix, target] of proxies) if (reqPath.startsWith(prefix)) return proxyTo(req, res, target);
247
283
  const session = gate.session;
@@ -24,6 +24,10 @@
24
24
  * token is only ever asked those two questions, never used to list. With no such session the tick does nothing and says so
25
25
  * once for the stretch (and once more when a session is back). Whose sign-in it looks with is logged each time that changes.
26
26
  *
27
+ * **It also follows agent runs** (issue #80, server/agent-runs.js): when there is a run to look at (`runs.hasWork()`, a question of the
28
+ * database alone, so an idle repo costs the forge nothing), the same tick hands the same maintainer's forge to `runs.follow`, which reads the
29
+ * runs' comments and pull requests. A server with no `spec.ready` hook still polls for runs (`specs: false`) and then does nothing else.
30
+ *
27
31
  * **Never in the way.** One tick runs at a time (the next is scheduled when one ends, so a slow forge cannot pile them up).
28
32
  * A forge error is logged and never thrown; the next tick waits twice the interval until one succeeds. The timer is
29
33
  * unref'd and `close()` clears it.
@@ -48,14 +52,17 @@ const words = (err) => (err instanceof Error ? err.message : String(err));
48
52
  * config: any,
49
53
  * where: { proposals: string },
50
54
  * auth: { store: () => Promise<import('node:sqlite').DatabaseSync>, forgeForUser: (userId: string) => Promise<import('./forge.js').Forge> },
51
- * hooks: import('./hooks.js').Hooks,
55
+ * hooks: import('./hooks.js').Hooks | null,
56
+ * runs?: { hasWork: () => Promise<boolean>, follow: (forge: import('./forge.js').Forge) => Promise<unknown> } | null,
57
+ * specs?: boolean,
52
58
  * intervalMs: number,
53
59
  * now?: () => number,
54
60
  * timer?: { setTimeout: Function, clearTimeout: Function },
55
61
  * firstMs?: number,
56
- * }} opts `now`, `timer` and `firstMs` are the tests' clock
62
+ * }} opts `now`, `timer` and `firstMs` are the tests' clock; `runs` is the agent runs engine, whose runs are followed on the same tick, and
63
+ * `specs: false` leaves the spec PRs alone (no hook wants `spec.ready`), so only the runs are looked at
57
64
  */
58
- export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = Date.now, timer = { setTimeout, clearTimeout }, firstMs = FIRST_MS }) {
65
+ export function createSpecPoll({ config, where, auth, hooks, runs = null, specs = true, intervalMs, now = Date.now, timer = { setTimeout, clearTimeout }, firstMs = FIRST_MS }) {
59
66
  const repo = config.forge.repo;
60
67
  const log = (what) => console.error(`[cornfield] spec.ready poll: ${what}`);
61
68
  let handle = null;
@@ -101,6 +108,14 @@ export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = D
101
108
 
102
109
  /** One look. Resolves to what happened; never rejects. */
103
110
  async function look() {
111
+ // Nothing to look at: no forge call is made (an idle repo with only runs to follow costs nothing)
112
+ let followRuns = false;
113
+ try {
114
+ followRuns = Boolean(runs) && await /** @type {NonNullable<typeof runs>} */ (runs).hasWork();
115
+ } catch (err) {
116
+ log(`the runs could not be counted: ${words(err)}`);
117
+ }
118
+ if (!specs && !followRuns) return 'ok';
104
119
  let found;
105
120
  try {
106
121
  found = await maintainerForge();
@@ -120,20 +135,31 @@ export function createSpecPoll({ config, where, auth, hooks, intervalMs, now = D
120
135
  noSession = false;
121
136
  if (found.login !== lookingAs) log(`looking at the open spec PRs${threadsOn(config) && hookWants(config, THREAD_EVENTS) ? ' and threads' : ''} with the sign-in of @${found.login}, a maintainer`);
122
137
  lookingAs = found.login;
123
- try {
124
- const specs = hookWants(config, ['spec.ready']);
125
- const threads = threadsOn(config) && hookWants(config, THREAD_EVENTS);
126
- // One listing of the open PRs serves both: the threads' look makes it itself when it runs
127
- const problem = threads ? await lookAtThreads(found.forge, specs) : (await listPullProposals(found.forge, where, { hooks })).problem;
128
- if (problem) {
129
- log(`the forge could not be asked: ${problem}`);
130
- return 'error';
138
+ let out = 'ok';
139
+ if (specs) {
140
+ try {
141
+ const specReady = hookWants(config, ['spec.ready']);
142
+ const threads = threadsOn(config) && hookWants(config, THREAD_EVENTS);
143
+ // One listing of the open PRs serves both: the threads' look makes it itself when it runs
144
+ const problem = threads ? await lookAtThreads(found.forge, specReady) : (await listPullProposals(found.forge, where, { hooks })).problem;
145
+ if (problem) {
146
+ log(`the forge could not be asked: ${problem}`);
147
+ out = 'error';
148
+ }
149
+ } catch (err) {
150
+ log(`failed: ${words(err)}`);
151
+ out = 'error';
152
+ }
153
+ }
154
+ if (followRuns && !closed) {
155
+ try {
156
+ await /** @type {NonNullable<typeof runs>} */ (runs).follow(found.forge);
157
+ } catch (err) {
158
+ log(`the runs could not be followed: ${words(err)}`);
159
+ out = 'error';
131
160
  }
132
- return 'ok';
133
- } catch (err) {
134
- log(`failed: ${words(err)}`);
135
- return 'error';
136
161
  }
162
+ return out;
137
163
  }
138
164
 
139
165
  function schedule(ms) {