clavarium 0.1.5 → 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 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
 
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.5",
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.1.5",
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.9.0",
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.9.0",
690
- "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.9.0.tgz",
691
- "integrity": "sha512-9JjM2GVlAq/a3n3G47QG/MSze8RjwiDH4aMqGQhJ92T4W/uDlRjCJiRwVyuPWx7PVAId/+r8sHNIPDAz7K0tzQ==",
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.1.5",
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.9.0",
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,17 @@ 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;
446
+ const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
447
+ if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
448
+ const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
449
+ if (hiding) return send(200, conversations.setHidden(decodeURIComponent(hiding[1]), body.on)), true;
427
450
  const marking = /^\/api\/conversations\/([^/]+)\/voice-mark$/.exec(pathname);
428
451
  if (marking) return send(200, conversations.setVoiceMark(decodeURIComponent(marking[1]), body.on)), true;
429
452
  const resuming = /^\/api\/conversations\/([^/]+)\/resume$/.exec(pathname);
@@ -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 } 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
- return this.#store.all().map((c) => {
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
 
@@ -189,7 +218,8 @@ export class Conversations {
189
218
 
190
219
  /* Whether a new machine may be made for this request: what `create`
191
220
  checks, and the server's room (capacity.mjs). Asked before `create`. */
192
- async admit({ name, tool, memoryMb } = {}) {
221
+ async admit({ name, title, tool, memoryMb } = {}) {
222
+ if (!name && typeof title === 'string' && !titleProblem(title)) ({ name } = this.#naming({ title }));
193
223
  const chosen = this.#tools.find((t) => t.id === tool);
194
224
  /* What `create` refuses anyway is said by `create`. */
195
225
  if (!chosen || !isConversationName(name) || this.#store.get(name)) return;
@@ -201,7 +231,18 @@ export class Conversations {
201
231
  /* `workOnDisk`: the tool works on its machine's own disk instead of in a
202
232
  folder kept by the server. Faster for a large code project; but what
203
233
  it makes is then not seen from the page, and goes with the machine. */
204
- create({ name, tool, memoryMb, workOnDisk }) {
234
+ /* A conversation is made from the name a person reads (`title`), its
235
+ identifier drawn from it, or from an identifier alone (the advanced
236
+ options, a blueprint). The identifier never changes afterwards. */
237
+ #naming({ name, title }) {
238
+ if (title !== undefined) { const problem = titleProblem(title); if (problem) throw new Refusal(400, problem); }
239
+ const id = name || (title !== undefined ? identifierFrom(title, (x) => Boolean(this.#store.get(x))) : name);
240
+ return { name: id, title: title !== undefined && title !== id ? title : undefined };
241
+ }
242
+
243
+ create({ name: given, title: read, tool, memoryMb, workOnDisk, group }) {
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); }
205
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.');
206
247
  const chosen = this.#tools.find((t) => t.id === tool);
207
248
  if (!chosen) throw new Refusal(400, 'Unknown tool.');
@@ -211,8 +252,11 @@ export class Conversations {
211
252
  }
212
253
  if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
213
254
  const now = new Date().toISOString();
214
- const conversation = { name, 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();
215
258
  this.#store.put(conversation);
259
+ this.#keepOrder({ order: [name, ...order], groups: group && !groups.includes(group) ? [...groups, group] : groups });
216
260
  this.built = this.#build(name, chosen, memory);
217
261
  return conversation;
218
262
  }
@@ -391,7 +435,10 @@ export class Conversations {
391
435
  const again = this.#mountAgain.delete(name);
392
436
  if (again || stopped) {
393
437
  this.#reach(name, 'folders'); tell();
394
- 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?.(); })]);
395
442
  }
396
443
  /* Woken from sleep: its tool is still there when its session is. */
397
444
  if (asleep) {
@@ -450,6 +497,100 @@ export class Conversations {
450
497
  return this.#store.get(name);
451
498
  }
452
499
 
500
+ /* THE NAME A PERSON READS, changed at any time; its identifier stays.
501
+ Kept only when it differs from the identifier: a conversation made
502
+ before free names is read by its identifier, and nothing is rewritten. */
503
+ setTitle(name, title) {
504
+ const c = this.#store.get(name);
505
+ if (!c) throw new Refusal(404, 'No such conversation.');
506
+ const problem = titleProblem(title);
507
+ if (problem) throw new Refusal(400, problem);
508
+ this.#store.patch(name, { title: title === name ? undefined : title });
509
+ return this.#store.get(name);
510
+ }
511
+
512
+ /* Hidden from the list, like a hidden file: it runs, keeps its rights,
513
+ and is still in Access; the list says how many are hidden. */
514
+ setHidden(name, on) {
515
+ const c = this.#store.get(name);
516
+ if (!c) throw new Refusal(404, 'No such conversation.');
517
+ if (typeof on !== 'boolean') throw new Refusal(400, 'Say true (hidden from the list) or false (shown).');
518
+ this.#store.patch(name, { hidden: on ? true : undefined });
519
+ return this.#store.get(name);
520
+ }
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
+
453
594
  /* What is dictated into its terminal starts with a microphone sign, once
454
595
  per message (true, the default for a tool that is an AI), or not. */
455
596
  setVoiceMark(name, on) {
@@ -637,6 +778,7 @@ export class Conversations {
637
778
  async #cycle(name, c, between, restart) {
638
779
  const step = (text) => { if (this.#store.get(name)) this.#store.patch(name, { step: text }); };
639
780
  try {
781
+ await this.#calm(name, step);
640
782
  this.drop(name);
641
783
  let state = await this.#vms.state(name);
642
784
  if (state !== 'Stopped') {
@@ -649,14 +791,15 @@ export class Conversations {
649
791
  await this.#watched(name, 'stop', this.#vms.kill(name));
650
792
  state = await this.#vms.state(name);
651
793
  }
652
- 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'}).`;
653
795
  }
654
796
  const failed = await between();
655
797
  if (!restart) return failed;
656
798
  step('Starting the machine');
657
799
  this.#reach(name, 'starting');
658
800
  const started = await this.#watched(name, 'start', this.#vms.start(name));
659
- if (started.code !== 0) return `${failed ? `${failed} And ` : ''}the machine did not start (${firstLine(started.stderr) || firstLine(started.stdout) || `exit ${started.code}`}).`;
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}`}).`;
660
803
  /* Whatever was put in its memory is gone: the next opening prepares it again, its folders first. */
661
804
  this.#fresh.add(name);
662
805
  this.#mountAgain.add(name);
@@ -665,6 +808,18 @@ export class Conversations {
665
808
  } catch { return 'it stopped on an unexpected error.'; }
666
809
  }
667
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
+
668
823
  /* `chat`: the same, as its chat says it. */
669
824
  async #settle(name, said, chat, failed = false) {
670
825
  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.' };