@popcorn245/cornfield 0.10.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.
@@ -39,7 +39,7 @@
39
39
  * @typedef {{ issue?: number, proposal?: string, pull?: number, thread?: number }} RunRefs
40
40
  * @typedef {{ minutes?: number, turns?: number, cost?: number }} RunLimits
41
41
  * @typedef {{ version: 1, runId: string, kind: RunKind, profile?: string, refs: RunRefs, branch?: string, brief: string,
42
- * limits?: RunLimits, nodes?: string[], requestedBy: string, requestedAt: string }} RunRequest
42
+ * limits?: RunLimits, nodes?: string[], requestedBy: string, requestedAt: string, trigger?: { event: 'spec.approved' | 'build.opened' | 'thread.message', by: string, ref: string } }} RunRequest
43
43
  * @typedef {'claimed' | 'working' | 'done' | 'failed' | 'stopped'} ReportState
44
44
  * @typedef {{ state: ReportState, note?: string, url?: string, tokens?: number, cost?: number }} RunReport
45
45
  * @typedef {{ state: 'stop-requested', note?: string }} StopRequest
@@ -83,7 +83,10 @@ export const RUN_TAG = 'cornfield-run';
83
83
  /** The key of the trailer */
84
84
  export const TRAILER_KEY = 'Cornfield-Run';
85
85
 
