cornfield 0.11.0 → 0.12.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.
@@ -28,6 +28,19 @@
28
28
  * Every attempt past step 1 is a row in `actions` (`agent-request`), refusals included. The audit never holds a brief, a token's secret or a
29
29
  * credential: the request's shape, the brief's length, and the token's id and label.
30
30
  *
31
+ * ## On the map (issue #82)
32
+ *
33
+ * Each run in the API carries `nodes` (the ids of the map's nodes it is changing), `nodesFrom`, `collidesWith` and a `timeline`:
34
+ *
35
+ * - **nodes** come from the files the run has changed once it has a branch or a trusted pull request: the pull request's files, or a
36
+ * comparison of the branch with the base (`forge.compare`), read once for each head sha (`run_reads`) and mapped with `nodesForFile` against the checkout's
37
+ * map each time they are asked for. Before the first push: the request's own `nodes` and the `[[mentions]]` in the opening post of what the run is about
38
+ * - **collidesWith** lists the other OPEN runs that share a node with this one (an open run only: a finished or quiet run marks nothing)
39
+ * - `agents.refuseCollisions` (off unless set) refuses a new request that names a node an open run is already changing (409 `collision`), in the
40
+ * transaction that takes the place for it, right after the cap check. Only the nodes a request names are checked: its `nodes`
41
+ * - the **timeline** is the request, the runner's reports, pushes, the pull request, its checks, how it ended, a stop request, and quiet: the first
42
+ * and last are worked out from the row, the rest are kept as they are seen (`run_events`, one row for each, so a second look adds none)
43
+ *
31
44
  * ## What counts as open
32
45
  *
33
46
  * A run is **open** unless it is finished or quiet (`statusOf`): finished is the runner's report `done`, `failed` or `stopped`, or its pull
@@ -62,10 +75,14 @@
62
75
  * checks: string | null, last_activity: string }} RunRow
63
76
  */
64
77
 
78
+ import fs from 'node:fs';
79
+ import path from 'node:path';
65
80
  import { ForgeError, isMaintainer } from './forge.js';
66
81
  import { createRequest, findRunId, formatRunComment, runAuthors, runBlocksOf, runIdFromBranch, validateReport, validateRequest, MAX_NOTE_CHARS } from '../core/agent-contract.js';
67
82
  import { quietAfterMs } from '../core/config.js';
68
- import { listPullProposals } from './proposal-pulls.js';
83
+ import { nodesForFile, parseMentions, resolveMentions } from '../core/mentions.js';
84
+ import { readTopology } from '../core/validate.js';
85
+ import { findPullProposal, listPullProposals, readPullProposal } from './proposal-pulls.js';
69
86
  import { Refused, countAttempt, throttle } from './throttle.js';
70
87
 
71
88
  /** Requests one person may make in a minute (a ceiling of its own; every other action is counted by `ATTEMPTS_PER_MINUTE` and `PER_MINUTE`) */
@@ -81,6 +98,15 @@ export const FOLLOW_FLOOR_MS = 48 * 3_600_000;
81
98
  export const RESERVATION_MS = 10 * 60_000;
82
99
  /** The runs a listing answers */
83
100
  export const LIST_LIMIT = 200;
101
+ /** The most events a run's timeline holds */
102
+ export const TIMELINE_LIMIT = 100;
103
+ /** How much of a request's brief the API shows (the Runs view groups runs by it) */
104
+ export const BRIEF_SHOWN = 2000;
105
+ /** The most runs whose subject's opening post is read for mentions in one look, and how long a reading is kept */
106
+ export const MAX_MENTION_READS = 10;
107
+ /** The most runs whose changed files are read in one look */
108
+ export const MAX_FILE_READS = 10;
109
+ export const MENTIONS_KEEP_MS = 3_600_000;
84
110
  /** The queued requests a runner's listing answers at most, oldest first */
85
111
  export const QUEUE_LIMIT = 50;
86
112
  /** The runs that are over once the runner has said so */
