cornfield 0.10.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.
@@ -39,7 +39,7 @@
39
39
  * @typedef {{ issue?: number, proposal?: string, pull?: number, thread?: number }} RunRefs
40
40
  * @typedef {{ minutes?: number, turns?: number, cost?: number }} RunLimits
41
41
  * @typedef {{ version: 1, runId: string, kind: RunKind, profile?: string, refs: RunRefs, branch?: string, brief: string,
42
- * limits?: RunLimits, nodes?: string[], requestedBy: string, requestedAt: string }} RunRequest
42
+ * limits?: RunLimits, nodes?: string[], requestedBy: string, requestedAt: string, trigger?: { event: 'spec.approved' | 'build.opened' | 'thread.message', by: string, ref: string } }} RunRequest
43
43
  * @typedef {'claimed' | 'working' | 'done' | 'failed' | 'stopped'} ReportState
44
44
  * @typedef {{ state: ReportState, note?: string, url?: string, tokens?: number, cost?: number }} RunReport
45
45
  * @typedef {{ state: 'stop-requested', note?: string }} StopRequest
@@ -83,7 +83,10 @@ export const RUN_TAG = 'cornfield-run';
83
83
  /** The key of the trailer */
84
84
  export const TRAILER_KEY = 'Cornfield-Run';
85
85
 
86
- const REQUEST_KEYS = ['version', 'runId', 'kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'requestedAt'];
86
+ const REQUEST_KEYS = ['version', 'runId', 'kind', 'profile', 'refs', 'branch', 'brief', 'limits', 'nodes', 'requestedBy', 'requestedAt', 'trigger'];
87
+ const TRIGGER_KEYS = ['event', 'by', 'ref'];
88
+ /** The events an opt-in trigger may fire on (`agents.on`, spec 0005 D10): each is something a maintainer did */
89
+ export const TRIGGER_EVENTS = Object.freeze(['spec.approved', 'build.opened', 'thread.message']);
87
90
  const REF_KEYS = ['issue', 'proposal', 'pull', 'thread'];
88
91
  const LIMIT_KEYS = ['minutes', 'turns', 'cost'];
89
92
  const REPORT_KEYS = ['state', 'note', 'url', 'tokens', 'cost'];
@@ -101,6 +104,8 @@ export const LOGIN_SOURCE = '^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$';
101
104
  export const SLUG_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._-]{0,${MAX_SLUG_CHARS - 1}}$`;
102
105
  export const PROFILE_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._-]{0,${MAX_PROFILE_CHARS - 1}}$`;
103
106
  export const NODE_ID_SOURCE = `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,${MAX_NODE_CHARS - 1}}$`;
107
+ /** What an automatic request says caused it: a pull request, an issue or a comment, by its number or id */
108
+ export const TRIGGER_REF_SOURCE = '^(pull|issue|comment):[0-9]{1,20}$';
104
109
  export const ISO_SOURCE = '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?Z$';
105
110
  /** An http(s) link with no user and password in it */
106
111
  export const URL_SOURCE = '^https?://[^\\s/@?#]+([/?#]\\S*)?$';
@@ -150,6 +155,7 @@ const SLUG = new RegExp(SLUG_SOURCE);
150
155
  const PROFILE = new RegExp(PROFILE_SOURCE);
151
156
  const NODE_ID = new RegExp(NODE_ID_SOURCE);
152
157
  const ISO = new RegExp(ISO_SOURCE);
158
+ const TRIGGER_REF = new RegExp(TRIGGER_REF_SOURCE);
153
159
  const URL_RE = new RegExp(URL_SOURCE);
154
160
  const CREDENTIAL_KEY = new RegExp(CREDENTIAL_KEY_SOURCE);
155
161
  const CREDENTIAL_VALUE = new RegExp(CREDENTIAL_VALUE_SOURCE);
