@popcorn245/cornfield 0.7.0 → 0.9.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/README.md +1 -0
- package/core/agent-contract.js +5 -2
- package/core/agent-contract.schema.json +9 -9
- package/core/config.js +94 -11
- package/core/mentions.js +239 -0
- package/explorer/explorer.css +12 -1
- package/explorer/forge-panel-model.js +88 -21
- package/explorer/forge-panel.js +512 -19
- package/explorer/index.html +46 -0
- package/explorer/panels.js +24 -0
- package/explorer/review-tokens-model.js +5 -1
- package/explorer/review-tokens.js +11 -7
- package/explorer/runs-model.js +107 -0
- package/explorer/state.js +7 -0
- package/package.json +1 -1
- package/server/agent-api.js +227 -0
- package/server/agent-runs.js +769 -0
- package/server/db.js +68 -0
- package/server/forge-actions.js +106 -5
- package/server/forge-forgejo.js +33 -5
- package/server/forge-github.js +12 -4
- package/server/forge-routes.js +33 -1
- package/server/forge.js +24 -7
- package/server/hooks.js +100 -1
- package/server/proposal-pulls.js +3 -3
- package/server/review-api.js +26 -12
- package/server/review-tokens.js +42 -25
- package/server/server.js +32 -8
- package/server/spec-poll.js +71 -14
- package/server/threads.js +275 -0
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ ssh -L 3350:127.0.0.1:3350 you@the-machine
|
|
|
42
42
|
- **Derived, not drawn by hand.** `cornfield sync` runs the derivers named in your config and merges what they find into `topology.json`; hand-written parts (descriptions, flows, anything of a type no deriver owns) are kept. `cornfield validate` fails on drift, so CI keeps the map honest.
|
|
43
43
|
- **Proposals.** `docs/specs/proposals/NNNN-slug.json` says what a change would add, change and remove, with scenarios. cornfield lists them from your checkout, from every git worktree in the configured worktrees folder and, with sign-in, from your open spec PRs, and shows Before / Diff / After, the scenario player, what-if edits, and Approve / Send back. Writing, reviewing and landing one in your repo: [docs/proposals.md](docs/proposals.md). The format and its validator: [core/proposal.js](core/proposal.js).
|
|
44
44
|
- **Issues and pull requests.** With a forge in the config and sign-in, a panel of its pull requests and issues (GitHub; Forgejo behind the same interface), with filters. Select a pull request and the nodes its files are light up on the map, and a failing check lights the tests it names. A Deploys list reads the forge's workflow runs and lights what a selected deploy restarted, from a mapping in the config ([docs/deploys.md](docs/deploys.md)). A maintainer can land a green pull request, re-run a failed check, start a spec on an issue and open the build issue of an approved spec, each one button with a confirm step. A bottom sheet on a phone. A **Release notes** tab drafts the notes from the pull requests merged since the last tag, grouped by what they changed on the map, to copy or to post as a draft release (never published); `cornfield notes` prints the same Markdown. [docs/issues-and-prs.md](docs/issues-and-prs.md).
|
|
45
|
+
- **Threads.** The panel's Threads tab shows ideas (issues labelled `idea`) and spec PRs' conversations from the forge, read-only, and lights the nodes they mention with `[[node-id]]` or `[[repo/path]]`; a node's panel lists the threads that mention it. "New idea" opens an issue as you. Hooks tell outside code when a thread opens, gets a comment or is linked to the map. Nothing is kept in cornfield. [docs/threads.md](docs/threads.md).
|
|
45
46
|
- **Agents, tracked.** The panel's Agents tab groups branches, spec PRs and PRs by the issue and the proposal they belong to, and marks the work agents made (by author, bot account, label or branch prefix, set in the config). Nothing is launched. [docs/agents.md](docs/agents.md).
|
|
46
47
|
- **Personas, not people.** `topology.personas.json` holds archetypes (Admin, Member, Guest); scenarios act as them, never as a real person.
|
|
47
48
|
- **The cloud you do not control.** `topology.cloud.json` describes the outside services; the cloud deriver's detectors (hosts in code, Dockerfiles, container images, CI workflows, a Caddyfile) keep it complete in both directions. "Cloudflare is down" plays as an outage: what breaks now, at the next deploy, at the next certificate renewal. [docs/cloud.md](docs/cloud.md).
|
package/core/agent-contract.js
CHANGED
|
@@ -118,7 +118,8 @@ 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,
|
|
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
|
|
122
|
+
* private key, a bearer header, a user and password in a link.
|
|
122
123
|
* Prose that only talks about tokens and passwords passes; `sk-` keys and bearer values need a digit in their first 41
|
|
123
124
|
* characters, as real ones have, so a long hyphenated word after "Bearer" or "sk-" is not taken for one. Each alternative tries a
|
|
124
125
|
* bounded stretch from each place it may start, so a 20000 character brief is read in linear time whatever it holds.
|
|
@@ -128,6 +129,7 @@ export const CREDENTIAL_VALUE_SOURCE = [
|
|
|
128
129
|
`${EDGE}github_pat_[A-Za-z0-9_]{20,}`,
|
|
129
130
|
`${EDGE}glpat-[A-Za-z0-9_-]{15,}`,
|
|
130
131
|
`${EDGE}gto-[A-Za-z0-9]{20,}`,
|
|
132
|
+
`${EDGE}cf_(?:rt|at)_[A-Za-z0-9_-]{20,}`,
|
|
131
133
|
`${EDGE}sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]`,
|
|
132
134
|
`${EDGE}AKIA[0-9A-Z]{16}`,
|
|
133
135
|
`${EDGE}AIza[0-9A-Za-z_-]{35}`,
|
|
@@ -624,7 +626,8 @@ export function runBlocksOf(comments, { trust, profiles } = /** @type {any} */ (
|
|
|
624
626
|
const by = c.author?.login ?? null;
|
|
625
627
|
const comment = { id: String(c.id), url: c.url ?? null };
|
|
626
628
|
for (const r of read.refused) out.refused.push({ ...r, by, comment });
|
|
627
|
-
|
|
629
|
+
// A time that does not parse is not taken for "never edited": only a comment the forge says it left alone counts
|
|
630
|
+
const edited = c.updatedAt !== undefined && !(Date.parse(c.updatedAt) - Date.parse(c.createdAt) <= EDIT_GRACE_MS);
|
|
628
631
|
for (const b of read.blocks) {
|
|
629
632
|
const found = { ...b, by, at: c.createdAt, comment };
|
|
630
633
|
if (by === null || !trust(by, b)) out.ignored.push({ ...found, why: 'author' });
|
|
@@ -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])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)_[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])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)_[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])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)_[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])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)_[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])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)_[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])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)_[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])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)_[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])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)_[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])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)_[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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* },
|
|
14
14
|
* forge: { type: 'github', url: 'https://github.com', repo: 'owner/name' }, // or type 'forgejo'; mergeMethod: 'merge' | 'squash' | 'rebase'; baseBranch: 'main'
|
|
15
15
|
* deploys: { workflows: ['*deploy*'], restarts: { 'Deploy web': ['stack-web'] } }, // which workflow runs are deploys, and what they restart (docs/deploys.md)
|
|
16
|
-
* agents: { authors: [], labels: [], branchPrefixes: [], bots: true }, // what marks work as made by an agent (docs/agents.md)
|
|
16
|
+
* agents: { authors: [], labels: [], branchPrefixes: [], bots: true }, // what marks work as made by an agent (docs/agents.md); and how runs are asked for and followed: transport, maxOpen, profiles, runners, quietAfter, limits (docs/config.md#agents)
|
|
17
17
|
* port: 3350,
|
|
18
18
|
* host: '127.0.0.1', // loopback, unless sign-in is configured with an https publicUrl (server/bind.js)
|
|
19
19
|
* publicUrl: null, // the origin people open it at; sign-in needs it (server/auth.js)
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* personas: { roles: null }, // the roles a persona may have (null: any)
|
|
24
24
|
* releaseNotes: { breakingLabel: 'breaking' }, // the pull request label that lists a change under "Breaking" in the release notes
|
|
25
25
|
* hooks: [], // [{ url, events }]: signed JSON events posted to outside code (docs/config.md#hooks); the secret is env only
|
|
26
|
-
* hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs on its own, for the spec.ready
|
|
26
|
+
* hookPoll: { specs: '5m' }, // how often cornfield looks for ready specs and thread activity on its own, for the spec.ready and thread.* hooks; false turns it off (docs/config.md#hook-poll)
|
|
27
|
+
* threads: { label: 'idea' }, // an idea is an issue with this label, its thread the issue's comments; false turns threads off (docs/threads.md)
|
|
27
28
|
* links: [], // [{ label, href }] shown in the explorer's header
|
|
28
29
|
* proxy: {}, // { '/api/': 'http://127.0.0.1:3245' }: same-origin paths for a live source
|
|
29
30
|
* importMap: {}, // bare imports a repo-local browser module needs: { name: '/repo/path.js' }
|
|
@@ -39,6 +40,7 @@ import fs from 'node:fs';
|
|
|
39
40
|
import os from 'node:os';
|
|
40
41
|
import path from 'node:path';
|
|
41
42
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
43
|
+
import { PROFILE_SOURCE, MAX_NUMBER } from './agent-contract.js';
|
|
42
44
|
|
|
43
45
|
export const CONFIG_FILE = 'cornfield.config.js';
|
|
44
46
|
export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -66,7 +68,12 @@ export const FORGE_TYPES = ['github', 'forgejo'];
|
|
|
66
68
|
/** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
|
|
67
69
|
export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
|
|
68
70
|
/** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
|
|
69
|
-
export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring']);
|
|
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']);
|
|
72
|
+
/** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
|
|
73
|
+
export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
|
|
74
|
+
/** The keys of `threads` when it is an object, and the label's longest length */
|
|
75
|
+
export const THREADS_KEYS = Object.freeze(['label']);
|
|
76
|
+
export const MAX_LABEL = 50;
|
|
70
77
|
/** The most hooks a config lists */
|
|
71
78
|
export const MAX_HOOKS = 20;
|
|
72
79
|
/** The bounds of `hookPoll.specs` in milliseconds: not under a minute (the forge is not hammered), not over an hour */
|
|
@@ -85,18 +92,49 @@ export function parseDuration(v) {
|
|
|
85
92
|
return m ? Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2]] : null;
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
/** Whether some hook subscribes to any of `events` (by name, or with '*') */
|
|
96
|
+
export function hookWants(config, events) {
|
|
97
|
+
return Array.isArray(config.hooks) && config.hooks.some((h) => h.events === '*' || (Array.isArray(h.events) && h.events.some((e) => events.includes(e))));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether threads are on: `threads: false` turns them off, anything else (the default included) leaves them on */
|
|
101
|
+
export const threadsOn = (config) => config.threads !== false;
|
|
102
|
+
|
|
103
|
+
/** The label that makes an issue an idea, or null when threads are off */
|
|
104
|
+
export const ideaLabel = (config) => (threadsOn(config) ? (config.threads && config.threads.label) || DEFAULTS.threads.label : null);
|
|
105
|
+
|
|
88
106
|
/**
|
|
89
|
-
* How often cornfield looks
|
|
90
|
-
*
|
|
91
|
-
*
|
|
107
|
+
* How often cornfield looks by itself, in milliseconds, or null when it does not: when `hookPoll.specs` is false, or no
|
|
108
|
+
* hook subscribes to something the look finds (`spec.ready`, or a `thread.*` event while threads are on), by name or with '*'.
|
|
109
|
+
* One look covers both: the open spec PRs and the threads (server/spec-poll.js).
|
|
110
|
+
* @param {{ hooks?: { events: string[] | '*' }[], hookPoll?: { specs?: string | false } | false, threads?: false | { label?: string } }} config a loaded config
|
|
92
111
|
* @returns {number | null}
|
|
93
112
|
*/
|
|
94
113
|
export function specPollMs(config) {
|
|
95
|
-
const wanted =
|
|
114
|
+
const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && hookWants(config, THREAD_EVENTS));
|
|
96
115
|
const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
|
|
97
116
|
return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
|
|
98
117
|
}
|
|
99
118
|
|
|
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
|
+
/** `agents.maxOpen`: the most open runs per repo cornfield will ask for, by default and at most */
|
|
122
|
+
export const DEFAULT_MAX_OPEN = 3;
|
|
123
|
+
export const MAX_OPEN_CEILING = 100;
|
|
124
|
+
/** `agents.quietAfter`: how long a run may show no report, push or pull request activity before it is quiet; its bounds in milliseconds */
|
|
125
|
+
export const DEFAULT_QUIET_AFTER = '24h';
|
|
126
|
+
export const QUIET_MIN_MS = 5 * 60_000;
|
|
127
|
+
export const QUIET_MAX_MS = 30 * 24 * 3_600_000;
|
|
128
|
+
/** The most profiles and runners a config lists */
|
|
129
|
+
export const MAX_PROFILES = 20;
|
|
130
|
+
export const MAX_RUNNERS = 50;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How long a run may be silent before it is quiet, in milliseconds: `agents.quietAfter`, '24h' unless set.
|
|
134
|
+
* @param {{ agents?: { quietAfter?: string } }} config a loaded config
|
|
135
|
+
*/
|
|
136
|
+
export const quietAfterMs = (config) => parseDuration(config.agents?.quietAfter ?? DEFAULT_QUIET_AFTER) ?? parseDuration(DEFAULT_QUIET_AFTER);
|
|
137
|
+
|
|
100
138
|
export const DEFAULTS = Object.freeze({
|
|
101
139
|
title: null,
|
|
102
140
|
derivers: ['starter', 'personas', 'cloud'],
|
|
@@ -112,9 +150,10 @@ export const DEFAULTS = Object.freeze({
|
|
|
112
150
|
personas: { roles: null, parent: 'root' },
|
|
113
151
|
cloud: { parent: 'root' },
|
|
114
152
|
releaseNotes: { breakingLabel: 'breaking' },
|
|
115
|
-
agents: { authors: [], labels: [], branchPrefixes: [], bots: true },
|
|
153
|
+
agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {} },
|
|
116
154
|
hooks: [],
|
|
117
155
|
hookPoll: { specs: '5m' },
|
|
156
|
+
threads: { label: 'idea' },
|
|
118
157
|
links: [],
|
|
119
158
|
proxy: {},
|
|
120
159
|
importMap: {},
|
|
@@ -124,7 +163,8 @@ export const DEFAULTS = Object.freeze({
|
|
|
124
163
|
const KEYS = Object.keys(DEFAULTS);
|
|
125
164
|
/** 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) */
|
|
126
165
|
export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
|
|
127
|
-
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots'];
|
|
166
|
+
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits'];
|
|
167
|
+
const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
|
|
128
168
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
129
169
|
const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
130
170
|
/** A path inside the repo: relative, forward slashes, no `..` segment */
|
|
@@ -184,7 +224,7 @@ export function configProblems(c) {
|
|
|
184
224
|
}
|
|
185
225
|
}
|
|
186
226
|
if (c.agents !== undefined) {
|
|
187
|
-
if (!isObject(c.agents)) out.push(
|
|
227
|
+
if (!isObject(c.agents)) out.push(`"agents" is { ${AGENT_KEYS.join(', ')} }.`);
|
|
188
228
|
else {
|
|
189
229
|
for (const k of Object.keys(c.agents)) if (!AGENT_KEYS.includes(k)) out.push(`agents.${k}: the keys are ${AGENT_KEYS.join(', ')}.`);
|
|
190
230
|
for (const k of ['authors', 'labels', 'branchPrefixes']) {
|
|
@@ -196,6 +236,7 @@ export function configProblems(c) {
|
|
|
196
236
|
}
|
|
197
237
|
}
|
|
198
238
|
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
|
+
out.push(...agentRunProblems(c.agents));
|
|
199
240
|
}
|
|
200
241
|
}
|
|
201
242
|
if (c.deploys !== undefined && c.deploys !== null) {
|
|
@@ -241,6 +282,14 @@ export function configProblems(c) {
|
|
|
241
282
|
}
|
|
242
283
|
}
|
|
243
284
|
}
|
|
285
|
+
if (c.threads !== undefined && c.threads !== false) {
|
|
286
|
+
if (!isObject(c.threads)) out.push('"threads" is { label: "idea" }, or false to turn threads off.');
|
|
287
|
+
else {
|
|
288
|
+
for (const k of Object.keys(c.threads)) if (!THREADS_KEYS.includes(k)) out.push(`threads.${k}: the key is ${THREADS_KEYS.join(', ')}.`);
|
|
289
|
+
const l = c.threads.label;
|
|
290
|
+
if (l !== undefined && (!isText(l) || l.includes(',') || l.trim() !== l || l.length > MAX_LABEL)) out.push(`threads.label is the name of the label that makes an issue an idea, like "idea": text of at most ${MAX_LABEL} characters, no comma, no space at either end.`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
244
293
|
if (c.releaseNotes !== undefined) {
|
|
245
294
|
if (!isObject(c.releaseNotes)) out.push('"releaseNotes" is { breakingLabel }.');
|
|
246
295
|
else if (c.releaseNotes.breakingLabel !== undefined && (!isText(c.releaseNotes.breakingLabel) || c.releaseNotes.breakingLabel.includes(','))) out.push('releaseNotes.breakingLabel is a label\'s name, like "breaking".');
|
|
@@ -279,6 +328,38 @@ export function configProblems(c) {
|
|
|
279
328
|
return out;
|
|
280
329
|
}
|
|
281
330
|
|
|
331
|
+
/** What is wrong with the keys of `agents` that ask for and follow runs (spec 0005), in words */
|
|
332
|
+
function agentRunProblems(a) {
|
|
333
|
+
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).`);
|
|
335
|
+
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
|
+
if (a.profiles !== undefined) {
|
|
337
|
+
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) {
|
|
338
|
+
out.push(`agents.profiles is a list of at most ${MAX_PROFILES} different names ("claude", "local-qwen"): letters, digits and . _ -, at most 64 characters each. The runner maps each to a model; cornfield only knows the name.`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (a.runners !== undefined) {
|
|
342
|
+
if (!Array.isArray(a.runners) || a.runners.length > MAX_RUNNERS || !a.runners.every((r) => isText(r) && r === r.trim() && !/[\s,]/.test(r))) {
|
|
343
|
+
out.push(`agents.runners is a list of at most ${MAX_RUNNERS} forge logins ("claude-bot"), the runners' own accounts whose reports count besides a maintainer's.`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (a.quietAfter !== undefined) {
|
|
347
|
+
const ms = parseDuration(a.quietAfter);
|
|
348
|
+
if (ms === null) out.push('agents.quietAfter is a duration like "90m", "24h" or "72h".');
|
|
349
|
+
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
|
+
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
|
+
}
|
|
352
|
+
if (a.limits !== undefined) {
|
|
353
|
+
if (!isObject(a.limits)) out.push('agents.limits is { minutes?, turns?, cost? }: hints the runner may enforce.');
|
|
354
|
+
else {
|
|
355
|
+
for (const k of Object.keys(a.limits)) if (!['minutes', 'turns', 'cost'].includes(k)) out.push(`agents.limits.${k}: the keys are minutes, turns, cost.`);
|
|
356
|
+
for (const k of ['minutes', 'cost']) if (a.limits[k] !== undefined && !(typeof a.limits[k] === 'number' && Number.isFinite(a.limits[k]) && a.limits[k] > 0)) out.push(`agents.limits.${k} is a number above zero.`);
|
|
357
|
+
if (a.limits.turns !== undefined && !(Number.isInteger(a.limits.turns) && a.limits.turns > 0 && a.limits.turns <= MAX_NUMBER)) out.push('agents.limits.turns is a whole number above zero.');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
282
363
|
/** Whether a host is the machine itself: `localhost`, 127.x.x.x or ::1 */
|
|
283
364
|
const isLoopbackHost = (host) => host === 'localhost' || host === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
284
365
|
|
|
@@ -323,7 +404,7 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
|
|
|
323
404
|
if (problems.length) throw new Error(`${path.relative(absRoot, configFile) || CONFIG_FILE}:\n ${problems.join('\n ')}`);
|
|
324
405
|
}
|
|
325
406
|
const config = {
|
|
326
|
-
...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes } }),
|
|
407
|
+
...structuredClone({ ...DEFAULTS, sources: { ...DEFAULTS.sources }, personas: { ...DEFAULTS.personas }, cloud: { ...DEFAULTS.cloud }, agents: { ...DEFAULTS.agents }, releaseNotes: { ...DEFAULTS.releaseNotes }, threads: { ...DEFAULTS.threads } }),
|
|
327
408
|
...raw,
|
|
328
409
|
sources: { ...DEFAULTS.sources, ...(raw.sources || {}) },
|
|
329
410
|
personas: { ...DEFAULTS.personas, ...(raw.personas || {}) },
|
|
@@ -332,6 +413,8 @@ export async function loadConfig(root, { env = process.env, file } = {}) {
|
|
|
332
413
|
deploys: { ...DEFAULTS.deploys, ...(raw.deploys || {}) },
|
|
333
414
|
agents: { ...DEFAULTS.agents, ...(raw.agents || {}) },
|
|
334
415
|
};
|
|
416
|
+
// `threads: false` stays false; an object is read over the default
|
|
417
|
+
config.threads = raw.threads === false ? false : { ...DEFAULTS.threads, ...(raw.threads || {}) };
|
|
335
418
|
// `hookPoll: false` is `{ specs: false }`; an object is read over the default
|
|
336
419
|
config.hookPoll = raw.hookPoll === false ? { specs: false } : { ...DEFAULTS.hookPoll, ...(raw.hookPoll || {}) };
|
|
337
420
|
// `proposals` is the folder to everything downstream, whichever way the file wrote it; the options are read from `approveLands`
|
package/core/mentions.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/mentions
|
|
3
|
+
*
|
|
4
|
+
* How a thread points at the map (spec 0005, D7). Pure: no I/O, no DOM, no Node built-ins, so the server (a hook's
|
|
5
|
+
* payload) and the explorer (the lights, a node's "Mentioned in") load the same file.
|
|
6
|
+
*
|
|
7
|
+
* - **Mentions.** People write `[[core-server-auth]]` (a node's id) or `[[server/auth.js]]` (a repo path) in prose.
|
|
8
|
+
* `parseMentions` reads them outside code: a fenced block and an inline code span are text about code, not links.
|
|
9
|
+
* - **A block.** Tools and agents may write a fenced block for precise links:
|
|
10
|
+
*
|
|
11
|
+
* ```cornfield-thread
|
|
12
|
+
* { "nodes": ["core-server-auth", "server/hooks.js"], "proposal": "0013-meals" }
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `nodes` are read like mentions (an id or a path); `proposal` is a proposal's slug. A block that is not that shape
|
|
16
|
+
* is ignored, and prose is read as before.
|
|
17
|
+
* - **Resolving.** `resolveMention` answers a reference in this order: a node's id; a node whose `file` is exactly
|
|
18
|
+
* that path; the node(s) that own the path (the deepest `file` that is the path or a folder above it, as a file
|
|
19
|
+
* lights its node for a pull request, `nodesForFile`). A reference that is none of these resolves to nothing and
|
|
20
|
+
* stays plain text, which reads sensibly on the forge without cornfield.
|
|
21
|
+
* - **Caps.** At most `MAX_MENTIONS` distinct references are read from one text, and a reference is at most
|
|
22
|
+
* `MAX_REF` characters, so a comment cannot make the map light up everything or cost a lot to read.
|
|
23
|
+
* - **Links.** `threadOfPull` reads the thread a spec PR names: `Thread: #n` or `Closes #n` (also Fixes and Resolves).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The most distinct references read from one text, and the longest one */
|
|
27
|
+
export const MAX_MENTIONS = 50;
|
|
28
|
+
export const MAX_REF = 200;
|
|
29
|
+
/** The most a `cornfield-thread` block may name */
|
|
30
|
+
export const MAX_BLOCK_NODES = 50;
|
|
31
|
+
/** The fence's info string that marks a block of links */
|
|
32
|
+
export const THREAD_BLOCK = 'cornfield-thread';
|
|
33
|
+
|
|
34
|
+
const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
|
|
35
|
+
|
|
36
|
+
const clean = (p) => String(p).replace(/^\.\//, '').replace(/\/+$/, '');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The node(s) whose `file` holds a path: the node's `file` is the path itself or a folder above it, and the deepest
|
|
40
|
+
* (longest) `file` wins. A file that holds several nodes (a page and what renders it) lights all of them. (The explorer's
|
|
41
|
+
* forge panel lights a pull request's files with this same rule.)
|
|
42
|
+
* @param {string} path a repo-relative path
|
|
43
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
44
|
+
* @returns {object[]} the nodes, [] when none holds it
|
|
45
|
+
*/
|
|
46
|
+
export function nodesForFile(path, nodes) {
|
|
47
|
+
const p = clean(path);
|
|
48
|
+
let best = -1;
|
|
49
|
+
let out = [];
|
|
50
|
+
for (const node of nodes) {
|
|
51
|
+
if (typeof node.file !== 'string' || node.file === '') continue;
|
|
52
|
+
const file = clean(node.file);
|
|
53
|
+
if (p !== file && !p.startsWith(`${file}/`)) continue;
|
|
54
|
+
if (file.length > best) { best = file.length; out = [node]; } else if (file.length === best) out.push(node);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Splits text into prose and code: `prose` is the text with every fenced block and inline code span blanked (same length,
|
|
61
|
+
* so offsets hold), `fences` are the fenced blocks as `{ info, body }`.
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {{ prose: string, fences: { info: string, body: string }[] }}
|
|
64
|
+
*/
|
|
65
|
+
export function scan(text) {
|
|
66
|
+
const src = String(text ?? '');
|
|
67
|
+
const lines = src.split('\n');
|
|
68
|
+
const fences = [];
|
|
69
|
+
const out = [];
|
|
70
|
+
let open = null; // { char, len, info, body: [] }
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (open) {
|
|
73
|
+
const close = new RegExp(`^ {0,3}${open.char}{${open.len},}[ \\t]*\\r?$`).test(line);
|
|
74
|
+
if (close) { fences.push({ info: open.info, body: open.body.join('\n') }); open = null; } else open.body.push(line.replace(/\r$/, ''));
|
|
75
|
+
out.push(' '.repeat(line.length));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const m = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\r]*)$/.exec(line);
|
|
79
|
+
// An info string of a backtick fence cannot hold a backtick (then it is an inline span, not a fence)
|
|
80
|
+
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
|
|
81
|
+
open = { char: m[1][0], len: m[1].length, info: m[2].trim(), body: [] };
|
|
82
|
+
out.push(' '.repeat(line.length));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
out.push(line);
|
|
86
|
+
}
|
|
87
|
+
// A fence left open runs to the end of the text, as the forge's Markdown reads it
|
|
88
|
+
if (open) fences.push({ info: open.info, body: open.body.join('\n') });
|
|
89
|
+
return { prose: blankSpans(out.join('\n')), fences };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Inline code spans blanked: a run of backticks closes at the next run of the same length */
|
|
93
|
+
function blankSpans(text) {
|
|
94
|
+
let out = '';
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < text.length) {
|
|
97
|
+
if (text[i] !== '`') { out += text[i++]; continue; }
|
|
98
|
+
let n = 0;
|
|
99
|
+
while (text[i + n] === '`') n++;
|
|
100
|
+
let j = i + n;
|
|
101
|
+
let end = -1;
|
|
102
|
+
while (j < text.length) {
|
|
103
|
+
if (text[j] !== '`') { j++; continue; }
|
|
104
|
+
let m = 0;
|
|
105
|
+
while (text[j + m] === '`') m++;
|
|
106
|
+
if (m === n) { end = j + m; break; }
|
|
107
|
+
j += m;
|
|
108
|
+
}
|
|
109
|
+
if (end === -1) { out += text.slice(i, i + n); i += n; continue; }
|
|
110
|
+
out += text.slice(i, end).replace(/[^\n]/g, ' ');
|
|
111
|
+
i = end;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A reference as written, made comparable: no `./`, no trailing slash, no `#L10` or `:10` line suffix; or null when it is not one */
|
|
117
|
+
export function normalizeRef(raw) {
|
|
118
|
+
let ref = String(raw ?? '').trim();
|
|
119
|
+
if (ref === '' || ref.length > MAX_REF || /[\0\n\r]/.test(ref)) return null;
|
|
120
|
+
ref = ref.replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '');
|
|
121
|
+
ref = clean(ref).trim();
|
|
122
|
+
if (ref === '' || ref.startsWith('/') || ref.split('/').some((p) => p === '..' || p === '.')) return null;
|
|
123
|
+
return ref;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The `cornfield-thread` block of a text, or null: the first well-formed one wins.
|
|
128
|
+
* @param {{ info: string, body: string }[]} fences
|
|
129
|
+
* @returns {{ nodes: string[], proposal: string | null } | null}
|
|
130
|
+
*/
|
|
131
|
+
function blockOf(fences) {
|
|
132
|
+
for (const f of fences) {
|
|
133
|
+
if (f.info.split(/\s+/)[0] !== THREAD_BLOCK) continue;
|
|
134
|
+
let json;
|
|
135
|
+
try { json = JSON.parse(f.body); } catch { continue; }
|
|
136
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) continue;
|
|
137
|
+
const nodes = Array.isArray(json.nodes) ? json.nodes.map((n) => (typeof n === 'string' ? normalizeRef(n) : null)).filter((r) => r !== null).slice(0, MAX_BLOCK_NODES) : [];
|
|
138
|
+
const proposal = typeof json.proposal === 'string' && SLUG.test(json.proposal) ? json.proposal : null;
|
|
139
|
+
if (!nodes.length && !proposal) continue;
|
|
140
|
+
return { nodes: [...new Set(nodes)], proposal };
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* What a text points at, before the map is asked: the `[[…]]` references outside code (each once, in order, at most
|
|
147
|
+
* `MAX_MENTIONS`), and the `cornfield-thread` block when there is one.
|
|
148
|
+
* @param {string} text
|
|
149
|
+
* @returns {{ refs: string[], block: { nodes: string[], proposal: string | null } | null, truncated: boolean }}
|
|
150
|
+
*/
|
|
151
|
+
export function parseMentions(text) {
|
|
152
|
+
const { prose, fences } = scan(text);
|
|
153
|
+
const refs = [];
|
|
154
|
+
let truncated = false;
|
|
155
|
+
for (const m of prose.matchAll(/(?<!\\)\[\[([^[\]\n]{1,400})\]\]/g)) {
|
|
156
|
+
const ref = normalizeRef(m[1]);
|
|
157
|
+
if (ref === null || refs.includes(ref)) continue;
|
|
158
|
+
if (refs.length >= MAX_MENTIONS) { truncated = true; break; }
|
|
159
|
+
refs.push(ref);
|
|
160
|
+
}
|
|
161
|
+
return { refs, block: blockOf(fences), truncated };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The nodes one reference stands for, in order of how sure it is: the id; else nodes whose `file` is exactly the path;
|
|
166
|
+
* else the nodes that own the path.
|
|
167
|
+
* @param {string} ref
|
|
168
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
169
|
+
* @returns {object[]}
|
|
170
|
+
*/
|
|
171
|
+
export function resolveMention(ref, nodes) {
|
|
172
|
+
const r = normalizeRef(ref);
|
|
173
|
+
if (r === null) return [];
|
|
174
|
+
const byId = nodes.filter((n) => n.id === r);
|
|
175
|
+
if (byId.length) return byId;
|
|
176
|
+
const exact = nodes.filter((n) => typeof n.file === 'string' && n.file !== '' && clean(n.file) === r);
|
|
177
|
+
if (exact.length) return exact;
|
|
178
|
+
return nodesForFile(r, nodes);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* References resolved against a map.
|
|
183
|
+
* @param {string[]} refs references as `parseMentions` gives them (or a block's `nodes`)
|
|
184
|
+
* @param {{ id: string, file?: string }[]} nodes
|
|
185
|
+
* @returns {{ resolved: { ref: string, ids: string[] }[], ids: string[], unresolved: string[] }} `ids`: every node once, in
|
|
186
|
+
* order; `unresolved`: the references that are on no node (they stay text)
|
|
187
|
+
*/
|
|
188
|
+
export function resolveMentions(refs, nodes) {
|
|
189
|
+
const resolved = [];
|
|
190
|
+
const unresolved = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const list = Array.isArray(refs) ? refs.slice(0, MAX_MENTIONS + MAX_BLOCK_NODES) : [];
|
|
193
|
+
for (const ref of list) {
|
|
194
|
+
const found = resolveMention(ref, nodes);
|
|
195
|
+
if (!found.length) { unresolved.push(ref); continue; }
|
|
196
|
+
const ids = found.map((n) => n.id);
|
|
197
|
+
resolved.push({ ref, ids });
|
|
198
|
+
for (const id of ids) seen.add(id);
|
|
199
|
+
}
|
|
200
|
+
return { resolved, ids: [...seen], unresolved };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Everything a set of texts (a thread's messages) points at, before the map is asked: the references of every text and of
|
|
205
|
+
* its blocks, each once, and the proposal a block names (the first).
|
|
206
|
+
* @param {string[]} texts
|
|
207
|
+
* @returns {{ refs: string[], proposal: string | null, truncated: boolean }}
|
|
208
|
+
*/
|
|
209
|
+
export function linksOf(texts) {
|
|
210
|
+
const refs = [];
|
|
211
|
+
let proposal = null;
|
|
212
|
+
let truncated = false;
|
|
213
|
+
const cap = MAX_MENTIONS * 2;
|
|
214
|
+
for (const text of texts) {
|
|
215
|
+
const got = parseMentions(text);
|
|
216
|
+
truncated ||= got.truncated;
|
|
217
|
+
for (const ref of [...got.refs, ...(got.block ? got.block.nodes : [])]) {
|
|
218
|
+
if (refs.includes(ref)) continue;
|
|
219
|
+
if (refs.length >= cap) { truncated = true; break; }
|
|
220
|
+
refs.push(ref);
|
|
221
|
+
}
|
|
222
|
+
if (proposal === null && got.block && got.block.proposal) proposal = got.block.proposal;
|
|
223
|
+
}
|
|
224
|
+
return { refs, proposal, truncated };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The thread a spec PR's text names: `Thread: #n`, or `Closes #n` (also Fixes and Resolves, and their other tenses), outside
|
|
229
|
+
* code. The first one named. An issue in another repo (`owner/name#n`) is not this repo's thread.
|
|
230
|
+
* @param {string} body
|
|
231
|
+
* @returns {number | null}
|
|
232
|
+
*/
|
|
233
|
+
export function threadOfPull(body) {
|
|
234
|
+
const { prose } = scan(body);
|
|
235
|
+
// `(?:\s*:)?\s+` and not `\s*:?\s+`: two whitespace runs side by side backtrack against each other, which made a long
|
|
236
|
+
// run of spaces after "Closes" (or a code block, blanked to spaces) quadratic; this way each run is read once
|
|
237
|
+
const m = /(?:^|[\s(])(?:thread\s*:|(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?:\s*:)?)\s+#([1-9]\d{0,8})\b/i.exec(prose);
|
|
238
|
+
return m ? Number(m[1]) : null;
|
|
239
|
+
}
|
package/explorer/explorer.css
CHANGED
|
@@ -852,7 +852,7 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
|
|
|
852
852
|
.hx-forge-detail { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--hx-line); border-left: 4px solid var(--hx-indigo); border-radius: 10px; background: var(--hx-well-soft); }
|
|
853
853
|
.hx-forge-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
854
854
|
.hx-forge-detail-title { min-width: 0; font-size: 13px; font-weight: 700; color: var(--hx-ink); overflow-wrap: anywhere; }
|
|
855
|
-
.hx-forge-sub, .hx-forge-subact, .hx-forge-confirm-title { margin: 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--hx-slate-300); }
|
|
855
|
+
.hx-forge-sub, .hx-forge-subact, .hx-forge-runs-title, .hx-forge-confirm-title { margin: 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--hx-slate-300); }
|
|
856
856
|
.hx-forge-check { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
|
|
857
857
|
.hx-forge-check .hx-link { display: inline-flex; align-items: center; min-height: 24px; }
|
|
858
858
|
.hx-forge-lights { flex-basis: 100%; font-size: 11px; line-height: 1.5; }
|
|
@@ -913,3 +913,14 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
|
|
|
913
913
|
:root[data-theme="light"] .hx-forge-tab[aria-pressed="true"] { box-shadow: 0 1px 3px rgba(15, 23, 42, 0.25); }
|
|
914
914
|
:root[data-theme="light"] .hx-forge { box-shadow: 10px 0 30px -12px rgba(15, 23, 42, 0.18); }
|
|
915
915
|
:root[data-theme="light"] .hx-forge:not(.forge-open) { box-shadow: none; }
|
|
916
|
+
.hx-forge-runs { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; }
|
|
917
|
+
.hx-forge-runs[hidden] { display: none; }
|
|
918
|
+
|
|
919
|
+
/* Threads (forge-panel.js, docs/threads.md): messages as written, read-only; a node's "Mentioned in" opens one */
|
|
920
|
+
.hx-thread-msgs { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
921
|
+
.hx-thread-msg { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--hx-line); border-radius: 10px; background: var(--hx-control); }
|
|
922
|
+
.hx-thread-body { margin: 0; color: var(--hx-ink); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
|
|
923
|
+
.hx-thread-nodes { padding-left: 0; }
|
|
924
|
+
.hx-thread-compose { font-family: inherit; min-height: 8em; }
|
|
925
|
+
.hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
|
|
926
|
+
#forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }
|