@popcorn245/cornfield 0.10.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`)
@@ -172,6 +237,8 @@ export function publicRun(row, nowMs, quietMs) {
172
237
  * @property {{ auth: object, now?: () => number }} limits what server/throttle.js counts by
173
238
  * @property {{ token: { id: number, label: string } } | null} [via] set when the request came through the API: the token it came with
174
239
  * @property {() => string | null | Promise<string | null>} [stillAllowed] asked once more just before the cap is taken: null to go on, or why not
240
+ * @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
241
+ * 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
175
242
  */
176
243
 
177
244
  /**
@@ -193,6 +260,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
193
260
  const runnerSet = new Set((Array.isArray(agents.runners) ? agents.runners : []).map(lower));
194
261
  const authorSet = new Set((Array.isArray(agents.authors) ? agents.authors : []).map(lower));
195
262
  const quietMs = quietAfterMs(config);
263
+ const refuseCollisions = agents.refuseCollisions === true;
196
264
  /** How requests travel from now on (`agents.transport`); a run keeps the transport it was asked for with */
197
265
  const transport = /** @type {'forge' | 'push' | 'pull'} */ (agents.transport ?? 'forge');
198
266
  const log = (what) => console.error(`[cornfield] agent runs: ${what}`);
@@ -206,6 +274,87 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
206
274
  }
207
275
  };
208
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
+
209
358
  /**
210
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
211
360
  * posted (a crash between the reservation and the post) holds its place for `RESERVATION_MS` only, whenever cornfield last restarted.
@@ -216,7 +365,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
216
365
  }
217
366
 
218
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`. */
219
- function reserve(db, request, { target, who, via, force }) {
368
+ function reserve(db, request, { target, who, via, force, trigger = false, derived = null }) {
220
369
  const at = now();
221
370
  const mine = subjectKeys(request.refs);
222
371
  db.exec('BEGIN IMMEDIATE');
@@ -227,6 +376,18 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
227
376
  if (open.length >= cap) {
228
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');
229
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
+ }
230
391
  if (!force) {
231
392
  const clash = open.find((r) => (r.profile ?? null) === (request.profile ?? null)
232
393
  && subjectKeys({ issue: r.issue ?? undefined, thread: r.thread ?? undefined, proposal: r.proposal ?? undefined }).some((k) => mine.includes(k)));
@@ -237,7 +398,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
237
398
  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)
238
399
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
239
400
  .run(request.runId, repo, request.kind, request.profile ?? null, request.refs.issue ?? null, request.refs.proposal ?? null, request.refs.thread ?? null,
240
- target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : 'dashboard', iso(at), transport);
401
+ target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : trigger ? 'trigger' : 'dashboard', iso(at), transport);
241
402
  db.exec('COMMIT');
242
403
  } catch (err) {
243
404
  try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
@@ -288,7 +449,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
288
449
  try {
289
450
  if (problem) throw new Refused(problem.status, problem.message, 'invalid');
290
451
  if (!isObject(body)) throw new Refused(400, `A request is a JSON object: { kind, refs, profile?, brief?, branch?, limits?, nodes?, force? }.`, 'invalid');
291
- const { force, requestedBy, ...fields } = body;
452
+ const { force, requestedBy, trigger: forged, ...fields } = body;
453
+ // What caused a request is cornfield's to write, from a trigger's own call; a caller cannot say it was a trigger
454
+ if (forged !== undefined) throw new Refused(400, '"trigger" is written by cornfield for an automatic request; leave it out.', 'invalid');
292
455
  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');
293
456
  const build = (login, pull) => {
294
457
  // The config's limits are hints the request starts from; what the person sent wins key by key
@@ -298,10 +461,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
298
461
  else if (isObject(limits)) limits = { ...base, ...limits };
299
462
  // The brief is required by the contract and may be empty; the spec PR that holds a proposal is named beside it
300
463
  const refs = pull != null && isObject(fields.refs) ? { ...fields.refs, pull } : fields.refs;
301
- const made = createRequest({ brief: '', ...fields, refs, ...(limits !== undefined ? { limits } : {}), requestedBy: login }, { profiles, now: at });
464
+ 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 });
302
465
  if (!made.ok) throw new Refused(400, made.error, 'invalid');
303
466
  return /** @type {RunRequest} */ (made.value);
304
467
  };
468
+ if (ctx.trigger) Object.assign(asked, { trigger: { event: ctx.trigger.event, ref: ctx.trigger.ref } });
305
469
  let made = build(who.login);
306
470
  runId = made.runId;
307
471
  Object.assign(asked, { runId });