@@ -355,6 +361,17 @@ export function validateRequest(body, { profiles } = {}) {
355
361
  if (body.nodes !== undefined) value.nodes = [...body.nodes];
356
362
  value.requestedBy = body.requestedBy;
357
363
  value.requestedAt = body.requestedAt;
364
+ if (body.trigger !== undefined) {
365
+ if (!isObject(body.trigger)) return no('"trigger" is { event, by, ref }: what caused an automatic request.');
366
+ const triggerKeys = keyProblem(body.trigger, TRIGGER_KEYS, '"trigger"');
367
+ if (triggerKeys) return no(triggerKeys);
368
+ if (!TRIGGER_EVENTS.includes(body.trigger.event)) return no(`"trigger.event" is one of ${TRIGGER_EVENTS.join(', ')}.`);
369
+ if (typeof body.trigger.by !== 'string' || !LOGIN.test(body.trigger.by)) return no('"trigger.by" is the forge login of the maintainer whose act caused the request.');
370
+ if (typeof body.trigger.ref !== 'string' || !TRIGGER_REF.test(body.trigger.ref)) return no('"trigger.ref" is what the act was on: pull:<number>, issue:<number> or comment:<id>.');
371
+ // The request is posted as the maintainer whose act caused it, so the two name one person
372
+ if (body.trigger.by !== body.requestedBy) return no('"trigger.by" is the maintainer the request is made as, the same login as "requestedBy".');
373
+ value.trigger = { event: body.trigger.event, by: body.trigger.by, ref: body.trigger.ref };
374
+ }
358
375
  return { ok: true, value };
359
376
  }
360
377
 
@@ -181,6 +181,37 @@
181
181
  "requestedAt": {
182
182
  "type": "string",
183
183
  "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,3})?Z$"
184
+ },
185
+ "trigger": {
186
+ "description": "Only on an automatic request (agents.on): what caused it. A maintainer's act, never a run's output.",
187
+ "type": "object",
188
+ "additionalProperties": false,
189
+ "required": [
190
+ "event",
191
+ "by",
192
+ "ref"
193
+ ],
194
+ "properties": {
195
+ "event": {
196
+ "enum": [
197
+ "spec.approved",
198
+ "build.opened",
199
+ "thread.message"
200
+ ]
201
+ },
202
+ "by": {
203
+ "type": "string",
204
+ "maxLength": 40,
205
+ "not": {
206
+ "pattern": "(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{30,}|(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{20,}|(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{15,}|(?<![A-Za-z0-9])gto-[A-Za-z0-9]{20,}|(?<![A-Za-z0-9])cf_(?:rt|at)_[A-Za-z0-9_-]{20,}|(?<![A-Za-z0-9])sk-(?=[A-Za-z0-9_-]{20})[A-Za-z_-]{0,40}[0-9]|(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}|(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}|(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,}|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}|-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----|(?<![A-Za-z0-9])[bB][eE][aA][rR][eE][rR][ \\t]+(?=[A-Za-z0-9._~+/=-]{20})[A-Za-z._~+/=-]{0,40}[0-9]|(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]{0,31}://[^\\s/@:]+:[^\\s/@]+@"
207
+ },
208
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$"
209
+ },
210
+ "ref": {
211
+ "type": "string",
212
+ "pattern": "^(pull|issue|comment):[0-9]{1,20}$"
213
+ }
214
+ }
184
215
  }
185
216
  }
186
217
  },
package/core/config.js CHANGED
@@ -40,7 +40,9 @@ import fs from 'node:fs';
40
40
  import os from 'node:os';
41
41
  import path from 'node:path';
42
42
  import { pathToFileURL, fileURLToPath } from 'node:url';
43
- import { PROFILE_SOURCE, MAX_NUMBER } from './agent-contract.js';
43
+ import { PROFILE_SOURCE, MAX_NUMBER, MAX_BRIEF_CHARS, RUN_KINDS, TRIGGER_EVENTS, CREDENTIAL_VALUE_SOURCE } from './agent-contract.js';
44
+
45
+ export { TRIGGER_EVENTS };
44
46
 
45
47
  export const CONFIG_FILE = 'cornfield.config.js';
46
48
  export const CORNFIELD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -68,7 +70,7 @@ export const FORGE_TYPES = ['github', 'forgejo'];
68
70
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
69
71
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
70
72
  /** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
71
- export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked', 'agent.requested', 'run.claimed', 'run.state', 'run.stopRequested', 'run.finished']);
73
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring', 'thread.opened', 'thread.message', 'thread.linked', 'agent.requested', 'run.claimed', 'run.state', 'run.stopRequested', 'run.finished', 'trigger.skipped']);
72
74
  /** The events that come from looking at threads (server/threads.js): a poll that wants any of them looks at threads too */
73
75
  export const THREAD_EVENTS = Object.freeze(['thread.opened', 'thread.message', 'thread.linked']);