86
- const REQUEST_KEYS = ['version', 'runId', 'kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'requestedAt'];
86
+ const REQUEST_KEYS = ['version', 'runId', 'kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'requestedAt', 'trigger'];
87
+ const TRIGGER_KEYS = ['event', 'by', 'ref'];
88
+ /** The events an opt-in trigger may fire on (`agents.on`, spec 0005 D10): each is something a maintainer did */
89
+ export const TRIGGER_EVENTS = Object.freeze(['spec.approved', 'build.opened', 'thread.message']);
87
90
  const REF_KEYS = ['issue', 'proposal', 'pull', 'thread'];
88
91
  const LIMIT_KEYS = ['minutes', 'turns', 'cost'];
89
92
  const REPORT_KEYS = ['state', 'note', 'url', 'tokens', 'cost'];
@@ -101,6 +104,8 @@ export const LOGIN_SOURCE = '^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$';
101
104
  export const SLUG_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._-]{0,${MAX_SLUG_CHARS - 1}}$`;
102
105
  export const PROFILE_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._-]{0,${MAX_PROFILE_CHARS - 1}}$`;
103
106
  export const NODE_ID_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,${MAX_NODE_CHARS - 1}}$`;
107
+ /** What an automatic request says caused it: a pull request, an issue or a comment, by its number or id */
108
+ export const TRIGGER_REF_SOURCE = '^(pull|issue|comment):[0-9]{1,20}$';
104
109
  export const ISO_SOURCE = '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?Z$';
105
110
  /** An http(s) link with no user and password in it */
106
111
  export const URL_SOURCE = '^https?://[^\\s/@?#]+([/?#]\\S*)?$';
@@ -150,6 +155,7 @@ const SLUG = new RegExp(SLUG_SOURCE);
150
155
  const PROFILE = new RegExp(PROFILE_SOURCE);
151
156
  const NODE_ID = new RegExp(NODE_ID_SOURCE);
152
157
  const ISO = new RegExp(ISO_SOURCE);
158
+ const TRIGGER_REF = new RegExp(TRIGGER_REF_SOURCE);
153
159
  const URL_RE = new RegExp(URL_SOURCE);
154
160
  const CREDENTIAL_KEY = new RegExp(CREDENTIAL_KEY_SOURCE);
155
161
  const CREDENTIAL_VALUE = new RegExp(CREDENTIAL_VALUE_SOURCE);
@@ -355,6 +361,17 @@ export function validateRequest(body, { profiles } = {}) {
355
361
  if (body.nodes !== undefined) value.nodes = [...body.nodes];
356
362
  value.requestedBy = body.requestedBy;
357
363
  value.requestedAt = body.requestedAt;
364
+ if (body.trigger !== undefined) {
365
+ if (!isObject(body.trigger)) return no('"trigger" is { event, by, ref }: what caused an automatic request.');
366
+ const triggerKeys = keyProblem(body.trigger, TRIGGER_KEYS, '"trigger"');
367
+ if (triggerKeys) return no(triggerKeys);
368
+ if (!TRIGGER_EVENTS.includes(body.trigger.event)) return no(`"trigger.event" is one of ${TRIGGER_EVENTS.join(', ')}.`);
369
+ if (typeof body.trigger.by !== 'string' || !LOGIN.test(body.trigger.by)) return no('"trigger.by" is the forge login of the maintainer whose act caused the request.');
370
+ if (typeof body.trigger.ref !== 'string' || !TRIGGER_REF.test(body.trigger.ref)) return no('"trigger.ref" is what the act was on: pull:<number>, issue:<number> or comment:<id>.');
371
+ // The request is posted as the maintainer whose act caused it, so the two name one person
372
+ if (body.trigger.by !== body.requestedBy) return no('"trigger.by" is the maintainer the request is made as, the same login as "requestedBy".');
373
+ value.trigger = { event: body.trigger.event, by: body.trigger.by, ref: body.trigger.ref };
374
+ }
358
375
  return { ok: true, value };
359
376
  }
360
377
 
@@ -181,6 +181,37 @@
181
181
  "requestedAt": {
182
182
  "type": "string",
183
183
  "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?Z$"
184
+ },
185
+ "trigger": {
186
+ "description": "Only on an automatic request (agents.on): what caused it. A maintainer's act, never a run's output.",
187
+ "type": "object",
188
+ "additionalProperties": false,
189
+ "required": [
190
+ "event",
191
+ "by",
192
+ "ref"
193
+ ],
194
+ "properties": {
195
+ "event": {
196
+ "enum": [
197
+ "spec.approved",
198
+ "build.opened",
199
+ "thread.message"
200
+ ]
201
+ },
202
+ "by": {
203
+ "type": "string",
204
+ "maxLength": 40,
205
+ "not": {
206
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
207
+ },
208
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$"
209
+ },
210
+ "ref": {
211
+ "type": "string",
212
+ "pattern": "^(pull|issue|comment):[0-9]{1,20}$"
213
+ }
214
+ }
184
215
  }
185
216
  }
186
217
  },
package/core/config.js CHANGED
@@ -40,7 +40,9 @@ import fs from 'node:fs';
40
40
  import os from 'node:os';
41
41
  import path from 'node:path';
42
42
  import { pathToFileURL, fileURLToPath } from 'node:url';
43
- import { PROFILE_SOURCE, MAX_NUMBER } from './agent-contract.js';
43
+ import { PROFILE_SOURCE, MAX_NUMBER, MAX_BRIEF_CHARS, RUN_KINDS, TRIGGER_EVENTS, CREDENTIAL_VALUE_SOURCE } from './agent-contract.js';
44
+
45
+ export { TRIGGER_EVENTS };
44
46
 
45
47
  export const CONFIG_FILE = 'cornfield.config.js';
46
48
  export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -68,7 +70,7 @@ export const FORGE_TYPES = ['github', 'forgejo'];
68
70
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
69
71
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
70
72
  /** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
71
- export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked', 'agent.requested', 'run.claimed', 'run.state', 'run.stopRequested', 'run.finished']);
73
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked', 'agent.requested', 'run.claimed', 'run.state', 'run.stopRequested', 'run.finished', 'trigger.skipped']);
72
74
  /** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
73
75
  export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
74
76
  /** The keys of `threads` when it is an object, and the label's longest length */
@@ -111,7 +113,7 @@ export const ideaLabel = (config) => (threadsOn(config) ? (config.threads && con
111
113
  * @returns {number | null}
112
114
  */
113
115
  export function specPollMs(config) {
114
- const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && hookWants(config, THREAD_EVENTS));
116
+ const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && (hookWants(config, THREAD_EVENTS) || triggersOf(config).some(([e]) => e === 'thread.message')));
115
117
  const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
116
118
  return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
117
119
  }
@@ -150,7 +152,7 @@ export const DEFAULTS = Object.freeze({
150
152
  personas: { roles: null, parent: 'root' },
151
153
  cloud: { parent: 'root' },
152
154
  releaseNotes: { breakingLabel: 'breaking' },
153
- agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {} },
155
+ agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {} },
154
156
  hooks: [],
155
157
  hookPoll: { specs: '5m' },
156
158
  threads: { label: 'idea' },
@@ -163,7 +165,7 @@ export const DEFAULTS = Object.freeze({
163
165
  const KEYS = Object.keys(DEFAULTS);
164
166
  /** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
165
167
  export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
166
- const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits'];
168
+ const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on'];
167
169
  const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
168
170
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
169
171
  const isText = (v) => typeof v === 'string' && v.trim() !== '';
@@ -237,6 +239,7 @@ export function configProblems(c) {
237
239
  }
238
240
  if (c.agents.bots !== undefined && typeof c.agents.bots !== 'boolean') out.push('agents.bots is true or false: whether a bot account ("[bot]" or the forge\'s Bot type) counts as an agent.');
239
241
  out.push(...agentRunProblems(c.agents));
242
+ if (isObject(c.agents.on) && c.agents.on['thread.message'] !== undefined && c.threads === false) out.push('agents.on["thread.message"] needs threads, which are off (threads: false): the /agent command is read in a thread.');
240
243
  }
241
244
  }
242
245
  if (c.deploys !== undefined && c.deploys !== null) {
@@ -332,6 +335,30 @@ export function configProblems(c) {
332
335
  return out;
333
336
  }
334
337
 
338
+ /**
339
+ * What is wrong with `agents.on`, the opt-in triggers (spec 0005 D10): `{ '<event>': { kind, profile?, brief? } }` for the events a
340
+ * maintainer causes. Off by default (no key, no trigger). `kind` is checked against the contract and `profile` against `agents.profiles`.
341
+ */
342
+ function triggerProblems(a) {
343
+ const out = [];
344
+ const on = a.on;
345
+ if (!isObject(on)) return [`agents.on is { ${TRIGGER_EVENTS.map((e) => `"${e}": { kind, profile? }`).join(', ')} }: which events request a run by themselves (none unless set).`];
346
+ const profiles = Array.isArray(a.profiles) ? a.profiles : [];
347
+ for (const [event, t] of Object.entries(on)) {
348
+ const at = `agents.on[${JSON.stringify(event)}]`;
349
+ if (!TRIGGER_EVENTS.includes(event)) { out.push(`${at}: a trigger fires only on ${TRIGGER_EVENTS.join(', ')}: something a maintainer did, never a run's output.`); continue; }
350
+ if (!isObject(t)) { out.push(`${at} is { kind, profile?, brief? }.`); continue; }
351
+ for (const k of Object.keys(t)) if (!['kind', 'profile', 'brief'].includes(k)) out.push(`${at}.${k}: the keys are kind, profile, brief.`);
352
+ if (!RUN_KINDS.includes(t.kind)) out.push(`${at}.kind is one of ${RUN_KINDS.join(', ')}.`);
353
+ if (t.profile !== undefined && !(typeof t.profile === 'string' && profiles.includes(t.profile))) out.push(`${at}.profile ${JSON.stringify(t.profile)} is not one of agents.profiles (${profiles.join(', ') || 'none listed'}).`);
354
+ if (t.brief !== undefined && !(typeof t.brief === 'string' && t.brief.length <= MAX_BRIEF_CHARS && !new RegExp(CREDENTIAL_VALUE_SOURCE).test(t.brief))) out.push(`${at}.brief is text of at most ${MAX_BRIEF_CHARS} characters, and holds nothing shaped like a credential.`);
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /** The triggers a config turns on, as `[event, { kind, profile?, brief? }]` (none when off, which is the default) */
360
+ export const triggersOf = (config) => Object.entries(isObject(config.agents?.on) ? config.agents.on : {}).filter(([e]) => TRIGGER_EVENTS.includes(e));
361
+
335
362
  /** What is wrong with the keys of `agents` that ask for and follow runs (spec 0005), in words */
