@medicine-wheel/app 0.15.6 → 0.15.8
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 +4 -1
- package/app/api/ceremonies/route.ts +1 -1
- package/app/api/diary/route.ts +1 -1
- package/app/api/health/route.ts +2 -1
- package/app/api/honcho/pending/route.ts +67 -0
- package/app/api/narrative/beats/route.ts +1 -1
- package/instrumentation.ts +12 -0
- package/lib/honcho-projection.ts +317 -19
- package/package.json +26 -25
package/README.md
CHANGED
|
@@ -348,7 +348,10 @@ HONCHO_WORKSPACE_ID=medicine-wheel # default
|
|
|
348
348
|
With `HONCHO_URL` set, every stored beat, ceremony and diary entry projects into
|
|
349
349
|
Honcho on write, in the background. `GET /api/health` reports `honcho.enabled`.
|
|
350
350
|
A projection never delays the wheel's answer, and a Honcho that is down is a
|
|
351
|
-
line on stderr, not an error to the writer.
|
|
351
|
+
line on stderr, not an error to the writer. The record is not lost: its
|
|
352
|
+
reference waits in `honcho-pending.jsonl` beside the store and is sent again on
|
|
353
|
+
start, every five minutes, and after any projection that gets through —
|
|
354
|
+
`honcho.pending` counts what waits. A running server holds its old
|
|
352
355
|
build and its old environment — restart it for either to take effect.
|
|
353
356
|
|
|
354
357
|
## License
|
|
@@ -162,7 +162,7 @@ export async function POST(request: Request) {
|
|
|
162
162
|
await store.logCeremony(ceremony);
|
|
163
163
|
// The river: the stored ceremony leaves for Honcho in the background when
|
|
164
164
|
// HONCHO_URL is set. Never awaited.
|
|
165
|
-
projectAfterWrite(
|
|
165
|
+
projectAfterWrite({ kind: "ceremony", id: ceremony.id }, () => projectCeremony(ceremony));
|
|
166
166
|
return NextResponse.json({ success: true, ceremony, provider: detectProvider() }, { status: 201 });
|
|
167
167
|
} catch (error: unknown) {
|
|
168
168
|
const message = error instanceof Error ? error.message : String(error);
|
package/app/api/diary/route.ts
CHANGED
|
@@ -126,7 +126,7 @@ export async function POST(request: Request) {
|
|
|
126
126
|
});
|
|
127
127
|
// The river: the participant's voice leaves for Honcho in the background
|
|
128
128
|
// when HONCHO_URL is set. Never awaited.
|
|
129
|
-
projectAfterWrite(
|
|
129
|
+
projectAfterWrite({ kind: "diary", id: entry.id }, () => projectDiaryEntry(entry));
|
|
130
130
|
return NextResponse.json({ success: true, entry, provider: detectProvider() }, { status: 201 });
|
|
131
131
|
} catch (error: unknown) {
|
|
132
132
|
const message = error instanceof Error ? error.message : String(error);
|
package/app/api/health/route.ts
CHANGED
|
@@ -28,7 +28,8 @@ export async function GET() {
|
|
|
28
28
|
ceremonies,
|
|
29
29
|
},
|
|
30
30
|
// The river to Honcho: enabled when HONCHO_URL is set. A wheel that
|
|
31
|
-
// reports enabled projects every stored beat, ceremony and diary entry
|
|
31
|
+
// reports enabled projects every stored beat, ceremony and diary entry;
|
|
32
|
+
// `pending` counts those still waiting for Honcho to take them (#147).
|
|
32
33
|
honcho: honchoProjectionStatus(),
|
|
33
34
|
env: {
|
|
34
35
|
MW_STORAGE_PROVIDER: process.env.MW_STORAGE_PROVIDER || 'not set',
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
holdsRecord,
|
|
4
|
+
honchoProjectionStatus,
|
|
5
|
+
isPendingRef,
|
|
6
|
+
listPending,
|
|
7
|
+
markPending,
|
|
8
|
+
PENDING_KINDS,
|
|
9
|
+
retryPending,
|
|
10
|
+
type PendingRef,
|
|
11
|
+
} from "@/lib/honcho-projection";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The river's pending ledger (#147): records the wheel holds and Honcho has
|
|
15
|
+
* not received yet.
|
|
16
|
+
*
|
|
17
|
+
* GET — what waits, oldest first, with the river's status.
|
|
18
|
+
* POST — put records on the ledger by reference: `{ kind, id }`, or
|
|
19
|
+
* `{ refs: [{ kind, id }, …] }`. For records lost before the ledger
|
|
20
|
+
* existed, whose ids the server log still names. Each must be a record
|
|
21
|
+
* the wheel holds. A retry pass starts at once and the answer does not
|
|
22
|
+
* wait for it; `{ refs: [] }` only starts the pass.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export async function GET() {
|
|
26
|
+
try {
|
|
27
|
+
return NextResponse.json({ honcho: honchoProjectionStatus(), pending: listPending() });
|
|
28
|
+
} catch (error: unknown) {
|
|
29
|
+
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function POST(request: Request) {
|
|
34
|
+
try {
|
|
35
|
+
const status = honchoProjectionStatus();
|
|
36
|
+
if (!status.enabled) {
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ error: "The river is off (HONCHO_URL is unset); nothing would carry these records." },
|
|
39
|
+
{ status: 409 },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const body = await request.json().catch(() => null);
|
|
43
|
+
const raw: unknown[] | null = Array.isArray(body?.refs) ? body.refs : isPendingRef(body) ? [body] : null;
|
|
44
|
+
if (!raw) {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: "Provide { kind, id } or { refs: [{ kind, id }, …] }", kinds: PENDING_KINDS },
|
|
47
|
+
{ status: 400 },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const refs: PendingRef[] = [];
|
|
51
|
+
for (const r of raw) {
|
|
52
|
+
if (!isPendingRef(r)) {
|
|
53
|
+
return NextResponse.json({ error: `Not a record reference: ${JSON.stringify(r)}`, kinds: PENDING_KINDS }, { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
if (!(await holdsRecord(r))) {
|
|
56
|
+
return NextResponse.json({ error: `The wheel holds no ${r.kind} ${r.id}` }, { status: 404 });
|
|
57
|
+
}
|
|
58
|
+
refs.push({ kind: r.kind, id: r.id });
|
|
59
|
+
}
|
|
60
|
+
let pending = listPending().length;
|
|
61
|
+
for (const ref of refs) pending = markPending(ref, "queued by hand");
|
|
62
|
+
void retryPending();
|
|
63
|
+
return NextResponse.json({ queued: refs.length, pending }, { status: 202 });
|
|
64
|
+
} catch (error: unknown) {
|
|
65
|
+
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -37,7 +37,7 @@ export async function POST(request: Request) {
|
|
|
37
37
|
});
|
|
38
38
|
// The river: a stored beat leaves for Honcho in the background when
|
|
39
39
|
// HONCHO_URL is set. Never awaited — the wheel answers on its own clock.
|
|
40
|
-
projectAfterWrite(
|
|
40
|
+
projectAfterWrite({ kind: "beat", id: beat.id }, () => projectBeat(beat));
|
|
41
41
|
// Warnings ride on the created beat rather than replacing it, so clients
|
|
42
42
|
// that read the beat back by id keep working while advisory findings stop
|
|
43
43
|
// being computed-and-discarded.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs once when the server starts (Next.js instrumentation hook).
|
|
3
|
+
*
|
|
4
|
+
* Starts the Honcho river's retry clock (#147): records Honcho could not
|
|
5
|
+
* receive while it was away wait on the pending ledger, and are sent again on
|
|
6
|
+
* start and every few minutes after. With `HONCHO_URL` unset this does nothing.
|
|
7
|
+
*/
|
|
8
|
+
export async function register() {
|
|
9
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
10
|
+
const { startPendingRetries } = await import("@/lib/honcho-projection");
|
|
11
|
+
startPendingRetries();
|
|
12
|
+
}
|
package/lib/honcho-projection.ts
CHANGED
|
@@ -11,16 +11,32 @@
|
|
|
11
11
|
* A failure is reported once on stderr with the record's id, because a silent
|
|
12
12
|
* memory is worse than a noisy one. `awaitProjections()` exists for tests and
|
|
13
13
|
* for anything that must know the river has run dry before it exits.
|
|
14
|
+
*
|
|
15
|
+
* A record Honcho could not receive is not lost (#147). Until then a Honcho
|
|
16
|
+
* that was offline left a hole in its memory of every talking circle held
|
|
17
|
+
* while it was away — the wheel kept the turns, and nothing ever sent them.
|
|
18
|
+
* Now the record's reference, `{ kind, id }`, waits in a pending ledger beside
|
|
19
|
+
* the wheel's store (`honcho-pending.jsonl`, or `HONCHO_PENDING_FILE`), and
|
|
20
|
+
* {@link retryPending} sends it again: when the server starts, every few
|
|
21
|
+
* minutes, and right after any projection that gets through. Only the
|
|
22
|
+
* reference waits. The words stay in the wheel, which remains canonical and is
|
|
23
|
+
* read again when the record finally leaves.
|
|
14
24
|
*/
|
|
15
25
|
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
16
28
|
import {
|
|
17
29
|
createHonchoClient,
|
|
18
30
|
honchoFromEnv,
|
|
19
31
|
project,
|
|
32
|
+
projectBeat,
|
|
33
|
+
projectCeremony,
|
|
34
|
+
projectDiaryEntry,
|
|
20
35
|
type HonchoClient,
|
|
21
36
|
type HonchoConfig,
|
|
22
37
|
type Projection,
|
|
23
38
|
} from '@medicine-wheel/honcho';
|
|
39
|
+
import { resolveProjectDataDir } from '@/lib/jsonl-store';
|
|
24
40
|
|
|
25
41
|
const inFlight = new Set<Promise<unknown>>();
|
|
26
42
|
|
|
@@ -32,41 +48,323 @@ function clientFor(cfg: HonchoConfig): HonchoClient {
|
|
|
32
48
|
return cached.client;
|
|
33
49
|
}
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
function track(run: Promise<unknown>): void {
|
|
52
|
+
inFlight.add(run);
|
|
53
|
+
run.finally(() => { inFlight.delete(run); }).catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── what can wait ───────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const PENDING_KINDS = ['beat', 'ceremony', 'diary'] as const;
|
|
59
|
+
export type PendingKind = (typeof PENDING_KINDS)[number];
|
|
60
|
+
|
|
61
|
+
/** A record the river owes Honcho, by reference. */
|
|
62
|
+
export interface PendingRef {
|
|
63
|
+
kind: PendingKind;
|
|
64
|
+
id: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PendingEntry extends PendingRef {
|
|
68
|
+
/** When the record first failed to arrive. */
|
|
69
|
+
since: string;
|
|
70
|
+
/** The failure that put it here. */
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const KIND_LABEL: Record<PendingKind, string> = { beat: 'beat', ceremony: 'ceremony', diary: 'diary entry' };
|
|
75
|
+
|
|
76
|
+
const labelOf = (ref: PendingRef) => `${KIND_LABEL[ref.kind]} ${ref.id}`;
|
|
77
|
+
const same = (a: PendingRef, b: PendingRef) => a.kind === b.kind && a.id === b.id;
|
|
78
|
+
|
|
79
|
+
export function isPendingRef(value: unknown): value is PendingRef {
|
|
80
|
+
const v = value as Partial<PendingRef> | null;
|
|
81
|
+
return !!v && typeof v === 'object'
|
|
82
|
+
&& typeof v.kind === 'string' && (PENDING_KINDS as readonly string[]).includes(v.kind)
|
|
83
|
+
&& typeof v.id === 'string' && v.id.length > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── the pending ledger ──────────────────────────────────────────────────────
|
|
87
|
+
//
|
|
88
|
+
// Every read and write below is synchronous on purpose. Next compiles
|
|
89
|
+
// `instrumentation.ts` and the routes into separate bundles, each with its own
|
|
90
|
+
// copy of this module; the file is what they share. A synchronous
|
|
91
|
+
// read-modify-write cannot interleave with another inside one Node process, so
|
|
92
|
+
// a record queued by a route while the timer's pass is sending another is
|
|
93
|
+
// never overwritten away.
|
|
94
|
+
|
|
95
|
+
/** Where the ledger lives: `HONCHO_PENDING_FILE`, else beside the wheel's JSONL store. */
|
|
96
|
+
export function pendingFile(): string {
|
|
97
|
+
return process.env.HONCHO_PENDING_FILE || path.join(resolveProjectDataDir(), 'honcho-pending.jsonl');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readLedger(): PendingEntry[] {
|
|
101
|
+
let text: string;
|
|
102
|
+
try {
|
|
103
|
+
text = fs.readFileSync(pendingFile(), 'utf-8');
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
const entries: PendingEntry[] = [];
|
|
109
|
+
for (const line of text.split('\n')) {
|
|
110
|
+
if (!line.trim()) continue;
|
|
111
|
+
try {
|
|
112
|
+
const entry = JSON.parse(line);
|
|
113
|
+
if (isPendingRef(entry) && !entries.some((e) => same(e, entry))) entries.push(entry as PendingEntry);
|
|
114
|
+
} catch {
|
|
115
|
+
// A torn line is skipped, not fatal: the rest of the ledger still sends.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return entries;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** An empty ledger is no file, so a data repository shows it only while something waits. */
|
|
122
|
+
function writeLedger(entries: PendingEntry[]): void {
|
|
123
|
+
const file = pendingFile();
|
|
124
|
+
if (!entries.length) {
|
|
125
|
+
fs.rmSync(file, { force: true });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
129
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
130
|
+
fs.writeFileSync(tmp, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
131
|
+
fs.renameSync(tmp, file);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What waits, oldest first. */
|
|
135
|
+
export function listPending(): PendingEntry[] {
|
|
136
|
+
return readLedger();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Put a record on the ledger (once — a reference already waiting keeps its place). Returns how many wait. */
|
|
140
|
+
export function markPending(ref: PendingRef, error?: string): number {
|
|
141
|
+
const entries = readLedger();
|
|
142
|
+
if (!entries.some((e) => same(e, ref))) {
|
|
143
|
+
entries.push({ kind: ref.kind, id: ref.id, since: new Date().toISOString(), ...(error ? { error } : {}) });
|
|
144
|
+
writeLedger(entries);
|
|
145
|
+
}
|
|
146
|
+
return entries.length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearPending(ref: PendingRef): void {
|
|
150
|
+
const entries = readLedger();
|
|
151
|
+
const rest = entries.filter((e) => !same(e, ref));
|
|
152
|
+
if (rest.length !== entries.length) writeLedger(rest);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── reading a record back ───────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The record a reference names, shaped for Honcho — read from the wheel now,
|
|
159
|
+
* so what leaves is what the wheel holds, including any witness added since.
|
|
160
|
+
* `null` when the wheel no longer holds it. Throws when the store cannot be
|
|
161
|
+
* read, which is a reason to wait, not to forget.
|
|
162
|
+
*/
|
|
163
|
+
async function shaperFor(ref: PendingRef): Promise<(() => Projection) | null> {
|
|
164
|
+
if (ref.kind === 'beat') {
|
|
165
|
+
const { getBeat } = await import('@/lib/store');
|
|
166
|
+
const beat = getBeat(ref.id);
|
|
167
|
+
return beat ? () => projectBeat(beat) : null;
|
|
168
|
+
}
|
|
169
|
+
const { createProvider } = await import('@medicine-wheel/storage-provider');
|
|
170
|
+
const store = await createProvider();
|
|
171
|
+
if (ref.kind === 'ceremony') {
|
|
172
|
+
const ceremony = await store.getCeremony(ref.id);
|
|
173
|
+
return ceremony ? () => projectCeremony(ceremony) : null;
|
|
174
|
+
}
|
|
175
|
+
const entry = await store.getDiaryEntry(ref.id);
|
|
176
|
+
return entry ? () => projectDiaryEntry(entry) : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Whether the wheel holds the record a reference names. */
|
|
180
|
+
export async function holdsRecord(ref: PendingRef): Promise<boolean> {
|
|
181
|
+
return (await shaperFor(ref)) !== null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── failures ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether sending again can change the outcome: Honcho unreachable or timed
|
|
188
|
+
* out (the client reports 502), failing on its own side (5xx), or asking us
|
|
189
|
+
* to slow down. A 4xx refusal is Honcho's answer to this record as shaped;
|
|
190
|
+
* it would give the same answer every five minutes, forever.
|
|
191
|
+
*/
|
|
192
|
+
function mayArriveLater(error: unknown): boolean {
|
|
193
|
+
const status = (error as { status?: unknown })?.status;
|
|
194
|
+
return typeof status !== 'number' || status >= 500 || status === 408 || status === 429;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function report(cfg: HonchoConfig, label: string, error: unknown, tail = ''): void {
|
|
198
|
+
const err = error as { message?: string; body?: string };
|
|
199
|
+
console.error(
|
|
200
|
+
`[honcho] projection of ${label} into ${cfg.workspace}@${cfg.baseUrl} failed: ${err?.message ?? String(error)}` +
|
|
201
|
+
(err?.body ? ` — ${err.body}` : '') +
|
|
202
|
+
tail,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** What `/api/health` reports: whether the river runs, where to, and how much waits for it. */
|
|
207
|
+
export function honchoProjectionStatus(): { enabled: boolean; url?: string; workspace?: string; pending?: number } {
|
|
37
208
|
const cfg = honchoFromEnv();
|
|
38
|
-
|
|
209
|
+
if (!cfg) return { enabled: false };
|
|
210
|
+
let pending: number | undefined;
|
|
211
|
+
try {
|
|
212
|
+
pending = readLedger().length;
|
|
213
|
+
} catch {
|
|
214
|
+
pending = undefined;
|
|
215
|
+
}
|
|
216
|
+
return { enabled: true, url: cfg.baseUrl, workspace: cfg.workspace, ...(pending !== undefined ? { pending } : {}) };
|
|
39
217
|
}
|
|
40
218
|
|
|
219
|
+
// ── the river ───────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
41
221
|
/**
|
|
42
222
|
* Fire-and-forget. Returns immediately; the projection runs in the background.
|
|
43
|
-
* `
|
|
44
|
-
* unreachable.
|
|
223
|
+
* `ref` names the record — in the one line written when Honcho refuses or is
|
|
224
|
+
* unreachable, and on the ledger when it may arrive later.
|
|
45
225
|
*
|
|
46
226
|
* The projection is built by `shape`, called *inside* the error boundary. It
|
|
47
227
|
* used to be built by the caller, in the route's own expression — so a record
|
|
48
228
|
* the projection could not shape (a `learnings` that arrived as a string, say)
|
|
49
229
|
* threw synchronously and turned an already-stored write into a 500 for the
|
|
50
230
|
* writer. The wheel's answer to its caller must not depend on the river, and
|
|
51
|
-
* that includes the part that reads the record.
|
|
231
|
+
* that includes the part that reads the record. A record that cannot be
|
|
232
|
+
* shaped is reported and not queued: shaping it again fails the same way.
|
|
52
233
|
*/
|
|
53
|
-
export function projectAfterWrite(shape: () => Projection
|
|
234
|
+
export function projectAfterWrite(ref: PendingRef, shape: () => Projection): void {
|
|
54
235
|
const cfg = honchoFromEnv();
|
|
55
236
|
if (!cfg) return;
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
237
|
+
const label = labelOf(ref);
|
|
238
|
+
track(
|
|
239
|
+
Promise.resolve().then(async () => {
|
|
240
|
+
let projection: Projection;
|
|
241
|
+
try {
|
|
242
|
+
projection = shape();
|
|
243
|
+
} catch (error) {
|
|
244
|
+
report(cfg, label, error);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await project(clientFor(cfg), projection);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!mayArriveLater(error)) {
|
|
251
|
+
report(cfg, label, error);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
let tail: string;
|
|
255
|
+
try {
|
|
256
|
+
tail = ` — waiting to be sent again (${markPending(ref, (error as Error)?.message)} pending)`;
|
|
257
|
+
} catch (ledgerError) {
|
|
258
|
+
tail = ` — and could not be kept for later: ${(ledgerError as Error)?.message ?? String(ledgerError)}`;
|
|
259
|
+
}
|
|
260
|
+
report(cfg, label, error, tail);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Honcho answered, so whatever waited behind it can go now.
|
|
264
|
+
try {
|
|
265
|
+
if (readLedger().length) void retryPending();
|
|
266
|
+
} catch {
|
|
267
|
+
// The ledger is the timer's to reach; this was only a head start.
|
|
268
|
+
}
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface RetryResult {
|
|
274
|
+
/** Delivered on this pass and cleared from the ledger. */
|
|
275
|
+
sent: number;
|
|
276
|
+
/** Cleared without delivery: the wheel no longer holds it, or Honcho refused it. */
|
|
277
|
+
dropped: number;
|
|
278
|
+
/** Still waiting after this pass. */
|
|
279
|
+
pending: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// One pass at a time across every copy of this module (see the ledger note):
|
|
283
|
+
// two passes over the same ledger would send the same record twice.
|
|
284
|
+
const RETRY_STATE = Symbol.for('@medicine-wheel/app.honcho-retry');
|
|
285
|
+
type RetryState = { running?: Promise<RetryResult>; timer?: ReturnType<typeof setInterval> };
|
|
286
|
+
const retryState: RetryState = ((globalThis as Record<symbol, RetryState>)[RETRY_STATE] ??= {});
|
|
287
|
+
|
|
288
|
+
async function drain(): Promise<RetryResult> {
|
|
289
|
+
const result: RetryResult = { sent: 0, dropped: 0, pending: 0 };
|
|
290
|
+
const cfg = honchoFromEnv();
|
|
291
|
+
try {
|
|
292
|
+
if (cfg) {
|
|
293
|
+
for (const entry of readLedger()) {
|
|
294
|
+
const label = labelOf(entry);
|
|
295
|
+
// A store that cannot be read ends the pass with everything still waiting.
|
|
296
|
+
const shape = await shaperFor(entry);
|
|
297
|
+
if (!shape) {
|
|
298
|
+
console.error(`[honcho] ${label} was waiting for Honcho and the wheel no longer holds it; dropped from the ledger`);
|
|
299
|
+
clearPending(entry);
|
|
300
|
+
result.dropped++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
let projection: Projection;
|
|
304
|
+
try {
|
|
305
|
+
projection = shape();
|
|
306
|
+
} catch (error) {
|
|
307
|
+
report(cfg, label, error, ' — dropped from the ledger');
|
|
308
|
+
clearPending(entry);
|
|
309
|
+
result.dropped++;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
await project(clientFor(cfg), projection);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// Still away: this record and every one behind it keep waiting,
|
|
316
|
+
// and a Honcho that is down is not asked once per record.
|
|
317
|
+
if (mayArriveLater(error)) break;
|
|
318
|
+
report(cfg, label, error, ' — dropped from the ledger');
|
|
319
|
+
clearPending(entry);
|
|
320
|
+
result.dropped++;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
clearPending(entry);
|
|
324
|
+
result.sent++;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
result.pending = readLedger().length;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.error(`[honcho] retrying the pending ledger (${pendingFile()}) failed: ${(error as Error)?.message ?? String(error)}`);
|
|
330
|
+
}
|
|
331
|
+
if (result.sent) {
|
|
332
|
+
console.log(`[honcho] ${result.sent} waiting projection(s) delivered; ${result.pending} still pending`);
|
|
333
|
+
}
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Send what waits, oldest first, and clear each record Honcho takes. Stops at
|
|
339
|
+
* the first sign Honcho is still away. Never rejects. A pass already running
|
|
340
|
+
* is joined, not repeated.
|
|
341
|
+
*/
|
|
342
|
+
export function retryPending(): Promise<RetryResult> {
|
|
343
|
+
if (retryState.running) return retryState.running;
|
|
344
|
+
const run = drain().finally(() => {
|
|
345
|
+
if (retryState.running === run) retryState.running = undefined;
|
|
346
|
+
});
|
|
347
|
+
retryState.running = run;
|
|
348
|
+
track(run);
|
|
349
|
+
return run;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Start the retry clock: one pass now, then one every `intervalMs`
|
|
354
|
+
* (`HONCHO_RETRY_INTERVAL_MS`, default five minutes). Called once from
|
|
355
|
+
* `instrumentation.ts` when the server starts; a second call is a no-op, and
|
|
356
|
+
* with `HONCHO_URL` unset there is no river to wait for.
|
|
357
|
+
*/
|
|
358
|
+
export function startPendingRetries(
|
|
359
|
+
intervalMs = Number(process.env.HONCHO_RETRY_INTERVAL_MS) || 5 * 60_000,
|
|
360
|
+
): void {
|
|
361
|
+
if (retryState.timer || !honchoFromEnv()) return;
|
|
362
|
+
retryState.timer = setInterval(() => { void retryPending(); }, intervalMs);
|
|
363
|
+
retryState.timer.unref?.();
|
|
364
|
+
void retryPending();
|
|
67
365
|
}
|
|
68
366
|
|
|
69
|
-
/** Resolves once every projection started so far has settled. */
|
|
367
|
+
/** Resolves once every projection and retry pass started so far has settled. */
|
|
70
368
|
export async function awaitProjections(): Promise<void> {
|
|
71
369
|
while (inFlight.size) await Promise.allSettled([...inFlight]);
|
|
72
370
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.8",
|
|
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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"public/**/*",
|
|
15
15
|
"dist/cli/**/*",
|
|
16
16
|
"next.config.mjs",
|
|
17
|
+
"instrumentation.ts",
|
|
17
18
|
"next-env.d.ts",
|
|
18
19
|
"postcss.config.mjs",
|
|
19
20
|
"tsconfig.json",
|
|
@@ -89,30 +90,30 @@
|
|
|
89
90
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
90
91
|
},
|
|
91
92
|
"dependencies": {
|
|
92
|
-
"@medicine-wheel/ceremonial-diary": "^0.15.
|
|
93
|
-
"@medicine-wheel/ceremony-protocol": "^0.15.
|
|
94
|
-
"@medicine-wheel/community-review": "^0.15.
|
|
95
|
-
"@medicine-wheel/consent-lifecycle": "^0.15.
|
|
96
|
-
"@medicine-wheel/creative-orientation": "^0.15.
|
|
97
|
-
"@medicine-wheel/data-store": "^0.15.
|
|
98
|
-
"@medicine-wheel/data-store-postgres": "^0.15.
|
|
99
|
-
"@medicine-wheel/fire-keeper": "^0.15.
|
|
100
|
-
"@medicine-wheel/github-ceremony": "^0.15.
|
|
101
|
-
"@medicine-wheel/graph-viz": "^0.15.
|
|
102
|
-
"@medicine-wheel/honcho": "^0.15.
|
|
103
|
-
"@medicine-wheel/importance-unit": "^0.15.
|
|
104
|
-
"@medicine-wheel/mcp": "^4.15.
|
|
105
|
-
"@medicine-wheel/narrative-cluster": "^0.15.
|
|
106
|
-
"@medicine-wheel/narrative-engine": "^0.15.
|
|
107
|
-
"@medicine-wheel/ontology-core": "^0.15.
|
|
108
|
-
"@medicine-wheel/perception-layer": "^0.15.
|
|
109
|
-
"@medicine-wheel/prompt-decomposition": "^0.15.
|
|
110
|
-
"@medicine-wheel/relational-index": "^0.15.
|
|
111
|
-
"@medicine-wheel/relational-query": "^0.15.
|
|
112
|
-
"@medicine-wheel/session-reader": "^0.15.
|
|
113
|
-
"@medicine-wheel/storage-provider": "^0.15.
|
|
114
|
-
"@medicine-wheel/transformation-tracker": "^0.15.
|
|
115
|
-
"@medicine-wheel/ui-components": "^0.15.
|
|
93
|
+
"@medicine-wheel/ceremonial-diary": "^0.15.8",
|
|
94
|
+
"@medicine-wheel/ceremony-protocol": "^0.15.8",
|
|
95
|
+
"@medicine-wheel/community-review": "^0.15.8",
|
|
96
|
+
"@medicine-wheel/consent-lifecycle": "^0.15.8",
|
|
97
|
+
"@medicine-wheel/creative-orientation": "^0.15.8",
|
|
98
|
+
"@medicine-wheel/data-store": "^0.15.8",
|
|
99
|
+
"@medicine-wheel/data-store-postgres": "^0.15.8",
|
|
100
|
+
"@medicine-wheel/fire-keeper": "^0.15.8",
|
|
101
|
+
"@medicine-wheel/github-ceremony": "^0.15.8",
|
|
102
|
+
"@medicine-wheel/graph-viz": "^0.15.8",
|
|
103
|
+
"@medicine-wheel/honcho": "^0.15.8",
|
|
104
|
+
"@medicine-wheel/importance-unit": "^0.15.8",
|
|
105
|
+
"@medicine-wheel/mcp": "^4.15.8",
|
|
106
|
+
"@medicine-wheel/narrative-cluster": "^0.15.8",
|
|
107
|
+
"@medicine-wheel/narrative-engine": "^0.15.8",
|
|
108
|
+
"@medicine-wheel/ontology-core": "^0.15.8",
|
|
109
|
+
"@medicine-wheel/perception-layer": "^0.15.8",
|
|
110
|
+
"@medicine-wheel/prompt-decomposition": "^0.15.8",
|
|
111
|
+
"@medicine-wheel/relational-index": "^0.15.8",
|
|
112
|
+
"@medicine-wheel/relational-query": "^0.15.8",
|
|
113
|
+
"@medicine-wheel/session-reader": "^0.15.8",
|
|
114
|
+
"@medicine-wheel/storage-provider": "^0.15.8",
|
|
115
|
+
"@medicine-wheel/transformation-tracker": "^0.15.8",
|
|
116
|
+
"@medicine-wheel/ui-components": "^0.15.8",
|
|
116
117
|
"@neondatabase/serverless": "^0.10.0",
|
|
117
118
|
"@xyflow/react": "^12.3.0",
|
|
118
119
|
"clsx": "^2.1.1",
|