@medicine-wheel/app 0.17.10 → 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
package/README.md
CHANGED
|
@@ -354,6 +354,48 @@ start, every five minutes, and after any projection that gets through —
|
|
|
354
354
|
`honcho.pending` counts what waits. A running server holds its old
|
|
355
355
|
build and its old environment — restart it for either to take effect.
|
|
356
356
|
|
|
357
|
+
### Event log
|
|
358
|
+
|
|
359
|
+
Every write to a cycle, beat, ceremony, diary entry, node or relation leaves one
|
|
360
|
+
line in `events.jsonl` beside the store (`MW_EVENTS_FILE` overrides). Nodes
|
|
361
|
+
include circles, chronicle episodes and memory conclusions. Captures, inquiry
|
|
362
|
+
weaves, plan perspectives, charts and MMOTs are not recorded yet. Each line is
|
|
363
|
+
a `MiadiEventEnvelope` (jgwill/Miadi
|
|
364
|
+
`rispecs/events/00-miadi-event-spine.rispec.md`) namespaced `medicine-wheel.*`:
|
|
365
|
+
`cycle.opened`, `beat.recorded`, `ceremony.closed`, `circle.created`,
|
|
366
|
+
`chronicle.episode.registered`, `relation.made`, … — 29 in all, catalogued in
|
|
367
|
+
[`@medicine-wheel/events`](src/events/README.md).
|
|
368
|
+
|
|
369
|
+
- A payload names records and carries labels. A cycle's question, a beat's
|
|
370
|
+
words and a diary entry's text stay in the store.
|
|
371
|
+
- A write that changes nothing writes no event.
|
|
372
|
+
- A log that cannot be written never fails the write.
|
|
373
|
+
- The MCP server's local store records its own writes the same way.
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
curl 'localhost:8040/api/events?type=cycle.&limit=20' # newest first; type, subject, correlation, since, limit
|
|
377
|
+
mw events --correlation <cycle-id> # a cycle's moments; also on the cycle's page
|
|
378
|
+
mw events schema [--asyncapi] # the JSON Schema, or the AsyncAPI document
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Set `MW_INSTANCE` (e.g. `ilex:8040`) to name this wheel in each event's
|
|
382
|
+
`source.instance`.
|
|
383
|
+
|
|
384
|
+
**Publishing.** Nothing leaves the machine until `MW_EVENTS_PUBLISH_URL` is set.
|
|
385
|
+
Once it is, the wheel POSTs each event, in log order, after every write and
|
|
386
|
+
every minute (`MW_EVENTS_PUBLISH_INTERVAL_MS`).
|
|
387
|
+
|
|
388
|
+
- **Headers:** `Idempotency-Key`, `X-Event-Type`, and `Authorization: Bearer
|
|
389
|
+
$MW_EVENTS_PUBLISH_TOKEN` when a token is set.
|
|
390
|
+
- **Delivery records:** a cursor and receipts beside the log record
|
|
391
|
+
`arrived`, `failed` (retried) or `refused` (passed).
|
|
392
|
+
- **Checking and replaying:** `mw events status`, `mw events publish` and
|
|
393
|
+
`mw events replay <event-id|all>` (also `GET`/`POST /api/events/publish`).
|
|
394
|
+
- **What can receive it:** a webhook endpoint, a Hookdeck source, a Cloudflare
|
|
395
|
+
Worker in front of a queue, or the Miadi hooks-gateway. A broker client is
|
|
396
|
+
another publisher beside it. The events keep their names
|
|
397
|
+
(jgwill/medicine-wheel#164, miadi-chronicle://548).
|
|
398
|
+
|
|
357
399
|
## License
|
|
358
400
|
|
|
359
401
|
Indigenous Knowledge Stewardship License (IKSL) v1.0 MIT Derived see [LICENSE](LICENSE)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
|
+
import { ceremonyRemoved } from "@medicine-wheel/events";
|
|
3
4
|
import { getAllBeats } from "@/lib/store";
|
|
5
|
+
import { recordEvent } from "@/lib/event-log";
|
|
4
6
|
|
|
5
7
|
type RouteContext = { params: Promise<{ id: string }> };
|
|
6
8
|
|
|
@@ -52,6 +54,7 @@ export async function DELETE(_request: Request, context: RouteContext) {
|
|
|
52
54
|
);
|
|
53
55
|
}
|
|
54
56
|
await store.deleteCeremony(id);
|
|
57
|
+
recordEvent(ceremonyRemoved(ceremony));
|
|
55
58
|
return NextResponse.json({ success: true, deleted: { id }, provider: detectProvider() });
|
|
56
59
|
} catch (error: unknown) {
|
|
57
60
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -4,6 +4,8 @@ import { createProvider, detectProvider } from "@medicine-wheel/storage-provider
|
|
|
4
4
|
import { ceremonyBelongsToEpisode, ceremonyEpisodePath } from "@/lib/ceremony-response";
|
|
5
5
|
import { projectCeremony } from "@medicine-wheel/honcho";
|
|
6
6
|
import { projectAfterWrite } from "@/lib/honcho-projection";
|
|
7
|
+
import { ceremonyWritten } from "@medicine-wheel/events";
|
|
8
|
+
import { recordEvent } from "@/lib/event-log";
|
|
7
9
|
|
|
8
10
|
/** Episode directory names are `YYYY-MM-DD-episode-NNN-slug`; nothing else may bind. */
|
|
9
11
|
const EPISODE_PATH = /^\d{4}-\d{2}-\d{2}-episode-\d{3,}-[a-z0-9-]+$/;
|
|
@@ -159,10 +161,14 @@ export async function POST(request: Request) {
|
|
|
159
161
|
...(typeof body.subject_id === "string" ? { subject_id: body.subject_id } : {}),
|
|
160
162
|
};
|
|
161
163
|
|
|
164
|
+
const existed = typeof body.id === "string" && body.id ? await store.getCeremony(body.id) : null;
|
|
162
165
|
await store.logCeremony(ceremony);
|
|
163
166
|
// The river: the stored ceremony leaves for Honcho in the background when
|
|
164
167
|
// HONCHO_URL is set. Never awaited.
|
|
165
168
|
projectAfterWrite({ kind: "ceremony", id: ceremony.id }, () => projectCeremony(ceremony));
|
|
169
|
+
// The event log (#164). A closing names the ceremony it closes, and both
|
|
170
|
+
// share that ceremony's id as their correlation.
|
|
171
|
+
recordEvent(ceremonyWritten(Boolean(existed), ceremony));
|
|
166
172
|
return NextResponse.json({ success: true, ceremony, provider: detectProvider() }, { status: 201 });
|
|
167
173
|
} catch (error: unknown) {
|
|
168
174
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
|
+
import { diaryRemoved } from "@medicine-wheel/events";
|
|
4
|
+
import { recordEvent } from "@/lib/event-log";
|
|
3
5
|
|
|
4
6
|
type RouteContext = { params: Promise<{ id: string }> };
|
|
5
7
|
|
|
@@ -23,6 +25,7 @@ export async function DELETE(_request: Request, context: RouteContext) {
|
|
|
23
25
|
const entry = await store.getDiaryEntry(id);
|
|
24
26
|
if (!entry) return NextResponse.json({ error: `No diary entry with id ${id}` }, { status: 404 });
|
|
25
27
|
await store.deleteDiaryEntry(id);
|
|
28
|
+
recordEvent(diaryRemoved(entry));
|
|
26
29
|
return NextResponse.json({ success: true, deleted: { id }, provider: detectProvider() });
|
|
27
30
|
} catch (error: unknown) {
|
|
28
31
|
const message = error instanceof Error ? error.message : String(error);
|
package/app/api/diary/route.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { DiaryEntryFilters, DiaryEntryRecord } from "@medicine-wheel/storag
|
|
|
5
5
|
import { createDiaryEntry } from "@medicine-wheel/ceremonial-diary";
|
|
6
6
|
import { projectDiaryEntry } from "@medicine-wheel/honcho";
|
|
7
7
|
import { projectAfterWrite } from "@/lib/honcho-projection";
|
|
8
|
+
import { diaryWritten } from "@medicine-wheel/events";
|
|
9
|
+
import { recordEvent } from "@/lib/event-log";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* The ceremonial diary door (0.14.0).
|
|
@@ -113,6 +115,7 @@ export async function POST(request: Request) {
|
|
|
113
115
|
}
|
|
114
116
|
metadata.ceremony_id = body.ceremony_id;
|
|
115
117
|
}
|
|
118
|
+
const existed = typeof body.id === "string" && body.id ? await store.getDiaryEntry(body.id) : null;
|
|
116
119
|
const entry = await createDiaryEntry(store, {
|
|
117
120
|
...(typeof body.id === "string" && body.id ? { id: body.id } : {}),
|
|
118
121
|
...(typeof body.timestamp === "string" && body.timestamp ? { timestamp: body.timestamp } : {}),
|
|
@@ -127,6 +130,9 @@ export async function POST(request: Request) {
|
|
|
127
130
|
// The river: the participant's voice leaves for Honcho in the background
|
|
128
131
|
// when HONCHO_URL is set. Never awaited.
|
|
129
132
|
projectAfterWrite({ kind: "diary", id: entry.id }, () => projectDiaryEntry(entry));
|
|
133
|
+
// The event log (#164): who wrote, in which phase and ceremony — not what
|
|
134
|
+
// they wrote, which stays in the diary.
|
|
135
|
+
recordEvent(diaryWritten(Boolean(existed), entry));
|
|
130
136
|
return NextResponse.json({ success: true, entry, provider: detectProvider() }, { status: 201 });
|
|
131
137
|
} catch (error: unknown) {
|
|
132
138
|
const message = error instanceof Error ? error.message : String(error);
|
package/app/api/edges/route.ts
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
detectProvider,
|
|
6
6
|
EdgeNotFoundError,
|
|
7
7
|
} from "@medicine-wheel/storage-provider";
|
|
8
|
+
import { relationRemoved, relationWritten } from "@medicine-wheel/events";
|
|
8
9
|
import { parseLimit } from "@/lib/api-paging";
|
|
10
|
+
import { recordEvent } from "@/lib/event-log";
|
|
9
11
|
|
|
10
12
|
const EdgeCreateSchema = z.object({
|
|
11
13
|
from_id: z.string({ required_error: "from_id is required" }).trim().min(1),
|
|
@@ -160,7 +162,8 @@ export async function POST(request: Request) {
|
|
|
160
162
|
);
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
const
|
|
165
|
+
const previous = await store.getEdge(parsed.data.from_id, parsed.data.to_id);
|
|
166
|
+
const replaced = Boolean(previous);
|
|
164
167
|
|
|
165
168
|
const edge = {
|
|
166
169
|
from_id: parsed.data.from_id,
|
|
@@ -174,6 +177,9 @@ export async function POST(request: Request) {
|
|
|
174
177
|
};
|
|
175
178
|
|
|
176
179
|
await store.createEdge(edge);
|
|
180
|
+
// The event log (#164): a relation made, or — over an existing pair —
|
|
181
|
+
// amended in what moved.
|
|
182
|
+
recordEvent(relationWritten(previous, edge));
|
|
177
183
|
return NextResponse.json(
|
|
178
184
|
{ success: true, edge, replaced, provider: detectProvider() },
|
|
179
185
|
{ status: 201 },
|
|
@@ -208,7 +214,9 @@ export async function PATCH(request: Request) {
|
|
|
208
214
|
}
|
|
209
215
|
|
|
210
216
|
const store = await createProvider();
|
|
217
|
+
const previous = await store.getEdge(pair.from, pair.to);
|
|
211
218
|
const edge = await store.updateEdge(pair.from, pair.to, parsed.data);
|
|
219
|
+
recordEvent(relationWritten(previous ?? edge, edge));
|
|
212
220
|
return NextResponse.json({ success: true, edge, provider: detectProvider() });
|
|
213
221
|
} catch (error: unknown) {
|
|
214
222
|
if (error instanceof EdgeNotFoundError) {
|
|
@@ -225,7 +233,9 @@ export async function DELETE(request: Request) {
|
|
|
225
233
|
if (!pair) return pairRequired();
|
|
226
234
|
|
|
227
235
|
const store = await createProvider();
|
|
236
|
+
const previous = await store.getEdge(pair.from, pair.to);
|
|
228
237
|
await store.deleteEdge(pair.from, pair.to);
|
|
238
|
+
recordEvent(relationRemoved(previous ?? { from_id: pair.from, to_id: pair.to }));
|
|
229
239
|
return NextResponse.json({
|
|
230
240
|
success: true,
|
|
231
241
|
deleted: { from_id: pair.from, to_id: pair.to },
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { eventPublisherReport, publishPending, replayEvents } from "@/lib/event-log";
|
|
3
|
+
|
|
4
|
+
const NO_PUBLISHER = {
|
|
5
|
+
error: "No publisher is configured — the events wait in events.jsonl.",
|
|
6
|
+
hint: "Set MW_EVENTS_PUBLISH_URL (and MW_EVENTS_PUBLISH_TOKEN) and restart the wheel.",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The publisher's side of the log (#164) — episode 548's Send, Receipt,
|
|
11
|
+
* Failure and Replay.
|
|
12
|
+
*
|
|
13
|
+
* GET: where the configured publisher stands (`pending`, the last event it is
|
|
14
|
+
* done with, the last arrival, the last failure) and its latest receipts.
|
|
15
|
+
* POST: send what waits now. `{ "replay_from": "<event id>" }` first moves the
|
|
16
|
+
* cursor back so that event and every later one go out again, with the same
|
|
17
|
+
* idempotency keys; `{ "replay_from": "all" }` replays the whole log.
|
|
18
|
+
*/
|
|
19
|
+
export async function GET() {
|
|
20
|
+
const report = eventPublisherReport();
|
|
21
|
+
if (!report) return NextResponse.json(NO_PUBLISHER, { status: 409 });
|
|
22
|
+
return NextResponse.json(report);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function POST(request: Request) {
|
|
26
|
+
const body = (await request.json().catch(() => ({}))) as { replay_from?: unknown };
|
|
27
|
+
let replay: { pending: number } | null = null;
|
|
28
|
+
if (body?.replay_from !== undefined) {
|
|
29
|
+
if (typeof body.replay_from !== "string" || !body.replay_from) {
|
|
30
|
+
return NextResponse.json({ error: 'replay_from must be an event id, or "all"' }, { status: 400 });
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
replay = replayEvents(body.replay_from === "all" ? undefined : body.replay_from);
|
|
34
|
+
} catch (error: unknown) {
|
|
35
|
+
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 404 });
|
|
36
|
+
}
|
|
37
|
+
if (!replay) return NextResponse.json(NO_PUBLISHER, { status: 409 });
|
|
38
|
+
}
|
|
39
|
+
const result = await publishPending();
|
|
40
|
+
if (!result) return NextResponse.json(NO_PUBLISHER, { status: 409 });
|
|
41
|
+
return NextResponse.json({ ...(replay ? { replayed: replay.pending } : {}), ...result });
|
|
42
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { parseLimit } from "@/lib/api-paging";
|
|
3
|
+
import { filterEvents, readEvents } from "@/lib/event-log";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The wheel's event log, read (#164).
|
|
7
|
+
*
|
|
8
|
+
* `?type=cycle.opened` (or the namespaced `medicine-wheel.cycle.opened`; a
|
|
9
|
+
* trailing dot, `cycle.`, takes the family), `?subject=<record id>`,
|
|
10
|
+
* `?correlation=<id>` (a cycle's events and the beats recorded in it),
|
|
11
|
+
* `?since=<ISO time>`, `?limit=<n>|all`. Newest first, so the moments a person
|
|
12
|
+
* is looking for come before the history behind them. `total` counts every
|
|
13
|
+
* match, so a caller holding a page knows it holds one.
|
|
14
|
+
*/
|
|
15
|
+
export async function GET(request: Request) {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(request.url);
|
|
18
|
+
const limit = parseLimit(url.searchParams.get("limit"));
|
|
19
|
+
if (limit instanceof NextResponse) return limit;
|
|
20
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
21
|
+
if (since !== undefined && Number.isNaN(Date.parse(since))) {
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ error: `Invalid since: ${since} — nothing was returned.`, hint: "since must be an ISO 8601 time." },
|
|
24
|
+
{ status: 400 },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const matching = filterEvents(readEvents(), {
|
|
28
|
+
type: url.searchParams.get("type") ?? undefined,
|
|
29
|
+
subject: url.searchParams.get("subject") ?? undefined,
|
|
30
|
+
correlation: url.searchParams.get("correlation") ?? undefined,
|
|
31
|
+
since,
|
|
32
|
+
}).reverse();
|
|
33
|
+
const events = limit === null ? matching : matching.slice(0, limit);
|
|
34
|
+
return NextResponse.json({ events, total: matching.length });
|
|
35
|
+
} catch (error: unknown) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
38
|
+
}
|
|
39
|
+
}
|
package/app/api/health/route.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { createProvider, detectProvider } from '@medicine-wheel/storage-provider';
|
|
3
3
|
import { honchoProjectionStatus } from '@/lib/honcho-projection';
|
|
4
|
+
import { eventLogStatus } from '@/lib/event-log';
|
|
4
5
|
|
|
5
6
|
export async function GET() {
|
|
6
7
|
const providerType = detectProvider();
|
|
@@ -31,6 +32,10 @@ export async function GET() {
|
|
|
31
32
|
// reports enabled projects every stored beat, ceremony and diary entry;
|
|
32
33
|
// `pending` counts those still waiting for Honcho to take them (#147).
|
|
33
34
|
honcho: honchoProjectionStatus(),
|
|
35
|
+
// The event log (#164): how many events the wheel has written beside its
|
|
36
|
+
// store, when the last one was, and — with a publisher configured — how
|
|
37
|
+
// many it still owes. Read them on /api/events.
|
|
38
|
+
events: eventLogStatus(),
|
|
34
39
|
env: {
|
|
35
40
|
MW_STORAGE_PROVIDER: process.env.MW_STORAGE_PROVIDER || 'not set',
|
|
36
41
|
DATABASE_URL: process.env.DATABASE_URL ? 'configured' : 'not configured',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { MEMORY_PROJECTION_KIND } from '@medicine-wheel/honcho';
|
|
3
3
|
import { createProvider } from '@medicine-wheel/storage-provider';
|
|
4
|
+
import { conclusionJudged } from '@medicine-wheel/events';
|
|
5
|
+
import { recordEvent } from '@/lib/event-log';
|
|
4
6
|
|
|
5
7
|
export const dynamic = 'force-dynamic';
|
|
6
8
|
type RouteContext = { params: Promise<{ id: string }> };
|
|
@@ -27,6 +29,8 @@ export async function PATCH(request: Request, context: RouteContext) {
|
|
|
27
29
|
const updated = await store.updateNode(id, {
|
|
28
30
|
metadata: { ...node.metadata, status: body.status, judged_by: by, judged_at: new Date().toISOString() },
|
|
29
31
|
});
|
|
32
|
+
// The event log (#164): a person confirmed or rejected it.
|
|
33
|
+
recordEvent(conclusionJudged(node, updated));
|
|
30
34
|
return NextResponse.json({ node: updated }, { headers: { 'cache-control': 'no-store' } });
|
|
31
35
|
} catch (error) {
|
|
32
36
|
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { honchoIdFor, memoryProjectionNode, type MemoryProjectionKind, type MemoryProjectionStatus } from '@medicine-wheel/honcho';
|
|
3
3
|
import { createProvider } from '@medicine-wheel/storage-provider';
|
|
4
|
+
import { nodeWritten } from '@medicine-wheel/events';
|
|
5
|
+
import { recordEvent } from '@/lib/event-log';
|
|
4
6
|
|
|
5
7
|
export const dynamic = 'force-dynamic';
|
|
6
8
|
|
|
@@ -36,7 +38,10 @@ export async function POST(request: Request) {
|
|
|
36
38
|
});
|
|
37
39
|
node.metadata.about = about;
|
|
38
40
|
const store = await createProvider();
|
|
41
|
+
const before = await store.getNode(node.id);
|
|
39
42
|
await store.createNode(node as never);
|
|
43
|
+
// The event log (#164): memory.conclusion.recorded, about its person.
|
|
44
|
+
recordEvent(nodeWritten(before, node));
|
|
40
45
|
return NextResponse.json({ node }, { status: 201, headers: { 'cache-control': 'no-store' } });
|
|
41
46
|
} catch (error) {
|
|
42
47
|
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getBeat, witnessBeat } from "@/lib/store";
|
|
4
|
+
import { beatWitnessed } from "@medicine-wheel/events";
|
|
5
|
+
import { recordEvent } from "@/lib/event-log";
|
|
4
6
|
|
|
5
7
|
type RouteContext = { params: Promise<{ id: string }> };
|
|
6
8
|
|
|
@@ -49,7 +51,11 @@ export async function PATCH(request: Request, context: RouteContext) {
|
|
|
49
51
|
{ status: 400 },
|
|
50
52
|
);
|
|
51
53
|
}
|
|
54
|
+
const before = getBeat(id);
|
|
52
55
|
const beat = witnessBeat(id, parsed.data);
|
|
53
56
|
if (!beat) return notFound(id);
|
|
57
|
+
// The event log (#164): a witness added or a speaker named. A name already
|
|
58
|
+
// witnessing, or the speaker restated, adds nothing and records nothing.
|
|
59
|
+
recordEvent(beatWitnessed(before, beat));
|
|
54
60
|
return NextResponse.json(beat);
|
|
55
61
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import { getAllBeats, createBeat } from "@/lib/store";
|
|
2
|
+
import { getAllBeats, getBeat, createBeat } from "@/lib/store";
|
|
3
3
|
import { projectBeat } from "@medicine-wheel/honcho";
|
|
4
4
|
import { projectAfterWrite } from "@/lib/honcho-projection";
|
|
5
|
+
import { beatWritten } from "@medicine-wheel/events";
|
|
6
|
+
import { recordEvent } from "@/lib/event-log";
|
|
5
7
|
|
|
6
8
|
export async function GET() {
|
|
7
9
|
try {
|
|
@@ -14,6 +16,7 @@ export async function GET() {
|
|
|
14
16
|
export async function POST(request: Request) {
|
|
15
17
|
try {
|
|
16
18
|
const body = await request.json();
|
|
19
|
+
const before = typeof body.id === "string" && body.id ? getBeat(body.id) : undefined;
|
|
17
20
|
// Pass the caller's id and timestamp through when supplied. A client that
|
|
18
21
|
// authored a beat holds its id and will look it up again — minting a fresh
|
|
19
22
|
// one here hands back a beat the caller can never find.
|
|
@@ -38,6 +41,11 @@ export async function POST(request: Request) {
|
|
|
38
41
|
// The river: a stored beat leaves for Honcho in the background when
|
|
39
42
|
// HONCHO_URL is set. Never awaited — the wheel answers on its own clock.
|
|
40
43
|
projectAfterWrite({ kind: "beat", id: beat.id }, () => projectBeat(beat));
|
|
44
|
+
// The event log (#164): a new beat is recorded; a known one is amended
|
|
45
|
+
// only in what moved — re-posting a beat as it stands is not an occurrence.
|
|
46
|
+
// Read back through the same normalization as `before`, so a field the
|
|
47
|
+
// reader fills in is not mistaken for one the write moved.
|
|
48
|
+
recordEvent(beatWritten(before, before ? getBeat(beat.id) ?? beat : beat));
|
|
41
49
|
// Warnings ride on the created beat rather than replacing it, so clients
|
|
42
50
|
// that read the beat back by id keep working while advisory findings stop
|
|
43
51
|
// being computed-and-discarded.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { getAllCycles, createCycle, upsertCycle } from "@/lib/store";
|
|
3
|
+
import { cycleWritten } from "@medicine-wheel/events";
|
|
4
|
+
import type { ApiMedicineWheelCycle } from "@/lib/cycle-response";
|
|
5
|
+
import { recordEvent } from "@/lib/event-log";
|
|
3
6
|
|
|
4
7
|
export async function GET() {
|
|
5
8
|
try {
|
|
@@ -18,15 +21,19 @@ export async function POST(request: Request) {
|
|
|
18
21
|
// id here is how archiving and beat-binding duplicated cycles instead of
|
|
19
22
|
// amending them; refusing outright is how a client's own id vanished.
|
|
20
23
|
if (typeof body.id === "string" && body.id.length > 0) {
|
|
21
|
-
const
|
|
22
|
-
const cycle = upsertCycle(body);
|
|
23
|
-
|
|
24
|
+
const before: ApiMedicineWheelCycle | undefined = getAllCycles().find((c) => c.id === body.id);
|
|
25
|
+
const cycle: ApiMedicineWheelCycle = upsertCycle(body);
|
|
26
|
+
// The event log (#164): opened, archived, or amended in the fields that
|
|
27
|
+
// moved. Writing back what is already stored is not an occurrence.
|
|
28
|
+
recordEvent(cycleWritten(before, cycle, Object.keys(body)));
|
|
29
|
+
return NextResponse.json(cycle, { status: before ? 200 : 201 });
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
const cycle = createCycle({
|
|
27
33
|
research_question: body.research_question,
|
|
28
34
|
current_direction: body.current_direction,
|
|
29
35
|
});
|
|
36
|
+
recordEvent(cycleWritten(null, cycle, []));
|
|
30
37
|
return NextResponse.json(cycle, { status: 201 });
|
|
31
38
|
} catch (error: any) {
|
|
32
39
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
NodeNotFoundError,
|
|
8
8
|
NodeHasRelationsError,
|
|
9
9
|
} from "@medicine-wheel/storage-provider";
|
|
10
|
+
import { nodeRemoved, nodeWritten } from "@medicine-wheel/events";
|
|
11
|
+
import { recordEvent } from "@/lib/event-log";
|
|
10
12
|
|
|
11
13
|
// `direction: null` releases the node from its direction.
|
|
12
14
|
const NodePatchSchema = z
|
|
@@ -82,8 +84,8 @@ export async function PATCH(request: Request, context: RouteContext) {
|
|
|
82
84
|
// silently drops its siblings breaks that everywhere it is used. A caller
|
|
83
85
|
// that genuinely wants to clear a key now says so with `null`.
|
|
84
86
|
let patch = parsed.data;
|
|
87
|
+
const existing = await store.getNode(id);
|
|
85
88
|
if (patch.metadata) {
|
|
86
|
-
const existing = await store.getNode(id);
|
|
87
89
|
if (existing) {
|
|
88
90
|
const merged: Record<string, unknown> = {
|
|
89
91
|
...(existing.metadata ?? {}),
|
|
@@ -99,6 +101,8 @@ export async function PATCH(request: Request, context: RouteContext) {
|
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
const node = await store.updateNode(id, patch);
|
|
104
|
+
// The event log (#164): amended in the fields and metadata keys that moved.
|
|
105
|
+
recordEvent(nodeWritten(existing, node));
|
|
102
106
|
return NextResponse.json({ success: true, node, provider: detectProvider() });
|
|
103
107
|
} catch (error: unknown) {
|
|
104
108
|
if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
|
|
@@ -110,7 +114,9 @@ export async function DELETE(_request: Request, context: RouteContext) {
|
|
|
110
114
|
try {
|
|
111
115
|
const { id } = await context.params;
|
|
112
116
|
const store = await createProvider();
|
|
117
|
+
const node = await store.getNode(id);
|
|
113
118
|
await store.deleteNode(id);
|
|
119
|
+
if (node) recordEvent(nodeRemoved(node));
|
|
114
120
|
return NextResponse.json({ success: true, deleted: id, provider: detectProvider() });
|
|
115
121
|
} catch (error: unknown) {
|
|
116
122
|
if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
|
package/app/api/nodes/route.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { DirectionNameSchema, NodeTypeSchema } from "@medicine-wheel/ontology-core";
|
|
4
4
|
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
5
|
+
import { nodeWritten } from "@medicine-wheel/events";
|
|
5
6
|
import { parseLimit } from "@/lib/api-paging";
|
|
7
|
+
import { recordEvent } from "@/lib/event-log";
|
|
6
8
|
|
|
7
9
|
const NodeCreateSchema = z.object({
|
|
8
10
|
id: z.string().trim().min(1).optional(),
|
|
@@ -171,7 +173,11 @@ export async function POST(request: Request) {
|
|
|
171
173
|
updated_at: new Date().toISOString(),
|
|
172
174
|
};
|
|
173
175
|
|
|
176
|
+
const before = parsed.data.id ? await store.getNode(parsed.data.id) : null;
|
|
174
177
|
await store.createNode(node);
|
|
178
|
+
// The event log (#164): a circle created, an episode registered, a node
|
|
179
|
+
// created — or, under a known id, amended in what moved.
|
|
180
|
+
recordEvent(nodeWritten(before, node));
|
|
175
181
|
return NextResponse.json({ success: true, node, provider: detectProvider() }, { status: 201 });
|
|
176
182
|
} catch (error: unknown) {
|
|
177
183
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -5,6 +5,7 @@ import type { FormEvent } from "react";
|
|
|
5
5
|
import { type MedicineWheelCycle, type DirectionName } from "@/lib/types";
|
|
6
6
|
import { extractCycles } from "@/lib/cycle-response";
|
|
7
7
|
import { toast } from "sonner";
|
|
8
|
+
import { CycleEvents } from "@/components/cycle-events";
|
|
8
9
|
|
|
9
10
|
const dirVar = (d: string) => `var(--mw-${d})`;
|
|
10
11
|
const dirInk = (d: string) => `var(--mw-${d}-ink)`;
|
|
@@ -138,6 +139,7 @@ export default function CyclesPage() {
|
|
|
138
139
|
<p className="font-bold">{cycle.relations_mapped}</p>
|
|
139
140
|
</div>
|
|
140
141
|
</div>
|
|
142
|
+
<CycleEvents cycleId={cycle.id} />
|
|
141
143
|
<p className="mt-3 text-xs text-muted-foreground">Cycle ID: {cycle.id}</p>
|
|
142
144
|
</div>
|
|
143
145
|
)}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { shortName, type WheelEvent } from "@medicine-wheel/events";
|
|
5
|
+
import { relativeTime, absoluteTime } from "@/lib/format-time";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A cycle's moments, newest first: when it was opened, amended, archived, and
|
|
9
|
+
* each beat recorded or witnessed in it (#164). Read from `/api/events` by the
|
|
10
|
+
* cycle's id as correlation. The events carry ids, not words, so a beat's title
|
|
11
|
+
* is read from the beats the wheel holds.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** `chronicle.episode.registered` → "Chronicle episode registered". */
|
|
15
|
+
export function eventLabel(eventType: string): string {
|
|
16
|
+
const words = shortName(eventType).split(".").join(" ").replace(/_/g, " ");
|
|
17
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What one line adds beside its label: the beat's title, the fields that moved, the witnesses added. */
|
|
21
|
+
export function eventDetail(event: WheelEvent, beatTitles: Record<string, string>): string {
|
|
22
|
+
const payload = event.payload as Record<string, unknown>;
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
if (event.subject.kind === "beat" && beatTitles[event.subject.id]) parts.push(`“${beatTitles[event.subject.id]}”`);
|
|
25
|
+
if (Array.isArray(payload.changed) && payload.changed.length) parts.push(`changed ${payload.changed.join(", ")}`);
|
|
26
|
+
if (Array.isArray(payload.added_witnesses) && payload.added_witnesses.length) {
|
|
27
|
+
parts.push(`${payload.added_witnesses.length} witness${payload.added_witnesses.length === 1 ? "" : "es"} added`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof payload.speaker === "string" && event.event_type.endsWith("witnessed")) parts.push(`speaker ${payload.speaker}`);
|
|
30
|
+
if (typeof payload.current_direction === "string" && event.event_type.endsWith("cycle.opened")) parts.push(`in the ${payload.current_direction}`);
|
|
31
|
+
return parts.join(" · ");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CycleEvents({ cycleId }: { cycleId: string }) {
|
|
35
|
+
const [events, setEvents] = useState<WheelEvent[] | null>(null);
|
|
36
|
+
const [total, setTotal] = useState(0);
|
|
37
|
+
const [beatTitles, setBeatTitles] = useState<Record<string, string>>({});
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
let live = true;
|
|
41
|
+
fetch(`/api/events?correlation=${encodeURIComponent(cycleId)}&limit=50`)
|
|
42
|
+
.then((r) => r.json())
|
|
43
|
+
.then((d) => {
|
|
44
|
+
if (!live) return;
|
|
45
|
+
setEvents(Array.isArray(d.events) ? d.events : []);
|
|
46
|
+
setTotal(typeof d.total === "number" ? d.total : 0);
|
|
47
|
+
})
|
|
48
|
+
.catch(() => live && setEvents([]));
|
|
49
|
+
fetch("/api/narrative/beats")
|
|
50
|
+
.then((r) => r.json())
|
|
51
|
+
.then((d) => {
|
|
52
|
+
if (!live || !Array.isArray(d)) return;
|
|
53
|
+
setBeatTitles(Object.fromEntries(d.map((b: { id: string; title: string }) => [b.id, b.title])));
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {});
|
|
56
|
+
return () => { live = false; };
|
|
57
|
+
}, [cycleId]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
|
|
61
|
+
<p className="text-xs font-medium text-muted-foreground mb-2">
|
|
62
|
+
Moments {events && total > events.length ? `(${events.length} of ${total})` : ""}
|
|
63
|
+
</p>
|
|
64
|
+
{events === null && <p className="text-xs text-muted-foreground">Reading the event log…</p>}
|
|
65
|
+
{events?.length === 0 && (
|
|
66
|
+
<p className="text-xs text-muted-foreground">No events yet. This wheel records them from the moment the event log was installed; earlier moments were not kept.</p>
|
|
67
|
+
)}
|
|
68
|
+
{!!events?.length && (
|
|
69
|
+
<ol className="space-y-1.5 border-l pl-3">
|
|
70
|
+
{events.map((event) => (
|
|
71
|
+
<li key={event.id} className="text-xs">
|
|
72
|
+
<span className="text-muted-foreground" title={absoluteTime(event.observed_at)}>{relativeTime(event.observed_at)}</span>
|
|
73
|
+
{" · "}
|
|
74
|
+
<span className="font-medium">{eventLabel(event.event_type)}</span>
|
|
75
|
+
{eventDetail(event, beatTitles) && <span className="text-muted-foreground"> · {eventDetail(event, beatTitles)}</span>}
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ol>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folders of an episode, drawn — every one open to begin with, so a file's
|
|
3
|
+
* folder is plain to see. Presentational: the panel owns which folders are
|
|
4
|
+
* closed and how a file's own row looks.
|
|
5
|
+
*
|
|
6
|
+
* @see lib/episode-file-tree.ts — how the paths are grouped and in what order
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import type { FileTreeFolder, TreeableFile } from "@/lib/episode-file-tree";
|
|
11
|
+
|
|
12
|
+
export interface EpisodeFileTreeProps<T extends TreeableFile> {
|
|
13
|
+
tree: FileTreeFolder<T>;
|
|
14
|
+
/** Paths of the folders the reader closed. Any folder not here is open. */
|
|
15
|
+
collapsed: ReadonlySet<string>;
|
|
16
|
+
onToggleFolder: (path: string) => void;
|
|
17
|
+
/** One file's row, and whatever opens beneath it. `name` is the path's last segment. */
|
|
18
|
+
renderFile: (file: T, name: string) => ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function EpisodeFileTree<T extends TreeableFile>({
|
|
22
|
+
tree,
|
|
23
|
+
collapsed,
|
|
24
|
+
onToggleFolder,
|
|
25
|
+
renderFile,
|
|
26
|
+
}: EpisodeFileTreeProps<T>) {
|
|
27
|
+
return (
|
|
28
|
+
<ul className="space-y-1">
|
|
29
|
+
{tree.children.map((node) => {
|
|
30
|
+
if (node.type === "file") {
|
|
31
|
+
return <li key={node.path}>{renderFile(node.file, node.name)}</li>;
|
|
32
|
+
}
|
|
33
|
+
const isOpen = !collapsed.has(node.path);
|
|
34
|
+
return (
|
|
35
|
+
<li key={node.path} data-folder={node.path}>
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() => onToggleFolder(node.path)}
|
|
39
|
+
aria-expanded={isOpen}
|
|
40
|
+
title={node.path}
|
|
41
|
+
className="w-full p-2 flex items-baseline gap-2 text-left text-sm font-semibold hover:underline"
|
|
42
|
+
>
|
|
43
|
+
<span aria-hidden="true" className="text-muted-foreground w-[1ch]">
|
|
44
|
+
{isOpen ? "▾" : "▸"}
|
|
45
|
+
</span>
|
|
46
|
+
<span aria-hidden="true">{isOpen ? "📂" : "📁"}</span>
|
|
47
|
+
<span className="font-mono break-all">{node.name}</span>
|
|
48
|
+
<span className="ml-auto text-xs font-normal text-muted-foreground shrink-0">
|
|
49
|
+
{node.fileCount} {node.fileCount === 1 ? "file" : "files"}
|
|
50
|
+
</span>
|
|
51
|
+
</button>
|
|
52
|
+
{isOpen && (
|
|
53
|
+
<div className="ml-4 pl-2 border-l">
|
|
54
|
+
<EpisodeFileTree
|
|
55
|
+
tree={node}
|
|
56
|
+
collapsed={collapsed}
|
|
57
|
+
onToggleFolder={onToggleFolder}
|
|
58
|
+
renderFile={renderFile}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</li>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</ul>
|
|
66
|
+
);
|
|
67
|
+
}
|