@miadi/stateloom-web 0.1.9 → 0.2.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 (68) hide show
  1. package/package.json +1 -1
  2. package/server/.next/BUILD_ID +1 -1
  3. package/server/.next/build-manifest.json +2 -2
  4. package/server/.next/server/app/_global-error.html +2 -2
  5. package/server/.next/server/app/_global-error.rsc +1 -1
  6. package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/server/.next/server/app/_not-found.html +1 -1
  13. package/server/.next/server/app/_not-found.rsc +2 -2
  14. package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  17. package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  20. package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
  21. package/server/.next/server/app/api/file/route.js.nft.json +1 -1
  22. package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
  23. package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
  24. package/server/.next/server/app/index.html +1 -1
  25. package/server/.next/server/app/index.rsc +3 -3
  26. package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  27. package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
  28. package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
  29. package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
  30. package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  31. package/server/.next/server/app/page.js.nft.json +1 -1
  32. package/server/.next/server/app/page_client-reference-manifest.js +1 -1
  33. package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
  34. package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
  35. package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
  36. package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +3 -3
  37. package/server/.next/server/chunks/ssr/{[root-of-the-server]__d5879c0c._.js → [root-of-the-server]__b1b2bf74._.js} +2 -2
  38. package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +4 -3
  39. package/server/.next/server/pages/404.html +1 -1
  40. package/server/.next/server/pages/500.html +2 -2
  41. package/server/.next/static/chunks/8458f462f4687e07.js +11 -0
  42. package/server/.next/static/chunks/a8a37c487ef91a19.js +1 -0
  43. package/server/.next/static/chunks/c86e96e4d6eeb632.css +2 -0
  44. package/server/package-lock.json +2 -2
  45. package/server/package.json +1 -1
  46. package/server/src/app/api/file/route.ts +57 -12
  47. package/server/src/app/globals.css +15 -0
  48. package/server/src/app/page.tsx +52 -10
  49. package/server/src/components/BoardBoundary.tsx +46 -0
  50. package/server/src/components/erd/ErdWorkspace.tsx +32 -3
  51. package/server/src/components/forms.tsx +129 -0
  52. package/server/src/components/sequence/SequenceWorkspace.tsx +1024 -0
  53. package/server/src/components/system/KindIcon.tsx +71 -0
  54. package/server/src/components/system/SystemStrip.tsx +285 -0
  55. package/server/src/components/system/SystemWorkspace.tsx +766 -0
  56. package/server/src/lib/docParam.ts +4 -1
  57. package/server/src/lib/machineFocus.ts +126 -0
  58. package/server/src/lib/projectFile.ts +180 -20
  59. package/server/src/lib/safely.ts +16 -0
  60. package/server/src/lib/systemLoad.ts +362 -0
  61. package/server/src/lib/systemParam.ts +110 -0
  62. package/server/stateloom-web.json +2 -2
  63. package/server/.next/static/chunks/636e7eb4aeda72e0.css +0 -2
  64. package/server/.next/static/chunks/92628504da59dcc8.js +0 -10
  65. package/server/.next/static/chunks/aea5b1716c68391c.js +0 -1
  66. /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → KjjOvPCIL-ta0AZ8r8SFk}/_buildManifest.js +0 -0
  67. /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → KjjOvPCIL-ta0AZ8r8SFk}/_clientMiddlewareManifest.json +0 -0
  68. /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → KjjOvPCIL-ta0AZ8r8SFk}/_ssgManifest.js +0 -0
@@ -11,7 +11,7 @@ import { useEffect, useState } from "react";
11
11
  * byte-identical to the old behavior.
12
12
  */
13
13
 
14
- const DOC_CHANGE_EVENT = "stateloom:doc-change";
14
+ export const DOC_CHANGE_EVENT = "stateloom:doc-change";
15
15
 
