strom-research 1.7.0 → 1.8.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 (42) 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/narrate.md +5 -1
  5. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  6. package/dist/agents/files.js +6 -4
  7. package/dist/cli/main.js +21 -3
  8. package/dist/cli/menu-links.js +35 -1
  9. package/dist/cli/menu-research.js +41 -2
  10. package/dist/cli/menu-settings.js +17 -1
  11. package/dist/cli/menu.js +19 -8
  12. package/dist/cli/ui.js +57 -1
  13. package/dist/commands/guide.js +6 -1
  14. package/dist/commands/links.js +39 -3
  15. package/dist/commands/pack.js +21 -18
  16. package/dist/commands/research.js +42 -1
  17. package/dist/commands/session.js +79 -9
  18. package/dist/commands/setup.js +3 -0
  19. package/dist/commands/start.js +6 -3
  20. package/dist/commands/story.js +47 -6
  21. package/dist/commands/tasks.js +15 -6
  22. package/dist/commands/trees.js +82 -2
  23. package/dist/core/changelog.js +223 -0
  24. package/dist/core/clock.js +20 -0
  25. package/dist/core/config.js +2 -0
  26. package/dist/core/connector.js +25 -1
  27. package/dist/core/directions.js +41 -0
  28. package/dist/core/links.js +17 -4
  29. package/dist/core/live.js +393 -50
  30. package/dist/core/pack.js +31 -0
  31. package/dist/core/phrases.js +61 -0
  32. package/dist/core/queue.js +7 -2
  33. package/dist/core/relocate.js +4 -4
  34. package/dist/core/session.js +8 -2
  35. package/dist/core/trash.js +72 -0
  36. package/dist/core/tree.js +1 -1
  37. package/dist/core/workers.js +28 -3
  38. package/dist/runners/claude.js +2 -1
  39. package/dist/runners/jsonl.js +2 -2
  40. package/dist/runners/runner.js +16 -3
  41. package/dist/runners/script.js +2 -1
  42. 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,7 +48,7 @@ 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";
51
+ import { gitProgram, runGit } from "./git.js";
38
52
  import { appOpensLinks, isStromAppOrigin } from "./stromapp.js";
39
53
  import { Settings } from "./config.js";
40
54
  import { linkActions, linkHandlerState } from "./links.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,173 @@ 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;
123
310
  }
124
311
  /** Who is at work now, on which task (an agent strom started, and its open session). */
125
- function working(root, tree) {
312
+ function working(root, tree, all) {
126
313
  const open = openSessions(tree);
127
314
  return liveWorkers(root).map((w) => {
128
315
  const s = open.find((x) => x.worker === w.id);
129
316
  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 } : {}) } : {}) };
317
+ const research = s ? sessionResearch(tree, s, all) : undefined;
318
+ return {
319
+ who: whoAtWork(w.label, tree.lang),
320
+ since: w.since,
321
+ ...(w.paused ? { paused: w.paused } : {}),
322
+ ...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task, ...personOf(task) } : {}) } : {}),
323
+ ...(research ? { research } : {}),
324
+ };
325
+ });
326
+ }
327
+ /** The direction a session worked on: its own, else its task's. */
328
+ function sessionResearch(tree, s, all) {
329
+ if (s.research)
330
+ return s.research;
331
+ const task = s.task ? tree.get(s.task) : undefined;
332
+ return task ? directionOf(tree, task, all) : undefined;
333
+ }
334
+ /** At most so many characters of a reason or a summary the app is sent. */
335
+ const SHORT = 140;
336
+ const short = (text) => (text.length > SHORT ? `${text.slice(0, SHORT - 1).trimEnd()}…` : text);
337
+ /** Generations the app is told of a direction of ancestors: parents first. */
338
+ const DIRECTION_GENERATIONS = 12;
339
+ /**
340
+ * The directions of the research for the app: how each goes, its tasks (each task counted in the one
341
+ * direction it belongs to), whether an agent is at work on it, why it was paused or ended and since when,
342
+ * the ancestors known in each generation, and what its last session did.
343
+ */
344
+ function directions(tree, all, queued, workingOn) {
345
+ const tasks = tree.list("task");
346
+ const of = new Map(tasks.map((t) => [t.id, directionOf(tree, t, all)]));
347
+ const ended = tree
348
+ .list("session")
349
+ .filter((s) => s.state !== "open" && s.ended && s.summary)
350
+ .sort((a, b) => b.ended.localeCompare(a.ended));
351
+ return all.map(({ research: r }) => {
352
+ // a paused or ended one: what would come back to the queue
353
+ 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;
354
+ const reason = r.state === "active" ? undefined : [...r.notes].reverse().find((n) => n.text.startsWith(`${r.state}: `))?.text.slice(r.state.length + 2).trim();
355
+ const last = ended.find((s) => sessionResearch(tree, s, all) === r.id);
356
+ let generations;
357
+ if (r.direction === "ancestors" && tree.get(r.focus)) {
358
+ const gens = ancestorGenerations(tree, r.focus, DIRECTION_GENERATIONS + 1);
359
+ const deepest = Math.min(Math.max(2, ...gens.values()), DIRECTION_GENERATIONS + 1);
360
+ generations = [];
361
+ for (let g = 2; g <= deepest; g++)
362
+ generations.push([...gens.values()].filter((n) => n === g).length);
363
+ }
364
+ return {
365
+ id: r.id,
366
+ name: r.name,
367
+ state: r.state,
368
+ direction: r.direction,
369
+ focus: r.focus,
370
+ tasks: inQueue,
371
+ waiting: tasks.filter((t) => t.state === "waiting" && of.get(t.id) === r.id).length,
372
+ working: workingOn.includes(r.id),
373
+ ...(reason ? { reason: short(reason) } : {}),
374
+ since: r.stateSince ?? (r.state === "active" ? r.created : r.updated),
375
+ ...(generations ? { generations } : {}),
376
+ ...(last ? { last: { at: last.ended, text: short(last.summary.replace(/\s+/g, " ").trim()) } } : {}),
377
+ };
131
378
  });
132
379
  }
