cornfield 0.9.0 → 0.11.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.
@@ -57,13 +57,13 @@
57
57
  * @typedef {{ run_id: string, repo: string, kind: string, profile: string | null, issue: number | null, proposal: string | null, thread: number | null,
58
58
  * target: number, comment_id: string, comment_url: string | null, request: string, requested_by: string, requested_by_id: string, requested_at: string,
59
59
  * via: string, state: string, reported_at: string | null, reported_by: string | null, note: string | null, url: string | null, tokens: number | null,
60
- * cost: number | null, stop_requested_at: string | null, stop_requested_by: string | null, pull: number | null, pull_state: string | null,
60
+ * cost: number | null, transport: 'forge' | 'push' | 'pull', claimed_by: number | null, claimed_at: string | null, stop_requested_at: string | null, stop_requested_by: string | null, pull: number | null, pull_state: string | null,
61
61
  * pull_url: string | null, pull_by: string | null, pull_trusted: number, linked_via: string | null, branch: string | null, head_sha: string | null,
62
62
  * checks: string | null, last_activity: string }} RunRow
63
63
  */
64
64
 
65
65
  import { ForgeError, isMaintainer } from './forge.js';
66
- import { createRequest, findRunId, formatRunComment, runAuthors, runBlocksOf, runIdFromBranch, MAX_NOTE_CHARS } from '../core/agent-contract.js';
66
+ import { createRequest, findRunId, formatRunComment, runAuthors, runBlocksOf, runIdFromBranch, validateReport, validateRequest, MAX_NOTE_CHARS } from '../core/agent-contract.js';
67
67
  import { quietAfterMs } from '../core/config.js';
68
68
  import { listPullProposals } from './proposal-pulls.js';
69
69
  import { Refused, countAttempt, throttle } from './throttle.js';
@@ -81,12 +81,17 @@ export const FOLLOW_FLOOR_MS = 48 * 3_600_000;
81
81
  export const RESERVATION_MS = 10 * 60_000;
82
82
  /** The runs a listing answers */
83
83
  export const LIST_LIMIT = 200;
84
+ /** The queued requests a runner's listing answers at most, oldest first */
85
+ export const QUEUE_LIMIT = 50;
86
+ /** The runs that are over once the runner has said so */
84
87
  /** The keys of a request as the API and the button send it: the contract's fields, less what cornfield writes, plus `force` */