@@ -147,7 +173,7 @@ const subjectWords = (refs) => [refs.issue != null && `issue #${refs.issue}`, re
147
173
  * @param {number} nowMs
148
174
  * @param {number} quietMs
149
175
  */
150
- export function publicRun(row, nowMs, quietMs) {
176
+ export function publicRun(row, nowMs, quietMs, extra = {}) {
151
177
  const { status, open } = statusOf(row, nowMs, quietMs);
152
178
  let limits = null;
153
179
  try { limits = JSON.parse(row.request).limits ?? null; } catch { limits = null; }
@@ -162,9 +188,48 @@ export function publicRun(row, nowMs, quietMs) {
162
188
  stopRequested: row.stop_requested_at ? { at: row.stop_requested_at, by: row.stop_requested_by } : null,
163
189
  pull: row.pull != null ? { number: row.pull, state: row.pull_state, url: row.pull_url, by: row.pull_by, trusted: row.pull_trusted === 1, via: row.linked_via } : null,
164
190
  branch: row.branch, headSha: row.head_sha, checks: row.checks, lastActivity: row.last_activity,
191
+ ...extra,
165
192
  };
166
193
  }
167
194
 
195
+ /**
196
+ * The run's story in order: the request first, what was seen (`events`, oldest first), and "quiet" last when the run is. Pure.
197
+ * @param {RunRow} row
198
+ * @param {{ at: string, kind: string, actor: string | null, ref: string | null, note: string | null, url: string | null }[]} events
199
+ * @param {{ status: string }} now `statusOf`'s answer
200
+ * @param {number} quietMs
201
+ */
202
+ export function timelineOf(row, events, now, quietMs) {
203
+ const out = [{ at: row.requested_at, kind: 'requested', actor: row.requested_by, ref: row.profile, note: null, url: row.comment_url }];
204
+ out.push(...events);
205
+ if (now.status === 'quiet') out.push({ at: iso(Date.parse(row.last_activity) + quietMs), kind: 'quiet', actor: null, ref: null, note: null, url: null });
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * The ids of the nodes a proposal touches: what it adds (and their parents), changes and removes, and both ends of the edges it adds,
211
+ * changes and removes. Anything that is not a proposal gives none. Pure.
212
+ * @param {any} proposal
213
+ * @returns {string[]}
214
+ */
215
+ export function proposalNodeIds(proposal) {
216
+ const out = new Set();
217
+ const list = (v) => (Array.isArray(v) ? v : []);
218
+ const id = (v) => { if (typeof v === 'string' && v !== '') out.add(v); };
219
+ const blocks = proposal && typeof proposal === 'object' ? proposal : {};
220
+ for (const op of ['add', 'change', 'remove']) {
221
+ for (const n of list(blocks.nodes?.[op])) { id(n?.id); if (op === 'add') id(n?.parent); }
222
+ for (const e of list(blocks.edges?.[op])) { id(e?.from); id(e?.to); }
223
+ }
224
+ return [...out];
225
+ }
226
+
227
+ /** The ids a request names, the ones the map knows when it is known */
228
+ function requestNodes(request, known) {
229
+ const ids = Array.isArray(request?.nodes) ? request.nodes.filter((n) => typeof n === 'string') : [];
230
+ return known ? ids.filter((id) => known.has(id)) : ids;
231
+ }
232
+
168
233
  /**
169
234
  * @typedef {object} RequestContext
170
235
  * @property {() => import('./forge.js').Forge | Promise<import('./forge.js').Forge>} forge the forge as the person (may throw a ForgeError `unauthorized`)
@@ -195,6 +260,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
195
260
  const runnerSet = new Set((Array.isArray(agents.runners) ? agents.runners : []).map(lower));
196
261
  const authorSet = new Set((Array.isArray(agents.authors) ? agents.authors : []).map(lower));
197
262
  const quietMs = quietAfterMs(config);
263
+ const refuseCollisions = agents.refuseCollisions === true;
198
264
  /** How requests travel from now on (`agents.transport`); a run keeps the transport it was asked for with */
199
265
  const transport = /** @type {'forge' | 'push' | 'pull'} */ (agents.transport ?? 'forge');
200
266
  const log = (what) => console.error(`[cornfield] agent runs: ${what}`);
@@ -208,6 +274,87 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
208
274
  }
209
275
  };
210
276
 
277
+ // -------------------------------------------------------------------------
278
+ // Nodes and collisions
279
+ // -------------------------------------------------------------------------
280
+
281
+ /** The checkout's map, read again when the file changes (a sync): `{ nodes, ids }`, empty when there is none */
282
+ let mapCache = { stamp: '', nodes: /** @type {{ id: string, file?: string }[]} */ ([]), ids: /** @type {Set<string>} */ (new Set()) };
283
+ function theMap() {
284
+ let stamp = '';
285
+ try { stamp = String(fs.statSync(path.join(config.root, config.topology)).mtimeMs); } catch { stamp = ''; }
286
+ if (stamp === mapCache.stamp) return mapCache;
287
+ let nodes = [];
288
+ try { nodes = readTopology(config)?.nodes ?? []; } catch { nodes = []; }
289
+ nodes = Array.isArray(nodes) ? nodes.filter((n) => n && typeof n.id === 'string') : [];
290
+ mapCache = { stamp, nodes, ids: new Set(nodes.map((n) => n.id)) };
291
+ return mapCache;
292
+ }
293
+
294
+ const readOf = (db, runId, source) => {
295
+ const r = /** @type {{ sha: string, data: string } | undefined} */ (db.prepare('SELECT sha, data FROM run_reads WHERE run_id = ? AND source = ?').get(runId, source));
296
+ if (!r) return null;
297
+ try { return { sha: r.sha, data: JSON.parse(r.data) }; } catch { return null; }
298
+ };
299
+
300
+ /**
301
+ * The nodes a run is changing: from the files at its head when they were read, else (before the first push, or before the files
302
+ * were read) what the request names and what the opening post of its subject mentions.
303
+ * @returns {{ nodes: string[], from: 'files' | 'request' }}
304
+ */
305
+ function nodesOfRow(db, row) {
306
+ const map = theMap();
307
+ if (row.head_sha) {
308
+ const files = readOf(db, row.run_id, 'files');
309
+ // `null` at this sha: the forge could not list the files (see readNodes), so what the run is about stands in, below
310
+ if (files && files.sha === row.head_sha && Array.isArray(files.data)) {
311
+ const ids = new Set();
312
+ for (const f of files.data) {
313
+ for (const p of [f.path, f.previousPath]) if (typeof p === 'string') for (const n of nodesForFile(p, map.nodes)) ids.add(n.id);
314
+ }
315
+ return { nodes: [...ids], from: 'files' };
316
+ }
317
+ }
318
+ let request = null;
319
+ try { request = JSON.parse(row.request); } catch { request = null; }
320
+ const ids = new Set(requestNodes(request, map.ids.size ? map.ids : null));
321
+ const mentions = readOf(db, row.run_id, 'mentions');
322
+ if (mentions && Array.isArray(mentions.data)) for (const id of resolveMentions(mentions.data, map.nodes).ids) ids.add(id);
323
+ return { nodes: [...ids], from: 'request' };
324
+ }
325
+
326
+ /** What the runs add to their public form: the nodes, the open runs they share a node with, and the timeline */
327
+ function extrasFor(db, rows, at) {
328
+ const open = openRows(db, at);
329
+ const openIds = new Set(open.map((r) => r.run_id));
330
+ const nodesOf = new Map();
331
+ for (const r of new Map([...open, ...rows].map((x) => [x.run_id, x])).values()) nodesOf.set(r.run_id, nodesOfRow(db, r));
332
+ const events = new Map();
333
+ if (rows.length) {
334
+ const ask = db.prepare('SELECT at, kind, actor, ref, note, url FROM run_events WHERE run_id = ? ORDER BY at, rowid LIMIT ?');
335
+ for (const r of rows) events.set(r.run_id, ask.all(r.run_id, TIMELINE_LIMIT));
336
+ }
337
+ return new Map(rows.map((r) => {
338
+ const mine = nodesOf.get(r.run_id);
339
+ const collidesWith = openIds.has(r.run_id)
340
+ ? open.filter((o) => o.run_id !== r.run_id && nodesOf.get(o.run_id).nodes.some((id) => mine.nodes.includes(id))).map((o) => o.run_id)
341
+ : [];
342
+ let brief = '';
343
+ try { brief = String(JSON.parse(r.request).brief ?? '').slice(0, BRIEF_SHOWN); } catch { brief = ''; }
344
+ return [r.run_id, { brief, nodes: mine.nodes, nodesFrom: mine.from, collidesWith, timeline: timelineOf(r, events.get(r.run_id) ?? [], statusOf(r, at, quietMs), quietMs) }];
345
+ }));
346
+ }
347
+
348
+ /** Writes what happened to a run, once: the key says what, so a second look at the same thing adds nothing */
349
+ function keepEvent(db, runId, ev) {
350
+ // A forge's answer may lack a field (a pull request with no `createdAt`): the timeline then says when cornfield saw it, and a value
351
+ // that is not text is left out. One odd answer must never throw out of the look, or every run's state in it would be lost
352
+ const text = (v) => (typeof v === 'string' && v !== '' ? v : v == null ? null : typeof v === 'number' ? String(v) : null);
353
+ const at = typeof ev.at === 'string' && !Number.isNaN(Date.parse(ev.at)) ? ev.at : iso(now());
354
+ db.prepare('INSERT OR IGNORE INTO run_events (run_id, key, at, kind, actor, ref, note, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
355
+ .run(runId, String(ev.key), at, ev.kind, text(ev.actor), text(ev.ref), text(ev.note), text(ev.url));
356
+ }
357
+
211
358
  /**
212
359
  * The runs of this repo that are open as of now (the cap and the one-per-subject rule count these). A reservation whose comment never got
213
360
  * posted (a crash between the reservation and the post) holds its place for `RESERVATION_MS` only, whenever cornfield last restarted.
@@ -218,7 +365,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
218
365
  }
219
366
 
220
367
  /** 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`. */
221
- function reserve(db, request, { target, who, via, force, trigger = false }) {
368
+ function reserve(db, request, { target, who, via, force, trigger = false, derived = null }) {
222
369
  const at = now();
223
370
  const mine = subjectKeys(request.refs);
224
371
  db.exec('BEGIN IMMEDIATE');
@@ -229,6 +376,18 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
229
376
  if (open.length >= cap) {
230
377
  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');
231
378
  }
379
+ if (refuseCollisions) {
380
+ const map = theMap();
381
+ // What the request names; when it names nothing (the dashboard's button), what its subject is about, read before the transaction
382
+ const wanted = [...new Set([...requestNodes(request, map.ids.size ? map.ids : null), ...resolveMentions(derived || [], map.nodes).ids])];
383
+ for (const r of open) {
384
+ const theirs = nodesOfRow(db, r).nodes;
385
+ const shared = wanted.filter((id) => theirs.includes(id));
386
+ if (shared.length) {
387
+ throw new Refused(409, `${shared.length === 1 ? 'The node' : 'The nodes'} ${shared.join(', ')} ${shared.length === 1 ? 'is' : 'are'} already being changed by the open run ${r.run_id} (agents.refuseCollisions is on). Wait for it to finish or go quiet, or stop it, or ask for a request that leaves ${shared.length === 1 ? 'it' : 'them'} out.`, 'collision');
388
+ }
389
+ }
390
+ }
232
391
  if (!force) {
233
392
  const clash = open.find((r) => (r.profile ?? null) === (request.profile ?? null)
234
393
  && subjectKeys({ issue: r.issue ?? undefined, thread: r.thread ?? undefined, proposal: r.proposal ?? undefined }).some((k) => mine.includes(k)));
@@ -322,10 +481,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
322
481
 
323
482
  // Where the request goes: the thread it came from, else the open spec PR of the proposal, else the issue
324
483
  let number;
484
+ let entry = null;
325
485
  if (made.refs.thread != null) number = made.refs.thread;
326
486
  else if (made.refs.proposal != null) {
327
487
  const listed = await listPullProposals(forge, folder, { hooks: null });
328
- const entry = listed.entries.find((e) => e.slug === made.refs.proposal);
488
+ entry = listed.entries.find((e) => e.slug === made.refs.proposal) ?? null;
329
489
  if (!entry) throw new Refused(404, listed.problem ? `The open spec pull requests could not be read: ${listed.problem}` : `No open spec pull request holds the proposal ${made.refs.proposal}, so there is nowhere to post the request.`, 'not-found');
330
490
  number = entry.pull.number;
331
491
  made = build(me.login, number);
@@ -359,14 +519,27 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
359
519
  const why = await ctx.stillAllowed();
360
520
  if (why) throw new Refused(403, why, 'forbidden');
361
521
  }
522
+ // With collisions refused, a request that names no node on the map is checked by what its subject is about (spec 0005 D9), as the
523
+ // map would mark it before its first push: the opening post's mentions and the proposal's nodes. Read now, outside the transaction
524
+ let derived = null;
525
+ if (refuseCollisions && !requestNodes(made, theMap().ids.size ? theMap().ids : null).length) {
526
+ try {
527
+ derived = await subjectRefs(forge, made.refs, entry);
528
+ } catch (err) {
529
+ throw new Refused(503, `What this request is about could not be read, so it cannot be checked for collisions (agents.refuseCollisions is on): ${words(err)}`, 'unavailable');
530
+ }
531
+ }
362
532
  const db = await auth.store();
363
- reserve(db, made, { target: number, who, via, force: force === true, trigger: Boolean(made.trigger) });
533
+ reserve(db, made, { target: number, who, via, force: force === true, trigger: Boolean(made.trigger), derived });
534
+ // What was read is what the map marks for this run until it pushes: kept, so the overlay has it now and the poll need not read it again
535
+ if (derived) db.prepare('INSERT OR REPLACE INTO run_reads (run_id, source, sha, data, at) VALUES (?, ?, ?, ?, ?)').run(made.runId, 'mentions', '', JSON.stringify(derived), iso(now()));
364
536
  let posted = null;
365
537
  if (comment !== null) {
366
538
  try {
367
539
  posted = await forge.comment(number, comment);
368
540
  } catch (err) {
369
541
  db.prepare('DELETE FROM runs WHERE run_id = ?').run(made.runId);
542
+ db.prepare('DELETE FROM run_reads WHERE run_id = ?').run(made.runId);
370
543
  throw err;
371
544
  }
372
545
  db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
@@ -376,7 +549,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
376
549
  hooks?.emit('agent.requested', { request: made, actor: me.login });
377
550
  try { onAsk?.(); } catch { /* the poll is a convenience */ }
378
551
  const row = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(made.runId));
379
- return { run: publicRun(row, now(), quietMs), request: made, comment: posted ? { id: posted.id, url: posted.url ?? null, number } : null };
552
+ return { run: publicRun(row, now(), quietMs, extrasFor(db, [row], now()).get(row.run_id)), request: made, comment: posted ? { id: posted.id, url: posted.url ?? null, number } : null };
380
553
  } catch (err) {
381
554
  if (status === null) {
382
555
  const out = describe(err);
@@ -447,6 +620,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
447
620
  db.exec('BEGIN IMMEDIATE');
448
621
  try {
449
622
  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);
623
+ keepEvent(db, row.run_id, { key: posted ? `stop:${posted.id}` : `stop:cornfield:${stamp}`, at: stamp, kind: 'stop-requested', actor: me.login, note: note || null, url: posted ? posted.url ?? null : null });
450
624
  if (posted) {
451
625
  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
626
  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);
@@ -468,7 +642,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
468
642
  hooks?.emit('run.state', { ...runPayload(after, me.login), state: 'stopped' });
469
643
  hooks?.emit('run.finished', { ...runPayload(after, me.login), state: 'stopped' });
470
644
  }
471
- return { run: publicRun(after, now(), quietMs), comment: posted ? { id: posted.id, url: posted.url ?? null } : null };
645
+ return { run: publicRun(after, now(), quietMs, extrasFor(db, [after], now()).get(after.run_id)), comment: posted ? { id: posted.id, url: posted.url ?? null } : null };
472
646
  } catch (err) {
473
647
  if (!done) {
474
648
  const out = describe(err);
@@ -497,6 +671,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
497
671
  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
672
  // 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
673
  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)));
674
+ const extras = extrasFor(db, rows, at);
500
675
  const reasons = {};
501
676
  for (const r of skipped) {
502
677
  let why = 'error';
@@ -504,8 +679,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
504
679
  reasons[why] = (reasons[why] || 0) + 1;
505
680
  }
506
681
  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,
682
+ runs: rows.map((r) => publicRun(r, at, quietMs, extras.get(r.run_id))), open: openRows(db, at).length, maxOpen: cap, profiles, limits: agents.limits || {}, quietAfter: agents.quietAfter, runners: [...runnerSet].length,
508
683
  triggerSkipped: skipped.length ? { count: skipped.length, last: skipped[0].at, reasons } : null,
684
+ refuseCollisions,
509
685
  };
510
686
  }
511
687
 
@@ -513,7 +689,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
513
689
  async function get(runId) {
514
690
  const db = await auth.store();
515
691
  const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare(`SELECT * FROM runs WHERE run_id = ? AND repo = ? AND ${POSTED}`).get(runId, repo)) : undefined;
516
- return row ? publicRun(row, now(), quietMs) : null;
692
+ return row ? publicRun(row, now(), quietMs, extrasFor(db, [row], now()).get(row.run_id)) : null;
517
693
  }