133
380
  /** How long what the system says of the links is taken as true (asking it costs a program started). */
@@ -143,11 +390,10 @@ function links(env) {
143
390
  const QUEUE_NEXT = 14;
144
391
  const QUEUE_PARKED = 6;
145
392
  /** 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) {
393
+ function queue(tree, next, all) {
147
394
  const lang = tree.lang;
148
- const next = rankTasks(tree, tree.list("task"), settings.strategy(tree.config)).map((r) => r.task);
149
395
  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 });
396
+ const item = (state) => (t) => ({ id: t.id, text: humanTask(tree, t.what, lang), state, ...personOf(t), ...researchOf(tree, t, all) });
151
397
  const shown = [...next.slice(0, QUEUE_NEXT).map(item("next")), ...parked.slice(0, QUEUE_PARKED).map(item("parked"))];
152
398
  return { queue: shown, queueMore: next.length + parked.length - shown.length };
153
399
  }
@@ -167,19 +413,24 @@ function status(root, env) {
167
413
  const waiting = tree.list("task").filter((t) => t.state === "waiting");
168
414
  const newer = knownNewerVersion(settings, env);
169
415
  const intake = lastIntake(tree);
416
+ const last = tip(root);
417
+ const all = scopes(tree);
418
+ const next = rankTasks(tree, tree.list("task"), settings.strategy(tree.config)).map((r) => r.task);
419
+ const atWork = working(root, tree, all);
170
420
  return {
171
421
  strom: VERSION,
172
422
  tree: { id: tree.config.id, name: tree.config.name, lang: tree.lang },
173
- head: head(root),
423
+ head: last.head,
424
+ ...(last.at ? { headAt: last.at } : {}),
174
425
  persons: tree.count("person"),
175
426
  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),
427
+ researches: directions(tree, all, next, atWork.filter((w) => !w.paused).map((w) => w.research)),
428
+ working: atWork,
178
429
  open: openSessions(tree).map((s) => ({ id: s.id, task: s.task, started: s.started })),
179
430
  // 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 })),
431
+ waiting: waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "", at: t.updated, ...personOf(t), ...researchOf(tree, t, all) })),
181
432
  links: links(env),
182
- ...queue(tree, settings),
433
+ ...queue(tree, next, all),
183
434
  ...(newer ? { update: { version: newer } } : {}),
184
435
  spend: { ...monthSpend(tree, new Date().toISOString().slice(0, 7)), currency: "USD" },
185
436
  ...(intake ? { lastIntake: intake } : {}),
@@ -187,14 +438,40 @@ function status(root, env) {
187
438
  }
188
439
  /** Run the bridge of the tree here, until idle or stopped. */
189
440
  export function serveLive(root, env) {
190
- const token = crypto.randomBytes(16).toString("hex");
441
+ // the address of the last bridge, so the app that followed it finds this one
442
+ const last = readLast(root);
443
+ const token = last?.token ?? crypto.randomBytes(16).toString("hex");
191
444
  const idleMs = Number(env.STROM_LIVE_IDLE_MS ?? 2 * 60 * 60_000);
192
445
  const pollMs = Number(env.STROM_LIVE_POLL_MS ?? 2000);
193
446
  const streams = new Set();
194
- let last = Date.now();
447
+ let lastAsked = Date.now();
195
448
  let ged;
196
- const server = http.createServer((req, res) => {
197
- last = Date.now();
449
+ let log;
450
+ /** A request that failed: said to the app, written into the log — the bridge goes on. */
451
+ const failed = (req, res, e) => {
452
+ noteLive(root, `${req.method} ${String(req.url ?? "").replace(token, "…")} failed: ${errorText(e)}`);
453
+ try {
454
+ // an answer begun (the events) is only ended
455
+ if (res.headersSent)
456
+ res.end();
457
+ else
458
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify({ error: e?.message ?? String(e) }));
459
+ }
460
+ catch {
461
+ res.destroy();
462
+ }
463
+ };
464
+ const safely = (req, res, fn) => {
465
+ try {
466
+ fn();
467
+ }
468
+ catch (e) {
469
+ failed(req, res, e);
470
+ }
471
+ };
472
+ const server = http.createServer((req, res) => safely(req, res, () => answer(req, res)));
473
+ const answer = (req, res) => {
474
+ lastAsked = Date.now();
198
475
  const origin = allowedOrigin(req.headers.origin);
199
476
  if (origin) {
200
477
  res.setHeader("Access-Control-Allow-Origin", origin);
@@ -233,10 +510,19 @@ export function serveLive(root, env) {
233
510
  const mark = pendingAdoption(root);
234
511
  const tree = Tree.open(root, env);
235
512
  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));
513
+ const text = JSON.stringify(body);
514
+ res.writeHead(mark ? 200 : 404, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(text);
237
515
  }
238
516
  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)));
517
+ // what is answered is made first: a read that fails can still answer 500
518
+ const text = JSON.stringify(status(root, env));
519
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(text);
520
+ }
521
+ else if (what === "log") {
522
+ const h = head(root);
523
+ if (!log || log.head !== h)
524
+ log = { head: h, text: JSON.stringify({ entries: history(root, Tree.open(root, env)) }) };
525
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(log.text);
240
526
  }
