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