@@ -317,10 +481,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
317
481
 
318
482
  // Where the request goes: the thread it came from, else the open spec PR of the proposal, else the issue
319
483
  let number;
484
+ let entry = null;
320
485
  if (made.refs.thread != null) number = made.refs.thread;
321
486
  else if (made.refs.proposal != null) {
322
487
  const listed = await listPullProposals(forge, folder, { hooks: null });
323
- const entry = listed.entries.find((e) => e.slug === made.refs.proposal);
488
+ entry = listed.entries.find((e) => e.slug === made.refs.proposal) ?? null;
324
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');
325
490
  number = entry.pull.number;
326
491
  made = build(me.login, number);
@@ -343,6 +508,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
343
508
  if (via) {
344
509
  const [head, ...rest] = comment.split('\n\n');
345
510
  comment = [`${head} Sent through the agent API with the token \`${via.token.label}\`.`, ...rest].join('\n\n');
511
+ } else if (made.trigger) {
512
+ const [head, ...rest] = comment.split('\n\n');
513
+ 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');
346
514
  }
347
515
  }
348
516
 
@@ -351,14 +519,27 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
351
519
  const why = await ctx.stillAllowed();
352
520
  if (why) throw new Refused(403, why, 'forbidden');
353
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
+ }
354
532
  const db = await auth.store();
355
- reserve(db, made, { target: number, who, via, force: force === true });
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()));
356
536
  let posted = null;
357
537
  if (comment !== null) {
358
538
  try {
359
539
  posted = await forge.comment(number, comment);
360
540
  } catch (err) {
361
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);
362
543
  throw err;
363
544
  }
364
545
  db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
@@ -368,7 +549,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
368
549
  hooks?.emit('agent.requested', { request: made, actor: me.login });
369
550
  try { onAsk?.(); } catch { /* the poll is a convenience */ }
370
551
  const row = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(made.runId));
371
- 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 };
372
553
  } catch (err) {
373
554
  if (status === null) {
374
555
  const out = describe(err);
@@ -439,6 +620,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
439
620
  db.exec('BEGIN IMMEDIATE');
440
621
  try {
441
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 });
442
624
  if (posted) {
443
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);
444
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);
@@ -460,7 +642,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
460
642
  hooks?.emit('run.state', { ...runPayload(after, me.login), state: 'stopped' });
461
643
  hooks?.emit('run.finished', { ...runPayload(after, me.login), state: 'stopped' });
462
644
  }
463
- 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 };
464
646
  } catch (err) {
465
647
  if (!done) {
466
648
  const out = describe(err);
@@ -487,14 +669,27 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
487
669
  const db = await auth.store();
488
670
  const at = now();
489
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));
490
- 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 };
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
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);
675
+ const reasons = {};
676
+ for (const r of skipped) {
677
+ let why = 'error';
678
+ try { why = JSON.parse(r.answer ?? '{}').reason || why; } catch { /* a row that is not JSON is counted as an error */ }
679
+ reasons[why] = (reasons[why] || 0) + 1;
680
+ }
681
+ return {
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,
683
+ triggerSkipped: skipped.length ? { count: skipped.length, last: skipped[0].at, reasons } : null,
684
+ refuseCollisions,
685
+ };
491
686
  }
492
687
 
493
688
  /** One run, or null */
494
689
  async function get(runId) {
495
690
  const db = await auth.store();
496
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;
497
- return row ? publicRun(row, now(), quietMs) : null;
692
+ return row ? publicRun(row, now(), quietMs, extrasFor(db, [row], now()).get(row.run_id)) : null;
498
693
  }
499
694
 
500
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 */
@@ -526,6 +721,92 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
526
721
  return following;
527
722
  }
528
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
+
529
810
  async function followRuns(forge) {
530
811
  const db = await auth.store();
531
812
  const at = now();
@@ -540,6 +821,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
540
821
  const events = [];
541
822
  /** @type {Map<string, string>} the cause of the latest change of each run, for the key of its finished event */
542
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 }); };
543
827
  /** @type {{ login: string | null, request: object, answer: object, target: number }[]} audit rows to write after */
544
828
  const audits = [];
545
829
 
@@ -612,6 +896,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
612
896
  const firstLink = row.pull == null;
613
897
  const state = pr.state === 'merged' ? 'merged' : pr.state === 'closed' ? 'closed' : 'open';
614
898
  const earlier = row.pull_state;
899
+ const pushedBefore = row.head_sha;
615
900
  let trusted = row.pull_trusted === 1;
