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.
Files changed (50) hide show
  1. package/README.md +3 -0
  2. package/assets/lang/cs.json +117 -2
  3. package/assets/lang/de.json +117 -2
  4. package/assets/method/core.md +8 -0
  5. package/assets/method/narrate.md +5 -1
  6. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  7. package/dist/agents/files.js +6 -4
  8. package/dist/cli/main.js +21 -3
  9. package/dist/cli/menu-links.js +35 -1
  10. package/dist/cli/menu-research.js +41 -2
  11. package/dist/cli/menu-settings.js +17 -1
  12. package/dist/cli/menu.js +19 -8
  13. package/dist/cli/ui.js +57 -1
  14. package/dist/commands/guide.js +6 -1
  15. package/dist/commands/links.js +39 -3
  16. package/dist/commands/output.js +4 -2
  17. package/dist/commands/pack.js +21 -18
  18. package/dist/commands/research.js +42 -1
  19. package/dist/commands/session.js +210 -11
  20. package/dist/commands/setup.js +3 -0
  21. package/dist/commands/start.js +6 -3
  22. package/dist/commands/story.js +47 -6
  23. package/dist/commands/tasks.js +50 -12
  24. package/dist/commands/trees.js +82 -2
  25. package/dist/core/changelog.js +223 -0
  26. package/dist/core/clock.js +20 -0
  27. package/dist/core/config.js +2 -0
  28. package/dist/core/connector.js +25 -1
  29. package/dist/core/directions.js +41 -0
  30. package/dist/core/edge.js +296 -0
  31. package/dist/core/frontier.js +12 -4
  32. package/dist/core/kin.js +40 -1
  33. package/dist/core/links.js +17 -4
  34. package/dist/core/live.js +428 -52
  35. package/dist/core/pack.js +31 -0
  36. package/dist/core/phrases.js +61 -0
  37. package/dist/core/queue.js +15 -4
  38. package/dist/core/relocate.js +4 -4
  39. package/dist/core/session.js +8 -2
  40. package/dist/core/stromapp.js +22 -0
  41. package/dist/core/trash.js +72 -0
  42. package/dist/core/tree.js +1 -1
  43. package/dist/core/workers.js +28 -3
  44. package/dist/gedcom/export.js +105 -1
  45. package/dist/gedcom/validate.js +7 -1
  46. package/dist/runners/claude.js +2 -1
  47. package/dist/runners/jsonl.js +2 -2
  48. package/dist/runners/runner.js +16 -3
  49. package/dist/runners/script.js +2 -1
  50. 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
- return r.status === 0 ? r.stdout.trim().split("\n").filter(Boolean).reverse() : [];
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
- return { who: w.label, since: w.since, ...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task } : {}) } : {}) };
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, settings) {
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(root),
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.list("research").map((r) => ({ id: r.id, name: r.name, state: r.state })),
177
- working: working(root, tree),
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, settings),
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
- const token = crypto.randomBytes(16).toString("hex");
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 last = Date.now();
478
+ let lastAsked = Date.now();
195
479
  let ged;
196
- const server = http.createServer((req, res) => {
197
- last = Date.now();
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
- res.writeHead(mark ? 200 : 404, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
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
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(status(root, env)));
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: ${JSON.stringify(status(root, env))}\n\n`);
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
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }).end(e.message);
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("end", () => {
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
- server.listen(0, "127.0.0.1", () => {
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
- let workers = JSON.stringify(working(root, Tree.open(root, env)));
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
- last = Date.now();
362
- if (Date.now() - last > idleMs)
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
- const h = head(root);
367
- if (h && h !== seen) {
368
- send("change", { head: h, what: subjects(root, seen, h), at: new Date().toISOString() });
369
- seen = h;
370
- }
371
- const now = working(root, Tree.open(root, env));
372
- const w = JSON.stringify(now);
373
- if (w !== workers) {
374
- workers = w;
375
- send("working", now);
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
  }