336
363
  function agentRunProblems(a) {
337
364
  const out = [];
@@ -353,6 +380,7 @@ function agentRunProblems(a) {
353
380
  else if (ms < QUIET_MIN_MS) out.push(`agents.quietAfter ${JSON.stringify(a.quietAfter)} is under the floor of 5m; use "5m" or more.`);
354
381
  else if (ms > QUIET_MAX_MS) out.push(`agents.quietAfter ${JSON.stringify(a.quietAfter)} is over the ceiling of 30 days; use "720h" or less.`);
355
382
  }
383
+ if (a.on !== undefined) out.push(...triggerProblems(a));
356
384
  if (a.limits !== undefined) {
357
385
  if (!isObject(a.limits)) out.push('agents.limits is { minutes?, turns?, cost? }: hints the runner may enforce.');
358
386
  else {
@@ -69,7 +69,7 @@ import { relativeTime } from './review-model.js';
69
69
  import { LAYER_DEFS, toggleLayer } from './layers.js';
70
70
  import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
71
71
  import {
72
- RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, startBody, startWords, stopWords, confirmStartWords,
72
+ RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, triggerNotice, startBody, startWords, stopWords, confirmStartWords,
73
73
  } from './runs-model.js';
74
74
  import {
75
75
  STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
@@ -1002,6 +1002,12 @@ function paintRuns() {
1002
1002
  note.hidden = !words;
1003
1003
  note.className = `hx-forge-notice${!(ui.runs && ui.runs.error) && ui.runNote && ui.runNote.ok ? ' hx-forge-notice--ok' : ''}`;
1004
1004
  note.textContent = words;
1005
+ const trig = $('forge-runs-trigger');
1006
+ if (trig) {
1007
+ const skipped = data ? triggerNotice(data) : '';
1008
+ trig.hidden = !skipped;
1009
+ trig.textContent = skipped;
1010
+ }
1005
1011
  const list = $('forge-runs-list');
1006
1012
  if (!data) { list.innerHTML = ''; return; }
1007
1013
  list.innerHTML = data.runs.length ? data.runs.map(runHtml).join('') : '<li class="hx-forge-empty hx-faint">No agent has been asked for yet. Start one from an issue or a proposal.</li>';
@@ -270,6 +270,7 @@
270
270
  <h3 id="forge-runs-title" class="hx-forge-runs-title">Agent runs</h3>
271
271
  <p id="forge-runs-status" class="hx-forge-meta hx-faint"></p>
272
272
  <p id="forge-runs-note" class="hx-forge-notice" hidden></p>
273
+ <p id="forge-runs-trigger" class="hx-forge-notice" hidden></p>
273
274
  <ul id="forge-runs-list" class="hx-forge-files" aria-label="Runs"></ul>
274
275
  </section>
275
276
  <section id="forge-detail" class="hx-forge-detail" aria-label="Selected pull request" hidden></section>
@@ -64,6 +64,24 @@ export function runMeta(run, nowMs) {
64
64
  return parts.join(' · ');
65
65
  }
66
66
 
67
+ /**
68
+ * The notice over the runs when an opt-in trigger (`agents.on`) did nothing in the last day, from the audit rows the server counted
69
+ * (`triggerSkipped`: { count, reasons }), or '' when none did. No live sign-in is the case the person can mend, so it says how.
70
+ */
71
+ export function triggerNotice(data) {
72
+ const n = data && data.triggerSkipped;
73
+ if (!n || !(n.count > 0)) return '';
74
+ const reasons = n.reasons || {};
75
+ const out = [];
76
+ if (reasons['no-session']) out.push('A trigger was skipped: sign in to let triggers request runs.');
77
+ const refused = Object.entries(reasons).filter(([why]) => why !== 'no-session');
78
+ if (refused.length) {
79
+ const count = refused.reduce((sum, [, c]) => sum + c, 0);
80
+ out.push(`${count} ${count === 1 ? 'trigger was' : 'triggers were'} refused in the last day (${refused.map(([why, c]) => `${why} ${c}`).join(', ')}), so nothing was asked.`);
81
+ }
82
+ return out.join(' ');
83
+ }
84
+
67
85
  /** Whether a Stop is offered: the run is open and nobody has asked it to stop yet */
68
86
  export const canStop = (run) => run.open === true && !run.stopRequested;
69
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popcorn245/cornfield",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A self-hostable dev dashboard with the topology map at its centre: proposals on the map, scenarios, outages and what-if edits, for any repo.",
5
5
  "license": "MIT",
6
6
  "author": "popcorn245 (https://github.com/popcorn245)",
@@ -172,6 +172,8 @@ export function publicRun(row, nowMs, quietMs) {
172
172
  * @property {{ auth: object, now?: () => number }} limits what server/throttle.js counts by
173
173
  * @property {{ token: { id: number, label: string } } | null} [via] set when the request came through the API: the token it came with
174
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
175
177
  */
176
178
 
177
179
  /**
@@ -216,7 +218,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
216
218
  }
217
219
 
218
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`. */
219
- function reserve(db, request, { target, who, via, force }) {
221
+ function reserve(db, request, { target, who, via, force, trigger = false }) {
220
222
  const at = now();
221
223
  const mine = subjectKeys(request.refs);
222
224
  db.exec('BEGIN IMMEDIATE');
@@ -237,7 +239,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
237
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)
238
240
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
239
241
  .run(request.runId, repo, request.kind, request.profile ?? null, request.refs.issue ?? null, request.refs.proposal ?? null, request.refs.thread ?? null,
240
- target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : 'dashboard', iso(at), transport);
242
+ target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : trigger ? 'trigger' : 'dashboard', iso(at), transport);
241
243
  db.exec('COMMIT');
242
244
  } catch (err) {
243
245
  try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
@@ -288,7 +290,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
288
290
  try {
289
291
  if (problem) throw new Refused(problem.status, problem.message, 'invalid');
290
292
  if (!isObject(body)) throw new Refused(400, `A request is a JSON object: { kind, refs, profile?, brief?, branch?, limits?, nodes?, force? }.`, 'invalid');
291
- const { force, requestedBy, ...fields } = body;
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');
292
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');
293
297
  const build = (login, pull) => {
294
298
  // The config's limits are hints the request starts from; what the person sent wins key by key
@@ -298,10 +302,11 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
298
302
  else if (isObject(limits)) limits = { ...base, ...limits };
299
303
  // The brief is required by the contract and may be empty; the spec PR that holds a proposal is named beside it
300
304
  const refs = pull != null && isObject(fields.refs) ? { ...fields.refs, pull } : fields.refs;
301
- const made = createRequest({ brief: '', ...fields, refs, ...(limits !== undefined ? { limits } : {}), requestedBy: login }, { profiles, now: at });
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 });
302
306
  if (!made.ok) throw new Refused(400, made.error, 'invalid');
303
307
  return /** @type {RunRequest} */ (made.value);
304
308
  };
309
+ if (ctx.trigger) Object.assign(asked, { trigger: { event: ctx.trigger.event, ref: ctx.trigger.ref } });
305
310
  let made = build(who.login);
306
311
  runId = made.runId;
307
312
  Object.assign(asked, { runId });
@@ -343,6 +348,9 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
343
348
  if (via) {
344
349
  const [head, ...rest] = comment.split('\n\n');
345
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');
346
354
  }
347
355
  }
348
356
 
@@ -352,7 +360,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
352
360
  if (why) throw new Refused(403, why, 'forbidden');
353
361
  }
354
362
  const db = await auth.store();
355
- reserve(db, made, { target: number, who, via, force: force === true });
363
+ reserve(db, made, { target: number, who, via, force: force === true, trigger: Boolean(made.trigger) });
356
364
  let posted = null;
357
365
  if (comment !== null) {
358
366
  try {
@@ -487,7 +495,18 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
487
495
  const db = await auth.store();
488
496
  const at = now();
489
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));
490
- return { runs: rows.map((r) => publicRun(r, at, quietMs)), open: openRows(db, at).length, maxOpen: cap, profiles, limits: agents.limits || {}, quietAfter: agents.quietAfter, runners: [...runnerSet].length };
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
+ };
491
510
  }
492
511
 
493
512
  /** One run, or null */
@@ -900,7 +919,22 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
900
919
  if (gone) log(`${gone} request${gone === 1 ? '' : 's'} that never reached the forge ${gone === 1 ? 'was' : 'were'} dropped`);
901
920
  }
