@medicine-wheel/app 0.18.0 → 0.19.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 CHANGED
@@ -363,7 +363,7 @@ weaves, plan perspectives, charts and MMOTs are not recorded yet. Each line is
363
363
  a `MiadiEventEnvelope` (jgwill/Miadi
364
364
  `rispecs/events/00-miadi-event-spine.rispec.md`) namespaced `medicine-wheel.*`:
365
365
  `cycle.opened`, `beat.recorded`, `ceremony.closed`, `circle.created`,
366
- `chronicle.episode.registered`, `relation.made`, … — 29 in all, catalogued in
366
+ `chronicle.episode.registered`, `chronicle.lineage.made`, `relation.made`, … — 32 in all, catalogued in
367
367
  [`@medicine-wheel/events`](src/events/README.md).
368
368
 
369
369
  - A payload names records and carries labels. A cycle's question, a beat's
@@ -381,6 +381,16 @@ mw events schema [--asyncapi] # the JSON Schema, or th
381
381
  Set `MW_INSTANCE` (e.g. `ilex:8040`) to name this wheel in each event's
382
382
  `source.instance`.
383
383
 
384
+ **Carried by git.** Set `MW_EVENTS_GIT_SYNC=push` and the wheel commits
385
+ `events.jsonl` (that file only) in its data repository and pushes it. It never
386
+ pushes someone else's commit. A follower elsewhere runs
387
+ `mw-events follow <clone> --exec <command>` and handles each event it has not
388
+ seen yet. See the [package README](src/events/README.md#carried-by-git).
389
+
390
+ Two chronicle episodes related through the Miadi lineage door (`POST /api/edges`
391
+ between two `chronicle_episode` nodes) produce `chronicle.lineage.made`, which
392
+ carries the manifest's sentence that says why.
393
+
384
394
  **Publishing.** Nothing leaves the machine until `MW_EVENTS_PUBLISH_URL` is set.
385
395
  Once it is, the wheel POSTs each event, in log order, after every write and
386
396
  every minute (`MW_EVENTS_PUBLISH_INTERVAL_MS`).
@@ -178,8 +178,9 @@ export async function POST(request: Request) {
178
178
 
179
179
  await store.createEdge(edge);
180
180
  // The event log (#164): a relation made, or — over an existing pair —
181
- // amended in what moved.
182
- recordEvent(relationWritten(previous, edge));
181
+ // amended in what moved. Between two chronicle episodes it is lineage,
182
+ // and carries the sentence that says why.
183
+ recordEvent(relationWritten(previous, edge, { from: fromNode, to: toNode }));
183
184
  return NextResponse.json(
184
185
  { success: true, edge, replaced, provider: detectProvider() },
185
186
  { status: 201 },
@@ -216,7 +217,8 @@ export async function PATCH(request: Request) {
216
217
  const store = await createProvider();
217
218
  const previous = await store.getEdge(pair.from, pair.to);
218
219
  const edge = await store.updateEdge(pair.from, pair.to, parsed.data);
219
- recordEvent(relationWritten(previous ?? edge, edge));
220
+ const [fromNode, toNode] = await Promise.all([store.getNode(pair.from), store.getNode(pair.to)]);
221
+ recordEvent(relationWritten(previous ?? edge, edge, { from: fromNode, to: toNode }));
220
222
  return NextResponse.json({ success: true, edge, provider: detectProvider() });
221
223
  } catch (error: unknown) {
222
224
  if (error instanceof EdgeNotFoundError) {
@@ -234,8 +236,9 @@ export async function DELETE(request: Request) {
234
236
 
235
237
  const store = await createProvider();
236
238
  const previous = await store.getEdge(pair.from, pair.to);
239
+ const [fromNode, toNode] = await Promise.all([store.getNode(pair.from), store.getNode(pair.to)]);
237
240
  await store.deleteEdge(pair.from, pair.to);
238
- recordEvent(relationRemoved(previous ?? { from_id: pair.from, to_id: pair.to }));
241
+ recordEvent(relationRemoved(previous ?? { from_id: pair.from, to_id: pair.to }, { from: fromNode, to: toNode }));
239
242
  return NextResponse.json({
240
243
  success: true,
241
244
  deleted: { from_id: pair.from, to_id: pair.to },
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from "next/server";
2
+ import { eventGitStatus, gitSyncMode, syncEventsToGit } from "@/lib/event-log";
3
+
4
+ const OFF = {
5
+ error: "The git carriage is off — events.jsonl is not committed by the wheel.",
6
+ hint: "Set MW_EVENTS_GIT_SYNC=push (or commit) and restart the wheel.",
7
+ };
8
+
9
+ /**
10
+ * The event log carried by git (#164). GET: the mode and the last pass —
11
+ * what was committed, whether it was pushed, how many commits the remote
12
+ * still lacks, the last error. POST: commit (and push) now.
13
+ */
14
+ export async function GET() {
15
+ if (gitSyncMode() === "off") return NextResponse.json(OFF, { status: 409 });
16
+ return NextResponse.json(eventGitStatus());
17
+ }
18
+
19
+ export async function POST() {
20
+ if (gitSyncMode() === "off") return NextResponse.json(OFF, { status: 409 });
21
+ const result = await syncEventsToGit();
22
+ return NextResponse.json(result, { status: result?.error ? 502 : 200 });
23
+ }
package/dist/cli/mw.js CHANGED
@@ -308,6 +308,7 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
308
308
  mw events status What the publisher still owes
309
309
  mw events publish Send what waits now
310
310
  mw events replay <event-id|all> Send again from that event
311
+ mw events sync Commit (and push) events.jsonl now
311
312
  mw events schema [--asyncapi] Print the JSON Schema (or AsyncAPI)
312
313
 
313
314
  SYSTEM
@@ -842,11 +843,18 @@ async function cmdEvents(positional, flags) {
842
843
  process.exitCode = 1;
843
844
  break;
844
845
  }
846
+ case 'sync': {
847
+ const { status, data } = await eventsApi('POST', '/api/events/git');
848
+ if (status !== 200)
849
+ return failed(status, data);
850
+ ppValue(data);
851
+ break;
852
+ }
845
853
  case 'schema':
846
854
  console.log(JSON.stringify(flags['asyncapi'] ? (0, events_1.asyncApiDocument)() : (0, events_1.wheelEventsJsonSchema)(), null, 2));
847
855
  break;
848
856
  default:
849
- unknownSub('events', sub, ['list', 'status', 'publish', 'replay', 'schema']);
857
+ unknownSub('events', sub, ['list', 'status', 'publish', 'replay', 'sync', 'schema']);
850
858
  }
851
859
  }
852
860
  // ── Main dispatch ─────────────────────────────────────────────────
@@ -8,11 +8,16 @@
8
8
  * Starts the event publisher's clock (#164): events in `events.jsonl` that the
9
9
  * configured publisher has not taken are sent on start and every minute after.
10
10
  * With `MW_EVENTS_PUBLISH_URL` unset this does nothing.
11
+ *
12
+ * Starts the event log's git carriage (#164): with `MW_EVENTS_GIT_SYNC` set,
13
+ * `events.jsonl` is committed in its data repository (and pushed) on start
14
+ * and every ten minutes, besides soon after each recorded event.
11
15
  */
12
16
  export async function register() {
13
17
  if (process.env.NEXT_RUNTIME !== "nodejs") return;
14
18
  const { startPendingRetries } = await import("@/lib/honcho-projection");
15
19
  startPendingRetries();
16
- const { startEventPublishing } = await import("@/lib/event-log");
20
+ const { startEventPublishing, startEventGitSync } = await import("@/lib/event-log");
17
21
  startEventPublishing();
22
+ startEventGitSync();
18
23
  }
package/lib/event-log.ts CHANGED
@@ -19,9 +19,16 @@
19
19
  * `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute), from this wheel's
20
20
  * cursor, with receipts beside the log. Unset, nothing leaves the machine.
21
21
  *
22
+ * Carried by git, before any broker: with `MW_EVENTS_GIT_SYNC=push` (or
23
+ * `commit`), the wheel commits `events.jsonl` — that file only — in the data
24
+ * repository it lives in, about half a minute after events are recorded
25
+ * (`MW_EVENTS_GIT_DELAY_MS`), on start, and every ten minutes
26
+ * (`MW_EVENTS_GIT_INTERVAL_MS`), then pushes. A follower elsewhere pulls a
27
+ * clone and acts on what it has not seen (`mw-events follow`).
28
+ *
22
29
  * 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.
30
+ * stderr with the record's id, and a publisher or a remote that cannot be
31
+ * reached is a receipt or a status, not a failed write.
25
32
  */
26
33
 
27
34
  import { buildEvent, filterEvents, type EventFilter, type WheelEvent, type WheelEventInput } from '@medicine-wheel/events';
@@ -37,6 +44,7 @@ import {
37
44
  type PublisherStatus,
38
45
  type Receipt,
39
46
  } from '@medicine-wheel/events/publish';
47
+ import { commitEventLog, type CommitResult } from '@medicine-wheel/events/git';
40
48
  import { resolveProjectDataDir } from '@/lib/jsonl-store';
41
49
 
42
50
  /** Where the log lives: `MW_EVENTS_FILE`, else beside the wheel's JSONL store. */
@@ -68,6 +76,7 @@ export function recordEvent(input: WheelEventInput | null): WheelEvent | undefin
68
76
  return undefined;
69
77
  }
70
78
  kickPublisher();
79
+ kickGitSync();
71
80
  return event;
72
81
  }
73
82
 
@@ -170,8 +179,90 @@ export function eventPublisherReport(limit = 20): { publisher: PublisherStatus;
170
179
  return { publisher: publisherStatus(file, publisher.name), receipts: readReceipts(file, publisher.name).slice(-limit).reverse() };
171
180
  }
172
181
 
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 } {
182
+ // ── carried by git ──────────────────────────────────────────────────────────
183
+
184
+ export type GitSyncMode = 'off' | 'commit' | 'push';
185
+
186
+ /** `MW_EVENTS_GIT_SYNC`: `push` (or `1`, `true`), `commit`, else off. */
187
+ export function gitSyncMode(): GitSyncMode {
188
+ const value = process.env.MW_EVENTS_GIT_SYNC?.trim().toLowerCase();
189
+ if (value === 'push' || value === '1' || value === 'true') return 'push';
190
+ if (value === 'commit') return 'commit';
191
+ return 'off';
192
+ }
193
+
194
+ const GIT = Symbol.for('@medicine-wheel/app.event-git');
195
+ type GitState = {
196
+ running?: Promise<CommitResult | null>;
197
+ again?: boolean;
198
+ timer?: ReturnType<typeof setTimeout>;
199
+ interval?: ReturnType<typeof setInterval>;
200
+ failing?: boolean;
201
+ last?: CommitResult & { at: string };
202
+ };
203
+ const gitState: GitState = ((globalThis as Record<symbol, GitState>)[GIT] ??= {});
204
+
205
+ async function gitPass(): Promise<CommitResult | null> {
206
+ const mode = gitSyncMode();
207
+ if (mode === 'off') return null;
208
+ const result = await commitEventLog(eventLogFile(), { push: mode === 'push', instance: eventInstance() });
209
+ gitState.last = { ...result, at: new Date().toISOString() };
210
+ if (result.error && !gitState.failing) {
211
+ console.error(`[events] git carriage stopped: ${result.error} — the log keeps growing and the next pass tries again`);
212
+ } else if (!result.error && gitState.failing) {
213
+ console.log('[events] git carriage resumed');
214
+ }
215
+ if (result.committed) {
216
+ console.log(`[events] committed ${result.events} event(s) in ${result.commit?.slice(0, 8)}${result.pushed ? ', pushed' : ''}`);
217
+ }
218
+ gitState.failing = Boolean(result.error);
219
+ return result;
220
+ }
221
+
222
+ /** Commit (and push) the log now. A pass already running is joined, and runs once more after it. */
223
+ export function syncEventsToGit(): Promise<CommitResult | null> {
224
+ if (gitState.running) {
225
+ gitState.again = true;
226
+ return gitState.running;
227
+ }
228
+ const run = gitPass().then(async (result) => {
229
+ while (gitState.again) {
230
+ gitState.again = false;
231
+ result = (await gitPass()) ?? result;
232
+ }
233
+ return result;
234
+ }).finally(() => {
235
+ if (gitState.running === run) gitState.running = undefined;
236
+ });
237
+ gitState.running = run;
238
+ return run;
239
+ }
240
+
241
+ /** After a recorded event: commit soon, so a burst of writes travels as one commit. */
242
+ export function kickGitSync(): void {
243
+ if (gitSyncMode() === 'off' || gitState.timer) return;
244
+ gitState.timer = setTimeout(() => {
245
+ gitState.timer = undefined;
246
+ void syncEventsToGit();
247
+ }, Number(process.env.MW_EVENTS_GIT_DELAY_MS) || 30_000);
248
+ gitState.timer.unref?.();
249
+ }
250
+
251
+ /** Start the carriage clock from `instrumentation.ts`: a pass now, then every ten minutes. */
252
+ export function startEventGitSync(intervalMs = Number(process.env.MW_EVENTS_GIT_INTERVAL_MS) || 10 * 60_000): void {
253
+ if (gitSyncMode() === 'off' || gitState.interval) return;
254
+ gitState.interval = setInterval(() => { void syncEventsToGit(); }, intervalMs);
255
+ gitState.interval.unref?.();
256
+ void syncEventsToGit();
257
+ }
258
+
259
+ /** The carriage's mode and its last pass. */
260
+ export function eventGitStatus(): { mode: GitSyncMode; last?: GitState['last'] } {
261
+ return { mode: gitSyncMode(), ...(gitState.last ? { last: gitState.last } : {}) };
262
+ }
263
+
264
+ /** What `/api/health` reports: how many events, the last one, what the publisher owes, how the git carriage stands. */
265
+ export function eventLogStatus(): { count: number; last_at?: string; publisher?: PublisherStatus; git?: ReturnType<typeof eventGitStatus> } | { error: string } {
175
266
  try {
176
267
  const events = readEvents();
177
268
  const last = events[events.length - 1];
@@ -180,6 +271,7 @@ export function eventLogStatus(): { count: number; last_at?: string; publisher?:
180
271
  count: events.length,
181
272
  ...(last ? { last_at: last.observed_at } : {}),
182
273
  ...(publisher ? { publisher: publisherStatus(eventLogFile(), publisher.name) } : {}),
274
+ ...(gitSyncMode() !== 'off' ? { git: eventGitStatus() } : {}),
183
275
  };
184
276
  } catch (error) {
185
277
  return { error: (error as Error)?.message ?? String(error) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -92,33 +92,33 @@
92
92
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
93
93
  },
94
94
  "dependencies": {
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",
95
+ "@medicine-wheel/ceremonial-diary": "^0.19.0",
96
+ "@medicine-wheel/ceremony-protocol": "^0.19.0",
97
+ "@medicine-wheel/community-identity": "^0.19.0",
98
+ "@medicine-wheel/community-review": "^0.19.0",
99
+ "@medicine-wheel/consent-lifecycle": "^0.19.0",
100
+ "@medicine-wheel/creative-orientation": "^0.19.0",
101
+ "@medicine-wheel/data-store": "^0.19.0",
102
+ "@medicine-wheel/data-store-postgres": "^0.19.0",
103
+ "@medicine-wheel/events": "^0.19.0",
104
+ "@medicine-wheel/fire-keeper": "^0.19.0",
105
+ "@medicine-wheel/github-ceremony": "^0.19.0",
106
+ "@medicine-wheel/graph-viz": "^0.19.0",
107
+ "@medicine-wheel/honcho": "^0.19.0",
108
+ "@medicine-wheel/importance-unit": "^0.19.0",
109
+ "@medicine-wheel/mcp": "^4.19.0",
110
+ "@medicine-wheel/memory": "^0.19.0",
111
+ "@medicine-wheel/narrative-cluster": "^0.19.0",
112
+ "@medicine-wheel/narrative-engine": "^0.19.0",
113
+ "@medicine-wheel/ontology-core": "^0.19.0",
114
+ "@medicine-wheel/perception-layer": "^0.19.0",
115
+ "@medicine-wheel/prompt-decomposition": "^0.19.0",
116
+ "@medicine-wheel/relational-index": "^0.19.0",
117
+ "@medicine-wheel/relational-query": "^0.19.0",
118
+ "@medicine-wheel/session-reader": "^0.19.0",
119
+ "@medicine-wheel/storage-provider": "^0.19.0",
120
+ "@medicine-wheel/transformation-tracker": "^0.19.0",
121
+ "@medicine-wheel/ui-components": "^0.19.0",
122
122
  "@miadi/episode-vessel": "^0.2.1",
123
123
  "@neondatabase/serverless": "^0.10.0",
124
124
  "@xyflow/react": "^12.3.0",