cornfield 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/config.js +3 -2
- package/explorer/explorer.css +49 -4
- package/explorer/forge-panel.js +116 -20
- package/explorer/index.html +9 -0
- package/explorer/layers.js +2 -0
- package/explorer/render.js +8 -0
- package/explorer/runs-map.js +135 -0
- package/explorer/runs-model.js +146 -0
- package/explorer/runs-view.js +101 -0
- package/explorer/state.js +6 -0
- package/package.json +1 -1
- package/server/agent-runs.js +290 -10
- package/server/db.js +26 -0
- package/server/forge-forgejo.js +7 -0
- package/server/forge-github.js +5 -0
- package/server/forge.js +5 -0
- package/server/server.js +2 -0
package/explorer/runs-model.js
CHANGED
|
@@ -123,3 +123,149 @@ export function confirmStartWords(refs, forgeName) {
|
|
|
123
123
|
const where = refs.proposal != null ? `the spec pull request of proposal ${refs.proposal}` : refs.thread != null ? `thread #${refs.thread}` : `issue #${refs.issue}`;
|
|
124
124
|
return `This posts a request as a comment on ${where} on ${forgeName}, as you. Your runner picks it up. cornfield starts nothing and sends no credentials.`;
|
|
125
125
|
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Runs on the map (issue #82): the Runs view, the compare, the timeline, and what the map shows
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/** Runs asked for within this long of each other, on the same subject, kind and brief, are one request under different profiles */
|
|
132
|
+
export const COMPARE_WINDOW_MS = 3 * 60_000;
|
|
133
|
+
|
|
134
|
+
const plural = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
|
|
135
|
+
const listWords = (items) => (items.length <= 2 ? items.join(' and ') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
|
|
136
|
+
|
|
137
|
+
/** The subject of a run as a key: the same issue, thread and proposal */
|
|
138
|
+
const subjectKey = (refs) => JSON.stringify([refs?.issue ?? null, refs?.thread ?? null, refs?.proposal ?? null]);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The runs for the view, newest first: a run on its own, or a **compare**: the same request (same subject, kind and brief) asked for under
|
|
142
|
+
* different profiles within `COMPARE_WINDOW_MS`, shown side by side. A group sits where its newest run is; inside it the runs are in profile order.
|
|
143
|
+
* @param {object[]} runs as the API lists them (newest first)
|
|
144
|
+
* @returns {({ kind: 'run', run: object } | { kind: 'compare', key: string, runs: object[] })[]}
|
|
145
|
+
*/
|
|
146
|
+
export function groupRuns(runs) {
|
|
147
|
+
const clusters = [];
|
|
148
|
+
const byKey = new Map();
|
|
149
|
+
for (const run of runs) {
|
|
150
|
+
const key = `${subjectKey(run.refs)}|${run.kind}|${typeof run.brief === 'string' ? run.brief : ''}`;
|
|
151
|
+
const at = Date.parse(run.requestedAt);
|
|
152
|
+
const open = byKey.get(key) ?? [];
|
|
153
|
+
// Newest first: a run joins the cluster its time is within the window of, if that cluster has not got its profile yet
|
|
154
|
+
const home = open.find((c) => Math.abs(c.newest - at) <= COMPARE_WINDOW_MS && !c.runs.some((r) => (r.profile ?? '') === (run.profile ?? '')));
|
|
155
|
+
if (home) { home.runs.push(run); continue; }
|
|
156
|
+
const fresh = { key, newest: at, runs: [run], first: clusters.length };
|
|
157
|
+
open.push(fresh);
|
|
158
|
+
byKey.set(key, open);
|
|
159
|
+
clusters.push(fresh);
|
|
160
|
+
}
|
|
161
|
+
return clusters.map((c) => (c.runs.length > 1
|
|
162
|
+
? { kind: 'compare', key: c.runs.map((r) => r.runId).join('+'), runs: [...c.runs].sort((a, b) => String(a.profile ?? '').localeCompare(String(b.profile ?? ''))) }
|
|
163
|
+
: { kind: 'run', run: c.runs[0] }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What the group is titled: "Code on issue #7 under 2 profiles: claude and qwen" */
|
|
167
|
+
export function compareTitle(runs) {
|
|
168
|
+
const first = runs[0];
|
|
169
|
+
const kind = (KINDS.find(([k]) => k === first.kind) || [first.kind, first.kind])[1].split(':')[0];
|
|
170
|
+
return `${kind} on ${aboutWords(first.refs)} under ${plural(runs.length, 'profile')}: ${listWords(runs.map((r) => r.profile || 'no profile'))}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Limits beside what the runner reported: a row for each of minutes, turns, cost and tokens that has either, in words (never a bare dash) */
|
|
174
|
+
export function limitRows(run) {
|
|
175
|
+
const l = run.limits || {};
|
|
176
|
+
const r = run.report || {};
|
|
177
|
+
const rows = [];
|
|
178
|
+
const add = (name, limit, reported) => {
|
|
179
|
+
if (limit == null && reported == null) return;
|
|
180
|
+
rows.push({ name, limit: limit == null ? 'no limit asked' : String(limit), reported: reported == null ? 'not reported' : String(reported) });
|
|
181
|
+
};
|
|
182
|
+
add('Minutes', l.minutes, null);
|
|
183
|
+
add('Turns', l.turns, null);
|
|
184
|
+
add('Cost', l.cost, r.cost);
|
|
185
|
+
add('Tokens', null, r.tokens);
|
|
186
|
+
return rows;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The checks of a run's pull request, said short */
|
|
190
|
+
export function checksWords(run) {
|
|
191
|
+
if (!run.pull) return 'No pull request yet, so no checks.';
|
|
192
|
+
return { success: 'Checks passing', failure: 'Checks failing', running: 'Checks running', none: 'The pull request has no checks' }[run.checks] || 'Checks not read yet';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One line of a run's timeline, and the runner's words under it (data for the page to escape) */
|
|
196
|
+
export function timelineWords(entry) {
|
|
197
|
+
const by = entry.actor ? ` by ${entry.actor}` : '';
|
|
198
|
+
switch (entry.kind) {
|
|
199
|
+
case 'requested': return { what: `Requested${by}${entry.ref ? ` under the profile ${entry.ref}` : ''}`, note: '' };
|
|
200
|
+
case 'claimed': return { what: `Claimed${by}`, note: entry.note || '' };
|
|
201
|
+
case 'report': return { what: `Reported ${entry.ref || 'in'}${by}`, note: entry.note || '' };
|
|
202
|
+
// When cornfield saw it, not when it was pushed: the forge does not say
|
|
203
|
+
case 'push': return { what: `Push seen: ${String(entry.ref || '').slice(0, 7)}${entry.note ? ` on ${entry.note}` : ''}`, note: '' };
|
|
204
|
+
case 'pull-opened': return { what: `Opened pull request ${entry.ref || ''}${by}`.trim(), note: '' };
|
|
205
|
+
case 'checks': return { what: { success: 'Checks passed', failure: 'Checks failed', running: 'Checks running', none: 'No checks' }[entry.ref] || 'Checks changed', note: '' };
|
|
206
|
+
case 'stop-requested': return { what: `Asked to stop${by}`, note: entry.note || '' };
|
|
207
|
+
case 'merged': return { what: `Landed: pull request ${entry.ref || ''} merged`, note: '' };
|
|
208
|
+
case 'closed': return { what: `Pull request ${entry.ref || ''} closed without landing`, note: '' };
|
|
209
|
+
case 'quiet': return { what: 'Went quiet: nothing reported or pushed for a while', note: '' };
|
|
210
|
+
default: return { what: entry.kind, note: entry.note || '' };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The ids of the nodes a run names, as the names the map shows (an id the map lacks is shown as it is) */
|
|
215
|
+
export function nodeLabels(ids, nodes) {
|
|
216
|
+
const byId = new Map((nodes || []).map((n) => [n.id, n.label || n.id]));
|
|
217
|
+
return (ids || []).map((id) => byId.get(id) || id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* What the map shows of the runs: the nodes of the open runs (in flight), and those two or more open runs share (collisions). A run that
|
|
222
|
+
* is finished or quiet marks nothing.
|
|
223
|
+
* @param {object[]} runs
|
|
224
|
+
* @returns {{ flight: Map<string, string[]>, collide: Map<string, string[]> }} node id to the ids of the open runs that name it
|
|
225
|
+
*/
|
|
226
|
+
export function overlayOf(runs) {
|
|
227
|
+
const flight = new Map();
|
|
228
|
+
for (const run of runs || []) {
|
|
229
|
+
if (!run.open) continue;
|
|
230
|
+
for (const id of run.nodes || []) flight.set(id, [...(flight.get(id) || []), run.runId]);
|
|
231
|
+
}
|
|
232
|
+
const collide = new Map([...flight].filter(([, ids]) => ids.length > 1));
|
|
233
|
+
return { flight, collide };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** What a run lights when it is selected, like a pull request: its nodes, whatever its state */
|
|
237
|
+
export const litOfRun = (run) => ({ changed: new Set(run ? run.nodes || [] : []), failing: new Set() });
|
|
238
|
+
|
|
239
|
+
/** The collision of a run in words: "Collides with run-… on orders and cart" (null when it does not) */
|
|
240
|
+
export function collisionWords(run, runs, nodes) {
|
|
241
|
+
const others = (run.collidesWith || []);
|
|
242
|
+
if (!run.open || !others.length) return null;
|
|
243
|
+
const mine = new Set(run.nodes || []);
|
|
244
|
+
const shared = [...new Set(others.flatMap((id) => ((runs.find((r) => r.runId === id) || {}).nodes || []).filter((n) => mine.has(n))))];
|
|
245
|
+
return `Collides with ${listWords(others)}${shared.length ? ` on ${listWords(nodeLabels(shared, nodes))}` : ''}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What changed between two answers, as sentences for the live region: a run that appeared, a new state, a collision begun or cleared.
|
|
250
|
+
* Nothing before the first answer (`before` is null): the page does not read its whole list out on load.
|
|
251
|
+
* @param {object[] | null} before
|
|
252
|
+
* @param {object[]} after
|
|
253
|
+
* @returns {string[]}
|
|
254
|
+
*/
|
|
255
|
+
export function changeWords(before, after) {
|
|
256
|
+
if (!before) return [];
|
|
257
|
+
const was = new Map(before.map((r) => [r.runId, r]));
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const run of after) {
|
|
260
|
+
const old = was.get(run.runId);
|
|
261
|
+
if (!old) { out.push(`New run ${run.runId}: ${run.kind} on ${aboutWords(run.refs)}, ${statusWords(run).toLowerCase()}`); continue; }
|
|
262
|
+
if (old.status !== run.status) out.push(`Run ${run.runId} is now ${statusWords(run).toLowerCase()}`);
|
|
263
|
+
const was2 = new Set(old.open ? old.collidesWith || [] : []);
|
|
264
|
+
const now2 = new Set(run.open ? run.collidesWith || [] : []);
|
|
265
|
+
const added = [...now2].filter((id) => !was2.has(id));
|
|
266
|
+
const gone = [...was2].filter((id) => !now2.has(id));
|
|
267
|
+
if (added.length) out.push(`Run ${run.runId} now collides with ${listWords(added)}`);
|
|
268
|
+
if (gone.length && !now2.size) out.push(`Run ${run.runId} no longer collides`);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module explorer/runs-view
|
|
3
|
+
*
|
|
4
|
+
* The markup of the Runs view in the Agents tab (issue #82), as strings: each run's state as a word, its profile, its limits beside what
|
|
5
|
+
* the runner reported, its thread, branch, pull request and checks, the nodes it changes and any collision, a timeline in order, and the
|
|
6
|
+
* runner's own `url` as "Runner's log" (cornfield streams no log). Runs of one request under different profiles are a **compare**: side by
|
|
7
|
+
* side on a wide panel, stacked on a narrow one. Pure: no DOM, no clock; the panel hands in what it knows and paints the string.
|
|
8
|
+
* Every word from a runner or the forge is escaped.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { relativeTime } from './review-model.js';
|
|
12
|
+
import { aboutWords, statusWords, statusWhy, runMeta, canStop, groupRuns, compareTitle, limitRows, checksWords, timelineWords, nodeLabels, collisionWords } from './runs-model.js';
|
|
13
|
+
|
|
14
|
+
const TONE = { done: 'success', merged: 'success', failed: 'failure', quiet: 'queued', 'stop-requested': 'queued' };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} ViewContext
|
|
18
|
+
* @property {(s: unknown) => string} esc HTML escaping
|
|
19
|
+
* @property {(u: unknown) => string | null} safeUrl an http(s) address, or null
|
|
20
|
+
* @property {string} forgeName "GitHub"
|
|
21
|
+
* @property {string | null} repoUrl the repo's address on the forge ("https://github.com/owner/name"), or null
|
|
22
|
+
* @property {string} forgeType 'github' | 'forgejo'
|
|
23
|
+
* @property {number} now
|
|
24
|
+
* @property {{ id: string, label?: string }[]} nodes the map's nodes, for names
|
|
25
|
+
* @property {string | null} selected the run lit on the map
|
|
26
|
+
* @property {Set<string>} timelines the runs whose timeline is open
|
|
27
|
+
* @property {boolean} busy an action is in flight
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** A link that opens a new tab, said so for a screen reader */
|
|
31
|
+
const link = (ctx, href, text, after = '') => (ctx.safeUrl(href) ? `<a class="hx-link" href="${ctx.esc(href)}" target="_blank" rel="noreferrer">${ctx.esc(text)} <span class="hx-sr-only">(new tab)</span></a>${after}` : '');
|
|
32
|
+
|
|
33
|
+
/** The address of a branch on the forge, or null */
|
|
34
|
+
function branchUrl(ctx, branch) {
|
|
35
|
+
if (!ctx.repoUrl || !branch) return null;
|
|
36
|
+
const at = branch.split('/').map(encodeURIComponent).join('/');
|
|
37
|
+
return ctx.forgeType === 'forgejo' ? `${ctx.repoUrl}/src/branch/${at}` : `${ctx.repoUrl}/tree/${at}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The links of a run: the request, its thread, its branch, its pull request, and the runner's log (the runner's own `url`; nothing is streamed here) */
|
|
41
|
+
function linksHtml(ctx, run) {
|
|
42
|
+
const thread = run.refs.thread ?? run.refs.issue;
|
|
43
|
+
return [
|
|
44
|
+
link(ctx, run.comment && run.comment.url, `Request on ${ctx.forgeName}`),
|
|
45
|
+
thread != null && ctx.repoUrl ? link(ctx, `${ctx.repoUrl}/issues/${thread}`, `Thread #${thread}`) : '',
|
|
46
|
+
run.branch ? link(ctx, branchUrl(ctx, run.branch), `Branch ${run.branch}`) : '',
|
|
47
|
+
run.pull ? link(ctx, run.pull.url, `Pull request #${run.pull.number}`, run.pull.trusted ? '' : ' <span class="hx-faint">(not from a runner, so it moves nothing)</span>') : '',
|
|
48
|
+
link(ctx, run.report && run.report.url, 'Runner\'s log'),
|
|
49
|
+
].filter(Boolean).join(' · ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function timelineHtml(ctx, run) {
|
|
53
|
+
const items = (run.timeline || []).map((e) => {
|
|
54
|
+
const w = timelineWords(e);
|
|
55
|
+
return `<li><time datetime="${ctx.esc(e.at)}" title="${ctx.esc(e.at)}">${ctx.esc(relativeTime(e.at, ctx.now) || e.at)}</time>${ctx.esc(w.what)}${w.note ? `<span class="hx-run-note">${ctx.esc(w.note)}</span>` : ''}</li>`;
|
|
56
|
+
});
|
|
57
|
+
return `<ol class="hx-run-timeline" id="run-tl-${ctx.esc(run.runId)}" aria-label="Timeline of ${ctx.esc(run.runId)}, oldest first">${items.join('')}</ol>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One run */
|
|
61
|
+
export function runHtml(ctx, run, all) {
|
|
62
|
+
const lit = ctx.selected === run.runId;
|
|
63
|
+
const open = ctx.timelines.has(run.runId);
|
|
64
|
+
const tone = TONE[run.status] || 'running';
|
|
65
|
+
const why = statusWhy(run);
|
|
66
|
+
const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${ctx.esc(run.report.note)}</p>` : '';
|
|
67
|
+
const stop = canStop(run) ? `<button type="button" class="hx-btn hx-forge-act" data-forge-stop="${ctx.esc(run.runId)}" aria-label="Stop run ${ctx.esc(run.runId)}"${ctx.busy ? ' disabled' : ''}>Stop</button>` : '';
|
|
68
|
+
const names = nodeLabels(run.nodes, ctx.nodes);
|
|
69
|
+
const collision = collisionWords(run, all, ctx.nodes);
|
|
70
|
+
const limits = limitRows(run);
|
|
71
|
+
const count = (run.timeline || []).length;
|
|
72
|
+
const links = linksHtml(ctx, run);
|
|
73
|
+
return `<li class="hx-run hx-forge-file" data-run="${ctx.esc(run.runId)}">
|
|
74
|
+
<div class="hx-run-head"><span class="hx-forge-state hx-forge-state--${tone}">${ctx.esc(statusWords(run))}</span> <span class="hx-name">${ctx.esc(run.kind)} on ${ctx.esc(aboutWords(run.refs))}</span>${run.profile ? ` <span class="hx-faint">profile ${ctx.esc(run.profile)}</span>` : ''}</div>
|
|
75
|
+
${collision ? `<p class="hx-run-collide"><span aria-hidden="true">⚠</span> ${ctx.esc(collision)}</p>` : ''}
|
|
76
|
+
<p class="hx-forge-meta">${ctx.esc(runMeta(run, ctx.now))}</p>${why ? `<p class="hx-forge-why hx-faint">${ctx.esc(why)}</p>` : ''}${note}
|
|
77
|
+
<dl class="hx-run-limits" aria-label="Limits and what the runner reported">${limits.length ? limits.map((r) => `<dt>${ctx.esc(r.name)}</dt><dd>limit: ${ctx.esc(r.limit)}; reported: ${ctx.esc(r.reported)}</dd>`).join('') : '<dt>Limits</dt><dd>none asked, nothing reported</dd>'}</dl>
|
|
78
|
+
<p class="hx-forge-meta">${ctx.esc(checksWords(run))}</p>
|
|
79
|
+
<p class="hx-forge-meta">${names.length ? `Changing ${run.nodesFrom === 'files' ? '' : 'or expected to change '}${ctx.esc(names.join(', '))}` : 'No node on the map yet.'}</p>
|
|
80
|
+
${links ? `<p class="hx-forge-meta">${links}</p>` : ''}
|
|
81
|
+
<div class="hx-run-actions">${stop}
|
|
82
|
+
<button type="button" class="hx-btn hx-forge-act" data-run-select="${ctx.esc(run.runId)}" aria-pressed="${lit}" aria-label="Light the nodes of ${ctx.esc(run.runId)} on the map"${(run.nodes || []).length ? '' : ' disabled'}>${lit ? 'Lit on the map' : 'Light on the map'}</button>
|
|
83
|
+
<button type="button" class="hx-btn hx-forge-act" data-run-timeline="${ctx.esc(run.runId)}" aria-expanded="${open}" aria-controls="run-tl-${ctx.esc(run.runId)}">Timeline (${count})</button></div>
|
|
84
|
+
${open ? timelineHtml(ctx, run) : ''}</li>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The list: runs on their own and compares, as the items of the list.
|
|
89
|
+
* @param {ViewContext} ctx
|
|
90
|
+
* @param {object[]} runs
|
|
91
|
+
*/
|
|
92
|
+
export function runsHtml(ctx, runs) {
|
|
93
|
+
return groupRuns(runs).map((g) => {
|
|
94
|
+
if (g.kind === 'run') return runHtml(ctx, g.run, runs);
|
|
95
|
+
const id = `cmp-${g.runs[0].runId}`;
|
|
96
|
+
return `<li class="hx-run-group"><section class="hx-run-compare" aria-labelledby="${ctx.esc(id)}">
|
|
97
|
+
<h4 class="hx-run-compare-title" id="${ctx.esc(id)}">${ctx.esc(compareTitle(g.runs))}</h4>
|
|
98
|
+
<p class="hx-forge-meta hx-faint">The same request under each profile, side by side.</p>
|
|
99
|
+
<ul class="hx-run-columns" aria-label="${ctx.esc(g.runs.length)} profiles compared">${g.runs.map((r) => runHtml(ctx, r, runs)).join('')}</ul></section></li>`;
|
|
100
|
+
}).join('');
|
|
101
|
+
}
|
package/explorer/state.js
CHANGED
|
@@ -119,6 +119,12 @@ export const state = {
|
|
|
119
119
|
*/
|
|
120
120
|
forgeLit: null,
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The runs on the map (explorer/runs-map.js): `{ flight: Map<nodeId, runId[]>, collide: Map<nodeId, runId[]>, runs }` for the open runs'
|
|
124
|
+
* nodes, or null when there are none. The map's marks over the nodes are drawn from it.
|
|
125
|
+
*/
|
|
126
|
+
runsMap: null,
|
|
127
|
+
|
|
122
128
|
/**
|
|
123
129
|
* Which threads mention which nodes (forge-panel.js, docs/threads.md), for a node's panel: `{ for(nodeId) -> thread[] | null,
|
|
124
130
|
* ensure(), open(number) }`, or null when threads are off or there is no forge. `for` answers null until the list has been
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cornfield",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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)",
|