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/core/config.js
CHANGED
|
@@ -152,7 +152,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
152
152
|
personas: { roles: null, parent: 'root' },
|
|
153
153
|
cloud: { parent: 'root' },
|
|
154
154
|
releaseNotes: { breakingLabel: 'breaking' },
|
|
155
|
-
agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {} },
|
|
155
|
+
agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {}, refuseCollisions: false },
|
|
156
156
|
hooks: [],
|
|
157
157
|
hookPoll: { specs: '5m' },
|
|
158
158
|
threads: { label: 'idea' },
|
|
@@ -165,7 +165,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
165
165
|
const KEYS = Object.keys(DEFAULTS);
|
|
166
166
|
/** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
|
|
167
167
|
export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
|
|
168
|
-
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on'];
|
|
168
|
+
const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on', 'refuseCollisions'];
|
|
169
169
|
const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
|
|
170
170
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
171
171
|
const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
@@ -363,6 +363,7 @@ export const triggersOf = (config) => Object.entries(isObject(config.agents?.on)
|
|
|
363
363
|
function agentRunProblems(a) {
|
|
364
364
|
const out = [];
|
|
365
365
|
if (a.transport !== undefined && !AGENT_TRANSPORTS.includes(a.transport)) out.push(`agents.transport is ${AGENT_TRANSPORTS.map((t) => JSON.stringify(t)).join(', ')}: where a request goes: "forge" posts it as a comment (the default), "push" sends it as an agent.requested hook event, "pull" keeps it for a runner to claim (docs/agents.md#transports).`);
|
|
366
|
+
if (a.refuseCollisions !== undefined && typeof a.refuseCollisions !== 'boolean') out.push('agents.refuseCollisions is true or false: whether a new request that names a node an open run is already changing is refused (false unless set).');
|
|
366
367
|
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).`);
|
|
367
368
|
if (a.profiles !== undefined) {
|
|
368
369
|
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/explorer.css
CHANGED
|
@@ -796,11 +796,13 @@ button.hx-proposal-item:focus-visible { outline: 2px solid var(--hx-indigo); out
|
|
|
796
796
|
.hx-forge * { touch-action: pan-y; }
|
|
797
797
|
.hx-forge.forge-open { pointer-events: auto; }
|
|
798
798
|
@media (min-width: 768px) {
|
|
799
|
-
|
|
799
|
+
/* The panel's width is the stage's `--hx-forge-w`, so what makes room for it reads the same number (the Runs view's compare widens it, below) */
|
|
800
|
+
.hx-stage { --hx-forge-w: min(400px, calc(100% - 24px)); }
|
|
801
|
+
.hx-forge { position: absolute; top: 0; left: 0; bottom: 0; width: var(--hx-forge-w); border-left: 0; transform: translateX(-100%); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); }
|
|
800
802
|
.hx-forge.forge-open { transform: translateX(0); }
|
|
801
|
-
/* The proposal bar and the player make room for it */
|
|
802
|
-
.hx-stage:has(.forge-open) .hx-proposal-bar { left:
|
|
803
|
-
.hx-stage:has(.forge-open):has(.drawer-open) .hx-proposal-bar { width: min(720px, calc(100% -
|
|
803
|
+
/* The proposal bar and the player make room for it: 12px past its edge, and the node panel's 480px (+24) when that is open too */
|
|
804
|
+
.hx-stage:has(.forge-open) .hx-proposal-bar { left: calc(var(--hx-forge-w) + 12px); width: min(720px, calc(100% - var(--hx-forge-w) - 36px)); }
|
|
805
|
+
.hx-stage:has(.forge-open):has(.drawer-open) .hx-proposal-bar { width: min(720px, calc(100% - var(--hx-forge-w) - 540px)); }
|
|
804
806
|
}
|
|
805
807
|
@media (max-width: 767px) {
|
|
806
808
|
.hx-forge:not(.forge-open) { display: none; }
|
|
@@ -924,3 +926,46 @@ a.hx-forge-row:hover, button.hx-forge-row:hover { background: var(--hx-control-h
|
|
|
924
926
|
.hx-thread-compose { font-family: inherit; min-height: 8em; }
|
|
925
927
|
.hx-thread-open { display: block; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
|
|
926
928
|
#forge-idea[hidden], #idea-form[hidden], #idea-message[hidden] { display: none; }
|
|
929
|
+
|
|
930
|
+
/* ---- agent runs on the map (explorer/runs-map.js, issue #82) ---- */
|
|
931
|
+
.hx-run-overlay { position: absolute; inset: 0; z-index: 2; overflow: hidden; pointer-events: none; }
|
|
932
|
+
.hx-run-overlay[hidden] { display: none; }
|
|
933
|
+
.hx-run-mark { position: absolute; width: 0; height: 0; --r: 14px; }
|
|
934
|
+
.hx-run-ring { position: absolute; left: calc(var(--r) * -1); top: calc(var(--r) * -1); width: calc(var(--r) * 2); height: calc(var(--r) * 2); box-sizing: border-box; border-radius: 50%; border: 2px solid var(--hx-teal-text); }
|
|
935
|
+
/* In flight: the ring pulses outward and fades; when motion is reduced it is a still, slightly thicker ring */
|
|
936
|
+
.hx-run-mark--flight .hx-run-ring { animation: hx-run-pulse 1.8s ease-out infinite; }
|
|
937
|
+
@keyframes hx-run-pulse { 0% { transform: scale(0.9); opacity: 1; } 100% { transform: scale(1.55); opacity: 0.1; } }
|
|
938
|
+
.hx-run-overlay.is-static .hx-run-ring { animation: none; transform: none; opacity: 1; border-width: 3px; }
|
|
939
|
+
@media (prefers-reduced-motion: reduce) { .hx-run-ring { animation: none !important; transform: none; opacity: 1; border-width: 3px; } }
|
|
940
|
+
/* Collision: dashed, a sign and words; the same ring whether or not colour is seen */
|
|
941
|
+
.hx-run-mark--collision .hx-run-ring { border: 3px dashed var(--hx-amber); animation: none; }
|
|
942
|
+
.hx-run-tag { position: absolute; left: 0; top: calc(var(--r) + 4px); transform: translateX(-50%); white-space: nowrap; padding: 1px 7px; border-radius: 9999px; border: 1px solid var(--hx-amber); background: var(--hx-well); color: var(--hx-amber); font-size: 11px; font-weight: 700; }
|
|
943
|
+
.hx-run-swatch { display: inline-block; width: 14px; height: 14px; flex: none; box-sizing: border-box; border-radius: 50%; border: 2px solid var(--hx-teal-text); }
|
|
944
|
+
.hx-run-swatch--collision { border: 2px dashed var(--hx-amber); }
|
|
945
|
+
|
|
946
|
+
/* The Runs view (forge-panel.js, runs-view.js): each run, its limits beside what was reported, its timeline; the compare puts profiles side by side */
|
|
947
|
+
.hx-runs-list { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
948
|
+
.hx-run { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--hx-line); border-radius: 10px; background: var(--hx-control); overflow-wrap: anywhere; min-width: 0; }
|
|
949
|
+
.hx-run-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
|
|
950
|
+
.hx-run-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
951
|
+
.hx-run-collide { margin: 0; padding: 2px 8px; border: 1px dashed var(--hx-amber); border-radius: 8px; color: var(--hx-amber); font-weight: 600; }
|
|
952
|
+
.hx-run-limits { display: grid; grid-template-columns: auto 1fr; gap: 0 10px; margin: 0; font-size: 11px; }
|
|
953
|
+
.hx-run-limits dt { color: var(--hx-slate-300); font-weight: 600; }
|
|
954
|
+
.hx-run-limits dd { margin: 0; color: var(--hx-text-2); }
|
|
955
|
+
.hx-run-timeline { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0 0 0 18px; font-size: 11px; }
|
|
956
|
+
.hx-run-timeline li { color: var(--hx-text-2); }
|
|
957
|
+
.hx-run-timeline time { color: var(--hx-slate-300); margin-right: 6px; }
|
|
958
|
+
.hx-run-note { display: block; color: var(--hx-slate-300); white-space: pre-wrap; }
|
|
959
|
+
.hx-run-compare { border: 1px solid var(--hx-line); border-left: 4px solid var(--hx-teal-strong); border-radius: 10px; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
960
|
+
.hx-run-compare-title { margin: 0; font-size: 12px; font-weight: 700; color: var(--hx-text-1); }
|
|
961
|
+
.hx-run-columns { display: grid; grid-template-columns: 1fr; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
962
|
+
@media (min-width: 1100px) {
|
|
963
|
+
.hx-stage:has(.hx-forge .hx-run-compare) { --hx-forge-w: min(780px, calc(100% - 24px)); }
|
|
964
|
+
.hx-run-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
965
|
+
}
|
|
966
|
+
/* With the node panel open as well, a 780px compare only fits beside it from 1600px: below that the panel keeps its width and the
|
|
967
|
+
profiles stack, so neither panel nor the proposal bar between them is covered */
|
|
968
|
+
@media (min-width: 1100px) and (max-width: 1599px) {
|
|
969
|
+
.hx-stage:has(.hx-forge .hx-run-compare):has(.drawer-open) { --hx-forge-w: min(400px, calc(100% - 24px)); }
|
|
970
|
+
.hx-stage:has(.drawer-open) .hx-run-columns { grid-template-columns: 1fr; }
|
|
971
|
+
}
|
package/explorer/forge-panel.js
CHANGED
|
@@ -69,8 +69,10 @@ import { relativeTime } from './review-model.js';
|
|
|
69
69
|
import { LAYER_DEFS, toggleLayer } from './layers.js';
|
|
70
70
|
import { WORK_URL, groupWork, filterGroups, groupWords, workStatus, markWords } from './agents-model.js';
|
|
71
71
|
import {
|
|
72
|
-
RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, triggerNotice, startBody, startWords, stopWords, confirmStartWords,
|
|
72
|
+
RUNS_URL, runsUrl, stopUrl, KINDS, aboutWords, statusWords, statusWhy, runMeta, canStop, runsStatus, triggerNotice, startBody, startWords, stopWords, confirmStartWords, litOfRun, changeWords,
|
|
73
73
|
} from './runs-model.js';
|
|
74
|
+
import { runsHtml } from './runs-view.js';
|
|
75
|
+
import { setRunsOnMap, clearRunsOnMap } from './runs-map.js';
|
|
74
76
|
import {
|
|
75
77
|
STATES, defaultFilters, listUrl, pullUrl, serverState, filterItems, facetsOf, stateWords, metaWords, checkWords, checksWords, litFor, errorWords,
|
|
76
78
|
landingUrl, cancelLandingUrl, landingWords, mergeBlock, rerunWhy, confirmMerge, mergeBody, actionUrl, actionWords,
|
|
@@ -116,6 +118,8 @@ const ui = {
|
|
|
116
118
|
notes: freshNotes(), // the Release notes view
|
|
117
119
|
runs: null, // { data, error } of the Agents tab's runs: what /api/agent-runs answered
|
|
118
120
|
runNote: null, // { text, ok }: what the last Stop came to
|
|
121
|
+
runSel: null, // the run lit on the map (Light on the map), by id
|
|
122
|
+
runTimelines: new Set(), // the runs whose timeline is open
|
|
119
123
|
agent: null, // { opener, refs, posting } while the Start an agent dialog is open
|
|
120
124
|
};
|
|
121
125
|
|
|
@@ -130,6 +134,9 @@ function freshIdea() {
|
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
let wired = false;
|
|
137
|
+
let runsTimer = 0;
|
|
138
|
+
/** How often the runs are read again while one is open */
|
|
139
|
+
export const RUNS_POLL_MS = 60_000;
|
|
133
140
|
let threadToken = 0;
|
|
134
141
|
let indexToken = 0;
|
|
135
142
|
let indexLoading = false;
|
|
@@ -188,11 +195,46 @@ async function loadWork(refresh, token) {
|
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
/** Loads the runs for the Agents tab (a refresh also has cornfield look at the forge for their reports first); a failure is a line, never a blank panel */
|
|
191
|
-
async function loadRuns(refresh) {
|
|
198
|
+
async function loadRuns(refresh, { say = true } = {}) {
|
|
199
|
+
const before = ui.runs && ui.runs.data ? ui.runs.data.runs : null;
|
|
192
200
|
const res = await request(runsUrl(refresh), refresh);
|
|
193
201
|
ui.runs = res.ok && Array.isArray(res.body.runs) ? { data: res.body, error: '' } : { data: ui.runs && ui.runs.data, error: errorWords(res.status, res.body) };
|
|
202
|
+
if (!(ui.runs && ui.runs.data)) return;
|
|
203
|
+
// The map shows the open runs' nodes; a run lit on the map follows its nodes; what changed is said, once the first answer is in
|
|
204
|
+
setRunsOnMap(ui.runs.data.runs);
|
|
205
|
+
syncRunsPoll();
|
|
206
|
+
if (ui.runSel) relightRun();
|
|
207
|
+
const said = say ? changeWords(before, ui.runs.data.runs) : [];
|
|
208
|
+
if (said.length) announce(said.join('. '));
|
|
194
209
|
}
|
|
195
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Looks at the runs again in `RUNS_POLL_MS`, but only while one is open: a run's answer that has none open disarms it, and the next answer
|
|
213
|
+
* with one open (a run requested here, or a look from elsewhere) arms it again. One timer at most; never while the page is hidden or put
|
|
214
|
+
* away (`pagehide`). So the marks on the map and the words follow the runs.
|
|
215
|
+
*/
|
|
216
|
+
function syncRunsPoll() {
|
|
217
|
+
const open = !!(ui.runs && ui.runs.data && ui.runs.data.open > 0);
|
|
218
|
+
if (!open || pageAway) {
|
|
219
|
+
clearTimeout(runsTimer);
|
|
220
|
+
runsTimer = 0;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (runsTimer) return;
|
|
224
|
+
runsTimer = setTimeout(async () => {
|
|
225
|
+
runsTimer = 0;
|
|
226
|
+
if (!(typeof document !== 'undefined' && document.hidden)) {
|
|
227
|
+
await loadRuns(false);
|
|
228
|
+
paint();
|
|
229
|
+
}
|
|
230
|
+
syncRunsPoll();
|
|
231
|
+
}, RUNS_POLL_MS);
|
|
232
|
+
}
|
|
233
|
+
/** The page is being put away (closed, or into the back-forward cache): no look until it is shown again */
|
|
234
|
+
let pageAway = false;
|
|
235
|
+
function onPageHide() { pageAway = true; syncRunsPoll(); }
|
|
236
|
+
function onPageShow() { pageAway = false; syncRunsPoll(); }
|
|
237
|
+
|
|
196
238
|
/** Loads the list the kind and state name (again, past the server's cache, on a refresh) */
|
|
197
239
|
async function loadList(refresh = false, quiet = false) {
|
|
198
240
|
const token = ++listToken;
|
|
@@ -293,7 +335,10 @@ export function clearPull() {
|
|
|
293
335
|
/** What the selected pull request lights, worked out again: for the nodes now on the map */
|
|
294
336
|
function relight() {
|
|
295
337
|
ui.nodesSeen = state.nodes;
|
|
296
|
-
if (ui.
|
|
338
|
+
if (ui.runSel) {
|
|
339
|
+
const run = runById(ui.runSel);
|
|
340
|
+
state.forgeLit = run ? litOfRun(run) : null;
|
|
341
|
+
} else if (ui.threadDetail) {
|
|
297
342
|
ui.litThread = litForThread(ui.threadDetail.thread, state.nodes);
|
|
298
343
|
state.forgeLit = { changed: ui.litThread.changed, failing: ui.litThread.failing };
|
|
299
344
|
} else if (ui.deployItem) {
|
|
@@ -359,6 +404,7 @@ export function clearDeploy() {
|
|
|
359
404
|
|
|
360
405
|
/** Forgets the selected thread without painting: another thing is being selected */
|
|
361
406
|
function dropThread() {
|
|
407
|
+
ui.runSel = null;
|
|
362
408
|
threadToken++;
|
|
363
409
|
ui.thread = null;
|
|
364
410
|
ui.threadDetail = null;
|
|
@@ -370,6 +416,7 @@ function dropThread() {
|
|
|
370
416
|
async function selectThread(n) {
|
|
371
417
|
const token = ++threadToken;
|
|
372
418
|
pullToken++;
|
|
419
|
+
ui.runSel = null;
|
|
373
420
|
ui.selected = null;
|
|
374
421
|
ui.detail = null;
|
|
375
422
|
ui.detailError = '';
|
|
@@ -924,6 +971,8 @@ function findAgain(el) {
|
|
|
924
971
|
if (el.dataset.forgeRerun) return `[data-forge-rerun="${el.dataset.forgeRerun}"]`;
|
|
925
972
|
if (el.dataset.forgeAgent) return `[data-forge-agent="${el.dataset.forgeAgent}"]`;
|
|
926
973
|
if (el.dataset.forgeStop) return `[data-forge-stop="${el.dataset.forgeStop}"]`;
|
|
974
|
+
if (el.dataset.runSelect) return `[data-run-select="${el.dataset.runSelect}"]`;
|
|
975
|
+
if (el.dataset.runTimeline) return `[data-run-timeline="${el.dataset.runTimeline}"]`;
|
|
927
976
|
return null;
|
|
928
977
|
}
|
|
929
978
|
|
|
@@ -1010,20 +1059,51 @@ function paintRuns() {
|
|
|
1010
1059
|
}
|
|
1011
1060
|
const list = $('forge-runs-list');
|
|
1012
1061
|
if (!data) { list.innerHTML = ''; return; }
|
|
1013
|
-
list.innerHTML = data.runs.length ? data.runs
|
|
1062
|
+
list.innerHTML = data.runs.length ? runsHtml(viewContext(), data.runs) : '<li class="hx-forge-empty hx-faint">No agent has been asked for yet. Start one from an issue or a proposal.</li>';
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** What the Runs view needs to know of the page */
|
|
1066
|
+
function viewContext() {
|
|
1067
|
+
const f = ui.config && ui.config.forge;
|
|
1068
|
+
const base = f && safeUrl(f.url) ? f.url.replace(/\/+$/, '') : null;
|
|
1069
|
+
return {
|
|
1070
|
+
esc: escHtml, safeUrl, forgeName: forgeName(), forgeType: f ? f.type : 'github', repoUrl: base && f.repo ? `${base}/${f.repo}` : null,
|
|
1071
|
+
now: now(), nodes: state.nodes, selected: ui.runSel, timelines: ui.runTimelines, busy: !!ui.busy,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const runById = (id) => ((ui.runs && ui.runs.data && ui.runs.data.runs) || []).find((r) => r.runId === id) || null;
|
|
1076
|
+
|
|
1077
|
+
/** The nodes of a selected run, worked out again (the runs were read again, or the map changed) */
|
|
1078
|
+
function relightRun() {
|
|
1079
|
+
if (!runById(ui.runSel)) ui.runSel = null;
|
|
1080
|
+
relight();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Lights the nodes of a run on the map, like a pull request; the same button lets go of it */
|
|
1084
|
+
function selectRun(id) {
|
|
1085
|
+
const run = runById(id);
|
|
1086
|
+
if (!run) return;
|
|
1087
|
+
if (ui.runSel === id) {
|
|
1088
|
+
ui.runSel = null;
|
|
1089
|
+
relight();
|
|
1090
|
+
announce(`Run ${id} no longer lit on the map`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
const had = ui.runSel;
|
|
1094
|
+
pullToken++;
|
|
1095
|
+
dropThread();
|
|
1096
|
+
Object.assign(ui, { selected: null, detail: null, detailError: '', lit: null, landing: null, note: null, deploy: null, deployItem: null, litDeploy: null });
|
|
1097
|
+
ui.runSel = id;
|
|
1098
|
+
relight();
|
|
1099
|
+
const n = (run.nodes || []).length;
|
|
1100
|
+
announce(n ? `Run ${id}: ${n} ${n === 1 ? 'node' : 'nodes'} lit on the map${had ? '' : ''}` : `Run ${id} has no node on the map yet`);
|
|
1014
1101
|
}
|
|
1015
1102
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
run.report && safeUrl(run.report.url) ? `<a class="hx-link" href="${escHtml(run.report.url)}" target="_blank" rel="noreferrer">Runner\'s logs <span class="hx-sr-only">(new tab)</span></a>` : '',
|
|
1021
|
-
].filter(Boolean).join(' · ');
|
|
1022
|
-
const why = statusWhy(run);
|
|
1023
|
-
const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${escHtml(run.report.note)}</p>` : '';
|
|
1024
|
-
const stop = canStop(run) ? `<button type="button" class="hx-btn hx-forge-act" data-forge-stop="${escHtml(run.runId)}" aria-label="Stop run ${escHtml(run.runId)}"${ui.busy ? ' disabled' : ''}>Stop</button>` : '';
|
|
1025
|
-
return `<li class="hx-forge-file"><span class="hx-forge-state hx-forge-state--${escHtml(run.status === 'done' || run.status === 'merged' ? 'success' : run.status === 'failed' ? 'failure' : run.status === 'quiet' || run.status === 'stop-requested' ? 'queued' : 'running')}">${escHtml(statusWords(run))}</span> <span class="hx-name">${escHtml(run.kind)} on ${escHtml(aboutWords(run.refs))}</span> ${stop}
|
|
1026
|
-
<p class="hx-forge-meta">${escHtml(runMeta(run, now()))}</p>${why ? `<p class="hx-forge-why hx-faint">${escHtml(why)}</p>` : ''}${note}${links ? `<p class="hx-forge-meta">${links}</p>` : ''}</li>`;
|
|
1103
|
+
/** Opens or closes the timeline of a run (it stays open when the list is drawn again) */
|
|
1104
|
+
function toggleTimeline(id) {
|
|
1105
|
+
if (ui.runTimelines.has(id)) ui.runTimelines.delete(id); else ui.runTimelines.add(id);
|
|
1106
|
+
paint();
|
|
1027
1107
|
}
|
|
1028
1108
|
|
|
1029
1109
|
/** The panel's lines for the Release notes view: the status, a failure, the rate limit; the rest is paintNotes */
|
|
@@ -1045,11 +1125,11 @@ function drawNotes() {
|
|
|
1045
1125
|
function paintButton() {
|
|
1046
1126
|
const btn = $('forge-btn');
|
|
1047
1127
|
if (!btn) return;
|
|
1048
|
-
const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
|
|
1128
|
+
const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null || !!ui.runSel;
|
|
1049
1129
|
const tag = $('forge-btn-lit');
|
|
1050
1130
|
tag.hidden = !lit;
|
|
1051
|
-
tag.textContent = ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
|
|
1052
|
-
btn.setAttribute('aria-label', ui.thread !== null ? `Issues and pull requests, thread ${ui.thread} selected` : ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
|
|
1131
|
+
tag.textContent = ui.runSel ? 'run' : ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
|
|
1132
|
+
btn.setAttribute('aria-label', ui.runSel ? 'Issues and pull requests, a run lit on the map' : ui.thread !== null ? `Issues and pull requests, thread ${ui.thread} selected` : ui.deployItem ? 'Issues and pull requests, a deploy selected' : lit ? `Issues and pull requests, pull request ${ui.selected} selected` : 'Issues and pull requests');
|
|
1053
1133
|
btn.setAttribute('aria-expanded', String(ui.open));
|
|
1054
1134
|
}
|
|
1055
1135
|
|
|
@@ -1380,7 +1460,7 @@ async function stopRun(runId) {
|
|
|
1380
1460
|
ui.busy = '';
|
|
1381
1461
|
ui.runNote = { text: stopWords(res.status, res.body, runId), ok: res.ok };
|
|
1382
1462
|
announce(ui.runNote.text);
|
|
1383
|
-
await loadRuns(false);
|
|
1463
|
+
await loadRuns(false, { say: false });
|
|
1384
1464
|
paint();
|
|
1385
1465
|
}
|
|
1386
1466
|
|
|
@@ -1514,6 +1594,10 @@ function onClick(e) {
|
|
|
1514
1594
|
if (agent) { openAgent(agent.dataset.forgeAgent, `[data-forge-agent="${agent.dataset.forgeAgent}"]`); return; }
|
|
1515
1595
|
const stop = t.closest('[data-forge-stop]');
|
|
1516
1596
|
if (stop) { stopRun(stop.dataset.forgeStop); return; }
|
|
1597
|
+
const pick = t.closest('[data-run-select]');
|
|
1598
|
+
if (pick) { selectRun(pick.dataset.runSelect); return; }
|
|
1599
|
+
const timeline = t.closest('[data-run-timeline]');
|
|
1600
|
+
if (timeline) { toggleTimeline(timeline.dataset.runTimeline); return; }
|
|
1517
1601
|
const unland = t.closest('[data-forge-landing-cancel]');
|
|
1518
1602
|
if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
|
|
1519
1603
|
const merge = t.closest('[data-forge-merge]');
|
|
@@ -1610,6 +1694,10 @@ export function initForgePanel(config, opts = {}) {
|
|
|
1610
1694
|
registerSheet('forge', panel);
|
|
1611
1695
|
paintFilters();
|
|
1612
1696
|
paint();
|
|
1697
|
+
// The map shows the open runs whether or not the panel is open: read them once now, and again now and then while any is open
|
|
1698
|
+
if (config.agents && config.agents.runs) {
|
|
1699
|
+
loadRuns(false).then(() => { if (ui.kind === 'work') paint(); }, () => {});
|
|
1700
|
+
}
|
|
1613
1701
|
if (!wired) {
|
|
1614
1702
|
wired = true;
|
|
1615
1703
|
btn.addEventListener('click', toggleForge);
|
|
@@ -1617,6 +1705,10 @@ export function initForgePanel(config, opts = {}) {
|
|
|
1617
1705
|
panel.addEventListener('change', onChange);
|
|
1618
1706
|
panel.addEventListener('input', onInput);
|
|
1619
1707
|
panel.addEventListener('keydown', onKey);
|
|
1708
|
+
if (typeof window !== 'undefined') {
|
|
1709
|
+
window.addEventListener('pagehide', onPageHide);
|
|
1710
|
+
window.addEventListener('pageshow', onPageShow);
|
|
1711
|
+
}
|
|
1620
1712
|
const agent = agentEl();
|
|
1621
1713
|
if (agent) {
|
|
1622
1714
|
agent.addEventListener('click', onAgentClick);
|
|
@@ -1657,5 +1749,9 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
|
|
|
1657
1749
|
indexCache = null;
|
|
1658
1750
|
state.forgeLit = null;
|
|
1659
1751
|
state.threadIndex = null;
|
|
1660
|
-
|
|
1752
|
+
clearTimeout(runsTimer);
|
|
1753
|
+
runsTimer = 0;
|
|
1754
|
+
pageAway = false;
|
|
1755
|
+
clearRunsOnMap();
|
|
1756
|
+
Object.assign(ui, { runSel: null, runTimelines: new Set(), thread: null, threadDetail: null, threadError: '', litThread: null, idea: freshIdea(), config: null, open: false, kind: 'pulls', filters: defaultFilters(), lists: {}, work: null, workFilters: { agentsOnly: false, finished: false }, loading: false, error: '', selected: null, detail: null, landing: null, detailError: '', lit: null, deploy: null, deployItem: null, litDeploy: null, nodesSeen: null, busy: '', note: null, confirm: null, actNote: null, notes: freshNotes(), runs: null, runNote: null, agent: null });
|
|
1661
1757
|
}
|
package/explorer/index.html
CHANGED
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
<div id="flow-legend" hidden></div>
|
|
49
49
|
<div id="cloud-legend" class="hx-menu-section" hidden></div>
|
|
50
50
|
|
|
51
|
+
<!-- What the marks of agent runs mean, while there are runs on the map (runs-map.js) -->
|
|
52
|
+
<div id="runs-legend" class="hx-menu-section" hidden>
|
|
53
|
+
<div class="hx-menu-title"><span>Agent runs</span></div>
|
|
54
|
+
<div class="hx-legend-row"><span class="hx-run-swatch hx-run-swatch--flight" aria-hidden="true"></span><span>In flight: a pulsing ring (still under reduced motion)</span></div>
|
|
55
|
+
<div class="hx-legend-row"><span class="hx-run-swatch hx-run-swatch--collision" aria-hidden="true"></span><span>Collision: dashed ring, ⚠ and the words, where two open runs change one node</span></div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
51
58
|
<!-- What a proposal's marks mean, while one is open (proposal.js) -->
|
|
52
59
|
<div id="proposal-legend" class="hx-menu-section" hidden>
|
|
53
60
|
<div class="hx-menu-title"><span>Proposal marks</span></div>
|
|
@@ -171,6 +178,8 @@
|
|
|
171
178
|
<p id="map-announcer" class="hx-sr-only" role="status" aria-live="polite"></p>
|
|
172
179
|
<!-- Every label, drawn on its own transparent canvas over the map (explorer/labels.js); the map canvas still gets the pointer -->
|
|
173
180
|
<canvas id="label-canvas" aria-hidden="true"></canvas>
|
|
181
|
+
<!-- The nodes agent runs are changing, marked over the map (explorer/runs-map.js): a picture; the Runs view says the same in words -->
|
|
182
|
+
<div id="run-overlay" class="hx-run-overlay" aria-hidden="true" hidden></div>
|
|
174
183
|
|
|
175
184
|
<!-- Below 768px the proposal bar, the scenario player and the node panel stack here as bottom sheets (sheets.js); from 768px the dock is display: contents -->
|
|
176
185
|
<div id="sheet-dock" class="hx-dock">
|
package/explorer/layers.js
CHANGED
|
@@ -34,6 +34,7 @@ export const LAYER_DEFS = [
|
|
|
34
34
|
{ key: 'container', label: 'Containers', color: '#d6d3d1', desc: 'One service of a stack, from its compose file' },
|
|
35
35
|
{ key: 'device', label: 'Devices', color: '#2dd4bf', desc: 'Cameras, and live: phones, NFC tags, Home Assistant devices', live: true },
|
|
36
36
|
{ key: 'house', label: 'House', color: '#a3e635', desc: 'Floors and rooms (live)', live: true },
|
|
37
|
+
{ key: 'runs', label: 'Runs in flight', color: '#5eead4', desc: 'A pulsing ring on the nodes an open agent run is changing; a dashed ring and a warning where two runs change the same node', overlay: true },
|
|
37
38
|
{ key: 'persona', label: 'People', color: '#fb7185', desc: 'Personas, with a live count of their accounts (never a name)', live: true },
|
|
38
39
|
];
|
|
39
40
|
|
|
@@ -68,6 +69,7 @@ export function renderLayerToggles() {
|
|
|
68
69
|
const counts = {};
|
|
69
70
|
state.nodes.forEach((n) => { const key = n.type === 'external' ? externalLayer(n) : layerOf(n.type); counts[key] = (counts[key] || 0) + 1; });
|
|
70
71
|
counts.flow = state.edges.filter((e) => e.kind === 'flow').length;
|
|
72
|
+
counts.runs = state.runsMap ? state.runsMap.flight.size : 0;
|
|
71
73
|
|
|
72
74
|
container.innerHTML = LAYER_DEFS.map((layer) => {
|
|
73
75
|
const on = state.activeLayers[layer.key] !== false;
|
package/explorer/render.js
CHANGED
|
@@ -150,6 +150,14 @@ function paint() {
|
|
|
150
150
|
// The labels repaint only when the camera, the scene or the size changed, never on a packet frame
|
|
151
151
|
const labels = getLabelLayer();
|
|
152
152
|
if (labels) labels.draw(scene, state.camera);
|
|
153
|
+
paintHooks.forEach((fn) => fn(scene, state.camera));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Things drawn over the map by the page itself (the runs' marks, explorer/runs-map.js): called after each draw with the scene and the camera */
|
|
157
|
+
const paintHooks = new Set();
|
|
158
|
+
export function onPaint(fn) {
|
|
159
|
+
paintHooks.add(fn);
|
|
160
|
+
return () => paintHooks.delete(fn);
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
const scheduler = createFrameScheduler({
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module explorer/runs-map
|
|
3
|
+
*
|
|
4
|
+
* Agent runs on the map (issue #82, spec 0005 D9): the nodes an open run is changing **pulse**, and a node two or more open runs are
|
|
5
|
+
* changing carries a **collision** mark. The marks are a layer of their own over the map (`#run-overlay`, a DOM layer, so one drawing serves the
|
|
6
|
+
* Canvas 2D and the WebGL renderer, and the pulse is plain CSS that `prefers-reduced-motion` turns into a still ring):
|
|
7
|
+
*
|
|
8
|
+
* - in flight a ring that pulses (still under reduced motion: a thicker ring, no movement)
|
|
9
|
+
* - collision a dashed ring, a warning sign and the words "Collision" with how many runs: shape and text, never colour alone
|
|
10
|
+
*
|
|
11
|
+
* The layer is a toggle in the Layers menu ("Runs in flight", on unless turned off). The layer is decoration for the eye (`aria-hidden`):
|
|
12
|
+
* what a screen reader gets is the Runs view, which names each run's nodes and collisions in words, and the live region, which says when a
|
|
13
|
+
* state or a collision changes. The positions come from the scene the map just drew, so they follow every view, zoom and pan.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { state } from './state.js';
|
|
17
|
+
import { render, onPaint } from './render.js';
|
|
18
|
+
import { overlayOf } from './runs-model.js';
|
|
19
|
+
import { flowReduceMotion } from './flows.js';
|
|
20
|
+
|
|
21
|
+
/** The most marks drawn at once: past this the Runs view still lists them all */
|
|
22
|
+
export const MAX_MARKS = 80;
|
|
23
|
+
|
|
24
|
+
/** The key of the layer in the Layers menu and `state.activeLayers` */
|
|
25
|
+
export const RUNS_LAYER = 'runs';
|
|
26
|
+
|
|
27
|
+
/** @type {Map<string, HTMLElement>} the marks on the page by `<kind>:<node id>`, kept so a pulse is not restarted by every pan */
|
|
28
|
+
const marks = new Map();
|
|
29
|
+
let wired = false;
|
|
30
|
+
/** What the marks were last placed for: a paint with the same scene, camera, runs, layer and motion moves nothing (packet frames) */
|
|
31
|
+
let placed = '';
|
|
32
|
+
let placedFor = null;
|
|
33
|
+
let placedScene = null;
|
|
34
|
+
let placedLayer = null;
|
|
35
|
+
let reduce = null;
|
|
36
|
+
|
|
37
|
+
/** Whether motion is reduced: the page's setting, or a test's */
|
|
38
|
+
const reducedMotion = () => (reduce === null ? flowReduceMotion : reduce);
|
|
39
|
+
/** For tests: force reduced motion on or off (null: follow the page's setting) */
|
|
40
|
+
export function setReducedMotionForTests(on) { reduce = on; }
|
|
41
|
+
|
|
42
|
+
/** Whether the layer is on */
|
|
43
|
+
export const runsLayerOn = () => state.activeLayers[RUNS_LAYER] !== false;
|
|
44
|
+
|
|
45
|
+
/** Hands the map the runs the API listed: what pulses and what collides is worked out here, and the map is drawn again */
|
|
46
|
+
export function setRunsOnMap(runs) {
|
|
47
|
+
state.runsMap = Array.isArray(runs) && runs.length ? { ...overlayOf(runs), runs } : null;
|
|
48
|
+
wire();
|
|
49
|
+
paintLegend();
|
|
50
|
+
render();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Takes the marks off (the panel is reset, or there is no forge) */
|
|
54
|
+
export function clearRunsOnMap() {
|
|
55
|
+
state.runsMap = null;
|
|
56
|
+
clearMarks();
|
|
57
|
+
paintLegend();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function wire() {
|
|
61
|
+
if (wired) return;
|
|
62
|
+
wired = true;
|
|
63
|
+
onPaint((scene, camera) => syncRunOverlay(scene, camera));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clearMarks() {
|
|
67
|
+
for (const el of marks.values()) el.remove();
|
|
68
|
+
marks.clear();
|
|
69
|
+
placed = '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The words on a collision mark */
|
|
73
|
+
export const collisionTag = (n) => `Collision · ${n} runs`;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Puts the marks on the nodes the scene drew, at the camera. Marks are kept between frames (only their place changes), so a pan does not
|
|
77
|
+
* restart the pulse.
|
|
78
|
+
* @param {{ nodes: { id: string, x: number, y: number, r: number, alpha: number }[] }} scene
|
|
79
|
+
* @param {{ x: number, y: number, zoom: number }} camera
|
|
80
|
+
*/
|
|
81
|
+
export function syncRunOverlay(scene, camera) {
|
|
82
|
+
const layer = typeof document !== 'undefined' ? document.getElementById('run-overlay') : null;
|
|
83
|
+
if (!layer) return;
|
|
84
|
+
const map = state.runsMap;
|
|
85
|
+
if (!map || !runsLayerOn() || (!map.flight.size && !map.collide.size)) { if (marks.size) clearMarks(); layer.hidden = true; placed = ''; return; }
|
|
86
|
+
// The map paints every frame while packets move; the marks only move with the scene or the camera
|
|
87
|
+
const key = `${scene.version}|${camera.x}|${camera.y}|${camera.zoom}|${reducedMotion()}`;
|
|
88
|
+
if (key === placed && placedFor === map && placedScene === scene && placedLayer === layer) return;
|
|
89
|
+
placed = key;
|
|
90
|
+
placedLayer = layer;
|
|
91
|
+
placedFor = map;
|
|
92
|
+
placedScene = scene;
|
|
93
|
+
layer.hidden = false;
|
|
94
|
+
layer.classList.toggle('is-static', reducedMotion());
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
let n = 0;
|
|
97
|
+
for (const node of scene.nodes) {
|
|
98
|
+
if (n >= MAX_MARKS) break;
|
|
99
|
+
const runs = map.collide.get(node.id) || map.flight.get(node.id);
|
|
100
|
+
if (!runs || !(node.alpha > 0.2)) continue;
|
|
101
|
+
const kind = map.collide.has(node.id) ? 'collision' : 'flight';
|
|
102
|
+
const key = `${kind}:${node.id}`;
|
|
103
|
+
n += 1;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
let el = marks.get(key);
|
|
106
|
+
if (!el) {
|
|
107
|
+
el = document.createElement('span');
|
|
108
|
+
el.className = `hx-run-mark hx-run-mark--${kind}`;
|
|
109
|
+
el.dataset.node = node.id;
|
|
110
|
+
el.dataset.kind = kind;
|
|
111
|
+
el.innerHTML = `<span class="hx-run-ring"></span>${kind === 'collision' ? `<span class="hx-run-tag"><span class="hx-run-sign">⚠</span> ${collisionTag(runs.length)}</span>` : ''}`;
|
|
112
|
+
layer.append(el);
|
|
113
|
+
marks.set(key, el);
|
|
114
|
+
}
|
|
115
|
+
const r = Math.max(10, (node.r + 9) * camera.zoom);
|
|
116
|
+
el.style.left = `${(node.x * camera.zoom + camera.x).toFixed(1)}px`;
|
|
117
|
+
el.style.top = `${(node.y * camera.zoom + camera.y).toFixed(1)}px`;
|
|
118
|
+
el.style.setProperty('--r', `${r.toFixed(1)}px`);
|
|
119
|
+
}
|
|
120
|
+
for (const [key, el] of marks) if (!seen.has(key)) { el.remove(); marks.delete(key); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The legend in the Layers menu: shown while runs are on the map, saying in words what each mark is */
|
|
124
|
+
export function paintLegend() {
|
|
125
|
+
const el = typeof document !== 'undefined' ? document.getElementById('runs-legend') : null;
|
|
126
|
+
if (!el) return;
|
|
127
|
+
el.hidden = !(state.runsMap && (state.runsMap.flight.size || state.runsMap.collide.size));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** For tests: forget the page */
|
|
131
|
+
export function resetRunsMapForTests() {
|
|
132
|
+
clearMarks();
|
|
133
|
+
state.runsMap = null;
|
|
134
|
+
reduce = null;
|
|
135
|
+
}
|