518
694
 
519
695
  /** The runs a follow looks at: posted, not over for good (a trusted pull request merged or closed), and active within the follow window */
@@ -545,6 +721,92 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
545
721
  return following;
546
722
  }
547
723
 
724
+ /**
725
+ * What a run is about, as references the map resolves (spec 0005 D9: "before the first push, the nodes come from what the request was
726
+ * about: the proposal, or the thread's mentions"): the `[[mentions]]` in the opening post of its thread, issue or spec PR, and for a
727
+ * proposal the ids of every node it touches (what it adds, changes and removes, the parents of what it adds, and the ends of the edges it
728
+ * adds, changes and removes). Asks the forge; throws what it throws. `entry`: the spec PR's listing when the caller has it already.
729
+ * @returns {Promise<string[]>}
730
+ */
731
+ async function subjectRefs(forge, refs, entry = null) {
732
+ const out = [];
733
+ const add = (r) => { if (typeof r === 'string' && r !== '' && !out.includes(r)) out.push(r); };
734
+ if (refs?.proposal != null && refs.pull != null) {
735
+ const pull = await forge.pull(refs.pull);
736
+ parseMentions(pull.body ?? '').refs.forEach(add);
737
+ const found = entry ?? await findPullProposal(forge, folder, refs.proposal, refs.pull);
738
+ if (found) {
739
+ let proposal = null;
740
+ try { proposal = JSON.parse(await readPullProposal(forge, folder, found)); } catch (err) { if (!(err instanceof SyntaxError)) throw err; }
741
+ for (const id of proposalNodeIds(proposal)) add(id);
742
+ }
743
+ } else if (refs?.thread != null || refs?.issue != null) {
744
+ const n = refs.thread ?? refs.issue;
745
+ let item;
746
+ try {
747
+ item = await forge.issue(n);
748
+ } catch (err) {
749
+ // A thread that is a pull request is not an issue to the forge: ask for it as one
750
+ if (!(err instanceof ForgeError && err.code === 'not-found')) throw err;
751
+ item = await forge.pull(n);
752
+ }
753
+ parseMentions(item.body ?? '').refs.forEach(add);
754
+ }
755
+ return out;
756
+ }
757
+
758
+ /**
759
+ * What the map needs from the forge, kept in `run_reads` so it is asked once: for a run with a head (a trusted pull request, or a branch)
760
+ * the files it changes, read again only when the head moved; for a run with no head yet, the `[[mentions]]` in the opening post of what it is about.
761
+ * A failure is logged and tried again at the next look. At most `MAX_FILE_READS` and `MAX_MENTION_READS` a look.
762
+ */
763
+ async function readNodes(forge, db, rows, at) {
764
+ const save = db.prepare('INSERT OR REPLACE INTO run_reads (run_id, source, sha, data, at) VALUES (?, ?, ?, ?, ?)');
765
+ let files = 0;
766
+ let mentions = 0;
767
+ let base = null;
768
+ for (const row of rows) {
769
+ if (!statusOf(row, at, quietMs).open) continue;
770
+ if (row.head_sha) {
771
+ const had = readOf(db, row.run_id, 'files');
772
+ // Files listed at this head: they say the nodes. Listed as unknowable (`null`): what the run is about stands in, read below
773
+ if (had && had.sha === row.head_sha && Array.isArray(had.data)) continue;
774
+ if (!(had && had.sha === row.head_sha) && files < MAX_FILE_READS) {
775
+ files += 1;
776
+ try {
777
+ let list;
778
+ if (row.pull != null && row.pull_trusted === 1) list = await forge.pullFiles(row.pull);
779
+ else {
780
+ base ??= config.forge.baseBranch || await forge.defaultBranch();
781
+ list = await forge.compare(base, row.head_sha);
782
+ }
783
+ save.run(row.run_id, 'files', row.head_sha, JSON.stringify(list.map((f) => ({ path: f.path, previousPath: f.previousPath ?? null, status: f.status }))), iso(at));
784
+ } catch (err) {
785
+ // A forge that cannot list a comparison's files (an older Forgejo) will not at this head either: say so once, keep `null` for this
786
+ // sha so it is not asked again until the next push, and the run keeps the nodes of what it is about. Anything else is tried again.
787
+ if (err instanceof ForgeError && err.code === 'invalid') save.run(row.run_id, 'files', row.head_sha, 'null', iso(at));
788
+ log(`the files of ${row.run_id} could not be read: ${words(err)}`);
789
+ }
790
+ }
791
+ const read = readOf(db, row.run_id, 'files');
792
+ if (!(read && read.sha === row.head_sha && read.data === null)) continue; // only an unknowable list falls through to the subject
793
+ }
794
+ const had = readOf(db, row.run_id, 'mentions');
795
+ const stamp = /** @type {{ at: string } | undefined} */ (db.prepare("SELECT at FROM run_reads WHERE run_id = ? AND source = 'mentions'").get(row.run_id));
796
+ if (had && stamp && at - Date.parse(stamp.at) < MENTIONS_KEEP_MS) continue;
797
+ if (mentions >= MAX_MENTION_READS) continue;
798
+ let refs = null;
799
+ try { refs = JSON.parse(row.request).refs; } catch { refs = null; }
800
+ if (!(refs?.proposal != null && refs.pull != null) && refs?.thread == null && refs?.issue == null) continue;
801
+ mentions += 1;
802
+ try {
803
+ save.run(row.run_id, 'mentions', '', JSON.stringify(await subjectRefs(forge, refs)), iso(at));
804
+ } catch (err) {
805
+ log(`what ${row.run_id} is about could not be read for its nodes: ${words(err)}`);
806
+ }
807
+ }
808
+ }
809
+
548
810
  async function followRuns(forge) {
549
811
  const db = await auth.store();
550
812
  const at = now();
@@ -559,6 +821,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
559
821
  const events = [];
560
822
  /** @type {Map<string, string>} the cause of the latest change of each run, for the key of its finished event */
561
823
  const cause = new Map();
824
+ /** @type {{ runId: string, key: string, at: string, kind: string, actor?: string | null, ref?: string | null, note?: string | null, url?: string | null }[]} what happened, for the timelines */
825
+ const happened = [];
826
+ const happen = (row, ev) => { happened.push({ runId: row.run_id, ...ev }); };
562
827
  /** @type {{ login: string | null, request: object, answer: object, target: number }[]} audit rows to write after */
563
828
  const audits = [];
564
829
 
@@ -631,6 +896,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
631
896
  const firstLink = row.pull == null;
632
897
  const state = pr.state === 'merged' ? 'merged' : pr.state === 'closed' ? 'closed' : 'open';
633
898
  const earlier = row.pull_state;
899
+ const pushedBefore = row.head_sha;
634
900
  let trusted = row.pull_trusted === 1;
635
901
  if (firstLink) {
636
902
  try { trusted = await trustedAuthor(pr.author?.login ?? null); } catch (err) { log(`who opened #${pr.number} could not be asked: ${words(err)}`); continue; }
@@ -639,6 +905,8 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
639
905
  // A stranger's branch is not the run's: only a trusted pull request says where the run pushes
640
906
  if (trusted) Object.assign(row, { branch: pr.head.ref || null, head_sha: pr.head.sha || null });
641
907
  dirty.add(row.run_id);
908
+ if (trusted && firstLink) happen(row, { key: `pull:${pr.number}:opened`, at: pr.createdAt, kind: 'pull-opened', actor: pr.author?.login ?? null, ref: `#${pr.number}`, url: pr.url ?? null });
909
+ if (trusted && pr.head.sha && pr.head.sha !== pushedBefore) happen(row, { key: `push:${pr.head.sha}`, at: iso(at), kind: 'push', actor: pr.author?.login ?? null, ref: pr.head.sha, note: pr.head.ref || null });
642
910
  if (trusted) touch(row, pr.updatedAt);
643
911
  if (firstLink) {
644
912
  out.linked += 1;
@@ -647,6 +915,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
647
915
  if (trusted) queue(row, 'run.state', `ev:linked:${pr.number}`, pr.author?.login ?? null, 'working');
648
916
  }
649
917
  if (trusted && state !== 'open' && earlier !== state) {
918
+ happen(row, { key: `pull:${pr.number}:${state}`, at: (state === 'merged' && pr.mergedAt) || pr.updatedAt || iso(at), kind: state, actor: pr.author?.login ?? null, ref: `#${pr.number}`, url: pr.url ?? null });
650
919
  cause.set(row.run_id, `pull:${pr.number}:${state}`);
651
920
  queue(row, 'run.state', `ev:pull:${pr.number}:${state}`, pr.author?.login ?? null, state);
652
921
  }
@@ -730,11 +999,13 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
730
999
  touch(row, when);
731
1000
  out.reports += 1;
732
1001
  cause.set(row.run_id, key);
1002
+ happen(row, { key: `report:${key}`, at: when, kind: r.state === 'claimed' ? 'claimed' : 'report', actor: login, ref: r.state, note: r.note ?? null, url: r.url ?? null });
733
1003
  if (r.state === 'claimed') queue(row, 'run.claimed', 'ev:claimed', login, 'claimed');
734
1004
  if (r.state !== previous) queue(row, 'run.state', `ev:state:${id}:${key}`, login, r.state);
735
1005
  } else {
736
1006
  if (!row.stop_requested_at) Object.assign(row, { stop_requested_at: when, stop_requested_by: login });
737
1007
  touch(row, when);
1008
+ happen(row, { key: `stop:${id}`, at: when, kind: 'stop-requested', actor: login, note: block.value.note ?? null });
738
1009
  queue(row, 'run.stopRequested', `ev:stop:${id}:${key}`, login, 'stop-requested');
739
1010
  }
740
1011
  }
