mainflow 0.34.0 → 0.35.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.
@@ -1,13 +1,14 @@
1
- import { j as configSchema } from "./dist-IdbnCDqM.js";
2
- import { A as attributeTrace, C as transcriptPath, G as linksInto, H as loadHub, J as readYaml, K as refIndex, M as transcriptTitle, S as readSession, U as dependencyGraph, W as mainlineEdges, a as openInApp, h as setArchivedMany, ht as WriteError, j as traceTranscript, m as ARCHIVE_KINDS, mt as DATA_DIR, pt as ConflictError, x as loadSessions } from "./host-79T-_NMu.js";
1
+ import { F as configSchema, f as GROUP_SECTIONS } from "./dist-BdvWGTsb.js";
2
+ import { C as readSession, G as sectionText, H as HubError, J as linksInto, K as dependencyGraph, M as traceTranscript, N as transcriptTitle, S as loadSessions, U as loadConfig, W as loadHub, Y as refIndex, Z as readYaml, _t as DATA_DIR, a as openInApp, g as setArchivedMany, gt as ConflictError, h as ARCHIVE_KINDS, j as attributeTrace, q as mainlineEdges, vt as WriteError, w as transcriptPath } from "./host-bnHYIwuY.js";
3
3
  import { t as listProgress } from "./progress-1BfPg2zN.js";
4
- import { A as syncPaths, O as SYNC_DIR, _ as readDaemon, a as remoteMode, h as callDaemon, k as isSynced, m as DaemonUnavailable, n as hubWriter, r as writtenLines, y as readRemote } from "./operation--fJnUqvA.js";
5
- import { l as CLI_VERSION } from "./launch-DQVyIbpf.js";
4
+ import { A as isSynced, _ as readDaemon, a as remoteMode, b as readRemote, h as callDaemon, j as syncPaths, k as SYNC_DIR, m as DaemonUnavailable, n as hubWriter, r as writtenLines, y as watchTree } from "./operation-DVcJgKM9.js";
5
+ import { c as realRoot, f as CLI_VERSION, s as readUiRecord } from "./launch-D80CYPcm.js";
6
6
  import { createRequire } from "node:module";
7
- import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, realpathSync, statSync, watch } from "node:fs";
7
+ import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
8
8
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawnSync } from "node:child_process";
11
+ import { parse as parse$1 } from "yaml";
11
12
  import { homedir } from "node:os";
12
13
  import { serve } from "@hono/node-server";
13
14
  import { Hono } from "hono";
@@ -32,7 +33,11 @@ function prefixedRefs(hub) {
32
33
  refs.push(...t.checklist.map((c) => c.supersededBy));
33
34
  }
34
35
  }
35
- for (const d of [...hub.issues, ...hub.findings]) doc(d);
36
+ for (const d of [
37
+ ...hub.issues,
38
+ ...hub.findings,
39
+ ...hub.groups
40
+ ]) doc(d);
36
41
  return [...new Set(refs.filter((r) => !!r && /^[a-z0-9-]+:/.test(r)))];
37
42
  }
