Skip to content

Commit cde973b

Browse files
authored
Merge pull request #59 from alanshurafa/contrib/alanshurafa/wiki-profile-worker
[integrations] wiki-profile consolidation worker + regenerate flow
2 parents 2c01741 + 305ee57 commit cde973b

11 files changed

Lines changed: 1841 additions & 15 deletions

File tree

dashboards/open-brain-dashboard-pro/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ SESSION_SECRET=
2828
# to your own repo/mirror if you maintain one. Build-time, like the other
2929
# NEXT_PUBLIC_* vars above.
3030
# NEXT_PUBLIC_SCHEMA_REPO_BASE=
31+
32+
# Optional: full URL of the wiki-profile consolidation worker Edge Function
33+
# (integrations/consolidation-workers/wiki-profile), used by the "Regenerate
34+
# profile" button. When unset, the dashboard derives it from
35+
# NEXT_PUBLIC_API_URL by swapping the function name:
36+
# .../functions/v1/open-brain-rest -> .../functions/v1/wiki-profile
37+
# Plain server-side env — only route handlers read it, so no NEXT_PUBLIC_
38+
# prefix and a restart (not a rebuild) picks up changes. Must be https://
39+
# (http://localhost is allowed for dev).
40+
# WIKI_PROFILE_URL=