@@ -745,7 +1016,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
745
1016
  try {
746
1017
  const checks = await forge.checks(row.head_sha);
747
1018
  const summary = !checks.length ? 'none' : checks.some((c) => c.state === 'failure') ? 'failure' : checks.some((c) => c.state === 'queued' || c.state === 'running') ? 'running' : checks.some((c) => c.state === 'success') ? 'success' : 'none';
748
- if (summary !== row.checks) { row.checks = summary; dirty.add(row.run_id); }
1019
+ if (summary !== row.checks) {
1020
+ row.checks = summary;
1021
+ dirty.add(row.run_id);
1022
+ happen(row, { key: `checks:${row.head_sha}:${summary}`, at: iso(at), kind: 'checks', ref: summary });
1023
+ }
749
1024
  } catch (err) {
750
1025
  log(`the checks of #${row.pull} could not be read: ${words(err)}`);
751
1026
  }
@@ -760,6 +1035,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
760
1035
  if (b.name !== row.branch || b.sha !== row.head_sha) {
761
1036
  Object.assign(row, { branch: b.name, head_sha: b.sha });
762
1037
  touch(row, iso(at)); // a push: the forge does not say when, so now
1038
+ happen(row, { key: `push:${b.sha}`, at: iso(at), kind: 'push', ref: b.sha, note: b.name });
763
1039
  }
