clavarium 0.2.0-next.7 → 0.2.0-next.9

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.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.7",
3
+ "version": "0.2.0-next.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.0-next.7",
9
+ "version": "0.2.0-next.9",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
13
13
  "@xterm/xterm": "^6.0.0",
14
- "clavarium-parley": "0.10.0-next.3",
14
+ "clavarium-parley": "0.10.0-next.4",
15
15
  "mermaid": "11.17.2",
16
16
  "node-pty": "^1.1.0",
17
17
  "pdfjs-dist": "6.4.299",
@@ -686,9 +686,9 @@
686
686
  }
687
687
  },
688
688
  "node_modules/clavarium-parley": {
689
- "version": "0.10.0-next.3",
690
- "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.3.tgz",
691
- "integrity": "sha512-a750ZvTUDngz8nMdmja3/slCYCUCxM24uFEdgM1X4dNZ666Pf3HEPBbN9GwGgU6F+Ozc1JML5o87tCAMqcRyGQ==",
689
+ "version": "0.10.0-next.4",
690
+ "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.4.tgz",
691
+ "integrity": "sha512-axfpTiDMmxO0RofGc475DywHuoOLz/Jnh1o44WbyOHba6PCj0KDE/WBo36OJQCt47/3AQWMlttYn5nSBvDV1hg==",
692
692
  "license": "MIT",
693
693
  "bin": {
694
694
  "parley": "bin/parley.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.7",
3
+ "version": "0.2.0-next.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -35,7 +35,8 @@
35
35
  "prepare": "git config core.hooksPath hooks 2>/dev/null || true",
36
36
  "check:hooks": "node tools/check-hooks.mjs",
37
37
  "test:e2e": "node --test --test-concurrency=1 \"e2e/*.e2e.mjs\"",
38
- "toolbox": "node deploy/parley-archive.mjs"
38
+ "toolbox": "node deploy/parley-archive.mjs",
39
+ "acceptance": "node acceptance/run.mjs"
39
40
  },
40
41
  "engines": {
41
42
  "node": ">=22"
@@ -43,7 +44,7 @@
43
44
  "dependencies": {
44
45
  "@xterm/addon-fit": "^0.11.0",
45
46
  "@xterm/xterm": "^6.0.0",
46
- "clavarium-parley": "0.10.0-next.3",
47
+ "clavarium-parley": "0.10.0-next.4",
47
48
  "mermaid": "11.17.2",
48
49
  "node-pty": "^1.1.0",
49
50
  "pdfjs-dist": "6.4.299",
package/server/api.mjs CHANGED
@@ -382,7 +382,7 @@ export async function handleApi(request, response, conversations, headers, resou
382
382
  if (kept && gitKeeper) {
383
383
  if (kept[2] === 'keep') return send(200, gitKeeper.set(kept[1], body)), true;
384
384
  if (kept[2] === 'refresh') return send(200, await gitKeeper.refresh(kept[1])), true;
385
- return send(200, await gitKeeper.check(kept[1], body)), true;
385
+ return send(200, body?.again === true ? await gitKeeper.checkAgain(kept[1], body) : await gitKeeper.check(kept[1], body)), true;
386
386
  }
387
387
  /* Automatic sending belongs to the attachment: set in the conversation's panel. */
388
388
  const sending = /^\/api\/conversations\/([^/]+)\/git\/([a-z0-9-]{1,32})\/auto$/.exec(pathname);
@@ -38,6 +38,8 @@ export const STARTING_MS = 180_000;
38
38
  export const ANSWER_MS = 90_000;
39
39
  /* What the folders of a machine just started are waited for, at most, before its tool starts. */
40
40
  const MOUNTED_MS = 30_000;
41
+ /* How long a stop waits for folders being mounted (six folders took a minute on a real host). */
42
+ const CALM_MS = 180_000;
41
43
  const POLL_MS = 2000;
42
44
  /* The tool's screen not drawn this long after its terminal was opened: its
43
45
  start is said to be over all the same (the page then opens it itself). */
@@ -79,6 +81,13 @@ export class Conversations {
79
81
  when it was just built, restarted or its work moved; otherwise only
80
82
  looked at, and put back if they came loose (health.mjs). */
81
83
  mounted = async () => {};
84
+ /* Set by the server: what is being done to its machine's folders just
85
+ now (a look of health.mjs that may mount one again), or null. */
86
+ quiet = () => null;
87
+ /* Folders being mounted again after a start, by name: a stop waits for
88
+ them (#calm). Stopping a machine while the VM manager mounts a folder
89
+ in it was seen to hang the VM manager itself, for every machine. */
90
+ #mounting = new Map();
82
91
  /* Set by the server: told when its machine was restarted or built again
83
92
  by this server, in words for its chat, and whether the tool runs again
84
93
  (its chat says it, and the tool is told to read it again: talk.mjs). */
@@ -426,7 +435,10 @@ export class Conversations {
426
435
  const again = this.#mountAgain.delete(name);
427
436
  if (again || stopped) {
428
437
  this.#reach(name, 'folders'); tell();
429
- await Promise.race([Promise.resolve().then(() => this.mounted(name, again)).catch(() => {}), new Promise((r) => { setTimeout(r, MOUNTED_MS).unref?.(); })]);
438
+ const mounting = Promise.resolve().then(() => this.mounted(name, again)).catch(() => {});
439
+ this.#mounting.set(name, mounting);
440
+ void mounting.finally(() => { if (this.#mounting.get(name) === mounting) this.#mounting.delete(name); });
441
+ await Promise.race([mounting, new Promise((r) => { setTimeout(r, MOUNTED_MS).unref?.(); })]);
430
442
  }
431
443
  /* Woken from sleep: its tool is still there when its session is. */
432
444
  if (asleep) {
@@ -766,6 +778,7 @@ export class Conversations {
766
778
  async #cycle(name, c, between, restart) {
767
779
  const step = (text) => { if (this.#store.get(name)) this.#store.patch(name, { step: text }); };
768
780
  try {
781
+ await this.#calm(name, step);
769
782
  this.drop(name);
770
783
  let state = await this.#vms.state(name);
771
784
  if (state !== 'Stopped') {
@@ -778,7 +791,7 @@ export class Conversations {
778
791
  await this.#watched(name, 'stop', this.#vms.kill(name));
779
792
  state = await this.#vms.state(name);
780
793
  }
781
- if (state !== 'Stopped') return `the machine did not stop (it is ${state ?? 'gone'}).`;
794
+ if (state !== 'Stopped') return `the machine did not stop (${state ? `it is ${state}` : 'its state could not be read'}).`;
782
795
  }
783
796
  const failed = await between();
784
797
  if (!restart) return failed;
@@ -794,6 +807,18 @@ export class Conversations {
794
807
  } catch { return 'it stopped on an unexpected error.'; }
795
808
  }
796
809
 
810
+ /* Before its machine is stopped: its folders being mounted again are
811
+ left to finish, within a bound (a new look does not start meanwhile:
812
+ health.mjs leaves a machine that is not `ready` alone). */
813
+ async #calm(name, step) {
814
+ const pending = [this.#mounting.get(name), this.quiet(name)].filter(Boolean);
815
+ if (pending.length === 0) return;
816
+ step('Waiting for its folders to be mounted');
817
+ let timer;
818
+ await Promise.race([Promise.allSettled(pending), new Promise((r) => { timer = setTimeout(r, CALM_MS); timer.unref?.(); })]);
819
+ clearTimeout(timer);
820
+ }
821
+
797
822
  /* `chat`: the same, as its chat says it. */
798
823
  async #settle(name, said, chat, failed = false) {
799
824
  if (!this.#store.get(name)) return;
@@ -101,14 +101,14 @@ export class GitChecks {
101
101
  return Boolean(await this.#conversations.running(conversation).catch(() => false));
102
102
  }
103
103
 
104
- /* -> { status: 'passed' | 'failed' | 'none' | 'unavailable', output, machine, at, head, target }.
104
+ /* -> { status: 'passed' | 'failed' | 'none' | 'unavailable', output, machine, at, head, branch, target }.
105
105
  'none': nothing to run (no hook, no command). 'unavailable': no
106
106
  machine to run them in, or it did not answer.
107
107
  `machine`: the conversation whose machine must run them (automatic
108
108
  sending: the one whose settings decided), else any that has it. */
109
109
  async run(repository, { head, branch, target, remoteTip, remote, command = '', hook = true, seconds = 600, machine: wanted }) {
110
110
  const at = this.#now().toISOString();
111
- const base = { head, target, at, machine: null };
111
+ const base = { head, branch, target, at, machine: null };
112
112
  if (!hook && !command) return { ...base, status: 'none', output: 'No check is set: no pre-push hook asked for, no check command.' };
113
113
  const machine = wanted === undefined ? await this.machineFor(repository) : (await this.runs(wanted, repository) ? wanted : undefined);
114
114
  if (!machine) return { ...base, status: 'unavailable', output: 'No machine that has this repository attached is running: the checks could not run. Open a conversation it is attached to, then look again.' };
@@ -91,6 +91,9 @@ export const DEFAULTS = Object.freeze({
91
91
  change, so a few seconds more are enough to let a commit series finish.
92
92
  `confirmed`: the patterns of the list that needed the owner's word and
93
93
  had it. `perHour` 0: no cap. */
94
+ /* The last checks kept per repository: the end of their output, and how many targets. */
95
+ export const LAST_OUTPUT_MAX = 20_000;
96
+ export const LAST_TARGETS = 20;
94
97
  export const AUTO_DEFAULTS = Object.freeze({ branches: [], confirmed: [], quietSeconds: 5, perHour: 20, create: false, checks: true });
95
98
  export const QUIET_MAX = 86_400;
96
99
  export const PER_HOUR_MAX = 1000;
@@ -205,7 +208,7 @@ const upTo = (id) => (short(id) ? ` (up to ${short(id)})` : '');
205
208
  const seeCommits = (name, head, base, n) => (short(head) && short(base) && Number.isInteger(n) && n > 0 ? [`commits:${name}.${head.slice(0, 12)}.${base.slice(0, 12)}.${Math.min(n, 9999)}`] : []);
206
209
 
207
210
  export class GitKeeper {
208
- #store; #review; #resources; #checks; #alerts; #now; #tellAgent; #thread; #held = new Map(); #results = new Map(); #running = new Set(); #telling = new Map();
211
+ #store; #review; #resources; #checks; #alerts; #now; #tellAgent; #thread; #last; #held = new Map(); #results = new Map(); #running = new Set(); #telling = new Map();
209
212
 
210
213
  /* `review`: GitReview (reads, the reference, Send). `checks`: GitChecks.
211
214
  `alerts`: what tells the owner's phone. `tellAgent(repository, line,
@@ -213,8 +216,11 @@ export class GitKeeper {
213
216
  repository (only to that conversation's, when one is named); it
214
217
  resolves false when nobody could be told (then it is tried again).
215
218
  `thread(conversation, event)`: one line in that conversation's chat
216
- (Talk.event: kept only where its chat is on). */
217
- constructor({ store, review, resources, checks, alerts, now = () => new Date(), tellAgent = async () => {}, thread = () => {} }) {
219
+ (Talk.event: kept only where its chat is on). `last`: a Store where
220
+ the last checks of each repository and target are kept, so that they
221
+ outlive a restart (none: only in memory). */
222
+ constructor({ store, review, resources, checks, alerts, now = () => new Date(), tellAgent = async () => {}, thread = () => {}, last = null }) {
223
+ this.#last = last;
218
224
  this.#store = store; this.#review = review; this.#resources = resources; this.#checks = checks; this.#alerts = alerts; this.#now = now; this.#tellAgent = tellAgent; this.#thread = thread;
219
225
  review.listen({ read: (name, failure, got) => this.#readOutcome(name, failure, got), refresh: (name, result) => this.#refreshOutcome(name, result) });
220
226
  review.useWaivers?.((name) => this.settings(name).fsck);
@@ -257,6 +263,7 @@ export class GitKeeper {
257
263
  auto,
258
264
  origin: hasRemote ? { path: `/resources/${folder}`, kept: this.#resources.hasOrigin(r.name), ...(record.mirror ?? {}) } : null,
259
265
  checks: this.#results.get(r.name) ?? null,
266
+ last: this.lastChecks(r.name),
260
267
  read: record.lastRead ?? null,
261
268
  blocked: Object.keys(record.blocked ?? {}),
262
269
  log: record.log ?? [],
@@ -361,7 +368,7 @@ export class GitKeeper {
361
368
  /* A conversation archived: all its settings, whatever the repository. */
362
369
  forgetConversation(conversation) { for (const record of this.#store.all()) this.forgetAttachment(record.name, conversation); }
363
370
 
364
- forget(name) { this.#store.remove(name); this.#held.delete(name); this.#results.delete(name); }
371
+ forget(name) { this.#store.remove(name); this.#held.delete(name); this.#results.delete(name); this.#last?.remove(name); }
365
372
 
366
373
  /* ---------- what every read and every refresh says ---------- */
367
374
 
@@ -479,9 +486,39 @@ export class GitKeeper {
479
486
  const s = this.settings(name);
480
487
  const result = await this.#checks.run(name, { head: seen.head, branch: seen.branch, target: seen.target, remoteTip: seen.remoteTip, remote: this.#review.remoteOf(name), command: s.checkCommand, hook: s.runHook, seconds: s.checkSeconds, ...(machine ? { machine } : {}) });
481
488
  this.#results.set(name, result);
489
+ this.#keepLast(name, result);
482
490
  return result;
483
491
  }
484
492
 
493
+ /* THE LAST CHECKS, kept: one per target of the repository (the newest
494
+ LAST_TARGETS), with the end of their output. Said "outdated" when the
495
+ agent's branch they ran on has moved since. */
496
+ #keepLast(name, result) {
497
+ if (!this.#last) return;
498
+ const target = typeof result.target === 'string' ? result.target : '';
499
+ const kept = { status: result.status, head: result.head ?? null, branch: result.branch ?? null, target: target || null, at: result.at, machine: result.machine ?? null, output: String(result.output ?? '').slice(-LAST_OUTPUT_MAX) };
500
+ const before = Object.entries(this.#last.get(name)?.targets ?? {}).filter(([t]) => t !== target);
501
+ const targets = Object.fromEntries([...before, [target, kept]].sort((a, b) => String(b[1].at).localeCompare(String(a[1].at))).slice(0, LAST_TARGETS));
502
+ try { this.#last.put({ name, targets }); } catch { /* kept in memory still: a full disk does not stop the checks */ }
503
+ }
504
+ /* [{ status, head, branch, target, at, machine, output, outdated }], the newest first. */
505
+ lastChecks(name) {
506
+ const targets = this.#last?.get(name)?.targets ?? {};
507
+ let tips = {};
508
+ try { tips = this.#review.tips?.(name) ?? {}; } catch { /* not read yet */ }
509
+ return Object.values(targets).filter((x) => x && typeof x === 'object').sort((a, b) => String(b.at).localeCompare(String(a.at)))
510
+ .map((x) => ({ ...x, outdated: Boolean(x.branch && x.head && tips[x.branch]?.id && tips[x.branch].id !== x.head) }));
511
+ }
512
+ /* "Run again", from the last checks: on the tip of that branch now, for that target. */
513
+ async checkAgain(name, body) {
514
+ const r = this.#repository(name);
515
+ const target = typeof body?.target === 'string' && body.target ? body.target : undefined;
516
+ const branch = typeof body?.branch === 'string' && body.branch ? body.branch : undefined;
517
+ const out = await this.#review.outgoing(r.name, { target, branch });
518
+ if (!out?.head || !out.target) throw new Refusal(409, out?.message || 'There is no commit to check on this branch.');
519
+ return this.#check(r.name, { head: out.head, branch: out.branch, target: out.target, remoteTip: out.remoteTip ?? null });
520
+ }
521
+
485
522
  /* Send, from the page: as GitReview.push, and refused when the owner
486
523
  requires passing checks and none passed for this commit and branch. */
487
524
  async send(name, body) {
package/server/guide.mjs CHANGED
@@ -86,7 +86,7 @@ export function commonText() {
86
86
  '- Check out branches in the main folder of the repository or under /work, never in /tmp: /tmp is emptied when the machine restarts.',
87
87
  '- Secrets are environment variables: never print them, never write them to a file or a commit.',
88
88
  /* Installed by the server in every machine (toolbox.mjs): the tool is never asked to install it. */
89
- '- To talk to the person, when their chat is on: parley --help.',
89
+ '- To talk to the person, when their chat is on: parley guide says how (parley --help, every command).',
90
90
  /* Dictation into the terminal is marked once per message (app.js); in the chat, parley read says it. */
91
91
  '- A message in your terminal that starts with a microphone sign (U+1F3A4), or that parley read says was dictated, came by voice: a word may be misheard; when a key word seems wrong, ask rather than guess.',
92
92
  '- The rest is yours to explore (ls, --help).',
@@ -113,9 +113,9 @@ export function ownText({ name, tool, workKept, resources = [], chat, online, se
113
113
  out.push(`- ${repo}: its origin, read-only and kept up to date, is ${origin}. Its branches: git -C ${repo} fetch ${origin} '+refs/heads/*:refs/remotes/origin/*'`);
114
114
  }
115
115
  out.push(!chat ? '- Chat with the person: off. Do not use parley.'
116
- : '- Chat with the person: on. Parley is installed: run parley --help.');
117
- /* How to write there: the chat renders Markdown; what matters stays in sight. */
118
- if (chat) out.push('- In the chat, write as in this terminal (Markdown: short lists, a bold label per point, tables); a long message gets --short, a title that holds any action or decision; one key sentence in "> [!IMPORTANT]"; a picture of /work where it belongs: ![before](/work/a.png); close the questions they answered another way (parley close).');
116
+ : '- Chat with the person: on. Parley is installed: run parley guide.');
117
+ /* How Parley is used is Parley's to say (parley guide); what is here is this chat's: it renders Markdown. */
118
+ if (chat) out.push('- The chat shows Markdown (lists, tables, "> [!IMPORTANT]") and pictures of /work: ![before](/work/a.png).');
119
119
  out.push(online === false ? '- Internet: off.' : '- Internet: on.');
120
120
  if (secrets.length) out.push(`- Secrets given, by name only: ${list(secrets.map((s) => plain(s, 40)))}.`);
121
121
  const own = lines.map((l) => plain(l)).filter(Boolean).slice(0, LINES_MAX);
package/server/health.mjs CHANGED
@@ -120,6 +120,9 @@ export class Health {
120
120
  of(name) { return this.#records.get(name) ?? null; }
121
121
  forget(name) { this.#records.delete(name); }
122
122
 
123
+ /* The look under way (it may be mounting a folder again), or null. */
124
+ busy(name) { return this.#running.get(name) ?? null; }
125
+
123
126
  /* Looks now. One look at a time per conversation: a second caller gets the same. */
124
127
  check(name, { repair = true } = {}) {
125
128
  const going = this.#running.get(name);
package/server/main.mjs CHANGED
@@ -313,7 +313,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
313
313
  return heard;
314
314
  };
315
315
  /* What happened to a repository is said in the chat of the conversations it is attached to (talk.mjs). */
316
- const gitKeeper = new GitKeeper({ store: new Store(`${dataDir}/git-keep.json`), review: gitReview, resources: folders, alerts, tellAgent,
316
+ const gitKeeper = new GitKeeper({ store: new Store(`${dataDir}/git-keep.json`), last: new Store(`${dataDir}/git-checks.json`), review: gitReview, resources: folders, alerts, tellAgent,
317
317
  thread: (conversation, event) => talk.event(conversation, event),
318
318
  checks: new GitChecks({ vms, conversations: all, resources: folders }) });
319
319
  const gitTimer = gitKeeper.start(gitEveryMs);
@@ -337,6 +337,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
337
337
  /* A machine just started: its folders are mounted again (after a build,
338
338
  a restart, a move) or looked at and put back if they came loose (after
339
339
  a wake), before its tool starts in them. */
340
+ all.quiet = (name) => health.busy(name);
340
341
  all.mounted = async (name, again) => { if (again) await folders.remount(name).catch(() => {}); await health.check(name).catch(() => {}); };
341
342
  /* What the VM manager says each machine has is what is recorded (a record
342
343
  may say otherwise: set by hand, or by an earlier version). Not waited for. */
package/web/app.js CHANGED
@@ -2572,7 +2572,7 @@
2572
2572
  holder.append(facts, chapter('Branches', acts), diagram);
2573
2573
  const back = place ? { label: name, go: () => { void paintAssets(); } } : { label: name, go: () => { void paintRoomAssets(); } };
2574
2574
  review.addEventListener('click', () => { void openGit(name, { back }); });
2575
- const [out, graph] = await Promise.all([getJson(`/git/${name}/outgoing`, null), getJson(`/git/${name}/graph`, null)]);
2575
+ const [out, graph, keep] = await Promise.all([getJson(`/git/${name}/outgoing`, null), getJson(`/git/${name}/graph`, null), getJson(`/git/${name}/keep`, null)]);
2576
2576
  facts.replaceChildren();
2577
2577
  if (out) {
2578
2578
  const line = el('p', 'as-line');
@@ -2585,10 +2585,48 @@
2585
2585
  card('State', { ahead: 'ahead', 'up-to-date': 'up to date', diverged: 'behind: pull first', 'no-remote': 'no remote', unreachable: 'remote not answering', empty: 'empty' }[out.state] ?? out.state, out.state === 'diverged' || out.state === 'unreachable' ? 'bad' : ''),
2586
2586
  card('Remote', out.remote ? hostOf(out.remote) : 'none'));
2587
2587
  facts.append(line, cards);
2588
+ const last = lastFor(keep?.last, out);
2589
+ if (last) facts.append(lastChecksCard(name, { ...last, outdated: moved(last, out) }, back));
2588
2590
  if (out.state === 'ahead' && out.target) { send.hidden = false; send.textContent = `Send to ${out.target}...`; send.addEventListener('click', () => { void openGit(name, { back, target: out.target, branch: out.branch ?? '' }); }); }
2589
2591
  }
2590
2592
  diagram.replaceChildren(graph ? window.clavariumGraph.draw(graph, { pick: (c) => { void openGit(name, { commit: c.id, back }); } }) : el('p', 'hint', 'The branches could not be read.'));
2591
2593
  }
2594
+ /* THE LAST CHECKS of a repository (gitkeeper.mjs keeps them): those for the
2595
+ branch and target shown, else the newest. Their output is text, always. */
2596
+ const CHECK_WORDS = { passed: ['Passed', 'ok'], failed: ['Failed', 'bad'], none: ['Nothing to check', ''], unavailable: ['Could not run', 'warn'] };
2597
+ function lastFor(list, out) {
2598
+ if (!Array.isArray(list) || list.length === 0) return null;
2599
+ return list.find((x) => x.target === out?.target && x.branch === out?.branch) ?? list.find((x) => x.target === out?.target) ?? list[0];
2600
+ }
2601
+ /* Outdated: the branch they ran on has another tip now, as the server last read it or as just seen. */
2602
+ const moved = (last, out) => {
2603
+ const tip = (out?.agentBranches ?? []).find((x) => x.name === last.branch)?.id;
2604
+ return Boolean(last.outdated || (tip && last.head && tip !== last.head));
2605
+ };
2606
+ function lastChecksCard(name, last, back) {
2607
+ const box = el('div', 'as-lastchecks'); box.dataset.status = String(last.status ?? '');
2608
+ const head = el('div', 'as-lastchecks-head');
2609
+ const [word, tone] = CHECK_WORDS[last.status] ?? [String(last.status ?? ''), ''];
2610
+ head.append(el('small', '', 'Last checks'), el('span', `as-badge check${tone ? ` ${tone}` : ''}`, word));
2611
+ if (last.outdated) { const old = el('span', 'as-badge outdated', 'outdated'); old.title = `${last.branch ?? 'The branch'} has moved since they ran.`; head.append(old); }
2612
+ const shortId = typeof last.head === 'string' ? last.head.slice(0, 7) : '';
2613
+ const line = el('p', 'as-line', `${shortId ? `on ${shortId}` : 'on no commit'} \u00b7 ${last.at ? sinceWords(last.at) : ''}${last.target ? ` \u00b7 to ${last.target}` : ''}`);
2614
+ if (last.at) line.title = when(last.at);
2615
+ const acts = el('div', 'as-acts');
2616
+ const again = el('button', '', 'Run again'); again.type = 'button'; again.dataset.act = 'checks-again';
2617
+ const see = el('button', 'as-link', 'See the output'); see.type = 'button'; see.dataset.act = 'checks-output';
2618
+ see.addEventListener('click', () => { void openGit(name, { back, target: last.target ?? '', branch: last.branch ?? '', tab: 'checks' }); });
2619
+ again.addEventListener('click', async () => {
2620
+ again.disabled = true; again.textContent = 'Running...';
2621
+ let result;
2622
+ try { result = await post(`${API}/git/${name}/check`, { again: true, target: last.target ?? '', branch: last.branch ?? '' }); }
2623
+ catch (error) { result = { ...last, status: 'unavailable', output: error.message, at: new Date().toISOString(), outdated: false }; }
2624
+ box.replaceWith(lastChecksCard(name, { ...result, outdated: false }, back));
2625
+ });
2626
+ acts.append(again, see);
2627
+ box.append(head, line, acts);
2628
+ return box;
2629
+ }
2592
2630
  /* The files of a folder or a mailbox, in a conversation: the Files sheet, moved into the detail. */
2593
2631
  let filesHome = null;
2594
2632
  function embedFiles(holder, name) {
@@ -3511,7 +3549,7 @@
3511
3549
  else the one that moved last). `gitConfirm`: more than the page could
3512
3550
  show, so Send asks once more. `gitFsck`: the owner's fsck waivers. */
3513
3551
  let gitShown = ''; let gitHead = null; let gitTarget = null; let gitAsked = ''; let gitState = ''; let gitRequire = false;
3514
- let gitBranch = null; let gitAskedBranch = ''; let gitConfirm = false; let gitFsck = {};
3552
+ let gitBranch = null; let gitAskedBranch = ''; let gitConfirm = false; let gitFsck = {}; let gitLast = [];
3515
3553
  const NEW_BRANCH = '\u0000new';
3516
3554
  function paintChecks(result) {
3517
3555
  const state = $('gitview-checks-state'); state.className = '';
@@ -3555,7 +3593,7 @@
3555
3593
  try { const r = await fetch(`${API}/git/${name}/keep`); keep = await r.json(); if (!r.ok) throw new Error(keep.error); } catch { return; }
3556
3594
  if (gitShown !== name) return;
3557
3595
  const s = keep.settings;
3558
- gitRequire = s.requireForSend; gitFsck = s.fsck ?? {};
3596
+ gitRequire = s.requireForSend; gitFsck = s.fsck ?? {}; gitLast = Array.isArray(keep.last) ? keep.last : [];
3559
3597
  $('gitview-runhook').checked = s.runHook; $('gitview-checkcommand').value = s.checkCommand; $('gitview-checkseconds').value = String(s.checkSeconds);
3560
3598
  $('gitview-require').checked = s.requireForSend; $('gitview-refreshminutes').value = String(s.refreshMinutes);
3561
3599
  $('gitview-alert').checked = s.alert;
@@ -3701,6 +3739,7 @@
3701
3739
  if (commit) $('gitview-target-row').hidden = $('gitview-branch-row').hidden = true;
3702
3740
  gitHead = null; gitTarget = null; gitState = ''; gitConfirm = false;
3703
3741
  paintReviewTab();
3742
+ gitLast = [];
3704
3743
  if (!commit) await paintKeep();
3705
3744
  let out;
3706
3745
  try {
@@ -3765,6 +3804,12 @@
3765
3804
  is shown, and Send waits for them (it is refused without them only
3766
3805
  if the owner asked for that). */
3767
3806
  if (!commit && out.state === 'ahead') await runChecks();
3807
+ else if (!commit) {
3808
+ /* Nothing to send now: the last checks kept for this branch and target, as they were. */
3809
+ const found = lastFor(gitLast.filter((x) => x.target === out.target), out);
3810
+ const last = found ? { ...found, outdated: moved(found, out) } : null;
3811
+ if (last) { $('gitview-checks').hidden = false; paintChecks(last); $('gitview-checks-state').textContent += ` On ${String(last.head ?? '').slice(0, 7)}, ${sinceWords(last.at)}${last.outdated ? ' (outdated: the branch moved since)' : ''}.`; }
3812
+ }
3768
3813
  }
3769
3814
  /* `commit`: one commit of the diagram. `back`: {label, go} -- where the page goes back to. */
3770
3815
  /* The review is shown inside the page it was opened from, never over it:
package/web/style.css CHANGED
@@ -1086,6 +1086,12 @@ header .ic, #folders .ic { width: 18px; height: 18px; fill: none; stroke: curren
1086
1086
  .as-card b { font-size: 1.05rem; }
1087
1087
  .as-card b.warn { color: var(--warn); } .as-card b.ok { color: var(--ok); } .as-card b.bad { color: var(--danger); }
1088
1088
  .as-acts { display: flex; gap: 8px; flex-wrap: wrap; }
1089
+ .as-lastchecks { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
1090
+ .as-lastchecks-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1091
+ .as-lastchecks-head small { color: var(--ink2); }
1092
+ .as-badge.check.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); }
1093
+ .as-badge.check.bad { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
1094
+ .as-badge.check.warn, .as-badge.outdated { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
1089
1095
  .as-users { display: flex; flex-direction: column; gap: 6px; }
1090
1096
  .as-picker { padding: 8px; border: 1px dashed var(--line); border-radius: 10px; }
1091
1097
  .as-chips { display: flex; flex-wrap: wrap; gap: 6px; }