241
527
  else if (what === "tree.ged" || what === "images.ged") {
242
528
  const h = head(root);
@@ -257,18 +543,20 @@ export function serveLive(root, env) {
257
543
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
258
544
  }
259
545
  else if (what === "events") {
546
+ const hello = JSON.stringify(status(root, env));
260
547
  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`);
548
+ res.write(`event: hello\ndata: ${hello}\n\n`);
262
549
  streams.add(res);
263
550
  req.on("close", () => streams.delete(res));
551
+ res.on("error", () => streams.delete(res));
264
552
  }
265
553
  else
266
554
  res.writeHead(404).end();
267
555
  }
268
556
  catch (e) {
269
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }).end(e.message);
557
+ failed(req, res, e);
270
558
  }
271
- });
559
+ };
272
560
  /** The user's tree from the app: only from its pages, only so large; the answer in the research's language. */
273
561
  const receive = (req, res, origin, as = "sync") => {
274
562
  const reply = (code, body) => res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(body));
@@ -294,7 +582,8 @@ export function serveLive(root, env) {
294
582
  else
295
583
  chunks.push(c);
296
584
  });
297
- req.on("end", () => {
585
+ req.on("error", (e) => noteLive(root, `a tree from the app did not come whole: ${errorText(e)}`));
586
+ req.on("end", () => safely(req, res, () => {
298
587
  if (over) {
299
588
  reply(413, { error: ui(lang(), "ui.sync.bridge.large") });
300
589
  return;
@@ -318,7 +607,7 @@ export function serveLive(root, env) {
318
607
  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
608
  reply(400, { error: key ? ui(lang(), key) : m });
320
609
  }
321
- });
610
+ }));
322
611
  };
323
612
  /** The app sends nothing (unchanged, the user said no, no tree of this research): strom sync --app stops waiting. */
324
613
  const nothing = (req, res, origin) => {
@@ -332,7 +621,7 @@ export function serveLive(root, env) {
332
621
  if (body.length < 4096)
333
622
  body += c.toString("utf8");
334
623
  });
335
- req.on("end", () => {
624
+ req.on("end", () => safely(req, res, () => {
336
625
  let reason = "cancelled";
337
626
  try {
338
627
  reason = String(JSON.parse(body).reason ?? reason);
@@ -341,45 +630,95 @@ export function serveLive(root, env) {
341
630
  reason = body.trim() || reason;
342
631
  }
343
632
  reply(200, { ok: true, reason: noteNothingSent(root, reason) });
344
- });
633
+ }));
345
634
  };
635
+ // What breaks where nobody catches it is written down; the bridge only reads, so it goes on.
636
+ process.on("uncaughtException", (e) => noteLive(root, `uncaught: ${errorText(e)}`));
637
+ process.on("unhandledRejection", (e) => noteLive(root, `unhandled: ${errorText(e)}`));
346
638
  return new Promise((resolve) => {
347
- server.listen(0, "127.0.0.1", () => {
639
+ let up = false;
640
+ let wanted = last?.port ?? 0;
641
+ server.on("error", (e) => {
642
+ if (!up && wanted && e.code === "EADDRINUSE") {
643
+ // its old port is taken meanwhile: another one (the app following the old address stops following)
644
+ noteLive(root, `port ${wanted} is taken: another one`);
645
+ wanted = 0;
646
+ server.listen(0, "127.0.0.1");
647
+ return;
648
+ }
649
+ noteLive(root, `the server: ${errorText(e)}`);
650
+ if (!up)
651
+ resolve();
652
+ });
653
+ server.listen(wanted, "127.0.0.1");
654
+ server.once("listening", () => {
655
+ up = true;
348
656
  const port = server.address().port;
349
657
  const info = { port, token, pid: process.pid, url: `http://127.0.0.1:${port}/${token}`, started: new Date().toISOString(), version: VERSION };
350
658
  fs.mkdirSync(path.dirname(liveFile(root)), { recursive: true });
351
659
  fs.writeFileSync(liveFile(root), JSON.stringify(info, null, 2));
660
+ writeLast(root, { port, token, pid: process.pid, started: info.started });
661
+ 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"}`);
662
+ // A read that fails (another strom writing that moment) is tried again at the next tick; written down once.
663
+ let trouble;
664
+ const safe = (what, fn) => {
665
+ try {
666
+ const got = fn();
667
+ if (trouble)
668
+ noteLive(root, `works again (${trouble.times}× failed)`);
669
+ trouble = undefined;
670
+ return got;
671
+ }
672
+ catch (e) {
673
+ const text = errorText(e);
674
+ if (trouble?.text === text)
675
+ trouble.times++;
676
+ else {
677
+ trouble = { text, times: 1 };
678
+ noteLive(root, `${what} failed (tried again): ${text}`);
679
+ }
680
+ return undefined;
681
+ }
682
+ };
352
683
  // 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)));
684
+ let seen = safe("the start", () => head(root)) ?? "";
685
+ const atWork = () => {
686
+ const tree = Tree.open(root, env);
687
+ return working(root, tree, scopes(tree));
688
+ };
689
+ let workers = safe("the start", () => JSON.stringify(atWork())) ?? "";
355
690
  const send = (event, data) => {
356
691
  for (const s of streams)
357
692
  s.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
358
693
  };
359
694
  const timer = setInterval(() => {
360
695
  if (streams.size)
361
- last = Date.now();
362
- if (Date.now() - last > idleMs)
363
- return stop();
696
+ lastAsked = Date.now();
697
+ if (Date.now() - lastAsked > idleMs)
698
+ return stop("idle: nobody asked for a while");
364
699
  if (!streams.size)
365
700
  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
- }
701
+ safe("a tick", () => {
702
+ const { head: h, at } = tip(root);
703
+ if (h && h !== seen) {
704
+ // 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)
705
+ const entries = history(root, Tree.open(root, env), seen ? [`-n${LOG_MAX}`, `${seen}..${h}`] : ["-1", h]);
706
+ send("change", { head: h, what: subjects(root, seen, h), at: at ?? new Date().toISOString(), entries });
707
+ seen = h;
708
+ }
709
+ const now = atWork();
710
+ const w = JSON.stringify(now);
711
+ if (w !== workers) {
712
+ workers = w;
713
+ send("working", now);
714
+ }
715
+ });
377
716
  }, pollMs);
378
717
  const keepAlive = setInterval(() => {
379
718
  for (const s of streams)
380
719
  s.write(": still here\n\n");
381
720
  }, 20_000);
382
- const stop = () => {
721
+ const stop = (why) => {
383
722
  clearInterval(timer);
384
723
  clearInterval(keepAlive);
385
724
  for (const s of streams)
@@ -393,10 +732,14 @@ export function serveLive(root, env) {
393
732
  catch {
394
733
  // gone already
395
734
  }
735
+ const was = readLast(root);
736
+ if (was?.pid === process.pid && !was.ended)
737
+ writeLast(root, { ...was, ended: { at: new Date().toISOString(), reason: why } });
738
+ noteLive(root, `ended: ${why}`);
396
739
  resolve();
397
740
  };
398
- process.once("SIGTERM", stop);
399
- process.once("SIGINT", stop);
741
+ process.once("SIGTERM", () => stop("SIGTERM"));
742
+ process.once("SIGINT", () => stop("SIGINT"));
400
743
  });
401
744
  });
402
745
  }