@popcorn245/cornfield 0.8.0 → 0.9.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 +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +59 -5
- package/explorer/explorer.css +3 -1
- package/explorer/forge-panel.js +203 -5
- package/explorer/index.html +28 -0
- package/explorer/review-tokens-model.js +5 -1
- package/explorer/review-tokens.js +11 -7
- package/explorer/runs-model.js +107 -0
- package/package.json +1 -1
- package/server/agent-api.js +227 -0
- package/server/agent-runs.js +769 -0
- package/server/db.js +53 -0
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +7 -0
- package/server/forge.js +5 -1
- package/server/hooks.js +19 -0
- package/server/review-api.js +26 -12
- package/server/review-tokens.js +42 -25
- package/server/server.js +29 -7
- package/server/spec-poll.js +41 -15
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/agent-runs
|
|
3
|
+
*
|
|
4
|
+
* Runs on the forge (issue #80, spec 0005, ADR 0012): cornfield **asks** for an agent run and **follows** it, and nothing else. It never
|
|
5
|
+
* starts a process or holds an agent's credentials. The contract with the developer's runner is core/agent-contract.js
|
|
6
|
+
* (docs/agent-contract.md); this module is cornfield's side of it, and uses that module for every byte of the contract.
|
|
7
|
+
*
|
|
8
|
+
* ## Asking (`request`)
|
|
9
|
+
*
|
|
10
|
+
* A maintainer's request (the dashboard's Start an agent, or `POST /api/agent-requests` with an agent token: server/agent-api.js) is
|
|
11
|
+
* the contract's request, posted as a comment with the maintainer's OWN forge token (`auth.forgeForUser`, as a review is posted), so the
|
|
12
|
+
* forge shows who asked. No live sign-in for them: 401. Where it goes: a `thread` or an `issue` gets the comment on that issue or
|
|
13
|
+
* thread, a `proposal` on its open spec PR. In this order, each step refusing in words (a `Refused`, or the forge's `ForgeError`):
|
|
14
|
+
*
|
|
15
|
+
* 1. a flood (`ATTEMPTS_PER_MINUTE` attempts of any kind, and `AGENT_REQUESTS_PER_MINUTE` requests, per person per minute): 429, and
|
|
16
|
+
* nothing is written down, so a flood cannot grow the audit table (server/throttle.js)
|
|
17
|
+
* 2. the body is strictly the contract's request fields plus `force` (`createRequest`): a credential, by name or by shape, anywhere in it is 400
|
|
18
|
+
* 3. a repeat (the same subject and profile again within a few seconds) and a ceiling of actions per minute (server/throttle.js): 429
|
|
19
|
+
* 4. the forge, as the person: a maintainer NOW (403), the issue exists (404), the proposal has an open spec PR (404)
|
|
20
|
+
* 5. **the cap and the one-run-per-subject rule, in ONE transaction with the insert** of the run's row, so two requests at once cannot
|
|
21
|
+
* both take the last place: at most `agents.maxOpen` open runs per repo (409 `cap-reached`), and one open run per issue or proposal and
|
|
22
|
+
* profile unless `force: true` asks for another on purpose (409 `run-open`; `force` never lifts the cap)
|
|
23
|
+
* 6. the comment is posted. If the forge refuses it the row is removed again
|
|
24
|
+
*
|
|
25
|
+
* **One run per subject AND profile.** The same request under two profiles is two runs (D8), so the rule that stops a second click on the
|
|
26
|
+
* same issue does not stop a second profile on it. The cap still counts both.
|
|
27
|
+
*
|
|
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
|
+
* credential: the request's shape, the brief's length, and the token's id and label.
|
|
30
|
+
*
|
|
31
|
+
* ## What counts as open
|
|
32
|
+
*
|
|
33
|
+
* A run is **open** unless it is finished or quiet (`statusOf`): finished is the runner's report `done`, `failed` or `stopped`, or its pull
|
|
34
|
+
* request merged or closed; **quiet** is no report, push or pull request activity within `agents.quietAfter` (24h unless set). A quiet run
|
|
35
|
+
* is shown as quiet and counts toward neither the cap nor the one-per-subject rule; a later report or push makes it open again.
|
|
36
|
+
*
|
|
37
|
+
* ## Following (`follow`)
|
|
38
|
+
*
|
|
39
|
+
* No timer of its own: the spec poll's tick (server/spec-poll.js) calls `follow` with the forge as a signed-in maintainer when `hasWork`
|
|
40
|
+
* says there is a run to look at, and the dashboard's Refresh does the same. A follow reads, for the runs it looks at (those not
|
|
41
|
+
* finished for good, with activity in the last two quiet periods or two days):
|
|
42
|
+
*
|
|
43
|
+
* - **the pull requests** (one listing): the PR that carries a run's id, in its body as a `Cornfield-Run:` trailer or in its branch name,
|
|
44
|
+
* else in a commit message (`forge.pullCommits`, at most `MAX_COMMIT_LOOKUPS` PRs a tick, each head once); then that PR's state and
|
|
45
|
+
* head, its checks, and its merge or close. A PR moves the run's state and activity only when its author is trusted (a runner, a
|
|
46
|
+
* listed `agents.authors` account, or a maintainer); anyone can write a trailer, so any other PR is linked but marked unverified
|
|
47
|
+
* - **the comments** on each run's issue or PR (and on its linked PR): `cornfield-run` blocks. A **report** counts only from a login in
|
|
48
|
+
* `agents.runners` or a maintainer, a **stop request** only from a maintainer, and neither from a comment edited after it was posted
|
|
49
|
+
* (anyone with write access can edit any comment). Everything else is ignored, and the ignoring is a row in `actions`
|
|
50
|
+
* - **the branches**, for a run that has no pull request yet: a branch whose name holds the run id, and when its head moves
|
|
51
|
+
*
|
|
52
|
+
* Everything seen is kept in `runs` and `run_seen` (server/db.js) before any hook is sent, and each comment is judged once and each hook event
|
|
53
|
+
* sent once (a `run_seen` key), so a restart re-reads the forge and fires nothing twice. Hooks: `run.claimed`, `run.state`, `run.stopRequested`,
|
|
54
|
+
* `run.finished` (server/hooks.js).
|
|
55
|
+
*
|
|
56
|
+
* @typedef {import('../core/agent-contract.js').RunRequest} RunRequest
|
|
57
|
+
* @typedef {{ run_id: string, repo: string, kind: string, profile: string | null, issue: number | null, proposal: string | null, thread: number | null,
|
|
58
|
+
* target: number, comment_id: string, comment_url: string | null, request: string, requested_by: string, requested_by_id: string, requested_at: string,
|
|
59
|
+
* via: string, state: string, reported_at: string | null, reported_by: string | null, note: string | null, url: string | null, tokens: number | null,
|
|
60
|
+
* cost: number | null, stop_requested_at: string | null, stop_requested_by: string | null, pull: number | null, pull_state: string | null,
|
|
61
|
+
* pull_url: string | null, pull_by: string | null, pull_trusted: number, linked_via: string | null, branch: string | null, head_sha: string | null,
|
|
62
|
+
* checks: string | null, last_activity: string }} RunRow
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { ForgeError, isMaintainer } from './forge.js';
|
|
66
|
+
import { createRequest, findRunId, formatRunComment, runAuthors, runBlocksOf, runIdFromBranch, MAX_NOTE_CHARS } from '../core/agent-contract.js';
|
|
67
|
+
import { quietAfterMs } from '../core/config.js';
|
|
68
|
+
import { listPullProposals } from './proposal-pulls.js';
|
|
69
|
+
import { Refused, countAttempt, throttle } from './throttle.js';
|
|
70
|
+
|
|
71
|
+
/** 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`) */
|
|
72
|
+
export const AGENT_REQUESTS_PER_MINUTE = 6;
|
|
73
|
+
/** The most runs a follow looks at, and the most pull requests whose commits it reads, in one tick */
|
|
74
|
+
export const MAX_FOLLOWED = 100;
|
|
75
|
+
export const MAX_COMMIT_LOOKUPS = 10;
|
|
76
|
+
/** The newest pull requests listed to look for a run's id in (the work listing's size) */
|
|
77
|
+
export const WORK_PULLS = 100;
|
|
78
|
+
/** A run is looked at for at least this long after its last activity, even with a short `quietAfter` */
|
|
79
|
+
export const FOLLOW_FLOOR_MS = 48 * 3_600_000;
|
|
80
|
+
/** A run whose request comment never got posted (a crash between the reservation and the post) is dropped after this */
|
|
81
|
+
export const RESERVATION_MS = 10 * 60_000;
|
|
82
|
+
/** The runs a listing answers */
|
|
83
|
+
export const LIST_LIMIT = 200;
|
|
84
|
+
/** The keys of a request as the API and the button send it: the contract's fields, less what cornfield writes, plus `force` */
|
|
85
|
+
export const REQUEST_BODY_KEYS = Object.freeze(['kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'force']);
|
|
86
|
+
|
|
87
|
+
/** The states in which a run is over for good unless the runner says more */
|
|
88
|
+
export const FINISHED = Object.freeze(['done', 'failed', 'stopped', 'merged', 'closed']);
|
|
89
|
+
const REPORT_FINISHED = ['done', 'failed', 'stopped'];
|
|
90
|
+
|
|
91
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
92
|
+
const lower = (s) => String(s).toLowerCase();
|
|
93
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
94
|
+
const words = (err) => (err instanceof Error ? err.message : String(err));
|
|
95
|
+
const maxIso = (a, b) => (Date.parse(b) > Date.parse(a) ? b : a);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a run is, as of `nowMs`, from the runner's reports and what the forge shows. Pure.
|
|
99
|
+
*
|
|
100
|
+
* done / failed / stopped the runner's latest report
|
|
101
|
+
* merged / closed its (trusted) pull request ended
|
|
102
|
+
* quiet nothing happened within `quietMs`
|
|
103
|
+
* stop-requested a maintainer asked it to stop and the runner has not said so
|
|
104
|
+
* claimed / working the runner's latest report
|
|
105
|
+
* working no report, but a (trusted) pull request or a branch carries its id: a silent runner is seen through what it pushes
|
|
106
|
+
* requested asked for, nothing seen yet
|
|
107
|
+
*
|
|
108
|
+
* `open` is whether it counts toward the cap and the one-per-subject rule: not finished, not quiet.
|
|
109
|
+
* @param {RunRow} row
|
|
110
|
+
* @param {number} nowMs
|
|
111
|
+
* @param {number} quietMs
|
|
112
|
+
* @returns {{ status: string, open: boolean, finished: boolean, quiet: boolean }}
|
|
113
|
+
*/
|
|
114
|
+
export function statusOf(row, nowMs, quietMs) {
|
|
115
|
+
const ended = row.pull_trusted === 1 && (row.pull_state === 'merged' || row.pull_state === 'closed');
|
|
116
|
+
let status;
|
|
117
|
+
if (REPORT_FINISHED.includes(row.state)) status = row.state;
|
|
118
|
+
else if (ended) status = /** @type {string} */ (row.pull_state);
|
|
119
|
+
else if (nowMs - Date.parse(row.last_activity) >= quietMs) status = 'quiet';
|
|
120
|
+
else if (row.stop_requested_at) status = 'stop-requested';
|
|
121
|
+
else if (row.state !== 'requested') status = row.state;
|
|
122
|
+
else status = (row.pull_trusted === 1 && row.pull) || row.branch ? 'working' : 'requested';
|
|
123
|
+
const finished = FINISHED.includes(status);
|
|
124
|
+
const quiet = status === 'quiet';
|
|
125
|
+
return { status, open: !finished && !quiet, finished, quiet };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The numbers a run is about, as keys for the one-per-subject rule: an issue and a thread are conversations (numbers are shared by issues and pull requests) */
|
|
129
|
+
function subjectKeys(refs) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const n of [refs.issue, refs.thread]) if (n != null && !out.includes(`n:${n}`)) out.push(`n:${n}`);
|
|
132
|
+
if (refs.proposal != null) out.push(`p:${refs.proposal}`);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const subjectWords = (refs) => [refs.issue != null && `issue #${refs.issue}`, refs.thread != null && refs.thread !== refs.issue && `thread #${refs.thread}`, refs.proposal != null && `proposal ${refs.proposal}`].filter(Boolean).join(' and ');
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A run for the dashboard and the API: derived status, the request's limits beside the runner's report, no credential, nothing that is the
|
|
140
|
+
* database's own. Text the runner wrote (a note, a link) is data for the page to escape.
|
|
141
|
+
* @param {RunRow} row
|
|
142
|
+
* @param {number} nowMs
|
|
143
|
+
* @param {number} quietMs
|
|
144
|
+
*/
|
|
145
|
+
export function publicRun(row, nowMs, quietMs) {
|
|
146
|
+
const { status, open } = statusOf(row, nowMs, quietMs);
|
|
147
|
+
let limits = null;
|
|
148
|
+
try { limits = JSON.parse(row.request).limits ?? null; } catch { limits = null; }
|
|
149
|
+
return {
|
|
150
|
+
runId: row.run_id, kind: row.kind, profile: row.profile,
|
|
151
|
+
refs: { ...(row.issue != null ? { issue: row.issue } : {}), ...(row.proposal != null ? { proposal: row.proposal } : {}), ...(row.thread != null ? { thread: row.thread } : {}) },
|
|
152
|
+
status, open, requestedBy: row.requested_by, requestedAt: row.requested_at, via: row.via, target: row.target,
|
|
153
|
+
comment: row.comment_id ? { id: row.comment_id, url: row.comment_url } : null,
|
|
154
|
+
limits,
|
|
155
|
+
report: row.reported_at ? { state: row.state, at: row.reported_at, by: row.reported_by, note: row.note, url: row.url, tokens: row.tokens, cost: row.cost } : null,
|
|
156
|
+
stopRequested: row.stop_requested_at ? { at: row.stop_requested_at, by: row.stop_requested_by } : null,
|
|
157
|
+
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,
|
|
158
|
+
branch: row.branch, headSha: row.head_sha, checks: row.checks, lastActivity: row.last_activity,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @typedef {object} RequestContext
|
|
164
|
+
* @property {() => import('./forge.js').Forge | Promise<import('./forge.js').Forge>} forge the forge as the person (may throw a ForgeError `unauthorized`)
|
|
165
|
+
* @property {{ login: string, userId: string }} who
|
|
166
|
+
* @property {{ auth: object, now?: () => number }} limits what server/throttle.js counts by
|
|
167
|
+
* @property {{ token: { id: number, label: string } } | null} [via] set when the request came through the API: the token it came with
|
|
168
|
+
* @property {() => string | null | Promise<string | null>} [stillAllowed] asked once more just before the cap is taken: null to go on, or why not
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {{
|
|
173
|
+
* config: any,
|
|
174
|
+
* where?: { proposals: string },
|
|
175
|
+
* auth: { store: () => Promise<import('node:sqlite').DatabaseSync>, record: Function, forgeForUser?: Function },
|
|
176
|
+
* hooks?: import('./hooks.js').Hooks | null,
|
|
177
|
+
* now?: () => number,
|
|
178
|
+
* onAsk?: () => void,
|
|
179
|
+
* }} opts `onAsk` is called when a run was asked for: the server starts the poll that follows it
|
|
180
|
+
*/
|
|
181
|
+
export function createRuns({ config, where, auth, hooks = null, now = Date.now, onAsk }) {
|
|
182
|
+
const repo = config.forge.repo;
|
|
183
|
+
const folder = where || { proposals: config.proposals };
|
|
184
|
+
const agents = config.agents || {};
|
|
185
|
+
const cap = Number.isInteger(agents.maxOpen) ? agents.maxOpen : 3;
|
|
186
|
+
const profiles = Array.isArray(agents.profiles) ? agents.profiles : [];
|
|
187
|
+
const runnerSet = new Set((Array.isArray(agents.runners) ? agents.runners : []).map(lower));
|
|
188
|
+
const authorSet = new Set((Array.isArray(agents.authors) ? agents.authors : []).map(lower));
|
|
189
|
+
const quietMs = quietAfterMs(config);
|
|
190
|
+
const log = (what) => console.error(`[cornfield] agent runs: ${what}`);
|
|
191
|
+
let following = null;
|
|
192
|
+
|
|
193
|
+
const audit = async (entry) => {
|
|
194
|
+
try {
|
|
195
|
+
await auth.record(entry);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
log(`an audit row (${entry.action}) could not be written: ${words(err)}`);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 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
|
|
203
|
+
* posted (a crash between the reservation and the post) holds its place for `RESERVATION_MS` only, whenever cornfield last restarted.
|
|
204
|
+
*/
|
|
205
|
+
function openRows(db, at) {
|
|
206
|
+
return /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND state NOT IN ('done', 'failed', 'stopped') ORDER BY requested_at").all(repo))
|
|
207
|
+
.filter((r) => (r.comment_id !== '' || at - Date.parse(r.requested_at) < RESERVATION_MS) && statusOf(r, at, quietMs).open);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Takes a place for a run: the cap and the one-per-subject rule are checked and the row inserted in one transaction. Throws a `Refused`. */
|
|
211
|
+
function reserve(db, request, { target, who, via, force }) {
|
|
212
|
+
const at = now();
|
|
213
|
+
const mine = subjectKeys(request.refs);
|
|
214
|
+
db.exec('BEGIN IMMEDIATE');
|
|
215
|
+
try {
|
|
216
|
+
// What a crash left (a reservation never posted) is dropped here too, not only at the next start
|
|
217
|
+
db.prepare("DELETE FROM runs WHERE repo = ? AND comment_id = '' AND requested_at < ?").run(repo, iso(at - RESERVATION_MS));
|
|
218
|
+
const open = openRows(db, at);
|
|
219
|
+
if (open.length >= cap) {
|
|
220
|
+
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');
|
|
221
|
+
}
|
|
222
|
+
if (!force) {
|
|
223
|
+
const clash = open.find((r) => (r.profile ?? null) === (request.profile ?? null)
|
|
224
|
+
&& subjectKeys({ issue: r.issue ?? undefined, thread: r.thread ?? undefined, proposal: r.proposal ?? undefined }).some((k) => mine.includes(k)));
|
|
225
|
+
if (clash) {
|
|
226
|
+
throw new Refused(409, `${subjectWords(request.refs)} already has an open run${clash.profile ? ` under the profile ${clash.profile}` : ''}: ${clash.run_id} (${statusOf(clash, at, quietMs).status}). To ask for another on purpose, send "force": true${profiles.length ? ', or pick another profile' : ''}.`, 'run-open');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
db.prepare(`INSERT INTO runs (run_id, repo, kind, profile, issue, proposal, thread, target, request, requested_by, requested_by_id, requested_at, via, last_activity)
|
|
230
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
231
|
+
.run(request.runId, repo, request.kind, request.profile ?? null, request.refs.issue ?? null, request.refs.proposal ?? null, request.refs.thread ?? null,
|
|
232
|
+
target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : 'dashboard', iso(at));
|
|
233
|
+
db.exec('COMMIT');
|
|
234
|
+
} catch (err) {
|
|
235
|
+
try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** What a request's audit row says: the shape of what was asked and never its words (a brief may be long, and may be anything) */
|
|
241
|
+
function askedOf(body, via) {
|
|
242
|
+
const out = /** @type {Record<string, unknown>} */ ({});
|
|
243
|
+
if (!isObject(body)) return { ...(via ? { via: 'api', token: via.token } : {}) };
|
|
244
|
+
const known = Object.keys(body).every((k) => REQUEST_BODY_KEYS.includes(k));
|
|
245
|
+
if (!known) out.unknownKeys = Object.keys(body).filter((k) => !REQUEST_BODY_KEYS.includes(k)).length;
|
|
246
|
+
if (typeof body.kind === 'string' && body.kind.length <= 20) out.kind = body.kind;
|
|
247
|
+
// Only a name the config lists: anything else sent in that field could be a secret pasted in the wrong place
|
|
248
|
+
if (typeof body.profile === 'string' && profiles.includes(body.profile)) out.profile = body.profile;
|
|
249
|
+
if (isObject(body.refs)) {
|
|
250
|
+
const refs = {};
|
|
251
|
+
if (Number.isInteger(body.refs.issue)) refs.issue = body.refs.issue;
|
|
252
|
+
if (Number.isInteger(body.refs.thread)) refs.thread = body.refs.thread;
|
|
253
|
+
if (typeof body.refs.proposal === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(body.refs.proposal)) refs.proposal = body.refs.proposal;
|
|
254
|
+
out.refs = refs;
|
|
255
|
+
}
|
|
256
|
+
if (typeof body.brief === 'string') out.briefChars = body.brief.length;
|
|
257
|
+
if (body.force === true) out.force = true;
|
|
258
|
+
if (via) Object.assign(out, { via: 'api', token: via.token });
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Asks for a run: checks and posts a request (the steps in this module's header). Throws a `Refused` or a `ForgeError` (answer with
|
|
264
|
+
* `refusal(err)`, server/submit-review.js); resolves with the run and the comment that is the request.
|
|
265
|
+
* @param {RequestContext} ctx
|
|
266
|
+
* @param {{ body: unknown, problem?: { status: number, message: string } }} input `problem`: something already wrong with the request
|
|
267
|
+
* itself (not same-origin, not JSON, too large): refused here, after the flood count and with a row, like every other refusal
|
|
268
|
+
*/
|
|
269
|
+
async function request(ctx, { body, problem }) {
|
|
270
|
+
const { who, via = null } = ctx;
|
|
271
|
+
const at = ctx.limits.now ?? now;
|
|
272
|
+
const asked = askedOf(body, via);
|
|
273
|
+
const flood = countAttempt(ctx.limits, who.userId, at()) || countAttempt(ctx.limits, `agent|${who.userId}`, at(), AGENT_REQUESTS_PER_MINUTE);
|
|
274
|
+
if (flood) throw flood;
|
|
275
|
+
let target = repo;
|
|
276
|
+
let status = null;
|
|
277
|
+
let release = () => {};
|
|
278
|
+
let runId = null;
|
|
279
|
+
try {
|
|
280
|
+
if (problem) throw new Refused(problem.status, problem.message, 'invalid');
|
|
281
|
+
if (!isObject(body)) throw new Refused(400, `A request is a JSON object: { kind, refs, profile?, brief?, branch?, limits?, nodes?, force? }.`, 'invalid');
|
|
282
|
+
const { force, requestedBy, ...fields } = body;
|
|
283
|
+
if (force !== undefined && typeof force !== 'boolean') throw new Refused(400, '"force" is true or false: whether to ask for another run on an issue or proposal that has an open one.', 'invalid');
|
|
284
|
+
const build = (login, pull) => {
|
|
285
|
+
// The config's limits are hints the request starts from; what the person sent wins key by key
|
|
286
|
+
const base = agents.limits || {};
|
|
287
|
+
let limits = fields.limits;
|
|
288
|
+
if (limits === undefined) limits = Object.keys(base).length ? { ...base } : undefined;
|
|
289
|
+
else if (isObject(limits)) limits = { ...base, ...limits };
|
|
290
|
+
// The brief is required by the contract and may be empty; the spec PR that holds a proposal is named beside it
|
|
291
|
+
const refs = pull != null && isObject(fields.refs) ? { ...fields.refs, pull } : fields.refs;
|
|
292
|
+
const made = createRequest({ brief: '', ...fields, refs, ...(limits !== undefined ? { limits } : {}), requestedBy: login }, { profiles, now: at });
|
|
293
|
+
if (!made.ok) throw new Refused(400, made.error, 'invalid');
|
|
294
|
+
return /** @type {RunRequest} */ (made.value);
|
|
295
|
+
};
|
|
296
|
+
let made = build(who.login);
|
|
297
|
+
runId = made.runId;
|
|
298
|
+
Object.assign(asked, { runId });
|
|
299
|
+
// The same ask again within a few seconds is a double click; asking again on purpose (`force`) is another key, so a refusal can be answered at once
|
|
300
|
+
release = throttle(ctx.limits, who.userId, `agent|${subjectKeys(made.refs).join('+')}|${made.profile ?? ''}|${made.kind}${force === true ? '|force' : ''}`, at());
|
|
301
|
+
|
|
302
|
+
const forge = await ctx.forge();
|
|
303
|
+
const me = await forge.me();
|
|
304
|
+
if (String(me.id) !== who.userId) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
|
|
305
|
+
if (!isMaintainer(await forge.permission(repo, me.login))) throw new Refused(403, `Only the repo's maintainers ask for agent runs; ${me.login} is not one now.`, 'forbidden');
|
|
306
|
+
if (requestedBy !== undefined && lower(requestedBy) !== lower(me.login)) throw new Refused(400, `"requestedBy" is who asks: ${me.login}. Leave it out; it is filled in.`, 'invalid');
|
|
307
|
+
if (me.login !== who.login) { made = build(me.login); runId = made.runId; Object.assign(asked, { runId }); }
|
|
308
|
+
|
|
309
|
+
// Where the request goes: the thread it came from, else the open spec PR of the proposal, else the issue
|
|
310
|
+
let number;
|
|
311
|
+
if (made.refs.thread != null) number = made.refs.thread;
|
|
312
|
+
else if (made.refs.proposal != null) {
|
|
313
|
+
const listed = await listPullProposals(forge, folder, { hooks: null });
|
|
314
|
+
const entry = listed.entries.find((e) => e.slug === made.refs.proposal);
|
|
315
|
+
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');
|
|
316
|
+
number = entry.pull.number;
|
|
317
|
+
made = build(me.login, number);
|
|
318
|
+
runId = made.runId;
|
|
319
|
+
Object.assign(asked, { runId });
|
|
320
|
+
} else number = /** @type {number} */ (made.refs.issue);
|
|
321
|
+
if (made.refs.issue != null) {
|
|
322
|
+
try {
|
|
323
|
+
await forge.issue(made.refs.issue);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err instanceof ForgeError && err.code === 'not-found') throw new Refused(404, `#${made.refs.issue} is not an issue of ${repo} (a pull request is asked about as its thread).`, 'not-found');
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
target = `${repo}#${number}`;
|
|
330
|
+
let comment = formatRunComment(made.runId, made, { profiles });
|
|
331
|
+
if (via) {
|
|
332
|
+
const [head, ...rest] = comment.split('\n\n');
|
|
333
|
+
comment = [`${head} Sent through the agent API with the token \`${via.token.label}\`.`, ...rest].join('\n\n');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The token once more, after every forge call: one revoked or expired meanwhile does not ask
|
|
337
|
+
if (ctx.stillAllowed) {
|
|
338
|
+
const why = await ctx.stillAllowed();
|
|
339
|
+
if (why) throw new Refused(403, why, 'forbidden');
|
|
340
|
+
}
|
|
341
|
+
const db = await auth.store();
|
|
342
|
+
reserve(db, made, { target: number, who, via, force: force === true });
|
|
343
|
+
let posted;
|
|
344
|
+
try {
|
|
345
|
+
posted = await forge.comment(number, comment);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
db.prepare('DELETE FROM runs WHERE run_id = ?').run(made.runId);
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
|
|
351
|
+
status = 201;
|
|
352
|
+
await audit({ login: me.login, action: 'agent-request', target, request: { ...asked, target: number }, status, answer: { comment: posted.id, url: posted.url ?? null } });
|
|
353
|
+
hooks?.emit('agent.requested', { request: made, actor: me.login });
|
|
354
|
+
try { onAsk?.(); } catch { /* the poll is a convenience */ }
|
|
355
|
+
const row = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(made.runId));
|
|
356
|
+
return { run: publicRun(row, now(), quietMs), request: made, comment: { id: posted.id, url: posted.url ?? null, number } };
|
|
357
|
+
} catch (err) {
|
|
358
|
+
if (status === null) {
|
|
359
|
+
const out = describe(err);
|
|
360
|
+
await audit({ login: who.login, action: 'agent-request', target, request: asked, status: err instanceof ForgeError ? err.status ?? (err.code === 'unavailable' ? null : out.status) : out.status, answer: { error: out.error, code: out.code } });
|
|
361
|
+
if (!(err instanceof Refused) && !(err instanceof ForgeError)) log(`the request ${runId ?? ''} failed: ${err instanceof Error ? err.stack || err.message : err}`);
|
|
362
|
+
}
|
|
363
|
+
throw err;
|
|
364
|
+
} finally {
|
|
365
|
+
release();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Words and codes for an error, for the audit row (the routes answer with `refusal`) */
|
|
370
|
+
function describe(err) {
|
|
371
|
+
if (err instanceof Refused) return { status: err.status, error: err.message, code: err.code };
|
|
372
|
+
if (err instanceof ForgeError) return { status: { 'not-found': 404, invalid: 400, unauthorized: 401, 'rate-limited': 429 }[err.code] ?? 502, error: err.message, code: err.code };
|
|
373
|
+
return { status: 500, error: 'cornfield failed before it could answer.', code: 'unavailable' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Asks a run to stop: posts a `stop-requested` block in the same place as the request, as the maintainer who clicked. The runner decides what
|
|
378
|
+
* stopping means. Throws like `request`.
|
|
379
|
+
* @param {RequestContext} ctx
|
|
380
|
+
* @param {{ runId: unknown, note?: unknown, problem?: { status: number, message: string } }} input
|
|
381
|
+
*/
|
|
382
|
+
async function stop(ctx, { runId, note, problem }) {
|
|
383
|
+
const { who, via = null } = ctx;
|
|
384
|
+
const at = ctx.limits.now ?? now;
|
|
385
|
+
const flood = countAttempt(ctx.limits, who.userId, at());
|
|
386
|
+
if (flood) throw flood;
|
|
387
|
+
let target = repo;
|
|
388
|
+
let done = false;
|
|
389
|
+
let release = () => {};
|
|
390
|
+
const asked = { ...(typeof runId === 'string' && runId.length <= 40 ? { runId } : {}), ...(via ? { via: 'api', token: via.token } : {}) };
|
|
391
|
+
try {
|
|
392
|
+
if (problem) throw new Refused(problem.status, problem.message, 'invalid');
|
|
393
|
+
if (note !== undefined && (typeof note !== 'string' || note.length > MAX_NOTE_CHARS)) throw new Refused(400, `"note" is text of at most ${MAX_NOTE_CHARS} characters.`, 'invalid');
|
|
394
|
+
const db = await auth.store();
|
|
395
|
+
const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare('SELECT * FROM runs WHERE run_id = ? AND repo = ?').get(runId, repo)) : undefined;
|
|
396
|
+
if (!row) throw new Refused(404, 'cornfield has no such run.', 'not-found');
|
|
397
|
+
target = `${repo}#${row.target}`;
|
|
398
|
+
release = throttle(ctx.limits, who.userId, `agent-stop|${row.run_id}`, at());
|
|
399
|
+
const forge = await ctx.forge();
|
|
400
|
+
const me = await forge.me();
|
|
401
|
+
if (String(me.id) !== who.userId) throw new Refused(403, 'The forge says this sign-in is someone else now; sign in again.', 'forbidden');
|
|
402
|
+
if (!isMaintainer(await forge.permission(repo, me.login))) throw new Refused(403, `Only the repo's maintainers stop agent runs; ${me.login} is not one now.`, 'forbidden');
|
|
403
|
+
const was = statusOf(row, now(), quietMs);
|
|
404
|
+
if (was.finished) throw new Refused(409, `Run ${row.run_id} is ${was.status}; there is nothing to stop.`, 'conflict');
|
|
405
|
+
if (row.stop_requested_at) throw new Refused(409, `${row.stop_requested_by ?? 'A maintainer'} already asked run ${row.run_id} to stop (${row.stop_requested_at}); the runner decides what that means.`, 'conflict');
|
|
406
|
+
if (ctx.stillAllowed) {
|
|
407
|
+
const why = await ctx.stillAllowed();
|
|
408
|
+
if (why) throw new Refused(403, why, 'forbidden');
|
|
409
|
+
}
|
|
410
|
+
let comment;
|
|
411
|
+
try {
|
|
412
|
+
comment = formatRunComment(row.run_id, { state: 'stop-requested', ...(note ? { note } : {}) }, { line: `Asked run \`${row.run_id}\` to stop (${me.login}, in cornfield${via ? `, through the agent API with the token \`${via.token.label}\`` : ''}).` });
|
|
413
|
+
} catch (err) {
|
|
414
|
+
throw new Refused(400, words(err), 'invalid');
|
|
415
|
+
}
|
|
416
|
+
const posted = await forge.comment(row.target, comment);
|
|
417
|
+
const stamp = iso(now());
|
|
418
|
+
// Judged already: the follow that reads this comment back must not fire the hook again
|
|
419
|
+
db.exec('BEGIN IMMEDIATE');
|
|
420
|
+
try {
|
|
421
|
+
db.prepare('UPDATE runs SET stop_requested_at = ?, stop_requested_by = ?, last_activity = MAX(last_activity, ?) WHERE run_id = ?').run(stamp, me.login, stamp, row.run_id);
|
|
422
|
+
db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
|
|
423
|
+
db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `ev:stop:${posted.id}:c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
|
|
424
|
+
db.exec('COMMIT');
|
|
425
|
+
} catch (err) {
|
|
426
|
+
try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
done = true;
|
|
430
|
+
await audit({ login: me.login, action: 'agent-stop', target, request: { ...asked, ...(note ? { noteChars: note.length } : {}) }, status: 201, answer: { comment: posted.id, url: posted.url ?? null } });
|
|
431
|
+
const after = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(row.run_id));
|
|
432
|
+
hooks?.emit('run.stopRequested', runPayload(after, me.login));
|
|
433
|
+
return { run: publicRun(after, now(), quietMs), comment: { id: posted.id, url: posted.url ?? null } };
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (!done) {
|
|
436
|
+
const out = describe(err);
|
|
437
|
+
await audit({ login: who.login, action: 'agent-stop', target, request: asked, status: err instanceof ForgeError ? err.status ?? (err.code === 'unavailable' ? null : out.status) : out.status, answer: { error: out.error, code: out.code } });
|
|
438
|
+
if (!(err instanceof Refused) && !(err instanceof ForgeError)) log(`stopping ${asked.runId ?? ''} failed: ${err instanceof Error ? err.stack || err.message : err}`);
|
|
439
|
+
}
|
|
440
|
+
throw err;
|
|
441
|
+
} finally {
|
|
442
|
+
release();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The body of a run hook event: which run, in what state, about what, caused by whom */
|
|
447
|
+
function runPayload(row, actor) {
|
|
448
|
+
return { runId: row.run_id, state: statusOf(row, now(), quietMs).status, pull: row.pull ?? undefined, issue: row.issue ?? row.thread ?? undefined, actor };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// -------------------------------------------------------------------------
|
|
452
|
+
// Reading
|
|
453
|
+
// -------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
/** The runs, newest first, as the dashboard shows them, with the cap and how many are open */
|
|
456
|
+
async function list({ limit = LIST_LIMIT } = {}) {
|
|
457
|
+
const db = await auth.store();
|
|
458
|
+
const at = now();
|
|
459
|
+
const rows = /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND comment_id <> '' ORDER BY requested_at DESC, run_id DESC LIMIT ?").all(repo, limit));
|
|
460
|
+
return { runs: rows.map((r) => publicRun(r, at, quietMs)), open: openRows(db, at).length, maxOpen: cap, profiles, limits: agents.limits || {}, quietAfter: agents.quietAfter, runners: [...runnerSet].length };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** One run, or null */
|
|
464
|
+
async function get(runId) {
|
|
465
|
+
const db = await auth.store();
|
|
466
|
+
const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare("SELECT * FROM runs WHERE run_id = ? AND repo = ? AND comment_id <> ''").get(runId, repo)) : undefined;
|
|
467
|
+
return row ? publicRun(row, now(), quietMs) : null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The runs a follow looks at: posted, not over for good (a trusted pull request merged or closed), and active within the follow window */
|
|
471
|
+
function followable(db, at) {
|
|
472
|
+
const window = Math.max(2 * quietMs, FOLLOW_FLOOR_MS);
|
|
473
|
+
return /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND comment_id <> '' AND last_activity > ? AND NOT (pull_trusted = 1 AND pull_state IN ('merged', 'closed')) ORDER BY last_activity DESC LIMIT ?")
|
|
474
|
+
.all(repo, iso(at - window), MAX_FOLLOWED));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Whether there is a run to look at: what the spec poll asks before it spends a call of the forge */
|
|
478
|
+
async function hasWork() {
|
|
479
|
+
return followable(await auth.store(), now()).length > 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Looks at the forge for what the runs I follow have left (the steps in this module's header). One at a time: a follow that is running
|
|
484
|
+
* is the answer to another asked meanwhile. Never throws; resolves with a count.
|
|
485
|
+
* @param {import('./forge.js').Forge} forge a signed-in maintainer's
|
|
486
|
+
* @returns {Promise<{ followed: number, reports: number, ignored: number, linked: number }>}
|
|
487
|
+
*/
|
|
488
|
+
function follow(forge) {
|
|
489
|
+
if (following) return following;
|
|
490
|
+
following = followRuns(forge)
|
|
491
|
+
.catch((err) => {
|
|
492
|
+
log(`a look at the runs failed: ${words(err)}`);
|
|
493
|
+
return { followed: 0, reports: 0, ignored: 0, linked: 0 };
|
|
494
|
+
})
|
|
495
|
+
.finally(() => { following = null; });
|
|
496
|
+
return following;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function followRuns(forge) {
|
|
500
|
+
const db = await auth.store();
|
|
501
|
+
const at = now();
|
|
502
|
+
const rows = followable(db, at);
|
|
503
|
+
const out = { followed: rows.length, reports: 0, ignored: 0, linked: 0 };
|
|
504
|
+
if (!rows.length) return out;
|
|
505
|
+
/** @type {Map<string, RunRow>} the rows as they will be written: patched in place as the forge is read */
|
|
506
|
+
const cur = new Map(rows.map((r) => [r.run_id, { ...r }]));
|
|
507
|
+
const before = new Map(rows.map((r) => [r.run_id, statusOf(r, at, quietMs).status]));
|
|
508
|
+
const dirty = new Set();
|
|
509
|
+
/** @type {{ runId: string, key: string, event: string, actor: string | null }[]} hook events to send once they are kept */
|
|
510
|
+
const events = [];
|
|
511
|
+
/** @type {Map<string, string>} the cause of the latest change of each run, for the key of its finished event */
|
|
512
|
+
const cause = new Map();
|
|
513
|
+
/** @type {{ login: string | null, request: object, answer: object, target: number }[]} audit rows to write after */
|
|
514
|
+
const audits = [];
|
|
515
|
+
|
|
516
|
+
const seen = (runId, key) => db.prepare('SELECT 1 FROM run_seen WHERE run_id = ? AND key = ?').get(runId, key) !== undefined;
|
|
517
|
+
const touch = (row, when) => { row.last_activity = maxIso(row.last_activity, iso(Number.isFinite(Date.parse(when)) ? Date.parse(when) : at)); dirty.add(row.run_id); };
|
|
518
|
+
const queue = (row, event, key, actor, state) => { events.push({ runId: row.run_id, event, key, actor, state }); };
|
|
519
|
+
|
|
520
|
+
// Who counts: runners by the config, maintainers by what the forge says now (asked once per login per look)
|
|
521
|
+
const maintainerOf = new Map();
|
|
522
|
+
const isMaintainerLogin = (login) => {
|
|
523
|
+
const k = lower(login);
|
|
524
|
+
if (!maintainerOf.has(k)) maintainerOf.set(k, forge.permission(repo, login).then(isMaintainer));
|
|
525
|
+
return maintainerOf.get(k);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// ---- the pull requests ------------------------------------------------
|
|
529
|
+
let pulls = null;
|
|
530
|
+
try {
|
|
531
|
+
pulls = await forge.pulls({ state: 'all', limit: WORK_PULLS });
|
|
532
|
+
} catch (err) {
|
|
533
|
+
log(`the pull requests could not be listed: ${words(err)}`);
|
|
534
|
+
}
|
|
535
|
+
if (pulls) {
|
|
536
|
+
const byNumber = new Map(pulls.map((p) => [p.number, p]));
|
|
537
|
+
const taken = new Set([...cur.values()].filter((r) => r.pull != null).map((r) => r.pull));
|
|
538
|
+
/** @type {Map<string, { pull: import('./forge.js').Pull, via: 'body' | 'commit' | 'branch' }>} the run ids PRs name; the lowest PR number wins */
|
|
539
|
+
const named = new Map();
|
|
540
|
+
const undecided = [];
|
|
541
|
+
for (const p of [...pulls].sort((a, b) => a.number - b.number)) {
|
|
542
|
+
if (taken.has(p.number)) continue;
|
|
543
|
+
const found = findRunId({ body: p.body, branch: p.head.ref });
|
|
544
|
+
if (found && cur.has(found.runId)) { if (!named.has(found.runId)) named.set(found.runId, { pull: p, via: found.via }); } else if (!found) undecided.push(p);
|
|
545
|
+
}
|
|
546
|
+
// A trailer in a commit message: only for a pull request nothing else names, opened after a run still unlinked was asked for, one head once
|
|
547
|
+
const unlinked = [...cur.values()].filter((r) => r.pull == null && !named.has(r.run_id));
|
|
548
|
+
if (unlinked.length) {
|
|
549
|
+
let lookups = 0;
|
|
550
|
+
for (const p of undecided) {
|
|
551
|
+
if (lookups >= MAX_COMMIT_LOOKUPS) break;
|
|
552
|
+
if (!unlinked.some((r) => Date.parse(p.createdAt) >= Date.parse(r.requested_at) - 60_000)) continue;
|
|
553
|
+
const key = `commits:${p.number}@${p.head.sha}`;
|
|
554
|
+
if (seen('', key)) continue;
|
|
555
|
+
lookups += 1;
|
|
556
|
+
let commits;
|
|
557
|
+
try {
|
|
558
|
+
commits = await forge.pullCommits(p.number);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
log(`the commits of #${p.number} could not be read: ${words(err)}`);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run('', key, iso(at));
|
|
564
|
+
const found = findRunId({ commits: commits.map((c) => c.message) });
|
|
565
|
+
if (found && cur.has(found.runId) && !named.has(found.runId)) named.set(found.runId, { pull: p, via: 'commit' });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const trustedAuthor = async (login) => !!login && (runnerSet.has(lower(login)) || authorSet.has(lower(login)) || await isMaintainerLogin(login));
|
|
570
|
+
for (const row of cur.values()) {
|
|
571
|
+
let pr = null;
|
|
572
|
+
let via = row.linked_via;
|
|
573
|
+
if (row.pull != null) {
|
|
574
|
+
pr = byNumber.get(row.pull) ?? null;
|
|
575
|
+
if (!pr) {
|
|
576
|
+
try { pr = await forge.pull(row.pull); } catch (err) { log(`pull request #${row.pull} could not be read: ${words(err)}`); }
|
|
577
|
+
}
|
|
578
|
+
} else if (named.has(row.run_id)) {
|
|
579
|
+
({ pull: pr, via } = /** @type {{ pull: import('./forge.js').Pull, via: 'body' | 'commit' | 'branch' }} */ (named.get(row.run_id)));
|
|
580
|
+
}
|
|
581
|
+
if (!pr) continue;
|
|
582
|
+
const firstLink = row.pull == null;
|
|
583
|
+
const state = pr.state === 'merged' ? 'merged' : pr.state === 'closed' ? 'closed' : 'open';
|
|
584
|
+
const earlier = row.pull_state;
|
|
585
|
+
let trusted = row.pull_trusted === 1;
|
|
586
|
+
if (firstLink) {
|
|
587
|
+
try { trusted = await trustedAuthor(pr.author?.login ?? null); } catch (err) { log(`who opened #${pr.number} could not be asked: ${words(err)}`); continue; }
|
|
588
|
+
}
|
|
589
|
+
Object.assign(row, { pull: pr.number, pull_state: state, pull_url: pr.url ?? null, pull_by: pr.author?.login ?? null, pull_trusted: trusted ? 1 : 0, linked_via: via });
|
|
590
|
+
// A stranger's branch is not the run's: only a trusted pull request says where the run pushes
|
|
591
|
+
if (trusted) Object.assign(row, { branch: pr.head.ref || null, head_sha: pr.head.sha || null });
|
|
592
|
+
dirty.add(row.run_id);
|
|
593
|
+
if (trusted) touch(row, pr.updatedAt);
|
|
594
|
+
if (firstLink) {
|
|
595
|
+
out.linked += 1;
|
|
596
|
+
audits.push({ login: pr.author?.login ?? null, target: pr.number, request: { runId: row.run_id, pull: pr.number, via }, answer: { linked: true, trusted, ...(trusted ? {} : { reason: 'its author is not a runner, a listed agent author or a maintainer, so it moves nothing' }) } });
|
|
597
|
+
cause.set(row.run_id, `link:${pr.number}`);
|
|
598
|
+
if (trusted) queue(row, 'run.state', `ev:linked:${pr.number}`, pr.author?.login ?? null, 'working');
|
|
599
|
+
}
|
|
600
|
+
if (trusted && state !== 'open' && earlier !== state) {
|
|
601
|
+
cause.set(row.run_id, `pull:${pr.number}:${state}`);
|
|
602
|
+
queue(row, 'run.state', `ev:pull:${pr.number}:${state}`, pr.author?.login ?? null, state);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---- the comments -----------------------------------------------------
|
|
608
|
+
/** @type {Map<number, Set<string>>} */
|
|
609
|
+
const places = new Map();
|
|
610
|
+
for (const r of cur.values()) for (const n of [r.target, r.pull]) if (n != null) places.set(n, (places.get(n) ?? new Set()).add(r.run_id));
|
|
611
|
+
/** @type {{ row: RunRow, id: string, key: string, at: string, by: string | null, block: any }[]} */
|
|
612
|
+
const heard = [];
|
|
613
|
+
for (const [number, ids] of places) {
|
|
614
|
+
let comments;
|
|
615
|
+
try {
|
|
616
|
+
comments = await forge.comments(number);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
log(`the comments on #${number} could not be read: ${words(err)}`);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// Whom the forge says to believe: asked once per login, for the comments that hold a block at all (the contract's reader does the rest)
|
|
622
|
+
const standing = new Map();
|
|
623
|
+
try {
|
|
624
|
+
for (const login of runAuthors(comments)) standing.set(lower(login), { runner: runnerSet.has(lower(login)), maintainer: await isMaintainerLogin(login) });
|
|
625
|
+
} catch (err) {
|
|
626
|
+
log(`who wrote the comments on #${number} could not be asked: ${words(err)}`);
|
|
627
|
+
continue; // judged next look
|
|
628
|
+
}
|
|
629
|
+
const trust = (login, block) => {
|
|
630
|
+
const who = standing.get(lower(login));
|
|
631
|
+
if (!who) return false;
|
|
632
|
+
// A report: a runner (agents.runners) or a maintainer. A stop request: a maintainer. A request is not ours to follow
|
|
633
|
+
return block.type === 'report' ? who.runner || who.maintainer : block.type === 'stop-request' ? who.maintainer : false;
|
|
634
|
+
};
|
|
635
|
+
const found = runBlocksOf(comments, { trust });
|
|
636
|
+
const counts = new Map();
|
|
637
|
+
const keyOf = (id, runId, type) => {
|
|
638
|
+
const base = `c:${id}:${runId}:${type}`;
|
|
639
|
+
const n = counts.get(base) ?? 0;
|
|
640
|
+
counts.set(base, n + 1);
|
|
641
|
+
return `${base}:${n}`;
|
|
642
|
+
};
|
|
643
|
+
/** Judges one block once: audited whatever the outcome; true when it is new and is ours */
|
|
644
|
+
const judge = (row, id, key, by, type, request, answer) => {
|
|
645
|
+
if (seen(row.run_id, key)) return false;
|
|
646
|
+
audits.push({ login: by, target: number, request: { runId: row.run_id, type, comment: id, ...request }, answer, runId: row.run_id, key });
|
|
647
|
+
return true;
|
|
648
|
+
};
|
|
649
|
+
for (const b of found.blocks) {
|
|
650
|
+
const row = cur.get(b.runId);
|
|
651
|
+
if (!row || !ids.has(b.runId)) continue;
|
|
652
|
+
const key = keyOf(b.comment.id, b.runId, b.type);
|
|
653
|
+
if (!judge(row, b.comment.id, key, b.by, b.type, { ...(b.value.state ? { state: b.value.state } : {}) }, { accepted: true })) continue;
|
|
654
|
+
heard.push({ row, id: b.comment.id, key, at: b.at, by: b.by, block: b });
|
|
655
|
+
}
|
|
656
|
+
for (const b of found.ignored) {
|
|
657
|
+
const row = cur.get(b.runId);
|
|
658
|
+
if (!row || !ids.has(b.runId) || b.type === 'request') continue;
|
|
659
|
+
const key = keyOf(b.comment.id, b.runId, b.type);
|
|
660
|
+
const reason = b.why === 'edited' ? 'edited after it was posted, so it is not believed'
|
|
661
|
+
: b.type === 'report' ? 'not from a runner (agents.runners) or a maintainer' : 'a stop request is believed only from a maintainer';
|
|
662
|
+
if (judge(row, b.comment.id, key, b.by, b.type, { ...(b.value.state ? { state: b.value.state } : {}) }, { accepted: false, reason })) out.ignored += 1;
|
|
663
|
+
}
|
|
664
|
+
// A block that is not strictly the contract's (a credential in a report, a stray key) is turned away, and written down once
|
|
665
|
+
for (const [n, r] of found.refused.entries()) {
|
|
666
|
+
const row = r.runId ? cur.get(r.runId) : null;
|
|
667
|
+
if (!row || !ids.has(row.run_id)) continue;
|
|
668
|
+
if (judge(row, r.comment.id, `c:${r.comment.id}:${row.run_id}:refused:${n}`, r.by, 'refused', {}, { accepted: false, reason: `not a valid block: ${r.error}` })) out.ignored += 1;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
heard.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
672
|
+
for (const { row, id, key, at: when, by: login, block } of heard) {
|
|
673
|
+
if (block.type === 'report') {
|
|
674
|
+
const r = block.value;
|
|
675
|
+
const previous = row.state;
|
|
676
|
+
Object.assign(row, {
|
|
677
|
+
state: r.state, reported_at: when, reported_by: login, note: r.note ?? null, url: r.url ?? null,
|
|
678
|
+
tokens: r.tokens ?? row.tokens, cost: r.cost ?? row.cost,
|
|
679
|
+
});
|
|
680
|
+
touch(row, when);
|
|
681
|
+
out.reports += 1;
|
|
682
|
+
cause.set(row.run_id, key);
|
|
683
|
+
if (r.state === 'claimed') queue(row, 'run.claimed', 'ev:claimed', login, 'claimed');
|
|
684
|
+
if (r.state !== previous) queue(row, 'run.state', `ev:state:${id}:${key}`, login, r.state);
|
|
685
|
+
} else {
|
|
686
|
+
if (!row.stop_requested_at) Object.assign(row, { stop_requested_at: when, stop_requested_by: login });
|
|
687
|
+
touch(row, when);
|
|
688
|
+
queue(row, 'run.stopRequested', `ev:stop:${id}:${key}`, login, 'stop-requested');
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---- the checks of the pull requests I trust, and the branches of the runs that have none -----------------------------
|
|
693
|
+
for (const row of cur.values()) {
|
|
694
|
+
if (row.pull == null || row.pull_trusted !== 1 || row.pull_state !== 'open' || !row.head_sha || !statusOf(row, at, quietMs).open) continue;
|
|
695
|
+
try {
|
|
696
|
+
const checks = await forge.checks(row.head_sha);
|
|
697
|
+
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';
|
|
698
|
+
if (summary !== row.checks) { row.checks = summary; dirty.add(row.run_id); }
|
|
699
|
+
} catch (err) {
|
|
700
|
+
log(`the checks of #${row.pull} could not be read: ${words(err)}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const branchless = [...cur.values()].filter((r) => r.pull == null);
|
|
704
|
+
if (branchless.length) {
|
|
705
|
+
try {
|
|
706
|
+
const branches = await forge.branches();
|
|
707
|
+
for (const row of branchless) {
|
|
708
|
+
const b = branches.find((x) => !x.protected && runIdFromBranch(x.name) === row.run_id);
|
|
709
|
+
if (!b) continue;
|
|
710
|
+
if (b.name !== row.branch || b.sha !== row.head_sha) {
|
|
711
|
+
Object.assign(row, { branch: b.name, head_sha: b.sha });
|
|
712
|
+
touch(row, iso(at)); // a push: the forge does not say when, so now
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
} catch (err) {
|
|
716
|
+
log(`the branches could not be listed: ${words(err)}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---- kept first, then announced ------------------------------------------------------------------------------------------------
|
|
721
|
+
for (const row of cur.values()) {
|
|
722
|
+
const now2 = statusOf(row, at, quietMs);
|
|
723
|
+
if (now2.finished && !FINISHED.includes(before.get(row.run_id) ?? '')) queue(row, 'run.finished', `ev:finished:${cause.get(row.run_id) ?? now2.status}`, row.reported_by ?? row.pull_by, now2.status);
|
|
724
|
+
}
|
|
725
|
+
/** @type {typeof events} */
|
|
726
|
+
const fresh = [];
|
|
727
|
+
db.exec('BEGIN IMMEDIATE');
|
|
728
|
+
try {
|
|
729
|
+
const mark = db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)');
|
|
730
|
+
for (const key of new Set(audits.map((a) => a.key).filter(Boolean))) {
|
|
731
|
+
const owner = audits.find((a) => a.key === key);
|
|
732
|
+
mark.run(owner.runId, key, iso(at));
|
|
733
|
+
}
|
|
734
|
+
for (const id of dirty) {
|
|
735
|
+
const r = /** @type {RunRow} */ (cur.get(id));
|
|
736
|
+
db.prepare(`UPDATE runs SET state = ?, reported_at = ?, reported_by = ?, note = ?, url = ?, tokens = ?, cost = ?,
|
|
737
|
+
stop_requested_at = COALESCE(stop_requested_at, ?), stop_requested_by = COALESCE(stop_requested_by, ?),
|
|
738
|
+
pull = ?, pull_state = ?, pull_url = ?, pull_by = ?, pull_trusted = ?, linked_via = ?, branch = ?, head_sha = ?, checks = ?,
|
|
739
|
+
last_activity = MAX(last_activity, ?) WHERE run_id = ?`)
|
|
740
|
+
.run(r.state, r.reported_at, r.reported_by, r.note, r.url, r.tokens, r.cost, r.stop_requested_at, r.stop_requested_by,
|
|
741
|
+
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);
|
|
742
|
+
}
|
|
743
|
+
for (const e of events) if (mark.run(e.runId, e.key, iso(at)).changes === 1) fresh.push(e);
|
|
744
|
+
db.exec('COMMIT');
|
|
745
|
+
} catch (err) {
|
|
746
|
+
try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
749
|
+
for (const a of audits) {
|
|
750
|
+
await audit({ login: a.login ?? 'unknown', action: a.answer.linked !== undefined ? 'agent-link' : 'agent-report', target: `${repo}#${a.target}`, request: a.request, status: null, answer: a.answer });
|
|
751
|
+
}
|
|
752
|
+
for (const e of fresh) {
|
|
753
|
+
const row = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(e.runId));
|
|
754
|
+
hooks?.emit(e.event, { ...runPayload(row, e.actor), state: e.state });
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Drops what a crash left: a request that was never posted. Run once at start. */
|
|
760
|
+
async function resume() {
|
|
761
|
+
const db = await auth.store();
|
|
762
|
+
const gone = db.prepare("DELETE FROM runs WHERE comment_id = '' AND requested_at < ?").run(iso(now() - RESERVATION_MS)).changes;
|
|
763
|
+
if (gone) log(`${gone} request${gone === 1 ? '' : 's'} that never reached the forge ${gone === 1 ? 'was' : 'were'} dropped`);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return { request, stop, list, get, follow, hasWork, resume, maxOpen: cap, profiles };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** @typedef {ReturnType<typeof createRuns>} Runs */
|