16
16
  export function requestedDoc(): string | null {
17
17
  if (typeof window === "undefined") return null;
@@ -29,6 +29,9 @@ export function navigateToDoc(doc: string | null): void {
29
29
  const url = new URL(window.location.href);
30
30
  if (doc) url.searchParams.set("doc", doc);
31
31
  else url.searchParams.delete("doc");
32
+ // A focus names an element of the document it was written for; it does not
33
+ // travel to another one. (`system=` does: the strip stays while you browse.)
34
+ url.searchParams.delete("focus");
32
35
  window.history.pushState({}, "", url);
33
36
  window.dispatchEvent(new Event(DOC_CHANGE_EVENT));
34
37
  }
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `focus=` for the state designer (Spec 82): `state:X` drills to X's level,
5
+ * selects it and centres it; `event:E` does the same for the first transition
6
+ * that fires E; `machine:*` (and anything else) leaves the board as it is.
7
+ *
8
+ * And the other way: when a person selects a state or a transition, the URL's
9
+ * focus follows, so the system strip can list what that element links to.
10
+ *
11
+ * Kept as one hook beside the store rather than inside it, so the state
12
+ * designer changes by the single line that mounts it.
13
+ */
14
+ import { useEffect, useRef } from "react";
15
+ import { fitToBoxes, parseFocus, type StateDef, type StateMachineDefinition } from "@miadi/stateloom-protocol";
16
+ import { useDesignerStore } from "@/store/useDesignerStore";
17
+ import { CANVAS_SVG_ID } from "@/lib/exportImage";
18
+ import { urlParam, useFocusRequest, writeFocus } from "@/lib/systemParam";
19
+
20
+ const kids = (s: StateDef): StateDef[] => (s.parallel ? s.parallel.states : (s.states ?? []));
21
+
22
+ /** Names from the root down to `name`, inclusive; null when it is not in the tree. */
23
+ function chainTo(root: StateDef | undefined, name: string): string[] | null {
24
+ if (!root) return null;
25
+ if (root.name === name) return [root.name];
26
+ for (const child of kids(root)) {
27
+ const below = chainTo(child, name);
28
+ if (below) return [root.name, ...below];
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** The first transition, in document order, that fires `event`. */
34
+ function firstTransition(root: StateDef | undefined, event: string): { state: string; index: number } | null {
35
+ if (!root) return null;
36
+ const i = (root.transitions ?? []).findIndex((t) => t?.event === event);
37
+ if (i >= 0) return { state: root.name, index: i };
38
+ for (const child of kids(root)) {
39
+ const hit = firstTransition(child, event);
40
+ if (hit) return hit;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function transitionEvent(def: StateMachineDefinition, id: string): string | null {
46
+ const cut = id.lastIndexOf(":");
47
+ const chain = chainTo(def.state, id.slice(0, cut));
48
+ if (!chain) return null;
49
+ let s: StateDef | undefined = def.state;
50
+ for (const name of chain.slice(1)) s = kids(s!).find((k) => k.name === name);
51
+ return s?.transitions?.[Number(id.slice(cut + 1))]?.event ?? null;
52
+ }
53
+
54
+ export function useMachineFocus(): void {
55
+ const request = useFocusRequest();
56
+ const pending = useRef<{ focus: string; docAt: string | null; defAt: StateMachineDefinition } | null>(null);
57
+
58
+ useEffect(() => {
59
+ const tryApply = (): void => {
60
+ const p = pending.current;
61
+ if (!p) return;
62
+ const s = useDesignerStore.getState();
63
+ const doc = urlParam("doc");
64
+ // Not the requested document yet, or still its predecessor's definition.
65
+ if (doc && s.docPath !== doc) return;
66
+ if (p.docAt !== s.docPath && s.definition === p.defAt) return;
67
+ pending.current = null;
68
+
69
+ const f = parseFocus(p.focus);
70
+ if (!f) return;
71
+ let chain: string[] | null = null;
72
+ let select: { kind: "state" | "transition"; id: string } | null = null;
73
+ if (f.kind === "state") {
74
+ chain = chainTo(s.definition.state, f.name);
75
+ if (chain) select = { kind: "state", id: f.name };
76
+ } else if (f.kind === "event") {
77
+ const hit = firstTransition(s.definition.state, f.name);
78
+ if (hit) {
79
+ chain = chainTo(s.definition.state, hit.state);
80
+ select = { kind: "transition", id: `${hit.state}:${hit.index}` };
81
+ }
82
+ }
83
+ if (!chain || !select) return;
84
+ // The level that draws the element: its parent's children.
85
+ const level = chain.length > 1 ? chain.slice(0, -1) : chain;
86
+ useDesignerStore.setState({ navigationPath: level, currentParent: level[level.length - 1] });
87
+ s.select(select.kind, select.id);
88
+
89
+ const centre = (): void => {
90
+ const box = useDesignerStore.getState().layout.positions[chain![chain!.length - 1]];
91
+ const rect = document.getElementById(CANVAS_SVG_ID)?.getBoundingClientRect();
92
+ if (!box || !rect?.width) return;
93
+ const vp = fitToBoxes([box], rect.width, rect.height, { padding: 60, maxScale: 1 });
94
+ useDesignerStore.getState().setViewport(vp);
95
+ };
96
+ // Once when the board has drawn the level, and once more after this
97
+ // browser's remembered view has had its chance to land on top.
98
+ requestAnimationFrame(centre);
99
+ setTimeout(centre, 450);
100
+ };
101
+
102
+ if (request.nonce > 0) {
103
+ const s = useDesignerStore.getState();
104
+ pending.current = request.focus ? { focus: request.focus, docAt: s.docPath, defAt: s.definition } : null;
105
+ tryApply();
106
+ }
107
+ return useDesignerStore.subscribe(tryApply);
108
+ }, [request]);
109
+
110
+ // A person's selection becomes the focus. Clearing it leaves the last one in
111
+ // the URL: the strip keeps listing what was last in hand.
112
+ useEffect(
113
+ () =>
114
+ useDesignerStore.subscribe((state, prev) => {
115
+ if (state.selection === prev.selection || !state.selection.id) return;
116
+ const { kind, id } = state.selection;
117
+ if (kind === "state") writeFocus(`state:${id}`);
118
+ else if (kind === "event") writeFocus(`event:${id}`);
119
+ else if (kind === "transition") {
120
+ const event = transitionEvent(state.definition, id);
121
+ if (event) writeFocus(`event:${event}`);
122
+ }
123
+ }),
124
+ [],
125
+ );
126
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, realpathSync, statSync } from "fs";
1
+ import { closeSync, constants, lstatSync, openSync, readdirSync, readlinkSync, realpathSync, statSync, writeSync } from "fs";
2
2
  import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
3
3
  import { envAlias } from "@miadi/stateloom-protocol";
4
4
 
@@ -35,6 +35,44 @@ export function allowedDocRoots(): string[] {
35
35
  return real;
36
36
  }
37
37
 
38
+ /**
39
+ * The chronicle's diagrams (D2, approved by Guillaume 2026-09-30 — the narrow
40
+ * rule). A chronicle root, from STATELOOM_CHRONICLE_ROOT (SMCRAFT_ twin), admits
41
+ * exactly one shape of path and nothing else under it:
42
+ *
43
+ * <chronicleRoot>/<episode>/diagrams/<file>.json
44
+ *
45
+ * One episode directory, then `diagrams/`, then the file — not the episode's
46
+ * other files, not the root's own files, nothing deeper. The shape is checked on
47
+ * the REAL path, so a symlink in the episode or in `diagrams/` cannot lead out
48
+ * of it; and since the real path of the parent is required, a new file can be
49
+ * created only where `diagrams/` already exists. Nothing here makes a directory.
50
+ */
51
+ export function chronicleRoot(): string | null {
52
+ const raw = envAlias("CHRONICLE_ROOT");
53
+ if (!raw) return null;
54
+ try {
55
+ return realpathSync(resolve(raw));
56
+ } catch {
57
+ return null; // a chronicle root that does not exist admits nothing
58
+ }
59
+ }
60
+
61
+ /** Is this real path `<root>/<episode>/diagrams/<file>.json`, with `diagrams/` a real directory? */
62
+ export function isChronicleDiagram(real: string, root: string): boolean {
63
+ if (!real.startsWith(root + sep)) return false;
64
+ const parts = real.slice(root.length + 1).split(sep);
65
+ if (parts.length !== 3) return false;
66
+ const [episode, folder, file] = parts;
67
+ if (!episode || episode.startsWith(".") || folder !== "diagrams") return false;
68
+ if (!file || file.startsWith(".") || !file.endsWith(".json")) return false;
69
+ try {
70
+ return statSync(join(root, episode, folder)).isDirectory();
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
38
76
  export type DocResolution =
39
77
  | { ok: true; path: string }
40
78
  | { ok: false; error: string };
@@ -51,21 +89,60 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
51
89
  return { ok: false, error: "doc must name a .json document" };
52
90
  }
53
91
 
92
+ if (doc.includes("\0")) {
93
+ return { ok: false, error: "doc must not contain a NUL byte" };
94
+ }
95
+
54
96
  const lexical = resolve(doc);
55
- // Realpath the file itself when it exists (GET/PUT of a known doc), else its
56
- // parent (PUT may create the file) — symlinks cannot smuggle a path outside
57
- // the allowlist either way. A doc whose parent does not exist is refused.
97
+ // Where the request really lands, symlinks followed:
98
+ // - an existing file (or a live link): its realpath;
99
+ // - a DANGLING link: the path it points at — the write would follow it there,
100
+ // so that is what the roots must admit (the parent realpathed, as for a
101
+ // new file). A link that points at another link is refused outright.
102
+ // - a new file: its realpathed parent plus its name.
103
+ // A doc whose parent does not exist is refused; nothing here makes a directory.
58
104
  let anchored: string;
59
105
  try {
60
- anchored = existsSync(lexical)
61
- ? realpathSync(lexical)
62
- : resolve(realpathSync(dirname(lexical)), lexical.slice(lexical.lastIndexOf(sep) + 1));
106
+ let link: ReturnType<typeof lstatSync> | null = null;
107
+ try {
108
+ link = lstatSync(lexical);
109
+ } catch {
110
+ link = null;
111
+ }
112
+ if (link?.isSymbolicLink()) {
113
+ try {
114
+ anchored = realpathSync(lexical);
115
+ } catch {
116
+ const target = resolve(dirname(lexical), readlinkSync(lexical));
117
+ let chained = false;
118
+ try {
119
+ chained = lstatSync(target).isSymbolicLink();
120
+ } catch {
121
+ chained = false;
122
+ }
123
+ if (chained) return { ok: false, error: `${doc} is a link to a link, which is not followed` };
124
+ anchored = resolve(realpathSync(dirname(target)), basename(target));
125
+ }
126
+ } else if (link) {
127
+ anchored = realpathSync(lexical);
128
+ } else {
129
+ anchored = resolve(realpathSync(dirname(lexical)), basename(lexical));
130
+ }
63
131
  } catch {
64
132
  // Almost always a host path handed to a containerised server: the directory
65
133
  // exists where the human is looking and not where the server is.
66
134
  return { ok: false, error: outsideRootsMessage(doc, allowedDocRoots()) };
67
135
  }
68
136
 
137
+ // Under the chronicle root only the chronicle rule decides — even when a doc
138
+ // root contains it, or is it — so the narrow rule cannot be widened by a
139
+ // broader root.
140
+ const chronicle = chronicleRoot();
141
+ if (chronicle && (anchored === chronicle || anchored.startsWith(chronicle + sep))) {
142
+ return isChronicleDiagram(anchored, chronicle)
143
+ ? { ok: true, path: anchored }
144
+ : { ok: false, error: outsideRootsMessage(doc, allowedDocRoots()) };
145
+ }
69
146
  const roots = allowedDocRoots();
70
147
  for (const root of roots) {
71
148
  if (anchored === root || anchored.startsWith(root + sep)) {
@@ -75,6 +152,22 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
75
152
  return { ok: false, error: outsideRootsMessage(doc, roots) };
76
153
  }
77
154
 
155
+ /**
156
+ * Write a resolved document without following a link at the destination:
157
+ * `O_NOFOLLOW` fails on a symlink in the last component, so a link placed there
158
+ * after `resolveDocPath` looked cannot redirect the write. The parent was
159
+ * realpathed by `resolveDocPath`. The file keeps its inode, so watchers and
160
+ * hard links see an ordinary change.
161
+ */
162
+ export function writeDocFile(path: string, content: string): void {
163
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o666);
164
+ try {
165
+ writeSync(fd, content, 0, "utf8");
166
+ } finally {
167
+ closeSync(fd);
168
+ }
169
+ }
170
+
78
171
  /**
79
172
  * A refusal that is true from the READER's side.
80
173
  *
@@ -86,7 +179,10 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
86
179
  * asked for, says what the translation is.
87
180
  */
88
181
  function outsideRootsMessage(doc: string, roots: string[]): string {
89
- const where = roots.length ? roots.join(", ") : "(no readable root is configured)";
182
+ const chronicle = chronicleRoot();
183
+ const where =
184
+ (roots.length ? roots.join(", ") : "(no readable root is configured)") +
185
+ (chronicle ? `, nor a chronicle diagram (${chronicle}/<episode>/diagrams/<file>.json, in an existing diagrams/ folder)` : "");
90
186
  const hint =
91
187
  roots.length === 1 && roots[0] === "/data"
92
188
  ? " — this server is in a container, and it sees your mounted directory as /data," +
@@ -95,6 +191,10 @@ function outsideRootsMessage(doc: string, roots: string[]): string {
95
191
  return `${doc} is not under the permitted document root${roots.length === 1 ? "" : "s"} ${where}${hint}`;
96
192
  }
97
193
 
194
+ /** How many episode folders, newest name first, and how many chronicle files the listing reads. */
195
+ const CHRONICLE_EPISODE_LIMIT = 400;
196
+ const CHRONICLE_FILE_LIMIT = 200;
197
+
98
198
  export interface DocEntry {
99
199
  /** Absolute path as the SERVER sees it — the value `?doc=` wants. */
100
200
  path: string;
@@ -125,7 +225,13 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
125
225
  const seen = new Set<string>();
126
226
  let truncated = false;
127
227
 
128
- const walk = (dir: string, depth: number): void => {
228
+ // Nothing under the chronicle root is listed by a doc root's walk: only the
229
+ // chronicle rule admits there, and it is listed on its own below.
230
+ const chronicle = chronicleRoot();
231
+ const underChronicle = (real: string): boolean =>
232
+ !!chronicle && (real === chronicle || real.startsWith(chronicle + sep));
233
+
234
+ const walk = (dir: string, depth: number, root: string): void => {
129
235
  if (docs.length >= limit) {
130
236
  truncated = true;
131
237
  return;
@@ -139,33 +245,87 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
139
245
  for (const entry of entries) {
140
246
  if (entry.startsWith(".") || entry === "node_modules") continue;
141
247
  const full = join(dir, entry);
248
+ let real: string;
142
249
  let stat;
143
250
  try {
144
- stat = statSync(full);
251
+ real = realpathSync(full);
252
+ stat = statSync(real);
145
253
  } catch {
146
254
  continue; // a broken symlink lists as nothing, not as a crash
147
255
  }
256
+ // A link out of the root is neither listed nor descended: the listing
257
+ // never names a path the file API would refuse.
258
+ if (real !== root && !real.startsWith(root + sep)) continue;
259
+ if (underChronicle(real)) continue;
148
260
  if (stat.isDirectory()) {
149
- if (depth < maxDepth) walk(full, depth + 1);
261
+ if (depth < maxDepth) walk(real, depth + 1, root);
150
262
  continue;
151
263
  }
152
- if (!entry.endsWith(".json")) continue;
264
+ if (!entry.endsWith(".json") || !real.endsWith(".json")) continue;
153
265
  if (docs.length >= limit) {
154
266
  truncated = true;
155
267
  return;
156
268
  }
157
- if (seen.has(full)) continue;
158
- seen.add(full);
159
- docs.push({ path: full, name: entry, dir, size: stat.size, mtime: stat.mtimeMs });
269
+ if (seen.has(real)) continue;
270
+ seen.add(real);
271
+ docs.push({ path: real, name: basename(real), dir: dirname(real), size: stat.size, mtime: stat.mtimeMs });
160
272
  }
161
273
  };
162
274
 
163
- for (const root of allowedDocRoots()) walk(root, 0);
275
+ for (const root of allowedDocRoots()) walk(root, 0, root);
276
+
277
+ // The chronicle's diagrams: `<episode>/diagrams/*.json` only, as the rule
278
+ // admits them, and bounded on its own so a long chronicle neither crowds the
279
+ // roots out of the list nor turns it into a crawl of every episode.
280
+ if (chronicle) {
281
+ let listed = 0;
282
+ let episodes: string[] = [];
283
+ try {
284
+ episodes = readdirSync(chronicle).filter((e) => !e.startsWith(".")).sort().reverse().slice(0, CHRONICLE_EPISODE_LIMIT);
285
+ } catch {
286
+ episodes = [];
287
+ }
288
+ for (const episode of episodes) {
289
+ if (listed >= CHRONICLE_FILE_LIMIT) {
290
+ truncated = true;
291
+ break;
292
+ }
293
+ const dir = join(chronicle, episode, "diagrams");
294
+ let files: string[];
295
+ try {
296
+ files = readdirSync(dir);
297
+ } catch {
298
+ continue; // no diagrams/ folder: nothing of this episode is admitted
299
+ }
300
+ for (const file of files) {
301
+ if (file.startsWith(".") || !file.endsWith(".json")) continue;
302
+ const full = join(dir, file);
303
+ let real: string;
304
+ let stat;
305
+ try {
306
+ real = realpathSync(full);
307
+ stat = statSync(real);
308
+ } catch {
309
+ continue;
310
+ }
311
+ // Listed only where the file API would serve it: never a path it refuses.
312
+ if (!stat.isFile() || !isChronicleDiagram(real, chronicle) || seen.has(real)) continue;
313
+ if (listed >= CHRONICLE_FILE_LIMIT) {
314
+ truncated = true;
315
+ break;
316
+ }
317
+ seen.add(real);
318
+ docs.push({ path: real, name: file, dir: dirname(real), size: stat.size, mtime: stat.mtimeMs });
319
+ listed += 1;
320
+ }
321
+ }
322
+ }
164
323
 
165
- // The loom's own documents first (`.smdf.json` machines, `.erdf.json` ERDs) —
166
- // the rest are `.json` files the allowlist tolerates but nobody came here
167
- // looking for — then most recently touched.
168
- const rank = (name: string): number => (/\.(smdf|erdf)\.json$/i.test(name) ? 0 : 1);
324
+ // The loom's own documents first (`.smdf.json` machines, `.erdf.json` ERDs,
325
+ // `.sqdf.json` sequences, `.sysdf.json` systems) — the rest are `.json` files
326
+ // the allowlist tolerates but nobody came here looking for — then most
327
+ // recently touched.
328
+ const rank = (name: string): number => (/\.(smdf|erdf|sqdf|sysdf)\.json$/i.test(name) ? 0 : 1);
169
329
  docs.sort((a, b) => rank(a.name) - rank(b.name) || b.mtime - a.mtime || a.path.localeCompare(b.path));
170
330
 
171
331
  return { docs, truncated };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Run a check or a derivation over a document that may be malformed, and fall
3
+ * back instead of throwing: a hand- or agent-written file is not always the
4
+ * shape the types say, and one bad field must not take the page down. The
5
+ * error is kept for whoever asks (`onError`) and logged once.
6
+ */
7
+ export function safely<T>(run: () => T, fallback: T, onError?: (message: string) => void): T {
8
+ try {
9
+ return run();
10
+ } catch (e) {
11
+ const message = e instanceof Error ? e.message : String(e);
12
+ console.warn("[stateloom] skipped on a malformed document:", message);
13
+ onError?.(message);
14
+ return fallback;
15
+ }
16
+ }