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.
- package/README.md +3 -0
- package/assets/lang/cs.json +117 -2
- package/assets/lang/de.json +117 -2
- 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/pack.js +21 -18
- package/dist/commands/research.js +42 -1
- package/dist/commands/session.js +79 -9
- 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 +15 -6
- 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/links.js +17 -4
- package/dist/core/live.js +393 -50
- package/dist/core/pack.js +31 -0
- package/dist/core/phrases.js +61 -0
- package/dist/core/queue.js +7 -2
- package/dist/core/relocate.js +4 -4
- package/dist/core/session.js +8 -2
- package/dist/core/trash.js +72 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/workers.js +28 -3
- 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,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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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.
|
|
177
|
-
working:
|
|
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,
|
|
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
|
-
|
|
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
|
|
447
|
+
let lastAsked = Date.now();
|
|
195
448
|
let ged;
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
if (Date.now() -
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
}
|