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.
- package/core/agent-contract.js +19 -2
- package/core/agent-contract.schema.json +31 -0
- package/core/config.js +34 -5
- package/explorer/explorer.css +49 -4
- package/explorer/forge-panel.js +122 -20
- package/explorer/index.html +10 -0
- package/explorer/layers.js +2 -0
- package/explorer/render.js +8 -0
- package/explorer/runs-map.js +135 -0
- package/explorer/runs-model.js +164 -0
- package/explorer/runs-view.js +101 -0
- package/explorer/state.js +6 -0
- package/package.json +1 -1
- package/server/agent-runs.js +328 -14
- package/server/db.js +26 -0
- package/server/forge-actions.js +1 -1
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +5 -0
- package/server/forge.js +5 -0
- package/server/hooks.js +37 -3
- package/server/server.js +14 -4
- package/server/spec-poll.js +26 -16
- package/server/threads.js +4 -2
- package/server/triggers.js +284 -0
package/server/agent-runs.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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` */
|
package/server/forge-actions.js
CHANGED
|
@@ -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') {
|
package/server/forge-forgejo.js
CHANGED
|
@@ -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
|
},
|