cornfield 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/agent-contract.js +19 -2
- package/core/agent-contract.schema.json +31 -0
- package/core/config.js +34 -5
- package/explorer/explorer.css +49 -4
- package/explorer/forge-panel.js +122 -20
- package/explorer/index.html +10 -0
- package/explorer/layers.js +2 -0
- package/explorer/render.js +8 -0
- package/explorer/runs-map.js +135 -0
- package/explorer/runs-model.js +164 -0
- package/explorer/runs-view.js +101 -0
- package/explorer/state.js +6 -0
- package/package.json +1 -1
- package/server/agent-runs.js +328 -14
- package/server/db.js +26 -0
- package/server/forge-actions.js +1 -1
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +5 -0
- package/server/forge.js +5 -0
- package/server/hooks.js +37 -3
- package/server/server.js +14 -4
- package/server/spec-poll.js +26 -16
- package/server/threads.js +4 -2
- package/server/triggers.js +284 -0
package/server/forge-github.js
CHANGED
|
@@ -233,6 +233,11 @@ export function createGitHubForge(forge, { token = null, fetch, apiUrl, timeoutM
|
|
|
233
233
|
return (await client.list(`repos/${repo}/pulls/${n}/files?per_page=${PER_PAGE}`, { what: `the files of ${pullWhat(n)}` })).map(toFile);
|
|
234
234
|
},
|
|
235
235
|
|
|
236
|
+
async compare(base, head) {
|
|
237
|
+
const { body } = await client.request('GET', `repos/${repo}/compare/${segments(base)}...${segments(head)}`, { what: `the files of ${head} against ${base} in ${forge.repo}` });
|
|
238
|
+
return (Array.isArray(body?.files) ? body.files : []).map(toFile);
|
|
239
|
+
},
|
|
240
|
+
|
|
236
241
|
async pullCommits(n) {
|
|
237
242
|
return (await client.list(`repos/${repo}/pulls/${n}/commits?per_page=${PER_PAGE}`, { what: `the commits of ${pullWhat(n)}` })).map(toCommit);
|
|
238
243
|
},
|
package/server/forge.js
CHANGED
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
* createIssue(issue: { title: string, body: string, labels?: string[] }): Promise<Issue>,
|
|
87
87
|
* pullFiles(n: number): Promise<PullFile[]>,
|
|
88
88
|
* pullCommits(n: number): Promise<PullCommit[]>,
|
|
89
|
+
* compare(base: string, head: string): Promise<PullFile[]>, // the files a branch changes against another (optional: a forge that cannot list them throws `invalid`)
|
|
89
90
|
* comments(n: number, opts?: { since?: string }): Promise<Comment[]>,
|
|
90
91
|
* comment(n: number, body: string): Promise<Comment>,
|
|
91
92
|
* checks(ref: string): Promise<Check[]>,
|
|
@@ -280,6 +281,10 @@ export function wrap(a, forge) {
|
|
|
280
281
|
},
|
|
281
282
|
pullFiles: async (n) => a.pullFiles(number(n)),
|
|
282
283
|
pullCommits: async (n) => a.pullCommits(number(n)),
|
|
284
|
+
compare: async (base, head) => {
|
|
285
|
+
if (typeof a.compare !== 'function') throw invalid('This forge does not list the files of a comparison.');
|
|
286
|
+
return a.compare(text(base, 'The base branch'), text(head, 'The branch'));
|
|
287
|
+
},
|
|
283
288
|
comments: async (n, opts) => {
|
|
284
289
|
if (opts !== undefined && (opts === null || typeof opts !== 'object' || Array.isArray(opts))) throw invalid('The options are an object.');
|
|
285
290
|
const since = opts && opts.since;
|
package/server/hooks.js
CHANGED
|
@@ -25,10 +25,18 @@
|
|
|
25
25
|
* `{ event, at, actor, expiresAt, mapUrl }`
|
|
26
26
|
* agent.requested a maintainer asked for an agent run (server/agent-runs.js). Its body is its own: `{ event, at, request, actor, mapUrl }`,
|
|
27
27
|
* `request` being the whole request JSON of the contract (docs/agent-contract.md), which holds no credential by construction
|
|
28
|
+
* trigger.skipped an opt-in trigger (`agents.on`, server/triggers.js) was going to request a run and did not: the maintainer whose act caused it has no
|
|
29
|
+
* live sign-in (`reason: 'no-session'`), or the request was refused (the cap, the one-run-per-subject rule, the rate limit: the refusal's code).
|
|
30
|
+
* Its body is its own: `{ event, at, trigger: { event, by, ref }, by, reason, mapUrl }`
|
|
28
31
|
* run.claimed run.state run.stopRequested run.finished a run was claimed by its runner, changed state, was asked to stop, ended. They are
|
|
29
32
|
* seen when cornfield looks (the spec poll, server/spec-poll.js), each once. Their body is their own:
|
|
30
33
|
* `{ event, at, runId, state, pull?, issue?, actor, mapUrl }`
|
|
31
34
|
*
|
|
35
|
+
* **Triggers listen in-process.** `subscribe(fn)` hands a function the same moments the hooks fire at, with the payload resolved, whether or not
|
|
36
|
+
* any hook asks for them (server/triggers.js: `spec.approved`, `build.opened`, `thread.message`). A thread's payload then also holds the
|
|
37
|
+
* comment's text and times for that function alone; `bodyOf` picks fixed fields, so none of it can leave through a hook. A subscriber never
|
|
38
|
+
* slows or breaks a delivery, and a failing one is only logged.
|
|
39
|
+
*
|
|
32
40
|
* **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
|
|
33
41
|
* queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
|
|
34
42
|
* (four attempts in all) and every attempt is a row in the `actions` audit table (`action: 'hook'`). A hook that is
|
|
@@ -135,6 +143,9 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
135
143
|
if (typeof secret !== 'string' || secret.length < MIN_HOOK_SECRET) throw new Error(`${HOOK_SECRET_ENV} must be at least ${MIN_HOOK_SECRET} characters.`);
|
|
136
144
|
const targets = hooks.map((h, i) => ({ index: i, url: h.url, origin: originOf(h.url), events: h.events === '*' ? HOOK_EVENTS : h.events }));
|
|
137
145
|
const timers = new Set();
|
|
146
|
+
/** @type {{ fn: (event: string, payload: any) => unknown, events: string[] | null }[]} */
|
|
147
|
+
const subscribers = [];
|
|
148
|
+
const listeners = (event) => subscribers.filter((s) => s.events === null || s.events.includes(event));
|
|
138
149
|
let pending = 0;
|
|
139
150
|
let closed = false;
|
|
140
151
|
|
|
@@ -200,6 +211,11 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
200
211
|
function bodyOf(event, p) {
|
|
201
212
|
// A session's end has no spec, pull request or proposal: its body is only who, until when, and where cornfield is
|
|
202
213
|
if (event === 'session.expiring') return JSON.stringify({ event, at: new Date(now()).toISOString(), actor: p.actor ?? null, expiresAt: p.expiresAt ?? null, mapUrl: publicUrl || null });
|
|
214
|
+
// A skipped trigger says which trigger, who caused it and why it did nothing: never a brief or any text
|
|
215
|
+
if (event === 'trigger.skipped') {
|
|
216
|
+
const t = p.trigger && typeof p.trigger === 'object' ? { event: p.trigger.event ?? null, by: p.trigger.by ?? null, ref: p.trigger.ref ?? null } : null;
|
|
217
|
+
return JSON.stringify({ event, at: new Date(now()).toISOString(), trigger: t, by: p.by ?? null, reason: p.reason ?? null, mapUrl: publicUrl || null });
|
|
218
|
+
}
|
|
203
219
|
// A request goes out as it was posted, and is checked once more on the way: nothing like a credential can leave through a hook
|
|
204
220
|
if (event === 'agent.requested') {
|
|
205
221
|
const checked = validateRequest(p.request);
|
|
@@ -244,10 +260,17 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
244
260
|
try {
|
|
245
261
|
if (closed || !HOOK_EVENTS.includes(event)) return;
|
|
246
262
|
const to = targets.filter((t) => t.events.includes(event));
|
|
247
|
-
|
|
263
|
+
// Nobody wants it: the payload is not even worked out (it may read the forge)
|
|
264
|
+
const local = listeners(event);
|
|
265
|
+
if (!to.length && !local.length) return;
|
|
248
266
|
Promise.resolve()
|
|
249
267
|
.then(() => (typeof payload === 'function' ? payload() : payload))
|
|
250
268
|
.then((p) => {
|
|
269
|
+
// In-process listeners first, each on its own: they cannot hold up or break a delivery
|
|
270
|
+
for (const { fn } of local) {
|
|
271
|
+
Promise.resolve().then(() => fn(event, p || {})).catch((err) => console.error(`[cornfield] a listener of ${event} failed: ${err instanceof Error ? err.message : err}`));
|
|
272
|
+
}
|
|
273
|
+
if (!to.length) return;
|
|
251
274
|
const body = bodyOf(event, p || {});
|
|
252
275
|
for (const t of to) {
|
|
253
276
|
if (pending >= MAX_PENDING) {
|
|
@@ -267,6 +290,17 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
267
290
|
}
|
|
268
291
|
}
|
|
269
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Listens in-process to every event `emit` is given (a trigger does: server/triggers.js). `fn(event, payload)` gets the payload
|
|
295
|
+
* resolved; it may be async, is never awaited and its failure is only logged. With `events`, only those: an event nobody listens to and no
|
|
296
|
+
* hook asks for has its payload left unworked out.
|
|
297
|
+
* @param {(event: string, payload: any) => unknown} fn
|
|
298
|
+
* @param {string[]} [events]
|
|
299
|
+
*/
|
|
300
|
+
function subscribe(fn, events) {
|
|
301
|
+
if (typeof fn === 'function') subscribers.push({ fn, events: Array.isArray(events) ? [...events] : null });
|
|
302
|
+
}
|
|
303
|
+
|
|
270
304
|
/**
|
|
271
305
|
* `spec.ready`: the spec PRs a listing just showed. A (pull request, head, proposal) that is not a draft and was not
|
|
272
306
|
* seen before is announced once, and remembered in the SQLite file so a restart does not announce it again. A draft is
|
|
@@ -345,7 +379,7 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
345
379
|
emit('thread.linked', { thread: t.number, actor: t.opened.actor, nodes: t.opened.linked.nodes, proposal: t.opened.linked.proposal });
|
|
346
380
|
}
|
|
347
381
|
for (const m of t.messages) {
|
|
348
|
-
if (fresh(t.number, String(m.id), m.stamp)) emit('thread.message', { thread: t.number, comment: m.id, actor: m.actor });
|
|
382
|
+
if (fresh(t.number, String(m.id), m.stamp)) emit('thread.message', { thread: t.number, comment: m.id, actor: m.actor, message: m.text });
|
|
349
383
|
if (m.linked && fresh(t.number, `link:${m.id}`)) emit('thread.linked', { thread: t.number, comment: m.id, actor: m.actor, nodes: m.linked.nodes, proposal: m.linked.proposal });
|
|
350
384
|
}
|
|
351
385
|
// The last-update time moves with every look that read the thread, so the next look can skip it when nothing changed
|
|
@@ -395,7 +429,7 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
|
|
|
395
429
|
timers.clear();
|
|
396
430
|
}
|
|
397
431
|
|
|
398
|
-
return { emit, specsSeen, threadCursors, threadsSeen, watchSessions, close };
|
|
432
|
+
return { emit, subscribe, specsSeen, threadCursors, threadsSeen, watchSessions, close };
|
|
399
433
|
}
|
|
400
434
|
|
|
401
435
|
/** The SHA-256 of a proposal file as `read` gives it, or null when it cannot be read (an event still goes out) */
|
package/server/server.js
CHANGED
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
import fs from 'node:fs';
|
|
47
47
|
import http from 'node:http';
|
|
48
48
|
import path from 'node:path';
|
|
49
|
-
import { createHash } from 'node:crypto';
|
|
49
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
50
50
|
import { fileURLToPath } from 'node:url';
|
|
51
|
-
import { browserSources, BUILTIN_SOURCES, parseDuration, specPollMs, DEFAULTS, hookWants, ideaLabel } from '../core/config.js';
|
|
51
|
+
import { browserSources, BUILTIN_SOURCES, parseDuration, specPollMs, triggersOf, DEFAULTS, hookWants, ideaLabel } from '../core/config.js';
|
|
52
52
|
import { CLOUD_FILE } from '../core/cloud.js';
|
|
53
53
|
import { decodePath, inside, notFound, sendFile } from './files.js';
|
|
54
54
|
import { serveProposals } from './proposals.js';
|
|
@@ -65,6 +65,7 @@ import { createAuth, signInSettings } from './auth.js';
|
|
|
65
65
|
import { createHooks, hooksSettings } from './hooks.js';
|
|
66
66
|
import { createLander } from './lander.js';
|
|
67
67
|
import { createSpecPoll } from './spec-poll.js';
|
|
68
|
+
import { createTriggers } from './triggers.js';
|
|
68
69
|
|
|
69
70
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
70
71
|
const EXPLORER_DIR = path.join(CORNFIELD_DIR, 'explorer');
|
|
@@ -122,6 +123,8 @@ export function clientConfig(config, signIn = false) {
|
|
|
122
123
|
// Threads (docs/threads.md): the idea label, or false when `threads: false` turns the Threads tab off
|
|
123
124
|
threads: config.threads === false ? false : { label: ideaLabel(config) },
|
|
124
125
|
sources: browserSources(config),
|
|
126
|
+
// Agent runs are followed (and marked on the map) where there is a forge and a sign-in (docs/agents.md)
|
|
127
|
+
...(config.forge && signIn ? { agents: { runs: true } } : {}),
|
|
125
128
|
signIn,
|
|
126
129
|
};
|
|
127
130
|
}
|
|
@@ -150,8 +153,13 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
150
153
|
const auth = createAuth(config, opts);
|
|
151
154
|
// Hooks listed without a secret (or without sign-in) are a startup error, like a half-configured sign-in
|
|
152
155
|
const hookSettings = hooksSettings(config, env, auth.enabled);
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
// Opt-in triggers (agents.on, server/triggers.js) act as the maintainer whose act caused them and read the forge: both need sign-in and a forge
|
|
157
|
+
const triggerList = triggersOf(config);
|
|
158
|
+
if (triggerList.length && !auth.enabled) throw new Error('The config says agents.on, but sign-in is not set up. A trigger requests a run as the maintainer whose act caused it, with their own sign-in, so without sign-in nothing would ever fire: set up sign-in (docs/sign-in.md), or remove agents.on.');
|
|
159
|
+
if (triggerList.length && !(config.forge && hasAdapter(config.forge.type))) throw new Error('The config says agents.on, but there is no forge to ask on. Set up `forge` (docs/config.md#forge), or remove agents.on.');
|
|
160
|
+
// With triggers and no hook listed, the hooks object has no destination: it is only the moments the triggers subscribe to (and the thread look's memory)
|
|
161
|
+
const hooks = hookSettings || triggerList.length
|
|
162
|
+
? createHooks({ hooks: hookSettings ? config.hooks : [], secret: hookSettings ? hookSettings.secret : randomBytes(32).toString('hex'), fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
|
|
155
163
|
: null;
|
|
156
164
|
hooks?.watchSessions();
|
|
157
165
|
// Landing on approval needs somebody signed in to land as, like the hooks' events: a half-set key is a startup error
|
|
@@ -174,6 +182,8 @@ export function createCornfieldServer(config, opts = {}) {
|
|
|
174
182
|
const transport = config.agents?.transport ?? 'forge';
|
|
175
183
|
if (transport !== 'forge' && !runs) throw new Error(`agents.transport is "${transport}", which needs sign-in and a forge to ask as a maintainer and to check them: set them up (docs/sign-in.md, docs/config.md#forge).`);
|
|
176
184
|
if (transport === 'push' && !hookWants(config, ['agent.requested'])) throw new Error('agents.transport is "push", so a hook must subscribe to agent.requested: add { url, events: ["agent.requested"] } to "hooks" (docs/config.md#hooks). Without one a request would go nowhere.');
|
|
185
|
+
// Triggers subscribe to the hooks' moments and call the runs' own request path; nothing a run produces is wired to them (server/triggers.js)
|
|
186
|
+
if (runs && hooks && triggerList.length) createTriggers({ config, auth, runs, hooks, now: opts.now });
|
|
177
187
|
runs?.resume().catch((err) => console.error(`[cornfield] agent runs could not be picked up: ${err instanceof Error ? err.message : err}`));
|
|
178
188
|
// Looks at the open spec PRs on a timer when a hook wants spec.ready (config `hookPoll`); started when the server listens. The same timer follows
|
|
179
189
|
// agent runs: it is started for them when there is a run to follow, or as one is asked for, and does nothing while there is none
|
package/server/spec-poll.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
import { isMaintainer } from './forge.js';
|
|
37
37
|
import { listPullProposals } from './proposal-pulls.js';
|
|
38
38
|
import { gatherThreads, snapshotOf, POLL_LIMIT } from './threads.js';
|
|
39
|
-
import { hookWants, threadsOn, THREAD_EVENTS } from '../core/config.js';
|
|
39
|
+
import { hookWants, threadsOn, triggersOf, THREAD_EVENTS } from '../core/config.js';
|
|
40
40
|
import { readTopology } from '../core/validate.js';
|
|
41
41
|
|
|
42
42
|
/** How long after start the first look is made: soon, so the repo's silent first look is not left to the first interval */
|
|
@@ -47,6 +47,26 @@ export const MAX_CANDIDATES = 5;
|
|
|
47
47
|
const iso = (ms) => new Date(ms).toISOString();
|
|
48
48
|
const words = (err) => (err instanceof Error ? err.message : String(err));
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The forge as the newest live sign-in of a user who is a maintainer now, and their login, or null when there is none: the sign-ins are
|
|
52
|
+
* tried newest first, at most `MAX_CANDIDATES` users, and a token the forge refuses passes to the next. The token is only asked who it is and
|
|
53
|
+
* what it may do on the repo. Shared with the triggers (server/triggers.js), which confirm a commenter with it.
|
|
54
|
+
* @param {{ auth: { store: () => Promise<import('node:sqlite').DatabaseSync>, forgeForUser: (userId: string) => Promise<import('./forge.js').Forge> }, repo: string, now?: () => number }} opts
|
|
55
|
+
* @returns {Promise<{ forge: import('./forge.js').Forge, login: string } | null>}
|
|
56
|
+
*/
|
|
57
|
+
export async function findMaintainerForge({ auth, repo, now = Date.now }) {
|
|
58
|
+
const rows = /** @type {{ user_id: string }[]} */ ((await auth.store()).prepare("SELECT user_id FROM sessions WHERE token <> '' AND expires_at > ? ORDER BY created_at DESC, rowid DESC").all(iso(now())));
|
|
59
|
+
const users = [...new Set(rows.map((r) => r.user_id))].slice(0, MAX_CANDIDATES);
|
|
60
|
+
for (const userId of users) {
|
|
61
|
+
try {
|
|
62
|
+
const forge = await auth.forgeForUser(userId);
|
|
63
|
+
const me = await forge.me();
|
|
64
|
+
if (String(me.id) === userId && isMaintainer(await forge.permission(repo, me.login))) return { forge, login: me.login };
|
|
65
|
+
} catch { /* this user's token is gone or refused: the next one */ }
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
/**
|
|
51
71
|
* @param {{
|
|
52
72
|
* config: any,
|
|
@@ -74,19 +94,9 @@ export function createSpecPoll({ config, where, auth, hooks, runs = null, specs
|
|
|
74
94
|
/** Whose token the last look used: the log names them each time that changes, so whose sign-in is read with is on record */
|
|
75
95
|
let lookingAs = null;
|
|
76
96
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const users = [...new Set(rows.map((r) => r.user_id))].slice(0, MAX_CANDIDATES);
|
|
81
|
-
for (const userId of users) {
|
|
82
|
-
try {
|
|
83
|
-
const forge = await auth.forgeForUser(userId);
|
|
84
|
-
const me = await forge.me();
|
|
85
|
-
if (String(me.id) === userId && isMaintainer(await forge.permission(repo, me.login))) return { forge, login: me.login };
|
|
86
|
-
} catch { /* this user's token is gone or refused: the next one */ }
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
97
|
+
/** Whether a look reads the threads: a hook wants a thread.* event, or a trigger listens for `/agent` in a message (server/triggers.js) */
|
|
98
|
+
const wantsThreads = () => threadsOn(config) && (hookWants(config, THREAD_EVENTS) || triggersOf(config).some(([e]) => e === 'thread.message'));
|
|
99
|
+
const maintainerForge = () => findMaintainerForge({ auth, repo, now });
|
|
90
100
|
|
|
91
101
|
/** The checkout's nodes, for a hook's `nodes` to be the ones a mention resolves to; none when there is no map */
|
|
92
102
|
function mapNodes() {
|
|
@@ -133,13 +143,13 @@ export function createSpecPoll({ config, where, auth, hooks, runs = null, specs
|
|
|
133
143
|
}
|
|
134
144
|
if (noSession) log('a signed-in maintainer is back; checking the open spec PRs again');
|
|
135
145
|
noSession = false;
|
|
136
|
-
if (found.login !== lookingAs) log(`looking at the open spec PRs${
|
|
146
|
+
if (found.login !== lookingAs) log(`looking at the open spec PRs${wantsThreads() ? ' and threads' : ''} with the sign-in of @${found.login}, a maintainer`);
|
|
137
147
|
lookingAs = found.login;
|
|
138
148
|
let out = 'ok';
|
|
139
149
|
if (specs) {
|
|
140
150
|
try {
|
|
141
151
|
const specReady = hookWants(config, ['spec.ready']);
|
|
142
|
-
const threads =
|
|
152
|
+
const threads = wantsThreads();
|
|
143
153
|
// One listing of the open PRs serves both: the threads' look makes it itself when it runs
|
|
144
154
|
const problem = threads ? await lookAtThreads(found.forge, specReady) : (await listPullProposals(found.forge, where, { hooks })).problem;
|
|
145
155
|
if (problem) {
|
package/server/threads.js
CHANGED
|
@@ -256,7 +256,8 @@ const linkOf = (text, nodes) => {
|
|
|
256
256
|
|
|
257
257
|
/**
|
|
258
258
|
* What `hooks.threadsSeen` is handed: for each thread, who opened it, its comments (their id, author, own time and what they
|
|
259
|
-
* link to, resolved against `nodes`), and the spec PRs that name it.
|
|
259
|
+
* link to, resolved against `nodes`), and the spec PRs that name it. A hook says that something was said, not what: the text rides along
|
|
260
|
+
* as `text` for a trigger's in-process listener only, and no hook body holds it.
|
|
260
261
|
* @param {{ threads: any[], pulls: any[] }} got `gatherThreads`' answer
|
|
261
262
|
* @param {{ id: string, file?: string }[]} nodes the checkout's map
|
|
262
263
|
*/
|
|
@@ -268,7 +269,8 @@ export function snapshotOf(got, nodes) {
|
|
|
268
269
|
stamp: t.item.updatedAt,
|
|
269
270
|
unchanged: Boolean(t.unchanged),
|
|
270
271
|
opened: t.kind === 'idea' ? { actor: t.item.author ? t.item.author.login : null, linked: linkOf(t.item.body, nodes) } : null,
|
|
271
|
-
|
|
272
|
+
// `text` is for the in-process listeners of thread.message only (an opt-in trigger reads /agent in it): bodyOf never lets it into a hook's body
|
|
273
|
+
messages: t.comments.map((c) => ({ id: c.id, actor: c.author ? c.author.login : null, stamp: c.updatedAt, linked: linkOf(c.body, nodes), text: { body: c.body, createdAt: c.createdAt, updatedAt: c.updatedAt, bot: Boolean(c.author && c.author.bot) } })),
|
|
272
274
|
})),
|
|
273
275
|
pulls: got.pulls,
|
|
274
276
|
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module server/triggers
|
|
3
|
+
*
|
|
4
|
+
* Opt-in triggers (issue #83, spec 0005 D10, docs/agents.md#triggers): a run asked for by something a maintainer did, with no click.
|
|
5
|
+
* Off by default, and set like landing on approval is:
|
|
6
|
+
*
|
|
7
|
+
* agents: { profiles: ['claude'], on: { 'spec.approved': { kind: 'code', profile: 'claude' } } }
|
|
8
|
+
*
|
|
9
|
+
* **One internal path.** A trigger subscribes in-process to the moments the hooks fire at (`hooks.subscribe`, server/hooks.js; not over
|
|
10
|
+
* HTTP) and calls the very function the button calls, `runs.request` (server/agent-runs.js), as the maintainer whose act caused it, with
|
|
11
|
+
* THEIR sign-in (`auth.forgeForUser`), so the request comment is theirs and the forge keeps the record. The cap, the rate limit and
|
|
12
|
+
* the one-run-per-subject rule are the request's own, so they apply unchanged. A trigger needs no hook to be configured: the server
|
|
13
|
+
* makes the hooks object anyway, with no destination, so there is a moment to subscribe to.
|
|
14
|
+
*
|
|
15
|
+
* **Only three events, each a maintainer's act.**
|
|
16
|
+
*
|
|
17
|
+
* spec.approved a maintainer approved a proposal (the button or the review API) request: kind, about the proposal, posted on its spec PR
|
|
18
|
+
* build.opened a maintainer opened the build issue of an approved proposal request: about that issue
|
|
19
|
+
* thread.message a comment with a line that starts `/agent`, optionally with a brief request: about that thread (the issue or spec PR)
|
|
20
|
+
*
|
|
21
|
+
* `/agent` is honoured only from a maintainer the forge confirms NOW, only in a comment that was not edited, and only once per comment
|
|
22
|
+
* (a `thread_seen` row keyed `trigger:…`, so a restart never repeats it; the table's other readers skip keys with a colon). The rest of
|
|
23
|
+
* the line is the brief. A line inside a code span or a fenced block is never a command, nor one in a quote or an indented block.
|
|
24
|
+
* Every automatic request records its cause in the request itself (`trigger: { event, by, ref }`, core/agent-contract.js).
|
|
25
|
+
*
|
|
26
|
+
* **No chaining: a run's OUTPUT never causes a run.** In three layers:
|
|
27
|
+
* 1. Structural: the only events handled are the three above. Nothing a run produces (a push, a pull request, a check, a report, a run's
|
|
28
|
+
* state change: `run.*`, `agent.requested`) is wired to a trigger; `server/agent-runs.js` does not know this module exists.
|
|
29
|
+
* 2. Who: no trigger fires when the actor is a login in `agents.runners` or a bot account ("[bot]", or the forge's Bot type), or the
|
|
30
|
+
* comment holds a `cornfield-run` block.
|
|
31
|
+
* 3. What: no trigger fires on a run's output: a pull request cornfield holds as a run's linked one (in any state, the run finished or
|
|
32
|
+
* not), or a pull request or issue whose text or branch carries a run id (`Cornfield-Run:`) or whose author is a runner or a bot.
|
|
33
|
+
* Each posts nothing and sends no hook, but writes a `trigger-ignored` audit row with the reason (`machine`, `run-block`, `edited`,
|
|
34
|
+
* `run-output`), so a maintainer can see why nothing happened. A comment that holds no `/agent` command is not a cause at all and writes
|
|
35
|
+
* nothing.
|
|
36
|
+
*
|
|
37
|
+
* **A run's TARGET is not its output.** The thread, issue or spec pull request a run was requested on may fire again once every run on it is
|
|
38
|
+
* over (finished, or quiet: what the cap counts). While one is open, the trigger is skipped with `run-open` (`trigger-skipped` and the
|
|
39
|
+
* hook), whatever its profile; the one-run-per-subject rule of the request path only clashes within a profile.
|
|
40
|
+
*
|
|
41
|
+
* **approveLands.** With `proposals.approveLands`, an approval may land the spec pull request before the trigger's request reads it; the
|
|
42
|
+
* request then finds no open proposal and the trigger is skipped with `not-found` (audited, and the hook). The proposal is on the default
|
|
43
|
+
* branch by then: ask for the run with the button, or use the `build.opened` trigger, which fires after the build issue is opened.
|
|
44
|
+
*
|
|
45
|
+
* **No live sign-in.** If the maintainer has no live session, the trigger does nothing else (cornfield holds no forge token of its own, and
|
|
46
|
+
* another maintainer's would put words in their name): an audit row (`trigger-skipped`), the hook event `trigger.skipped`
|
|
47
|
+
* (`reason: 'no-session'`), and the dashboard's notice on the Agents tab, which reads the last 24 hours of those rows
|
|
48
|
+
* (`runs.list().triggerSkipped`). A request the cap or a limit refuses ends the same way with that refusal's code: nothing is posted.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { ForgeError, isMaintainer } from './forge.js';
|
|
52
|
+
import { triggersOf } from '../core/config.js';
|
|
53
|
+
import { EDIT_GRACE_MS, MAX_BRIEF_CHARS, RUN_TAG, TRIGGER_EVENTS, findRunId } from '../core/agent-contract.js';
|
|
54
|
+
import { Refused } from './throttle.js';
|
|
55
|
+
import { findMaintainerForge } from './spec-poll.js';
|
|
56
|
+
|
|
57
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
58
|
+
const lower = (s) => String(s).toLowerCase();
|
|
59
|
+
const words = (err) => (err instanceof Error ? err.message : String(err));
|
|
60
|
+
|
|
61
|
+
/** A bot account by its name: GitHub's apps are "name[bot]"; the forge's own Bot type rides in `Author.bot` */
|
|
62
|
+
export const isBotLogin = (login) => /\[bot\]$/i.test(String(login));
|
|
63
|
+
|
|
64
|
+
/** A run's own block in a comment: a fence whose info string starts `cornfield-run` (a trigger never fires on one, whoever wrote it) */
|
|
65
|
+
const RUN_FENCE = new RegExp(`^[ \\t]{0,3}(?:\`{3,}|~{3,})[ \\t]*${RUN_TAG}\\b`, 'mi');
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The `/agent` command of a comment: the first line that starts with `/agent` (up to three spaces in, as Markdown allows) and has
|
|
69
|
+
* nothing glued to it, outside fenced code and not in a quote. The rest of that line, trimmed, is the brief. A command inside a code span is no
|
|
70
|
+
* command (the span is not at the start of the line, so the line does not start with the command); one inside a fence, a quote or an
|
|
71
|
+
* indented block is not read at all.
|
|
72
|
+
* @param {string} text
|
|
73
|
+
* @returns {{ brief: string } | null}
|
|
74
|
+
*/
|
|
75
|
+
export function agentCommand(text) {
|
|
76
|
+
if (typeof text !== 'string') return null;
|
|
77
|
+
let fence = null;
|
|
78
|
+
for (const line of text.split(/\r?\n/)) {
|
|
79
|
+
if (fence) {
|
|
80
|
+
// A fence closes with a line of the same mark, at least as long, and nothing else
|
|
81
|
+
const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
82
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
86
|
+
// A backtick fence's info string holds no backtick: ```x``` on one line is a code span, not a fence
|
|
87
|
+
if (open && !(open[1][0] === '`' && line.slice(line.indexOf(open[1]) + open[1].length).includes('`'))) { fence = open[1]; continue; }
|
|
88
|
+
const m = /^ {0,3}\/agent(?=[ \t]|$)(.*)$/.exec(line);
|
|
89
|
+
if (m) return { brief: m[1].trim().slice(0, MAX_BRIEF_CHARS) };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The reason a refused request gives in `trigger.skipped` and the audit row: the refusal's own code */
|
|
95
|
+
function reasonOf(err) {
|
|
96
|
+
if (err instanceof ForgeError) return err.code === 'unauthorized' ? 'no-session' : err.code || 'forge';
|
|
97
|
+
if (err instanceof Refused) return err.code || 'refused';
|
|
98
|
+
return 'error';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {{
|
|
103
|
+
* config: any,
|
|
104
|
+
* auth: { store: () => Promise<import('node:sqlite').DatabaseSync>, record: Function, forgeForUser: (userId: string) => Promise<import('./forge.js').Forge> },
|
|
105
|
+
* runs: { request: Function },
|
|
106
|
+
* hooks: import('./hooks.js').Hooks,
|
|
107
|
+
* now?: () => number,
|
|
108
|
+
* }} opts
|
|
109
|
+
*/
|
|
110
|
+
export function createTriggers({ config, auth, runs, hooks, now = Date.now }) {
|
|
111
|
+
const repo = config.forge.repo;
|
|
112
|
+
const agents = config.agents || {};
|
|
113
|
+
const rules = new Map(triggersOf(config));
|
|
114
|
+
const runnerSet = new Set((Array.isArray(agents.runners) ? agents.runners : []).map(lower));
|
|
115
|
+
const log = (what) => console.error(`[cornfield] triggers: ${what}`);
|
|
116
|
+
/** One event at a time, in the order they came: the cap is checked inside the request, and a test can wait for the queue */
|
|
117
|
+
let queue = Promise.resolve();
|
|
118
|
+
|
|
119
|
+
/** Whether an account is a runner or a bot: what it does is a run's output, never a cause */
|
|
120
|
+
const machine = (login, bot = false) => bot === true || isBotLogin(login) || runnerSet.has(lower(login));
|
|
121
|
+
|
|
122
|
+
/** Whether cornfield holds this number as a run's linked pull request: a run's output, in any state, the run finished or not */
|
|
123
|
+
async function runOutput(numbers) {
|
|
124
|
+
const db = await auth.store();
|
|
125
|
+
const ask = db.prepare('SELECT 1 FROM runs WHERE repo = ? AND pull = ? LIMIT 1');
|
|
126
|
+
return numbers.some((n) => Number.isInteger(n) && ask.get(repo, n) !== undefined);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a pull request or issue is a run's output by what it says: a run id in its text or branch, or an author that is a runner or a bot.
|
|
131
|
+
* Read with the person's own forge. Fails closed: what cannot be read is treated as a run's.
|
|
132
|
+
*/
|
|
133
|
+
async function outputOfRun(forge, n) {
|
|
134
|
+
try {
|
|
135
|
+
let item;
|
|
136
|
+
try {
|
|
137
|
+
item = await forge.pull(n);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (!(err instanceof ForgeError) || err.code !== 'not-found') throw err;
|
|
140
|
+
item = await forge.issue(n);
|
|
141
|
+
}
|
|
142
|
+
if (item.author && machine(item.author.login, item.author.bot === true)) return true;
|
|
143
|
+
return findRunId({ body: item.body, branch: item.head?.ref }) !== null;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
log(`#${n} could not be read, so no trigger fires on it: ${words(err)}`);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A live sign-in's user id for a login, or null */
|
|
151
|
+
async function sessionOf(login) {
|
|
152
|
+
const db = await auth.store();
|
|
153
|
+
const row = /** @type {{ user_id: string } | undefined} */ (db.prepare("SELECT user_id FROM sessions WHERE lower(login) = ? AND token <> '' AND expires_at > ? ORDER BY created_at DESC, rowid DESC LIMIT 1").get(lower(login), iso(now())));
|
|
154
|
+
return row ? row.user_id : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Remembers that this cause was acted on; false if it was already (a repeat, or a restart) */
|
|
158
|
+
async function first(number, key) {
|
|
159
|
+
const db = await auth.store();
|
|
160
|
+
return db.prepare('INSERT OR IGNORE INTO thread_seen (repo, issue, comment, stamp, at) VALUES (?, ?, ?, ?, ?)').run(repo, number, key, '', iso(now())).changes === 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A no-chaining exit: an audit row (`trigger-ignored`, with the reason) and nothing else, no hook and nothing posted, so a maintainer can
|
|
165
|
+
* see why an act of theirs started nothing. Never the comment's text: only the event, who and the reference.
|
|
166
|
+
*/
|
|
167
|
+
async function ignore(cause, reason) {
|
|
168
|
+
try {
|
|
169
|
+
await auth.record({ login: cause.by, action: 'trigger-ignored', target: `${repo}#${cause.subject}`, request: { event: cause.event, by: cause.by, ref: cause.ref }, status: null, answer: { reason } });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
log(`an ignored trigger could not be written to the audit table: ${words(err)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The trigger did nothing, and says why: an audit row and the `trigger.skipped` hook event. It posts nothing. */
|
|
176
|
+
async function skip(cause, reason) {
|
|
177
|
+
try {
|
|
178
|
+
await auth.record({ login: cause.by, action: 'trigger-skipped', target: `${repo}#${cause.subject}`, request: { event: cause.event, by: cause.by, ref: cause.ref }, status: null, answer: { reason } });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
log(`a skipped trigger could not be written to the audit table: ${words(err)}`);
|
|
181
|
+
}
|
|
182
|
+
hooks.emit('trigger.skipped', { trigger: { event: cause.event, by: cause.by, ref: cause.ref }, by: cause.by, reason });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** What caused a trigger, from a hook event's payload; null when it is not one a trigger may fire on */
|
|
186
|
+
function causeOf(event, p) {
|
|
187
|
+
if (event === 'spec.approved') {
|
|
188
|
+
if (typeof p.actor !== 'string' || !Number.isInteger(p.pull) || typeof p.slug !== 'string') return null;
|
|
189
|
+
return { event, by: p.actor, ref: `pull:${p.pull}`, subject: p.pull, owned: [p.pull], checked: [p.pull], refs: { proposal: p.slug }, key: `trigger:spec.approved:${p.headSha ?? ''}`, brief: '' };
|
|
190
|
+
}
|
|
191
|
+
if (event === 'build.opened') {
|
|
192
|
+
if (typeof p.actor !== 'string' || !Number.isInteger(p.issue)) return null;
|
|
193
|
+
return { event, by: p.actor, ref: `issue:${p.issue}`, subject: p.issue, owned: [p.issue, p.pull], checked: [p.pull, p.issue], refs: { issue: p.issue }, key: `trigger:build.opened:${p.issue}`, brief: '' };
|
|
194
|
+
}
|
|
195
|
+
if (event === 'thread.message') {
|
|
196
|
+
const m = p.message;
|
|
197
|
+
if (!m || typeof m.body !== 'string' || typeof p.actor !== 'string' || !Number.isInteger(p.thread) || p.comment === undefined) return null;
|
|
198
|
+
return { event, by: p.actor, ref: `comment:${p.comment}`, subject: p.thread, owned: [p.thread], checked: [p.thread], refs: { thread: p.thread }, key: `trigger:comment:${p.comment}`, message: m, brief: '' };
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function handle(event, p) {
|
|
204
|
+
const rule = rules.get(event);
|
|
205
|
+
if (!rule) return;
|
|
206
|
+
const cause = causeOf(event, p);
|
|
207
|
+
if (!cause) return;
|
|
208
|
+
|
|
209
|
+
// --- a comment with no /agent command is no cause at all: nothing, not even a row (most thread messages are this)
|
|
210
|
+
const command = cause.message ? agentCommand(cause.message.body) : null;
|
|
211
|
+
if (cause.message && !command) return;
|
|
212
|
+
// --- who: a runner or a bot never causes anything; a run's own block is no command; an edited comment is not trusted
|
|
213
|
+
if (machine(cause.by) || cause.message?.bot === true) return ignore(cause, 'machine');
|
|
214
|
+
if (cause.message) {
|
|
215
|
+
const m = cause.message;
|
|
216
|
+
if (RUN_FENCE.test(m.body)) return ignore(cause, 'run-block');
|
|
217
|
+
// Both forges give the comment's own created and updated times; one missing or unreadable counts as edited (fails closed)
|
|
218
|
+
if (!(Date.parse(m.updatedAt) - Date.parse(m.createdAt) <= EDIT_GRACE_MS)) return ignore(cause, 'edited');
|
|
219
|
+
cause.brief = command.brief;
|
|
220
|
+
}
|
|
221
|
+
// --- what: a run's output (its linked pull request) never fires, in any state
|
|
222
|
+
if (await runOutput(cause.owned)) return ignore(cause, 'run-output');
|
|
223
|
+
// --- once: a cause is acted on one time, across restarts
|
|
224
|
+
if (!(await first(cause.subject, cause.key))) return;
|
|
225
|
+
|
|
226
|
+
// --- a command is honoured only from a maintainer the forge confirms now (the others get nothing, not even a row)
|
|
227
|
+
if (cause.message) {
|
|
228
|
+
const found = await findMaintainerForge({ auth, repo, now });
|
|
229
|
+
if (!found) { log('no signed-in maintainer to confirm who wrote an /agent comment, so it was not honoured'); return; }
|
|
230
|
+
let permission;
|
|
231
|
+
try {
|
|
232
|
+
permission = await found.forge.permission(repo, cause.by);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
log(`the permission of ${cause.by} could not be read, so their /agent comment was not honoured: ${words(err)}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!isMaintainer(permission)) return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- a run's target may fire again once its runs are over; while one is open, the trigger is skipped and says so
|
|
241
|
+
if (typeof runs.openOn === 'function' && await runs.openOn({ number: cause.subject, refs: cause.refs })) return skip(cause, 'run-open');
|
|
242
|
+
|
|
243
|
+
// --- the maintainer's own sign-in, or nothing else
|
|
244
|
+
const userId = await sessionOf(cause.by);
|
|
245
|
+
if (userId === null) return skip(cause, 'no-session');
|
|
246
|
+
let forge;
|
|
247
|
+
try {
|
|
248
|
+
forge = await auth.forgeForUser(userId);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return skip(cause, reasonOf(err));
|
|
251
|
+
}
|
|
252
|
+
// The pull request or issue itself may be a run's (a run id in its text or branch, a runner as its author) before cornfield has linked it
|
|
253
|
+
for (const n of cause.checked) if (Number.isInteger(n) && await outputOfRun(forge, n)) return ignore(cause, 'run-output');
|
|
254
|
+
|
|
255
|
+
const body = { kind: rule.kind, ...(rule.profile ? { profile: rule.profile } : {}), refs: cause.refs, brief: cause.brief || rule.brief || '' };
|
|
256
|
+
try {
|
|
257
|
+
await runs.request({
|
|
258
|
+
forge: () => forge, who: { login: cause.by, userId: String(userId) }, limits: { auth, now },
|
|
259
|
+
trigger: { event: cause.event, ref: cause.ref },
|
|
260
|
+
}, { body });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (!(err instanceof Refused) && !(err instanceof ForgeError)) log(`a ${event} trigger failed: ${err instanceof Error ? err.stack || err.message : err}`);
|
|
263
|
+
return skip(cause, reasonOf(err));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The hooks' moments: the only ones a trigger looks at are the three a maintainer causes */
|
|
268
|
+
function onEvent(event, payload) {
|
|
269
|
+
if (!TRIGGER_EVENTS.includes(event) || !rules.has(event)) return queue;
|
|
270
|
+
queue = queue.then(() => handle(event, payload)).catch((err) => log(`a ${event} trigger failed: ${words(err)}`));
|
|
271
|
+
return queue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
hooks.subscribe(onEvent, [...rules.keys()]);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
/** Which events have a trigger */
|
|
278
|
+
events: [...rules.keys()],
|
|
279
|
+
/** Resolves when every event handed so far has been dealt with (for tests and for a clean shutdown) */
|
|
280
|
+
idle: () => queue,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @typedef {ReturnType<typeof createTriggers>} Triggers */
|