@medicine-wheel/app 0.19.1 → 0.20.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 +17 -14
- package/app/api/events/publish/route.ts +20 -18
- package/dist/cli/mw.js +7 -6
- package/lib/event-log.ts +127 -51
- package/package.json +28 -28
package/README.md
CHANGED
|
@@ -391,20 +391,23 @@ Two chronicle episodes related through the Miadi lineage door (`POST /api/edges`
|
|
|
391
391
|
between two `chronicle_episode` nodes) produce `chronicle.lineage.made`, which
|
|
392
392
|
carries the manifest's sentence that says why.
|
|
393
393
|
|
|
394
|
-
**Publishing.** Nothing leaves the machine until
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
394
|
+
**Publishing.** Nothing leaves the machine until a destination is configured. Each destination is an
|
|
395
|
+
adapter with its own cursor, so one that is down never holds another back:
|
|
396
|
+
|
|
397
|
+
- any HTTP receiver: `MW_EVENTS_PUBLISH_URL`, with `_FORMAT` = `envelope`, `cloudevents` or
|
|
398
|
+
`cloudevents-binary`;
|
|
399
|
+
- a Hookdeck source: `MW_EVENTS_HOOKDECK_SOURCE_URL`;
|
|
400
|
+
- a Cloudflare queue: `MW_EVENTS_CF_ACCOUNT_ID`, `MW_EVENTS_CF_QUEUE_ID` and `MW_EVENTS_CF_API_TOKEN`.
|
|
401
|
+
|
|
402
|
+
The wheel sends in log order, after every write and every minute. Each adapter declares what it guarantees,
|
|
403
|
+
and receipts record `arrived`, `failed` (kept and retried) or `refused` (recorded and passed). Wrong
|
|
404
|
+
credentials and wrong addresses count as `failed`, never as `refused`.
|
|
405
|
+
|
|
406
|
+
`mw events status`, `mw events publish` and `mw events replay <event-id|all> --publisher <name>` show and
|
|
407
|
+
drive it, as do `GET` and `POST /api/events/publish`.
|
|
408
|
+
|
|
409
|
+
NATS JetStream and Kafka are specified, not built. Which broker to try first is weighed in
|
|
410
|
+
miadi-chronicle://548, `foundations/broker-adapters/` (jgwill/medicine-wheel#165).
|
|
408
411
|
|
|
409
412
|
## License
|
|
410
413
|
|
|
@@ -2,41 +2,43 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { eventPublisherReport, publishPending, replayEvents } from "@/lib/event-log";
|
|
3
3
|
|
|
4
4
|
const NO_PUBLISHER = {
|
|
5
|
-
error: "No
|
|
6
|
-
hint: "Set MW_EVENTS_PUBLISH_URL
|
|
5
|
+
error: "No destination is configured — the events wait in events.jsonl.",
|
|
6
|
+
hint: "Set MW_EVENTS_PUBLISH_URL, MW_EVENTS_HOOKDECK_SOURCE_URL, or MW_EVENTS_CF_ACCOUNT_ID + MW_EVENTS_CF_QUEUE_ID + MW_EVENTS_CF_API_TOKEN, and restart the wheel.",
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The
|
|
11
|
-
* Failure and Replay.
|
|
10
|
+
* The destinations' side of the log (#164, #165) — episode 548's Send,
|
|
11
|
+
* Receipt, Failure and Replay.
|
|
12
12
|
*
|
|
13
|
-
* GET:
|
|
14
|
-
* done with, the last arrival, the last failure)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* GET: for each destination, where it stands (`pending`, the last event it
|
|
14
|
+
* is done with, the last arrival, the last failure), what it promises, and
|
|
15
|
+
* its latest receipts.
|
|
16
|
+
* POST: send what waits now, to every destination. `{ "replay_from": "<event
|
|
17
|
+
* id>" | "all", "publisher": "<name>" }` first moves that destination's
|
|
18
|
+
* cursor back, so the same lines go out again with the same idempotency keys;
|
|
19
|
+
* the name may be left out when only one destination is configured.
|
|
18
20
|
*/
|
|
19
21
|
export async function GET() {
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
-
return NextResponse.json(
|
|
22
|
+
const publishers = eventPublisherReport();
|
|
23
|
+
if (!publishers) return NextResponse.json(NO_PUBLISHER, { status: 409 });
|
|
24
|
+
return NextResponse.json({ publishers });
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
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
|
+
const body = (await request.json().catch(() => ({}))) as { replay_from?: unknown; publisher?: unknown };
|
|
29
|
+
let replay: { publisher: string; pending: number } | null = null;
|
|
28
30
|
if (body?.replay_from !== undefined) {
|
|
29
31
|
if (typeof body.replay_from !== "string" || !body.replay_from) {
|
|
30
32
|
return NextResponse.json({ error: 'replay_from must be an event id, or "all"' }, { status: 400 });
|
|
31
33
|
}
|
|
32
34
|
try {
|
|
33
|
-
replay = replayEvents(body.replay_from === "all" ? undefined : body.replay_from);
|
|
35
|
+
replay = replayEvents(body.replay_from === "all" ? undefined : body.replay_from, typeof body.publisher === "string" ? body.publisher : undefined);
|
|
34
36
|
} catch (error: unknown) {
|
|
35
37
|
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 404 });
|
|
36
38
|
}
|
|
37
39
|
if (!replay) return NextResponse.json(NO_PUBLISHER, { status: 409 });
|
|
38
40
|
}
|
|
39
|
-
const
|
|
40
|
-
if (!
|
|
41
|
-
return NextResponse.json({ ...(replay ? { replayed: replay
|
|
41
|
+
const results = await publishPending();
|
|
42
|
+
if (!results) return NextResponse.json(NO_PUBLISHER, { status: 409 });
|
|
43
|
+
return NextResponse.json({ ...(replay ? { replayed: replay } : {}), results });
|
|
42
44
|
}
|
package/dist/cli/mw.js
CHANGED
|
@@ -305,9 +305,9 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
|
|
|
305
305
|
EVENTS (what happened on the wheel, kept in events.jsonl)
|
|
306
306
|
mw events [--type cycle.] [--subject <id>] [--correlation <id>]
|
|
307
307
|
[--since <iso>] [--limit 20] List events, newest first
|
|
308
|
-
mw events status What
|
|
309
|
-
mw events publish Send what waits now
|
|
310
|
-
mw events replay <event-id|all>
|
|
308
|
+
mw events status What each destination owes and promises
|
|
309
|
+
mw events publish Send what waits now, to every destination
|
|
310
|
+
mw events replay <event-id|all> [--publisher <name>] Send again from that event
|
|
311
311
|
mw events sync Commit (and push) events.jsonl now
|
|
312
312
|
mw events schema [--asyncapi] Print the JSON Schema (or AsyncAPI)
|
|
313
313
|
|
|
@@ -824,7 +824,7 @@ async function cmdEvents(positional, flags) {
|
|
|
824
824
|
if (status !== 200)
|
|
825
825
|
return failed(status, data);
|
|
826
826
|
ppValue(data);
|
|
827
|
-
if (data.stopped)
|
|
827
|
+
if (data.results?.some((r) => r.stopped))
|
|
828
828
|
process.exitCode = 1;
|
|
829
829
|
break;
|
|
830
830
|
}
|
|
@@ -835,11 +835,12 @@ async function cmdEvents(positional, flags) {
|
|
|
835
835
|
process.exitCode = EXIT_USAGE;
|
|
836
836
|
return;
|
|
837
837
|
}
|
|
838
|
-
const
|
|
838
|
+
const publisher = typeof flags['publisher'] === 'string' ? flags['publisher'] : undefined;
|
|
839
|
+
const { status, data } = await eventsApi('POST', '/api/events/publish', { replay_from: from, ...(publisher ? { publisher } : {}) });
|
|
839
840
|
if (status !== 200)
|
|
840
841
|
return failed(status, data);
|
|
841
842
|
ppValue(data);
|
|
842
|
-
if (data.stopped)
|
|
843
|
+
if (data.results?.some((r) => r.stopped))
|
|
843
844
|
process.exitCode = 1;
|
|
844
845
|
break;
|
|
845
846
|
}
|
package/lib/event-log.ts
CHANGED
|
@@ -14,10 +14,18 @@
|
|
|
14
14
|
* `0.0.0.0` every wheel looks alike, and two browsers name the same wheel two
|
|
15
15
|
* ways.
|
|
16
16
|
*
|
|
17
|
-
* Publishing (episode 548's Send):
|
|
18
|
-
*
|
|
19
|
-
* `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one
|
|
20
|
-
*
|
|
17
|
+
* Publishing (episode 548's Send, #165): each configured destination is a
|
|
18
|
+
* publisher with its own cursor and receipts beside the log, sent to after
|
|
19
|
+
* every recorded event and every `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one
|
|
20
|
+
* minute). One destination down never holds another back. Unset, nothing
|
|
21
|
+
* leaves the machine. The destinations:
|
|
22
|
+
*
|
|
23
|
+
* - `MW_EVENTS_PUBLISH_URL` (`_TOKEN`, `_NAME`, `_FORMAT` = envelope |
|
|
24
|
+
* cloudevents | cloudevents-binary): any HTTP receiver.
|
|
25
|
+
* - `MW_EVENTS_HOOKDECK_SOURCE_URL` (`_FORMAT`): a Hookdeck source.
|
|
26
|
+
* - `MW_EVENTS_CF_ACCOUNT_ID` + `MW_EVENTS_CF_QUEUE_ID` + `MW_EVENTS_CF_API_TOKEN`
|
|
27
|
+
* (`MW_EVENTS_CF_FORMAT` = envelope | cloudevents; `MW_EVENTS_CF_API_BASE`
|
|
28
|
+
* for a proxy): a Cloudflare queue.
|
|
21
29
|
*
|
|
22
30
|
* Carried by git, before any broker: with `MW_EVENTS_GIT_SYNC=push` (or
|
|
23
31
|
* `commit`), the wheel commits `events.jsonl` — that file only — in the data
|
|
@@ -34,13 +42,17 @@
|
|
|
34
42
|
import { buildEvent, filterEvents, type EventFilter, type WheelEvent, type WheelEventInput } from '@medicine-wheel/events';
|
|
35
43
|
import { appendEvent, eventLogPath, readEventLog } from '@medicine-wheel/events/log';
|
|
36
44
|
import {
|
|
45
|
+
cloudflareQueuesPublisher,
|
|
37
46
|
drainLog,
|
|
47
|
+
hookdeckPublisher,
|
|
38
48
|
httpPublisher,
|
|
39
49
|
publisherStatus,
|
|
40
50
|
readReceipts,
|
|
41
51
|
rewind,
|
|
52
|
+
type AdapterGuarantees,
|
|
42
53
|
type DrainResult,
|
|
43
54
|
type Publisher,
|
|
55
|
+
type WireFormat,
|
|
44
56
|
type PublisherStatus,
|
|
45
57
|
type Receipt,
|
|
46
58
|
} from '@medicine-wheel/events/publish';
|
|
@@ -89,55 +101,99 @@ export { filterEvents, type EventFilter };
|
|
|
89
101
|
|
|
90
102
|
// ── publishing ──────────────────────────────────────────────────────────────
|
|
91
103
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
const env = (key: string) => process.env[key]?.trim() || undefined;
|
|
105
|
+
|
|
106
|
+
function formatOf(key: string, allowed: readonly WireFormat[]): WireFormat {
|
|
107
|
+
const value = env(key);
|
|
108
|
+
if (!value) return 'envelope';
|
|
109
|
+
if ((allowed as readonly string[]).includes(value)) return value as WireFormat;
|
|
110
|
+
console.error(`[events] ${key}=${value} is not one of ${allowed.join(', ')}; sending the envelope`);
|
|
111
|
+
return 'envelope';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every destination the environment names, each a publisher with its own cursor. None: nothing leaves the machine. */
|
|
115
|
+
export function publishersFromEnv(): Publisher[] {
|
|
116
|
+
const publishers: Publisher[] = [];
|
|
117
|
+
const url = env('MW_EVENTS_PUBLISH_URL');
|
|
118
|
+
if (url) {
|
|
119
|
+
publishers.push(httpPublisher({
|
|
120
|
+
url,
|
|
121
|
+
token: env('MW_EVENTS_PUBLISH_TOKEN'),
|
|
122
|
+
name: env('MW_EVENTS_PUBLISH_NAME') || 'http',
|
|
123
|
+
format: formatOf('MW_EVENTS_PUBLISH_FORMAT', ['envelope', 'cloudevents', 'cloudevents-binary']),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
const hookdeck = env('MW_EVENTS_HOOKDECK_SOURCE_URL');
|
|
127
|
+
if (hookdeck) {
|
|
128
|
+
publishers.push(hookdeckPublisher({ sourceUrl: hookdeck, format: formatOf('MW_EVENTS_HOOKDECK_FORMAT', ['envelope', 'cloudevents', 'cloudevents-binary']) }));
|
|
129
|
+
}
|
|
130
|
+
const [accountId, queueId, token] = [env('MW_EVENTS_CF_ACCOUNT_ID'), env('MW_EVENTS_CF_QUEUE_ID'), env('MW_EVENTS_CF_API_TOKEN')];
|
|
131
|
+
if (accountId && queueId && token) {
|
|
132
|
+
publishers.push(cloudflareQueuesPublisher({
|
|
133
|
+
accountId,
|
|
134
|
+
queueId,
|
|
135
|
+
token,
|
|
136
|
+
format: formatOf('MW_EVENTS_CF_FORMAT', ['envelope', 'cloudevents']) as 'envelope' | 'cloudevents',
|
|
137
|
+
apiBase: env('MW_EVENTS_CF_API_BASE'),
|
|
138
|
+
}));
|
|
139
|
+
} else if (accountId || queueId || token) {
|
|
140
|
+
console.error('[events] Cloudflare Queues needs MW_EVENTS_CF_ACCOUNT_ID, MW_EVENTS_CF_QUEUE_ID and MW_EVENTS_CF_API_TOKEN together; it is off');
|
|
141
|
+
}
|
|
142
|
+
// Two publishers under one name would share a cursor and each skip what the other sent.
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
return publishers.filter((p) => {
|
|
145
|
+
if (!seen.has(p.name)) return seen.add(p.name), true;
|
|
146
|
+
console.error(`[events] two publishers are named ${p.name}; the second is off`);
|
|
147
|
+
return false;
|
|
100
148
|
});
|
|
101
149
|
}
|
|
102
150
|
|
|
151
|
+
const publishingConfigured = () =>
|
|
152
|
+
Boolean(env('MW_EVENTS_PUBLISH_URL') || env('MW_EVENTS_HOOKDECK_SOURCE_URL') || env('MW_EVENTS_CF_ACCOUNT_ID'));
|
|
153
|
+
|
|
103
154
|
// One pass at a time across every copy of this module: Next compiles
|
|
104
155
|
// `instrumentation.ts` and the routes into separate bundles, and two passes
|
|
105
156
|
// over the same cursor would send the same event twice.
|
|
106
157
|
const STATE = Symbol.for('@medicine-wheel/app.event-publisher');
|
|
107
|
-
type PublishState = { running?: Promise<DrainResult | null>; again?: boolean; timer?: ReturnType<typeof setInterval>; down?:
|
|
158
|
+
type PublishState = { running?: Promise<DrainResult[] | null>; again?: boolean; timer?: ReturnType<typeof setInterval>; down?: Set<string> };
|
|
108
159
|
const state: PublishState = ((globalThis as Record<symbol, PublishState>)[STATE] ??= {});
|
|
109
160
|
|
|
110
|
-
async function pass(): Promise<DrainResult | null> {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
161
|
+
async function pass(): Promise<DrainResult[] | null> {
|
|
162
|
+
const publishers = publishersFromEnv();
|
|
163
|
+
if (!publishers.length) return null;
|
|
164
|
+
const down = (state.down ??= new Set());
|
|
165
|
+
const results: DrainResult[] = [];
|
|
166
|
+
for (const publisher of publishers) {
|
|
167
|
+
try {
|
|
168
|
+
const result = await drainLog(eventLogFile(), publisher);
|
|
169
|
+
// A destination that stays down is one line when it goes, one when it returns.
|
|
170
|
+
if (result.stopped && !down.has(publisher.name)) {
|
|
171
|
+
console.error(`[events] publishing to ${publisher.name} stopped: ${result.error} — ${result.pending} event(s) waiting`);
|
|
172
|
+
down.add(publisher.name);
|
|
173
|
+
} else if (!result.stopped && down.has(publisher.name)) {
|
|
174
|
+
console.log(`[events] publishing to ${publisher.name} resumed; ${result.arrived} event(s) arrived`);
|
|
175
|
+
down.delete(publisher.name);
|
|
176
|
+
}
|
|
177
|
+
results.push(result);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(`[events] publishing to ${publisher.name} failed: ${(error as Error)?.message ?? String(error)}`);
|
|
120
180
|
}
|
|
121
|
-
state.down = result.stopped;
|
|
122
|
-
return result;
|
|
123
|
-
} catch (error) {
|
|
124
|
-
console.error(`[events] publishing pass failed: ${(error as Error)?.message ?? String(error)}`);
|
|
125
|
-
return null;
|
|
126
181
|
}
|
|
182
|
+
return results;
|
|
127
183
|
}
|
|
128
184
|
|
|
129
|
-
/** Send what waits. A pass already running is joined, and runs once more after it for what arrived meanwhile. */
|
|
130
|
-
export function publishPending(): Promise<DrainResult | null> {
|
|
185
|
+
/** Send what waits, to every destination. A pass already running is joined, and runs once more after it for what arrived meanwhile. */
|
|
186
|
+
export function publishPending(): Promise<DrainResult[] | null> {
|
|
131
187
|
if (state.running) {
|
|
132
188
|
state.again = true;
|
|
133
189
|
return state.running;
|
|
134
190
|
}
|
|
135
|
-
const run = pass().then(async (
|
|
191
|
+
const run = pass().then(async (results) => {
|
|
136
192
|
while (state.again) {
|
|
137
193
|
state.again = false;
|
|
138
|
-
|
|
194
|
+
results = (await pass()) ?? results;
|
|
139
195
|
}
|
|
140
|
-
return
|
|
196
|
+
return results;
|
|
141
197
|
}).finally(() => {
|
|
142
198
|
if (state.running === run) state.running = undefined;
|
|
143
199
|
});
|
|
@@ -145,38 +201,58 @@ export function publishPending(): Promise<DrainResult | null> {
|
|
|
145
201
|
return run;
|
|
146
202
|
}
|
|
147
203
|
|
|
148
|
-
/** After a recorded event: send it soon, in the background. Nothing when no
|
|
204
|
+
/** After a recorded event: send it soon, in the background. Nothing when no destination is set. */
|
|
149
205
|
export function kickPublisher(): void {
|
|
150
|
-
if (!
|
|
206
|
+
if (!publishingConfigured()) return;
|
|
151
207
|
void publishPending();
|
|
152
208
|
}
|
|
153
209
|
|
|
154
210
|
/**
|
|
155
211
|
* Start the publishing clock: one pass now, then one every
|
|
156
212
|
* `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute). Called once from
|
|
157
|
-
* `instrumentation.ts`; a second call is a no-op, and without a
|
|
213
|
+
* `instrumentation.ts`; a second call is a no-op, and without a destination
|
|
158
214
|
* there is nothing to send.
|
|
159
215
|
*/
|
|
160
216
|
export function startEventPublishing(intervalMs = Number(process.env.MW_EVENTS_PUBLISH_INTERVAL_MS) || 60_000): void {
|
|
161
|
-
if (state.timer || !
|
|
217
|
+
if (state.timer || !publishersFromEnv().length) return;
|
|
162
218
|
state.timer = setInterval(() => { void publishPending(); }, intervalMs);
|
|
163
219
|
state.timer.unref?.();
|
|
164
220
|
void publishPending();
|
|
165
221
|
}
|
|
166
222
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
223
|
+
/**
|
|
224
|
+
* Replay: the named publisher's next pass sends `fromEventId` and everything
|
|
225
|
+
* after it again (the whole log without one). With one destination the name
|
|
226
|
+
* may be left out. `null`: no destination is configured.
|
|
227
|
+
*/
|
|
228
|
+
export function replayEvents(fromEventId?: string, publisherName?: string): { publisher: string; pending: number } | null {
|
|
229
|
+
const publishers = publishersFromEnv();
|
|
230
|
+
if (!publishers.length) return null;
|
|
231
|
+
const publisher = publisherName ? publishers.find((p) => p.name === publisherName) : publishers.length === 1 ? publishers[0] : undefined;
|
|
232
|
+
if (!publisher) {
|
|
233
|
+
throw new Error(publisherName
|
|
234
|
+
? `No publisher named ${publisherName}; configured: ${publishers.map((p) => p.name).join(', ')}`
|
|
235
|
+
: `Name the publisher to replay: ${publishers.map((p) => p.name).join(', ')}`);
|
|
236
|
+
}
|
|
237
|
+
return { publisher: publisher.name, ...rewind(eventLogFile(), publisher.name, fromEventId) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface PublisherReport {
|
|
241
|
+
status: PublisherStatus;
|
|
242
|
+
guarantees?: AdapterGuarantees;
|
|
243
|
+
receipts: Receipt[];
|
|
172
244
|
}
|
|
173
245
|
|
|
174
|
-
/** Where
|
|
175
|
-
export function eventPublisherReport(limit = 20):
|
|
176
|
-
const
|
|
177
|
-
if (!
|
|
246
|
+
/** Where each destination stands, what it promises, and its latest receipts. `null`: none is configured. */
|
|
247
|
+
export function eventPublisherReport(limit = 20): PublisherReport[] | null {
|
|
248
|
+
const publishers = publishersFromEnv();
|
|
249
|
+
if (!publishers.length) return null;
|
|
178
250
|
const file = eventLogFile();
|
|
179
|
-
return
|
|
251
|
+
return publishers.map((p) => ({
|
|
252
|
+
status: publisherStatus(file, p.name),
|
|
253
|
+
...(p.guarantees ? { guarantees: p.guarantees } : {}),
|
|
254
|
+
receipts: readReceipts(file, p.name).slice(-limit).reverse(),
|
|
255
|
+
}));
|
|
180
256
|
}
|
|
181
257
|
|
|
182
258
|
// ── carried by git ──────────────────────────────────────────────────────────
|
|
@@ -261,16 +337,16 @@ export function eventGitStatus(): { mode: GitSyncMode; last?: GitState['last'] }
|
|
|
261
337
|
return { mode: gitSyncMode(), ...(gitState.last ? { last: gitState.last } : {}) };
|
|
262
338
|
}
|
|
263
339
|
|
|
264
|
-
/** What `/api/health` reports: how many events, the last one, what
|
|
265
|
-
export function eventLogStatus(): { count: number; last_at?: string;
|
|
340
|
+
/** What `/api/health` reports: how many events, the last one, what each destination owes, how the git carriage stands. */
|
|
341
|
+
export function eventLogStatus(): { count: number; last_at?: string; publishers?: PublisherStatus[]; git?: ReturnType<typeof eventGitStatus> } | { error: string } {
|
|
266
342
|
try {
|
|
267
343
|
const events = readEvents();
|
|
268
344
|
const last = events[events.length - 1];
|
|
269
|
-
const
|
|
345
|
+
const publishers = publishersFromEnv();
|
|
270
346
|
return {
|
|
271
347
|
count: events.length,
|
|
272
348
|
...(last ? { last_at: last.observed_at } : {}),
|
|
273
|
-
...(
|
|
349
|
+
...(publishers.length ? { publishers: publishers.map((p) => publisherStatus(eventLogFile(), p.name)) } : {}),
|
|
274
350
|
...(gitSyncMode() !== 'off' ? { git: eventGitStatus() } : {}),
|
|
275
351
|
};
|
|
276
352
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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.
|
|
96
|
-
"@medicine-wheel/ceremony-protocol": "^0.
|
|
97
|
-
"@medicine-wheel/community-identity": "^0.
|
|
98
|
-
"@medicine-wheel/community-review": "^0.
|
|
99
|
-
"@medicine-wheel/consent-lifecycle": "^0.
|
|
100
|
-
"@medicine-wheel/creative-orientation": "^0.
|
|
101
|
-
"@medicine-wheel/data-store": "^0.
|
|
102
|
-
"@medicine-wheel/data-store-postgres": "^0.
|
|
103
|
-
"@medicine-wheel/events": "^0.
|
|
104
|
-
"@medicine-wheel/fire-keeper": "^0.
|
|
105
|
-
"@medicine-wheel/github-ceremony": "^0.
|
|
106
|
-
"@medicine-wheel/graph-viz": "^0.
|
|
107
|
-
"@medicine-wheel/honcho": "^0.
|
|
108
|
-
"@medicine-wheel/importance-unit": "^0.
|
|
109
|
-
"@medicine-wheel/mcp": "^4.
|
|
110
|
-
"@medicine-wheel/memory": "^0.
|
|
111
|
-
"@medicine-wheel/narrative-cluster": "^0.
|
|
112
|
-
"@medicine-wheel/narrative-engine": "^0.
|
|
113
|
-
"@medicine-wheel/ontology-core": "^0.
|
|
114
|
-
"@medicine-wheel/perception-layer": "^0.
|
|
115
|
-
"@medicine-wheel/prompt-decomposition": "^0.
|
|
116
|
-
"@medicine-wheel/relational-index": "^0.
|
|
117
|
-
"@medicine-wheel/relational-query": "^0.
|
|
118
|
-
"@medicine-wheel/session-reader": "^0.
|
|
119
|
-
"@medicine-wheel/storage-provider": "^0.
|
|
120
|
-
"@medicine-wheel/transformation-tracker": "^0.
|
|
121
|
-
"@medicine-wheel/ui-components": "^0.
|
|
95
|
+
"@medicine-wheel/ceremonial-diary": "^0.20.0",
|
|
96
|
+
"@medicine-wheel/ceremony-protocol": "^0.20.0",
|
|
97
|
+
"@medicine-wheel/community-identity": "^0.20.0",
|
|
98
|
+
"@medicine-wheel/community-review": "^0.20.0",
|
|
99
|
+
"@medicine-wheel/consent-lifecycle": "^0.20.0",
|
|
100
|
+
"@medicine-wheel/creative-orientation": "^0.20.0",
|
|
101
|
+
"@medicine-wheel/data-store": "^0.20.0",
|
|
102
|
+
"@medicine-wheel/data-store-postgres": "^0.20.0",
|
|
103
|
+
"@medicine-wheel/events": "^0.20.0",
|
|
104
|
+
"@medicine-wheel/fire-keeper": "^0.20.0",
|
|
105
|
+
"@medicine-wheel/github-ceremony": "^0.20.0",
|
|
106
|
+
"@medicine-wheel/graph-viz": "^0.20.0",
|
|
107
|
+
"@medicine-wheel/honcho": "^0.20.0",
|
|
108
|
+
"@medicine-wheel/importance-unit": "^0.20.0",
|
|
109
|
+
"@medicine-wheel/mcp": "^4.20.0",
|
|
110
|
+
"@medicine-wheel/memory": "^0.20.0",
|
|
111
|
+
"@medicine-wheel/narrative-cluster": "^0.20.0",
|
|
112
|
+
"@medicine-wheel/narrative-engine": "^0.20.0",
|
|
113
|
+
"@medicine-wheel/ontology-core": "^0.20.0",
|
|
114
|
+
"@medicine-wheel/perception-layer": "^0.20.0",
|
|
115
|
+
"@medicine-wheel/prompt-decomposition": "^0.20.0",
|
|
116
|
+
"@medicine-wheel/relational-index": "^0.20.0",
|
|
117
|
+
"@medicine-wheel/relational-query": "^0.20.0",
|
|
118
|
+
"@medicine-wheel/session-reader": "^0.20.0",
|
|
119
|
+
"@medicine-wheel/storage-provider": "^0.20.0",
|
|
120
|
+
"@medicine-wheel/transformation-tracker": "^0.20.0",
|
|
121
|
+
"@medicine-wheel/ui-components": "^0.20.0",
|
|
122
122
|
"@miadi/episode-vessel": "^0.2.1",
|
|
123
123
|
"@neondatabase/serverless": "^0.10.0",
|
|
124
124
|
"@xyflow/react": "^12.3.0",
|