strom-research 1.0.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 (130) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +142 -0
  3. package/assets/lang/cs.json +302 -0
  4. package/assets/lang/de.json +302 -0
  5. package/assets/method/core.md +43 -0
  6. package/assets/method/enrich.md +11 -0
  7. package/assets/method/intake.md +30 -0
  8. package/assets/method/link.md +28 -0
  9. package/assets/method/locate.md +28 -0
  10. package/assets/method/narrate.md +13 -0
  11. package/assets/method/reading.md +62 -0
  12. package/assets/method/recording.md +59 -0
  13. package/assets/method/request.md +10 -0
  14. package/assets/method/verify.md +17 -0
  15. package/assets/plugins/README.md +23 -0
  16. package/assets/plugins/connectors/DISCOVERY.md +159 -0
  17. package/assets/plugins/connectors/README.md +376 -0
  18. package/assets/plugins/connectors/sdk.ts +168 -0
  19. package/assets/plugins/connectors/template.ts +38 -0
  20. package/assets/plugins/gitignore +4 -0
  21. package/dist/agents/files.js +313 -0
  22. package/dist/agents/global.js +257 -0
  23. package/dist/agents/launch.js +36 -0
  24. package/dist/agents/profiles.js +95 -0
  25. package/dist/brief/brief.js +345 -0
  26. package/dist/cli/commit.js +44 -0
  27. package/dist/cli/context.js +311 -0
  28. package/dist/cli/execute.js +154 -0
  29. package/dist/cli/fixes.js +78 -0
  30. package/dist/cli/format.js +53 -0
  31. package/dist/cli/help.js +59 -0
  32. package/dist/cli/main.js +152 -0
  33. package/dist/cli/menu.js +212 -0
  34. package/dist/cli/registry.js +96 -0
  35. package/dist/cli/ui.js +266 -0
  36. package/dist/cli/wizard.js +142 -0
  37. package/dist/cli.js +14 -0
  38. package/dist/commands/analysis.js +622 -0
  39. package/dist/commands/batch.js +181 -0
  40. package/dist/commands/checks.js +153 -0
  41. package/dist/commands/connectors.js +1377 -0
  42. package/dist/commands/guide.js +160 -0
  43. package/dist/commands/index.js +19 -0
  44. package/dist/commands/intake.js +234 -0
  45. package/dist/commands/media.js +406 -0
  46. package/dist/commands/meta.js +195 -0
  47. package/dist/commands/output.js +117 -0
  48. package/dist/commands/people.js +664 -0
  49. package/dist/commands/read.js +199 -0
  50. package/dist/commands/research.js +139 -0
  51. package/dist/commands/session.js +605 -0
  52. package/dist/commands/setup.js +465 -0
  53. package/dist/commands/sources.js +634 -0
  54. package/dist/commands/start.js +383 -0
  55. package/dist/commands/story.js +75 -0
  56. package/dist/commands/tasks.js +436 -0
  57. package/dist/commands/trees.js +128 -0
  58. package/dist/core/actions.js +852 -0
  59. package/dist/core/age.js +95 -0
  60. package/dist/core/apps.js +74 -0
  61. package/dist/core/assets.js +34 -0
  62. package/dist/core/awake.js +33 -0
  63. package/dist/core/browser.js +281 -0
  64. package/dist/core/calibration.js +48 -0
  65. package/dist/core/check.js +112 -0
  66. package/dist/core/chromium.js +88 -0
  67. package/dist/core/config.js +348 -0
  68. package/dist/core/connector.js +811 -0
  69. package/dist/core/deps.js +73 -0
  70. package/dist/core/dialog.js +61 -0
  71. package/dist/core/errors.js +89 -0
  72. package/dist/core/evidence.js +58 -0
  73. package/dist/core/frontier.js +219 -0
  74. package/dist/core/gdate.js +77 -0
  75. package/dist/core/git.js +300 -0
  76. package/dist/core/guard.js +124 -0
  77. package/dist/core/http2.js +76 -0
  78. package/dist/core/import.js +541 -0
  79. package/dist/core/install.js +28 -0
  80. package/dist/core/integrity.js +219 -0
  81. package/dist/core/json.js +87 -0
  82. package/dist/core/lang.js +70 -0
  83. package/dist/core/live.js +244 -0
  84. package/dist/core/lock.js +112 -0
  85. package/dist/core/logins.js +67 -0
  86. package/dist/core/media.js +223 -0
  87. package/dist/core/model.js +101 -0
  88. package/dist/core/net.js +366 -0
  89. package/dist/core/open.js +29 -0
  90. package/dist/core/paths.js +84 -0
  91. package/dist/core/people.js +283 -0
  92. package/dist/core/phrases.js +85 -0
  93. package/dist/core/queue.js +113 -0
  94. package/dist/core/reader.js +76 -0
  95. package/dist/core/records.js +105 -0
  96. package/dist/core/roles.js +30 -0
  97. package/dist/core/schema.js +261 -0
  98. package/dist/core/seal.js +77 -0
  99. package/dist/core/self.js +40 -0
  100. package/dist/core/session.js +155 -0
  101. package/dist/core/shortcut.js +90 -0
  102. package/dist/core/stories.js +61 -0
  103. package/dist/core/stromapp.js +138 -0
  104. package/dist/core/text.js +104 -0
  105. package/dist/core/tree.js +507 -0
  106. package/dist/core/uninstall.js +128 -0
  107. package/dist/core/update.js +193 -0
  108. package/dist/core/validate.js +260 -0
  109. package/dist/core/views.js +164 -0
  110. package/dist/core/which.js +51 -0
  111. package/dist/core/workers.js +42 -0
  112. package/dist/gedcom/export.js +454 -0
  113. package/dist/gedcom/labels.js +103 -0
  114. package/dist/gedcom/lines.js +91 -0
  115. package/dist/gedcom/parse.js +53 -0
  116. package/dist/gedcom/validate.js +183 -0
  117. package/dist/image/image.js +223 -0
  118. package/dist/image/index.js +114 -0
  119. package/dist/image/jpeg-decode.js +552 -0
  120. package/dist/image/jpeg-encode.js +254 -0
  121. package/dist/image/png.js +241 -0
  122. package/dist/runners/antigravity.js +70 -0
  123. package/dist/runners/claude.js +179 -0
  124. package/dist/runners/codex.js +45 -0
  125. package/dist/runners/index.js +13 -0
  126. package/dist/runners/jsonl.js +86 -0
  127. package/dist/runners/opencode.js +50 -0
  128. package/dist/runners/runner.js +63 -0
  129. package/dist/runners/script.js +58 -0
  130. package/package.json +44 -0