85
88
  export const REQUEST_BODY_KEYS = Object.freeze(['kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'force']);
86
89
 
87
90
  /** The states in which a run is over for good unless the runner says more */
88
91
  export const FINISHED = Object.freeze(['done', 'failed', 'stopped', 'merged', 'closed']);
89
92
  const REPORT_FINISHED = ['done', 'failed', 'stopped'];
93
+ /** Where a run is looked up for the dashboard and the poll: posted on the forge, or (push and pull) there is no comment to wait for */
94
+ const POSTED = "(comment_id <> '' OR transport <> 'forge')";
90
95
 
91
96
  const iso = (ms) => new Date(ms).toISOString();
92
97
  const lower = (s) => String(s).toLowerCase();
@@ -149,7 +154,8 @@ export function publicRun(row, nowMs, quietMs) {
149
154
  return {
150
155
  runId: row.run_id, kind: row.kind, profile: row.profile,
151
156
  refs: { ...(row.issue != null ? { issue: row.issue } : {}), ...(row.proposal != null ? { proposal: row.proposal } : {}), ...(row.thread != null ? { thread: row.thread } : {}) },
152
- status, open, requestedBy: row.requested_by, requestedAt: row.requested_at, via: row.via, target: row.target,
157
+ status, open, requestedBy: row.requested_by, requestedAt: row.requested_at, via: row.via, transport: row.transport, target: row.target,
158
+ claimedAt: row.claimed_at,
153
159
  comment: row.comment_id ? { id: row.comment_id, url: row.comment_url } : null,
154
160
  limits,
155
161
  report: row.reported_at ? { state: row.state, at: row.reported_at, by: row.reported_by, note: row.note, url: row.url, tokens: row.tokens, cost: row.cost } : null,
@@ -166,6 +172,8 @@ export function publicRun(row, nowMs, quietMs) {
166
172
  * @property {{ auth: object, now?: () => number }} limits what server/throttle.js counts by
167
173
  * @property {{ token: { id: number, label: string } } | null} [via] set when the request came through the API: the token it came with
168
174
  * @property {() => string | null | Promise<string | null>} [stillAllowed] asked once more just before the cap is taken: null to go on, or why not
175
+ * @property {{ event: string, ref: string }} [trigger] set only by an opt-in trigger (server/triggers.js): what caused this request. It is written into the
176
+ * request (`trigger: { event, by, ref }`, `by` being the maintainer asking) and the audit row, and the run's `via` is 'trigger'. A caller's body can never set it
169
177
  */
170
178
 
171
179
  /**
@@ -187,6 +195,8 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
187
195
  const runnerSet = new Set((Array.isArray(agents.runners) ? agents.runners : []).map(lower));
188
196
  const authorSet = new Set((Array.isArray(agents.authors) ? agents.authors : []).map(lower));
189
197
  const quietMs = quietAfterMs(config);
198
+ /** How requests travel from now on (`agents.transport`); a run keeps the transport it was asked for with */
199
+ const transport = /** @type {'forge' | 'push' | 'pull'} */ (agents.transport ?? 'forge');
190
200
  const log = (what) => console.error(`[cornfield] agent runs: ${what}`);
191
201
  let following = null;
192
202
 
@@ -204,17 +214,17 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
204
214
  */
205
215
  function openRows(db, at) {
206
216
  return /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND state NOT IN ('done', 'failed', 'stopped') ORDER BY requested_at").all(repo))
207
- .filter((r) => (r.comment_id !== '' || at - Date.parse(r.requested_at) < RESERVATION_MS) && statusOf(r, at, quietMs).open);
217
+ .filter((r) => (r.comment_id !== '' || r.transport !== 'forge' || at - Date.parse(r.requested_at) < RESERVATION_MS) && statusOf(r, at, quietMs).open);
208
218
  }
209
219
 
210
220
  /** Takes a place for a run: the cap and the one-per-subject rule are checked and the row inserted in one transaction. Throws a `Refused`. */
211
- function reserve(db, request, { target, who, via, force }) {
221
+ function reserve(db, request, { target, who, via, force, trigger = false }) {
212
222
  const at = now();
213
223
  const mine = subjectKeys(request.refs);
214
224
  db.exec('BEGIN IMMEDIATE');
215
225
  try {
216
226
  // What a crash left (a reservation never posted) is dropped here too, not only at the next start
217
- db.prepare("DELETE FROM runs WHERE repo = ? AND comment_id = '' AND requested_at < ?").run(repo, iso(at - RESERVATION_MS));
227
+ db.prepare("DELETE FROM runs WHERE repo = ? AND comment_id = '' AND transport = 'forge' AND requested_at < ?").run(repo, iso(at - RESERVATION_MS));
218
228
  const open = openRows(db, at);
219
229
  if (open.length >= cap) {
220
230
  throw new Refused(409, `${repo} already has ${open.length} open run${open.length === 1 ? '' : 's'}, the most cornfield asks for (agents.maxOpen is ${cap}): ${open.map((r) => r.run_id).join(', ')}. Wait for one to finish or go quiet, or stop one.`, 'cap-reached');
@@ -226,10 +236,10 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
226
236
  throw new Refused(409, `${subjectWords(request.refs)} already has an open run${clash.profile ? ` under the profile ${clash.profile}` : ''}: ${clash.run_id} (${statusOf(clash, at, quietMs).status}). To ask for another on purpose, send "force": true${profiles.length ? ', or pick another profile' : ''}.`, 'run-open');
227
237
  }
228
238
  }
229
- db.prepare(`INSERT INTO runs (run_id, repo, kind, profile, issue, proposal, thread, target, request, requested_by, requested_by_id, requested_at, via, last_activity)
230
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
239
+ db.prepare(`INSERT INTO runs (run_id, repo, kind, profile, issue, proposal, thread, target, request, requested_by, requested_by_id, requested_at, via, last_activity, transport)
240
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
231
241
  .run(request.runId, repo, request.kind, request.profile ?? null, request.refs.issue ?? null, request.refs.proposal ?? null, request.refs.thread ?? null,
232
- target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : 'dashboard', iso(at));
242
+ target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : trigger ? 'trigger' : 'dashboard', iso(at), transport);
233
243
  db.exec('COMMIT');
234
244
  } catch (err) {
235
245
  try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
@@ -255,6 +265,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
255
265
  }
256
266
  if (typeof body.brief === 'string') out.briefChars = body.brief.length;
257
267
  if (body.force === true) out.force = true;
268
+ if (transport !== 'forge') out.transport = transport;
258
269
  if (via) Object.assign(out, { via: 'api', token: via.token });
259
270
  return out;
260
271
  }
@@ -279,7 +290,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
279
290
  try {
280
291
  if (problem) throw new Refused(problem.status, problem.message, 'invalid');
281
292
  if (!isObject(body)) throw new Refused(400, `A request is a JSON object: { kind, refs, profile?, brief?, branch?, limits?, nodes?, force? }.`, 'invalid');
282
- const { force, requestedBy, ...fields } = body;
293
+ const { force, requestedBy, trigger: forged, ...fields } = body;
294
+ // What caused a request is cornfield's to write, from a trigger's own call; a caller cannot say it was a trigger
295
+ if (forged !== undefined) throw new Refused(400, '"trigger" is written by cornfield for an automatic request; leave it out.', 'invalid');
283
296
  if (force !== undefined && typeof force !== 'boolean') throw new Refused(400, '"force" is true or false: whether to ask for another run on an issue or proposal that has an open one.', 'invalid');
284
297
  const build = (login, pull) => {
285
298
  // The config's limits are hints the request starts from; what the person sent wins key by key
@@ -289,10 +302,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
289
302
  else if (isObject(limits)) limits = { ...base, ...limits };
290
303
  // The brief is required by the contract and may be empty; the spec PR that holds a proposal is named beside it
291
304
  const refs = pull != null && isObject(fields.refs) ? { ...fields.refs, pull } : fields.refs;
292
- const made = createRequest({ brief: '', ...fields, refs, ...(limits !== undefined ? { limits } : {}), requestedBy: login }, { profiles, now: at });
305
+ const made = createRequest({ brief: '', ...fields, refs, ...(limits !== undefined ? { limits } : {}), requestedBy: login, ...(ctx.trigger ? { trigger: { event: ctx.trigger.event, by: login, ref: ctx.trigger.ref } } : {}) }, { profiles, now: at });
293
306
  if (!made.ok) throw new Refused(400, made.error, 'invalid');
294
307
  return /** @type {RunRequest} */ (made.value);
295
308
  };
309
+ if (ctx.trigger) Object.assign(asked, { trigger: { event: ctx.trigger.event, ref: ctx.trigger.ref } });
296
310
  let made = build(who.login);
297
311
  runId = made.runId;
298
312
  Object.assign(asked, { runId });
@@ -327,10 +341,17 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
327
341
  }
328
342
  }
329
343
  target = `${repo}#${number}`;
330
- let comment = formatRunComment(made.runId, made, { profiles });
331
- if (via) {
332
- const [head, ...rest] = comment.split('\n\n');
333
- comment = [`${head} Sent through the agent API with the token \`${via.token.label}\`.`, ...rest].join('\n\n');
344
+ // On the forge the request is a comment. On push and pull there is none: the run is recorded and the transport delivers it
345
+ let comment = null;
346
+ if (transport === 'forge') {
347
+ comment = formatRunComment(made.runId, made, { profiles });
348
+ if (via) {
349
+ const [head, ...rest] = comment.split('\n\n');
350
+ comment = [`${head} Sent through the agent API with the token \`${via.token.label}\`.`, ...rest].join('\n\n');
351
+ } else if (made.trigger) {
352
+ const [head, ...rest] = comment.split('\n\n');
353
+ comment = [`${head} Asked by the trigger on ${made.trigger.event} (${made.trigger.ref}), after an act of ${made.trigger.by}'s.`, ...rest].join('\n\n');
354
+ }
334
355
  }
335
356
 
336
357
  // The token once more, after every forge call: one revoked or expired meanwhile does not ask
@@ -339,21 +360,23 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
339
360
  if (why) throw new Refused(403, why, 'forbidden');
340
361
  }
341
362
  const db = await auth.store();
342
- reserve(db, made, { target: number, who, via, force: force === true });
343
- let posted;
344
- try {
345
- posted = await forge.comment(number, comment);
346
- } catch (err) {
347
- db.prepare('DELETE FROM runs WHERE run_id = ?').run(made.runId);
348
- throw err;
363
+ reserve(db, made, { target: number, who, via, force: force === true, trigger: Boolean(made.trigger) });
364
+ let posted = null;
365
+ if (comment !== null) {
366
+ try {
367
+ posted = await forge.comment(number, comment);
368
+ } catch (err) {
369
+ db.prepare('DELETE FROM runs WHERE run_id = ?').run(made.runId);
370
+ throw err;
371
+ }
372
+ db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
349
373
  }
350
- db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
351
374
  status = 201;
352
- await audit({ login: me.login, action: 'agent-request', target, request: { ...asked, target: number }, status, answer: { comment: posted.id, url: posted.url ?? null } });
375
+ await audit({ login: me.login, action: 'agent-request', target, request: { ...asked, target: number }, status, answer: posted ? { comment: posted.id, url: posted.url ?? null } : { transport, queued: transport === 'pull' } });
353
376
  hooks?.emit('agent.requested', { request: made, actor: me.login });
354
377
  try { onAsk?.(); } catch { /* the poll is a convenience */ }
355
378
  const row = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(made.runId));
356
- return { run: publicRun(row, now(), quietMs), request: made, comment: { id: posted.id, url: posted.url ?? null, number } };
379
+ return { run: publicRun(row, now(), quietMs), request: made, comment: posted ? { id: posted.id, url: posted.url ?? null, number } : null };
357
380
  } catch (err) {
358
381
  if (status === null) {
359
382
  const out = describe(err);
@@ -407,30 +430,45 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
407
430
  const why = await ctx.stillAllowed();
408
431
  if (why) throw new Refused(403, why, 'forbidden');
409
432
  }
410
- let comment;
411
- try {
412
- comment = formatRunComment(row.run_id, { state: 'stop-requested', ...(note ? { note } : {}) }, { line: `Asked run \`${row.run_id}\` to stop (${me.login}, in cornfield${via ? `, through the agent API with the token \`${via.token.label}\`` : ''}).` });
413
- } catch (err) {
414
- throw new Refused(400, words(err), 'invalid');
415
- }
416
- const posted = await forge.comment(row.target, comment);
417
433
  const stamp = iso(now());
434
+ /** A pull run nobody has claimed has no runner to stop: cornfield ends it itself, saying so (the one `stopped` it writes) */
435
+ const unclaimed = row.transport === 'pull' && row.claimed_by == null && row.state === 'requested';
436
+ let posted = null;
437
+ if (row.transport === 'forge') {
438
+ let comment;
439
+ try {
440
+ comment = formatRunComment(row.run_id, { state: 'stop-requested', ...(note ? { note } : {}) }, { line: `Asked run \`${row.run_id}\` to stop (${me.login}, in cornfield${via ? `, through the agent API with the token \`${via.token.label}\`` : ''}).` });
441
+ } catch (err) {
442
+ throw new Refused(400, words(err), 'invalid');
443
+ }
444
+ posted = await forge.comment(row.target, comment);
445
+ }
418
446
  // Judged already: the follow that reads this comment back must not fire the hook again
419
447
  db.exec('BEGIN IMMEDIATE');
420
448
  try {
421
449
  db.prepare('UPDATE runs SET stop_requested_at = ?, stop_requested_by = ?, last_activity = MAX(last_activity, ?) WHERE run_id = ?').run(stamp, me.login, stamp, row.run_id);
422
- db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
423
- db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `ev:stop:${posted.id}:c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
450
+ if (posted) {
451
+ db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
452
+ db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `ev:stop:${posted.id}:c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
453
+ }
454
+ if (unclaimed) {
455
+ const ended = db.prepare("UPDATE runs SET state = 'stopped', reported_at = ?, reported_by = ?, note = ? WHERE run_id = ? AND state = 'requested' AND claimed_by IS NULL").run(stamp, me.login, 'Stopped before a runner claimed it.', row.run_id).changes;
456
+ if (ended !== 1) throw new Refused(409, `Run ${row.run_id} was claimed just now; ask again to stop it.`, 'conflict');
457
+ }
424
458
  db.exec('COMMIT');
425
459
  } catch (err) {
426
460
  try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
427
461
  throw err;
428
462
  }
429
463
  done = true;
430
- await audit({ login: me.login, action: 'agent-stop', target, request: { ...asked, ...(note ? { noteChars: note.length } : {}) }, status: 201, answer: { comment: posted.id, url: posted.url ?? null } });
464
+ await audit({ login: me.login, action: 'agent-stop', target, request: { ...asked, ...(note ? { noteChars: note.length } : {}) }, status: 201, answer: posted ? { comment: posted.id, url: posted.url ?? null } : { transport: row.transport, ...(unclaimed ? { stopped: true } : {}) } });
431
465
  const after = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(row.run_id));
432
466
  hooks?.emit('run.stopRequested', runPayload(after, me.login));
433
- return { run: publicRun(after, now(), quietMs), comment: { id: posted.id, url: posted.url ?? null } };
467
+ if (unclaimed) {
468
+ hooks?.emit('run.state', { ...runPayload(after, me.login), state: 'stopped' });
469
+ hooks?.emit('run.finished', { ...runPayload(after, me.login), state: 'stopped' });
470
+ }
471
+ return { run: publicRun(after, now(), quietMs), comment: posted ? { id: posted.id, url: posted.url ?? null } : null };
434
472
  } catch (err) {
435
473
  if (!done) {
436
474
  const out = describe(err);
@@ -456,21 +494,32 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
456
494
  async function list({ limit = LIST_LIMIT } = {}) {
457
495
  const db = await auth.store();
458
496
  const at = now();
459
- const rows = /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND comment_id <> '' ORDER BY requested_at DESC, run_id DESC LIMIT ?").all(repo, limit));
460
- return { runs: rows.map((r) => publicRun(r, at, quietMs)), open: openRows(db, at).length, maxOpen: cap, profiles, limits: agents.limits || {}, quietAfter: agents.quietAfter, runners: [...runnerSet].length };
497
+ const rows = /** @type {RunRow[]} */ (db.prepare(`SELECT * FROM runs WHERE repo = ? AND ${POSTED} ORDER BY requested_at DESC, run_id DESC LIMIT ?`).all(repo, limit));
498
+ // The dashboard's notice for triggers that did nothing (server/triggers.js writes a `trigger-skipped` row each time): the last day's, by reason
499
+ const skipped = /** @type {{ at: string, answer: string | null }[]} */ (db.prepare("SELECT at, answer FROM actions WHERE action = 'trigger-skipped' AND (target = ? OR target LIKE ?) AND at > ? ORDER BY id DESC LIMIT 200").all(repo, `${repo}#%`, iso(at - 24 * 3_600_000)));
500
+ const reasons = {};
501
+ for (const r of skipped) {
502
+ let why = 'error';
503
+ try { why = JSON.parse(r.answer ?? '{}').reason || why; } catch { /* a row that is not JSON is counted as an error */ }
504
+ reasons[why] = (reasons[why] || 0) + 1;
505
+ }
506
+ return {
507
+ runs: rows.map((r) => publicRun(r, at, quietMs)), open: openRows(db, at).length, maxOpen: cap, profiles, limits: agents.limits || {}, quietAfter: agents.quietAfter, runners: [...runnerSet].length,
508
+ triggerSkipped: skipped.length ? { count: skipped.length, last: skipped[0].at, reasons } : null,
509
+ };
461
510
  }
462
511
 
463
512
  /** One run, or null */
464
513
  async function get(runId) {
465
514
  const db = await auth.store();
466
- const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare("SELECT * FROM runs WHERE run_id = ? AND repo = ? AND comment_id <> ''").get(runId, repo)) : undefined;
515
+ const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare(`SELECT * FROM runs WHERE run_id = ? AND repo = ? AND ${POSTED}`).get(runId, repo)) : undefined;
467
516
  return row ? publicRun(row, now(), quietMs) : null;
468
517
  }
469
518
 
470
519
  /** The runs a follow looks at: posted, not over for good (a trusted pull request merged or closed), and active within the follow window */
471
520
  function followable(db, at) {
472
521
  const window = Math.max(2 * quietMs, FOLLOW_FLOOR_MS);
473
- return /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND comment_id <> '' AND last_activity > ? AND NOT (pull_trusted = 1 AND pull_state IN ('merged', 'closed')) ORDER BY last_activity DESC LIMIT ?")
522
+ return /** @type {RunRow[]} */ (db.prepare(`SELECT * FROM runs WHERE repo = ? AND ${POSTED} AND last_activity > ? AND NOT (pull_trusted = 1 AND pull_state IN ('merged', 'closed')) ORDER BY last_activity DESC LIMIT ?`)
474
523
  .all(repo, iso(at - window), MAX_FOLLOWED));
475
524
  }
476
525
 
@@ -607,7 +656,8 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
607
656
  // ---- the comments -----------------------------------------------------
608
657
  /** @type {Map<number, Set<string>>} */
609
658
  const places = new Map();
610
- for (const r of cur.values()) for (const n of [r.target, r.pull]) if (n != null) places.set(n, (places.get(n) ?? new Set()).add(r.run_id));
659
+ // A run asked for over push or pull is reported to over the API, with a runner token: a comment on the forge does not move it
660
+ for (const r of cur.values()) if (r.transport === 'forge') for (const n of [r.target, r.pull]) if (n != null) places.set(n, (places.get(n) ?? new Set()).add(r.run_id));
611
661
  /** @type {{ row: RunRow, id: string, key: string, at: string, by: string | null, block: any }[]} */
612
662
  const heard = [];
613
663
  for (const [number, ids] of places) {
@@ -756,14 +806,135 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
756
806
  return out;
757
807
  }
758
808
 
809
+ // -------------------------------------------------------------------------
810
+ // Runners: pull and push (a runner token, server/runner-api.js; spec 0005 D2, D5)
811
+ // -------------------------------------------------------------------------
812
+
813
+ /** @typedef {{ id: number, label: string }} RunnerToken the runner token a call came with */
814
+
815
+ const runnerActor = (token) => `runner:${token.label}`;
816
+
817
+ /** A run for a runner: the state it is in as cornfield derives it, and whether a maintainer asked it to stop. Nothing else about the run, no credential */
818
+ function runnerView(row) {
819
+ const st = statusOf(row, now(), quietMs);
820
+ return { runId: row.run_id, state: st.status, reported: row.state === 'requested' ? null : row.state, stopRequested: row.stop_requested_at != null, ...(row.stop_requested_at ? { stopRequestedAt: row.stop_requested_at } : {}), open: st.open, finished: st.finished };
821
+ }
822
+
823
+ /** The run a runner token names, or a `Refused`: 404 when there is none, 403 when the token may not touch it */
824
+ function runnerRun(db, token, runId) {
825
+ const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare(`SELECT * FROM runs WHERE run_id = ? AND repo = ? AND ${POSTED}`).get(runId, repo)) : undefined;
826
+ if (!row) throw new Refused(404, 'cornfield has no such run.', 'not-found');
827
+ if (row.transport === 'forge') throw new Refused(403, 'This run was asked for on the forge, not over push or pull. A runner token does not reach it: report it as a cornfield-run comment.', 'forbidden');
828
+ if (row.transport === 'pull' && row.claimed_by == null) throw new Refused(403, 'Nobody has claimed this run. Claim it first: POST /api/agent-requests/<runId>/claim.', 'forbidden');
829
+ if (row.claimed_by != null && row.claimed_by !== token.id) throw new Refused(403, 'Another runner token holds this run. Only the token that claimed it (for a pushed run, the first to report on it) reads and reports on it.', 'forbidden');
830
+ return row;
831
+ }
832
+
833
+ /**
834
+ * The requests waiting for a runner to claim: pull runs nobody has claimed, not asked to stop, not quiet, oldest first, at most `QUEUE_LIMIT`.
835
+ * Each is the contract's request as it was made, checked once more on the way out.
836
+ */
837
+ async function queued() {
838
+ const db = await auth.store();
839
+ const at = now();
840
+ const rows = /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND transport = 'pull' AND state = 'requested' AND claimed_by IS NULL AND stop_requested_at IS NULL AND last_activity > ? ORDER BY requested_at, run_id LIMIT ?")
841
+ .all(repo, iso(at - quietMs), QUEUE_LIMIT));
842
+ return rows.flatMap((r) => {
843
+ const checked = validateRequest(safeJson(r.request));
844
+ if (!checked.ok) { log(`the request ${r.run_id} is not a valid request any more and is not offered: ${checked.error}`); return []; }
845
+ return [checked.value];
846
+ });
847
+ }
848
+
849
+ const safeJson = (text) => { try { return JSON.parse(text); } catch { return null; } };
850
+
851
+ /**
852
+ * Claims a queued request for a runner token. ONE statement decides who wins (`UPDATE … WHERE claimed_by IS NULL`), so two claims at once
853
+ * cannot both succeed: the second is a 409. Answers the request and the run as the runner sees it.
854
+ * @param {RunnerToken} token
855
+ * @param {unknown} runId
856
+ */
857
+ async function claim(token, runId) {
858
+ const db = await auth.store();
859
+ const at = now();
860
+ const stamp = iso(at);
861
+ const won = typeof runId === 'string' ? db.prepare(`UPDATE runs SET claimed_by = ?, claimed_at = ?, state = 'claimed', reported_at = ?, reported_by = ?, note = NULL, last_activity = MAX(last_activity, ?)
862
+ WHERE run_id = ? AND repo = ? AND transport = 'pull' AND state = 'requested' AND claimed_by IS NULL AND stop_requested_at IS NULL AND last_activity > ?`)
863
+ .run(token.id, stamp, stamp, token.label, stamp, runId, repo, iso(at - quietMs)).changes === 1 : false;
864
+ const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare('SELECT * FROM runs WHERE run_id = ? AND repo = ?').get(runId, repo)) : undefined;
865
+ if (!row) throw new Refused(404, 'cornfield has no such request.', 'not-found');
866
+ if (!won) {
867
+ if (row.transport !== 'pull') throw new Refused(409, `This request goes over ${row.transport === 'push' ? 'push (an agent.requested hook event), not pull' : 'the forge, not pull'}; there is nothing to claim.`, 'conflict');
868
+ if (row.claimed_by != null) throw new Refused(409, 'Another runner claimed this request first.', 'already-claimed');
869
+ if (row.stop_requested_at) throw new Refused(409, 'A maintainer asked to stop this run before anyone claimed it.', 'conflict');
870
+ if (statusOf(row, at, quietMs).quiet) throw new Refused(409, 'This request has waited past agents.quietAfter and is no longer offered.', 'conflict');
871
+ throw new Refused(409, `This request is ${row.state}; there is nothing to claim.`, 'conflict');
872
+ }
873
+ const checked = validateRequest(safeJson(row.request));
874
+ hooks?.emit('run.claimed', { ...runPayload(row, runnerActor(token)), state: 'claimed' });
875
+ hooks?.emit('run.state', { ...runPayload(row, runnerActor(token)), state: 'claimed' });
876
+ return { request: checked.ok ? checked.value : null, run: runnerView(row), row };
877
+ }
878
+
879
+ /** One run as the runner that holds it sees it: `GET /api/runs/<runId>`, how a runner learns it was asked to stop */
880
+ async function runnerGet(token, runId) {
881
+ const db = await auth.store();
882
+ return runnerView(runnerRun(db, token, runId));
883
+ }
884
+
885
+ /**
886
+ * A runner's report: the contract's report JSON, from the token that holds the run (the one that claimed it; for a pushed run, the first to
887
+ * report). The same run state and hooks as a report on the forge. A run that is over takes no more.
888
+ * @param {RunnerToken} token
889
+ * @param {unknown} runId
890
+ * @param {unknown} body
891
+ */
892
+ async function report(token, runId, body) {
893
+ const db = await auth.store();
894
+ const row = runnerRun(db, token, runId);
895
+ const checked = validateReport(body);
896
+ if (!checked.ok) throw new Refused(400, checked.error, 'invalid');
897
+ const r = checked.value;
898
+ const at = now();
899
+ const was = statusOf(row, at, quietMs);
900
+ if (was.finished) throw new Refused(409, `Run ${row.run_id} is ${was.status}; it takes no more reports.`, 'conflict');
901
+ const stamp = iso(at);
902
+ const changed = db.prepare(`UPDATE runs SET state = ?, reported_at = ?, reported_by = ?, note = ?, url = ?, tokens = COALESCE(?, tokens), cost = COALESCE(?, cost),
903
+ claimed_by = COALESCE(claimed_by, ?), claimed_at = COALESCE(claimed_at, ?), last_activity = MAX(last_activity, ?)
904
+ WHERE run_id = ? AND (claimed_by IS NULL OR claimed_by = ?) AND state NOT IN ('done', 'failed', 'stopped')`)
905
+ .run(r.state, stamp, token.label, r.note ?? null, r.url ?? null, r.tokens ?? null, r.cost ?? null, token.id, stamp, stamp, row.run_id, token.id).changes;
906
+ if (changed !== 1) throw new Refused(409, `Run ${row.run_id} changed while the report was being taken; read it again (GET /api/runs/${row.run_id}).`, 'conflict');
907
+ const after = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(row.run_id));
908
+ const actor = runnerActor(token);
909
+ if (r.state === 'claimed' && row.state !== 'claimed') hooks?.emit('run.claimed', { ...runPayload(after, actor), state: r.state });
910
+ if (r.state !== row.state) hooks?.emit('run.state', { ...runPayload(after, actor), state: r.state });
911
+ if (REPORT_FINISHED.includes(r.state)) hooks?.emit('run.finished', { ...runPayload(after, actor), state: r.state });
912
+ return { run: runnerView(after), row: after, was: row };
913
+ }
914
+
759
915
  /** Drops what a crash left: a request that was never posted. Run once at start. */