74
76
  /** The keys of `threads` when it is an object, and the label's longest length */
@@ -111,7 +113,7 @@ export const ideaLabel = (config) => (threadsOn(config) ? (config.threads && con
111
113
  * @returns {number | null}
112
114
  */
113
115
  export function specPollMs(config) {
114
- const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && hookWants(config, THREAD_EVENTS));
116
+ const wanted = hookWants(config, ['spec.ready']) || (threadsOn(config) && (hookWants(config, THREAD_EVENTS) || triggersOf(config).some(([e]) => e === 'thread.message')));
115
117
  const every = config.hookPoll === false ? false : config.hookPoll ? config.hookPoll.specs : DEFAULTS.hookPoll.specs;
116
118
  return wanted && every !== false ? parseDuration(every ?? DEFAULTS.hookPoll.specs) : null;
117
119
  }
@@ -150,7 +152,7 @@ export const DEFAULTS = Object.freeze({
150
152
  personas: { roles: null, parent: 'root' },
151
153
  cloud: { parent: 'root' },
152
154
  releaseNotes: { breakingLabel: 'breaking' },
153
- agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {} },
155
+ agents: { authors: [], labels: [], branchPrefixes: [], bots: true, transport: 'forge', maxOpen: DEFAULT_MAX_OPEN, profiles: [], runners: [], quietAfter: DEFAULT_QUIET_AFTER, limits: {}, on: {}, refuseCollisions: false },
154
156
  hooks: [],
155
157
  hookPoll: { specs: '5m' },
156
158
  threads: { label: 'idea' },
@@ -163,7 +165,7 @@ export const DEFAULTS = Object.freeze({
163
165
  const KEYS = Object.keys(DEFAULTS);
164
166
  /** The keys of `proposals` when it is an object: the folder, and whether an approval also lands the spec PR (opt-in, off unless true) */
165
167
  export const PROPOSALS_KEYS = Object.freeze(['dir', 'approveLands']);
166
- const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits'];
168
+ const AGENT_KEYS = ['authors', 'labels', 'branchPrefixes', 'bots', 'transport', 'maxOpen', 'profiles', 'runners', 'quietAfter', 'limits', 'on', 'refuseCollisions'];
167
169
  const PROFILE_NAME = new RegExp(PROFILE_SOURCE);
168
170
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
169
171
  const isText = (v) => typeof v === 'string' && v.trim() !== '';
@@ -237,6 +239,7 @@ export function configProblems(c) {
237
239
  }
238
240
  if (c.agents.bots !== undefined && typeof c.agents.bots !== 'boolean') out.push('agents.bots is true or false: whether a bot account ("[bot]" or the forge\'s Bot type) counts as an agent.');
239
241
  out.push(...agentRunProblems(c.agents));
242
+ if (isObject(c.agents.on) && c.agents.on['thread.message'] !== undefined && c.threads === false) out.push('agents.on["thread.message"] needs threads, which are off (threads: false): the /agent command is read in a thread.');
240
243
  }
241
244
  }
242
245
  if (c.deploys !== undefined && c.deploys !== null) {
@@ -332,10 +335,35 @@ export function configProblems(c) {
332
335
  return out;
333
336
  }
334
337
 
338
+ /**
339
+ * What is wrong with `agents.on`, the opt-in triggers (spec 0005 D10): `{ '<event>': { kind, profile?, brief? } }` for the events a
340
+ * maintainer causes. Off by default (no key, no trigger). `kind` is checked against the contract and `profile` against `agents.profiles`.
341
+ */
342
+ function triggerProblems(a) {
343
+ const out = [];
344
+ const on = a.on;
345
+ if (!isObject(on)) return [`agents.on is { ${TRIGGER_EVENTS.map((e) => `"${e}": { kind, profile? }`).join(', ')} }: which events request a run by themselves (none unless set).`];
346
+ const profiles = Array.isArray(a.profiles) ? a.profiles : [];
347
+ for (const [event, t] of Object.entries(on)) {
348
+ const at = `agents.on[${JSON.stringify(event)}]`;
349
+ if (!TRIGGER_EVENTS.includes(event)) { out.push(`${at}: a trigger fires only on ${TRIGGER_EVENTS.join(', ')}: something a maintainer did, never a run's output.`); continue; }
350
+ if (!isObject(t)) { out.push(`${at} is { kind, profile?, brief? }.`); continue; }
351
+ for (const k of Object.keys(t)) if (!['kind', 'profile', 'brief'].includes(k)) out.push(`${at}.${k}: the keys are kind, profile, brief.`);
352
+ if (!RUN_KINDS.includes(t.kind)) out.push(`${at}.kind is one of ${RUN_KINDS.join(', ')}.`);
353
+ if (t.profile !== undefined && !(typeof t.profile === 'string' && profiles.includes(t.profile))) out.push(`${at}.profile ${JSON.stringify(t.profile)} is not one of agents.profiles (${profiles.join(', ') || 'none listed'}).`);
354
+ if (t.brief !== undefined && !(typeof t.brief === 'string' && t.brief.length <= MAX_BRIEF_CHARS && !new RegExp(CREDENTIAL_VALUE_SOURCE).test(t.brief))) out.push(`${at}.brief is text of at most ${MAX_BRIEF_CHARS} characters, and holds nothing shaped like a credential.`);
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /** The triggers a config turns on, as `[event, { kind, profile?, brief? }]` (none when off, which is the default) */
360
+ export const triggersOf = (config) => Object.entries(isObject(config.agents?.on) ? config.agents.on : {}).filter(([e]) => TRIGGER_EVENTS.includes(e));
361
+
335
362
  /** What is wrong with the keys of `agents` that ask for and follow runs (spec 0005), in words */
336
363
  function agentRunProblems(a) {
337
364
  const out = [];
338
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).');
339
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).`);
340
368
  if (a.profiles !== undefined) {
341
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) {
@@ -353,6 +381,7 @@ function agentRunProblems(a) {
353
381
  else if (ms < QUIET_MIN_MS) out.push(`agents.quietAfter ${JSON.stringify(a.quietAfter)} is under the floor of 5m; use "5m" or more.`);
354
382
  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.`);
355
383
  }
384
+ if (a.on !== undefined) out.push(...triggerProblems(a));
356
385
  if (a.limits !== undefined) {
357
386
  if (!isObject(a.limits)) out.push('agents.limits is { minutes?, turns?, cost? }: hints the runner may enforce.');
358
387
  else {
@@ -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
- .hx-forge { position: absolute; top: 0; left: 0; bottom: 0; width: min(400px, calc(100% - 24px)); border-left: 0; transform: translateX(-100%); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); }
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: 412px; width: min(720px, calc(100% - 436px)); }
803
- .hx-stage:has(.forge-open):has(.drawer-open) .hx-proposal-bar { width: min(720px, calc(100% - 940px)); }
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
+ }
@@ -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, 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.threadDetail) {
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
 
@@ -1002,22 +1051,59 @@ function paintRuns() {
1002
1051
  note.hidden = !words;
1003
1052
  note.className = `hx-forge-notice${!(ui.runs && ui.runs.error) && ui.runNote && ui.runNote.ok ? ' hx-forge-notice--ok' : ''}`;
1004
1053
  note.textContent = words;
1054
+ const trig = $('forge-runs-trigger');
1055
+ if (trig) {
1056
+ const skipped = data ? triggerNotice(data) : '';
1057
+ trig.hidden = !skipped;
1058
+ trig.textContent = skipped;
1059
+ }
1005
1060
  const list = $('forge-runs-list');
1006
1061
  if (!data) { list.innerHTML = ''; return; }
1007
- list.innerHTML = data.runs.length ? data.runs.map(runHtml).join('') : '<li class="hx-forge-empty hx-faint">No agent has been asked for yet. Start one from an issue or a proposal.</li>';
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
+ };
1008
1073
  }
