basepad 0.0.0-stage → 0.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.
@@ -0,0 +1,254 @@
1
+ import { homedir } from "node:os";
2
+ import { kindOf } from "../catalogue.js";
3
+ import { loadProject } from "../config.js";
4
+ import { BasepadError } from "../internal/exec.js";
5
+ import { daemonId, layout, namespaceOf, ofDaemon, placeOf, } from "../layout.js";
6
+ import { hasChanges, listWorktrees, mainBranch } from "../worktrees.js";
7
+ import { takeWork } from "./work.js";
8
+ /** How often states and worktrees are read again. */
9
+ const REFRESH_MS = 1000;
10
+ /** How often a change is passed on at most: a burst of output becomes one repaint. */
11
+ const CHANGED_MS = 100;
12
+ /** Lines of each part's output from before the view opened. */
13
+ const HISTORY = 200;
14
+ /** Lines kept per part, the scrollback; older ones are dropped. */
15
+ const KEEP = 2000;
16
+ /**
17
+ * Opens the feed on `main`'s project and its worktrees, as served on `machine` (whose `portFree`
18
+ * may change while it is open: the supervisor starts after the view does, while `up` launches)
19
+ * is known), with the worktree work in `work` (`startWork`'s folder). Resolves once the first read
20
+ * is done. `changed` is called when there is something new.
21
+ */
22
+ export async function openFeed(options) {
23
+ const { main, machine, supervisor, changed } = options;
24
+ const arrivals = [];
25
+ /** Each worktree's config by folder, read once: a config that does not load, as its reason. */
26
+ const configs = new Map();
27
+ let environments = [];
28
+ let dirty = false;
29
+ let closed = false;
30
+ const book = logBook(supervisor.follow, HISTORY, () => {
31
+ arrivals.push(Date.now());
32
+ dirty = true;
33
+ });
34
+ let running = [];
35
+ const notices = [];
36
+ const configOf = async (root) => {
37
+ let config = configs.get(root);
38
+ if (config === undefined) {
39
+ config = await loadProject(root).catch((error) => error instanceof BasepadError ? error.message : String(error));
40
+ configs.set(root, config);
41
+ }
42
+ return config;
43
+ };
44
+ const refresh = async () => {
45
+ // First and alone: what finished is told once, whatever fails after.
46
+ const work = await takeWork(options.work);
47
+ running = work.running;
48
+ notices.push(...work.finished);
49
+ const [trees, daemons, branch] = await Promise.all([
50
+ listWorktrees(main),
51
+ supervisor.daemons(),
52
+ mainBranch(main),
53
+ ]);
54
+ const byId = new Map(daemons.map((daemon) => [daemon.id, daemon]));
55
+ const here = machine();
56
+ const next = [await environmentOf(main, undefined, branch, main.root, byId, here)];
57
+ // A worktree being added shows once it is ready: until then, only those there before it began.
58
+ const adding = running.flatMap((w) => (w.before === undefined ? [] : [w.before]));
59
+ const shown = trees.filter((t) => t.present && adding.every((was) => was.includes(t.suffix)));
60
+ // A worktree removed and made again under its suffix reads its config again.
61
+ for (const root of configs.keys()) {
62
+ if (!shown.some((t) => t.root === root))
63
+ configs.delete(root);
64
+ }
65
+ const changes = await Promise.all(shown.map((t) => hasChanges(t.path).catch(() => undefined)));
66
+ for (const [i, tree] of shown.entries()) {
67
+ const changed = changes[i] === undefined ? {} : { changes: changes[i] };
68
+ const config = await configOf(tree.root);
69
+ next.push({
70
+ ...(typeof config === "string"
71
+ ? troubled(main.name, tree.suffix, tree.branch, tree.root, config)
72
+ : await environmentOf(config, tree.suffix, tree.branch, tree.root, byId, here)),
73
+ ...changed,
74
+ });
75
+ }
76
+ environments = next;
77
+ dirty = true;
78
+ const ids = new Set(next.flatMap((env) => [...env.apps, ...env.services].map((p) => p.id)));
79
+ if (!closed)
80
+ book.follow([...ids].sort());
81
+ };
82
+ let reading = false;
83
+ const tick = async () => {
84
+ if (reading || closed)
85
+ return;
86
+ reading = true;
87
+ // A failed read keeps the last snapshot; the next tick tries again.
88
+ await refresh().catch(() => { });
89
+ reading = false;
90
+ };
91
+ await refresh();
92
+ const timers = [
93
+ setInterval(tick, REFRESH_MS),
94
+ setInterval(() => {
95
+ if (!dirty || closed)
96
+ return;
97
+ dirty = false;
98
+ changed();
99
+ }, CHANGED_MS),
100
+ ];
101
+ return {
102
+ snapshot() {
103
+ const since = Date.now() - 1000;
104
+ while (arrivals.length > 0 && arrivals[0] < since)
105
+ arrivals.shift();
106
+ return {
107
+ project: main.name,
108
+ environments,
109
+ logs: book.logs,
110
+ rate: arrivals.length,
111
+ work: running,
112
+ };
113
+ },
114
+ refresh() {
115
+ void tick();
116
+ },
117
+ notices() {
118
+ return notices.splice(0);
119
+ },
120
+ close() {
121
+ closed = true;
122
+ for (const timer of timers)
123
+ clearInterval(timer);
124
+ book.close();
125
+ },
126
+ };
127
+ }
128
+ /**
129
+ * Every shown part's output, followed live: the scrollback, by daemon id. `follow` sets the parts
130
+ * shown. A part shown for the first time is followed from its last `history` lines; a part already
131
+ * followed keeps its lines and their arrival numbers, so a paused stream stays where it is. A part
132
+ * no longer shown loses its lines and is tailed no more. Each environment's parts are followed
133
+ * together. `read` is told of each line.
134
+ */
135
+ export function logBook(follow, history, read) {
136
+ const logs = new Map();
137
+ /** Which follower each shown id's lines are taken from. */
138
+ const owner = new Map();
139
+ let seq = 0;
140
+ /** Starts following `ids`, from their last `lines` lines: their lines are taken from it. */
141
+ const start = (ids, lines) => {
142
+ const follower = { ids: new Set(ids), stop: () => { } };
143
+ for (const id of ids)
144
+ owner.set(id, follower);
145
+ follower.stop = follow(ids, lines, (entry) => {
146
+ // A part shown again has a follower of its own; an old one's copy is not taken.
147
+ if (owner.get(entry.id) !== follower)
148
+ return;
149
+ const kept = logs.get(entry.id) ?? [];
150
+ kept.push({ seq: seq++, id: entry.id, time: entry.time, text: entry.text, at: Date.now() });
151
+ if (kept.length > KEEP * 1.5)
152
+ kept.splice(0, kept.length - KEEP);
153
+ logs.set(entry.id, kept);
154
+ read();
155
+ });
156
+ };
157
+ return {
158
+ logs,
159
+ follow(ids) {
160
+ const shown = new Set(ids);
161
+ const shrunk = new Set();
162
+ for (const [id, follower] of owner) {
163
+ if (shown.has(id))
164
+ continue;
165
+ owner.delete(id);
166
+ logs.delete(id);
167
+ follower.ids.delete(id);
168
+ shrunk.add(follower);
169
+ }
170
+ // A follower tails exactly what is shown: one that lost a part starts again on the rest,
171
+ // whose lines it already has.
172
+ for (const follower of shrunk) {
173
+ follower.stop();
174
+ if (follower.ids.size > 0)
175
+ start([...follower.ids], 0);
176
+ }
177
+ // Each environment's new parts on a follower of their own, so a worktree removed stops
178
+ // exactly its own.
179
+ const added = Map.groupBy(ids.filter((id) => !owner.has(id)), (id) => ofDaemon(id).namespace);
180
+ for (const group of added.values())
181
+ start(group, history);
182
+ },
183
+ close() {
184
+ for (const follower of new Set(owner.values()))
185
+ follower.stop();
186
+ owner.clear();
187
+ },
188
+ };
189
+ }
190
+ /** One environment's parts, with states from the supervisor and addresses from Layout. */
191
+ async function environmentOf(project, suffix, branch, dir, daemons, machine) {
192
+ let lay;
193
+ try {
194
+ lay = layout(project, machine, suffix);
195
+ }
196
+ catch (error) {
197
+ if (!(error instanceof BasepadError))
198
+ throw error;
199
+ return troubled(project.name, suffix, branch, dir, error.message);
200
+ }
201
+ const base = {
202
+ label: suffix ?? "main",
203
+ ...(branch === undefined ? {} : { branch }),
204
+ dir: shortened(dir),
205
+ };
206
+ const apps = Object.entries(lay.apps).map(([key, app]) => {
207
+ const id = daemonId(lay.namespace, key);
208
+ const daemon = daemons.get(id);
209
+ const running = daemon?.state === "running";
210
+ return {
211
+ id,
212
+ name: key,
213
+ state: daemon?.state ?? "stopped",
214
+ ...(app.url === undefined ? {} : { address: app.url }),
215
+ ...(running && daemon.port !== undefined
216
+ ? { note: `:${daemon.port}${daemon.pid === undefined ? "" : ` pid ${daemon.pid}`}` }
217
+ : {}),
218
+ ...(daemon?.error === undefined ? {} : { error: daemon.error }),
219
+ };
220
+ });
221
+ const services = [];
222
+ for (const [key, service] of Object.entries(project.services)) {
223
+ // In main's namespace, or the worktree's for a server of its own.
224
+ const id = lay.services[key].daemon;
225
+ const daemon = daemons.get(id);
226
+ services.push({
227
+ id,
228
+ name: key,
229
+ // A service is not started by a request of its own, so not running is stopped.
230
+ state: daemon === undefined || daemon.state === "idle" ? "stopped" : daemon.state,
231
+ address: await kindOf(service).address(service, placeOf(project, lay, machine, key)),
232
+ note: service.kind,
233
+ ...(daemon?.error === undefined ? {} : { error: daemon.error }),
234
+ });
235
+ }
236
+ return { namespace: lay.namespace, ...base, apps, services };
237
+ }
238
+ /** An environment whose parts cannot be shown, with why: a config that does not load, or names past their limits. */
239
+ function troubled(name, suffix, branch, dir, problem) {
240
+ return {
241
+ namespace: namespaceOf(name, suffix),
242
+ label: suffix ?? "main",
243
+ ...(branch === undefined ? {} : { branch }),
244
+ dir: shortened(dir),
245
+ apps: [],
246
+ services: [],
247
+ problem,
248
+ };
249
+ }
250
+ /** A folder as it is shown: under the home folder, from `~`. */
251
+ function shortened(dir) {
252
+ const home = homedir();
253
+ return dir === home || dir.startsWith(`${home}/`) ? `~${dir.slice(home.length)}` : dir;
254
+ }