760
916
  async function resume() {
761
917
  const db = await auth.store();
762
- const gone = db.prepare("DELETE FROM runs WHERE comment_id = '' AND requested_at < ?").run(iso(now() - RESERVATION_MS)).changes;
918
+ const gone = db.prepare("DELETE FROM runs WHERE comment_id = '' AND transport = 'forge' AND requested_at < ?").run(iso(now() - RESERVATION_MS)).changes;
763
919
  if (gone) log(`${gone} request${gone === 1 ? '' : 's'} that never reached the forge ${gone === 1 ? 'was' : 'were'} dropped`);
764
920
  }
765
921
 
766
- return { request, stop, list, get, follow, hasWork, resume, maxOpen: cap, profiles };
922
+ /**
923
+ * The open run on a subject, under any profile, or null: one whose request was posted on `number` (its target), or that is about the
924
+ * same issue, thread or proposal as `refs`. Open as the cap counts it (not finished, not quiet). An opt-in trigger asks before it requests
925
+ * (server/triggers.js), so a maintainer's act on a target whose run is still going is skipped with `run-open`, whatever the profile.
926
+ * @param {{ number?: number, refs?: { issue?: number, thread?: number, proposal?: string } }} about
927
+ * @returns {Promise<string | null>} the run id
928
+ */
929
+ async function openOn({ number, refs = {} }) {
930
+ const db = await auth.store();
931
+ const mine = subjectKeys(refs);
932
+ const hit = openRows(db, now()).find((r) => (Number.isInteger(number) && (r.target === number || r.issue === number || r.thread === number))
933
+ || subjectKeys({ issue: r.issue ?? undefined, thread: r.thread ?? undefined, proposal: r.proposal ?? undefined }).some((k) => mine.includes(k)));
934
+ return hit ? hit.run_id : null;
935
+ }
936
+
937
+ return { request, stop, list, get, follow, hasWork, resume, queued, claim, runnerGet, report, openOn, maxOpen: cap, profiles, transport };
767
938
  }
