@medicine-wheel/app 0.17.11 → 0.18.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 +42 -0
- package/app/api/ceremonies/[id]/route.ts +3 -0
- package/app/api/ceremonies/route.ts +6 -0
- package/app/api/diary/[id]/route.ts +3 -0
- package/app/api/diary/route.ts +6 -0
- package/app/api/edges/route.ts +11 -1
- package/app/api/events/publish/route.ts +42 -0
- package/app/api/events/route.ts +39 -0
- package/app/api/health/route.ts +5 -0
- package/app/api/memory/conclusions/[id]/route.ts +4 -0
- package/app/api/memory/conclusions/route.ts +5 -0
- package/app/api/narrative/beats/[id]/route.ts +6 -0
- package/app/api/narrative/beats/route.ts +9 -1
- package/app/api/narrative/cycles/route.ts +10 -3
- package/app/api/nodes/[id]/route.ts +7 -1
- package/app/api/nodes/route.ts +6 -0
- package/app/narrative/cycles/page.tsx +2 -0
- package/components/cycle-events.tsx +82 -0
- package/components/episode-file-tree.tsx +67 -0
- package/components/episode-files-panel.tsx +52 -18
- package/dist/cli/mw.js +95 -0
- package/instrumentation.ts +6 -0
- package/lib/episode-file-tree.ts +135 -0
- package/lib/event-log.ts +187 -0
- package/package.json +30 -27
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* An episode's folder on the episode page — listed, and any text file read in
|
|
5
5
|
* place. Read-only: the wheel reads an episode, Miadi writes it.
|
|
6
6
|
*
|
|
7
|
+
* Files are listed in the folders their paths name, every one open to begin
|
|
8
|
+
* with, so a reader can tell which folder a file is in.
|
|
9
|
+
*
|
|
7
10
|
* Text is shown as text. Markdown is not rendered here: rendering it means a
|
|
8
11
|
* renderer and a sanitizer between an episode's files and this page, and plain
|
|
9
12
|
* text is already readable. Media and documents are listed, not played.
|
|
@@ -11,7 +14,9 @@
|
|
|
11
14
|
* @see app/api/episodes/[id]/files/route.ts — and the rules it states
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
18
|
+
import { EpisodeFileTree } from "@/components/episode-file-tree";
|
|
19
|
+
import { buildFileTree, folderPaths } from "@/lib/episode-file-tree";
|
|
15
20
|
|
|
16
21
|
type EpisodeFile = {
|
|
17
22
|
relativePath: string;
|
|
@@ -61,6 +66,8 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
61
66
|
// The path last asked for, so a slow answer for an earlier click cannot
|
|
62
67
|
// replace the file the reader opened after it.
|
|
63
68
|
const wanted = useRef<string | null>(null);
|
|
69
|
+
// Folders the reader closed. The tree opens fully; only a closed folder is remembered.
|
|
70
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
|
64
71
|
|
|
65
72
|
useEffect(() => {
|
|
66
73
|
let live = true;
|
|
@@ -99,17 +106,37 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
99
106
|
[base],
|
|
100
107
|
);
|
|
101
108
|
|
|
102
|
-
const files =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
const files = useMemo(() => (room?.state === "open" ? room.files : []), [room]);
|
|
110
|
+
const tree = useMemo(() => buildFileTree(files), [files]);
|
|
111
|
+
const folders = useMemo(() => folderPaths(tree), [tree]);
|
|
112
|
+
const allClosed = folders.length > 0 && folders.every((path) => collapsed.has(path));
|
|
113
|
+
|
|
114
|
+
const toggleFolder = useCallback(
|
|
115
|
+
(path: string) =>
|
|
116
|
+
setCollapsed((current) => {
|
|
117
|
+
const next = new Set(current);
|
|
118
|
+
if (!next.delete(path)) next.add(path);
|
|
119
|
+
return next;
|
|
120
|
+
}),
|
|
121
|
+
[],
|
|
122
|
+
);
|
|
107
123
|
|
|
108
124
|
return (
|
|
109
125
|
<section>
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
<div className="flex items-baseline justify-between gap-3 mb-2">
|
|
127
|
+
<h2 className="text-sm font-semibold">
|
|
128
|
+
Files {files.length > 0 && `(${files.length})`}
|
|
129
|
+
</h2>
|
|
130
|
+
{folders.length > 0 && (
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => setCollapsed(allClosed ? new Set() : new Set(folders))}
|
|
134
|
+
className="text-xs text-muted-foreground hover:underline"
|
|
135
|
+
>
|
|
136
|
+
{allClosed ? "Expand all" : "Collapse all"}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
113
140
|
|
|
114
141
|
{room === null ? (
|
|
115
142
|
<p className="text-sm text-muted-foreground">Opening the folder…</p>
|
|
@@ -118,23 +145,30 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
118
145
|
) : files.length === 0 ? (
|
|
119
146
|
<p className="text-sm text-muted-foreground">The folder holds nothing the room shows.</p>
|
|
120
147
|
) : (
|
|
121
|
-
<
|
|
122
|
-
{
|
|
148
|
+
<EpisodeFileTree
|
|
149
|
+
tree={tree}
|
|
150
|
+
collapsed={collapsed}
|
|
151
|
+
onToggleFolder={toggleFolder}
|
|
152
|
+
renderFile={(file, name) => {
|
|
123
153
|
const isOpen = opened?.path === file.relativePath;
|
|
124
154
|
return (
|
|
125
|
-
|
|
155
|
+
<>
|
|
126
156
|
<div className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap">
|
|
127
157
|
{file.previewable ? (
|
|
128
158
|
<button
|
|
129
159
|
onClick={() => toggle(file)}
|
|
160
|
+
title={file.relativePath}
|
|
130
161
|
className="text-sm font-mono text-left break-all hover:underline"
|
|
131
162
|
aria-expanded={isOpen}
|
|
132
163
|
>
|
|
133
|
-
{isOpen ? "▾" : "▸"} {
|
|
164
|
+
{isOpen ? "▾" : "▸"} {name}
|
|
134
165
|
</button>
|
|
135
166
|
) : (
|
|
136
|
-
<span
|
|
137
|
-
{file.relativePath}
|
|
167
|
+
<span
|
|
168
|
+
title={file.relativePath}
|
|
169
|
+
className="text-sm font-mono break-all text-muted-foreground"
|
|
170
|
+
>
|
|
171
|
+
{name}
|
|
138
172
|
</span>
|
|
139
173
|
)}
|
|
140
174
|
<span className="text-xs text-muted-foreground shrink-0 flex gap-2 items-baseline">
|
|
@@ -157,10 +191,10 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
157
191
|
)}
|
|
158
192
|
</div>
|
|
159
193
|
)}
|
|
160
|
-
|
|
194
|
+
</>
|
|
161
195
|
);
|
|
162
|
-
}
|
|
163
|
-
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
164
198
|
)}
|
|
165
199
|
</section>
|
|
166
200
|
);
|
package/dist/cli/mw.js
CHANGED
|
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
|
|
|
48
48
|
const skills_1 = require("./skills");
|
|
49
49
|
const beats_register_1 = require("./beats-register");
|
|
50
50
|
const orientation_1 = require("./orientation");
|
|
51
|
+
const events_1 = require("@medicine-wheel/events");
|
|
51
52
|
// ── Config ────────────────────────────────────────────────────────
|
|
52
53
|
const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:8040';
|
|
53
54
|
const MW_FORMAT = process.env.MW_FORMAT ?? 'pretty';
|
|
@@ -301,6 +302,14 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
|
|
|
301
302
|
MEMORY
|
|
302
303
|
mw memory store <key> <value> [dir] Store relational memory
|
|
303
304
|
|
|
305
|
+
EVENTS (what happened on the wheel, kept in events.jsonl)
|
|
306
|
+
mw events [--type cycle.] [--subject <id>] [--correlation <id>]
|
|
307
|
+
[--since <iso>] [--limit 20] List events, newest first
|
|
308
|
+
mw events status What the publisher still owes
|
|
309
|
+
mw events publish Send what waits now
|
|
310
|
+
mw events replay <event-id|all> Send again from that event
|
|
311
|
+
mw events schema [--asyncapi] Print the JSON Schema (or AsyncAPI)
|
|
312
|
+
|
|
304
313
|
SYSTEM
|
|
305
314
|
mw status Show system status
|
|
306
315
|
mw tools List all MCP tools
|
|
@@ -758,6 +767,88 @@ function cmdMemory(positional) {
|
|
|
758
767
|
unknownSub('memory', sub, ['store']);
|
|
759
768
|
}
|
|
760
769
|
}
|
|
770
|
+
async function eventsApi(method, urlPath, body) {
|
|
771
|
+
const res = await fetch(`${MW_API_URL}${urlPath}`, {
|
|
772
|
+
method,
|
|
773
|
+
headers: { 'Content-Type': 'application/json' },
|
|
774
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
775
|
+
});
|
|
776
|
+
return { status: res.status, data: (await res.json().catch(() => ({}))) };
|
|
777
|
+
}
|
|
778
|
+
function printEvent(e) {
|
|
779
|
+
const when = e.observed_at.replace('T', ' ').slice(0, 19);
|
|
780
|
+
const payload = e.payload;
|
|
781
|
+
const changed = Array.isArray(payload.changed) ? ` ${C.dim}changed ${payload.changed.join(', ')}${C.reset}` : '';
|
|
782
|
+
const corr = e.correlation_id && e.correlation_id !== e.subject.id ? ` ${C.dim}↳ ${e.correlation_id}${C.reset}` : '';
|
|
783
|
+
console.log(` ${C.dim}${when}${C.reset} ${C.bold}${(0, events_1.shortName)(e.event_type)}${C.reset} ${e.subject.kind} ${e.subject.id}${changed}${corr}`);
|
|
784
|
+
}
|
|
785
|
+
async function cmdEvents(positional, flags) {
|
|
786
|
+
const sub = positional[0] ?? 'list';
|
|
787
|
+
const failed = (status, data) => {
|
|
788
|
+
console.error(`${C.south}${data?.error ?? `HTTP ${status}`}${C.reset}`);
|
|
789
|
+
if (data?.hint)
|
|
790
|
+
console.error(` ${data.hint}`);
|
|
791
|
+
process.exitCode = 1;
|
|
792
|
+
};
|
|
793
|
+
switch (sub) {
|
|
794
|
+
case 'list': {
|
|
795
|
+
const params = new URLSearchParams();
|
|
796
|
+
for (const key of ['type', 'subject', 'correlation', 'since', 'limit']) {
|
|
797
|
+
const value = flags[key];
|
|
798
|
+
if (typeof value === 'string')
|
|
799
|
+
params.set(key, value);
|
|
800
|
+
}
|
|
801
|
+
if (!params.has('limit'))
|
|
802
|
+
params.set('limit', '20');
|
|
803
|
+
const { status, data } = await eventsApi('GET', `/api/events?${params}`);
|
|
804
|
+
if (status !== 200)
|
|
805
|
+
return failed(status, data);
|
|
806
|
+
if (MW_FORMAT !== 'pretty')
|
|
807
|
+
return ppValue(data);
|
|
808
|
+
const events = data.events ?? [];
|
|
809
|
+
console.log(`${C.bold}${events.length} of ${data.total ?? events.length} event(s)${C.reset}`);
|
|
810
|
+
for (const e of events)
|
|
811
|
+
printEvent(e);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
case 'status': {
|
|
815
|
+
const { status, data } = await eventsApi('GET', '/api/events/publish');
|
|
816
|
+
if (status !== 200)
|
|
817
|
+
return failed(status, data);
|
|
818
|
+
ppValue(data);
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
case 'publish': {
|
|
822
|
+
const { status, data } = await eventsApi('POST', '/api/events/publish', {});
|
|
823
|
+
if (status !== 200)
|
|
824
|
+
return failed(status, data);
|
|
825
|
+
ppValue(data);
|
|
826
|
+
if (data.stopped)
|
|
827
|
+
process.exitCode = 1;
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
case 'replay': {
|
|
831
|
+
const from = positional[1];
|
|
832
|
+
if (!from) {
|
|
833
|
+
console.error(`${C.south}mw events replay needs an event id, or "all"${C.reset}`);
|
|
834
|
+
process.exitCode = EXIT_USAGE;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const { status, data } = await eventsApi('POST', '/api/events/publish', { replay_from: from });
|
|
838
|
+
if (status !== 200)
|
|
839
|
+
return failed(status, data);
|
|
840
|
+
ppValue(data);
|
|
841
|
+
if (data.stopped)
|
|
842
|
+
process.exitCode = 1;
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
case 'schema':
|
|
846
|
+
console.log(JSON.stringify(flags['asyncapi'] ? (0, events_1.asyncApiDocument)() : (0, events_1.wheelEventsJsonSchema)(), null, 2));
|
|
847
|
+
break;
|
|
848
|
+
default:
|
|
849
|
+
unknownSub('events', sub, ['list', 'status', 'publish', 'replay', 'schema']);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
761
852
|
// ── Main dispatch ─────────────────────────────────────────────────
|
|
762
853
|
async function main() {
|
|
763
854
|
const { flags, positional } = parseArgs(process.argv);
|
|
@@ -824,6 +915,10 @@ async function main() {
|
|
|
824
915
|
case 'mem':
|
|
825
916
|
cmdMemory(rest);
|
|
826
917
|
break;
|
|
918
|
+
case 'events':
|
|
919
|
+
case 'ev':
|
|
920
|
+
await cmdEvents(rest, flags);
|
|
921
|
+
break;
|
|
827
922
|
case 'skill':
|
|
828
923
|
case 'sk':
|
|
829
924
|
cmdSkill(rest);
|
package/instrumentation.ts
CHANGED
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
* Starts the Honcho river's retry clock (#147): records Honcho could not
|
|
5
5
|
* receive while it was away wait on the pending ledger, and are sent again on
|
|
6
6
|
* start and every few minutes after. With `HONCHO_URL` unset this does nothing.
|
|
7
|
+
*
|
|
8
|
+
* Starts the event publisher's clock (#164): events in `events.jsonl` that the
|
|
9
|
+
* configured publisher has not taken are sent on start and every minute after.
|
|
10
|
+
* With `MW_EVENTS_PUBLISH_URL` unset this does nothing.
|
|
7
11
|
*/
|
|
8
12
|
export async function register() {
|
|
9
13
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
10
14
|
const { startPendingRetries } = await import("@/lib/honcho-projection");
|
|
11
15
|
startPendingRetries();
|
|
16
|
+
const { startEventPublishing } = await import("@/lib/event-log");
|
|
17
|
+
startEventPublishing();
|
|
12
18
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An episode's files, grouped into the folders their paths name.
|
|
3
|
+
*
|
|
4
|
+
* `listEpisodeFiles` returns files with the path each sits at, and no folders,
|
|
5
|
+
* so the Files section printed whole paths one under another and a reader could
|
|
6
|
+
* not tell which folder a file was in. This builds the folders back from the
|
|
7
|
+
* paths, so the panel can draw them.
|
|
8
|
+
*
|
|
9
|
+
* This is layout, not a rule about what an episode shows — those live in
|
|
10
|
+
* `@miadi/episode-vessel` and in `lib/episode-files.ts`, and nothing here
|
|
11
|
+
* decides what may be listed or read. The same grouping is drawn in Miadi's room
|
|
12
|
+
* by `@miadi/episode-ui` (`buildFileTree`); the wheel keeps its own small copy
|
|
13
|
+
* until it takes that package, and the two are meant to agree on order:
|
|
14
|
+
*
|
|
15
|
+
* 1. a `README.md`;
|
|
16
|
+
* 2. the folders, by name;
|
|
17
|
+
* 3. the files — text, documents, guidance, then media — by name.
|
|
18
|
+
*
|
|
19
|
+
* Names compare numerically, so `chapter-2` comes before `chapter-10`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The part of a listed file the grouping reads. Any richer shape passes through untouched. */
|
|
23
|
+
export interface TreeableFile {
|
|
24
|
+
relativePath: string;
|
|
25
|
+
name: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
guidance: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FileTreeFile<T extends TreeableFile> {
|
|
31
|
+
type: "file";
|
|
32
|
+
/** The last segment of the path. */
|
|
33
|
+
name: string;
|
|
34
|
+
path: string;
|
|
35
|
+
file: T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FileTreeFolder<T extends TreeableFile> {
|
|
39
|
+
type: "folder";
|
|
40
|
+
/** The last segment of the path. Empty for the root. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** The path from the episode root. Empty for the root. */
|
|
43
|
+
path: string;
|
|
44
|
+
children: FileTreeNode<T>[];
|
|
45
|
+
/** Every file beneath this folder, at any depth. */
|
|
46
|
+
fileCount: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type FileTreeNode<T extends TreeableFile> = FileTreeFile<T> | FileTreeFolder<T>;
|
|
50
|
+
|
|
51
|
+
const compareNames = (left: string, right: string): number =>
|
|
52
|
+
left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" }) ||
|
|
53
|
+
(left < right ? -1 : left > right ? 1 : 0);
|
|
54
|
+
|
|
55
|
+
function rank(file: TreeableFile): number {
|
|
56
|
+
if (/^README\.md$/i.test(file.name)) return 0;
|
|
57
|
+
if (file.guidance) return 3;
|
|
58
|
+
if (file.kind === "text") return 1;
|
|
59
|
+
if (file.kind === "document") return 2;
|
|
60
|
+
return 4;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function order<T extends TreeableFile>(folder: FileTreeFolder<T>): void {
|
|
64
|
+
const folders = folder.children.filter((n): n is FileTreeFolder<T> => n.type === "folder");
|
|
65
|
+
const files = folder.children.filter((n): n is FileTreeFile<T> => n.type === "file");
|
|
66
|
+
folders.sort((a, b) => compareNames(a.name, b.name));
|
|
67
|
+
files.sort((a, b) => rank(a.file) - rank(b.file) || compareNames(a.name, b.name));
|
|
68
|
+
folder.children = [
|
|
69
|
+
...files.filter((n) => rank(n.file) === 0),
|
|
70
|
+
...folders,
|
|
71
|
+
...files.filter((n) => rank(n.file) !== 0),
|
|
72
|
+
];
|
|
73
|
+
folders.forEach(order);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Group `files` into folders by path. A path listed twice is kept once, and a
|
|
78
|
+
* folder exists only because a file is beneath it.
|
|
79
|
+
*/
|
|
80
|
+
export function buildFileTree<T extends TreeableFile>(files: readonly T[]): FileTreeFolder<T> {
|
|
81
|
+
const root: FileTreeFolder<T> = { type: "folder", name: "", path: "", children: [], fileCount: 0 };
|
|
82
|
+
const folders = new Map<string, FileTreeFolder<T>>([["", root]]);
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
|
|
85
|
+
const folderAt = (path: string): FileTreeFolder<T> => {
|
|
86
|
+
const known = folders.get(path);
|
|
87
|
+
if (known) return known;
|
|
88
|
+
const cut = path.lastIndexOf("/");
|
|
89
|
+
const parent = folderAt(cut === -1 ? "" : path.slice(0, cut));
|
|
90
|
+
const created: FileTreeFolder<T> = {
|
|
91
|
+
type: "folder",
|
|
92
|
+
name: cut === -1 ? path : path.slice(cut + 1),
|
|
93
|
+
path,
|
|
94
|
+
children: [],
|
|
95
|
+
fileCount: 0,
|
|
96
|
+
};
|
|
97
|
+
folders.set(path, created);
|
|
98
|
+
parent.children.push(created);
|
|
99
|
+
return created;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
if (seen.has(file.relativePath)) continue;
|
|
104
|
+
seen.add(file.relativePath);
|
|
105
|
+
const cut = file.relativePath.lastIndexOf("/");
|
|
106
|
+
const parent = folderAt(cut === -1 ? "" : file.relativePath.slice(0, cut));
|
|
107
|
+
parent.children.push({
|
|
108
|
+
type: "file",
|
|
109
|
+
name: cut === -1 ? file.relativePath : file.relativePath.slice(cut + 1),
|
|
110
|
+
path: file.relativePath,
|
|
111
|
+
file,
|
|
112
|
+
});
|
|
113
|
+
for (let path = parent.path; ; path = path.slice(0, Math.max(path.lastIndexOf("/"), 0))) {
|
|
114
|
+
folders.get(path)!.fileCount += 1;
|
|
115
|
+
if (!path) break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
order(root);
|
|
120
|
+
return root;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every folder path in the tree, outermost first. The root is not one. */
|
|
124
|
+
export function folderPaths<T extends TreeableFile>(tree: FileTreeFolder<T>): string[] {
|
|
125
|
+
const paths: string[] = [];
|
|
126
|
+
const walk = (folder: FileTreeFolder<T>) => {
|
|
127
|
+
for (const child of folder.children) {
|
|
128
|
+
if (child.type !== "folder") continue;
|
|
129
|
+
paths.push(child.path);
|
|
130
|
+
walk(child);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
walk(tree);
|
|
134
|
+
return paths;
|
|
135
|
+
}
|
package/lib/event-log.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wheel's event log — what happened, kept beside the store (#164).
|
|
3
|
+
*
|
|
4
|
+
* The store keeps each record's latest state: a cycle amended twice is one
|
|
5
|
+
* line in `cycles.jsonl`, and the moment it was opened exists nowhere once it
|
|
6
|
+
* has passed. This log keeps those moments. Each write a route commits leaves
|
|
7
|
+
* one line in `events.jsonl` beside the store (`MW_EVENTS_FILE` overrides).
|
|
8
|
+
* What an event is, and which event a write is, lives in
|
|
9
|
+
* `@medicine-wheel/events` — the routes hand it before and after and record
|
|
10
|
+
* what it decides, and the MCP server's local store does the same.
|
|
11
|
+
*
|
|
12
|
+
* `source.instance` names this wheel as configured (`MW_INSTANCE`, e.g.
|
|
13
|
+
* `ilex:8040`). The address a request reached is not a name: behind
|
|
14
|
+
* `0.0.0.0` every wheel looks alike, and two browsers name the same wheel two
|
|
15
|
+
* ways.
|
|
16
|
+
*
|
|
17
|
+
* Publishing (episode 548's Send): with `MW_EVENTS_PUBLISH_URL` set, the log is
|
|
18
|
+
* sent there after every recorded event and every
|
|
19
|
+
* `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute), from this wheel's
|
|
20
|
+
* cursor, with receipts beside the log. Unset, nothing leaves the machine.
|
|
21
|
+
*
|
|
22
|
+
* Never the caller's error: a line that cannot be written is reported once on
|
|
23
|
+
* stderr with the record's id, and a publisher that cannot be reached is a
|
|
24
|
+
* receipt, not a failed write.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { buildEvent, filterEvents, type EventFilter, type WheelEvent, type WheelEventInput } from '@medicine-wheel/events';
|
|
28
|
+
import { appendEvent, eventLogPath, readEventLog } from '@medicine-wheel/events/log';
|
|
29
|
+
import {
|
|
30
|
+
drainLog,
|
|
31
|
+
httpPublisher,
|
|
32
|
+
publisherStatus,
|
|
33
|
+
readReceipts,
|
|
34
|
+
rewind,
|
|
35
|
+
type DrainResult,
|
|
36
|
+
type Publisher,
|
|
37
|
+
type PublisherStatus,
|
|
38
|
+
type Receipt,
|
|
39
|
+
} from '@medicine-wheel/events/publish';
|
|
40
|
+
import { resolveProjectDataDir } from '@/lib/jsonl-store';
|
|
41
|
+
|
|
42
|
+
/** Where the log lives: `MW_EVENTS_FILE`, else beside the wheel's JSONL store. */
|
|
43
|
+
export function eventLogFile(): string {
|
|
44
|
+
return eventLogPath(resolveProjectDataDir());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** This wheel's name in `source.instance`, when one is configured. */
|
|
48
|
+
export function eventInstance(): string | undefined {
|
|
49
|
+
return process.env.MW_INSTANCE?.trim() || undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Record what a decider returned (`null`: the write changed nothing, so
|
|
54
|
+
* nothing to record). Returns the event, or `undefined` when there was none or
|
|
55
|
+
* its line could not be written.
|
|
56
|
+
*/
|
|
57
|
+
export function recordEvent(input: WheelEventInput | null): WheelEvent | undefined {
|
|
58
|
+
if (!input) return undefined;
|
|
59
|
+
const file = eventLogFile();
|
|
60
|
+
let event: WheelEvent;
|
|
61
|
+
try {
|
|
62
|
+
event = buildEvent(input, { instance: eventInstance() });
|
|
63
|
+
appendEvent(file, event);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(
|
|
66
|
+
`[events] medicine-wheel.${input.name} for ${input.subject.kind} ${input.subject.id} was not written to ${file}: ${(error as Error)?.message ?? String(error)}`,
|
|
67
|
+
);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
kickPublisher();
|
|
71
|
+
return event;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every event, oldest first. */
|
|
75
|
+
export function readEvents(): WheelEvent[] {
|
|
76
|
+
return readEventLog(eventLogFile());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { filterEvents, type EventFilter };
|
|
80
|
+
|
|
81
|
+
// ── publishing ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/** The publisher `MW_EVENTS_PUBLISH_URL` names, or `null`: nothing leaves the machine. */
|
|
84
|
+
export function publisherFromEnv(): Publisher | null {
|
|
85
|
+
const url = process.env.MW_EVENTS_PUBLISH_URL?.trim();
|
|
86
|
+
if (!url) return null;
|
|
87
|
+
return httpPublisher({
|
|
88
|
+
url,
|
|
89
|
+
token: process.env.MW_EVENTS_PUBLISH_TOKEN?.trim() || undefined,
|
|
90
|
+
name: process.env.MW_EVENTS_PUBLISH_NAME?.trim() || 'http',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// One pass at a time across every copy of this module: Next compiles
|
|
95
|
+
// `instrumentation.ts` and the routes into separate bundles, and two passes
|
|
96
|
+
// over the same cursor would send the same event twice.
|
|
97
|
+
const STATE = Symbol.for('@medicine-wheel/app.event-publisher');
|
|
98
|
+
type PublishState = { running?: Promise<DrainResult | null>; again?: boolean; timer?: ReturnType<typeof setInterval>; down?: boolean };
|
|
99
|
+
const state: PublishState = ((globalThis as Record<symbol, PublishState>)[STATE] ??= {});
|
|
100
|
+
|
|
101
|
+
async function pass(): Promise<DrainResult | null> {
|
|
102
|
+
const publisher = publisherFromEnv();
|
|
103
|
+
if (!publisher) return null;
|
|
104
|
+
try {
|
|
105
|
+
const result = await drainLog(eventLogFile(), publisher);
|
|
106
|
+
// A destination that stays down is one line when it goes, one when it returns.
|
|
107
|
+
if (result.stopped && !state.down) {
|
|
108
|
+
console.error(`[events] publishing to ${publisher.name} stopped: ${result.error} — ${result.pending} event(s) waiting`);
|
|
109
|
+
} else if (!result.stopped && state.down) {
|
|
110
|
+
console.log(`[events] publishing to ${publisher.name} resumed; ${result.arrived} event(s) arrived`);
|
|
111
|
+
}
|
|
112
|
+
state.down = result.stopped;
|
|
113
|
+
return result;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`[events] publishing pass failed: ${(error as Error)?.message ?? String(error)}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Send what waits. A pass already running is joined, and runs once more after it for what arrived meanwhile. */
|
|
121
|
+
export function publishPending(): Promise<DrainResult | null> {
|
|
122
|
+
if (state.running) {
|
|
123
|
+
state.again = true;
|
|
124
|
+
return state.running;
|
|
125
|
+
}
|
|
126
|
+
const run = pass().then(async (result) => {
|
|
127
|
+
while (state.again) {
|
|
128
|
+
state.again = false;
|
|
129
|
+
result = (await pass()) ?? result;
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}).finally(() => {
|
|
133
|
+
if (state.running === run) state.running = undefined;
|
|
134
|
+
});
|
|
135
|
+
state.running = run;
|
|
136
|
+
return run;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** After a recorded event: send it soon, in the background. Nothing when no publisher is set. */
|
|
140
|
+
export function kickPublisher(): void {
|
|
141
|
+
if (!process.env.MW_EVENTS_PUBLISH_URL?.trim()) return;
|
|
142
|
+
void publishPending();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Start the publishing clock: one pass now, then one every
|
|
147
|
+
* `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute). Called once from
|
|
148
|
+
* `instrumentation.ts`; a second call is a no-op, and without a publisher
|
|
149
|
+
* there is nothing to send.
|
|
150
|
+
*/
|
|
151
|
+
export function startEventPublishing(intervalMs = Number(process.env.MW_EVENTS_PUBLISH_INTERVAL_MS) || 60_000): void {
|
|
152
|
+
if (state.timer || !publisherFromEnv()) return;
|
|
153
|
+
state.timer = setInterval(() => { void publishPending(); }, intervalMs);
|
|
154
|
+
state.timer.unref?.();
|
|
155
|
+
void publishPending();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Replay: the next pass sends `fromEventId` and everything after it again (the whole log without one). */
|
|
159
|
+
export function replayEvents(fromEventId?: string): { pending: number } | null {
|
|
160
|
+
const publisher = publisherFromEnv();
|
|
161
|
+
if (!publisher) return null;
|
|
162
|
+
return rewind(eventLogFile(), publisher.name, fromEventId);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Where the configured publisher stands, and its latest receipts. */
|
|
166
|
+
export function eventPublisherReport(limit = 20): { publisher: PublisherStatus; receipts: Receipt[] } | null {
|
|
167
|
+
const publisher = publisherFromEnv();
|
|
168
|
+
if (!publisher) return null;
|
|
169
|
+
const file = eventLogFile();
|
|
170
|
+
return { publisher: publisherStatus(file, publisher.name), receipts: readReceipts(file, publisher.name).slice(-limit).reverse() };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What `/api/health` reports: how many events, the last one, and what the publisher still owes. */
|
|
174
|
+
export function eventLogStatus(): { count: number; last_at?: string; publisher?: PublisherStatus } | { error: string } {
|
|
175
|
+
try {
|
|
176
|
+
const events = readEvents();
|
|
177
|
+
const last = events[events.length - 1];
|
|
178
|
+
const publisher = publisherFromEnv();
|
|
179
|
+
return {
|
|
180
|
+
count: events.length,
|
|
181
|
+
...(last ? { last_at: last.observed_at } : {}),
|
|
182
|
+
...(publisher ? { publisher: publisherStatus(eventLogFile(), publisher.name) } : {}),
|
|
183
|
+
};
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { error: (error as Error)?.message ?? String(error) };
|
|
186
|
+
}
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mw": "dist/cli/mw.js",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"workspaces": [
|
|
29
29
|
"src/ontology-core",
|
|
30
30
|
"src/infra",
|
|
31
|
+
"src/events",
|
|
31
32
|
"src/creative-orientation",
|
|
32
33
|
"src/session-reader",
|
|
33
34
|
"src/ceremony-protocol",
|
|
@@ -91,32 +92,33 @@
|
|
|
91
92
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
92
93
|
},
|
|
93
94
|
"dependencies": {
|
|
94
|
-
"@medicine-wheel/ceremonial-diary": "^0.
|
|
95
|
-
"@medicine-wheel/ceremony-protocol": "^0.
|
|
96
|
-
"@medicine-wheel/community-identity": "^0.
|
|
97
|
-
"@medicine-wheel/community-review": "^0.
|
|
98
|
-
"@medicine-wheel/consent-lifecycle": "^0.
|
|
99
|
-
"@medicine-wheel/creative-orientation": "^0.
|
|
100
|
-
"@medicine-wheel/data-store": "^0.
|
|
101
|
-
"@medicine-wheel/data-store-postgres": "^0.
|
|
102
|
-
"@medicine-wheel/
|
|
103
|
-
"@medicine-wheel/
|
|
104
|
-
"@medicine-wheel/
|
|
105
|
-
"@medicine-wheel/
|
|
106
|
-
"@medicine-wheel/
|
|
107
|
-
"@medicine-wheel/
|
|
108
|
-
"@medicine-wheel/
|
|
109
|
-
"@medicine-wheel/
|
|
110
|
-
"@medicine-wheel/narrative-
|
|
111
|
-
"@medicine-wheel/
|
|
112
|
-
"@medicine-wheel/
|
|
113
|
-
"@medicine-wheel/
|
|
114
|
-
"@medicine-wheel/
|
|
115
|
-
"@medicine-wheel/relational-
|
|
116
|
-
"@medicine-wheel/
|
|
117
|
-
"@medicine-wheel/
|
|
118
|
-
"@medicine-wheel/
|
|
119
|
-
"@medicine-wheel/
|
|
95
|
+
"@medicine-wheel/ceremonial-diary": "^0.18.0",
|
|
96
|
+
"@medicine-wheel/ceremony-protocol": "^0.18.0",
|
|
97
|
+
"@medicine-wheel/community-identity": "^0.18.0",
|
|
98
|
+
"@medicine-wheel/community-review": "^0.18.0",
|
|
99
|
+
"@medicine-wheel/consent-lifecycle": "^0.18.0",
|
|
100
|
+
"@medicine-wheel/creative-orientation": "^0.18.0",
|
|
101
|
+
"@medicine-wheel/data-store": "^0.18.0",
|
|
102
|
+
"@medicine-wheel/data-store-postgres": "^0.18.0",
|
|
103
|
+
"@medicine-wheel/events": "^0.18.0",
|
|
104
|
+
"@medicine-wheel/fire-keeper": "^0.18.0",
|
|
105
|
+
"@medicine-wheel/github-ceremony": "^0.18.0",
|
|
106
|
+
"@medicine-wheel/graph-viz": "^0.18.0",
|
|
107
|
+
"@medicine-wheel/honcho": "^0.18.0",
|
|
108
|
+
"@medicine-wheel/importance-unit": "^0.18.0",
|
|
109
|
+
"@medicine-wheel/mcp": "^4.18.0",
|
|
110
|
+
"@medicine-wheel/memory": "^0.18.0",
|
|
111
|
+
"@medicine-wheel/narrative-cluster": "^0.18.0",
|
|
112
|
+
"@medicine-wheel/narrative-engine": "^0.18.0",
|
|
113
|
+
"@medicine-wheel/ontology-core": "^0.18.0",
|
|
114
|
+
"@medicine-wheel/perception-layer": "^0.18.0",
|
|
115
|
+
"@medicine-wheel/prompt-decomposition": "^0.18.0",
|
|
116
|
+
"@medicine-wheel/relational-index": "^0.18.0",
|
|
117
|
+
"@medicine-wheel/relational-query": "^0.18.0",
|
|
118
|
+
"@medicine-wheel/session-reader": "^0.18.0",
|
|
119
|
+
"@medicine-wheel/storage-provider": "^0.18.0",
|
|
120
|
+
"@medicine-wheel/transformation-tracker": "^0.18.0",
|
|
121
|
+
"@medicine-wheel/ui-components": "^0.18.0",
|
|
120
122
|
"@miadi/episode-vessel": "^0.2.1",
|
|
121
123
|
"@neondatabase/serverless": "^0.10.0",
|
|
122
124
|
"@xyflow/react": "^12.3.0",
|
|
@@ -138,6 +140,7 @@
|
|
|
138
140
|
"@types/node": "^22.0.0",
|
|
139
141
|
"@types/react": "^19.0.0",
|
|
140
142
|
"@types/react-dom": "^19.0.0",
|
|
143
|
+
"ajv": "^6.12.6",
|
|
141
144
|
"eslint": "^9.39.5",
|
|
142
145
|
"eslint-config-next": "^15.5.25",
|
|
143
146
|
"shx": "^0.4.0",
|