strom-research 1.7.0 → 1.9.0
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/README.md +3 -0
- package/assets/lang/cs.json +117 -2
- package/assets/lang/de.json +117 -2
- package/assets/method/core.md +8 -0
- package/assets/method/narrate.md +5 -1
- package/assets/plugins/gates/claude-usage/gate.ts +2 -1
- package/dist/agents/files.js +6 -4
- package/dist/cli/main.js +21 -3
- package/dist/cli/menu-links.js +35 -1
- package/dist/cli/menu-research.js +41 -2
- package/dist/cli/menu-settings.js +17 -1
- package/dist/cli/menu.js +19 -8
- package/dist/cli/ui.js +57 -1
- package/dist/commands/guide.js +6 -1
- package/dist/commands/links.js +39 -3
- package/dist/commands/output.js +4 -2
- package/dist/commands/pack.js +21 -18
- package/dist/commands/research.js +42 -1
- package/dist/commands/session.js +210 -11
- package/dist/commands/setup.js +3 -0
- package/dist/commands/start.js +6 -3
- package/dist/commands/story.js +47 -6
- package/dist/commands/tasks.js +50 -12
- package/dist/commands/trees.js +82 -2
- package/dist/core/changelog.js +223 -0
- package/dist/core/clock.js +20 -0
- package/dist/core/config.js +2 -0
- package/dist/core/connector.js +25 -1
- package/dist/core/directions.js +41 -0
- package/dist/core/edge.js +296 -0
- package/dist/core/frontier.js +12 -4
- package/dist/core/kin.js +40 -1
- package/dist/core/links.js +17 -4
- package/dist/core/live.js +428 -52
- package/dist/core/pack.js +31 -0
- package/dist/core/phrases.js +61 -0
- package/dist/core/queue.js +15 -4
- package/dist/core/relocate.js +4 -4
- package/dist/core/session.js +8 -2
- package/dist/core/stromapp.js +22 -0
- package/dist/core/trash.js +72 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/workers.js +28 -3
- package/dist/gedcom/export.js +105 -1
- package/dist/gedcom/validate.js +7 -1
- package/dist/runners/claude.js +2 -1
- package/dist/runners/jsonl.js +2 -2
- package/dist/runners/runner.js +16 -3
- package/dist/runners/script.js +2 -1
- package/package.json +1 -1
package/dist/core/live.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// GET <token>/status the tree, its researches, who is at work, what waits
|
|
10
10
|
// GET <token>/tree.ged the tree for the Strom app, as it is now, each entry with its image (the settings excerpts.*)
|
|
11
11
|
// GET <token>/images.ged the same (the address an older strom gave)
|
|
12
|
+
// GET <token>/log what the research saved, newest first (each commit: when, what, for which task;
|
|
13
|
+
// text: its lines as the user reads them, kinds: what each is about)
|
|
12
14
|
// GET <token>/events server-sent events: hello, change, working
|
|
13
15
|
// POST <token>/sync the Strom app sends the user's edited tree back (?send=): kept to be shown
|
|
14
16
|
// and written on the user's word (strom sync) — nothing of the research changes
|
|
@@ -17,12 +19,24 @@
|
|
|
17
19
|
// Only pages of the Strom app may read it (CORS: https://stromapp.info, its beta, and a
|
|
18
20
|
// local copy on localhost for its development), and a browser asks first
|
|
19
21
|
// whether a public page may talk to this computer (Private Network Access).
|
|
22
|
+
//
|
|
23
|
+
// It never ends because of one error: a read that fails while another strom
|
|
24
|
+
// writes is tried again at the next tick, a request that fails answers 500.
|
|
25
|
+
// What it did and what went wrong is in .strom/live.log (start, end and why,
|
|
26
|
+
// errors with their stack). Started again, it takes the address it had
|
|
27
|
+
// (.strom/live-last.json: its port while free, its token), so the app following
|
|
28
|
+
// it goes on by itself; a bridge that ended without a word is started again
|
|
29
|
+
// when a session starts (reviveLive).
|
|
20
30
|
import fs from "node:fs";
|
|
21
31
|
import http from "node:http";
|
|
22
32
|
import path from "node:path";
|
|
23
33
|
import crypto from "node:crypto";
|
|
24
34
|
import { spawn, spawnSync } from "node:child_process";
|
|
25
35
|
import { Tree, VERSION } from "./tree.js";
|
|
36
|
+
import { changeLines } from "./changelog.js";
|
|
37
|
+
import { directionOf, scopes } from "./directions.js";
|
|
38
|
+
import { ancestorGenerations } from "./people.js";
|
|
39
|
+
import { phrase } from "./phrases.js";
|
|
26
40
|
import { exportGedcom } from "../gedcom/export.js";
|
|
27
41
|
import { excerptSettings, planExcerpts } from "./excerpt.js";
|
|
28
42
|
import { noteAdoptFailed, noteNothingSent, pendingAdoption, receiveAdopted, receiveTree, SYNC_MAX_BYTES } from "./sync.js";
|
|
@@ -34,8 +48,8 @@ import { monthSpend, openSessions } from "./session.js";
|
|
|
34
48
|
import { rankTasks } from "./queue.js";
|
|
35
49
|
import { humanTask } from "../cli/human.js";
|
|
36
50
|
import { knownNewerVersion } from "./update.js";
|
|
37
|
-
import { gitProgram } from "./git.js";
|
|
38
|
-
import { appOpensLinks, isStromAppOrigin } from "./stromapp.js";
|
|
51
|
+
import { gitProgram, runGit } from "./git.js";
|
|
52
|
+
import { appOpensLinks, appShowsEdges, isStromAppOrigin } from "./stromapp.js";
|
|
39
53
|
import { Settings } from "./config.js";
|
|
40
54
|
import { linkActions, linkHandlerState } from "./links.js";
|
|
41
55
|
import { stromLauncher } from "./self.js";
|
|
@@ -43,6 +57,53 @@ import { stromLauncher } from "./self.js";
|
|
|
43
57
|
export function liveFile(root) {
|
|
44
58
|
return path.join(root, ".strom", "live.json");
|
|
45
59
|
}
|
|
60
|
+
/** What the bridge did and what went wrong: plain text, the newest LIVE_LOG_BYTES at most. */
|
|
61
|
+
export function liveLogFile(root) {
|
|
62
|
+
return path.join(root, ".strom", "live.log");
|
|
63
|
+
}
|
|
64
|
+
const LIVE_LOG_BYTES = 200 * 1024;
|
|
65
|
+
function lastFile(root) {
|
|
66
|
+
return path.join(root, ".strom", "live-last.json");
|
|
67
|
+
}
|
|
68
|
+
function readLast(root) {
|
|
69
|
+
try {
|
|
70
|
+
const last = JSON.parse(fs.readFileSync(lastFile(root), "utf8"));
|
|
71
|
+
return Number.isInteger(last.port) && /^[0-9a-f]{32}$/.test(last.token) ? last : undefined;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function writeLast(root, last) {
|
|
78
|
+
try {
|
|
79
|
+
const file = lastFile(root);
|
|
80
|
+
fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify(last, null, 2));
|
|
81
|
+
fs.renameSync(`${file}.${process.pid}`, file);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// the next start takes a new address
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** One line (or an error with its stack) into the bridge's log; never fails, never grows past its size. */
|
|
88
|
+
export function noteLive(root, text) {
|
|
89
|
+
try {
|
|
90
|
+
const file = liveLogFile(root);
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
fs.appendFileSync(file, `${new Date().toISOString()} [${process.pid}] ${text}\n`);
|
|
93
|
+
const size = fs.statSync(file).size;
|
|
94
|
+
if (size > LIVE_LOG_BYTES) {
|
|
95
|
+
const kept = fs.readFileSync(file).subarray(size - LIVE_LOG_BYTES / 2);
|
|
96
|
+
const from = kept.indexOf(10) + 1;
|
|
97
|
+
fs.writeFileSync(file, kept.subarray(from));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// a log that cannot be written stops nothing
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function errorText(e) {
|
|
105
|
+
return e instanceof Error ? (e.stack ?? `${e.name}: ${e.message}`) : String(e);
|
|
106
|
+
}
|
|
46
107
|
function alive(pid) {
|
|
47
108
|
try {
|
|
48
109
|
process.kill(pid, 0);
|
|
@@ -76,17 +137,28 @@ export function startLive(root, env, opts = {}) {
|
|
|
76
137
|
if (running && (!opts.current || running.version === VERSION))
|
|
77
138
|
return running;
|
|
78
139
|
if (running)
|
|
79
|
-
stopLive(root);
|
|
140
|
+
stopLive(root, `a bridge of strom ${VERSION} is wanted (this one: ${running.version ?? "older"})`);
|
|
80
141
|
fs.rmSync(liveFile(root), { force: true });
|
|
81
142
|
const { command, args } = stromLauncher();
|
|
143
|
+
// what the bridge says itself goes to its log — also what Node says when the bridge dies
|
|
144
|
+
let log = "ignore";
|
|
145
|
+
try {
|
|
146
|
+
fs.mkdirSync(path.dirname(liveLogFile(root)), { recursive: true });
|
|
147
|
+
log = fs.openSync(liveLogFile(root), "a");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// without a log
|
|
151
|
+
}
|
|
82
152
|
const child = spawn(command, [...args, "live", "serve"], {
|
|
83
153
|
cwd: root,
|
|
84
154
|
env: { ...env, STROM_TREE: root },
|
|
85
155
|
detached: true,
|
|
86
|
-
stdio: "ignore",
|
|
156
|
+
stdio: ["ignore", "ignore", log],
|
|
87
157
|
windowsHide: true,
|
|
88
158
|
});
|
|
89
159
|
child.unref();
|
|
160
|
+
if (typeof log === "number")
|
|
161
|
+
fs.closeSync(log);
|
|
90
162
|
for (let i = 0; i < 100; i++) {
|
|
91
163
|
const info = liveRunning(root);
|
|
92
164
|
if (info)
|
|
@@ -95,11 +167,15 @@ export function startLive(root, env, opts = {}) {
|
|
|
95
167
|
}
|
|
96
168
|
return undefined;
|
|
97
169
|
}
|
|
98
|
-
/** Stop the bridge of a tree; false when none ran. */
|
|
99
|
-
export function stopLive(root) {
|
|
170
|
+
/** Stop the bridge of a tree (why: into its log); false when none ran. */
|
|
171
|
+
export function stopLive(root, why = "strom live stop") {
|
|
100
172
|
const info = liveRunning(root);
|
|
101
173
|
if (!info)
|
|
102
174
|
return false;
|
|
175
|
+
noteLive(root, `stop asked: ${why}`);
|
|
176
|
+
const last = readLast(root);
|
|
177
|
+
if (last?.pid === info.pid)
|
|
178
|
+
writeLast(root, { ...last, ended: { at: new Date().toISOString(), reason: why } });
|
|
103
179
|
try {
|
|
104
180
|
process.kill(info.pid);
|
|
105
181
|
}
|
|
@@ -109,6 +185,21 @@ export function stopLive(root) {
|
|
|
109
185
|
fs.rmSync(liveFile(root), { force: true });
|
|
110
186
|
return true;
|
|
111
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* The bridge of a tree that ended without a word (a crash, killed) is started again — on its address, so the
|
|
190
|
+
* Strom app following it goes on by itself. One that ended as it should (idle, stopped) stays so.
|
|
191
|
+
*/
|
|
192
|
+
export function reviveLive(root, env) {
|
|
193
|
+
const last = readLast(root);
|
|
194
|
+
if (!last || last.ended || alive(last.pid) || liveRunning(root))
|
|
195
|
+
return undefined;
|
|
196
|
+
noteLive(root, `the bridge ${last.pid} ended without a word: started again`);
|
|
197
|
+
const info = startLive(root, env);
|
|
198
|
+
// one that does not come up is not tried at every session again
|
|
199
|
+
if (!info)
|
|
200
|
+
writeLast(root, { ...last, ended: { at: new Date().toISOString(), reason: "did not start again" } });
|
|
201
|
+
return info;
|
|
202
|
+
}
|
|
112
203
|
/** The pages that may read the bridge: the Strom app, and a local copy of it for its development. */
|
|
113
204
|
function allowedOrigin(origin) {
|
|
114
205
|
return origin && isStromAppOrigin(origin) ? origin : undefined;
|
|
@@ -117,17 +208,204 @@ function head(root) {
|
|
|
117
208
|
const r = spawnSync(gitProgram() ?? "git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
118
209
|
return r.status === 0 ? r.stdout.trim() : "";
|
|
119
210
|
}
|
|
211
|
+
/** The last commit and when it was made (the app: "changed 5 min ago"). */
|
|
212
|
+
function tip(root) {
|
|
213
|
+
const r = spawnSync(gitProgram() ?? "git", ["log", "-1", "--format=%H%x1f%cI"], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
214
|
+
const [head = "", at = ""] = r.status === 0 ? r.stdout.trim().split("\x1f") : [];
|
|
215
|
+
return { head, ...(at ? { at } : {}) };
|
|
216
|
+
}
|
|
217
|
+
/** What one commit saved, as lines: each change a command listed, else its subject. */
|
|
218
|
+
function saved(subject, body) {
|
|
219
|
+
const listed = body.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2));
|
|
220
|
+
return listed.length ? listed : [subject];
|
|
221
|
+
}
|
|
120
222
|
function subjects(root, from, to) {
|
|
121
|
-
const r = spawnSync(gitProgram() ?? "git", ["log", "--format=%s", from ? `${from}..${to}` : "-1", to], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
122
|
-
|
|
223
|
+
const r = spawnSync(gitProgram() ?? "git", ["log", "--format=%x1e%s%x1f%b", from ? `${from}..${to}` : "-1", to], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
224
|
+
if (r.status !== 0)
|
|
225
|
+
return [];
|
|
226
|
+
const commits = r.stdout.split("\x1e").slice(1).reverse();
|
|
227
|
+
return commits.flatMap((c) => {
|
|
228
|
+
const [subject = "", body = ""] = c.split("\x1f");
|
|
229
|
+
return saved(subject.trim(), body);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** At most so many commits /log gives (the app takes no more). */
|
|
233
|
+
const LOG_MAX = 500;
|
|
234
|
+
/**
|
|
235
|
+
* What the research saved, newest first: each commit, when, what, and the task
|
|
236
|
+
* it was saved for — the session whose operation log (data/ops/N….jsonl) or
|
|
237
|
+
* record the commit wrote, else the one session open at that time.
|
|
238
|
+
*/
|
|
239
|
+
/** The operations each commit logged (data/ops/*.jsonl only grow: its added lines), in the order they were made. */
|
|
240
|
+
function opsByCommit(root, range) {
|
|
241
|
+
const out = new Map();
|
|
242
|
+
const r = runGit(root, ["log", ...range, "--format=%x1e%H", "-p", "--unified=0", "--no-color", "--no-ext-diff", "--", "data/ops/"]);
|
|
243
|
+
if (r.status !== 0)
|
|
244
|
+
return out;
|
|
245
|
+
for (const c of r.stdout.split("\x1e").slice(1)) {
|
|
246
|
+
const [hash = "", ...rest] = c.split("\n");
|
|
247
|
+
const ops = [];
|
|
248
|
+
for (const line of rest)
|
|
249
|
+
if (line.startsWith("+{"))
|
|
250
|
+
try {
|
|
251
|
+
ops.push(JSON.parse(line.slice(1)));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// a line cut short: its commit is said by its subject
|
|
255
|
+
}
|
|
256
|
+
out.set(hash.trim(), ops.sort((a, b) => a.at.localeCompare(b.at)));
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
function history(root, tree, range = [`-n${LOG_MAX}`]) {
|
|
261
|
+
const r = runGit(root, ["log", ...range, "--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x1f", "--name-only"]);
|
|
262
|
+
if (r.status !== 0)
|
|
263
|
+
return [];
|
|
264
|
+
const ops = opsByCommit(root, range);
|
|
265
|
+
const sessions = new Map(tree.list("session").map((s) => [s.id, s]));
|
|
266
|
+
const all = scopes(tree);
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const taskOf = (s) => {
|
|
269
|
+
if (!s?.task)
|
|
270
|
+
return {};
|
|
271
|
+
const task = tree.get(s.task);
|
|
272
|
+
return { task: task ? `${task.id} ${task.what}` : s.task };
|
|
273
|
+
};
|
|
274
|
+
return r.stdout
|
|
275
|
+
.split("\x1e")
|
|
276
|
+
.slice(1)
|
|
277
|
+
.map((c) => {
|
|
278
|
+
const [head = "", at = "", subject = "", body = "", files = ""] = c.split("\x1f");
|
|
279
|
+
const named = new Set();
|
|
280
|
+
for (const f of files.split("\n")) {
|
|
281
|
+
const m = /^data\/(?:ops\/(N\d+)\.jsonl|sessions\/(N\d+)\.json)$/.exec(f.trim());
|
|
282
|
+
if (m)
|
|
283
|
+
named.add((m[1] ?? m[2]));
|
|
284
|
+
}
|
|
285
|
+
const when = Date.parse(at);
|
|
286
|
+
const open = named.size ? [] : [...sessions.values()].filter((s) => Date.parse(s.started) <= when && when <= (s.ended ? Date.parse(s.ended) : now));
|
|
287
|
+
const s = named.size === 1 ? sessions.get([...named][0]) : open.length === 1 ? open[0] : undefined;
|
|
288
|
+
const lines = changeLines(tree, ops.get(head) ?? [], subject.trim(), tree.lang);
|
|
289
|
+
const research = s ? sessionResearch(tree, s, all) : undefined;
|
|
290
|
+
return { head, at, what: saved(subject.trim(), body), text: lines.map((l) => l.text), kinds: lines.map((l) => l.kind), ...taskOf(s), ...(research ? { research } : {}) };
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/** The direction a task belongs to, for the app's names of directions on tasks and its filter. */
|
|
294
|
+
function researchOf(tree, t, all) {
|
|
295
|
+
const research = directionOf(tree, t, all);
|
|
296
|
+
return research ? { research } : {};
|
|
297
|
+
}
|
|
298
|
+
/** The one person a task is about (its subject names exactly one), for the app's badge on that person. */
|
|
299
|
+
function personOf(t) {
|
|
300
|
+
const people = (t?.subject ?? []).filter((id) => /^P\d{1,7}$/.test(id));
|
|
301
|
+
return people.length === 1 ? { person: people[0] } : {};
|
|
302
|
+
}
|
|
303
|
+
/** Who works, as the user reads it: "Claude Code on its own", "Codex conversation" in the research language. */
|
|
304
|
+
function whoAtWork(label, lang) {
|
|
305
|
+
const run = /^(.+) on its own$/.exec(label);
|
|
306
|
+
if (run)
|
|
307
|
+
return phrase(lang, "who.run", { agent: run[1] });
|
|
308
|
+
const chat = /^(.+) conversation$/.exec(label);
|
|
309
|
+
return chat ? phrase(lang, "who.chat", { agent: chat[1] }) : label;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The person an agent works on, for the app to follow it in the tree: the first person its task is about (a marriage
|
|
313
|
+
* names both, the one it asks about first), else the last one its session wrote about.
|
|
314
|
+
*/
|
|
315
|
+
function workedOn(root, tree, s, task) {
|
|
316
|
+
const known = (id) => /^P\d{1,7}$/.test(id) && tree.get(id) !== undefined;
|
|
317
|
+
const first = (task?.subject ?? []).find(known);
|
|
318
|
+
if (first)
|
|
319
|
+
return { person: first };
|
|
320
|
+
const file = path.join(root, "data", "ops", `${s.id}.jsonl`);
|
|
321
|
+
let lines = [];
|
|
322
|
+
try {
|
|
323
|
+
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
329
|
+
let op;
|
|
330
|
+
try {
|
|
331
|
+
op = JSON.parse(lines[i]);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
continue; // empty, or a line being written
|
|
335
|
+
}
|
|
336
|
+
const person = (op.targets ?? []).findLast(known);
|
|
337
|
+
if (person)
|
|
338
|
+
return { person };
|
|
339
|
+
}
|
|
340
|
+
return {};
|
|
123
341
|
}
|
|
124
342
|
/** Who is at work now, on which task (an agent strom started, and its open session). */
|
|
125
|
-
function working(root, tree) {
|
|
343
|
+
function working(root, tree, all) {
|
|
126
344
|
const open = openSessions(tree);
|
|
127
345
|
return liveWorkers(root).map((w) => {
|
|
128
346
|
const s = open.find((x) => x.worker === w.id);
|
|
129
347
|
const task = s?.task ? tree.get(s.task) : undefined;
|
|
130
|
-
|
|
348
|
+
const research = s ? sessionResearch(tree, s, all) : undefined;
|
|
349
|
+
return {
|
|
350
|
+
who: whoAtWork(w.label, tree.lang),
|
|
351
|
+
since: w.since,
|
|
352
|
+
...(w.paused ? { paused: w.paused } : {}),
|
|
353
|
+
...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task } : {}), ...workedOn(root, tree, s, task) } : {}),
|
|
354
|
+
...(research ? { research } : {}),
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/** The direction a session worked on: its own, else its task's. */
|
|
359
|
+
function sessionResearch(tree, s, all) {
|
|
360
|
+
if (s.research)
|
|
361
|
+
return s.research;
|
|
362
|
+
const task = s.task ? tree.get(s.task) : undefined;
|
|
363
|
+
return task ? directionOf(tree, task, all) : undefined;
|
|
364
|
+
}
|
|
365
|
+
/** At most so many characters of a reason or a summary the app is sent. */
|
|
366
|
+
const SHORT = 140;
|
|
367
|
+
const short = (text) => (text.length > SHORT ? `${text.slice(0, SHORT - 1).trimEnd()}…` : text);
|
|
368
|
+
/** Generations the app is told of a direction of ancestors: parents first. */
|
|
369
|
+
const DIRECTION_GENERATIONS = 12;
|
|
370
|
+
/**
|
|
371
|
+
* The directions of the research for the app: how each goes, its tasks (each task counted in the one
|
|
372
|
+
* direction it belongs to), whether an agent is at work on it, why it was paused or ended and since when,
|
|
373
|
+
* the ancestors known in each generation, and what its last session did.
|
|
374
|
+
*/
|
|
375
|
+
function directions(tree, all, queued, workingOn) {
|
|
376
|
+
const tasks = tree.list("task");
|
|
377
|
+
const of = new Map(tasks.map((t) => [t.id, directionOf(tree, t, all)]));
|
|
378
|
+
const ended = tree
|
|
379
|
+
.list("session")
|
|
380
|
+
.filter((s) => s.state !== "open" && s.ended && s.summary)
|
|
381
|
+
.sort((a, b) => b.ended.localeCompare(a.ended));
|
|
382
|
+
return all.map(({ research: r }) => {
|
|
383
|
+
// a paused or ended one: what would come back to the queue
|
|
384
|
+
const inQueue = r.state === "active" ? queued.filter((t) => of.get(t.id) === r.id).length : tasks.filter((t) => ["open", "doing"].includes(t.state) && of.get(t.id) === r.id).length;
|
|
385
|
+
const reason = r.state === "active" ? undefined : [...r.notes].reverse().find((n) => n.text.startsWith(`${r.state}: `))?.text.slice(r.state.length + 2).trim();
|
|
386
|
+
const last = ended.find((s) => sessionResearch(tree, s, all) === r.id);
|
|
387
|
+
let generations;
|
|
388
|
+
if (r.direction === "ancestors" && tree.get(r.focus)) {
|
|
389
|
+
const gens = ancestorGenerations(tree, r.focus, DIRECTION_GENERATIONS + 1);
|
|
390
|
+
const deepest = Math.min(Math.max(2, ...gens.values()), DIRECTION_GENERATIONS + 1);
|
|
391
|
+
generations = [];
|
|
392
|
+
for (let g = 2; g <= deepest; g++)
|
|
393
|
+
generations.push([...gens.values()].filter((n) => n === g).length);
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
id: r.id,
|
|
397
|
+
name: r.name,
|
|
398
|
+
state: r.state,
|
|
399
|
+
direction: r.direction,
|
|
400
|
+
focus: r.focus,
|
|
401
|
+
tasks: inQueue,
|
|
402
|
+
waiting: tasks.filter((t) => t.state === "waiting" && of.get(t.id) === r.id).length,
|
|
403
|
+
working: workingOn.includes(r.id),
|
|
404
|
+
...(reason ? { reason: short(reason) } : {}),
|
|
405
|
+
since: r.stateSince ?? (r.state === "active" ? r.created : r.updated),
|
|
406
|
+
...(generations ? { generations } : {}),
|
|
407
|
+
...(last ? { last: { at: last.ended, text: short(last.summary.replace(/\s+/g, " ").trim()) } } : {}),
|
|
408
|
+
};
|
|
131
409
|
});
|
|
132
410
|
}
|
|
133
411
|
/** How long what the system says of the links is taken as true (asking it costs a program started). */
|
|
@@ -143,11 +421,10 @@ function links(env) {
|
|
|
143
421
|
const QUEUE_NEXT = 14;
|
|
144
422
|
const QUEUE_PARKED = 6;
|
|
145
423
|
/** The agent's queue in its order, for the user (records by their names), and how many more there are. */
|
|
146
|
-
function queue(tree,
|
|
424
|
+
function queue(tree, next, all) {
|
|
147
425
|
const lang = tree.lang;
|
|
148
|
-
const next = rankTasks(tree, tree.list("task"), settings.strategy(tree.config)).map((r) => r.task);
|
|
149
426
|
const parked = tree.list("task").filter((t) => t.state === "parked");
|
|
150
|
-
const item = (state) => (t) => ({ id: t.id, text: humanTask(tree, t.what, lang), state });
|
|
427
|
+
const item = (state) => (t) => ({ id: t.id, text: humanTask(tree, t.what, lang), state, ...personOf(t), ...researchOf(tree, t, all) });
|
|
151
428
|
const shown = [...next.slice(0, QUEUE_NEXT).map(item("next")), ...parked.slice(0, QUEUE_PARKED).map(item("parked"))];
|
|
152
429
|
return { queue: shown, queueMore: next.length + parked.length - shown.length };
|
|
153
430
|
}
|
|
@@ -167,19 +444,24 @@ function status(root, env) {
|
|
|
167
444
|
const waiting = tree.list("task").filter((t) => t.state === "waiting");
|
|
168
445
|
const newer = knownNewerVersion(settings, env);
|
|
169
446
|
const intake = lastIntake(tree);
|
|
447
|
+
const last = tip(root);
|
|
448
|
+
const all = scopes(tree);
|
|
449
|
+
const next = rankTasks(tree, tree.list("task"), settings.strategy(tree.config)).map((r) => r.task);
|
|
450
|
+
const atWork = working(root, tree, all);
|
|
170
451
|
return {
|
|
171
452
|
strom: VERSION,
|
|
172
453
|
tree: { id: tree.config.id, name: tree.config.name, lang: tree.lang },
|
|
173
|
-
head: head
|
|
454
|
+
head: last.head,
|
|
455
|
+
...(last.at ? { headAt: last.at } : {}),
|
|
174
456
|
persons: tree.count("person"),
|
|
175
457
|
families: tree.count("family"),
|
|
176
|
-
researches: tree.
|
|
177
|
-
working:
|
|
458
|
+
researches: directions(tree, all, next, atWork.filter((w) => !w.paused).map((w) => w.research)),
|
|
459
|
+
working: atWork,
|
|
178
460
|
open: openSessions(tree).map((s) => ({ id: s.id, task: s.task, started: s.started })),
|
|
179
461
|
// since when it waits: its last change is the one that made it wait
|
|
180
|
-
waiting: waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "", at: t.updated })),
|
|
462
|
+
waiting: waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "", at: t.updated, ...personOf(t), ...researchOf(tree, t, all) })),
|
|
181
463
|
links: links(env),
|
|
182
|
-
...queue(tree,
|
|
464
|
+
...queue(tree, next, all),
|
|
183
465
|
...(newer ? { update: { version: newer } } : {}),
|
|
184
466
|
spend: { ...monthSpend(tree, new Date().toISOString().slice(0, 7)), currency: "USD" },
|
|
185
467
|
...(intake ? { lastIntake: intake } : {}),
|
|
@@ -187,14 +469,40 @@ function status(root, env) {
|
|
|
187
469
|
}
|
|
188
470
|
/** Run the bridge of the tree here, until idle or stopped. */
|
|
189
471
|
export function serveLive(root, env) {
|
|
190
|
-
|
|
472
|
+
// the address of the last bridge, so the app that followed it finds this one
|
|
473
|
+
const last = readLast(root);
|
|
474
|
+
const token = last?.token ?? crypto.randomBytes(16).toString("hex");
|
|
191
475
|
const idleMs = Number(env.STROM_LIVE_IDLE_MS ?? 2 * 60 * 60_000);
|
|
192
476
|
const pollMs = Number(env.STROM_LIVE_POLL_MS ?? 2000);
|
|
193
477
|
const streams = new Set();
|
|
194
|
-
let
|
|
478
|
+
let lastAsked = Date.now();
|
|
195
479
|
let ged;
|
|
196
|
-
|
|
197
|
-
|
|
480
|
+
let log;
|
|
481
|
+
/** A request that failed: said to the app, written into the log — the bridge goes on. */
|
|
482
|
+
const failed = (req, res, e) => {
|
|
483
|
+
noteLive(root, `${req.method} ${String(req.url ?? "").replace(token, "…")} failed: ${errorText(e)}`);
|
|
484
|
+
try {
|
|
485
|
+
// an answer begun (the events) is only ended
|
|
486
|
+
if (res.headersSent)
|
|
487
|
+
res.end();
|
|
488
|
+
else
|
|
489
|
+
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify({ error: e?.message ?? String(e) }));
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
res.destroy();
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const safely = (req, res, fn) => {
|
|
496
|
+
try {
|
|
497
|
+
fn();
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
failed(req, res, e);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
const server = http.createServer((req, res) => safely(req, res, () => answer(req, res)));
|
|
504
|
+
const answer = (req, res) => {
|
|
505
|
+
lastAsked = Date.now();
|
|
198
506
|
const origin = allowedOrigin(req.headers.origin);
|
|
199
507
|
if (origin) {
|
|
200
508
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
@@ -233,10 +541,19 @@ export function serveLive(root, env) {
|
|
|
233
541
|
const mark = pendingAdoption(root);
|
|
234
542
|
const tree = Tree.open(root, env);
|
|
235
543
|
const body = mark ? { token: mark, name: tree.config.name, tree: tree.config.id } : { error: "this research waits for no tree" };
|
|
236
|
-
|
|
544
|
+
const text = JSON.stringify(body);
|
|
545
|
+
res.writeHead(mark ? 200 : 404, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(text);
|
|
237
546
|
}
|
|
238
547
|
else if (what === "status") {
|
|
239
|
-
|
|
548
|
+
// what is answered is made first: a read that fails can still answer 500
|
|
549
|
+
const text = JSON.stringify(status(root, env));
|
|
550
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(text);
|
|
551
|
+
}
|
|
552
|
+
else if (what === "log") {
|
|
553
|
+
const h = head(root);
|
|
554
|
+
if (!log || log.head !== h)
|
|
555
|
+
log = { head: h, text: JSON.stringify({ entries: history(root, Tree.open(root, env)) }) };
|
|
556
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(log.text);
|
|
240
557
|
}
|
|
241
558
|
else if (what === "tree.ged" || what === "images.ged") {
|
|
242
559
|
const h = head(root);
|
|
@@ -247,28 +564,32 @@ export function serveLive(root, env) {
|
|
|
247
564
|
const images = set ? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, budgetMs: LIVE_IMAGES_MS }) : undefined;
|
|
248
565
|
// an app that opens strom-research:// links: each excerpt's mark, and the links this computer takes
|
|
249
566
|
const opens = appOpensLinks(new Settings(env, {}));
|
|
567
|
+
// …and where the tree ends, for an app that shows it
|
|
568
|
+
const edges = appShowsEdges(new Settings(env, {}));
|
|
250
569
|
const offered = opens ? links(env) : [];
|
|
251
570
|
ged = {
|
|
252
571
|
head: h,
|
|
253
572
|
links: offered.join(" "),
|
|
254
|
-
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(offered.length ? { links: offered } : {}) }).text,
|
|
573
|
+
text: exportGedcom(tree, { for: "strom", ...(h ? { head: h } : {}), ...(images ? { excerpts: images.of } : {}), ...(opens ? { clips: true, research: true } : {}), ...(edges ? { edges } : {}), ...(offered.length ? { links: offered } : {}) }).text,
|
|
255
574
|
};
|
|
256
575
|
}
|
|
257
576
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
|
|
258
577
|
}
|
|
259
578
|
else if (what === "events") {
|
|
579
|
+
const hello = JSON.stringify(status(root, env));
|
|
260
580
|
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
261
|
-
res.write(`event: hello\ndata: ${
|
|
581
|
+
res.write(`event: hello\ndata: ${hello}\n\n`);
|
|
262
582
|
streams.add(res);
|
|
263
583
|
req.on("close", () => streams.delete(res));
|
|
584
|
+
res.on("error", () => streams.delete(res));
|
|
264
585
|
}
|
|
265
586
|
else
|
|
266
587
|
res.writeHead(404).end();
|
|
267
588
|
}
|
|
268
589
|
catch (e) {
|
|
269
|
-
|
|
590
|
+
failed(req, res, e);
|
|
270
591
|
}
|
|
271
|
-
}
|
|
592
|
+
};
|
|
272
593
|
/** The user's tree from the app: only from its pages, only so large; the answer in the research's language. */
|
|
273
594
|
const receive = (req, res, origin, as = "sync") => {
|
|
274
595
|
const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
|
|
@@ -294,7 +615,8 @@ export function serveLive(root, env) {
|
|
|
294
615
|
else
|
|
295
616
|
chunks.push(c);
|
|
296
617
|
});
|
|
297
|
-
req.on("
|
|
618
|
+
req.on("error", (e) => noteLive(root, `a tree from the app did not come whole: ${errorText(e)}`));
|
|
619
|
+
req.on("end", () => safely(req, res, () => {
|
|
298
620
|
if (over) {
|
|
299
621
|
reply(413, { error: ui(lang(), "ui.sync.bridge.large") });
|
|
300
622
|
return;
|
|
@@ -318,7 +640,7 @@ export function serveLive(root, env) {
|
|
|
318
640
|
const key = /another research/.test(m) ? "ui.sync.bridge.other" : /does not look like/.test(m) ? "ui.sync.bridge.foreign" : /empty|no people|not a GEDCOM/.test(m) ? "ui.sync.bridge.empty" : undefined;
|
|
319
641
|
reply(400, { error: key ? ui(lang(), key) : m });
|
|
320
642
|
}
|
|
321
|
-
});
|
|
643
|
+
}));
|
|
322
644
|
};
|
|
323
645
|
/** The app sends nothing (unchanged, the user said no, no tree of this research): strom sync --app stops waiting. */
|
|
324
646
|
const nothing = (req, res, origin) => {
|
|
@@ -332,7 +654,7 @@ export function serveLive(root, env) {
|
|
|
332
654
|
if (body.length < 4096)
|
|
333
655
|
body += c.toString("utf8");
|
|
334
656
|
});
|
|
335
|
-
req.on("end", () => {
|
|
657
|
+
req.on("end", () => safely(req, res, () => {
|
|
336
658
|
let reason = "cancelled";
|
|
337
659
|
try {
|
|
338
660
|
reason = String(JSON.parse(body).reason ?? reason);
|
|
@@ -341,45 +663,95 @@ export function serveLive(root, env) {
|
|
|
341
663
|
reason = body.trim() || reason;
|
|
342
664
|
}
|
|
343
665
|
reply(200, { ok: true, reason: noteNothingSent(root, reason) });
|
|
344
|
-
});
|
|
666
|
+
}));
|
|
345
667
|
};
|
|
668
|
+
// What breaks where nobody catches it is written down; the bridge only reads, so it goes on.
|
|
669
|
+
process.on("uncaughtException", (e) => noteLive(root, `uncaught: ${errorText(e)}`));
|
|
670
|
+
process.on("unhandledRejection", (e) => noteLive(root, `unhandled: ${errorText(e)}`));
|
|
346
671
|
return new Promise((resolve) => {
|
|
347
|
-
|
|
672
|
+
let up = false;
|
|
673
|
+
let wanted = last?.port ?? 0;
|
|
674
|
+
server.on("error", (e) => {
|
|
675
|
+
if (!up && wanted && e.code === "EADDRINUSE") {
|
|
676
|
+
// its old port is taken meanwhile: another one (the app following the old address stops following)
|
|
677
|
+
noteLive(root, `port ${wanted} is taken: another one`);
|
|
678
|
+
wanted = 0;
|
|
679
|
+
server.listen(0, "127.0.0.1");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
noteLive(root, `the server: ${errorText(e)}`);
|
|
683
|
+
if (!up)
|
|
684
|
+
resolve();
|
|
685
|
+
});
|
|
686
|
+
server.listen(wanted, "127.0.0.1");
|
|
687
|
+
server.once("listening", () => {
|
|
688
|
+
up = true;
|
|
348
689
|
const port = server.address().port;
|
|
349
690
|
const info = { port, token, pid: process.pid, url: `http://127.0.0.1:${port}/${token}`, started: new Date().toISOString(), version: VERSION };
|
|
350
691
|
fs.mkdirSync(path.dirname(liveFile(root)), { recursive: true });
|
|
351
692
|
fs.writeFileSync(liveFile(root), JSON.stringify(info, null, 2));
|
|
693
|
+
writeLast(root, { port, token, pid: process.pid, started: info.started });
|
|
694
|
+
noteLive(root, `started: strom ${VERSION}, port ${port}, ${last && last.port === port ? "the address of the last bridge" : last ? "the token of the last bridge, another port" : "a new address"}`);
|
|
695
|
+
// A read that fails (another strom writing that moment) is tried again at the next tick; written down once.
|
|
696
|
+
let trouble;
|
|
697
|
+
const safe = (what, fn) => {
|
|
698
|
+
try {
|
|
699
|
+
const got = fn();
|
|
700
|
+
if (trouble)
|
|
701
|
+
noteLive(root, `works again (${trouble.times}× failed)`);
|
|
702
|
+
trouble = undefined;
|
|
703
|
+
return got;
|
|
704
|
+
}
|
|
705
|
+
catch (e) {
|
|
706
|
+
const text = errorText(e);
|
|
707
|
+
if (trouble?.text === text)
|
|
708
|
+
trouble.times++;
|
|
709
|
+
else {
|
|
710
|
+
trouble = { text, times: 1 };
|
|
711
|
+
noteLive(root, `${what} failed (tried again): ${text}`);
|
|
712
|
+
}
|
|
713
|
+
return undefined;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
352
716
|
// What changed: strom commits every change, so a new commit is news.
|
|
353
|
-
let seen = head(root);
|
|
354
|
-
|
|
717
|
+
let seen = safe("the start", () => head(root)) ?? "";
|
|
718
|
+
const atWork = () => {
|
|
719
|
+
const tree = Tree.open(root, env);
|
|
720
|
+
return working(root, tree, scopes(tree));
|
|
721
|
+
};
|
|
722
|
+
let workers = safe("the start", () => JSON.stringify(atWork())) ?? "";
|
|
355
723
|
const send = (event, data) => {
|
|
356
724
|
for (const s of streams)
|
|
357
725
|
s.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
358
726
|
};
|
|
359
727
|
const timer = setInterval(() => {
|
|
360
728
|
if (streams.size)
|
|
361
|
-
|
|
362
|
-
if (Date.now() -
|
|
363
|
-
return stop();
|
|
729
|
+
lastAsked = Date.now();
|
|
730
|
+
if (Date.now() - lastAsked > idleMs)
|
|
731
|
+
return stop("idle: nobody asked for a while");
|
|
364
732
|
if (!streams.size)
|
|
365
733
|
return;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
734
|
+
safe("a tick", () => {
|
|
735
|
+
const { head: h, at } = tip(root);
|
|
736
|
+
if (h && h !== seen) {
|
|
737
|
+
// when the commit was made: the same as in /log, so the app knows it has it; each new commit as /log gives it (its task)
|
|
738
|
+
const entries = history(root, Tree.open(root, env), seen ? [`-n${LOG_MAX}`, `${seen}..${h}`] : ["-1", h]);
|
|
739
|
+
send("change", { head: h, what: subjects(root, seen, h), at: at ?? new Date().toISOString(), entries });
|
|
740
|
+
seen = h;
|
|
741
|
+
}
|
|
742
|
+
const now = atWork();
|
|
743
|
+
const w = JSON.stringify(now);
|
|
744
|
+
if (w !== workers) {
|
|
745
|
+
workers = w;
|
|
746
|
+
send("working", now);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
377
749
|
}, pollMs);
|
|
378
750
|
const keepAlive = setInterval(() => {
|
|
379
751
|
for (const s of streams)
|
|
380
752
|
s.write(": still here\n\n");
|
|
381
753
|
}, 20_000);
|
|
382
|
-
const stop = () => {
|
|
754
|
+
const stop = (why) => {
|
|
383
755
|
clearInterval(timer);
|
|
384
756
|
clearInterval(keepAlive);
|
|
385
757
|
for (const s of streams)
|
|
@@ -393,10 +765,14 @@ export function serveLive(root, env) {
|
|
|
393
765
|
catch {
|
|
394
766
|
// gone already
|
|
395
767
|
}
|
|
768
|
+
const was = readLast(root);
|
|
769
|
+
if (was?.pid === process.pid && !was.ended)
|
|
770
|
+
writeLast(root, { ...was, ended: { at: new Date().toISOString(), reason: why } });
|
|
771
|
+
noteLive(root, `ended: ${why}`);
|
|
396
772
|
resolve();
|
|
397
773
|
};
|
|
398
|
-
process.once("SIGTERM", stop);
|
|
399
|
-
process.once("SIGINT", stop);
|
|
774
|
+
process.once("SIGTERM", () => stop("SIGTERM"));
|
|
775
|
+
process.once("SIGINT", () => stop("SIGINT"));
|
|
400
776
|
});
|
|
401
777
|
});
|
|
402
778
|
}
|