768
939
 
769
940
  /** @typedef {ReturnType<typeof createRuns>} Runs */
package/server/auth.js CHANGED
@@ -42,6 +42,7 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, ran
42
42
  import { createForge as defaultCreateForge, isMaintainer, ForgeError } from './forge.js';
43
43
  import { githubApiUrl } from './forge-github.js';
44
44
  import { sameOrigin } from './proposals.js';
45
+ import { revokeOwnerTokens } from './review-tokens.js';
45
46
 
46
47
  /** How often a session's permission is asked of the forge again */
47
48
  export const RECHECK_MS = 15 * 60 * 1000;
@@ -335,6 +336,8 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
335
336
  forgeFor: () => { throw new Error('Sign-in is not configured.'); },
336
337
  forgeForUser: async () => { throw new Error('Sign-in is not configured.'); },
337
338
  record: async () => { throw new Error('Sign-in is not configured.'); },
339
+ knownPermission: async () => null,
340
+ demoted: async () => {},
338
341
  begin: async () => { throw new Error('Sign-in is not configured.'); },
339
342
  finish: async () => { throw new Error('Sign-in is not configured.'); },
340
343
  store: null,
@@ -610,6 +613,19 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
610
613
  return true;
611
614
  };
612
615
  const due = () => expiresAt !== null && Date.parse(expiresAt) - REFRESH_SKEW_MS <= now();
