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.
- package/npm-shrinkwrap.json +6 -6
- package/package.json +4 -3
- package/server/api.mjs +1 -1
- package/server/conversations.mjs +27 -2
- package/server/gitchecks.mjs +2 -2
- package/server/gitkeeper.mjs +41 -4
- package/server/guide.mjs +4 -4
- package/server/health.mjs +3 -0
- package/server/main.mjs +2 -1
- package/web/app.js +48 -3
- package/web/style.css +6 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.0-next.
|
|
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.
|
|
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.
|
|
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.
|
|
690
|
-
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.
|
|
691
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
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);
|
package/server/conversations.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/server/gitchecks.mjs
CHANGED
|
@@ -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.' };
|
package/server/gitkeeper.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
117
|
-
/* How
|
|
118
|
-
if (chat) out.push('-
|
|
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: .');
|
|
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; }
|