@@ -0,0 +1,219 @@
1
+ // `strom verify` — was anything changed outside strom?
2
+ //
3
+ // Two levels, both built on the same facts:
4
+ //
5
+ // fast (before every write, in the commit gate, in orientation)
6
+ // 1. HEAD carries a valid commit seal → everything committed is trusted,
7
+ // because strom only commits behind its own gate.
8
+ // 2. Only what changed since HEAD is examined: new operations must continue
9
+ // the chain and be signed, every changed record must match the hash its
10
+ // operation recorded. Cost: two git calls when nothing changed.
11
+ //
12
+ // full (`strom verify`, `strom check`)
13
+ // Every operation of every log and every record file, plus the commit seal.
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import * as git from "./git.js";
17
+ import { RECORD_TYPES } from "./model.js";
18
+ import { fileSha, fingerprint, verifyCommitSeal, verifyOp } from "./seal.js";
19
+ import { StromError } from "./errors.js";
20
+ export function snapshot(tree) {
21
+ if (!git.hasHead(tree.root))
22
+ return { changes: [], head: new Map() };
23
+ const changes = git.changes(tree.root, ["data", "strom.json"]);
24
+ return { changes, head: git.showFiles(tree.root, "HEAD", changes.map((c) => c.path)) };
25
+ }
26
+ const RECORD_DIRS = new Set(Object.values(RECORD_TYPES).map((d) => `data/${d.dir}/`));
27
+ function isRecordPath(p) {
28
+ const dir = p.slice(0, p.lastIndexOf("/") + 1);
29
+ return RECORD_DIRS.has(dir) && p.endsWith(".json");
30
+ }
31
+ function parseOps(text, file, findings, startLine = 0) {
32
+ const out = [];
33
+ text.split("\n").forEach((line, i) => {
34
+ if (!line.trim())
35
+ return;
36
+ try {
37
+ out.push(JSON.parse(line));
38
+ }
39
+ catch {
40
+ findings.push({ level: "error", code: "ops-corrupt", file, message: `line ${startLine + i + 1} is not valid JSON` });
41
+ }
42
+ });
43
+ return out;
44
+ }
45
+ /** Check chain and signatures of a run of operations. */
46
+ function checkOps(tree, ops, prevSig, file, findings) {
47
+ const key = tree.key;
48
+ const fp = key ? fingerprint(key) : undefined;
49
+ let prev = prevSig;
50
+ let adopted = false;
51
+ for (const op of ops) {
52
+ if (op.prev !== prev)
53
+ findings.push({ level: "error", code: "chain-broken", file, message: `operation ${op.op} at ${op.at}: chain broken (log rewritten or edited)` });
54
+ prev = op.sig ?? "";
55
+ if (op.op === "seal.adopt")
56
+ adopted = true;
57
+ if (!key)
58
+ continue;
59
+ if (op.k === fp) {
60
+ if (!verifyOp(op, key))
61
+ findings.push({ level: "error", code: "forged-op", file, message: `operation ${op.op} at ${op.at} has an invalid signature (written outside strom)` });
62
+ }
63
+ else if (adopted) {
64
+ findings.push({ level: "error", code: "forged-op", file, message: `operation ${op.op} at ${op.at} is signed with an unknown key after the tree was adopted` });
65
+ }
66
+ }
67
+ }
68
+ function headSeal(tree, findings) {
69
+ const head = git.headInfo(tree.root);
70
+ if (!head)
71
+ return;
72
+ const key = tree.key;
73
+ if (!key) {
74
+ findings.push({
75
+ level: "warn",
76
+ code: "foreign-seal",
77
+ message: "this tree was sealed on another computer — reading works, writing needs the user's consent",
78
+ hint: "the user runs in their terminal: strom seal adopt",
79
+ });
80
+ return;
81
+ }
82
+ if (!verifyCommitSeal(key, head.tree, head.message))
83
+ findings.push({
84
+ level: "error",
85
+ code: "foreign-commit",
86
+ message: "the last commit was not made by strom (git used directly)",
87
+ hint: "strom repair (return to the last sealed state)",
88
+ });
89
+ }
90
+ function checkRecordFile(tree, rel, expected, untracked, findings, tampered) {
91
+ const id = path.basename(rel, ".json");
92
+ const abs = path.join(tree.root, rel);
93
+ if (!fs.existsSync(abs)) {
94
+ tampered.add(rel);
95
+ findings.push({ level: "error", code: "manual-delete", id, file: rel, message: "record was deleted outside strom", hint: "strom repair" });
96
+ return;
97
+ }
98
+ if (expected === undefined) {
99
+ // No operation wrote this content: a new file is unsealed, a changed one was edited by hand.
100
+ tampered.add(rel);
101
+ findings.push(untracked
102
+ ? { level: "error", code: "unsealed", id, file: rel, message: "record was created outside strom", hint: "strom repair" }
103
+ : { level: "error", code: "manual-edit", id, file: rel, message: "record was edited outside strom", hint: "strom repair" });
104
+ return;
105
+ }
106
+ if (fileSha(abs) !== expected) {
107
+ tampered.add(rel);
108
+ findings.push({ level: "error", code: "manual-edit", id, file: rel, message: "record was edited outside strom", hint: "strom repair" });
109
+ }
110
+ }
111
+ /** Fast check: HEAD seal + everything changed since HEAD. */
112
+ export function verifyFast(tree, snap = snapshot(tree)) {
113
+ const findings = [];
114
+ const tampered = new Set();
115
+ if (!git.hasHead(tree.root))
116
+ return { findings, tampered: [] };
117
+ headSeal(tree, findings);
118
+ const { changes, head: committed } = snap;
119
+ if (changes.length === 0)
120
+ return { findings, tampered: [] };
121
+ const opsChanged = changes.filter((c) => c.path.startsWith("data/ops/") && c.path.endsWith(".jsonl"));
122
+ const latest = new Map();
123
+ for (const c of opsChanged) {
124
+ const before = committed.get(c.path) ?? "";
125
+ const abs = path.join(tree.root, c.path);
126
+ if (!fs.existsSync(abs)) {
127
+ findings.push({ level: "error", code: "ops-deleted", file: c.path, message: "operation log was deleted" });
128
+ continue;
129
+ }
130
+ const now = fs.readFileSync(abs, "utf8");
131
+ if (!now.startsWith(before)) {
132
+ findings.push({ level: "error", code: "ops-rewritten", file: c.path, message: "operation log was rewritten (it is append-only)" });
133
+ continue;
134
+ }
135
+ const prior = parseOps(before, c.path, findings);
136
+ const added = parseOps(now.slice(before.length), c.path, findings, prior.length);
137
+ checkOps(tree, added, prior.at(-1)?.sig ?? "", c.path, findings);
138
+ for (const op of added)
139
+ for (const f of op.files ?? [])
140
+ latest.set(f.path, f.sha);
141
+ }
142
+ for (const c of changes) {
143
+ if (c.path.startsWith("data/ops/") || c.path === "data/_counters.json")
144
+ continue;
145
+ if (c.path === "strom.json") {
146
+ // strom changes its config through a logged operation (strom config set --for-tree).
147
+ if (latest.get(c.path) !== undefined && latest.get(c.path) === fileSha(path.join(tree.root, c.path)))
148
+ continue;
149
+ findings.push({ level: "error", code: "config-edited", file: c.path, message: "strom.json was changed outside strom", hint: "strom repair" });
150
+ tampered.add(c.path);
151
+ continue;
152
+ }
153
+ if (!isRecordPath(c.path)) {
154
+ findings.push({ level: "error", code: "unsealed", file: c.path, message: "unknown file in data/ (created outside strom)", hint: "strom repair" });
155
+ tampered.add(c.path);
156
+ continue;
157
+ }
158
+ checkRecordFile(tree, c.path, latest.get(c.path), c.code === "??", findings, tampered);
159
+ }
160
+ return { findings, tampered: [...tampered] };
161
+ }
162
+ /** Full check: every operation and every record. */
163
+ export function verifyFull(tree) {
164
+ const findings = [];
165
+ const tampered = new Set();
166
+ headSeal(tree, findings);
167
+ const latest = new Map();
168
+ const opsDir = path.join(tree.dataDir, "ops");
169
+ const all = [];
170
+ if (fs.existsSync(opsDir))
171
+ for (const f of fs.readdirSync(opsDir).sort()) {
172
+ if (!f.endsWith(".jsonl"))
173
+ continue;
174
+ const rel = `data/ops/${f}`;
175
+ const ops = parseOps(fs.readFileSync(path.join(opsDir, f), "utf8"), rel, findings);
176
+ checkOps(tree, ops, "", rel, findings);
177
+ all.push(...ops);
178
+ }
179
+ // Logs are per session: the last word on a file is the newest operation across all of them.
180
+ all.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
181
+ for (const op of all)
182
+ for (const sf of op.files ?? [])
183
+ latest.set(sf.path, sf.sha);
184
+ const present = new Set();
185
+ for (const def of Object.values(RECORD_TYPES)) {
186
+ const dir = path.join(tree.dataDir, def.dir);
187
+ if (!fs.existsSync(dir))
188
+ continue;
189
+ for (const f of fs.readdirSync(dir)) {
190
+ if (!f.endsWith(".json"))
191
+ continue;
192
+ const rel = `data/${def.dir}/${f}`;
193
+ present.add(rel);
194
+ checkRecordFile(tree, rel, latest.get(rel), true, findings, tampered);
195
+ }
196
+ }
197
+ for (const rel of latest.keys())
198
+ if (!present.has(rel) && rel !== "strom.json")
199
+ checkRecordFile(tree, rel, latest.get(rel), false, findings, tampered);
200
+ const cfg = latest.get("strom.json");
201
+ if (cfg && fileSha(path.join(tree.root, "strom.json")) !== cfg) {
202
+ tampered.add("strom.json");
203
+ findings.push({ level: "error", code: "config-edited", file: "strom.json", message: "strom.json was changed outside strom", hint: "strom repair" });
204
+ }
205
+ return { findings, tampered: [...tampered] };
206
+ }
207
+ /** Refuse to write on top of data that was changed outside strom. */
208
+ export function assertIntact(tree) {
209
+ const { findings } = verifyFast(tree);
210
+ const errors = findings.filter((f) => f.level === "error");
211
+ if (errors.length === 0) {
212
+ tree.requireKey();
213
+ return;
214
+ }
215
+ throw new StromError(`data was changed outside strom (${errors.length} problem(s)) — refusing to write`, {
216
+ hint: "strom verify (details)\nstrom repair (restore the last sealed state)",
217
+ details: errors,
218
+ });
219
+ }
@@ -0,0 +1,87 @@
1
+ // Canonical JSON and atomic file writes.
2
+ //
3
+ // Every record is written in one canonical form (fixed key order, 2-space
4
+ // indent, trailing newline) so that git diffs only ever show real changes.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import crypto from "node:crypto";
8
+ /** Keys that come first, in this order; everything else is alphabetical. */
9
+ const LEADING_KEYS = ["id", "type", "name", "names", "title", "status", "kind"];
10
+ function keyRank(key) {
11
+ const i = LEADING_KEYS.indexOf(key);
12
+ return i === -1 ? LEADING_KEYS.length : i;
13
+ }
14
+ function compareKeys(a, b) {
15
+ const ra = keyRank(a);
16
+ const rb = keyRank(b);
17
+ if (ra !== rb)
18
+ return ra - rb;
19
+ return a < b ? -1 : a > b ? 1 : 0;
20
+ }
21
+ /** Deep copy with canonical key order; drops `undefined` values. */
22
+ export function canonicalize(value) {
23
+ if (Array.isArray(value))
24
+ return value.map(canonicalize);
25
+ if (value !== null && typeof value === "object") {
26
+ const src = value;
27
+ const out = {};
28
+ for (const key of Object.keys(src).sort(compareKeys)) {
29
+ const v = src[key];
30
+ if (v === undefined)
31
+ continue;
32
+ out[key] = canonicalize(v);
33
+ }
34
+ return out;
35
+ }
36
+ return value;
37
+ }
38
+ export function stringifyCanonical(value) {
39
+ return JSON.stringify(canonicalize(value), null, 2) + "\n";
40
+ }
41
+ /** Write via a temp file in the same directory and rename: never a half file. */
42
+ export function writeFileAtomic(file, content) {
43
+ const dir = path.dirname(file);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
46
+ fs.writeFileSync(tmp, content, "utf8");
47
+ try {
48
+ fs.renameSync(tmp, file);
49
+ }
50
+ catch (err) {
51
+ fs.rmSync(tmp, { force: true });
52
+ throw err;
53
+ }
54
+ }
55
+ export function writeJson(file, value) {
56
+ writeFileAtomic(file, stringifyCanonical(value));
57
+ }
58
+ export function readJson(file) {
59
+ const text = fs.readFileSync(file, "utf8");
60
+ try {
61
+ return JSON.parse(text);
62
+ }
63
+ catch (err) {
64
+ throw new Error(`invalid JSON in ${file}: ${err.message}`);
65
+ }
66
+ }
67
+ export function readJsonIfExists(file) {
68
+ if (!fs.existsSync(file))
69
+ return undefined;
70
+ return readJson(file);
71
+ }
72
+ /** Append one JSON line (append-only logs). */
73
+ export function appendJsonLine(file, value) {
74
+ fs.mkdirSync(path.dirname(file), { recursive: true });
75
+ fs.appendFileSync(file, JSON.stringify(canonicalize(value)) + "\n", "utf8");
76
+ }
77
+ export function readJsonLines(file) {
78
+ if (!fs.existsSync(file))
79
+ return [];
80
+ const out = [];
81
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
82
+ if (line.trim() === "")
83
+ continue;
84
+ out.push(JSON.parse(line));
85
+ }
86
+ return out;
87
+ }
@@ -0,0 +1,70 @@
1
+ // Research language: the language the agent uses with the user and for
2
+ // research texts (notes, tasks, stories). CLI output itself is English.
3
+ /** BCP 47 primary tag, e.g. "cs", "en", "de". */
4
+ export function normalizeLang(value) {
5
+ if (!value)
6
+ return undefined;
7
+ const v = value.trim();
8
+ if (v === "" || v === "C" || v === "POSIX")
9
+ return undefined;
10
+ // "cs_CZ.UTF-8" -> "cs", "de-AT" -> "de"
11
+ const m = /^([A-Za-z]{2,3})(?:[-_.@]|$)/.exec(v);
12
+ return m?.[1]?.toLowerCase();
13
+ }
14
+ /** Detect the user's language from the environment and the OS locale. */
15
+ export function detectLang(env) {
16
+ for (const key of ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"]) {
17
+ const lang = normalizeLang(env[key]?.split(":")[0]);
18
+ if (lang)
19
+ return lang;
20
+ }
21
+ try {
22
+ const lang = normalizeLang(Intl.DateTimeFormat().resolvedOptions().locale);
23
+ if (lang)
24
+ return lang;
25
+ }
26
+ catch {
27
+ // Intl unavailable: fall through
28
+ }
29
+ return "en";
30
+ }
31
+ const NAMES = {
32
+ cs: "Czech",
33
+ sk: "Slovak",
34
+ de: "German",
35
+ pl: "Polish",
36
+ en: "English",
37
+ hu: "Hungarian",
38
+ fr: "French",
39
+ it: "Italian",
40
+ es: "Spanish",
41
+ uk: "Ukrainian",
42
+ ru: "Russian",
43
+ sl: "Slovenian",
44
+ hr: "Croatian",
45
+ nl: "Dutch",
46
+ sv: "Swedish",
47
+ da: "Danish",
48
+ nb: "Norwegian",
49
+ fi: "Finnish",
50
+ pt: "Portuguese",
51
+ lt: "Lithuanian",
52
+ lv: "Latvian",
53
+ };
54
+ /** The name of a language — in English, or in another language for a person who reads it ("čeština"). */
55
+ export function langName(code, inLang) {
56
+ if (inLang && inLang !== "en") {
57
+ try {
58
+ const name = new Intl.DisplayNames([inLang], { type: "language", fallback: "none" }).of(code);
59
+ if (name)
60
+ return name;
61
+ }
62
+ catch {
63
+ // an unknown code: the English name, or the code
64
+ }
65
+ }
66
+ return NAMES[code] ?? code;
67
+ }
68
+ export function isValidLang(value) {
69
+ return /^[a-z]{2,3}$/.test(value);
70
+ }
@@ -0,0 +1,244 @@
1
+ // The live bridge: the Strom app follows a research while it goes on. strom
2
+ // runs a small web server on this computer only (127.0.0.1, a random port, a
3
+ // secret token in every address) that only reads: the app, opened with
4
+ // ?live=<address>, takes the tree's GEDCOM from it and hears what changes —
5
+ // who is at work on what, what was recorded, what waits for the user. Nothing
6
+ // leaves the computer. The bridge ends by itself when nobody has asked it
7
+ // anything for a while (LIVE_IDLE_MS), or with strom live stop.
8
+ //
9
+ // GET <token>/status the tree, its researches, who is at work, what waits
10
+ // GET <token>/tree.ged the tree for the Strom app, as it is now
11
+ // GET <token>/events server-sent events: hello, change, working
12
+ //
13
+ // Only pages of the Strom app may read it (CORS: https://stromapp.info, and a
14
+ // local copy on localhost for its development), and a browser asks first
15
+ // whether a public page may talk to this computer (Private Network Access).
16
+ import fs from "node:fs";
17
+ import http from "node:http";
18
+ import path from "node:path";
19
+ import crypto from "node:crypto";
20
+ import { spawn, spawnSync } from "node:child_process";
21
+ import { Tree, VERSION } from "./tree.js";
22
+ import { exportGedcom } from "../gedcom/export.js";
23
+ import { liveWorkers } from "./workers.js";
24
+ import { openSessions } from "./session.js";
25
+ import { gitProgram } from "./git.js";
26
+ import { stromLauncher } from "./self.js";
27
+ /** Where a running bridge of a tree is noted. */
28
+ export function liveFile(root) {
29
+ return path.join(root, ".strom", "live.json");
30
+ }
31
+ function alive(pid) {
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ }
36
+ catch (e) {
37
+ return e.code === "EPERM";
38
+ }
39
+ }
40
+ /** The bridge of this tree when it runs. */
41
+ export function liveRunning(root) {
42
+ try {
43
+ const info = JSON.parse(fs.readFileSync(liveFile(root), "utf8"));
44
+ if (info.pid && alive(info.pid))
45
+ return info;
46
+ }
47
+ catch {
48
+ // none
49
+ }
50
+ return undefined;
51
+ }
52
+ function sleep(ms) {
53
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
54
+ }
55
+ /** Start the bridge of a tree in the background (or find it running); undefined when it did not come up. */
56
+ export function startLive(root, env) {
57
+ const running = liveRunning(root);
58
+ if (running)
59
+ return running;
60
+ fs.rmSync(liveFile(root), { force: true });
61
+ const { command, args } = stromLauncher();
62
+ const child = spawn(command, [...args, "live", "serve"], {
63
+ cwd: root,
64
+ env: { ...env, STROM_TREE: root },
65
+ detached: true,
66
+ stdio: "ignore",
67
+ windowsHide: true,
68
+ });
69
+ child.unref();
70
+ for (let i = 0; i < 100; i++) {
71
+ const info = liveRunning(root);
72
+ if (info)
73
+ return info;
74
+ sleep(50);
75
+ }
76
+ return undefined;
77
+ }
78
+ /** Stop the bridge of a tree; false when none ran. */
79
+ export function stopLive(root) {
80
+ const info = liveRunning(root);
81
+ if (!info)
82
+ return false;
83
+ try {
84
+ process.kill(info.pid);
85
+ }
86
+ catch {
87
+ // already gone
88
+ }
89
+ fs.rmSync(liveFile(root), { force: true });
90
+ return true;
91
+ }
92
+ /** The pages that may read the bridge: the Strom app, and a local copy of it for its development. */
93
+ function allowedOrigin(origin) {
94
+ if (!origin)
95
+ return undefined;
96
+ if (origin === "https://stromapp.info")
97
+ return origin;
98
+ return /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin) ? origin : undefined;
99
+ }
100
+ function head(root) {
101
+ const r = spawnSync(gitProgram() ?? "git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true });
102
+ return r.status === 0 ? r.stdout.trim() : "";
103
+ }
104
+ function subjects(root, from, to) {
105
+ const r = spawnSync(gitProgram() ?? "git", ["log", "--format=%s", from ? `${from}..${to}` : "-1", to], { cwd: root, encoding: "utf8", windowsHide: true });
106
+ return r.status === 0 ? r.stdout.trim().split("\n").filter(Boolean).reverse() : [];
107
+ }
108
+ /** Who is at work now, on which task (an agent strom started, and its open session). */
109
+ function working(root, tree) {
110
+ const open = openSessions(tree);
111
+ return liveWorkers(root).map((w) => {
112
+ const s = open.find((x) => x.worker === w.id);
113
+ const task = s?.task ? tree.get(s.task) : undefined;
114
+ return { who: w.label, since: w.since, ...(s ? { session: s.id, ...(s.task ? { task: task ? `${task.id} ${task.what}` : s.task } : {}) } : {}) };
115
+ });
116
+ }
117
+ /** What the app shows beside the tree. */
118
+ function status(root, env) {
119
+ const tree = Tree.open(root, env);
120
+ const waiting = tree.list("task").filter((t) => t.state === "waiting");
121
+ return {
122
+ strom: VERSION,
123
+ tree: { id: tree.config.id, name: tree.config.name, lang: tree.lang },
124
+ head: head(root),
125
+ persons: tree.count("person"),
126
+ families: tree.count("family"),
127
+ researches: tree.list("research").map((r) => ({ id: r.id, name: r.name, state: r.state })),
128
+ working: working(root, tree),
129
+ open: openSessions(tree).map((s) => ({ id: s.id, task: s.task, started: s.started })),
130
+ waiting: waiting.map((t) => ({ id: t.id, what: t.what, on: t.waitingOn ?? "" })),
131
+ };
132
+ }
133
+ /** Run the bridge of the tree here, until idle or stopped. */
134
+ export function serveLive(root, env) {
135
+ const token = crypto.randomBytes(16).toString("hex");
136
+ const idleMs = Number(env.STROM_LIVE_IDLE_MS ?? 2 * 60 * 60_000);
137
+ const pollMs = Number(env.STROM_LIVE_POLL_MS ?? 2000);
138
+ const streams = new Set();
139
+ let last = Date.now();
140
+ let ged;
141
+ const server = http.createServer((req, res) => {
142
+ last = Date.now();
143
+ const origin = allowedOrigin(req.headers.origin);
144
+ if (origin) {
145
+ res.setHeader("Access-Control-Allow-Origin", origin);
146
+ res.setHeader("Vary", "Origin");
147
+ }
148
+ if (req.method === "OPTIONS") {
149
+ if (origin) {
150
+ res.setHeader("Access-Control-Allow-Methods", "GET");
151
+ res.setHeader("Access-Control-Allow-Headers", String(req.headers["access-control-request-headers"] ?? ""));
152
+ if (req.headers["access-control-request-private-network"])
153
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
154
+ }
155
+ res.writeHead(origin ? 204 : 403).end();
156
+ return;
157
+ }
158
+ const [, t, what] = (req.url ?? "").split("?")[0].split("/");
159
+ if (req.method !== "GET" || t !== token) {
160
+ res.writeHead(404).end();
161
+ return;
162
+ }
163
+ try {
164
+ if (what === "status") {
165
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(status(root, env)));
166
+ }
167
+ else if (what === "tree.ged") {
168
+ const h = head(root);
169
+ if (!ged || ged.head !== h) {
170
+ const tree = Tree.open(root, env);
171
+ ged = { head: h, text: exportGedcom(tree, { for: "strom" }).text };
172
+ }
173
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Strom-Head": h }).end(ged.text);
174
+ }
175
+ else if (what === "events") {
176
+ res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive" });
177
+ res.write(`event: hello\ndata: ${JSON.stringify(status(root, env))}\n\n`);
178
+ streams.add(res);
179
+ req.on("close", () => streams.delete(res));
180
+ }
181
+ else
182
+ res.writeHead(404).end();
183
+ }
184
+ catch (e) {
185
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }).end(e.message);
186
+ }
187
+ });
188
+ return new Promise((resolve) => {
189
+ server.listen(0, "127.0.0.1", () => {
190
+ const port = server.address().port;
191
+ const info = { port, token, pid: process.pid, url: `http://127.0.0.1:${port}/${token}`, started: new Date().toISOString() };
192
+ fs.mkdirSync(path.dirname(liveFile(root)), { recursive: true });
193
+ fs.writeFileSync(liveFile(root), JSON.stringify(info, null, 2));
194
+ // What changed: strom commits every change, so a new commit is news.
195
+ let seen = head(root);
196
+ let workers = JSON.stringify(working(root, Tree.open(root, env)));
197
+ const send = (event, data) => {
198
+ for (const s of streams)
199
+ s.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
200
+ };
201
+ const timer = setInterval(() => {
202
+ if (streams.size)
203
+ last = Date.now();
204
+ if (Date.now() - last > idleMs)
205
+ return stop();
206
+ if (!streams.size)
207
+ return;
208
+ const h = head(root);
209
+ if (h && h !== seen) {
210
+ send("change", { head: h, what: subjects(root, seen, h), at: new Date().toISOString() });
211
+ seen = h;
212
+ }
213
+ const now = working(root, Tree.open(root, env));
214
+ const w = JSON.stringify(now);
215
+ if (w !== workers) {
216
+ workers = w;
217
+ send("working", now);
218
+ }
219
+ }, pollMs);
220
+ const keepAlive = setInterval(() => {
221
+ for (const s of streams)
222
+ s.write(": still here\n\n");
223
+ }, 20_000);
224
+ const stop = () => {
225
+ clearInterval(timer);
226
+ clearInterval(keepAlive);
227
+ for (const s of streams)
228
+ s.end();
229
+ server.close();
230
+ try {
231
+ const now = JSON.parse(fs.readFileSync(liveFile(root), "utf8"));
232
+ if (now.pid === process.pid)
233
+ fs.rmSync(liveFile(root), { force: true });
234
+ }
235
+ catch {
236
+ // gone already
237
+ }
238
+ resolve();
239
+ };
240
+ process.once("SIGTERM", stop);
241
+ process.once("SIGINT", stop);
242
+ });
243
+ });
244
+ }