616
+ /** Who the forge says this token is: the login a `permission` question about the owner names */
617
+ let ownLogin = row.login;
618
+ /**
619
+ * What the call answered, looked at once on its way out: when the forge says the owner themself does not maintain the repo, their API
620
+ * tokens (review, agent, runner) are revoked there and then, wherever in cornfield the question was asked
621
+ */
622
+ const noted = async (prop, args, result) => {
623
+ if (prop === 'me' && result && typeof result.login === 'string') ownLogin = result.login;
624
+ if (prop === 'permission' && args[0] === forgeConfig.repo && typeof args[1] === 'string' && args[1].toLowerCase() === ownLogin.toLowerCase() && !isMaintainer(result)) {
625
+ await demoted(row.user_id, ownLogin, result);
626
+ }
627
+ return result;
628
+ };
613
629
  return new Proxy({}, {
614
630
  get(_, prop) {
615
631
  const member = forge[prop];
@@ -625,18 +641,40 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
625
641
  }
626
642
  }
627
643
  try {
628
- return await forge[prop](...args);
644
+ return await noted(prop, args, await forge[prop](...args));
629
645
  } catch (err) {
630
646
  if (!(err instanceof ForgeError) || err.code !== 'unauthorized') throw err;
631
647
  // The forge refused the token: renew it (when it can be) and ask once more; else its refusal stands
632
648
  if (!(await renewNow())) throw err;
633
649
  }
634
- return forge[prop](...args);
650
+ return noted(prop, args, await forge[prop](...args));
635
651
  };
