@popcorn245/cornfield 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/agent-contract.js +2 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +7 -3
- package/explorer/index.html +1 -1
- package/explorer/review-tokens-model.js +10 -3
- package/explorer/review-tokens.js +4 -2
- package/package.json +1 -1
- package/server/agent-api.js +4 -3
- package/server/agent-runs.js +175 -38
- package/server/auth.js +58 -3
- package/server/db.js +31 -0
- package/server/review-api.js +10 -8
- package/server/review-tokens.js +24 -7
- package/server/runner-api.js +270 -0
- package/server/server.js +15 -1
package/core/agent-contract.js
CHANGED
|
@@ -118,7 +118,7 @@ export const CREDENTIAL_KEY_SOURCE = `(?:${['token', 'secret', 'passw', 'credent
|
|
|
118
118
|
const EDGE = '(?<![A-Za-z0-9])';
|
|
119
119
|
|
|
120
120
|
/**
|
|
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
|
|
121
|
+
* 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
122
|
* private key, a bearer header, a user and password in a link.
|
|
123
123
|
* Prose that only talks about tokens and passwords passes; `sk-` keys and bearer values need a digit in their first 41
|
|
124
124
|
* 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 +129,7 @@ export const CREDENTIAL_VALUE_SOURCE = [
|
|
|
129
129
|
`${EDGE}github_pat_[A-Za-z0-9_]{20,}`,
|
|
130
130
|
`${EDGE}glpat-[A-Za-z0-9_-]{15,}`,
|
|
131
131
|
`${EDGE}gto-[A-Za-z0-9]{20,}`,
|
|
132
|
-
`${EDGE}cf_(?:rt|at)_[A-Za-z0-9_-]{20,}`,
|
|
132
|
+
`${EDGE}cf_(?:rt|at|nt)_[A-Za-z0-9_-]{20,}`,
|
|
133
133
|
`${EDGE}sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]`,
|
|
134
134
|
`${EDGE}AKIA[0-9A-Z]{16}`,
|
|
135
135
|
`${EDGE}AIza[0-9A-Za-z_-]{35}`,
|
|
@@ -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,7 +174,7 @@
|
|
|
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
|
},
|
|
@@ -205,14 +205,14 @@
|
|
|
205
205
|
"type": "string",
|
|
206
206
|
"maxLength": 4000,
|
|
207
207
|
"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/@]+@"
|
|
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|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
209
|
}
|
|
210
210
|
},
|
|
211
211
|
"url": {
|
|
212
212
|
"type": "string",
|
|
213
213
|
"maxLength": 2000,
|
|
214
214
|
"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/@]+@"
|
|
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|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
216
|
},
|
|
217
217
|
"pattern": "^https?://[^\\s/@?#]+([/?#]\\S*)?$",
|
|
218
218
|
"description": "Where the runner keeps its logs.",
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
"type": "string",
|
|
251
251
|
"maxLength": 4000,
|
|
252
252
|
"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/@]+@"
|
|
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|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
254
|
}
|
|
255
255
|
}
|
|
256
256
|
}
|
package/core/config.js
CHANGED
|
@@ -116,8 +116,8 @@ export function specPollMs(config) {
|
|
|
116
116
|
return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/** How a request reaches a runner: the forge
|
|
120
|
-
export const AGENT_TRANSPORTS = Object.freeze(['forge']);
|
|
119
|
+
/** 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 */
|
|
120
|
+
export const AGENT_TRANSPORTS = Object.freeze(['forge', 'push', 'pull']);
|
|
121
121
|
/** `agents.maxOpen`: the most open runs per repo cornfield will ask for, by default and at most */
|
|
122
122
|
export const DEFAULT_MAX_OPEN = 3;
|
|
123
123
|
export const MAX_OPEN_CEILING = 100;
|
|
@@ -269,6 +269,10 @@ export function configProblems(c) {
|
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
+
// Push is the hook's delivery: with no hook that wants agent.requested the request would go nowhere
|
|
273
|
+
if (isObject(c.agents) && c.agents.transport === 'push' && !hookWants(c, ['agent.requested'])) {
|
|
274
|
+
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.');
|
|
275
|
+
}
|
|
272
276
|
if (c.hookPoll !== undefined && c.hookPoll !== false) {
|
|
273
277
|
if (!isObject(c.hookPoll)) out.push('"hookPoll" is { specs: "5m" }, or false for none.');
|
|
274
278
|
else {
|
|
@@ -331,7 +335,7 @@ export function configProblems(c) {
|
|
|
331
335
|
/** What is wrong with the keys of `agents` that ask for and follow runs (spec 0005), in words */
|
|
332
336
|
function agentRunProblems(a) {
|
|
333
337
|
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('
|
|
338
|
+
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
339
|
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
340
|
if (a.profiles !== undefined) {
|
|
337
341
|
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) {
|
package/explorer/index.html
CHANGED
|
@@ -381,7 +381,7 @@
|
|
|
381
381
|
</div>
|
|
382
382
|
<ul id="tokens-list" class="hx-tokens-list" aria-label="Your review tokens"></ul>
|
|
383
383
|
<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>
|
|
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><option value="runner">Runner: pull, claim and report on runs (GET /api/agent-requests)</option></select></label>
|
|
385
385
|
<label class="hx-forge-field">Label<input id="tokens-label" class="hx-forge-input" type="text" maxlength="60" placeholder="chat bot" /></label>
|
|
386
386
|
<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
387
|
<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
|
|
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'
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popcorn245/cornfield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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)",
|
package/server/agent-api.js
CHANGED
|
@@ -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
|
};
|
package/server/agent-runs.js
CHANGED
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
* @typedef {{ run_id: string, repo: string, kind: string, profile: string | null, issue: number | null, proposal: string | null, thread: number | null,
|
|
58
58
|
* target: number, comment_id: string, comment_url: string | null, request: string, requested_by: string, requested_by_id: string, requested_at: string,
|
|
59
59
|
* via: string, state: string, reported_at: string | null, reported_by: string | null, note: string | null, url: string | null, tokens: number | null,
|
|
60
|
-
* cost: number | null, stop_requested_at: string | null, stop_requested_by: string | null, pull: number | null, pull_state: string | null,
|
|
60
|
+
* cost: number | null, transport: 'forge' | 'push' | 'pull', claimed_by: number | null, claimed_at: string | null, stop_requested_at: string | null, stop_requested_by: string | null, pull: number | null, pull_state: string | null,
|
|
61
61
|
* pull_url: string | null, pull_by: string | null, pull_trusted: number, linked_via: string | null, branch: string | null, head_sha: string | null,
|
|
62
62
|
* checks: string | null, last_activity: string }} RunRow
|
|
63
63
|
*/
|
|
64
64
|
|
|
65
65
|
import { ForgeError, isMaintainer } from './forge.js';
|
|
66
|
-
import { createRequest, findRunId, formatRunComment, runAuthors, runBlocksOf, runIdFromBranch, MAX_NOTE_CHARS } from '../core/agent-contract.js';
|
|
66
|
+
import { createRequest, findRunId, formatRunComment, runAuthors, runBlocksOf, runIdFromBranch, validateReport, validateRequest, MAX_NOTE_CHARS } from '../core/agent-contract.js';
|
|
67
67
|
import { quietAfterMs } from '../core/config.js';
|
|
68
68
|
import { listPullProposals } from './proposal-pulls.js';
|
|
69
69
|
import { Refused, countAttempt, throttle } from './throttle.js';
|
|
@@ -81,12 +81,17 @@ export const FOLLOW_FLOOR_MS = 48 * 3_600_000;
|
|
|
81
81
|
export const RESERVATION_MS = 10 * 60_000;
|
|
82
82
|
/** The runs a listing answers */
|
|
83
83
|
export const LIST_LIMIT = 200;
|
|
84
|
+
/** The queued requests a runner's listing answers at most, oldest first */
|
|
85
|
+
export const QUEUE_LIMIT = 50;
|
|
86
|
+
/** The runs that are over once the runner has said so */
|
|
84
87
|
/** The keys of a request as the API and the button send it: the contract's fields, less what cornfield writes, plus `force` */
|
|
85
88
|
export const REQUEST_BODY_KEYS = Object.freeze(['kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'force']);
|
|
86
89
|
|
|
87
90
|
/** The states in which a run is over for good unless the runner says more */
|
|
88
91
|
export const FINISHED = Object.freeze(['done', 'failed', 'stopped', 'merged', 'closed']);
|
|
89
92
|
const REPORT_FINISHED = ['done', 'failed', 'stopped'];
|
|
93
|
+
/** Where a run is looked up for the dashboard and the poll: posted on the forge, or (push and pull) there is no comment to wait for */
|
|
94
|
+
const POSTED = "(comment_id <> '' OR transport <> 'forge')";
|
|
90
95
|
|
|
91
96
|
const iso = (ms) => new Date(ms).toISOString();
|
|
92
97
|
const lower = (s) => String(s).toLowerCase();
|
|
@@ -149,7 +154,8 @@ export function publicRun(row, nowMs, quietMs) {
|
|
|
149
154
|
return {
|
|
150
155
|
runId: row.run_id, kind: row.kind, profile: row.profile,
|
|
151
156
|
refs: { ...(row.issue != null ? { issue: row.issue } : {}), ...(row.proposal != null ? { proposal: row.proposal } : {}), ...(row.thread != null ? { thread: row.thread } : {}) },
|
|
152
|
-
status, open, requestedBy: row.requested_by, requestedAt: row.requested_at, via: row.via, target: row.target,
|
|
157
|
+
status, open, requestedBy: row.requested_by, requestedAt: row.requested_at, via: row.via, transport: row.transport, target: row.target,
|
|
158
|
+
claimedAt: row.claimed_at,
|
|
153
159
|
comment: row.comment_id ? { id: row.comment_id, url: row.comment_url } : null,
|
|
154
160
|
limits,
|
|
155
161
|
report: row.reported_at ? { state: row.state, at: row.reported_at, by: row.reported_by, note: row.note, url: row.url, tokens: row.tokens, cost: row.cost } : null,
|
|
@@ -187,6 +193,8 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
187
193
|
const runnerSet = new Set((Array.isArray(agents.runners) ? agents.runners : []).map(lower));
|
|
188
194
|
const authorSet = new Set((Array.isArray(agents.authors) ? agents.authors : []).map(lower));
|
|
189
195
|
const quietMs = quietAfterMs(config);
|
|
196
|
+
/** How requests travel from now on (`agents.transport`); a run keeps the transport it was asked for with */
|
|
197
|
+
const transport = /** @type {'forge' | 'push' | 'pull'} */ (agents.transport ?? 'forge');
|
|
190
198
|
const log = (what) => console.error(`[cornfield] agent runs: ${what}`);
|
|
191
199
|
let following = null;
|
|
192
200
|
|
|
@@ -204,7 +212,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
204
212
|
*/
|
|
205
213
|
function openRows(db, at) {
|
|
206
214
|
return /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND state NOT IN ('done', 'failed', 'stopped') ORDER BY requested_at").all(repo))
|
|
207
|
-
.filter((r) => (r.comment_id !== '' || at - Date.parse(r.requested_at) < RESERVATION_MS) && statusOf(r, at, quietMs).open);
|
|
215
|
+
.filter((r) => (r.comment_id !== '' || r.transport !== 'forge' || at - Date.parse(r.requested_at) < RESERVATION_MS) && statusOf(r, at, quietMs).open);
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
/** 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`. */
|
|
@@ -214,7 +222,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
214
222
|
db.exec('BEGIN IMMEDIATE');
|
|
215
223
|
try {
|
|
216
224
|
// What a crash left (a reservation never posted) is dropped here too, not only at the next start
|
|
217
|
-
db.prepare("DELETE FROM runs WHERE repo = ? AND comment_id = '' AND requested_at < ?").run(repo, iso(at - RESERVATION_MS));
|
|
225
|
+
db.prepare("DELETE FROM runs WHERE repo = ? AND comment_id = '' AND transport = 'forge' AND requested_at < ?").run(repo, iso(at - RESERVATION_MS));
|
|
218
226
|
const open = openRows(db, at);
|
|
219
227
|
if (open.length >= cap) {
|
|
220
228
|
throw new Refused(409, `${repo} already has ${open.length} open run${open.length === 1 ? '' : 's'}, the most cornfield asks for (agents.maxOpen is ${cap}): ${open.map((r) => r.run_id).join(', ')}. Wait for one to finish or go quiet, or stop one.`, 'cap-reached');
|
|
@@ -226,10 +234,10 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
226
234
|
throw new Refused(409, `${subjectWords(request.refs)} already has an open run${clash.profile ? ` under the profile ${clash.profile}` : ''}: ${clash.run_id} (${statusOf(clash, at, quietMs).status}). To ask for another on purpose, send "force": true${profiles.length ? ', or pick another profile' : ''}.`, 'run-open');
|
|
227
235
|
}
|
|
228
236
|
}
|
|
229
|
-
db.prepare(`INSERT INTO runs (run_id, repo, kind, profile, issue, proposal, thread, target, request, requested_by, requested_by_id, requested_at, via, last_activity)
|
|
230
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
237
|
+
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
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
231
239
|
.run(request.runId, repo, request.kind, request.profile ?? null, request.refs.issue ?? null, request.refs.proposal ?? null, request.refs.thread ?? null,
|
|
232
|
-
target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : 'dashboard', iso(at));
|
|
240
|
+
target, JSON.stringify(request), request.requestedBy, String(who.userId), request.requestedAt, via ? 'api' : 'dashboard', iso(at), transport);
|
|
233
241
|
db.exec('COMMIT');
|
|
234
242
|
} catch (err) {
|
|
235
243
|
try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
|
|
@@ -255,6 +263,7 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
255
263
|
}
|
|
256
264
|
if (typeof body.brief === 'string') out.briefChars = body.brief.length;
|
|
257
265
|
if (body.force === true) out.force = true;
|
|
266
|
+
if (transport !== 'forge') out.transport = transport;
|
|
258
267
|
if (via) Object.assign(out, { via: 'api', token: via.token });
|
|
259
268
|
return out;
|
|
260
269
|
}
|
|
@@ -327,10 +336,14 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
327
336
|
}
|
|
328
337
|
}
|
|
329
338
|
target = `${repo}#${number}`;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
comment =
|
|
339
|
+
// On the forge the request is a comment. On push and pull there is none: the run is recorded and the transport delivers it
|
|
340
|
+
let comment = null;
|
|
341
|
+
if (transport === 'forge') {
|
|
342
|
+
comment = formatRunComment(made.runId, made, { profiles });
|
|
343
|
+
if (via) {
|
|
344
|
+
const [head, ...rest] = comment.split('\n\n');
|
|
345
|
+
comment = [`${head} Sent through the agent API with the token \`${via.token.label}\`.`, ...rest].join('\n\n');
|
|
346
|
+
}
|
|
334
347
|
}
|
|
335
348
|
|
|
336
349
|
// The token once more, after every forge call: one revoked or expired meanwhile does not ask
|
|
@@ -340,20 +353,22 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
340
353
|
}
|
|
341
354
|
const db = await auth.store();
|
|
342
355
|
reserve(db, made, { target: number, who, via, force: force === true });
|
|
343
|
-
let posted;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
let posted = null;
|
|
357
|
+
if (comment !== null) {
|
|
358
|
+
try {
|
|
359
|
+
posted = await forge.comment(number, comment);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
db.prepare('DELETE FROM runs WHERE run_id = ?').run(made.runId);
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
|
|
349
365
|
}
|
|
350
|
-
db.prepare('UPDATE runs SET comment_id = ?, comment_url = ? WHERE run_id = ?').run(posted.id, posted.url ?? null, made.runId);
|
|
351
366
|
status = 201;
|
|
352
|
-
await audit({ login: me.login, action: 'agent-request', target, request: { ...asked, target: number }, status, answer: { comment: posted.id, url: posted.url ?? null } });
|
|
367
|
+
await audit({ login: me.login, action: 'agent-request', target, request: { ...asked, target: number }, status, answer: posted ? { comment: posted.id, url: posted.url ?? null } : { transport, queued: transport === 'pull' } });
|
|
353
368
|
hooks?.emit('agent.requested', { request: made, actor: me.login });
|
|
354
369
|
try { onAsk?.(); } catch { /* the poll is a convenience */ }
|
|
355
370
|
const row = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(made.runId));
|
|
356
|
-
return { run: publicRun(row, now(), quietMs), request: made, comment: { id: posted.id, url: posted.url ?? null, number } };
|
|
371
|
+
return { run: publicRun(row, now(), quietMs), request: made, comment: posted ? { id: posted.id, url: posted.url ?? null, number } : null };
|
|
357
372
|
} catch (err) {
|
|
358
373
|
if (status === null) {
|
|
359
374
|
const out = describe(err);
|
|
@@ -407,30 +422,45 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
407
422
|
const why = await ctx.stillAllowed();
|
|
408
423
|
if (why) throw new Refused(403, why, 'forbidden');
|
|
409
424
|
}
|
|
410
|
-
let comment;
|
|
411
|
-
try {
|
|
412
|
-
comment = formatRunComment(row.run_id, { state: 'stop-requested', ...(note ? { note } : {}) }, { line: `Asked run \`${row.run_id}\` to stop (${me.login}, in cornfield${via ? `, through the agent API with the token \`${via.token.label}\`` : ''}).` });
|
|
413
|
-
} catch (err) {
|
|
414
|
-
throw new Refused(400, words(err), 'invalid');
|
|
415
|
-
}
|
|
416
|
-
const posted = await forge.comment(row.target, comment);
|
|
417
425
|
const stamp = iso(now());
|
|
426
|
+
/** A pull run nobody has claimed has no runner to stop: cornfield ends it itself, saying so (the one `stopped` it writes) */
|
|
427
|
+
const unclaimed = row.transport === 'pull' && row.claimed_by == null && row.state === 'requested';
|
|
428
|
+
let posted = null;
|
|
429
|
+
if (row.transport === 'forge') {
|
|
430
|
+
let comment;
|
|
431
|
+
try {
|
|
432
|
+
comment = formatRunComment(row.run_id, { state: 'stop-requested', ...(note ? { note } : {}) }, { line: `Asked run \`${row.run_id}\` to stop (${me.login}, in cornfield${via ? `, through the agent API with the token \`${via.token.label}\`` : ''}).` });
|
|
433
|
+
} catch (err) {
|
|
434
|
+
throw new Refused(400, words(err), 'invalid');
|
|
435
|
+
}
|
|
436
|
+
posted = await forge.comment(row.target, comment);
|
|
437
|
+
}
|
|
418
438
|
// Judged already: the follow that reads this comment back must not fire the hook again
|
|
419
439
|
db.exec('BEGIN IMMEDIATE');
|
|
420
440
|
try {
|
|
421
441
|
db.prepare('UPDATE runs SET stop_requested_at = ?, stop_requested_by = ?, last_activity = MAX(last_activity, ?) WHERE run_id = ?').run(stamp, me.login, stamp, row.run_id);
|
|
422
|
-
|
|
423
|
-
|
|
442
|
+
if (posted) {
|
|
443
|
+
db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
|
|
444
|
+
db.prepare('INSERT OR IGNORE INTO run_seen (run_id, key, at) VALUES (?, ?, ?)').run(row.run_id, `ev:stop:${posted.id}:c:${posted.id}:${row.run_id}:stop-request:0`, stamp);
|
|
445
|
+
}
|
|
446
|
+
if (unclaimed) {
|
|
447
|
+
const ended = db.prepare("UPDATE runs SET state = 'stopped', reported_at = ?, reported_by = ?, note = ? WHERE run_id = ? AND state = 'requested' AND claimed_by IS NULL").run(stamp, me.login, 'Stopped before a runner claimed it.', row.run_id).changes;
|
|
448
|
+
if (ended !== 1) throw new Refused(409, `Run ${row.run_id} was claimed just now; ask again to stop it.`, 'conflict');
|
|
449
|
+
}
|
|
424
450
|
db.exec('COMMIT');
|
|
425
451
|
} catch (err) {
|
|
426
452
|
try { db.exec('ROLLBACK'); } catch { /* nothing was begun */ }
|
|
427
453
|
throw err;
|
|
428
454
|
}
|
|
429
455
|
done = true;
|
|
430
|
-
await audit({ login: me.login, action: 'agent-stop', target, request: { ...asked, ...(note ? { noteChars: note.length } : {}) }, status: 201, answer: { comment: posted.id, url: posted.url ?? null } });
|
|
456
|
+
await audit({ login: me.login, action: 'agent-stop', target, request: { ...asked, ...(note ? { noteChars: note.length } : {}) }, status: 201, answer: posted ? { comment: posted.id, url: posted.url ?? null } : { transport: row.transport, ...(unclaimed ? { stopped: true } : {}) } });
|
|
431
457
|
const after = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(row.run_id));
|
|
432
458
|
hooks?.emit('run.stopRequested', runPayload(after, me.login));
|
|
433
|
-
|
|
459
|
+
if (unclaimed) {
|
|
460
|
+
hooks?.emit('run.state', { ...runPayload(after, me.login), state: 'stopped' });
|
|
461
|
+
hooks?.emit('run.finished', { ...runPayload(after, me.login), state: 'stopped' });
|
|
462
|
+
}
|
|
463
|
+
return { run: publicRun(after, now(), quietMs), comment: posted ? { id: posted.id, url: posted.url ?? null } : null };
|
|
434
464
|
} catch (err) {
|
|
435
465
|
if (!done) {
|
|
436
466
|
const out = describe(err);
|
|
@@ -456,21 +486,21 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
456
486
|
async function list({ limit = LIST_LIMIT } = {}) {
|
|
457
487
|
const db = await auth.store();
|
|
458
488
|
const at = now();
|
|
459
|
-
const rows = /** @type {RunRow[]} */ (db.prepare(
|
|
489
|
+
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));
|
|
460
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 };
|
|
461
491
|
}
|
|
462
492
|
|
|
463
493
|
/** One run, or null */
|
|
464
494
|
async function get(runId) {
|
|
465
495
|
const db = await auth.store();
|
|
466
|
-
const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare(
|
|
496
|
+
const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare(`SELECT * FROM runs WHERE run_id = ? AND repo = ? AND ${POSTED}`).get(runId, repo)) : undefined;
|
|
467
497
|
return row ? publicRun(row, now(), quietMs) : null;
|
|
468
498
|
}
|
|
469
499
|
|
|
470
500
|
/** The runs a follow looks at: posted, not over for good (a trusted pull request merged or closed), and active within the follow window */
|
|
471
501
|
function followable(db, at) {
|
|
472
502
|
const window = Math.max(2 * quietMs, FOLLOW_FLOOR_MS);
|
|
473
|
-
return /** @type {RunRow[]} */ (db.prepare(
|
|
503
|
+
return /** @type {RunRow[]} */ (db.prepare(`SELECT * FROM runs WHERE repo = ? AND ${POSTED} AND last_activity > ? AND NOT (pull_trusted = 1 AND pull_state IN ('merged', 'closed')) ORDER BY last_activity DESC LIMIT ?`)
|
|
474
504
|
.all(repo, iso(at - window), MAX_FOLLOWED));
|
|
475
505
|
}
|
|
476
506
|
|
|
@@ -607,7 +637,8 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
607
637
|
// ---- the comments -----------------------------------------------------
|
|
608
638
|
/** @type {Map<number, Set<string>>} */
|
|
609
639
|
const places = new Map();
|
|
610
|
-
for
|
|
640
|
+
// A run asked for over push or pull is reported to over the API, with a runner token: a comment on the forge does not move it
|
|
641
|
+
for (const r of cur.values()) if (r.transport === 'forge') for (const n of [r.target, r.pull]) if (n != null) places.set(n, (places.get(n) ?? new Set()).add(r.run_id));
|
|
611
642
|
/** @type {{ row: RunRow, id: string, key: string, at: string, by: string | null, block: any }[]} */
|
|
612
643
|
const heard = [];
|
|
613
644
|
for (const [number, ids] of places) {
|
|
@@ -756,14 +787,120 @@ export function createRuns({ config, where, auth, hooks = null, now = Date.now,
|
|
|
756
787
|
return out;
|
|
757
788
|
}
|
|
758
789
|
|
|
790
|
+
// -------------------------------------------------------------------------
|
|
791
|
+
// Runners: pull and push (a runner token, server/runner-api.js; spec 0005 D2, D5)
|
|
792
|
+
// -------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
/** @typedef {{ id: number, label: string }} RunnerToken the runner token a call came with */
|
|
795
|
+
|
|
796
|
+
const runnerActor = (token) => `runner:${token.label}`;
|
|
797
|
+
|
|
798
|
+
/** A run for a runner: the state it is in as cornfield derives it, and whether a maintainer asked it to stop. Nothing else about the run, no credential */
|
|
799
|
+
function runnerView(row) {
|
|
800
|
+
const st = statusOf(row, now(), quietMs);
|
|
801
|
+
return { runId: row.run_id, state: st.status, reported: row.state === 'requested' ? null : row.state, stopRequested: row.stop_requested_at != null, ...(row.stop_requested_at ? { stopRequestedAt: row.stop_requested_at } : {}), open: st.open, finished: st.finished };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** The run a runner token names, or a `Refused`: 404 when there is none, 403 when the token may not touch it */
|
|
805
|
+
function runnerRun(db, token, runId) {
|
|
806
|
+
const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare(`SELECT * FROM runs WHERE run_id = ? AND repo = ? AND ${POSTED}`).get(runId, repo)) : undefined;
|
|
807
|
+
if (!row) throw new Refused(404, 'cornfield has no such run.', 'not-found');
|
|
808
|
+
if (row.transport === 'forge') throw new Refused(403, 'This run was asked for on the forge, not over push or pull. A runner token does not reach it: report it as a cornfield-run comment.', 'forbidden');
|
|
809
|
+
if (row.transport === 'pull' && row.claimed_by == null) throw new Refused(403, 'Nobody has claimed this run. Claim it first: POST /api/agent-requests/<runId>/claim.', 'forbidden');
|
|
810
|
+
if (row.claimed_by != null && row.claimed_by !== token.id) throw new Refused(403, 'Another runner token holds this run. Only the token that claimed it (for a pushed run, the first to report on it) reads and reports on it.', 'forbidden');
|
|
811
|
+
return row;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* The requests waiting for a runner to claim: pull runs nobody has claimed, not asked to stop, not quiet, oldest first, at most `QUEUE_LIMIT`.
|
|
816
|
+
* Each is the contract's request as it was made, checked once more on the way out.
|
|
817
|
+
*/
|
|
818
|
+
async function queued() {
|
|
819
|
+
const db = await auth.store();
|
|
820
|
+
const at = now();
|
|
821
|
+
const rows = /** @type {RunRow[]} */ (db.prepare("SELECT * FROM runs WHERE repo = ? AND transport = 'pull' AND state = 'requested' AND claimed_by IS NULL AND stop_requested_at IS NULL AND last_activity > ? ORDER BY requested_at, run_id LIMIT ?")
|
|
822
|
+
.all(repo, iso(at - quietMs), QUEUE_LIMIT));
|
|
823
|
+
return rows.flatMap((r) => {
|
|
824
|
+
const checked = validateRequest(safeJson(r.request));
|
|
825
|
+
if (!checked.ok) { log(`the request ${r.run_id} is not a valid request any more and is not offered: ${checked.error}`); return []; }
|
|
826
|
+
return [checked.value];
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const safeJson = (text) => { try { return JSON.parse(text); } catch { return null; } };
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Claims a queued request for a runner token. ONE statement decides who wins (`UPDATE … WHERE claimed_by IS NULL`), so two claims at once
|
|
834
|
+
* cannot both succeed: the second is a 409. Answers the request and the run as the runner sees it.
|
|
835
|
+
* @param {RunnerToken} token
|
|
836
|
+
* @param {unknown} runId
|
|
837
|
+
*/
|
|
838
|
+
async function claim(token, runId) {
|
|
839
|
+
const db = await auth.store();
|
|
840
|
+
const at = now();
|
|
841
|
+
const stamp = iso(at);
|
|
842
|
+
const won = typeof runId === 'string' ? db.prepare(`UPDATE runs SET claimed_by = ?, claimed_at = ?, state = 'claimed', reported_at = ?, reported_by = ?, note = NULL, last_activity = MAX(last_activity, ?)
|
|
843
|
+
WHERE run_id = ? AND repo = ? AND transport = 'pull' AND state = 'requested' AND claimed_by IS NULL AND stop_requested_at IS NULL AND last_activity > ?`)
|
|
844
|
+
.run(token.id, stamp, stamp, token.label, stamp, runId, repo, iso(at - quietMs)).changes === 1 : false;
|
|
845
|
+
const row = typeof runId === 'string' ? /** @type {RunRow | undefined} */ (db.prepare('SELECT * FROM runs WHERE run_id = ? AND repo = ?').get(runId, repo)) : undefined;
|
|
846
|
+
if (!row) throw new Refused(404, 'cornfield has no such request.', 'not-found');
|
|
847
|
+
if (!won) {
|
|
848
|
+
if (row.transport !== 'pull') throw new Refused(409, `This request goes over ${row.transport === 'push' ? 'push (an agent.requested hook event), not pull' : 'the forge, not pull'}; there is nothing to claim.`, 'conflict');
|
|
849
|
+
if (row.claimed_by != null) throw new Refused(409, 'Another runner claimed this request first.', 'already-claimed');
|
|
850
|
+
if (row.stop_requested_at) throw new Refused(409, 'A maintainer asked to stop this run before anyone claimed it.', 'conflict');
|
|
851
|
+
if (statusOf(row, at, quietMs).quiet) throw new Refused(409, 'This request has waited past agents.quietAfter and is no longer offered.', 'conflict');
|
|
852
|
+
throw new Refused(409, `This request is ${row.state}; there is nothing to claim.`, 'conflict');
|
|
853
|
+
}
|
|
854
|
+
const checked = validateRequest(safeJson(row.request));
|
|
855
|
+
hooks?.emit('run.claimed', { ...runPayload(row, runnerActor(token)), state: 'claimed' });
|
|
856
|
+
hooks?.emit('run.state', { ...runPayload(row, runnerActor(token)), state: 'claimed' });
|
|
857
|
+
return { request: checked.ok ? checked.value : null, run: runnerView(row), row };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** One run as the runner that holds it sees it: `GET /api/runs/<runId>`, how a runner learns it was asked to stop */
|
|
861
|
+
async function runnerGet(token, runId) {
|
|
862
|
+
const db = await auth.store();
|
|
863
|
+
return runnerView(runnerRun(db, token, runId));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* A runner's report: the contract's report JSON, from the token that holds the run (the one that claimed it; for a pushed run, the first to
|
|
868
|
+
* report). The same run state and hooks as a report on the forge. A run that is over takes no more.
|
|
869
|
+
* @param {RunnerToken} token
|
|
870
|
+
* @param {unknown} runId
|
|
871
|
+
* @param {unknown} body
|
|
872
|
+
*/
|
|
873
|
+
async function report(token, runId, body) {
|
|
874
|
+
const db = await auth.store();
|
|
875
|
+
const row = runnerRun(db, token, runId);
|
|
876
|
+
const checked = validateReport(body);
|
|
877
|
+
if (!checked.ok) throw new Refused(400, checked.error, 'invalid');
|
|
878
|
+
const r = checked.value;
|
|
879
|
+
const at = now();
|
|
880
|
+
const was = statusOf(row, at, quietMs);
|
|
881
|
+
if (was.finished) throw new Refused(409, `Run ${row.run_id} is ${was.status}; it takes no more reports.`, 'conflict');
|
|
882
|
+
const stamp = iso(at);
|
|
883
|
+
const changed = db.prepare(`UPDATE runs SET state = ?, reported_at = ?, reported_by = ?, note = ?, url = ?, tokens = COALESCE(?, tokens), cost = COALESCE(?, cost),
|
|
884
|
+
claimed_by = COALESCE(claimed_by, ?), claimed_at = COALESCE(claimed_at, ?), last_activity = MAX(last_activity, ?)
|
|
885
|
+
WHERE run_id = ? AND (claimed_by IS NULL OR claimed_by = ?) AND state NOT IN ('done', 'failed', 'stopped')`)
|
|
886
|
+
.run(r.state, stamp, token.label, r.note ?? null, r.url ?? null, r.tokens ?? null, r.cost ?? null, token.id, stamp, stamp, row.run_id, token.id).changes;
|
|
887
|
+
if (changed !== 1) throw new Refused(409, `Run ${row.run_id} changed while the report was being taken; read it again (GET /api/runs/${row.run_id}).`, 'conflict');
|
|
888
|
+
const after = /** @type {RunRow} */ (db.prepare('SELECT * FROM runs WHERE run_id = ?').get(row.run_id));
|
|
889
|
+
const actor = runnerActor(token);
|
|
890
|
+
if (r.state === 'claimed' && row.state !== 'claimed') hooks?.emit('run.claimed', { ...runPayload(after, actor), state: r.state });
|
|
891
|
+
if (r.state !== row.state) hooks?.emit('run.state', { ...runPayload(after, actor), state: r.state });
|
|
892
|
+
if (REPORT_FINISHED.includes(r.state)) hooks?.emit('run.finished', { ...runPayload(after, actor), state: r.state });
|
|
893
|
+
return { run: runnerView(after), row: after, was: row };
|
|
894
|
+
}
|
|
895
|
+
|
|
759
896
|
/** Drops what a crash left: a request that was never posted. Run once at start. */
|
|
760
897
|
async function resume() {
|
|
761
898
|
const db = await auth.store();
|
|
762
|
-
const gone = db.prepare("DELETE FROM runs WHERE comment_id = '' AND requested_at < ?").run(iso(now() - RESERVATION_MS)).changes;
|
|
899
|
+
const gone = db.prepare("DELETE FROM runs WHERE comment_id = '' AND transport = 'forge' AND requested_at < ?").run(iso(now() - RESERVATION_MS)).changes;
|
|
763
900
|
if (gone) log(`${gone} request${gone === 1 ? '' : 's'} that never reached the forge ${gone === 1 ? 'was' : 'were'} dropped`);
|
|
764
901
|
}
|
|
765
902
|
|
|
766
|
-
return { request, stop, list, get, follow, hasWork, resume, maxOpen: cap, profiles };
|
|
903
|
+
return { request, stop, list, get, follow, hasWork, resume, queued, claim, runnerGet, report, maxOpen: cap, profiles, transport };
|
|
767
904
|
}
|
|
768
905
|
|
|
769
906
|
/** @typedef {ReturnType<typeof createRuns>} Runs */
|