902
921
 
903
- return { request, stop, list, get, follow, hasWork, resume, queued, claim, runnerGet, report, maxOpen: cap, profiles, transport };
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 };
904
938
  }
905
939
 
906
940
  /** @typedef {ReturnType<typeof createRuns>} Runs */
@@ -255,7 +255,7 @@ export async function serveAction(req, res, what, ctx, send) {
255
255
  if (!plan.existing && plan.digest !== digest) throw new ForgeError(`What the build issue for ${slug} would say has changed since it was shown (a new review, or a push); look again.`, { code: 'conflict' });
256
256
  const done = await openBuildIssue(forge, plan);
257
257
  // Only an issue this ask made is news: a second ask that found the first one's says nothing again
258
- if (done.created) ctx.hooks?.emit('build.opened', () => proposalPayload(forge, ctx.where || { proposals: config.proposals }, plan, { actor: session.login }));
258
+ if (done.created) ctx.hooks?.emit('build.opened', async () => ({ ...(await proposalPayload(forge, ctx.where || { proposals: config.proposals }, plan, { actor: session.login })), issue: done.issue.number }));
259
259
  await forgetKept(auth, config);
260
260
  out = { status: done.created ? 201 : 200, body: { ok: true, action, created: done.created, number: n, slug, issue: done.issue, path: plan.path } };
261
261
  } else if (action === 'merge') {
package/server/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
- if (!to.length) return;
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');
@@ -150,8 +151,13 @@ export function createCornfieldServer(config, opts = {}) {
150
151
  const auth = createAuth(config, opts);
151
152
  // Hooks listed without a secret (or without sign-in) are a startup error, like a half-configured sign-in
152
153
  const hookSettings = hooksSettings(config, env, auth.enabled);
153
- const hooks = hookSettings
154
- ? createHooks({ hooks: config.hooks, secret: hookSettings.secret, fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
154
+ // 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
155
+ const triggerList = triggersOf(config);
156
+ 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.');
157
+ 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.');
158
+ // 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)
159
+ const hooks = hookSettings || triggerList.length
160
+ ? 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
161
  : null;
156
162
  hooks?.watchSessions();
157
163
  // Landing on approval needs somebody signed in to land as, like the hooks' events: a half-set key is a startup error
@@ -174,6 +180,8 @@ export function createCornfieldServer(config, opts = {}) {
174
180
  const transport = config.agents?.transport ?? 'forge';
175
181
  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
182
  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.');
183
+ // Triggers subscribe to the hooks' moments and call the runs' own request path; nothing a run produces is wired to them (server/triggers.js)
184
+ if (runs && hooks && triggerList.length) createTriggers({ config, auth, runs, hooks, now: opts.now });
177
185
  runs?.resume().catch((err) => console.error(`[cornfield] agent runs could not be picked up: ${err instanceof Error ? err.message : err}`));
178
186
  // 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
187
  // 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
@@ -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
- /** 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 */
78
- async function maintainerForge() {
79
- 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())));
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${threadsOn(config) && hookWants(config, THREAD_EVENTS) ? ' and threads' : ''} with the sign-in of @${found.login}, a maintainer`);
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 = threadsOn(config) && hookWants(config, THREAD_EVENTS);
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. Never a text: a hook says that something was said, not what.
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
- messages: t.comments.map((c) => ({ id: c.id, actor: c.author ? c.author.login : null, stamp: c.updatedAt, linked: linkOf(c.body, nodes) })),
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 */