clavarium 0.2.0-next.1 → 0.2.0-next.10
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/deploy/install.sh +10 -3
- package/npm-shrinkwrap.json +6 -6
- package/package.json +4 -3
- package/server/api.mjs +22 -3
- package/server/conversations.mjs +133 -11
- package/server/gitchecks.mjs +2 -2
- package/server/gitkeeper.mjs +51 -9
- package/server/gitpush.mjs +78 -2
- package/server/guide.mjs +4 -4
- package/server/health.mjs +3 -0
- package/server/instance.mjs +35 -0
- package/server/main.mjs +22 -9
- package/server/names.mjs +35 -0
- package/server/store.mjs +21 -0
- package/web/app.js +1329 -394
- package/web/device.html +4 -3
- package/web/gitgraph.js +116 -0
- package/web/index.html +168 -142
- package/web/links.js +30 -10
- package/web/style.css +254 -5
- package/web/vendor/diff2html/LICENSE +49 -0
- package/web/vendor/diff2html/diff2html-ui-slim.min.js +1 -0
- package/web/vendor/diff2html/diff2html.min.css +1 -0
package/deploy/install.sh
CHANGED
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
# server mode)
|
|
39
39
|
# --relay-installed the relay was set up already (by `clavarium online`):
|
|
40
40
|
# do not print the command to run on it
|
|
41
|
+
# --name NAME the name of this Clavarium, under its icon and in its
|
|
42
|
+
# tab (default: drawn from --domain); changed in Settings.
|
|
43
|
+
# Letters, digits, spaces, dots, hyphens: 30 at most
|
|
41
44
|
# --dry-run check the parameters, say what would be done, do nothing
|
|
42
45
|
#
|
|
43
46
|
# AFTER --local, going online is one command: `sudo clavarium online --relay
|
|
@@ -107,12 +110,12 @@ DEX_LAYER="680f1119fdbcdd7f0ad8985b2534ded871b08b5b77c40f337c38aebd062b59c2"
|
|
|
107
110
|
DEX_SHA256="217aa675738d1cbaf25fe8d7bc04cb00dbc60f91d8465b76b8d8af963d571337"
|
|
108
111
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
109
112
|
|
|
110
|
-
bad() { echo "install: $*" >&2; echo "usage: install.sh --domain NAME --operator EMAIL [--operator EMAIL...] [--port N] [--relay HOST [--relay-port N]] [--core-only] [--version X.Y.Z | --from-dir DIR] [--updates auto|asap|ask|off] [--update-window HH:MM-HH:MM] [--channel latest|next] [--dry-run]" >&2; echo " or: install.sh --local [--port N] [same options] [--dry-run] to try it on this machine, with no domain" >&2; exit 64; }
|
|
113
|
+
bad() { echo "install: $*" >&2; echo "usage: install.sh --domain NAME --operator EMAIL [--operator EMAIL...] [--port N] [--relay HOST [--relay-port N]] [--core-only] [--version X.Y.Z | --from-dir DIR] [--updates auto|asap|ask|off] [--update-window HH:MM-HH:MM] [--channel latest|next] [--name NAME] [--dry-run]" >&2; echo " or: install.sh --local [--port N] [same options] [--dry-run] to try it on this machine, with no domain" >&2; exit 64; }
|
|
111
114
|
refuse() { echo "install: REFUSED: $*" >&2; exit 78; }
|
|
112
115
|
step() { echo "== $*"; }
|
|
113
116
|
|
|
114
117
|
DOMAIN=""; OPERATORS=""; PORT="7717"; CORE_ONLY="no"; DRY="no"; RELAY=""; RELAY_PORT="22"; LOCAL="no"; KEEP_RELEASE="no"; RELAY_INSTALLED="no"
|
|
115
|
-
VERSION=""; FROM_DIR=""; UPDATES=""; WINDOW=""; CHANNEL=""
|
|
118
|
+
VERSION=""; FROM_DIR=""; UPDATES=""; WINDOW=""; CHANNEL=""; APP_NAME=""
|
|
116
119
|
while [ $# -gt 0 ]; do
|
|
117
120
|
case "$1" in
|
|
118
121
|
--domain) DOMAIN="${2:-}"; shift 2 || bad "--domain needs a value" ;;
|
|
@@ -129,6 +132,7 @@ while [ $# -gt 0 ]; do
|
|
|
129
132
|
--channel) CHANNEL="${2:-}"; shift 2 || bad "--channel needs a value" ;;
|
|
130
133
|
--keep-release) KEEP_RELEASE="yes"; shift ;;
|
|
131
134
|
--relay-installed) RELAY_INSTALLED="yes"; shift ;;
|
|
135
|
+
--name) APP_NAME="${2:-}"; [ -n "$APP_NAME" ] || bad "--name needs a value"; shift 2 ;;
|
|
132
136
|
--dry-run) DRY="yes"; shift ;;
|
|
133
137
|
*) bad "unknown parameter: $1" ;;
|
|
134
138
|
esac
|
|
@@ -170,6 +174,8 @@ fi
|
|
|
170
174
|
[ -z "$UPDATES" ] || [[ "$UPDATES" =~ ^(auto|asap|ask|off)$ ]] || bad "--updates is auto, asap, ask or off"
|
|
171
175
|
[ -z "$WINDOW" ] || { [[ "$WINDOW" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$ ]] && [ "${WINDOW%-*}" != "${WINDOW#*-}" ]; } || bad "--update-window is two times, like 02:00-05:00"
|
|
172
176
|
[ -z "$CHANNEL" ] || [[ "$CHANNEL" =~ ^(latest|next)$ ]] || bad "--channel is latest or next"
|
|
177
|
+
# It goes in the unit file, between quotes: plain characters only (any other is chosen in Settings).
|
|
178
|
+
[ -z "$APP_NAME" ] || [[ "$APP_NAME" =~ ^[A-Za-z0-9]([A-Za-z0-9\ ._-]{0,28}[A-Za-z0-9.])?$ ]] || bad "--name is letters, digits, spaces, dots and hyphens, 30 at most, like \"Home lab\""
|
|
173
179
|
# What the updates will be, as the settings file has them or as asked now.
|
|
174
180
|
had() { { sed -n "s/^$1=//p" "$UPDATE_CONF" 2>/dev/null || true; } | head -1; }
|
|
175
181
|
SHOW_MODE="${UPDATES:-$(had MODE)}"; SHOW_MODE="${SHOW_MODE:-auto}"
|
|
@@ -188,6 +194,7 @@ if [ "$DRY" = "yes" ]; then
|
|
|
188
194
|
would install Clavarium:
|
|
189
195
|
address $([ "$LOCAL" = "yes" ] && echo "http://127.0.0.1:$PORT, from this machine only (--local)" || echo "https://$DOMAIN")
|
|
190
196
|
operators $([ "$LOCAL" = "yes" ] && echo "whoever can open a page on this machine: no sign-in" || echo "$OPERATORS")
|
|
197
|
+
name $([ -n "$APP_NAME" ] && echo "$APP_NAME" || echo "drawn from the address; changed in Settings")
|
|
191
198
|
server 127.0.0.1:$PORT, as the account "$ACCOUNT", Node.js $NODE_VERSION
|
|
192
199
|
release $([ "$KEEP_RELEASE" = "yes" ] && echo "the one that runs, kept (--keep-release)" || [ -n "$VERSION" ] && echo "clavarium@$VERSION from npm, checked (integrity, provenance from github.com/clavarium/clavarium, npm audit signatures)" || echo "the working tree $FROM_DIR, as it is (development: not checked)")
|
|
193
200
|
updates $(case "$SHOW_MODE" in auto) echo "automatic, between ${SHOW_WINDOW%-*} and ${SHOW_WINDOW#*-}" ;; asap) echo "as soon as a version is out" ;; ask) echo "said in Settings, done when you say" ;; off) echo "off" ;; esac), channel $SHOW_CHANNEL
|
|
@@ -335,7 +342,7 @@ User=$ACCOUNT
|
|
|
335
342
|
Group=$ACCOUNT
|
|
336
343
|
WorkingDirectory=$CODE/current/app
|
|
337
344
|
EnvironmentFile=$ETC/server.env
|
|
338
|
-
ExecStart=$CODE/current/node/bin/node server/main.mjs --host "sudo -n $HOST_PROGRAM" --data $DATA --vault-key $VAULT/vault.key --port $PORT$([ "$LOCAL" = "yes" ] || echo " --operators $OPERATORS --public-origin https://$DOMAIN")
|
|
345
|
+
ExecStart=$CODE/current/node/bin/node server/main.mjs --host "sudo -n $HOST_PROGRAM" --data $DATA --vault-key $VAULT/vault.key --port $PORT$([ "$LOCAL" = "yes" ] || echo " --operators $OPERATORS --public-origin https://$DOMAIN")$([ -z "$APP_NAME" ] || echo " --name \"$APP_NAME\"")
|
|
339
346
|
Restart=on-failure
|
|
340
347
|
RestartSec=3
|
|
341
348
|
|
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.10",
|
|
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.10",
|
|
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.10",
|
|
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
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* GET /api/conversations/<name>/guide what its tool is told about where it is: the exact texts, the owner's lines
|
|
23
23
|
* POST /api/conversations/<name>/guide {lines}: the owner's own lines for it (short, plain text)
|
|
24
24
|
* POST /api/conversations/<name>/guide/tell its guide written again, and one line typed in its terminal to read it
|
|
25
|
+
* POST /api/conversations/<name>/group {group}: its folder in the list ('' none); first in it
|
|
26
|
+
* POST /api/conversations/<name>/move {to: up|down}: one place among those of its folder
|
|
27
|
+
* POST /api/groups/rename {from, to}: for all its conversations
|
|
28
|
+
* POST /api/groups/move {group, to: up|down}
|
|
29
|
+
* POST /api/order {order?, groups?}: the whole order, as a drag and drop leaves it
|
|
30
|
+
* GET /api/instance {name, defaultName, given}: the name of this Clavarium
|
|
31
|
+
* POST /api/instance {name}: '' for the one drawn from its address
|
|
25
32
|
* GET /api/resources
|
|
26
33
|
* POST /api/resources {name} | {name, type: 'git', from?, via?: {method, key?, user?, secret?}}
|
|
27
34
|
* GET /api/git-keys SSH keys made here: the public half only
|
|
@@ -37,6 +44,8 @@
|
|
|
37
44
|
* POST /api/updates/apply { version }: update to the version that is out (in the background)
|
|
38
45
|
* POST /api/updates/back { version }: go back to a release that is kept (in the background)
|
|
39
46
|
* GET /api/git/<name>/outgoing?target=<branch>&branch=<branch> what would go from the agent's branch, to that branch of the remote
|
|
47
|
+
* GET /api/git/<name>/graph the branch diagram: branches and their last commits
|
|
48
|
+
* GET /api/git/<name>/commit?id=<sha> one commit, as a review shows it (&base=<sha>: the commits from base to it)
|
|
40
49
|
* POST /api/git/<name>/push {head, target?, sure?}: exactly that reviewed commit, to exactly that branch
|
|
41
50
|
* GET /api/git/<name>/keep origin kept up to date, checks, automatic sending, log
|
|
42
51
|
* POST /api/git/<name>/keep {refreshMinutes?, alert?, checkCommand?, checkSeconds?, runHook?, requireForSend?, fsck?}
|
|
@@ -102,7 +111,7 @@ function readBody(request, max = BODY_MAX) {
|
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
/* Returns true when the request was an API request (answered here). */
|
|
105
|
-
export async function handleApi(request, response, conversations, headers, resources, originOk = sameOrigin, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates) {
|
|
114
|
+
export async function handleApi(request, response, conversations, headers, resources, originOk = sameOrigin, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates, instance) {
|
|
106
115
|
const { pathname } = new URL(request.url ?? '/', 'http://local');
|
|
107
116
|
if (!pathname.startsWith('/api/')) return false;
|
|
108
117
|
const send = (status, body) => {
|
|
@@ -192,6 +201,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
192
201
|
if (pathname === '/api/git-keys' && gitAccess) return send(200, gitAccess.keys()), true;
|
|
193
202
|
if (pathname === '/api/git-hosts' && gitAccess) return send(200, gitAccess.hosts()), true;
|
|
194
203
|
if (pathname === '/api/platform' && platform) return send(200, platform), true;
|
|
204
|
+
if (pathname === '/api/instance' && instance) return send(200, instance.about()), true;
|
|
195
205
|
if (pathname === '/api/shelf' && shelf) return send(200, shelf.about()), true;
|
|
196
206
|
/* Settings, Updates (updates.mjs): what runs, what is out, the mode, what happened. */
|
|
197
207
|
if (pathname === '/api/updates' && updates) return send(200, updates.about()), true;
|
|
@@ -215,6 +225,8 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
215
225
|
if (answered && answerer) return send(200, answerer.log(decodeURIComponent(answered[1]))), true;
|
|
216
226
|
/* A repository: where its commits go, and what would go now. */
|
|
217
227
|
const repo = /^\/api\/git\/([a-z0-9-]{1,32})(\/outgoing|\/keep)?$/.exec(pathname);
|
|
228
|
+
const drawn = /^\/api\/git\/([a-z0-9-]{1,32})\/(graph|commit)$/.exec(pathname);
|
|
229
|
+
if (drawn && gitReview) return send(200, drawn[2] === 'graph' ? await gitReview.graph(drawn[1]) : await gitReview.commit(drawn[1], query.get('id'), query.get('base'))), true;
|
|
218
230
|
if (repo && repo[2] === '/keep' && gitKeeper) return send(200, gitKeeper.about(repo[1])), true;
|
|
219
231
|
if (repo && gitReview) return send(200, repo[2] ? await gitReview.outgoing(repo[1], { target: query.get('target') ?? undefined, branch: query.get('branch') ?? undefined }) : gitReview.settings(repo[1])), true;
|
|
220
232
|
const watched = /^\/api\/conversations\/([^/]+)\/signin$/.exec(pathname);
|
|
@@ -289,7 +301,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
289
301
|
if (dropped && secrets) { secrets.remove(dropped[1]); return send(200, { ok: true }), true; }
|
|
290
302
|
if (pathname === '/api/alerts' && alerts) return send(201, alerts.add(body.subscription, body.label)), true;
|
|
291
303
|
if (pathname === '/api/alerts/remove' && alerts) return send(200, alerts.remove(String(body.endpoint ?? ''))), true;
|
|
292
|
-
if (pathname === '/api/alerts/test' && alerts) return send(200, { reached: await alerts.tell({ title: 'Clavarium', body: 'This is what an alert looks like.', url: '/' }) }), true;
|
|
304
|
+
if (pathname === '/api/alerts/test' && alerts) return send(200, { reached: await alerts.tell({ title: instance?.name() ?? 'Clavarium', body: 'This is what an alert looks like.', url: '/' }) }), true;
|
|
293
305
|
if (pathname === '/api/talk/settings' && talk) return send(200, talk.setWords({ lang: body.lang, own: body.own })), true;
|
|
294
306
|
if (pathname === '/api/talk/thread' && talk) return send(200, talk.setThread({ levels: body.levels })), true;
|
|
295
307
|
const guiding = /^\/api\/conversations\/([^/]+)\/guide(\/tell)?$/.exec(pathname);
|
|
@@ -370,7 +382,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
370
382
|
if (kept && gitKeeper) {
|
|
371
383
|
if (kept[2] === 'keep') return send(200, gitKeeper.set(kept[1], body)), true;
|
|
372
384
|
if (kept[2] === 'refresh') return send(200, await gitKeeper.refresh(kept[1])), true;
|
|
373
|
-
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;
|
|
374
386
|
}
|
|
375
387
|
/* Automatic sending belongs to the attachment: set in the conversation's panel. */
|
|
376
388
|
const sending = /^\/api\/conversations\/([^/]+)\/git\/([a-z0-9-]{1,32})\/auto$/.exec(pathname);
|
|
@@ -424,6 +436,13 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
424
436
|
if (asking) return send(200, conversations.setAskFirst(decodeURIComponent(asking[1]), body.on)), true;
|
|
425
437
|
const naming = /^\/api\/conversations\/([^/]+)\/chat-name$/.exec(pathname);
|
|
426
438
|
if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
|
|
439
|
+
/* The list: folders and order, the same on every device (conversations.mjs). */
|
|
440
|
+
if (pathname === '/api/instance' && instance) return send(200, instance.setName(body.name)), true;
|
|
441
|
+
if (pathname === '/api/order') return send(200, conversations.setOrder({ order: body.order, groups: body.groups })), true;
|
|
442
|
+
if (pathname === '/api/groups/rename') return send(200, conversations.renameGroup(body.from, body.to)), true;
|
|
443
|
+
if (pathname === '/api/groups/move') return send(200, conversations.moveGroup(body.group, body.to)), true;
|
|
444
|
+
const grouping = /^\/api\/conversations\/([^/]+)\/(group|move)$/.exec(pathname);
|
|
445
|
+
if (grouping) return send(200, grouping[2] === 'group' ? conversations.setGroup(decodeURIComponent(grouping[1]), body.group) : conversations.move(decodeURIComponent(grouping[1]), body.to)), true;
|
|
427
446
|
const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
|
|
428
447
|
if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
|
|
429
448
|
const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
|
package/server/conversations.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - Sleep is a button, never a guess: telling a long job from an idle tool
|
|
7
7
|
* cannot be done without reading the tool, and this server does not read.
|
|
8
8
|
*/
|
|
9
|
-
import { isConversationName, identifierFrom, titleProblem } from './names.mjs';
|
|
9
|
+
import { isConversationName, identifierFrom, titleProblem, groupProblem } from './names.mjs';
|
|
10
|
+
import { JsonFile } from './store.mjs';
|
|
10
11
|
import { attachCommand, SOCKET } from './tmux.mjs';
|
|
11
12
|
import { WORK, VM_USER, SHARING_STEP } from './vm.mjs';
|
|
12
13
|
import { resumeOrFresh } from './tools.mjs';
|
|
@@ -37,6 +38,8 @@ export const STARTING_MS = 180_000;
|
|
|
37
38
|
export const ANSWER_MS = 90_000;
|
|
38
39
|
/* What the folders of a machine just started are waited for, at most, before its tool starts. */
|
|
39
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;
|
|
40
43
|
const POLL_MS = 2000;
|
|
41
44
|
/* The tool's screen not drawn this long after its terminal was opened: its
|
|
42
45
|
start is said to be over all the same (the page then opens it itself). */
|
|
@@ -60,7 +63,7 @@ const buildProgress = (since) => ({ kind: 'build', steps: ['starting', 'preparin
|
|
|
60
63
|
const firstLine = (text) => String(text ?? '').trim().split('\n').filter(Boolean).pop()?.slice(0, 200) ?? '';
|
|
61
64
|
|
|
62
65
|
export class Conversations {
|
|
63
|
-
#store; #vms; #tools; #stopDelayMs; #timers = new Map(); #watching = new Map(); #fresh = new Set(); #mountAgain = new Set(); #uid; #gid;
|
|
66
|
+
#layout; #store; #vms; #tools; #stopDelayMs; #timers = new Map(); #watching = new Map(); #fresh = new Set(); #mountAgain = new Set(); #uid; #gid;
|
|
64
67
|
/* Set by the server: prepares the VM before the tool is attached, and
|
|
65
68
|
names the program the tool is started through. See secrets.mjs. */
|
|
66
69
|
prepare = async () => {};
|
|
@@ -78,6 +81,13 @@ export class Conversations {
|
|
|
78
81
|
when it was just built, restarted or its work moved; otherwise only
|
|
79
82
|
looked at, and put back if they came loose (health.mjs). */
|
|
80
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();
|
|
81
91
|
/* Set by the server: told when its machine was restarted or built again
|
|
82
92
|
by this server, in words for its chat, and whether the tool runs again
|
|
83
93
|
(its chat says it, and the tool is told to read it again: talk.mjs). */
|
|
@@ -90,8 +100,8 @@ export class Conversations {
|
|
|
90
100
|
/* `uid`/`gid`: the account this server runs as; files the tool writes in
|
|
91
101
|
its kept work folder belong to it on the host. */
|
|
92
102
|
#startingMs; #answerMs; #pollMs;
|
|
93
|
-
constructor({ store, vms, tools, stopDelayMs = STOP_DELAY_MS, uid = process.getuid(), gid = process.getgid(), startingMs = STARTING_MS, answerMs = ANSWER_MS, pollMs = POLL_MS }) {
|
|
94
|
-
this.#store = store; this.#vms = vms; this.#tools = tools; this.#stopDelayMs = stopDelayMs; this.#uid = uid; this.#gid = gid;
|
|
103
|
+
constructor({ store, vms, tools, layout = new JsonFile(undefined, {}), stopDelayMs = STOP_DELAY_MS, uid = process.getuid(), gid = process.getgid(), startingMs = STARTING_MS, answerMs = ANSWER_MS, pollMs = POLL_MS }) {
|
|
104
|
+
this.#layout = layout; this.#store = store; this.#vms = vms; this.#tools = tools; this.#stopDelayMs = stopDelayMs; this.#uid = uid; this.#gid = gid;
|
|
95
105
|
this.#startingMs = startingMs; this.#answerMs = answerMs; this.#pollMs = pollMs;
|
|
96
106
|
/* A build interrupted by a restart of this server cannot be resumed
|
|
97
107
|
blindly: it is marked failed, and says so. */
|
|
@@ -177,11 +187,30 @@ export class Conversations {
|
|
|
177
187
|
/* The sign-in rules of a conversation's tool, or undefined. */
|
|
178
188
|
signinRules(name) { return this.#tools.find((t) => t.id === this.#store.get(name)?.tool)?.signin; }
|
|
179
189
|
|
|
190
|
+
/* THE ORDER OF THE LIST, the same on every device: the conversations
|
|
191
|
+
* (one order; a folder shows its own in it) and the folders. Kept in a
|
|
192
|
+
* file of its own; what is not in it (made before, or by an older
|
|
193
|
+
* version) comes after, in the order it was made. A folder is only a
|
|
194
|
+
* word on its conversations: with none left, it is gone. */
|
|
195
|
+
#order() {
|
|
196
|
+
const all = this.#store.all();
|
|
197
|
+
const names = new Set(all.map((c) => c.name));
|
|
198
|
+
const kept = this.#layout.get();
|
|
199
|
+
const order = [...new Set((Array.isArray(kept.order) ? kept.order : []).filter((n) => names.has(n)))];
|
|
200
|
+
for (const c of all) if (!order.includes(c.name)) order.push(c.name);
|
|
201
|
+
const present = [...new Set(all.map((c) => c.group).filter(Boolean))];
|
|
202
|
+
const groups = [...new Set((Array.isArray(kept.groups) ? kept.groups : []).filter((g) => present.includes(g)))];
|
|
203
|
+
for (const g of present) if (!groups.includes(g)) groups.push(g);
|
|
204
|
+
return { order, groups };
|
|
205
|
+
}
|
|
206
|
+
#keepOrder({ order, groups }) { this.#layout.set({ order, groups }); }
|
|
207
|
+
|
|
180
208
|
list() {
|
|
181
|
-
|
|
209
|
+
const { order, groups } = this.#order();
|
|
210
|
+
return this.#store.all().sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)).map((c) => {
|
|
182
211
|
const tool = this.#tools.find((t) => t.id === c.tool);
|
|
183
212
|
/* An AI tool has an instruction file; a shell has none: what is dictated there is never marked. */
|
|
184
|
-
return { ...c, toolLabel: tool?.label ?? c.tool, memoryMinMb: tool?.memoryMb, resumes: Boolean(tool?.resume), rights: Boolean(tool?.rights), ai: Boolean(tool?.instructions), voiceMark: Boolean(tool?.instructions) && c.voiceMark !== false };
|
|
213
|
+
return { ...c, rank: order.indexOf(c.name), ...(c.group ? { groupRank: groups.indexOf(c.group) } : {}), toolLabel: tool?.label ?? c.tool, memoryMinMb: tool?.memoryMb, resumes: Boolean(tool?.resume), rights: Boolean(tool?.rights), ai: Boolean(tool?.instructions), voiceMark: Boolean(tool?.instructions) && c.voiceMark !== false };
|
|
185
214
|
});
|
|
186
215
|
}
|
|
187
216
|
|
|
@@ -211,8 +240,9 @@ export class Conversations {
|
|
|
211
240
|
return { name: id, title: title !== undefined && title !== id ? title : undefined };
|
|
212
241
|
}
|
|
213
242
|
|
|
214
|
-
create({ name: given, title: read, tool, memoryMb, workOnDisk }) {
|
|
243
|
+
create({ name: given, title: read, tool, memoryMb, workOnDisk, group }) {
|
|
215
244
|
const { name, title } = this.#naming({ name: given, title: read });
|
|
245
|
+
if (group !== undefined) { const problem = groupProblem(group); if (problem) throw new Refusal(400, problem); }
|
|
216
246
|
if (!isConversationName(name)) throw new Refusal(400, 'A name is 1 to 32 lower-case letters, digits or hyphens; it starts with a letter and does not end with a hyphen.');
|
|
217
247
|
const chosen = this.#tools.find((t) => t.id === tool);
|
|
218
248
|
if (!chosen) throw new Refusal(400, 'Unknown tool.');
|
|
@@ -222,8 +252,11 @@ export class Conversations {
|
|
|
222
252
|
}
|
|
223
253
|
if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
|
|
224
254
|
const now = new Date().toISOString();
|
|
225
|
-
const conversation = { name, ...(title ? { title } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
255
|
+
const conversation = { name, ...(title ? { title } : {}), ...(group ? { group } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
256
|
+
/* A new one comes first in its folder (or first of those in none). */
|
|
257
|
+
const { order, groups } = this.#order();
|
|
226
258
|
this.#store.put(conversation);
|
|
259
|
+
this.#keepOrder({ order: [name, ...order], groups: group && !groups.includes(group) ? [...groups, group] : groups });
|
|
227
260
|
this.built = this.#build(name, chosen, memory);
|
|
228
261
|
return conversation;
|
|
229
262
|
}
|
|
@@ -402,7 +435,10 @@ export class Conversations {
|
|
|
402
435
|
const again = this.#mountAgain.delete(name);
|
|
403
436
|
if (again || stopped) {
|
|
404
437
|
this.#reach(name, 'folders'); tell();
|
|
405
|
-
|
|
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?.(); })]);
|
|
406
442
|
}
|
|
407
443
|
/* Woken from sleep: its tool is still there when its session is. */
|
|
408
444
|
if (asleep) {
|
|
@@ -483,6 +519,78 @@ export class Conversations {
|
|
|
483
519
|
return this.#store.get(name);
|
|
484
520
|
}
|
|
485
521
|
|
|
522
|
+
/* ITS FOLDER in the list ('' for none): first among those of that folder. */
|
|
523
|
+
setGroup(name, group) {
|
|
524
|
+
const c = this.#store.get(name);
|
|
525
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
526
|
+
const problem = groupProblem(group);
|
|
527
|
+
if (problem) throw new Refusal(400, problem);
|
|
528
|
+
if ((c.group ?? '') === group) return c;
|
|
529
|
+
const { order, groups } = this.#order();
|
|
530
|
+
this.#store.patch(name, { group: group || undefined });
|
|
531
|
+
this.#keepOrder({ order: [name, ...order.filter((n) => n !== name)], groups: group && !groups.includes(group) ? [...groups, group] : groups });
|
|
532
|
+
return this.#store.get(name);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* A folder renamed, for all its conversations; to the name of another, they join it. */
|
|
536
|
+
renameGroup(from, to) {
|
|
537
|
+
const problem = groupProblem(to);
|
|
538
|
+
if (problem) throw new Refusal(400, problem);
|
|
539
|
+
if (!to) throw new Refusal(400, 'Give the new name of the folder.');
|
|
540
|
+
const inside = this.#store.all().filter((c) => typeof from === 'string' && from && c.group === from);
|
|
541
|
+
if (inside.length === 0) throw new Refusal(404, 'No such folder.');
|
|
542
|
+
const { order, groups } = this.#order();
|
|
543
|
+
for (const c of inside) this.#store.patch(c.name, { group: to });
|
|
544
|
+
const renamed = groups.includes(to) ? groups.filter((g) => g !== from) : groups.map((g) => (g === from ? to : g));
|
|
545
|
+
this.#keepOrder({ order, groups: renamed });
|
|
546
|
+
return { renamed: inside.length, group: to };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* One place up or down among the others of its folder (`to`: 'up' or 'down').
|
|
550
|
+
A hidden one is passed over by one that is shown: it is not in sight. */
|
|
551
|
+
move(name, to) {
|
|
552
|
+
const c = this.#store.get(name);
|
|
553
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
554
|
+
if (to !== 'up' && to !== 'down') throw new Refusal(400, 'Say up or down.');
|
|
555
|
+
const { order, groups } = this.#order();
|
|
556
|
+
const byName = new Map(this.#store.all().map((x) => [x.name, x]));
|
|
557
|
+
const peers = order.filter((n) => (byName.get(n).group ?? '') === (c.group ?? '') && (c.hidden === true || byName.get(n).hidden !== true || n === name));
|
|
558
|
+
const at = peers.indexOf(name);
|
|
559
|
+
const other = peers[to === 'up' ? at - 1 : at + 1];
|
|
560
|
+
if (other === undefined) return { order: this.list().map((x) => x.name) };
|
|
561
|
+
const i = order.indexOf(name); const j = order.indexOf(other);
|
|
562
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
563
|
+
this.#keepOrder({ order, groups });
|
|
564
|
+
return { order };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* A folder one place up or down among the folders. */
|
|
568
|
+
moveGroup(group, to) {
|
|
569
|
+
if (to !== 'up' && to !== 'down') throw new Refusal(400, 'Say up or down.');
|
|
570
|
+
const { order, groups } = this.#order();
|
|
571
|
+
const at = groups.indexOf(group);
|
|
572
|
+
if (typeof group !== 'string' || at < 0) throw new Refusal(404, 'No such folder.');
|
|
573
|
+
const j = to === 'up' ? at - 1 : at + 1;
|
|
574
|
+
if (j >= 0 && j < groups.length) [groups[at], groups[j]] = [groups[j], groups[at]];
|
|
575
|
+
this.#keepOrder({ order, groups });
|
|
576
|
+
return { groups };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/* A whole order, as a drag and drop leaves it: names it does not know are
|
|
580
|
+
left out, and those it forgets keep their places after the others. */
|
|
581
|
+
setOrder({ order: wanted, groups: wantedGroups } = {}) {
|
|
582
|
+
const { order, groups } = this.#order();
|
|
583
|
+
const pick = (list, known) => {
|
|
584
|
+
if (list === undefined) return known;
|
|
585
|
+
if (!Array.isArray(list) || list.length > 10_000 || list.some((x) => typeof x !== 'string')) throw new Refusal(400, 'Give a list of names.');
|
|
586
|
+
const chosen = [...new Set(list.filter((x) => known.includes(x)))];
|
|
587
|
+
return [...chosen, ...known.filter((x) => !chosen.includes(x))];
|
|
588
|
+
};
|
|
589
|
+
const next = { order: pick(wanted, order), groups: pick(wantedGroups, groups) };
|
|
590
|
+
this.#keepOrder(next);
|
|
591
|
+
return next;
|
|
592
|
+
}
|
|
593
|
+
|
|
486
594
|
/* What is dictated into its terminal starts with a microphone sign, once
|
|
487
595
|
per message (true, the default for a tool that is an AI), or not. */
|
|
488
596
|
setVoiceMark(name, on) {
|
|
@@ -670,6 +778,7 @@ export class Conversations {
|
|
|
670
778
|
async #cycle(name, c, between, restart) {
|
|
671
779
|
const step = (text) => { if (this.#store.get(name)) this.#store.patch(name, { step: text }); };
|
|
672
780
|
try {
|
|
781
|
+
await this.#calm(name, step);
|
|
673
782
|
this.drop(name);
|
|
674
783
|
let state = await this.#vms.state(name);
|
|
675
784
|
if (state !== 'Stopped') {
|
|
@@ -682,14 +791,15 @@ export class Conversations {
|
|
|
682
791
|
await this.#watched(name, 'stop', this.#vms.kill(name));
|
|
683
792
|
state = await this.#vms.state(name);
|
|
684
793
|
}
|
|
685
|
-
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'}).`;
|
|
686
795
|
}
|
|
687
796
|
const failed = await between();
|
|
688
797
|
if (!restart) return failed;
|
|
689
798
|
step('Starting the machine');
|
|
690
799
|
this.#reach(name, 'starting');
|
|
691
800
|
const started = await this.#watched(name, 'start', this.#vms.start(name));
|
|
692
|
-
|
|
801
|
+
/* The VM manager may fail its start for its own mounts while the machine runs: those are mounted again here. */
|
|
802
|
+
if (started.code !== 0 && (await this.#vms.state(name).catch(() => undefined)) !== 'Running') return `${failed ? `${failed} And ` : ''}the machine did not start (${firstLine(started.stderr) || firstLine(started.stdout) || `exit ${started.code}`}).`;
|
|
693
803
|
/* Whatever was put in its memory is gone: the next opening prepares it again, its folders first. */
|
|
694
804
|
this.#fresh.add(name);
|
|
695
805
|
this.#mountAgain.add(name);
|
|
@@ -698,6 +808,18 @@ export class Conversations {
|
|
|
698
808
|
} catch { return 'it stopped on an unexpected error.'; }
|
|
699
809
|
}
|
|
700
810
|
|
|
811
|
+
/* Before its machine is stopped: its folders being mounted again are
|
|
812
|
+
left to finish, within a bound (a new look does not start meanwhile:
|
|
813
|
+
health.mjs leaves a machine that is not `ready` alone). */
|
|
814
|
+
async #calm(name, step) {
|
|
815
|
+
const pending = [this.#mounting.get(name), this.quiet(name)].filter(Boolean);
|
|
816
|
+
if (pending.length === 0) return;
|
|
817
|
+
step('Waiting for its folders to be mounted');
|
|
818
|
+
let timer;
|
|
819
|
+
await Promise.race([Promise.allSettled(pending), new Promise((r) => { timer = setTimeout(r, CALM_MS); timer.unref?.(); })]);
|
|
820
|
+
clearTimeout(timer);
|
|
821
|
+
}
|
|
822
|
+
|
|
701
823
|
/* `chat`: the same, as its chat says it. */
|
|
702
824
|
async #settle(name, said, chat, failed = false) {
|
|
703
825
|
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;
|
|
@@ -198,9 +201,14 @@ const TODO = Object.freeze({
|
|
|
198
201
|
/* Reasons whose sending stays stuck until somebody looks. */
|
|
199
202
|
const GRAVE_WHY = new Set(['refused-forced', 'refused-unknown-unreachable', 'refused-workspace']);
|
|
200
203
|
const commitsOf = (n) => `${n} commit${n === 1 ? '' : 's'}`;
|
|
204
|
+
/* A commit as a line of the chat names it: its first seven characters (the page makes them a link to it). */
|
|
205
|
+
const short = (id) => (typeof id === 'string' && /^[0-9a-f]{40}$/.test(id) ? id.slice(0, 7) : '');
|
|
206
|
+
const upTo = (id) => (short(id) ? ` (up to ${short(id)})` : '');
|
|
207
|
+
/* "See the N commits": from the tip before to the tip now, as the review shows them; none for a new branch. */
|
|
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)}`] : []);
|
|
201
209
|
|
|
202
210
|
export class GitKeeper {
|
|
203
|
-
#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();
|
|
204
212
|
|
|
205
213
|
/* `review`: GitReview (reads, the reference, Send). `checks`: GitChecks.
|
|
206
214
|
`alerts`: what tells the owner's phone. `tellAgent(repository, line,
|
|
@@ -208,8 +216,11 @@ export class GitKeeper {
|
|
|
208
216
|
repository (only to that conversation's, when one is named); it
|
|
209
217
|
resolves false when nobody could be told (then it is tried again).
|
|
210
218
|
`thread(conversation, event)`: one line in that conversation's chat
|
|
211
|
-
(Talk.event: kept only where its chat is on).
|
|
212
|
-
|
|
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;
|
|
213
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;
|
|
214
225
|
review.listen({ read: (name, failure, got) => this.#readOutcome(name, failure, got), refresh: (name, result) => this.#refreshOutcome(name, result) });
|
|
215
226
|
review.useWaivers?.((name) => this.settings(name).fsck);
|
|
@@ -252,6 +263,7 @@ export class GitKeeper {
|
|
|
252
263
|
auto,
|
|
253
264
|
origin: hasRemote ? { path: `/resources/${folder}`, kept: this.#resources.hasOrigin(r.name), ...(record.mirror ?? {}) } : null,
|
|
254
265
|
checks: this.#results.get(r.name) ?? null,
|
|
266
|
+
last: this.lastChecks(r.name),
|
|
255
267
|
read: record.lastRead ?? null,
|
|
256
268
|
blocked: Object.keys(record.blocked ?? {}),
|
|
257
269
|
log: record.log ?? [],
|
|
@@ -356,7 +368,7 @@ export class GitKeeper {
|
|
|
356
368
|
/* A conversation archived: all its settings, whatever the repository. */
|
|
357
369
|
forgetConversation(conversation) { for (const record of this.#store.all()) this.forgetAttachment(record.name, conversation); }
|
|
358
370
|
|
|
359
|
-
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); }
|
|
360
372
|
|
|
361
373
|
/* ---------- what every read and every refresh says ---------- */
|
|
362
374
|
|
|
@@ -366,7 +378,7 @@ export class GitKeeper {
|
|
|
366
378
|
#say(name, conversations, event) {
|
|
367
379
|
let attached;
|
|
368
380
|
try { attached = Object.keys(this.#resources.list().find((x) => x.name === name)?.attachments ?? {}); } catch { return; }
|
|
369
|
-
for (const c of conversations) if (attached.includes(c)) { try { this.#thread(c, { ...event, actions: [`review:${name}`] }); } catch { /* a chat never stops git */ } }
|
|
381
|
+
for (const c of conversations) if (attached.includes(c)) { try { this.#thread(c, { ...event, actions: [...(event.actions ?? []), `review:${name}`] }); } catch { /* a chat never stops git */ } }
|
|
370
382
|
}
|
|
371
383
|
#attached(name) { try { return Object.entries(this.#resources.list().find((x) => x.name === name)?.attachments ?? {}).filter(([, access]) => access && access !== 'none').map(([c]) => c).sort(); } catch { return []; } }
|
|
372
384
|
/* Marked as told only once somebody was: a line typed to no machine
|
|
@@ -427,8 +439,8 @@ export class GitKeeper {
|
|
|
427
439
|
const brought = result.ok ? (result.brought ?? []) : [];
|
|
428
440
|
if (brought.length) {
|
|
429
441
|
const total = brought.reduce((n, x) => n + x.commits, 0);
|
|
430
|
-
const where = brought.slice(0, 4).map((x) => `${x.commits} on ${x.branch}`).join(', ') + (brought.length > 4 ? `, and ${brought.length - 4} more branch(es)` : '');
|
|
431
|
-
this.#say(name, this.#attached(name), { kind: 'origin', text: `Origin of ${name} updated: ${total} new commit${total === 1 ? '' : 's'} (${where}).`, topic: 'Origin updated', brief: name });
|
|
442
|
+
const where = brought.slice(0, 4).map((x) => `${x.commits} on ${x.branch}${upTo(x.head)}`).join(', ') + (brought.length > 4 ? `, and ${brought.length - 4} more branch(es)` : '');
|
|
443
|
+
this.#say(name, this.#attached(name), { kind: 'origin', text: `Origin of ${name} updated: ${total} new commit${total === 1 ? '' : 's'} (${where}).`, topic: 'Origin updated', brief: name, actions: brought.length === 1 ? seeCommits(name, brought[0].head, brought[0].base, brought[0].commits) : [] });
|
|
432
444
|
}
|
|
433
445
|
if (result.ok && Object.keys(record.blocked ?? {}).length > 0) void this.#settle(name).catch(() => {});
|
|
434
446
|
}
|
|
@@ -474,9 +486,39 @@ export class GitKeeper {
|
|
|
474
486
|
const s = this.settings(name);
|
|
475
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 } : {}) });
|
|
476
488
|
this.#results.set(name, result);
|
|
489
|
+
this.#keepLast(name, result);
|
|
477
490
|
return result;
|
|
478
491
|
}
|
|
479
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
|
+
|
|
480
522
|
/* Send, from the page: as GitReview.push, and refused when the owner
|
|
481
523
|
requires passing checks and none passed for this commit and branch. */
|
|
482
524
|
async send(name, body) {
|
|
@@ -490,7 +532,7 @@ export class GitKeeper {
|
|
|
490
532
|
}
|
|
491
533
|
try {
|
|
492
534
|
const sent = await this.#review.push(r.name, body);
|
|
493
|
-
if (!sent.already) this.#say(r.name, this.#attached(r.name), { kind: 'sent-by-hand', text: `Sent by hand to ${sent.branch} of ${r.name}: ${commitsOf(sent.commits)}${sent.made ? ' (a new branch)' : ''}.`, topic: 'Sent', brief: sent.branch });
|
|
535
|
+
if (!sent.already) this.#say(r.name, this.#attached(r.name), { kind: 'sent-by-hand', text: `Sent by hand to ${sent.branch} of ${r.name}: ${commitsOf(sent.commits)}${upTo(sent.head)}${sent.made ? ' (a new branch)' : ''}.`, topic: 'Sent', brief: sent.branch, actions: seeCommits(r.name, sent.head, sent.base, sent.commits) });
|
|
494
536
|
return sent;
|
|
495
537
|
} catch (error) {
|
|
496
538
|
if (error instanceof GitFailure) this.#sendFailed(r.name, error, { branch: body?.target, head: body?.head });
|
|
@@ -604,7 +646,7 @@ export class GitKeeper {
|
|
|
604
646
|
if (sent.already) { this.#log(name, { branch: b, head: tip.id, conversation, ok: true, what: `Already on the remote: ${b}.` }, { sent: true, by: conversation }); return true; }
|
|
605
647
|
this.#log(name, { branch: b, head: tip.id, conversation, ok: true, what: `Sent automatically for ${conversation}: ${sent.commits} commit(s) to ${sent.branch}${sent.made ? ' (a new branch)' : ''}, ${checked}.${sent.note ? ` ${sent.note}` : ''}` }, { sent: true, by: conversation });
|
|
606
648
|
if (alert) this.#tell(`${name}: sent to ${sent.branch}`, `${sent.commits} commit(s), automatically for ${conversation}, ${checked}.`);
|
|
607
|
-
this.#say(name, [conversation], { kind: 'sent', text: `Sent to ${sent.branch} of ${name}: ${commitsOf(sent.commits)}${sent.made ? ' (a new branch)' : ''}, ${checked}.`, topic: 'Sent', brief: sent.branch });
|
|
649
|
+
this.#say(name, [conversation], { kind: 'sent', text: `Sent to ${sent.branch} of ${name}: ${commitsOf(sent.commits)}${upTo(sent.head)}${sent.made ? ' (a new branch)' : ''}, ${checked}.`, topic: 'Sent', brief: sent.branch, actions: seeCommits(name, sent.head, sent.base, sent.commits) });
|
|
608
650
|
return true;
|
|
609
651
|
} catch (error) {
|
|
610
652
|
const kind = error instanceof GitFailure ? error.kind : 'error';
|