cornfield 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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*)?$';
@@ -118,7 +123,7 @@ export const CREDENTIAL_KEY_SOURCE = `(?:${['token', 'secret', 'passw', 'credent
118
123
  const EDGE = '(?<![A-Za-z0-9])';
119
124
 
120
125
  /**
121
- * A value shaped like a credential: a forge or cloud token, cornfield's own API tokens (`cf_rt_…` review, `cf_at_…` agent), a JWT, a
126
+ * A value shaped like a credential: a forge or cloud token, cornfield's own API tokens (`cf_rt_…` review, `cf_at_…` agent, `cf_nt_…` runner), a JWT, a
122
127
  * private key, a bearer header, a user and password in a link.
123
128
  * Prose that only talks about tokens and passwords passes; `sk-` keys and bearer values need a digit in their first 41
124
129
  * characters, as real ones have, so a long hyphenated word after "Bearer" or "sk-" is not taken for one. Each alternative tries a
@@ -129,7 +134,7 @@ export const CREDENTIAL_VALUE_SOURCE = [
129
134
  `${EDGE}github_pat_[A-Za-z0-9_]{20,}`,
130
135
  `${EDGE}glpat-[A-Za-z0-9_-]{15,}`,
131
136
  `${EDGE}gto-[A-Za-z0-9]{20,}`,
132
- `${EDGE}cf_(?:rt|at)_[A-Za-z0-9_-]{20,}`,
137
+ `${EDGE}cf_(?:rt|at|nt)_[A-Za-z0-9_-]{20,}`,
133
138
  `${EDGE}sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]`,
134
139
  `${EDGE}AKIA[0-9A-Z]{16}`,
135
140
  `${EDGE}AIza[0-9A-Za-z_-]{35}`,
@@ -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
 
@@ -57,7 +57,7 @@
57
57
  "type": "string",
58
58
  "maxLength": 64,
59
59
  "not": {
60
- "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/@]+@"
60
+ "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|nt)_[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/@]+@"
61
61
  },
62
62
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
63
63
  "description": "A name the runner maps to a model, a machine and a budget. When the developer configured a list, only those names."
@@ -75,7 +75,7 @@
75
75
  "type": "string",
76
76
  "maxLength": 120,
77
77
  "not": {
78
- "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/@]+@"
78
+ "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|nt)_[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/@]+@"
79
79
  },
80
80
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$"
81
81
  },
@@ -118,7 +118,7 @@
118
118
  "type": "string",
119
119
  "maxLength": 200,
120
120
  "not": {
121
- "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/@]+@"
121
+ "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|nt)_[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/@]+@"
122
122
  },
123
123
  "minLength": 1,
124
124
  "description": "A hint: a safe git ref name.",
@@ -137,7 +137,7 @@
137
137
  "type": "string",
138
138
  "maxLength": 20000,
139
139
  "not": {
140
- "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/@]+@"
140
+ "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|nt)_[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/@]+@"
141
141
  }
142
142
  },
143
143
  "limits": {
@@ -165,7 +165,7 @@
165
165
  "type": "string",
166
166
  "maxLength": 200,
167
167
  "not": {
168
- "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/@]+@"
168
+ "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|nt)_[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/@]+@"
169
169
  },
170
170
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
171
171
  }
@@ -174,13 +174,44 @@
174
174
  "type": "string",
175
175
  "maxLength": 40,
176
176
  "not": {
177
- "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/@]+@"
177
+ "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|nt)_[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/@]+@"
178
178
  },
179
179
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$"
180
180
  },
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
  },
@@ -205,14 +236,14 @@
205
236
  "type": "string",
206
237
  "maxLength": 4000,
207
238
  "not": {
208
- "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/@]+@"
239
+ "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|nt)_[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/@]+@"
209
240
  }
210
241
  },
211
242
  "url": {
212
243
  "type": "string",
213
244
  "maxLength": 2000,
214
245
  "not": {
215
- "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/@]+@"
246
+ "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|nt)_[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/@]+@"
216
247
  },
217
248
  "pattern": "^https?://[^\\s/@?#]+([/?#]\\S*)?$",
218
249
  "description": "Where the runner keeps its logs.",
@@ -250,7 +281,7 @@
250
281
  "type": "string",
251
282
  "maxLength": 4000,
252
283
  "not": {
253
- "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/@]+@"
284
+ "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|nt)_[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/@]+@"
254
285
  }
255
286
  }
256
287
  }
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,13 +113,13 @@ 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
  }
118
120
 
119
- /** How a request reaches a runner: the forge only for now (spec 0005 D2; push and pull come with #81) */
120
- export const AGENT_TRANSPORTS = Object.freeze(['forge']);
121
+ /** How a request reaches a runner (spec 0005 D2): posted on the forge (the default), pushed as a signed `agent.requested` hook event, or kept for a runner to pull and claim */
122
+ export const AGENT_TRANSPORTS = Object.freeze(['forge', 'push', 'pull']);
121
123
  /** `agents.maxOpen`: the most open runs per repo cornfield will ask for, by default and at most */
122
124
  export const DEFAULT_MAX_OPEN = 3;
123
125
  export const MAX_OPEN_CEILING = 100;
@@ -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) {
@@ -269,6 +272,10 @@ export function configProblems(c) {
269
272
  });
270
273
  }
271
274
  }
275
+ // Push is the hook's delivery: with no hook that wants agent.requested the request would go nowhere
276
+ if (isObject(c.agents) && c.agents.transport === 'push' && !hookWants(c, ['agent.requested'])) {
277
+ out.push('agents.transport is "push", so a hook must subscribe to agent.requested: add { url, events: ["agent.requested"] } (or "*") to "hooks". Without one a request would go nowhere.');
278
+ }
272
279
  if (c.hookPoll !== undefined && c.hookPoll !== false) {
273
280
  if (!isObject(c.hookPoll)) out.push('"hookPoll" is { specs: "5m" }, or false for none.');
274
281
  else {
@@ -328,10 +335,34 @@ export function configProblems(c) {
328
335
  return out;
329
336
  }
330
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
+
331
362
  /** What is wrong with the keys of `agents` that ask for and follow runs (spec 0005), in words */
332
363
  function agentRunProblems(a) {
333
364
  const out = [];
334
- if (a.transport !== undefined && !AGENT_TRANSPORTS.includes(a.transport)) out.push(`agents.transport is ${AGENT_TRANSPORTS.map((t) => JSON.stringify(t)).join(' or ')}: a request is posted on the forge (push and pull come later).`);
365
+ if (a.transport !== undefined && !AGENT_TRANSPORTS.includes(a.transport)) out.push(`agents.transport is ${AGENT_TRANSPORTS.map((t) => JSON.stringify(t)).join(', ')}: where a request goes: "forge" posts it as a comment (the default), "push" sends it as an agent.requested hook event, "pull" keeps it for a runner to claim (docs/agents.md#transports).`);
335
366
  if (a.maxOpen !== undefined && !(Number.isInteger(a.maxOpen) && a.maxOpen >= 1 && a.maxOpen <= MAX_OPEN_CEILING)) out.push(`agents.maxOpen is a whole number from 1 to ${MAX_OPEN_CEILING}: the most open runs cornfield asks for at once (${DEFAULT_MAX_OPEN} unless set).`);
336
367
  if (a.profiles !== undefined) {
337
368
  if (!Array.isArray(a.profiles) || a.profiles.length > MAX_PROFILES || !a.profiles.every((p) => typeof p === 'string' && PROFILE_NAME.test(p)) || new Set(a.profiles).size !== a.profiles.length) {
@@ -349,6 +380,7 @@ function agentRunProblems(a) {
349
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.`);
350
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.`);
351
382
  }
383
+ if (a.on !== undefined) out.push(...triggerProblems(a));
352
384
  if (a.limits !== undefined) {
353
385
  if (!isObject(a.limits)) out.push('agents.limits is { minutes?, turns?, cost? }: hints the runner may enforce.');
354
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>
@@ -381,7 +382,7 @@
381
382
  </div>
382
383
  <ul id="tokens-list" class="hx-tokens-list" aria-label="Your review tokens"></ul>
383
384
  <form id="tokens-form" class="hx-tokens-form" autocomplete="off">
384
- <label class="hx-forge-field">Kind of token<select id="tokens-scope" class="hx-forge-select"><option value="review">Review: approve or send back proposals</option><option value="agent">Agent: ask for agent runs (POST /api/agent-requests)</option></select></label>
385
+ <label class="hx-forge-field">Kind of token<select id="tokens-scope" class="hx-forge-select"><option value="review">Review: approve or send back proposals</option><option value="agent">Agent: ask for agent runs (POST /api/agent-requests)</option><option value="runner">Runner: pull, claim and report on runs (GET /api/agent-requests)</option></select></label>
385
386
  <label class="hx-forge-field">Label<input id="tokens-label" class="hx-forge-input" type="text" maxlength="60" placeholder="chat bot" /></label>
386
387
  <label class="hx-forge-field">Days<input id="tokens-days" class="hx-forge-input" type="text" inputmode="numeric" maxlength="2" aria-describedby="tokens-days-hint" /></label>
387
388
  <span id="tokens-days-hint" class="hx-sr-only"></span>
@@ -25,9 +25,16 @@ const DAY = 24 * 60 * 60 * 1000;
25
25
  const HOUR = 60 * 60 * 1000;
26
26
  const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} ._:/()-]*$/u;
27
27
 
28
- /** Tokens come in two scopes (server/review-tokens.js): `review` approves proposals, `agent` asks for agent runs. Each has its own address. */
29
- export const SCOPES = Object.freeze([['review', 'Review: approve or send back proposals'], ['agent', 'Agent: ask for agent runs']]);
30
- export const tokensUrl = (scope) => (scope === 'agent' ? '/api/agent-tokens' : TOKENS_URL);
28
+ /** Tokens come in three scopes (server/review-tokens.js): `review` approves proposals, `agent` asks for agent runs, `runner` pulls and reports on them. Each has its own address. */
29
+ export const SCOPES = Object.freeze([['review', 'Review: approve or send back proposals'], ['agent', 'Agent: ask for agent runs'], ['runner', 'Runner: pull, claim and report on runs']]);
30
+ export const tokensUrl = (scope) => (scope === 'agent' ? '/api/agent-tokens' : scope === 'runner' ? '/api/runner-tokens' : TOKENS_URL);
31
+
32
+ /** What the dialog says a token of this scope is for */
33
+ export function introWords(scope) {
34
+ if (scope === 'agent') return 'An agent token lets a script or a chat bot ask for an agent run as you (POST /api/agent-requests). It works while you are signed in here and still maintain the repo; it expires, and you can revoke it below.';
35
+ if (scope === 'runner') return 'A runner token lets your own runner, wherever it runs, list the queued requests, claim one and report on it (GET /api/agent-requests, POST /api/agent-requests/<run>/claim, POST /api/runs/<run>). It reaches nothing else, and works until it expires or you revoke it below, even after you sign out.';
36
+ return 'A review token lets a script or a chat bot approve or send back a proposal as you (POST /api/reviews). It works while you are signed in here and still maintain the repo; it expires, and you can revoke it below.';
37
+ }
31
38
 
32
39
  /** The address that revokes token `id` */
33
40
  export const revokeUrl = (id, scope = 'review') => `${tokensUrl(scope)}/${encodeURIComponent(String(id))}/revoke`;
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { escHtml } from './state.js';
22
22
  import { announce } from './keyboard.js';
23
- import { tokensUrl, DEFAULT_DAYS, MAX_DAYS, makeRequest, revokeUrl, tokenLine, errorWords } from './review-tokens-model.js';
23
+ import { tokensUrl, introWords, DEFAULT_DAYS, MAX_DAYS, makeRequest, revokeUrl, tokenLine, errorWords } from './review-tokens-model.js';
24
24
 
25
25
  const $ = (id) => document.getElementById(id);
26
26
  let wired = false;
@@ -58,6 +58,8 @@ function paint() {
58
58
  return `<li class="hx-tokens-item" data-state="${line.state}"><span class="hx-tokens-label">${escHtml(line.label)}</span> <span class="hx-tokens-meta hx-faint">${escHtml(line.status)} · ${escHtml(line.used)}</span> ${line.canRevoke ? `<button type="button" class="hx-btn hx-btn--quiet" data-revoke="${escHtml(String(line.id))}" aria-label="Revoke ${escHtml(line.label)}"${ui.busy ? ' disabled' : ''}>Revoke</button>` : ''}</li>`;
59
59
  }).join('');