764
1040
  }
765
1041
  } catch (err) {
@@ -767,6 +1043,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
767
1043
  }
768
1044
  }
769
1045
 
1046
+ // ---- the files each run has changed (once for each head sha), and what its subject mentions before its first push -----------------
1047
+ await readNodes(forge, db, [...cur.values()], at);
1048
+
770
1049
  // ---- kept first, then announced ------------------------------------------------------------------------------------------------
771
1050
  for (const row of cur.values()) {
772
1051
  const now2 = statusOf(row, at, quietMs);
@@ -791,6 +1070,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
791
1070
  r.pull, r.pull_state, r.pull_url, r.pull_by, r.pull_trusted, r.linked_via, r.branch, r.head_sha, r.checks, r.last_activity, id);
792
1071
  }
793
1072
  for (const e of events) if (mark.run(e.runId, e.key, iso(at)).changes === 1) fresh.push(e);
1073
+ for (const ev of happened) keepEvent(db, ev.runId, ev);
794
1074
  db.exec('COMMIT');
795
1075
  } catch (err) {
796
1076
  try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
package/server/db.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * it and when anything last happened (server/agent-runs.js, migration 7); review_tokens also keep a scope
20
20
  * (review, agent, or runner: migration 8, which also gives a run its transport and the runner token that claimed it)
21
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
22
+ * run_events what happened to each run, in order, for its timeline; run_reads what was read to work out its nodes (migration 9, issue #82)
22
23
  * sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
23
24
  *
24
25
  * The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
@@ -232,6 +233,31 @@ export const MIGRATIONS = Object.freeze([
232
233
  ALTER TABLE review_tokens_new RENAME TO review_tokens;
233
234
  CREATE INDEX review_tokens_user_id ON review_tokens (user_id);
234
235
  `,
236
+ // 9: runs on the map (server/agent-runs.js, issue #82): what happened to each run in order (the timeline), and what was read to
237
+ // work out the nodes it changes, kept by head sha so the forge is asked once per push
238
+ `
239
+ CREATE TABLE run_events (
240
+ run_id TEXT NOT NULL,
241
+ key TEXT NOT NULL, -- what happened, once: 'report:<comment>:<n>', 'push:<sha>', 'pull:<n>:opened', 'checks:<sha>:<summary>' ...
242
+ at TEXT NOT NULL, -- when it happened (the forge's time where it says, else when cornfield saw it)
243
+ kind TEXT NOT NULL CHECK (kind IN ('claimed', 'report', 'stop-requested', 'push', 'pull-opened', 'checks', 'merged', 'closed')),
244
+ actor TEXT,
245
+ ref TEXT, -- the report's state, the sha pushed, the pull request number, the checks summary
246
+ note TEXT, -- the runner's words, as written: the page escapes them
247
+ url TEXT,
248
+ PRIMARY KEY (run_id, key)
249
+ ) STRICT;
250
+ CREATE INDEX run_events_at ON run_events (run_id, at);
251
+
252
+ CREATE TABLE run_reads (
253
+ run_id TEXT NOT NULL,
254
+ source TEXT NOT NULL CHECK (source IN ('files', 'mentions')), -- the files a branch changes at sha, or the [[mentions]] of what the run is about
255
+ sha TEXT NOT NULL DEFAULT '',
256
+ data TEXT NOT NULL CHECK (json_valid(data)),
257
+ at TEXT NOT NULL,
258
+ PRIMARY KEY (run_id, source)
259
+ ) STRICT;
260
+ `,
235
261
  ]);
236
262
 
237
263
  /** The version a file is at: its `user_version` */
@@ -240,6 +240,13 @@ export function createForgejoForge(forge, { token = null, fetch, apiUrl, timeout
240
240
  return (await client.list(`repos/${repo}/pulls/${n}/files?limit=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
241
241
  },
242
242
 
243
+ async compare(base, head) {
244
+ const { body } = await client.request('GET', `repos/${repo}/compare/${segments(base)}...${segments(head)}`, { what: `the files of ${head} against ${base} in ${forge.repo}` });
245
+ // Forgejo's comparison lists commits; only a version that also lists files can say which nodes a branch touches
246
+ if (!Array.isArray(body?.files)) throw new ForgeError('This Forgejo does not list the files of a comparison.', { code: 'invalid', forge: NAME });
247
+ return body.files.map(toFile);
248
+ },
249
+
243
250
  async pullCommits(n) {
244
251
  return (await client.list(`repos/${repo}/pulls/${n}/commits?limit=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
245
252
  },
@@ -233,6 +233,11 @@ export function createGitHubForge(forge, { token = null, fetch, apiUrl, timeoutM
233
233
  return (await client.list(`repos/${repo}/pulls/${n}/files?per_page=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
234
234
  },
235
235
 
236
+ async compare(base, head) {
237
+ const { body } = await client.request('GET', `repos/${repo}/compare/${segments(base)}...${segments(head)}`, { what: `the files of ${head} against ${base} in ${forge.repo}` });
238
+ return (Array.isArray(body?.files) ? body.files : []).map(toFile);
239
+ },
240
+
236
241
  async pullCommits(n) {
237
242
  return (await client.list(`repos/${repo}/pulls/${n}/commits?per_page=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
238
243
  },
package/server/forge.js CHANGED
@@ -86,6 +86,7 @@
86
86
  * createIssue(issue: { title: string, body: string, labels?: string[] }): Promise<Issue>,
87
87
  * pullFiles(n: number): Promise<PullFile[]>,
88
88
  * pullCommits(n: number): Promise<PullCommit[]>,
89
+ * compare(base: string, head: string): Promise<PullFile[]>, // the files a branch changes against another (optional: a forge that cannot list them throws `invalid`)
89
90
  * comments(n: number, opts?: { since?: string }): Promise<Comment[]>,
90
91
  * comment(n: number, body: string): Promise<Comment>,
91
92
  * checks(ref: string): Promise<Check[]>,
@@ -280,6 +281,10 @@ export function wrap(a, forge) {
280
281
  },
281
282
  pullFiles: async (n) => a.pullFiles(number(n)),
282
283
  pullCommits: async (n) => a.pullCommits(number(n)),
284
+ compare: async (base, head) => {
285
+ if (typeof a.compare !== 'function') throw invalid('This forge does not list the files of a comparison.');
286
+ return a.compare(text(base, 'The base branch'), text(head, 'The branch'));
287
+ },
283
288
  comments: async (n, opts) => {
284
289
  if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) throw invalid('The options are an object.');
285
290
  const since = opts && opts.since;
package/server/server.js CHANGED
@@ -123,6 +123,8 @@ export function clientConfig(config, signIn = false) {
123
123
  // Threads (docs/threads.md): the idea label, or false when `threads: false` turns the Threads tab off
124
124
  threads: config.threads === false ? false : { label: ideaLabel(config) },
125
125
  sources: browserSources(config),
126
+ // Agent runs are followed (and marked on the map) where there is a forge and a sign-in (docs/agents.md)
127
+ ...(config.forge && signIn ? { agents: { runs: true } } : {}),
126
128
  signIn,
127
129
  };
128
130
  }