1009
1074
 
1010
- function runHtml(run) {
1011
- const links = [
1012
- safeUrl(run.comment && run.comment.url) ? `<a class="hx-link" href="${escHtml(run.comment.url)}" target="_blank" rel="noreferrer">Request on ${escHtml(forgeName())} <span class="hx-sr-only">(new tab)</span></a>` : '',
1013
- run.pull && safeUrl(run.pull.url) ? `<a class="hx-link" href="${escHtml(run.pull.url)}" target="_blank" rel="noreferrer">Pull request #${run.pull.number} <span class="hx-sr-only">(new tab)</span></a>${run.pull.trusted ? '' : ' <span class="hx-faint">(not from a runner, so it moves nothing)</span>'}` : '',
1014
- 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>` : '',
1015
- ].filter(Boolean).join(' · ');
1016
- const why = statusWhy(run);
1017
- const note = run.report && run.report.note ? `<p class="hx-forge-meta">Runner: ${escHtml(run.report.note)}</p>` : '';
1018
- 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>` : '';
1019
- 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}
1020
- <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>`;
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`);
1101
+ }
1102
+
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();
1021
1107
  }
1022
1108
 
1023
1109
  /** The panel's lines for the Release notes view: the status, a failure, the rate limit; the rest is paintNotes */