616
901
  if (firstLink) {
617
902
  try { trusted = await trustedAuthor(pr.author?.login ?? null); } catch (err) { log(`who opened #${pr.number} could not be asked: ${words(err)}`); continue; }
@@ -620,6 +905,8 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
620
905
  // A stranger's branch is not the run's: only a trusted pull request says where the run pushes
621
906
  if (trusted) Object.assign(row, { branch: pr.head.ref || null, head_sha: pr.head.sha || null });
622
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 });
623
910
  if (trusted) touch(row, pr.updatedAt);
624
911
  if (firstLink) {
625
912
  out.linked += 1;
@@ -628,6 +915,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
628
915
  if (trusted) queue(row, 'run.state', `ev:linked:${pr.number}`, pr.author?.login ?? null, 'working');
629
916
  }
630
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 });
631
919
  cause.set(row.run_id, `pull:${pr.number}:${state}`);
632
920
  queue(row, 'run.state', `ev:pull:${pr.number}:${state}`, pr.author?.login ?? null, state);
633
921
  }
@@ -711,11 +999,13 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
711
999
  touch(row, when);
712
1000
  out.reports += 1;
713
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 });
714
1003
  if (r.state === 'claimed') queue(row, 'run.claimed', 'ev:claimed', login, 'claimed');
715
1004
  if (r.state !== previous) queue(row, 'run.state', `ev:state:${id}:${key}`, login, r.state);
716
1005
  } else {
717
1006
  if (!row.stop_requested_at) Object.assign(row, { stop_requested_at: when, stop_requested_by: login });
718
1007
  touch(row, when);
1008
+ happen(row, { key: `stop:${id}`, at: when, kind: 'stop-requested', actor: login, note: block.value.note ?? null });
719
1009
  queue(row, 'run.stopRequested', `ev:stop:${id}:${key}`, login, 'stop-requested');
720
1010
  }
721
1011
  }
@@ -726,7 +1016,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
726
1016
  try {
727
1017
  const checks = await forge.checks(row.head_sha);
728
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';
729
- 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
+ }
730
1024
  } catch (err) {
731
1025
  log(`the checks of #${row.pull} could not be read: ${words(err)}`);
732
1026
  }
@@ -741,6 +1035,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
741
1035
  if (b.name !== row.branch || b.sha !== row.head_sha) {
742
1036
  Object.assign(row, { branch: b.name, head_sha: b.sha });
743
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 });
744
1039
  }
745
1040
  }
746
1041
  } catch (err) {
@@ -748,6 +1043,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
748
1043
  }
749
1044
  }
750
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
+
751
1049
  // ---- kept first, then announced ------------------------------------------------------------------------------------------------
752
1050
  for (const row of cur.values()) {
753
1051
  const now2 = statusOf(row, at, quietMs);
@@ -772,6 +1070,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
772
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);
773
1071
  }
774
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);
775
1074
  db.exec('COMMIT');
776
1075
  } catch (err) {
777
1076
  try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
@@ -900,7 +1199,22 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
900
1199
  if (gone) log(`${gone} request${gone === 1 ? '' : 's'} that never reached the forge ${gone === 1 ? 'was' : 'were'} dropped`);
901
1200
  }
902
1201
 
903
- return { request, stop, list, get, follow, hasWork, resume, queued, claim, runnerGet, report, maxOpen: cap, profiles, transport };
1202
+ /**
1203
+ * 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
1204
+ * 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
1205
+ * (server/triggers.js), so a maintainer's act on a target whose run is still going is skipped with `run-open`, whatever the profile.
1206
+ * @param {{ number?: number, refs?: { issue?: number, thread?: number, proposal?: string } }} about
1207
+ * @returns {Promise<string | null>} the run id
1208
+ */
1209
+ async function openOn({ number, refs = {} }) {
1210
+ const db = await auth.store();
1211
+ const mine = subjectKeys(refs);
1212
+ const hit = openRows(db, now()).find((r) => (Number.isInteger(number) && (r.target === number || r.issue === number || r.thread === number))
1213
+ || subjectKeys({ issue: r.issue ?? undefined, thread: r.thread ?? undefined, proposal: r.proposal ?? undefined }).some((k) => mine.includes(k)));
1214
+ return hit ? hit.run_id : null;
1215
+ }
1216
+
1217
+ return { request, stop, list, get, follow, hasWork, resume, queued, claim, runnerGet, report, openOn, maxOpen: cap, profiles, transport };
904
1218
  }
905
1219
 
906
1220
  /** @typedef {ReturnType<typeof createRuns>} Runs */
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` */
@@ -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') {
@@ -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
  },