clavarium 0.2.366-next.1 → 0.2.368-next.1
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 +2 -2
- package/package.json +1 -1
- package/server/api.mjs +10 -0
- package/server/health.mjs +41 -4
- package/server/linkguard.mjs +191 -0
- package/server/main.mjs +14 -1
- package/server/resources.mjs +6 -0
- package/server/rooms.mjs +4 -3
- package/server/settingswords.mjs +19 -6
- package/server/talk.mjs +3 -2
- package/web/app.js +237 -67
- package/web/index.html +41 -34
- package/web/kit.js +75 -3
- package/web/style.css +52 -29
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.368-next.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.368-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/package.json
CHANGED
package/server/api.mjs
CHANGED
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
* GET /api/rooms the rooms (#268): [{id, name, host, members: [{id, kind, name, ref, host?}], unread, last, thinking: [names]}]
|
|
95
95
|
* POST /api/rooms {name, conversations: [names], boxes: [ids], host?}: a new room
|
|
96
96
|
* POST /api/rooms/<id> {name?, host?, add?: {conversations, boxes}, remove?: [member ids]}
|
|
97
|
+
* quiet?: true/false (muted in the list: no dot, at the bottom)
|
|
97
98
|
* POST /api/rooms/<id>/remove the room and its thread
|
|
98
99
|
* POST /api/rooms/<id>/give {asset, rights, shared?}: the same right given to each AI of it, its own
|
|
99
100
|
* conversation's -> {asset, rights, given: [{conversation, ok, kept?, error?}]}
|
|
@@ -699,6 +700,8 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
699
700
|
if (access !== 'write') gitKeeper?.forgetAttachment(name, conversation);
|
|
700
701
|
await resources.attach(conversation, name, access);
|
|
701
702
|
retell(conversation);
|
|
703
|
+
/* Newly attached: its links looked at (and its mounts), not waited for. */
|
|
704
|
+
if (access !== 'none') void health?.check?.(conversation, { asked: true })?.catch?.(() => {});
|
|
702
705
|
};
|
|
703
706
|
const handSecret = async (conversation, name, attached) => {
|
|
704
707
|
if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
|
|
@@ -867,6 +870,13 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
867
870
|
if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
|
|
868
871
|
return send(200, health.markSeen(conversation)), true;
|
|
869
872
|
}
|
|
873
|
+
/* "Check shared folders": each mounted and readable (put back when loose), each link the share cannot serve (health.mjs). */
|
|
874
|
+
const foldersOf = /^\/api\/conversations\/([^/]+)\/folders\/check$/.exec(pathname);
|
|
875
|
+
if (foldersOf && health) {
|
|
876
|
+
const conversation = decodeURIComponent(foldersOf[1]);
|
|
877
|
+
if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
|
|
878
|
+
return send(200, await health.checkFolders(conversation)), true;
|
|
879
|
+
}
|
|
870
880
|
const machine = /^\/api\/conversations\/([^/]+)\/(restart|machine|health)$/.exec(pathname);
|
|
871
881
|
if (machine) {
|
|
872
882
|
const conversation = decodeURIComponent(machine[1]);
|
package/server/health.mjs
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
should be mounted; they are passed as arguments, never parsed by the
|
|
25
25
|
shell. It prints one fact per line; the server reads only these lines. */
|
|
26
26
|
import { SHARING_STEP } from './vm.mjs';
|
|
27
|
+
import { WHY } from './linkguard.mjs';
|
|
27
28
|
|
|
28
29
|
/* How long installing the shared-folder helper in a machine may take (it comes from the snap store). */
|
|
29
30
|
export const SHARING_MS = 180_000;
|
|
@@ -123,13 +124,16 @@ export function judge(record) {
|
|
|
123
124
|
for (const { path, why } of record.failed ?? []) add('bad', 'mount', `failed:${path}`, `${path} is not reachable in the machine and could not be mounted again${why ? `: ${why}` : ''}.`);
|
|
124
125
|
for (const path of record.missing ?? []) if (!(record.failed ?? []).some((f) => f.path === path)) add('warn', 'mount', `missing:${path}`, `${path} is not reachable in the machine.`);
|
|
125
126
|
}
|
|
127
|
+
/* Links its folders hold that the share cannot serve (linkguard.mjs): looked at on this server, whatever its machine does. */
|
|
128
|
+
for (const l of record.links?.removed ?? []) add('info', 'link', `link-removed:${l.folder}/${l.path}`, `/resources/${l.folder}/${l.path} was a broken link (${WHY[l.kind] ?? l.kind}): it was removed on the server, what it pointed to left as it was.`);
|
|
129
|
+
for (const l of record.links?.found ?? []) add('warn', 'link', `link:${l.folder}/${l.path}`, `/resources/${l.folder}/${l.path} is a link its machine cannot follow (${WHY[l.kind] ?? l.kind}): make it point inside the folder, or remove it.`);
|
|
126
130
|
const order = ['ok', 'info', 'warn', 'bad'];
|
|
127
131
|
const level = problems.reduce((worst, p) => (order.indexOf(p.level) > order.indexOf(worst) ? p.level : worst), 'ok');
|
|
128
132
|
return { level, problems: problems.slice(0, PROBLEMS_MAX).map((p) => ({ ...p, text: p.text.slice(0, TEXT_MAX) })) };
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
export class Health {
|
|
132
|
-
#vms; #conversations; #resources; #records = new Map(); #running = new Map(); #quiet = new Map(); #settleMs; #probeMs; #lookMs; #seen;
|
|
136
|
+
#vms; #conversations; #resources; #records = new Map(); #running = new Map(); #quiet = new Map(); #settleMs; #probeMs; #lookMs; #seen; #links;
|
|
133
137
|
/* Set by the server: told each judgment (the chat of a conversation says what is wrong: talk.mjs). */
|
|
134
138
|
judged = () => {};
|
|
135
139
|
/* Set by the server: told a conversation is forgotten (what else is kept of its machine goes too). */
|
|
@@ -139,8 +143,9 @@ export class Health {
|
|
|
139
143
|
again (a machine that has just started may still be mounting it).
|
|
140
144
|
`lookMs`: how long the clock waits for one conversation's look. */
|
|
141
145
|
/* `seen`: a store of the problems its owner marked seen ("Seen"), by conversation. */
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
/* `links`: the guard of links its folders cannot serve (linkguard.mjs), or null. */
|
|
147
|
+
constructor({ vms, conversations, resources, settleMs = 5000, probeMs = PROBE_MS, lookMs = LOOK_MS, seen = null, links = null }) {
|
|
148
|
+
this.#seen = seen; this.#links = links;
|
|
144
149
|
this.#vms = vms; this.#conversations = conversations; this.#resources = resources; this.#settleMs = settleMs; this.#probeMs = probeMs; this.#lookMs = lookMs;
|
|
145
150
|
}
|
|
146
151
|
|
|
@@ -217,13 +222,45 @@ export class Health {
|
|
|
217
222
|
}
|
|
218
223
|
}
|
|
219
224
|
}
|
|
225
|
+
const links = await this.#lookLinks(name, asked);
|
|
220
226
|
return this.#keep(name, {
|
|
221
227
|
at, state, answering: true, memory: facts.memory, oomKills: facts.oomKills ?? 0, ...(facts.oomKinds ? { oomKinds: facts.oomKinds } : {}), disks: facts.disks,
|
|
222
|
-
missing: loose(), ...(remounted.length ? { remounted } : {}), ...(failed.length ? { failed } : {}),
|
|
228
|
+
paths, missing: loose(), ...(remounted.length ? { remounted } : {}), ...(failed.length ? { failed } : {}), ...(links ? { links } : {}),
|
|
223
229
|
/* What was put back stays said until a later look finds nothing to put back. */
|
|
224
230
|
});
|
|
225
231
|
}
|
|
226
232
|
|
|
233
|
+
/* Its folders' links, bounded (linkguard.mjs); never what holds up a look. */
|
|
234
|
+
async #lookLinks(name, force) {
|
|
235
|
+
if (!this.#links) return null;
|
|
236
|
+
return this.#links.check(name, { force }).catch(() => null);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* "CHECK SHARED FOLDERS", asked by its owner: the same looks, now -- each
|
|
240
|
+
folder mounted and readable (put back when loose), each link the share
|
|
241
|
+
cannot serve -- and what was found or done, one item each. A machine
|
|
242
|
+
that does not run is not woken: only its folders' links are looked at. */
|
|
243
|
+
async checkFolders(name) {
|
|
244
|
+
const record = await this.check(name, { asked: true });
|
|
245
|
+
if (!record) return null;
|
|
246
|
+
let links = record.links;
|
|
247
|
+
if (!links && this.#links) links = await this.#links.check(name, { force: true }).catch(() => null);
|
|
248
|
+
const items = [];
|
|
249
|
+
if (record.state === 'Running' && record.answering === true) {
|
|
250
|
+
const failed = new Map((record.failed ?? []).map((f) => [f.path, f.why]));
|
|
251
|
+
for (const path of record.paths ?? []) {
|
|
252
|
+
if (failed.has(path)) items.push({ kind: 'mount-failed', path, why: failed.get(path) });
|
|
253
|
+
else if ((record.remounted ?? []).includes(path)) items.push({ kind: 'mount-remounted', path });
|
|
254
|
+
else if ((record.missing ?? []).includes(path)) items.push({ kind: 'mount-missing', path });
|
|
255
|
+
else items.push({ kind: 'mount-ok', path });
|
|
256
|
+
}
|
|
257
|
+
} else items.push({ kind: 'machine-off', state: record.state ?? null });
|
|
258
|
+
for (const l of links?.removed ?? []) items.push({ kind: 'link-removed', folder: l.folder, path: l.path, why: l.kind });
|
|
259
|
+
for (const l of links?.found ?? []) items.push({ kind: 'link-found', folder: l.folder, path: l.path, why: l.kind });
|
|
260
|
+
for (const c of links?.cut ?? []) items.push({ kind: 'links-cut', folder: c.folder, why: c.why });
|
|
261
|
+
return { at: record.at, items, health: this.of(name) };
|
|
262
|
+
}
|
|
263
|
+
|
|
227
264
|
async #probe(name, paths) {
|
|
228
265
|
/* Its output is the agent's: not more than a few pages of it are even taken in. */
|
|
229
266
|
const r = await this.#vms.exec(name, ['sh', '-c', PROBE, PROBE_NAME, ...paths], { timeoutMs: this.#probeMs, maxBytes: PROBE_BYTES * 4 }).catch(() => ({ code: 1, stdout: '' }));
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* LINKS A SHARED FOLDER CANNOT SERVE, found on the server and, where it is
|
|
2
|
+
* safe, taken out.
|
|
3
|
+
*
|
|
4
|
+
* WHY. A folder is shown in a machine by the VM manager's own file server
|
|
5
|
+
* (multipass: sshfs in the machine, its SFTP server on this host). That
|
|
6
|
+
* server, not the machine, decides; root in the machine counts for nothing.
|
|
7
|
+
* It judges a link BY ITS TARGET: the target must lie in the folder and
|
|
8
|
+
* belong to the account the folder is mapped from (`-u`/`-g` of the mount).
|
|
9
|
+
* A link whose target is missing, loops, or lies elsewhere on this host has
|
|
10
|
+
* no such owner: reading it, renaming it and often removing it are refused
|
|
11
|
+
* ("Permission denied"), and what it says of it is nonsense ("Value too
|
|
12
|
+
* large"). Seen 07-11: app/node_modules made a link by a worktree, then
|
|
13
|
+
* committed, in a read-write folder its machine could not repair.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IS DONE, here, where the folder is plain files:
|
|
16
|
+
* - a link named node_modules that the share cannot serve: removed (the
|
|
17
|
+
* link, never what it points to); its tool is told it may install its
|
|
18
|
+
* dependencies again;
|
|
19
|
+
* - a link that loops, or points nowhere outside the folder: removed;
|
|
20
|
+
* - any other (an absolute target, one outside the folder, one inside it
|
|
21
|
+
* not there yet): said, and left.
|
|
22
|
+
*
|
|
23
|
+
* BOUNDED: a few levels deep, so many entries, so long; .git and real
|
|
24
|
+
* node_modules folders are not walked. A folder looked at recently is not
|
|
25
|
+
* looked at again unless asked (`force`).
|
|
26
|
+
*/
|
|
27
|
+
import { opendir, lstat, readlink, realpath, unlink } from 'node:fs/promises';
|
|
28
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
29
|
+
|
|
30
|
+
export const CAPS = Object.freeze({ depth: 6, entries: 20_000, ms: 2_000, links: 50 });
|
|
31
|
+
/* A folder is looked at again by the clock this often at most. */
|
|
32
|
+
export const EVERY_MS = 10 * 60_000;
|
|
33
|
+
/* A removal is said to a conversation once a day at most, should the link come back. */
|
|
34
|
+
export const SAY_AGAIN_MS = 24 * 60 * 60_000;
|
|
35
|
+
const NOT_WALKED = new Set(['.git', 'node_modules']);
|
|
36
|
+
export const KINDS = ['loop', 'dangling', 'absolute', 'outside'];
|
|
37
|
+
|
|
38
|
+
const inside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
|
|
39
|
+
|
|
40
|
+
/* What is wrong with the link at `full` (under the real path `root`), or null. */
|
|
41
|
+
export async function judgeLink(root, full) {
|
|
42
|
+
let target;
|
|
43
|
+
try { target = await readlink(full); } catch { return null; }
|
|
44
|
+
let real;
|
|
45
|
+
try { real = await realpath(full); } catch (error) {
|
|
46
|
+
if (error.code === 'ELOOP') return { kind: 'loop', target };
|
|
47
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
48
|
+
/* Where it would point, as written: outside the folder, it points nowhere the share serves. */
|
|
49
|
+
const aim = isAbsolute(target) ? target : resolve(dirname(full), target);
|
|
50
|
+
return { kind: 'dangling', target, escapes: isAbsolute(target) || !inside(root, aim) };
|
|
51
|
+
}
|
|
52
|
+
return { kind: 'outside', target };
|
|
53
|
+
}
|
|
54
|
+
/* An absolute target is a path of THIS host: in the machine it names something else, or nothing. */
|
|
55
|
+
if (isAbsolute(target)) return { kind: 'absolute', target };
|
|
56
|
+
if (!inside(root, real)) return { kind: 'outside', target };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Taken out without asking: a node_modules link, a loop, a link to nowhere outside the folder. */
|
|
61
|
+
export function removable(link) {
|
|
62
|
+
const base = link.path.split('/').pop();
|
|
63
|
+
return base === 'node_modules' || link.kind === 'loop' || (link.kind === 'dangling' && link.escapes === true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* The links of one folder the share cannot serve: { links: [{ path, kind, target }], cut }.
|
|
67
|
+
`cut`: why the look stopped early ('depth', 'entries', 'time', 'links'), or null. */
|
|
68
|
+
export async function scanFolder(folder, caps = CAPS, now = () => Date.now()) {
|
|
69
|
+
const root = await realpath(folder);
|
|
70
|
+
const until = now() + caps.ms;
|
|
71
|
+
const links = []; let entries = 0; let cut = null; let deeper = false;
|
|
72
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
73
|
+
while (stack.length > 0 && !cut) {
|
|
74
|
+
const { dir, depth } = stack.pop();
|
|
75
|
+
let handle;
|
|
76
|
+
/* A folder swapped for a link since it was listed is not followed out. */
|
|
77
|
+
try { if (dir !== root && (await realpath(dir)) !== dir) continue; handle = await opendir(dir); } catch { continue; }
|
|
78
|
+
try {
|
|
79
|
+
for (let entry = await handle.read(); entry !== null; entry = await handle.read()) {
|
|
80
|
+
if (++entries > caps.entries) { cut = 'entries'; break; }
|
|
81
|
+
if (now() > until) { cut = 'time'; break; }
|
|
82
|
+
const full = join(dir, entry.name);
|
|
83
|
+
if (entry.isSymbolicLink()) {
|
|
84
|
+
const wrong = await judgeLink(root, full);
|
|
85
|
+
if (!wrong) continue;
|
|
86
|
+
if (links.length >= caps.links) { cut = 'links'; break; }
|
|
87
|
+
links.push({ path: relative(root, full).split(sep).join('/'), ...wrong });
|
|
88
|
+
} else if (entry.isDirectory() && !NOT_WALKED.has(entry.name)) {
|
|
89
|
+
if (depth + 1 < caps.depth) stack.push({ dir: full, depth: depth + 1 });
|
|
90
|
+
else deeper = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} finally { await handle.close().catch(() => {}); }
|
|
94
|
+
}
|
|
95
|
+
return { links, cut: cut ?? (deeper ? 'depth' : null) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Removes the link `path` of `folder`: '' when done, or why not. Only a link,
|
|
99
|
+
and only where its folder is still the one walked (a folder on the way
|
|
100
|
+
swapped for a link meanwhile is not followed). */
|
|
101
|
+
export async function removeLink(folder, path) {
|
|
102
|
+
const root = await realpath(folder);
|
|
103
|
+
const full = join(root, ...path.split('/'));
|
|
104
|
+
if (!inside(root, full) || full === root) return 'not in the folder';
|
|
105
|
+
try {
|
|
106
|
+
if ((await realpath(dirname(full))) !== dirname(full)) return 'its folder moved';
|
|
107
|
+
if (!(await lstat(full)).isSymbolicLink()) return 'not a link any more';
|
|
108
|
+
await unlink(full);
|
|
109
|
+
return '';
|
|
110
|
+
} catch (error) { return error.code === 'ENOENT' ? 'already gone' : error.message; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* THE GUARD, for each conversation's attached folders.
|
|
114
|
+
`resources`: of(conversation) [{ name }], folder(name), attachedTo(name) [conversation]
|
|
115
|
+
`removed(conversation, { folder, path, kind, target })`: told once per removal, to each
|
|
116
|
+
conversation the folder is attached to (its chat, its tool: main.mjs). */
|
|
117
|
+
export class LinkGuard {
|
|
118
|
+
#resources; #caps; #everyMs; #now; #last = new Map(); #going = new Map(); #said = new Map();
|
|
119
|
+
removed = () => {};
|
|
120
|
+
constructor({ resources, caps = CAPS, everyMs = EVERY_MS, now = () => Date.now() }) {
|
|
121
|
+
this.#resources = resources; this.#caps = caps; this.#everyMs = everyMs; this.#now = now;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Looks at the folders of `conversation`: { found, removed, cut }, each with its folder's name. */
|
|
125
|
+
async check(conversation, { force = false } = {}) {
|
|
126
|
+
const out = { found: [], removed: [], cut: [] };
|
|
127
|
+
for (const { name } of this.#resources.of(conversation)) {
|
|
128
|
+
/* A mailbox is written by this server alone. */
|
|
129
|
+
if (this.#resources.get?.(name)?.type === 'mailbox') continue;
|
|
130
|
+
const r = await this.#folder(name, force).catch(() => null);
|
|
131
|
+
if (!r) continue;
|
|
132
|
+
out.found.push(...r.found.map((l) => ({ folder: name, ...l })));
|
|
133
|
+
out.removed.push(...r.removed.map((l) => ({ folder: name, ...l })));
|
|
134
|
+
if (r.cut) out.cut.push({ folder: name, why: r.cut });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* One folder, one look at a time (two conversations may share it). */
|
|
140
|
+
#folder(name, force) {
|
|
141
|
+
const last = this.#last.get(name);
|
|
142
|
+
if (!force && last && this.#now() - last.at < this.#everyMs) return Promise.resolve({ ...last.result, removed: [] });
|
|
143
|
+
const going = this.#going.get(name);
|
|
144
|
+
if (going) return going;
|
|
145
|
+
const look = this.#look(name).finally(() => this.#going.delete(name));
|
|
146
|
+
this.#going.set(name, look);
|
|
147
|
+
return look;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async #look(name) {
|
|
151
|
+
const folder = this.#resources.folder(name);
|
|
152
|
+
const { links, cut } = await scanFolder(folder, this.#caps, this.#now);
|
|
153
|
+
const found = []; const removed = [];
|
|
154
|
+
for (const link of links) {
|
|
155
|
+
if (removable(link) && (await removeLink(folder, link.path)) === '') removed.push(link);
|
|
156
|
+
else found.push(link);
|
|
157
|
+
}
|
|
158
|
+
const result = { found, removed, cut };
|
|
159
|
+
this.#last.set(name, { at: this.#now(), result: { found, removed: [], cut } });
|
|
160
|
+
for (const link of removed) this.#tell(name, link);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#tell(name, link) {
|
|
165
|
+
const conversations = this.#resources.attachedTo?.(name) ?? [];
|
|
166
|
+
for (const conversation of conversations) {
|
|
167
|
+
const key = `${conversation}\0${name}\0${link.path}`;
|
|
168
|
+
if (this.#now() - (this.#said.get(key) ?? -Infinity) < SAY_AGAIN_MS) continue;
|
|
169
|
+
this.#said.set(key, this.#now());
|
|
170
|
+
try { this.removed(conversation, { folder: name, ...link }); } catch { /* told, nothing more */ }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
forget(name) { this.#last.delete(name); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* What it says, in French, in the conversation's chat (one line). */
|
|
178
|
+
export function removedLine({ folder, path }) {
|
|
179
|
+
const where = path.includes('/') ? ` (${path})` : '';
|
|
180
|
+
return path.split('/').pop() === 'node_modules'
|
|
181
|
+
? `Un lien node_modules cassé dans ${folder}${where} a été retiré ; ses dépendances sont à réinstaller.`
|
|
182
|
+
: `Un lien cassé dans ${folder} (${path}) a été retiré : il ne menait nulle part.`;
|
|
183
|
+
}
|
|
184
|
+
/* What its tool is told, when the link was a node_modules (in English, as its other lines). */
|
|
185
|
+
export function toolLine({ folder, path }) {
|
|
186
|
+
const dir = path.split('/').slice(0, -1).join('/');
|
|
187
|
+
const at = `/resources/${folder}${dir ? `/${dir}` : ''}`;
|
|
188
|
+
return `Clavarium: ${at}/node_modules was a link your machine could not use; it was removed on the server (what it pointed to was left as it was). You may install the dependencies again now: run npm ci (or npm install) in ${at}. If your rules forbid npm install, ask the operator to allow it.`;
|
|
189
|
+
}
|
|
190
|
+
/* Why a link is said, in a few words. */
|
|
191
|
+
export const WHY = Object.freeze({ loop: 'it loops', dangling: 'its target is missing', absolute: 'its target is an absolute path of the server', outside: 'its target is outside the folder' });
|
package/server/main.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import { GitKeeper } from './gitkeeper.mjs';
|
|
|
59
59
|
import { GitRunner } from './gitrunner.mjs';
|
|
60
60
|
import { Shelf } from './shelf.mjs';
|
|
61
61
|
import { Health } from './health.mjs';
|
|
62
|
+
import { LinkGuard, removedLine, toolLine } from './linkguard.mjs';
|
|
62
63
|
import { Setup } from './setup.mjs';
|
|
63
64
|
import { Capacity } from './capacity.mjs';
|
|
64
65
|
import { packageFile, PARLEY } from './parley.mjs';
|
|
@@ -575,7 +576,17 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
575
576
|
}
|
|
576
577
|
}, 15_000); restartTimer.unref?.();
|
|
577
578
|
/* The health of each machine, and folders that came loose put back: see health.mjs. */
|
|
578
|
-
|
|
579
|
+
/* Links its folders hold that the share cannot serve: removed where safe, said once (linkguard.mjs). */
|
|
580
|
+
const linkGuard = typeof folders.attachedTo === 'function' ? new LinkGuard({ resources: folders }) : null;
|
|
581
|
+
if (linkGuard) linkGuard.removed = (name, link) => {
|
|
582
|
+
try { talk.event(name, { kind: 'machine-ok', text: removedLine(link), topic: 'Machine', brief: 'Shared folder' }); } catch { /* its chat is off */ }
|
|
583
|
+
if (link.path.split('/').pop() !== 'node_modules') return;
|
|
584
|
+
/* Its tool told it may install them again: through its docket where its chat is on, else typed to its agent. */
|
|
585
|
+
const line = toolLine(link);
|
|
586
|
+
try { if (dockets.on(name)) { dockets.post(name, { kind: 'shared-folder', text: line, data: { folder: link.folder, path: link.path } }); return; } } catch { /* typed instead */ }
|
|
587
|
+
if (all.list().find((c) => c.name === name)?.ai === true) void wakeups.say(name, line).catch(() => {});
|
|
588
|
+
};
|
|
589
|
+
const health = new Health({ vms, conversations: all, resources: folders, links: linkGuard, seen: new Store(`${dataDir}/health-seen.json`), ...(healthSettleMs === undefined ? {} : { settleMs: healthSettleMs }) });
|
|
579
590
|
const stopHealth = health.start(healthEveryMs);
|
|
580
591
|
/* What is wrong with its machine is said in its chat too, where it happened. */
|
|
581
592
|
/* And what its owner chose that it does on top (healthreact.mjs): its agent told, or a command run. */
|
|
@@ -589,6 +600,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
589
600
|
const problemsSaid = new Map();
|
|
590
601
|
health.judged = (name, record) => {
|
|
591
602
|
talk.machine(name, record); void reacting.judged(name, record).catch(() => {});
|
|
603
|
+
/* A folder that came loose and was put back: said once, in its chat (the look that put it back). */
|
|
604
|
+
for (const path of record?.remounted ?? []) { try { talk.event(name, { kind: 'machine-ok', text: `Le dossier ${path} s'était détaché de la machine : il a été remonté.`, topic: 'Machine', brief: 'Shared folder' }); } catch { /* its chat is off */ } }
|
|
592
605
|
try { healthDocket.judged(name, record); } catch { /* its docket: nothing more */ }
|
|
593
606
|
const now = (record?.problems ?? []).filter((x) => x.level === 'bad' || (x.level === 'warn' && ['memory', 'disk'].includes(x.kind)));
|
|
594
607
|
const before = problemsSaid.get(name) ?? new Set();
|
package/server/resources.mjs
CHANGED
|
@@ -140,6 +140,12 @@ export class Resources {
|
|
|
140
140
|
}
|
|
141
141
|
get(name) { return this.#store.get(name); }
|
|
142
142
|
|
|
143
|
+
/* The conversations a folder is attached to. */
|
|
144
|
+
attachedTo(name) {
|
|
145
|
+
const resource = this.#store.get(name);
|
|
146
|
+
return resource ? Object.keys(this.#attachments(resource)) : [];
|
|
147
|
+
}
|
|
148
|
+
|
|
143
149
|
/* What one conversation has attached: [{ name, access }]. */
|
|
144
150
|
of(conversation) {
|
|
145
151
|
return this.#store.all().map((r) => ({ name: r.name, access: this.#attachments(r)[conversation] })).filter((r) => r.access !== undefined);
|
package/server/rooms.mjs
CHANGED
|
@@ -100,7 +100,7 @@ export class Rooms {
|
|
|
100
100
|
in its row of the list and on its bar, « Hugo réfléchit… » in its thread. */
|
|
101
101
|
#thinkingOf(id, members) { return [...(this.#thinking.get(id) ?? new Map()).entries()].filter(([, v]) => Date.now() - v.at < THINKING_MS).map(([mid]) => members.find((m) => m.id === mid)?.name).filter(Boolean); }
|
|
102
102
|
#doingOf(id, members) { return [...(this.#thinking.get(id) ?? new Map()).entries()].filter(([, v]) => v.text && Date.now() - v.at < THINKING_MS).map(([mid, v]) => ({ name: members.find((m) => m.id === mid)?.name, text: v.text })).filter((x) => x.name); }
|
|
103
|
-
list() { return this.#all().map((r) => { const members = this.#shown(r); return { id: r.id, name: r.name, host: r.host, members, at: r.at, unread: this.#unread(r), last: this.#last(r), thinking: this.#thinkingOf(r.id, members) }; }); }
|
|
103
|
+
list() { return this.#all().map((r) => { const members = this.#shown(r); return { id: r.id, name: r.name, host: r.host, members, at: r.at, unread: this.#unread(r), last: this.#last(r), thinking: this.#thinkingOf(r.id, members), ...(r.quiet ? { quiet: true } : {}) }; }); }
|
|
104
104
|
/* ITS AIs, as conversations (06-11): the names of the conversations of its AI members that are there now --
|
|
105
105
|
never a box, never the person, never one taken out of the room. What « give to all its AIs » gives to. */
|
|
106
106
|
ais(id) { return this.#shown(this.#find(id)).filter((m) => m.kind === 'ai').map((m) => m.ref); }
|
|
@@ -139,10 +139,11 @@ export class Rooms {
|
|
|
139
139
|
if (new Set(names).size !== names.length) throw new Refusal(409, 'Two members of a room cannot have the same name: rename one first.');
|
|
140
140
|
if (room.members.length > MEMBERS_MAX) throw new Refusal(409, `${MEMBERS_MAX} members at most.`);
|
|
141
141
|
}
|
|
142
|
-
/* Its name, its host, members added or taken away. */
|
|
143
|
-
change(id, { name, host, add = {}, remove = [] } = {}) {
|
|
142
|
+
/* Its name, its host, members added or taken away; `quiet` (07-11, the tidy-up): muted in the list, or not. */
|
|
143
|
+
change(id, { name, host, add = {}, remove = [], quiet } = {}) {
|
|
144
144
|
const list = this.#all(); const room = list.find((r) => r.id === id);
|
|
145
145
|
if (!room) throw new Refusal(404, 'No such room.');
|
|
146
|
+
if (quiet !== undefined) { if (quiet === true) room.quiet = true; else delete room.quiet; }
|
|
146
147
|
if (name !== undefined) {
|
|
147
148
|
const n = nameOf(name); if (!n) throw new Refusal(400, 'Give the room a name.');
|
|
148
149
|
if (list.some((r) => r.id !== id && r.name.toLowerCase() === n.toLowerCase())) throw new Refusal(409, 'A room has that name already.');
|
package/server/settingswords.mjs
CHANGED
|
@@ -13,6 +13,13 @@ export const SETTINGS_WORDS_FR = {
|
|
|
13
13
|
'set-ch-s-usage': 'Usage', 'set-ch-s-backup': 'Sauvegarde', 'set-ch-s-advanced': 'Avancé',
|
|
14
14
|
'set-check-updates': 'Chercher une mise à jour', 'set-activity': 'Activité', 'set-restart-dots': 'Redémarrer…', 'set-terminal': 'Terminal',
|
|
15
15
|
'set-search': 'Chercher', 'set-why': 'Pourquoi',
|
|
16
|
+
/* Diagnostics › « Vérifier les dossiers partagés » (health.mjs, linkguard.mjs). */
|
|
17
|
+
'set-check-folders': 'Vérifier les dossiers partagés', 'set-looking': 'Vérification…',
|
|
18
|
+
'set-fold-ok': '{path} : monté, lisible', 'set-fold-remounted': '{path} : détaché, remonté', 'set-fold-missing': '{path} : inaccessible',
|
|
19
|
+
'set-fold-failed': '{path} : inaccessible, pas remonté', 'set-fold-link-removed': '{path} : lien cassé retiré ({why})',
|
|
20
|
+
'set-fold-link-found': '{path} : lien inutilisable ({why})', 'set-fold-cut': '{path} : trop gros, vérifié en partie',
|
|
21
|
+
'set-fold-off': 'Machine arrêtée : liens seuls vérifiés', 'set-fold-none': 'Aucun dossier partagé',
|
|
22
|
+
'set-link-loop': 'boucle', 'set-link-dangling': 'cible absente', 'set-link-absolute': 'cible en chemin absolu', 'set-link-outside': 'cible hors du dossier',
|
|
16
23
|
'set-memory': 'Mémoire', 'set-disk': 'Disque', 'set-cpu': 'Processeur',
|
|
17
24
|
'set-free-of': '{free} libres sur {total}', 'set-left-of': '{free} restants sur {total}', 'set-highest': 'au plus {n} % en 1 h',
|
|
18
25
|
'set-core': '1 cœur', 'set-cores': '{n} cœurs',
|
|
@@ -33,7 +40,7 @@ export const SETTINGS_WORDS_FR = {
|
|
|
33
40
|
'set-dict-backup': 'Secours en service', 'set-dict-expired': 'Clé expirée', 'set-dict-with-backup': 'avec secours', 'set-dict-server': 'réglée sur le serveur',
|
|
34
41
|
'set-attached': '{n} joints', 'set-secret': '1 secret', 'set-secrets': '{n} secrets', 'set-offline': 'hors ligne',
|
|
35
42
|
'set-device': '1 appareil', 'set-devices': '{n} appareils', 'set-no-device': 'Aucun appareil', 'set-chat': 'Chat',
|
|
36
|
-
'set-wakeups-on': '{on} sur {n} actifs', 'set-asleep': '
|
|
43
|
+
'set-wakeups-on': '{on} sur {n} actifs', 'set-asleep': 'En veille', 'set-not-looked': 'Pas encore regardée',
|
|
37
44
|
'set-running-well': 'En marche · rien d\'anormal', 'set-conv-advanced': 'Archiver · remettre à neuf · supprimer',
|
|
38
45
|
/* New asset, one box (05-11, step 3): one verb per gesture -- Créer, Attacher, Terminé. */
|
|
39
46
|
'set-na-new': 'Nouvel asset', 'set-na-attach': 'Attacher', 'set-na-there': 'Déjà là', 'set-na-or-new': 'ou un nouveau',
|
|
@@ -109,11 +116,15 @@ export const SETTINGS_WORDS_FR = {
|
|
|
109
116
|
'set-list-not-filed': 'Sans groupe', 'set-list-asks': 'vous demande', 'set-list-waits': 'vous attend',
|
|
110
117
|
'set-list-asks-for': 'le répondeur vous demande', 'set-list-answered': 'a répondu à votre place',
|
|
111
118
|
'set-list-wakeup': '1 réveil', 'set-list-wakeups': '{n} réveils', 'set-list-hidden': 'cachée',
|
|
112
|
-
'set-list-asleep': '
|
|
119
|
+
'set-list-asleep': 'en veille', 'set-list-starting': 'démarre', 'set-list-restarting': 'redémarre',
|
|
113
120
|
'set-list-moving': 'déplace son travail', 'set-list-failed': 'en échec', 'set-list-you': 'Vous : {text}',
|
|
114
121
|
'set-list-fewer': 'En montrer moins', 'set-list-more': 'En montrer {n} de plus',
|
|
115
122
|
'set-list-hide-hidden': 'Masquer les {n} cachées', 'set-list-n-hidden': '{n} cachées',
|
|
116
123
|
'set-list-tip': 'Rangez-les en groupes : Réglages, Groupe.',
|
|
124
|
+
'set-idle-h': 'rien depuis {n} h', 'set-idle-d': 'rien depuis {n} j', 'set-idle-w': 'rien depuis {n} sem.', 'set-idle-mo': 'rien depuis {n} mois',
|
|
125
|
+
'set-tidy-open': 'Faire le ménage ({n})', 'set-tidy-title': 'Mettre en veille ?', 'set-tidy-hint': 'Rien depuis 12 h ou plus.',
|
|
126
|
+
'set-tidy-go': 'Mettre en veille ({n})', 'set-close': 'Fermer',
|
|
127
|
+
'set-asleep-page': 'En veille. Elle reprend quand vous la réveillez.', 'set-wake-it': 'Réveiller',
|
|
117
128
|
'set-as-k-git': 'Dépôts git', 'set-as-k-folder': 'Dossiers', 'set-as-k-mailbox': 'Boîtes mail', 'set-as-k-agenda': 'Agendas',
|
|
118
129
|
'set-as-k-contacts': 'Contacts', 'set-as-k-secret': 'Secrets', 'set-as-k-list': 'Classeurs', 'set-as-k-acces': 'Accès',
|
|
119
130
|
'set-as-kind-folder': 'dossier', 'set-as-kind-git': 'dépôt', 'set-as-kind-mailbox': 'boîte mail',
|
|
@@ -134,12 +145,14 @@ export const SETTINGS_WORDS_FR = {
|
|
|
134
145
|
'set-files-read-only': 'Lecture seule : rien ne peut y être changé.',
|
|
135
146
|
'set-this-conv': 'Cette conversation · {name}', 'set-ask-first': 'Demander avant d\'agir',
|
|
136
147
|
'set-online': 'En ligne', 'set-offline-btn': 'Hors ligne', 'set-sleep': 'Endormir',
|
|
137
|
-
'set-room-name': 'Nom', 'set-room-
|
|
138
|
-
'set-room-
|
|
139
|
-
'set-room-in': '{n} dans le salon', 'set-room-find': 'Chercher une conversation', 'set-room-boxes': 'Boîtiers vocaux',
|
|
148
|
+
'set-room-name': 'Nom', 'set-room-welcomes': 'Accueille', 'set-room-ais': 'Ses IA',
|
|
149
|
+
'set-room-find': 'Chercher une conversation', 'set-room-boxes': 'Boîtiers vocaux',
|
|
140
150
|
'set-room-remove': 'Supprimer le salon', 'set-room-remove-what': 'Le fil part · les IA et boîtiers restent',
|
|
141
151
|
'set-room-remove-dots': 'Supprimer…', 'set-room-remove-yes': 'Supprimer {name}', 'set-room-keep': 'Garder',
|
|
142
|
-
'set-room-
|
|
152
|
+
'set-room-create': 'Créer', 'set-room-new': 'Nouveau salon',
|
|
153
|
+
/* A room's settings as every settings page (07-11): each change saved at once, said a moment in the bar. */
|
|
154
|
+
'set-room-saved': 'Enregistré', 'set-room-settings-of': '{name} · Réglages', 'set-room-of': '{n} sur {t}',
|
|
155
|
+
'set-room-host-how': 'Touchez une autre IA du salon pour la déplacer',
|
|
143
156
|
'set-room-people': 'Personnes', 'set-room-ais-of': 'IA', 'set-room-boxes-of': 'Boîtiers', 'set-room-host-mark': 'accueille',
|
|
144
157
|
'set-room-n-person': '1 personne', 'set-room-n-people': '{n} personnes', 'set-room-n-ai': '1 IA', 'set-room-n-ais': '{n} IA',
|
|
145
158
|
'set-room-n-box': '1 boîtier', 'set-room-n-boxes': '{n} boîtiers',
|
package/server/talk.mjs
CHANGED
|
@@ -653,10 +653,11 @@ export class Talk {
|
|
|
653
653
|
tone: fresh.some((p) => p.level === 'bad') ? 'bad' : 'warn',
|
|
654
654
|
topic: 'Machine',
|
|
655
655
|
/* Its name on the one line a problem is in the simple view (Parley, 03-11). */
|
|
656
|
-
brief: fresh.some((p) => p.kind === 'memory' || /^oom:|^memory/.test(p.key ?? '')) ? 'Out of memory' : fresh.every((p) => p.kind === 'disk') ? 'Disk almost full' : 'Machine problem',
|
|
656
|
+
brief: fresh.some((p) => p.kind === 'memory' || /^oom:|^memory/.test(p.key ?? '')) ? 'Out of memory' : fresh.every((p) => p.kind === 'disk') ? 'Disk almost full' : fresh.every((p) => p.kind === 'link') ? 'Shared folder' : 'Machine problem',
|
|
657
657
|
/* What can be done: a disk short of room is looked at (what takes it) or made larger --
|
|
658
658
|
a restart frees nothing; otherwise more memory where memory is short, a restart, its terminal. */
|
|
659
|
-
|
|
659
|
+
/* A link in a folder (linkguard.mjs) is the folder's business: a restart changes nothing. */
|
|
660
|
+
actions: [...(fresh.every((p) => p.kind === 'link') ? [] : fresh.every((p) => p.kind === 'disk') ? ['room', 'disk'] : [...(fresh.some((p) => p.kind === 'memory') ? ['memory'] : []), 'restart', 'terminal']), 'seen'],
|
|
660
661
|
});
|
|
661
662
|
}
|
|
662
663
|
|