60
60
  }
61
+ const intro = $('tokens-intro');
62
+ if (intro) intro.textContent = introWords(ui.scope);
61
63
  const msg = $('tokens-msg');
62
64
  msg.hidden = !ui.message;
63
65
  msg.textContent = ui.message ? ui.message.text : '';
@@ -204,7 +206,7 @@ export function initReviewTokens(config, opts = {}) {
204
206
  dialog().addEventListener('keydown', onKey);
205
207
  $('tokens-form').addEventListener('submit', make);
206
208
  const scope = $('tokens-scope');
207
- if (scope) scope.addEventListener('change', () => { ui.scope = scope.value === 'agent' ? 'agent' : 'review'; ui.loaded = false; ui.tokens = []; clearSecret(); paint(); load(); });
209
+ if (scope) scope.addEventListener('change', () => { ui.scope = scope.value === 'agent' || scope.value === 'runner' ? scope.value : 'review'; ui.loaded = false; ui.tokens = []; clearSecret(); paint(); load(); });
208
210
  }
209
211
  return true;
210
212
  }
@@ -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": "cornfield",
3
- "version": "0.9.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)",
@@ -4,6 +4,7 @@
4
4
  * The routes that ask for agent runs and show them (issue #80, spec 0005 D10; the engine is server/agent-runs.js).
5
5
  *
6
6
  * POST /api/agent-requests { kind, refs, profile?, brief?, branch?, limits?, nodes?, force? }, with `Authorization: Bearer cf_at_…`
7
+ * GET /api/agent-requests the queue, for a runner token: server/runner-api.js (and the pull transport's claim and report routes)
7
8
  * GET /api/agent-tokens the signed-in maintainer's agent tokens (and POST, and /<id>/revoke: server/review-api.js)
8
9
  * GET /api/agent-runs[?follow=1] the runs, newest first, with the cap and how many are open; `follow=1` looks at the forge first
9
10
  * GET /api/agent-runs/<runId> one run
@@ -13,7 +14,7 @@
13
14
  * **The call takes an agent token and nothing else,** exactly as the review API takes a review token (server/review-api.js): it runs
14
15
  * before the sign-in guard (a script has no cookie), reads no cookie, needs no same-origin check (a browser never adds an `Authorization`
15
16
  * header itself), and keeps the `Host` check in front of every route. **The two scopes are kept apart:** a review token on this route is 403
16
- * (and an agent token on `POST /api/reviews` is 403), whatever else is true of it. Token states, the anonymous flood ceiling and the rows
17
+ * (and an agent token on `POST /api/reviews` is 403, and a runner token on either), whatever else is true of it. Token states, the anonymous flood ceiling and the rows
17
18
  * in `actions` are the review API's, under the action `agent-request`:
18
19
  *
19
20
  * no token, or one that no row has 401 (`WWW-Authenticate: Bearer`), logged under `(no token)`; past `ANONYMOUS_PER_MINUTE` a minute 429 and no row
@@ -84,7 +85,7 @@ export async function serveAgentCall(req, res, ctx) {
84
85
  }
85
86
  if (req.method !== 'POST') {
86
87
  req.resume();
87
- return send(res, 405, { ok: false, error: 'A run is asked for with POST.', code: 'invalid' }, { Allow: 'POST' });
88
+ return send(res, 405, { ok: false, error: 'A run is asked for with POST (an agent token); a runner lists the queue with GET (a runner token).', code: 'invalid' }, { Allow: 'GET, HEAD, POST' });
88
89
  }
89
90
  const repo = config.forge.repo;
90
91
  const audit = async (login, request, status, body) => {
@@ -115,7 +116,7 @@ export async function serveAgentCall(req, res, ctx) {
115
116
  if (flood) return send(res, flood.status, { ok: false, error: flood.message, code: flood.code }, flood.headers);
116
117
  const wrong = state === 'wrong-scope';
117
118
  const body = {
118
- error: wrong ? `This is a ${row.scope} token. Asking for a run takes an agent token; make one in the dashboard.`
119
+ error: wrong ? `This is ${row.scope === 'agent' ? 'an' : 'a'} ${row.scope} token. Asking for a run takes an agent token; make one in the dashboard.`
119
120
  : state === 'revoked' ? 'This agent token was revoked; make a new one in the dashboard.' : 'This agent token has expired; make a new one in the dashboard.',
120
121
  code: 'forbidden',
121
122
  };