636
652
  },
637
653
  });
638
654
  }
639
655
 
656
+ /**
657
+ * A token owner the forge says no longer maintains the repo: every API token of theirs (review, agent and runner) is revoked at once, and
658
+ * the revoking is a row in the audit table. Never throws: what learned it goes on with its own answer.
659
+ * @param {string} userId
660
+ * @param {string} login
661
+ * @param {string} permission what the forge said
662
+ */
663
+ async function demoted(userId, login, permission) {
664
+ try {
665
+ const n = revokeOwnerTokens(await store(), userId, now());
666
+ if (!n) return;
667
+ try {
668
+ (await store()).prepare('INSERT INTO actions (at, login, action, target, request, status, answer) VALUES (?, ?, ?, ?, ?, ?, ?)')
669
+ .run(iso(now()), login, 'token-revoke', forgeConfig.repo, JSON.stringify({ reason: 'owner-not-maintainer', permission }), null, JSON.stringify({ revoked: n }));
670
+ } catch (err) {
671
+ console.error(`[cornfield] the revoking of ${login}'s tokens could not be written to the audit table: ${err instanceof Error ? err.message : err}`);
672
+ }
673
+ } catch (err) {
674
+ console.error(`[cornfield] the tokens of ${login}, no longer a maintainer, could not be revoked: ${err instanceof Error ? err.message : err}`);
675
+ }
676
+ }
677
+
640
678
  async function callback(req, res, url) {
641
679
  // The state cookie is cleared only by an answer to the sign-in it belongs to: a link from anywhere else
642
680
  // (`/auth/callback?error=x`) cannot cancel one in progress
@@ -668,7 +706,11 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
668
706
  file.prepare('INSERT INTO sessions (id, forge, user_id, login, token, permission, created_at, checked_at, expires_at, refresh_token, token_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
669
707
  .run(id, forgeConfig.type, String(user.id), user.login, keep ? sealFor(id, 'token', token) : NO_TOKEN, permission, iso(at), iso(at), iso(at + sessionMs),
670
708
  keep && given.refreshToken !== '' ? sealFor(id, 'refresh_token', given.refreshToken) : '', keep ? given.expiresAt : null);
671
- if (!keep) await revoke(token);
709
+ if (!keep) {
710
+ await revoke(token);
711
+ // Signed in, and not a maintainer: whatever API tokens they made while they were one stop working now
712
+ await demoted(String(user.id), user.login, permission);
713
+ }
672
714
  redirect(res, '/', [cookie(SESSION_COOKIE, value, { maxAge: sessionMs / 1000, secure }), clearState]);
673
715
  }
674
716
 
@@ -740,6 +782,19 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
740
782
  * decides what happens when this throws (the action it records has usually happened already).
741
783
  * @param {{ login: string, action: string, target: string, request?: object, status?: number | null, answer?: object | null }} entry
742
784
  */
785
+ /**
786
+ * What cornfield last heard of a user's permission on the repo: their newest sign-in's (asked of the forge at sign-in and every 15 minutes
787
+ * while they use cornfield), or null when it has no sign-in of theirs at all. A runner token is not asked of the forge per call; this is its
788
+ * cheap guard (server/runner-api.js).
789
+ * @param {string} userId
790
+ * @returns {Promise<string | null>}
791
+ */
792
+ async knownPermission(userId) {
793
+ const row = /** @type {{ permission: string } | undefined} */ ((await store()).prepare('SELECT permission FROM sessions WHERE user_id = ? ORDER BY checked_at DESC, rowid DESC LIMIT 1').get(String(userId)));
794
+ return row ? row.permission : null;
795
+ },
796
+ /** Revokes a user's API tokens when cornfield has learned they no longer maintain the repo (see `demoted`) */
797
+ demoted,
743
798
  async record({ login, action, target, request = {}, status = null, answer = null }) {
744
799
  (await store()).prepare('INSERT INTO actions (at, login, action, target, request, status, answer) VALUES (?, ?, ?, ?, ?, ?, ?)')
745
800
  .run(iso(now()), login, action, target, JSON.stringify(request), status, answer === null ? null : JSON.stringify(answer));
package/server/db.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * a row with `issue = 0` says the repo's threads have been looked at once
18
18
  * runs the agent runs cornfield asked for: the request, where it was posted, the latest report, the pull request found for
19
19
  * it and when anything last happened (server/agent-runs.js, migration 7); review_tokens also keep a scope
20
+ * (review, agent, or runner: migration 8, which also gives a run its transport and the runner token that claimed it)
20
21
  * run_seen what has been seen of each run (a comment judged, a hook event sent), so a restart sends and judges nothing twice
21
22
  * sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
22
23
  *
@@ -201,6 +202,36 @@ export const MIGRATIONS = Object.freeze([
201
202
  PRIMARY KEY (run_id, key)
202
203
  ) STRICT;
203
204
  `,
205
+ // 8: push and pull transports, and runner tokens (server/agent-runs.js, server/runner-api.js, spec 0005 D2, D5): a run records how its request
206
+ // travels and which runner token claimed it; the token scope check gains 'runner'. SQLite cannot change a CHECK, so review_tokens is rebuilt
207
+ // (the documented way: a new table, the rows copied with their ids, the old one dropped, the new one renamed) and keeps its id sequence
208
+ `
209
+ ALTER TABLE runs ADD COLUMN transport TEXT NOT NULL DEFAULT 'forge' CHECK (transport IN ('forge', 'push', 'pull')); -- how the request reaches the runner; a push or pull run has no request comment (comment_id stays '')
210
+ ALTER TABLE runs ADD COLUMN claimed_by INTEGER; -- the runner token (review_tokens.id) that claimed a pull run, or that first reported on a push run
211
+ ALTER TABLE runs ADD COLUMN claimed_at TEXT;
212
+ CREATE INDEX runs_queue ON runs (repo, transport, state, requested_at);
213
+
214
+ CREATE TABLE review_tokens_new (
215
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
216
+ token_hash TEXT NOT NULL UNIQUE,
217
+ user_id TEXT NOT NULL,
218
+ login TEXT NOT NULL,
219
+ label TEXT NOT NULL,
220
+ created_at TEXT NOT NULL,
221
+ expires_at TEXT NOT NULL,
222
+ revoked_at TEXT,
223
+ last_used TEXT,
224
+ scope TEXT NOT NULL DEFAULT 'review' CHECK (scope IN ('review', 'agent', 'runner')) -- what the token may call: POST /api/reviews, POST /api/agent-requests, or the runner routes
225
+ ) STRICT;
226
+ INSERT INTO review_tokens_new (id, token_hash, user_id, login, label, created_at, expires_at, revoked_at, last_used, scope)
227
+ SELECT id, token_hash, user_id, login, label, created_at, expires_at, revoked_at, last_used, scope FROM review_tokens;
228
+ UPDATE sqlite_sequence SET seq = MAX(seq, COALESCE((SELECT seq FROM sqlite_sequence WHERE name = 'review_tokens'), 0)) WHERE name = 'review_tokens_new';
229
+ INSERT INTO sqlite_sequence (name, seq) SELECT 'review_tokens_new', seq FROM sqlite_sequence WHERE name = 'review_tokens'
230
+ AND NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = 'review_tokens_new');
231
+ DROP TABLE review_tokens;
232
+ ALTER TABLE review_tokens_new RENAME TO review_tokens;
233
+ CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
234
+ `,
204
235
  ]);
205
236
 
206
237
  /** The version a file is at: its `user_version` */
@@ -255,7 +255,7 @@ export async function serveAction(req, res, what, ctx, send) {
255
255
  if (!plan.existing && plan.digest !== digest) throw new ForgeError(`What the build issue for ${slug} would say has changed since it was shown (a new review, or a push); look again.`, { code: 'conflict' });
256
256
  const done = await openBuildIssue(forge, plan);
257
257
  // Only an issue this ask made is news: a second ask that found the first one's says nothing again
258
- if (done.created) ctx.hooks?.emit('build.opened', () => proposalPayload(forge, ctx.where || { proposals: config.proposals }, plan, { actor: session.login }));
258
+ if (done.created) ctx.hooks?.emit('build.opened', async () => ({ ...(await proposalPayload(forge, ctx.where || { proposals: config.proposals }, plan, { actor: session.login })), issue: done.issue.number }));
259
259
  await forgetKept(auth, config);
260
260
  out = { status: done.created ? 201 : 200, body: { ok: true, action, created: done.created, number: n, slug, issue: done.issue, path: plan.path } };
261
261
  } else if (action === 'merge') {