dashboards/open-brain-dashboard-pro/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ If your brain runs the persistent-wiki layer (the `schemas/wiki-pages` schema an
4040
| **Wiki** (`/wiki`) | Page list filtered by kind (topic / entity / autobiography / custom), with section counts and a "new page" form. |
4141
| **Wiki page** (`/wiki/:slug`) | Sections in display order, each with its markdown body rendered sanitized, an origin chip (yours / generated), a lock toggle, and an evidence chip listing supporting thought ids. Edit a section inline (your edit takes ownership); add a section; archive the page. When a machine writer proposes an update to a section you own, a review panel shows the current body against the proposed draft with **accept / reject**. |
4242

43+
**User Profile regeneration.** The `/wiki/user-profile` page header gains a **Regenerate profile** button, and the wiki list shows a **Create your profile** card while no `user-profile` page exists yet. Both POST to the dashboard's `/api/wiki/profile/regenerate` route, which triggers the [`wiki-profile` consolidation worker](../../integrations/consolidation-workers/wiki-profile/) — a separate Edge Function that synthesizes the page section by section and returns per-section outcomes, rendered inline as a compact list (created / updated / **pending** — amber, awaiting your review in the section panel / skipped / error). The worker must be deployed for this to do anything (`supabase functions deploy wiki-profile --no-verify-jwt`); without it the button fails fast with a clear "worker not deployed" error and the rest of the wiki surface is unaffected. The route derives the worker URL from `NEXT_PUBLIC_API_URL` by swapping the function name (`…/open-brain-rest` → `…/wiki-profile`); set `WIKI_PROFILE_URL` to override (see [Configuration](#configuration)).
44+
4345
The Wiki nav entry appears **by default, whether or not the brain has the wiki layer installed yet**. Visiting `/wiki` on a brain without it — the login-time probe came back negative, a re-check still says no, or `GET /wiki/pages` returns 404 — shows a setup card: apply `schemas/wiki-pages`, confirm `open-brain-rest` exposes the `/wiki/*` route group, and a **Re-check now** button that re-probes the brain and refreshes the page without a sign-out.
4446

4547
Prefer the old hide-until-detected behavior? Set `NEXT_PUBLIC_OPTIONAL_NAV=auto` (see [Configuration](#configuration)) and rebuild — the dashboard probes for `/wiki` once at login, caches the result in the session, and only then shows the Wiki entry.
@@ -70,6 +72,7 @@ All configuration is through environment variables. **The app refuses to start i
7072
| `RESTRICTED_PASSPHRASE_HASH` | No | SHA-256 hash of a passphrase that unlocks restricted/sensitive content. Only meaningful if your brain has a `sensitivity_tier` column on `public.thoughts`. There is no official sensitivity-tiers primitive upstream yet — either add your own migration (see PR #192 for pattern) or wait for the primitive to land. On stock OB1, this dashboard's restricted-content toggle is hidden at startup. Generate with `echo -n "your-passphrase" \| shasum -a 256`. |
7173
| `NEXT_PUBLIC_OPTIONAL_NAV` | No | Controls whether the Contacts/Proposals (CRM) and Wiki nav entries show before their schema is installed. Default (unset, or any value other than `auto`): always show them, with a setup card on the page itself. Set to `auto` to restore hide-until-enabled behavior, gated on the login-time probe cached in the session. This is a `NEXT_PUBLIC_*` var, so it's inlined at **build** time — changing it needs a rebuild, not just a restart. |
7274
| `NEXT_PUBLIC_SCHEMA_REPO_BASE` | No | Base URL for the schema-folder links on the setup cards, e.g. `https://github.com/YOUR-ORG/YOUR-REPO/tree/main`. Defaults to the `alanshurafa/OB1` fork, where all the optional schemas currently live (they haven't landed upstream yet). Set it to your own repo/mirror if you maintain one. Build-time, like the other `NEXT_PUBLIC_*` vars. |
75+
| `WIKI_PROFILE_URL` | No | Full URL of the `wiki-profile` consolidation worker Edge Function. When unset, derived from `NEXT_PUBLIC_API_URL` by swapping the function name (`…/functions/v1/open-brain-rest``…/functions/v1/wiki-profile`). Plain server-side env — only the regenerate route handler reads it, so no `NEXT_PUBLIC_` prefix and a restart (not a rebuild) picks up changes. Must be `https://` (`http://localhost` allowed for dev). |
7376

7477
Copy `.env.example` to `.env.local` (gitignored) and fill it in.
7578

@@ -120,6 +123,7 @@ The dashboard calls these endpoints on your Open Brain REST gateway (all authent
120123
| `/ingest`, `/ingestion-jobs`, `/ingestion-jobs/:id`, `/ingestion-jobs/:id/execute` | POST / GET | Ingest page | Optional — page still loads without jobs |
121124
| `/crm/*` (contacts, proposals, notes, tasks, important-dates, timeline, history, …) | GET / POST / PATCH | Contacts, Proposals, contact detail panels | Optional — nav entries show by default; the pages themselves render a setup card until `/crm` is detected |
122125
| `/wiki/*` (pages, pages/:slug, sections/:id/accept-pending, reject-pending, lock, …) | GET / POST / PUT / DELETE | Wiki list, wiki page, section edit / lock / draft review | Optional — nav entry shows by default; the page itself renders a setup card until `/wiki` is detected |
126+
| `wiki-profile` Edge Function (a separate function, not a gateway route — see [Wiki](#wiki-optional)) | POST | "Regenerate profile" button, "Create your profile" card | Optional — without it the button returns a clear "worker not deployed" error; everything else works |
123127

124128
> **On `/reflections/*`:** The ExoCortex upstream dashboard staged a reflections feature. This fork does not yet ship a reflections UI surface, but the architecture is ready: if you add a reflection panel later and your gateway doesn't serve `/reflections/*`, expect a 404 that the UI should swallow. The existing optional endpoints already degrade this way — the Connections panel, Duplicates page, and Ingest history all swallow fetch errors and render an empty/neutral state instead of crashing.
125129
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { requireSession, AuthError } from "@/lib/auth";
3+
4+
// POST /api/wiki/profile/regenerate — trigger the wiki-profile consolidation
5+
// worker (integrations/consolidation-workers/wiki-profile) to synthesize the
6+
// "user-profile" wiki page. The worker bootstraps the page itself via
7+
// wiki_upsert_page, so this one route serves both "create" and "regenerate".
8+
//
9+
// The worker is its own Edge Function, separate from the open-brain-rest
10+
// gateway that lib/api.ts talks to, so this route resolves the worker URL
11+
// itself instead of reusing apiFetch:
12+
// 1. WIKI_PROFILE_URL wins when set. It is a plain server env — route
13+
// handlers run server-side, so no NEXT_PUBLIC_ prefix is needed (and a
14+
// restart, not a rebuild, picks up changes).
15+
// 2. Otherwise derive from NEXT_PUBLIC_API_URL by swapping the Edge Function
16+
// name: …/functions/v1/open-brain-rest → …/functions/v1/wiki-profile.
17+
18+
/**
19+
* How long to wait for the worker. A full profile run makes up to 10 LLM
20+
* calls; the platform default fetch has no timeout and would pin this route
21+
* until the host kills it. 120s covers a normal run — the Edge Function's own
22+
* 150s wall clock is the true upper bound.
23+
*/
24+
const WORKER_TIMEOUT_MS = 120_000;
25+
26+
/**
27+
* Mirror lib/api.ts's WR-06 check: refuse to send the session's brain key to
28+
* a non-https host (localhost excepted for dev), so a misconfigured env var
29+
* cannot fan the key out to an attacker-controlled URL.
30+
*/
31+
function validateWorkerUrl(candidate: string): string | null {
32+
try {
33+
const parsed = new URL(candidate);
34+
if (
35+
parsed.protocol !== "https:" &&
36+
parsed.hostname !== "localhost" &&
37+
parsed.hostname !== "127.0.0.1"
38+
) {
39+
return null;
40+
}
41+
// fetch() rejects credentialed URLs outright (TypeError before any
42+
// network attempt), which would surface as a misleading generic 502.
43+
if (parsed.username || parsed.password) {
44+
return null;
45+
}
46+
return candidate;
47+
} catch {
48+
return null;
49+
}
50+
}
51+
52+
function resolveWorkerUrl(): string | null {
53+
const override = process.env.WIKI_PROFILE_URL?.trim();
54+
if (override) return validateWorkerUrl(override);
55+
56+
const base = (process.env.NEXT_PUBLIC_API_URL ?? "").trim();
57+
if (!base) return null;
58+
const derived = base.replace(/\/open-brain-rest\/?$/, "/wiki-profile");
59+
// If the base doesn't end in /open-brain-rest the swap is a no-op and we
60+
// cannot guess the worker URL — the operator must set WIKI_PROFILE_URL.
61+
if (derived === base) return null;
62+
return validateWorkerUrl(derived);
63+
}
64+
65+
export async function POST(request: NextRequest) {
66+
let apiKey: string;
67+
try {
68+
({ apiKey } = await requireSession());
69+
} catch (err) {
70+
if (err instanceof AuthError)
71+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
72+
throw err;
73+
}
74+
75+
const workerUrl = resolveWorkerUrl();
76+
if (!workerUrl) {
77+
console.error(
78+
"[wiki/profile:regenerate] cannot resolve worker URL — set WIKI_PROFILE_URL " +
79+
"(NEXT_PUBLIC_API_URL does not end in /open-brain-rest, or the URL is not https)"
80+
);
81+
return NextResponse.json(
82+
{
83+
error:
84+
"wiki-profile worker URL is not configured — set WIKI_PROFILE_URL to the worker's https URL.",
85+
},
86+
{ status: 500 }
87+
);
88+
}
89+
90+
// Optional {dry_run: true} body flag. The worker takes dry-run as a query
91+
// param, so translate body → query here; anything else in the body is ignored.
92+
const body = (await request.json().catch(() => ({}))) as { dry_run?: unknown };
93+
const target = body?.dry_run === true ? `${workerUrl}?dry_run=true` : workerUrl;
94+
95+
const controller = new AbortController();
96+
const timer = setTimeout(
97+
() => controller.abort(new Error(`wiki-profile worker timeout after ${WORKER_TIMEOUT_MS}ms`)),
98+
WORKER_TIMEOUT_MS
99+
);
100+
let res: Response;
101+
try {
102+
res = await fetch(target, {
103+
method: "POST",
104+
headers: { "x-brain-key": apiKey, "Content-Type": "application/json" },
105+
signal: controller.signal,
106+
});
107+
} catch (err) {
108+
// With an abort reason, undici rejects with that Error; older runtimes
109+
// reject with a DOMException named AbortError. Catch both shapes.
110+
const isTimeout =
111+
(err instanceof Error && err.message.startsWith("wiki-profile worker timeout")) ||
112+
(typeof err === "object" &&
113+
err !== null &&
114+
(err as { name?: string }).name === "AbortError");
115+
if (isTimeout) {
116+
console.error(`[wiki/profile:regenerate] worker timed out after ${WORKER_TIMEOUT_MS}ms`);
117+
return NextResponse.json(
118+
{
119+
error:
120+
"Profile generation timed out. The worker may still be finishing — refresh in a minute to see the result.",
121+
},
122+
{ status: 504 }
123+
);
124+
}
125+
console.error("[wiki/profile:regenerate] fetch failed", err);
126+
return NextResponse.json(
127+
{ error: "Could not reach the wiki-profile worker." },
128+
{ status: 502 }
129+
);
130+
} finally {
131+
clearTimeout(timer);
132+
}
133+
134+
if (!res.ok) {
135+
// ApiError discipline: log the upstream body server-side for debugging,
136+
// return only a safe hand-written message to the browser.
137+
const upstreamBody = await res.text().catch(() => "");
138+
console.error("[wiki/profile:regenerate] upstream", res.status, upstreamBody);
139+
if (res.status === 404) {
140+
// The Supabase functions host answers 404 when the function isn't
141+
// deployed — the single most likely failure for a fresh install.
142+
return NextResponse.json(
143+
{
144+
error:
145+
"wiki-profile worker not deployed — deploy integrations/consolidation-workers/wiki-profile " +
146+
"(supabase functions deploy wiki-profile --no-verify-jwt), then try again.",
147+
},
148+
{ status: 404 }
149+
);
150+
}
151+
if (res.status === 401) {
152+
return NextResponse.json(
153+
{ error: "The worker rejected the brain key (MCP_ACCESS_KEY mismatch between functions)." },
154+
{ status: 401 }
155+
);
156+
}
157+
if (res.status === 503) {
158+
return NextResponse.json(
159+
{
160+
error:
161+
"The worker is missing configuration (LLM API keys or MCP_ACCESS_KEY) — check its Supabase secrets.",
162+
},
163+
{ status: 503 }
164+
);
165+
}
166+
return NextResponse.json(
167+
{ error: `Profile generation failed (upstream ${res.status}).` },
168+
{ status: res.status }
169+
);
170+
}
171+
172+
let payload: unknown;
173+
try {
174+
payload = await res.json();
175+
} catch (err) {
176+
console.error("[wiki/profile:regenerate] unreadable worker response", err);
177+
return NextResponse.json(
178+
{ error: "The worker returned an unreadable response." },
179+
{ status: 502 }
180+
);
181+
}
182+
183+
// Pass the worker's JSON through unchanged — the client renders the
184+
// per-section outcomes (created / updated / pending / skipped / error).
185+
return NextResponse.json(payload);
186+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
6+
// Per-section outcome from a wiki-profile worker run. `action` values:
7+
// created / updated — section written in place (machine-owned)
8+
// pending — section is human-owned; draft parked for review
9+
// skipped — no supporting thoughts / cap reached; left untouched
10+
// error — that section failed; the rest of the run continued
11+
type SectionOutcome = {
12+
section_key: string;
13+
action: string;
14+
reason?: string;
15+
thought_count?: number;
16+
};
17+
18+
type RegenerateResponse = {
19+
sections?: SectionOutcome[];
20+
error?: string;
21+
};
22+
23+
const ACTION_STYLES: Record<string, string> = {
24+
created: "text-violet",
25+
updated: "text-violet",
26+
pending: "text-amber",
27+
skipped: "text-text-muted",
28+
error: "text-danger",
29+
};
30+
31+
function outcomeLabel(o: SectionOutcome): string {
32+
if (o.action === "pending") return "pending — awaiting your review";
33+
if (o.action === "skipped" && o.reason === "no_supporting_thoughts") return "skipped — no evidence";
34+
return o.action;
35+
}
36+
37+
/**
38+
* Triggers the wiki-profile consolidation worker via
39+
* POST /api/wiki/profile/regenerate and renders the per-section outcome
40+
* summary the worker returns. Used in the /wiki/user-profile page header
41+
* ("Regenerate profile") and, with `successHref`, by the /wiki list's
42+
* "Create your profile" card — the worker bootstraps the page itself, so
43+
* both flows hit the same route.
44+
*/
45+
export function RegenerateProfileButton({
46+
label = "Regenerate profile",
47+
successHref,
48+
}: {
49+
label?: string;
50+
/**
51+
* When set, navigate here after a successful run instead of rendering the
52+
* outcome summary in place (the create-CTA lands the user on the fresh page,
53+
* where the sections — including any pending review panels — are visible).
54+
*/
55+
successHref?: string;
56+
}) {
57+
const router = useRouter();
58+
const [busy, setBusy] = useState(false);
59+
const [error, setError] = useState<string | null>(null);
60+
const [outcomes, setOutcomes] = useState<SectionOutcome[] | null>(null);
61+
62+
async function handleRun() {
63+
if (busy) return;
64+
setBusy(true);
65+
setError(null);
66+
setOutcomes(null);
67+
try {
68+
const res = await fetch("/api/wiki/profile/regenerate", { method: "POST" });
69+
const data = (await res.json().catch(() => ({}))) as RegenerateResponse;
70+
if (!res.ok) {
71+
throw new Error(data.error || "Profile generation failed");
72+
}
73+
const sections = Array.isArray(data.sections) ? data.sections : [];
74+
// The worker returns 200 even when individual sections fail; only
75+
// navigate away when the run was fully clean, otherwise stay and show
76+
// the per-section outcomes so errors aren't silently dropped.
77+
if (successHref && !sections.some((s) => s.action === "error")) {
78+
router.push(successHref);
79+
router.refresh();
80+
return;
81+
}
82+
setOutcomes(sections);
83+
// Re-read the server component so new/updated section bodies (and any
84+
// pending review panels) render without a manual reload.
85+
router.refresh();
86+
} catch (err) {
87+
setError(err instanceof Error ? err.message : "Something went wrong");
88+
} finally {
89+
setBusy(false);
90+
}
91+
}
92+
93+
return (
94+
<div className="flex flex-col items-end gap-2 max-w-sm">
95+
<button
96+
type="button"
97+
onClick={handleRun}
98+
disabled={busy}
99+
className="px-3 py-2 text-sm bg-violet hover:bg-violet-dim text-white font-medium rounded-lg transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
100+
>
101+
{busy ? "Generating… this can take a minute" : label}
102+
</button>
103+
{error && <p className="text-danger text-xs text-right">{error}</p>}
104+
{outcomes && (
105+
<div className="w-full bg-bg-surface border border-border rounded-lg px-3 py-2 space-y-1">
106+
{outcomes.length === 0 ? (
107+
<p className="text-text-muted text-xs">The run completed but reported no sections.</p>
108+
) : (
109+
outcomes.map((o) => (
110+
<div
111+
key={o.section_key}
112+
className="flex items-center justify-between gap-3 text-xs"
113+
>
114+
<span className="font-mono text-text-secondary truncate">{o.section_key}</span>
115+
<span className={`whitespace-nowrap ${ACTION_STYLES[o.action] ?? "text-text-secondary"}`}>
116+
{outcomeLabel(o)}
117+
</span>
118+
</div>
119+
))
120+
)}
121+
</div>
122+
)}
123+
</div>
124+
);
125+
}

dashboards/open-brain-dashboard-pro/app/wiki/[slug]/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { FormattedDate } from "@/components/FormattedDate";
55
import { WikiSectionPanel } from "./WikiSectionPanel";
66
import { AddSectionForm } from "./AddSectionForm";
77
import { ArchivePageButton } from "./ArchivePageButton";
8+
import { RegenerateProfileButton } from "./RegenerateProfileButton";
89

910
export const dynamic = "force-dynamic";
1011

@@ -76,7 +77,13 @@ export default async function WikiPageDetail({
7677
</div>
7778
<p className="text-text-muted text-xs mt-1 font-mono">{page.slug}</p>
7879
</div>
79-
{!archived && <ArchivePageButton slug={page.slug} />}
80+
<div className="flex items-start gap-2">
81+
{/* The user-profile page is built by the wiki-profile consolidation
82+
worker; give it a one-click regenerate. Other slugs have no
83+
backing generator, so the button only renders here. */}
84+
{page.slug === "user-profile" && <RegenerateProfileButton />}
85+
{!archived && <ArchivePageButton slug={page.slug} />}
86+
</div>
8087
</div>
8188
</div>
8289

0 commit comments

Comments
 (0)