@@ -1039,11 +1125,11 @@ function drawNotes() {
1039
1125
  function paintButton() {
1040
1126
  const btn = $('forge-btn');
1041
1127
  if (!btn) return;
1042
- const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null;
1128
+ const lit = ui.selected !== null || !!ui.deployItem || ui.thread !== null || !!ui.runSel;
1043
1129
  const tag = $('forge-btn-lit');
1044
1130
  tag.hidden = !lit;
1045
- tag.textContent = ui.thread !== null ? `thread #${ui.thread}` : ui.deployItem ? 'deploy' : lit ? `#${ui.selected}` : '';
1046
- 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');
1047
1133
  btn.setAttribute('aria-expanded', String(ui.open));
1048
1134
  }
1049
1135
 
@@ -1374,7 +1460,7 @@ async function stopRun(runId) {
1374
1460
  ui.busy = '';
1375
1461
  ui.runNote = { text: stopWords(res.status, res.body, runId), ok: res.ok };
1376
1462
  announce(ui.runNote.text);
1377
- await loadRuns(false);
1463
+ await loadRuns(false, { say: false });
1378
1464
  paint();
1379
1465
  }
1380
1466
 
@@ -1508,6 +1594,10 @@ function onClick(e) {
1508
1594
  if (agent) { openAgent(agent.dataset.forgeAgent, `[data-forge-agent="${agent.dataset.forgeAgent}"]`); return; }
1509
1595
  const stop = t.closest('[data-forge-stop]');
1510
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; }
1511
1601
  const unland = t.closest('[data-forge-landing-cancel]');
1512
1602
  if (unland) { cancelLanding(Number(unland.dataset.forgeLandingCancel)); return; }
1513
1603
  const merge = t.closest('[data-forge-merge]');
@@ -1604,6 +1694,10 @@ export function initForgePanel(config, opts = {}) {
1604
1694
  registerSheet('forge', panel);
1605
1695
  paintFilters();
1606
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
+ }
1607
1701
  if (!wired) {
1608
1702
  wired = true;
1609
1703
  btn.addEventListener('click', toggleForge);
@@ -1611,6 +1705,10 @@ export function initForgePanel(config, opts = {}) {
1611
1705
  panel.addEventListener('change', onChange);
1612
1706
  panel.addEventListener('input', onInput);
1613
1707
  panel.addEventListener('keydown', onKey);
1708
+ if (typeof window !== 'undefined') {
1709
+ window.addEventListener('pagehide', onPageHide);
1710
+ window.addEventListener('pageshow', onPageShow);
1711
+ }
1614
1712
  const agent = agentEl();
1615
1713
  if (agent) {
1616
1714
  agent.addEventListener('click', onAgentClick);
@@ -1651,5 +1749,9 @@ export function resetForgePanelForTests(clock = () => Date.now()) {
1651
1749
  indexCache = null;
1652
1750
  state.forgeLit = null;
1653
1751
  state.threadIndex = null;
1654
- Object.assign(ui, { 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 });
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 });
1655
1757
  }
@@ -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">
@@ -270,6 +279,7 @@
270
279
  <h3 id="forge-runs-title" class="hx-forge-runs-title">Agent runs</h3>
271
280
  <p id="forge-runs-status" class="hx-forge-meta hx-faint"></p>
272
281
  <p id="forge-runs-note" class="hx-forge-notice" hidden></p>
282
+ <p id="forge-runs-trigger" class="hx-forge-notice" hidden></p>
273
283
  <ul id="forge-runs-list" class="hx-forge-files" aria-label="Runs"></ul>
274
284
  </section>
275
285
  <section id="forge-detail" class="hx-forge-detail" aria-label="Selected pull request" hidden></section>
@@ -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;
@@ -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({