38
43
  function relationsView(hub) {
@@ -104,6 +109,8 @@ function summarize(m) {
104
109
  priority: m.priority ?? null,
105
110
  labels: m.labels ?? [],
106
111
  due: m.due ?? null,
112
+ group: m.group ?? null,
113
+ role: m.role ?? "impl",
107
114
  updated: [
108
115
  m,
109
116
  ...m.tasks,
@@ -118,6 +125,27 @@ function summarize(m) {
118
125
  decisions: m.decisions.length
119
126
  };
120
127
  }
128
+ /** The first paragraph of a group's 說明 on one line, for the heading of its group in a list. */
129
+ function groupSummary(body) {
130
+ return ((sectionText(body, GROUP_SECTIONS[0]) ?? "").split(/\n\s*\n/)[0] ?? "").replace(/\s+/g, " ").trim();
131
+ }
132
+ const groupView = (g) => ({
133
+ ...g,
134
+ summary: groupSummary(g.body)
135
+ });
136
+ /** The 503 body for a config.yaml that cannot be used, with the hub's root left out of paths. */
137
+ function configErrorView(root, error) {
138
+ const scrub = (text) => text.replaceAll(`${root}${sep}`, "");
139
+ const message = scrub(error.message);
140
+ const problems = error instanceof HubError ? error.problems : [];
141
+ const detail = problems.map((p) => `${p.field ? `${p.field}: ` : ""}${scrub(p.message)}`).join("; ");
142
+ return {
143
+ error: `config.yaml cannot be used: ${message}${detail ? ` (${detail})` : ""}`,
144
+ kind: "hub-config",
145
+ message,
146
+ problems
147
+ };
148
+ }
121
149
  /**
122
150
  * HTTP API over one hub. The CLI `ui` command and the web dev server both mount it, through createUiApp. Writes go
123
151
  * through the hub's writer (src/remote/operation.ts), so in remote mode they are operations the sync server applies;
@@ -147,12 +175,59 @@ function createApp(root, options = {}) {
147
175
  }
148
176
  return written;
149
177
  };
178
+ let knownName;
179
+ const configFailure = () => {
180
+ try {
181
+ knownName = loadConfig(root).hub;
182
+ return null;
183
+ } catch (error) {
184
+ return configErrorView(root, error);
185
+ }
186
+ };
187
+ /**
188
+ * The name /api/ping gives while config.yaml cannot be used: the last good read, else what a complete file of
189
+ * another format version says, else the directory's. A half-written file holds only part of its name, so it is not read.
190
+ */
191
+ const fallbackName = () => {
192
+ if (knownName) return knownName;
193
+ try {
194
+ const raw = configSchema.safeParse(parse$1(readFileSync(join(root, DATA_DIR, "config.yaml"), "utf8")));
195
+ if (raw.success) return raw.data.hub;
196
+ } catch {}
197
+ return basename(realRoot(root));
198
+ };
199
+ configFailure();
200
+ app.use("/api/*", async (c, next) => {
201
+ if (/\/api\/(ping|events)$/.test(c.req.path)) return next();
202
+ const failure = configFailure();
203
+ return failure ? c.json(failure, 503) : next();
204
+ });
150
205
  app.onError((error, c) => {
151
206
  const message = error.message.replaceAll(`${root}${sep}`, "");
207
+ if (error instanceof HubError) return c.json(configErrorView(root, error), 503);
152
208
  if (error instanceof ConflictError) return c.json({ error: message }, 409);
153
209
  if (error instanceof WriteError) return c.json({ error: message }, 400);
154
210
  throw error;
155
211
  });
212
+ app.get("/api/ping", (c) => {
213
+ const failure = configFailure();
214
+ const who = {
215
+ hub: failure ? fallbackName() : knownName,
216
+ root: realRoot(root),
217
+ version: CLI_VERSION,
218
+ pid: process.pid
219
+ };
220
+ if (failure) return c.json({
221
+ ...failure,
222
+ ...who,
223
+ status: "config-error"
224
+ }, 503);
225
+ return c.json(who);
226
+ });
227
+ app.get("/api/host", (c) => {
228
+ const hostError = readUiRecord(root)?.hostError;
229
+ return c.json(hostError ? { hostError } : {});
230
+ });
156
231
  app.get("/api/hub", (c) => {
157
232
  const hub = loadHub(root);
158
233
  const body = {
@@ -160,6 +235,7 @@ function createApp(root, options = {}) {
160
235
  version: CLI_VERSION,
161
236
  repos: hub.config.repos,
162
237
  mainlines: hub.mainlines.map(summarize),
238
+ groups: hub.groups.map(groupView),
163
239
  openIssues: hub.issues.filter((i) => i.status !== "closed").length,
164
240
  validFindings: hub.findings.filter((f) => f.status === "valid").length,
165
241
  problems: hub.problems
@@ -484,7 +560,8 @@ async function fetchFromServer(root, path) {
484
560
  * client's notices (`notices`), or nothing. The sync client's own state under local/sync/ (known.json, queue.json,
485
561
  * the conflicts it saves, its tmp/) is not the hub's content, so writing it reloads nothing; notices.jsonl only
486
562
  * matters when a warn-level line arrives. remote.json lists the files only on the server, which the data view shows,
487
- * so a change to it reloads. Lock and temporary files are left out as well.
563
+ * so a change to it reloads. Lock and temporary files are left out as well. A session file changes with every hook of a
564
+ * running session, so it is its own kind (`session`) and the interface reloads the sessions only, not the whole hub.
488
565
  */
489
566
  function eventOf(name) {
490
567
  const path = name.split(sep).join("/");
@@ -492,8 +569,28 @@ function eventOf(name) {
492
569
  if (path === `local/sync/remote.json`) return "change";
493
570
  if (path === "local/sync" || path.startsWith(`local/sync/`)) return null;
494
571
  if (path.endsWith("write.lock") || path.endsWith(".tmp")) return null;
572
+ if (path === SESSIONS_DIR || path.startsWith(`${SESSIONS_DIR}/`)) return "session";
495
573
  return "change";
496
574
  }
575
+ const SESSIONS_DIR = "local/sessions";
576
+ /**
577
+ * Watch the hub's .mainflow/ for `onName`, a path relative to it. macOS and Windows watch the tree natively. Linux has no
578
+ * native tree watch, and Node's emulation of it rereads a directory on every event in it, so hundreds of session files
579
+ * written at once keep the process busy for seconds and every request waits; there the watcher keeps one watch per directory.
580
+ */
581
+ function watchDataDir(dataDir, onName, platform = process.platform) {
582
+ const mode = platform === "linux" ? "directories" : "recursive";
583
+ const watcher = watchTree(dataDir, {
584
+ mode,
585
+ include: () => true,
586
+ onChange: onName,
587
+ onError: () => void 0
588
+ });
589
+ return {
590
+ mode,
591
+ close: () => watcher.close()
592
+ };
593
+ }
497
594
  /** One file watcher per hub, shared by every listener of that hub. */
498
595
  var HubWatch = class {
499
596
  root;
@@ -504,16 +601,15 @@ var HubWatch = class {
504
601
  constructor(root) {
505
602
  this.root = root;
506
603
  this.offset = sizeOf(syncPaths(root).notices);
507
- this.watcher = watch(join(root, DATA_DIR), { recursive: true }, (_event, name) => {
508
- const kind = name ? eventOf(String(name)) : null;
604
+ this.watcher = watchDataDir(join(root, DATA_DIR), (name) => {
605
+ const kind = eventOf(name);
509
606
  if (kind === null || kind === "notices" && !this.newWarnings()) return;
510
607
  const event = {
511
- kind: kind === "change" ? "change" : "notice",
512
- name: String(name)
608
+ kind: kind === "notices" ? "notice" : kind,
609
+ name
513
610
  };
514
611
  for (const notify of this.listeners) notify(event);
515
612
  });
516
- this.watcher.on("error", () => void 0);
517
613
  }
518
614
  close() {
519
615
  this.watcher.close();
@@ -623,6 +719,26 @@ function servedHubs(root) {
623
719
  root
624
720
  }, ...others];
625
721
  }
722
+ let lastTurn = Promise.resolve();
723
+ /** Hono middleware: a request waits for its turn, one per event loop iteration, so I/O such as a ping is served between. */
724
+ const pace = async (c, next) => {
725
+ if (!/\/api\/(ping|events|hubs\/events)$/.test(c.req.path)) {
726
+ const turn = lastTurn.then(() => new Promise((done) => setImmediate(done)));
727
+ lastTurn = turn;
728
+ await turn;
729
+ }
730
+ await next();
731
+ };
732
+ function hubListGuard(root) {
733
+ return async (c, next) => {
734
+ try {
735
+ loadConfig(root);
736
+ } catch (error) {
737
+ return c.json(configErrorView(root, error), 503);
738
+ }
739
+ return next();
740
+ };
741
+ }
626
742
  /**
627
743
  * The interface's API: this hub at /api, and every registered hub that is on this machine at /h/{name}/api, so one
628
744
  * interface follows several hubs and switches between them. /api/hubs lists them, and /api/hubs/events sends every
@@ -631,6 +747,10 @@ function servedHubs(root) {
631
747
  */
632
748
  function createUiApp(root) {
633
749
  const app = new Hono();
750
+ app.use("/api/*", pace);
751
+ app.use("/h/:name/api/*", pace);
752
+ app.use("/api/hubs", hubListGuard(root));
753
+ app.use("/api/hubs/events", hubListGuard(root));
634
754
  const own = createApp(root);
635
755
  const others = /* @__PURE__ */ new Map();
636
756
  const appOf = (name) => {