From 81acd8cb7191bbd9331c81f85967eb47a2287e0a Mon Sep 17 00:00:00 2001 From: hshum Date: Wed, 3 Jun 2026 00:58:21 -0700 Subject: [PATCH] feat(scratchnode): import published event recap into NodeBench workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap #3, slice 1. Lets a visitor import a published ScratchNode event wiki as ONE editable NodeBench product document under a fresh NodeBench-origin anonymous product identity. On later sign-in the existing bootstrap merge path (claimAnonymousProductWorkspace) re-owns it from anon: to user: — no bespoke merge code. Backend: - convex/events.ts: getPublishedWikiStructuredBySlug + shared reader loadStructuredPublishedWiki — the same published snapshot as getPublishedWikiBySlug, structured (answers + sources) for the importer. PUBLISHED-only; private notes excluded at publish time. BOUND ≤ 20/20. - convex/domains/product/scratchnodeImport.ts (new): importPublishedWiki + getScratchnodeImportStatus. Reuses the existing productDocuments/Blocks/Snapshots primitives (no parallel doc system), creates the canonical event entity (entityType "event", owner-private), and is idempotent via a stable entity slug + hash(eventId|version|owner) import key (same version = no-op, newer version = new revision). Honest no-op on draft/unpublished/unknown. No fuzzy entity extraction (deferred). Frontend: - ScratchnodeEventsSurface.tsx: per-row "Import this recap into NodeBench" action, gated on a published wiki, importing under the fresh product anon identity (getAnonymousProductSessionId, not the cross-domain sn_session_id), with real importing/done/error states and a link to /entity/. Reliability: BOUND, HONEST_STATUS, BOUND_READ, DETERMINISTIC. PUBLIC-DATA-ONLY — never reads or writes userNotes / private-note content. Tests: 11 convex-test scenarios (happy import, idempotency, re-publish→new revision, unpublished/draft/unknown no-ops, privacy no-leak, status query, structured read). 11/11 green; tsc --noEmit clean; npm run build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pages/scratchnode-nodebench-bridge.md | 67 +++ .../domains/product/scratchnodeImport.test.ts | 490 ++++++++++++++++++ convex/domains/product/scratchnodeImport.ts | 489 +++++++++++++++++ convex/events.ts | 120 +++++ .../surfaces/ScratchnodeEventsSurface.tsx | 146 +++++- 5 files changed, 1311 insertions(+), 1 deletion(-) create mode 100644 convex/domains/product/scratchnodeImport.test.ts create mode 100644 convex/domains/product/scratchnodeImport.ts diff --git a/CHANGELOG/pages/scratchnode-nodebench-bridge.md b/CHANGELOG/pages/scratchnode-nodebench-bridge.md index 7b03089c..18dc7c8e 100644 --- a/CHANGELOG/pages/scratchnode-nodebench-bridge.md +++ b/CHANGELOG/pages/scratchnode-nodebench-bridge.md @@ -3,6 +3,73 @@ Append-only lane for the conversion bridge from the disposable ScratchNode live-event room into the NodeBench app. Newest entries on top. +## 2026-06-03 — Slice 1: import a published event recap into the WORKSPACE + +Lets a visitor import a published ScratchNode event wiki as ONE editable +NodeBench product document under a FRESH NodeBench-origin anonymous product +identity. On later sign-in the EXISTING bootstrap merge path +(`convex/domains/product/bootstrap.ts` → `claimAnonymousProductWorkspace`) +re-owns it from `anon:` to `user:` — no bespoke merge code. + +**Backend** + +- `convex/events.ts` — added `getPublishedWikiStructuredBySlug({ slug })` plus the + shared reader `loadStructuredPublishedWiki(ctx, slug)`. Returns the SAME + published snapshot as `getPublishedWikiBySlug`, but STRUCTURED + (`{ eventId, slug, eventName, roomCode, wikiVersion, answers:[{question,body}], + sources:[{title,uri,excerpt}] }`) so the importer builds editable blocks with + no HTML round-trip. PUBLISHED-only; private notes are excluded at publish time, + so there is nothing private to read here. BOUND: answers ≤ 20, sources ≤ 20. +- `convex/domains/product/scratchnodeImport.ts` (new): + - `importPublishedWiki({ slug, anonymousSessionId })` — resolves the anon (or + signed-in) product identity, reads the published structured wiki, and + materializes ONE `entity_memory` product document (title `" — + recap"`, body = Q&A + sources blocks) plus the canonical event entity + (`entityType: "event"`, owner-private). Reuses the existing + `productDocuments`/`productDocumentBlocks`/`productDocumentSnapshots` + primitives — no parallel doc system. + - Idempotent: stable entity slug `scratchnode-event-` maps every + re-import of the same event to the same document; a per-import key + `hash(eventId|wikiVersion|ownerKey)` makes a re-import of the SAME published + version a no-op (`alreadyImported:true`), and a NEWER published version writes + a fresh revision/snapshot on the same document (no duplicate). + - `getScratchnodeImportStatus({ slug, anonymousSessionId })` — read-only state + (`published` / `imported` / `upToDate` / `entitySlug`) for an honest UI. + - Returns `{ ok, documentId, entitySlug, created, alreadyImported }`. Honest + no-op (`ok:false, reason:"no_published_wiki"`) on draft / unpublished / + unknown slug — never a fabricated empty recap. + - No fuzzy company/person extraction (avoids fabricated entities) — deferred. + +**Frontend** + +- `src/features/redesign/surfaces/ScratchnodeEventsSurface.tsx` — each event row + gains an "Import this recap into NodeBench" action via the new + `ImportRecapButton`. It only renders when a PUBLISHED wiki actually exists + (gated on `getScratchnodeImportStatus.published`), imports under the FRESH + NodeBench product anon identity (`getAnonymousProductSessionId`, NOT the + cross-domain `sn_session_id`), shows real importing/done/error states, and + links to the created recap at `/entity/`. The existing "Open in + ScratchNode" CTA is untouched. `(api as any)` mirrors the surface's existing + codegen-independent call style. + +**Reliability (agentic_reliability 8-point)**: BOUND (all reads ≤ 25), HONEST_STATUS +(no fake success; throws on entity-create failure), BOUND_READ (dangling ids +skipped, text sliced to caps), DETERMINISTIC (FNV-1a hash, stable block ids / +entity slug / import key). No external fetch, so SSRF/TIMEOUT are N/A. + +**Privacy**: PUBLIC-DATA-ONLY. The importer reads only the published wiki snapshot +via `loadStructuredPublishedWiki`; it never reads or writes `userNotes` / +`liveEventNoteAnchors`, and never writes under another user's owner key. + +Covered by 11 convex-test scenarios in +`convex/domains/product/scratchnodeImport.test.ts`: happy import (editable doc + +event entity), re-import idempotency (no dup), re-publish → new revision, +unpublished / draft / unknown → honest no-op, a PRIVACY test proving a private +note marker never reaches the imported document, plus status-query and +structured-read coverage. 11/11 green; `tsc --noEmit` clean. + +**Commit**: `this commit`. **Author**: Homen Shum + Claude (agent build). + ## 2026-06-03 — Make the bridge REAL: a `/events/:slug/wiki` receiving route The bridge was broken end-to-end. ScratchNode sent users to `nodebenchai.com/events//wiki`, but the `/events` matcher in `src/App.tsx` diff --git a/convex/domains/product/scratchnodeImport.test.ts b/convex/domains/product/scratchnodeImport.test.ts new file mode 100644 index 00000000..323af59d --- /dev/null +++ b/convex/domains/product/scratchnodeImport.test.ts @@ -0,0 +1,490 @@ +/// +/** + * Scenario tests for the ScratchNode event → NodeBench WORKSPACE import + * (roadmap #3, slice 1) — convex/domains/product/scratchnodeImport.ts. + * + * Per .claude/rules/scenario_testing.md: each test names a real persona + goal + * + prior state + actions + expected outcome. Runs the real Convex transaction + * engine via convex-test so the by_owner_entity_kind idempotency index + + * by_event_status published-only ordering behave exactly as in production. + * + * The contract under test: + * - happy import: a published wiki → ONE editable product document under the + * caller's FRESH NodeBench-origin anon identity, with Q&A + sources blocks. + * - idempotency: re-importing the SAME published version is a no-op (no dup + * document, alreadyImported:true). + * - re-publish: a NEWER published version creates a new revision on the SAME + * document (not a second document). + * - honesty: unpublished / draft / unknown slug → ok:false no-op (no + * fabricated empty recap, no document written). + * - PRIVACY: the importer NEVER reads or writes private-note content + * (userNotes); private bodies never reach the imported document. + */ +import { describe, expect, it } from "vitest"; +import { api } from "../../_generated/api"; +import schema from "../../schema"; + +// convex-test maps function modules by their convex-root-relative path +// (e.g. "domains/product/scratchnodeImport"). Vite's import.meta.glob keys are +// relative to THIS file's directory ("./scratchnodeImport.ts"), which would +// resolve to the wrong function path. The convex/__tests__ suites sit one level +// under convex/ so their "../**" keys happen to root correctly; this file sits +// two levels deeper, so we re-root each key from the test dir back to convex/. +const DIR_SEGMENTS = ["domains", "product"]; // this test file's dir under convex/ +function rerootGlobKey(key: string): string { + const parts = key.replace(/^\.\//, "").split("/"); + const base = [...DIR_SEGMENTS]; + while (parts[0] === "..") { + parts.shift(); + base.pop(); + } + return [...base, ...parts].join("/"); +} +const convexModules = Object.fromEntries( + Object.entries(import.meta.glob("../../**/*.{ts,js}")).map(([key, loader]) => [ + rerootGlobKey(key), + loader, + ]), +); + +let convexTest: any; +let convexTestAvailable = false; +try { + const mod = await import(/* @vite-ignore */ "convex-test"); + convexTest = mod.convexTest; + convexTestAvailable = typeof convexTest === "function"; +} catch { + convexTestAvailable = false; +} + +const NOW = 1_700_000_000_000; +const importApi = (api as any).domains.product.scratchnodeImport; + +async function seedEvent(t: any, opts: { slug: string; roomCode: string; name?: string }) { + return await t.run(async (ctx: any) => + ctx.db.insert("liveEvents", { + slug: opts.slug, + name: opts.name ?? `${opts.slug} event`, + roomCode: opts.roomCode, + status: "live", + startedAt: NOW, + }), + ); +} + +async function seedSource( + t: any, + eventId: any, + opts: { uri: string; title: string; excerpt: string; body: string }, +) { + return await t.run(async (ctx: any) => + ctx.db.insert("liveEventSources", { + eventId, + uri: opts.uri, + kind: "doc", + title: opts.title, + excerpt: opts.excerpt, + body: opts.body, + sourceHash: `hash-${opts.uri}`, + isSeeded: false, + uploadedAt: NOW, + }), + ); +} + +async function seedAnswer( + t: any, + eventId: any, + opts: { question: string; body: string; sourceIds: any[] }, +) { + return await t.run(async (ctx: any) => { + // liveEventAnswers.questionMessageId is required; seed a throwaway message. + const messageId = await ctx.db.insert("liveEventMessages", { + eventId, + sessionId: "seed-session", + displayName: "Seed", + text: opts.question, + kind: "ask", + createdAt: NOW, + }); + return await ctx.db.insert("liveEventAnswers", { + eventId, + questionMessageId: messageId, + question: opts.question, + normalizedQuestion: opts.question.toLowerCase(), + body: opts.body, + sourceIds: opts.sourceIds, + trace: [], + cacheHit: false, + faqStatus: "promoted", + createdAt: NOW, + }); + }); +} + +async function seedWiki( + t: any, + eventId: any, + opts: { + version: number; + status: "draft" | "published"; + answerIds?: any[]; + sourceIds?: any[]; + bodyHtml?: string; + }, +) { + return await t.run(async (ctx: any) => + ctx.db.insert("liveEventWikiVersions", { + eventId, + version: opts.version, + status: opts.status, + title: "Event Wiki", + bodyHtml: opts.bodyHtml ?? "

recap

", + sourceAnswerIds: opts.answerIds ?? [], + sourceIds: opts.sourceIds ?? [], + createdByOwnerKey: "hk1:host-key-never-imported", + createdAt: NOW + opts.version, + publishedAt: opts.status === "published" ? NOW + opts.version : undefined, + }), + ); +} + +/** Read all productDocuments + their blocks for an anon owner. */ +async function readOwnerDocuments(t: any, anonymousSessionId: string) { + const ownerKey = `anon:${anonymousSessionId}`; + return await t.run(async (ctx: any) => { + const documents = await ctx.db + .query("productDocuments") + .withIndex("by_owner_updated", (q: any) => q.eq("ownerKey", ownerKey)) + .collect(); + const out: any[] = []; + for (const doc of documents) { + const blocks = await ctx.db + .query("productDocumentBlocks") + .withIndex("by_document_order", (q: any) => q.eq("documentId", doc._id)) + .collect(); + out.push({ doc, blocks }); + } + return out; + }); +} + +describe.skipIf(!convexTestAvailable)("ScratchNode event import — importPublishedWiki", () => { + it("Maya (attended a launch) imports the published recap and gets an editable NodeBench doc", async () => { + // Persona: Maya joined a room on scratchnode.live and now wants the public + // recap saved in her NodeBench workspace under her own anon identity. + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "rooftop-launch", roomCode: "ROOF01", name: "Rooftop Launch" }); + const sourceId = await seedSource(t, eventId, { + uri: "https://example.com/launch-deck", + title: "Launch deck", + excerpt: "Pricing and GA timeline.", + body: "Full launch deck body.", + }); + const answerId = await seedAnswer(t, eventId, { + question: "When does GA ship?", + body: "GA ships next quarter per the launch deck.", + sourceIds: [sourceId], + }); + await seedWiki(t, eventId, { version: 1, status: "published", answerIds: [answerId], sourceIds: [sourceId] }); + + const sessionId = "maya-anon-001"; + const result = await t.mutation(importApi.importPublishedWiki, { + slug: "rooftop-launch", + anonymousSessionId: sessionId, + }); + + expect(result.ok).toBe(true); + expect(result.created).toBe(true); + expect(result.alreadyImported).toBe(false); + expect(result.documentId).toBeTruthy(); + + const owned = await readOwnerDocuments(t, sessionId); + expect(owned).toHaveLength(1); + const { doc, blocks } = owned[0]; + expect(doc.kind).toBe("entity_memory"); + expect(doc.title).toBe("Rooftop Launch — recap"); + // The Q&A and source content must have made it into editable blocks. + const blockText = blocks.map((b: any) => b.text).join("\n"); + expect(blockText).toContain("When does GA ship?"); + expect(blockText).toContain("GA ships next quarter"); + expect(blockText).toContain("Launch deck"); + + // The canonical event entity must exist, owner-private, type "event". + const entity = await t.run(async (ctx: any) => + ctx.db.get(doc.entityId), + ); + expect(entity.entityType).toBe("event"); + expect(entity.visibility).toBe("private"); + expect(entity.ownerKey).toBe(`anon:${sessionId}`); + }); + + it("re-importing the SAME published version is a no-op (idempotent — no duplicate doc)", async () => { + // Persona: Maya double-taps the import button / reloads and clicks again. + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "design-jam", roomCode: "JAM01" }); + await seedWiki(t, eventId, { version: 1, status: "published" }); + + const sessionId = "maya-anon-002"; + const first = await t.mutation(importApi.importPublishedWiki, { + slug: "design-jam", + anonymousSessionId: sessionId, + }); + const second = await t.mutation(importApi.importPublishedWiki, { + slug: "design-jam", + anonymousSessionId: sessionId, + }); + + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.alreadyImported).toBe(true); + expect(String(second.documentId)).toBe(String(first.documentId)); + + // Exactly ONE document — no duplication. + const owned = await readOwnerDocuments(t, sessionId); + expect(owned).toHaveLength(1); + }); + + it("a NEWER published version creates a new revision on the SAME document (not a 2nd doc)", async () => { + // Persona: the host publishes wiki v2 after Maya already imported v1; she + // clicks "Update recap". + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "weekly-sync", roomCode: "WK01" }); + const v1Answer = await seedAnswer(t, eventId, { + question: "Q1?", + body: "old answer v1", + sourceIds: [], + }); + await seedWiki(t, eventId, { version: 1, status: "published", answerIds: [v1Answer] }); + + const sessionId = "maya-anon-003"; + const first = await t.mutation(importApi.importPublishedWiki, { + slug: "weekly-sync", + anonymousSessionId: sessionId, + }); + + // Host publishes a newer version with fresh content. + const v2Answer = await seedAnswer(t, eventId, { + question: "Q2 NEW?", + body: "fresh answer v2", + sourceIds: [], + }); + await seedWiki(t, eventId, { version: 2, status: "published", answerIds: [v2Answer] }); + + const second = await t.mutation(importApi.importPublishedWiki, { + slug: "weekly-sync", + anonymousSessionId: sessionId, + }); + + expect(second.created).toBe(false); + expect(second.alreadyImported).toBe(false); // a real update happened + expect(String(second.documentId)).toBe(String(first.documentId)); + expect(second.wikiVersion).toBe(2); + + // Still ONE document, but body refreshed to v2 content and a 2nd snapshot. + const owned = await readOwnerDocuments(t, sessionId); + expect(owned).toHaveLength(1); + const { doc, blocks } = owned[0]; + expect(doc.latestRevision).toBe(2); + const blockText = blocks.map((b: any) => b.text).join("\n"); + expect(blockText).toContain("Q2 NEW?"); + expect(blockText).not.toContain("Q1?"); + + const snapshots = await t.run(async (ctx: any) => + ctx.db + .query("productDocumentSnapshots") + .withIndex("by_document_revision", (q: any) => q.eq("documentId", doc._id)) + .collect(), + ); + expect(snapshots).toHaveLength(2); + }); + + it("an UNPUBLISHED room returns an honest no-op — no document written", async () => { + // Persona: someone clicks import on a room whose host never published a wiki. + const t = convexTest(schema, convexModules); + await seedEvent(t, { slug: "not-published", roomCode: "NOPE01" }); + + const sessionId = "anon-004"; + const result = await t.mutation(importApi.importPublishedWiki, { + slug: "not-published", + anonymousSessionId: sessionId, + }); + + expect(result.ok).toBe(false); + expect(result.documentId).toBeNull(); + expect(result.reason).toBe("no_published_wiki"); + const owned = await readOwnerDocuments(t, sessionId); + expect(owned).toHaveLength(0); + }); + + it("a DRAFT-only wiki is never importable (published boundary)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "draft-only", roomCode: "DRFT01" }); + await seedWiki(t, eventId, { version: 1, status: "draft" }); + + const sessionId = "anon-005"; + const result = await t.mutation(importApi.importPublishedWiki, { + slug: "draft-only", + anonymousSessionId: sessionId, + }); + expect(result.ok).toBe(false); + expect((await readOwnerDocuments(t, sessionId))).toHaveLength(0); + }); + + it("an UNKNOWN slug returns an honest no-op (no fabricated room/recap)", async () => { + const t = convexTest(schema, convexModules); + const sessionId = "anon-006"; + const result = await t.mutation(importApi.importPublishedWiki, { + slug: "ghost-room-9999", + anonymousSessionId: sessionId, + }); + expect(result.ok).toBe(false); + expect(result.documentId).toBeNull(); + expect((await readOwnerDocuments(t, sessionId))).toHaveLength(0); + }); + + it("PRIVACY: the importer never reads or writes private-note content", async () => { + // Persona: an attendee wrote a private note in the room ("SECRET salary + // numbers"). The public recap import must NEVER surface it. + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "private-room", roomCode: "PRIV01" }); + + // A private note exists for the room — owner-keyed, NOT part of the wiki. + const PRIVATE_MARKER = "PRIVATE_SALARY_SECRET_DO_NOT_LEAK"; + await t.run(async (ctx: any) => + ctx.db.insert("userNotes", { + ownerKey: "sn_session_some_attendee", + eventId, + title: "my private note", + bodyHtml: `

${PRIVATE_MARKER}

`, + tags: [], + pinned: false, + isAsk: false, + createdAt: NOW, + updatedAt: NOW, + }), + ); + + // The published wiki contains only public answers. + const answerId = await seedAnswer(t, eventId, { + question: "What was announced publicly?", + body: "Public answer with no secrets.", + sourceIds: [], + }); + await seedWiki(t, eventId, { version: 1, status: "published", answerIds: [answerId] }); + + const sessionId = "anon-007"; + const result = await t.mutation(importApi.importPublishedWiki, { + slug: "private-room", + anonymousSessionId: sessionId, + }); + expect(result.ok).toBe(true); + + const owned = await readOwnerDocuments(t, sessionId); + expect(owned).toHaveLength(1); + const { doc, blocks } = owned[0]; + const allText = `${doc.markdown}\n${doc.plainText}\n${blocks.map((b: any) => b.text).join("\n")}`; + // The private marker must appear NOWHERE in the imported document. + expect(allText).not.toContain(PRIVATE_MARKER); + expect(allText).toContain("Public answer with no secrets."); + + // And the private note row itself was never re-owned or mutated. + const privateNote = await t.run(async (ctx: any) => + ctx.db + .query("userNotes") + .withIndex("by_owner_event", (q: any) => + q.eq("ownerKey", "sn_session_some_attendee").eq("eventId", eventId), + ) + .first(), + ); + expect(privateNote).not.toBeNull(); + expect(privateNote.bodyHtml).toContain(PRIVATE_MARKER); + }); +}); + +describe.skipIf(!convexTestAvailable)("ScratchNode event import — getScratchnodeImportStatus", () => { + it("reports not-imported before import, then imported + up-to-date after", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "status-room", roomCode: "STAT01" }); + await seedWiki(t, eventId, { version: 1, status: "published" }); + const sessionId = "anon-008"; + + const before = await t.query(importApi.getScratchnodeImportStatus, { + slug: "status-room", + anonymousSessionId: sessionId, + }); + expect(before.published).toBe(true); + expect(before.imported).toBe(false); + expect(before.entitySlug).toBeTruthy(); + + await t.mutation(importApi.importPublishedWiki, { + slug: "status-room", + anonymousSessionId: sessionId, + }); + + const after = await t.query(importApi.getScratchnodeImportStatus, { + slug: "status-room", + anonymousSessionId: sessionId, + }); + expect(after.published).toBe(true); + expect(after.imported).toBe(true); + expect(after.upToDate).toBe(true); + expect(after.documentId).toBeTruthy(); + }); + + it("an unknown slug reports not-published, not-imported (honest)", async () => { + const t = convexTest(schema, convexModules); + const status = await t.query(importApi.getScratchnodeImportStatus, { + slug: "ghost-9999", + anonymousSessionId: "anon-009", + }); + expect(status.published).toBe(false); + expect(status.imported).toBe(false); + expect(status.documentId).toBeNull(); + }); +}); + +describe.skipIf(!convexTestAvailable)("ScratchNode event import — getPublishedWikiStructuredBySlug", () => { + it("returns structured Q&A + sources for a published wiki, public-safe only", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "struct-room", roomCode: "STR01", name: "Struct Room" }); + const sourceId = await seedSource(t, eventId, { + uri: "https://example.com/x", + title: "Source X", + excerpt: "excerpt x", + body: "body x", + }); + const answerId = await seedAnswer(t, eventId, { + question: "Question A?", + body: "Answer A body.", + sourceIds: [sourceId], + }); + await seedWiki(t, eventId, { + version: 3, + status: "published", + answerIds: [answerId], + sourceIds: [sourceId], + }); + + const structured = await t.query(api.events.getPublishedWikiStructuredBySlug, { + slug: "struct-room", + }); + expect(structured).not.toBeNull(); + expect(structured.eventName).toBe("Struct Room"); + expect(structured.wikiVersion).toBe(3); + expect(structured.answers).toEqual([{ question: "Question A?", body: "Answer A body." }]); + expect(structured.sources).toEqual([ + { title: "Source X", uri: "https://example.com/x", excerpt: "excerpt x" }, + ]); + // PRIVACY: never leak the host ownerKey. + expect((structured as any).createdByOwnerKey).toBeUndefined(); + }); + + it("a draft / unknown slug returns null (no fabricated structure)", async () => { + const t = convexTest(schema, convexModules); + const eventId = await seedEvent(t, { slug: "struct-draft", roomCode: "SD01" }); + await seedWiki(t, eventId, { version: 1, status: "draft" }); + expect(await t.query(api.events.getPublishedWikiStructuredBySlug, { slug: "struct-draft" })).toBeNull(); + expect(await t.query(api.events.getPublishedWikiStructuredBySlug, { slug: "nope-xyz" })).toBeNull(); + }); +}); diff --git a/convex/domains/product/scratchnodeImport.ts b/convex/domains/product/scratchnodeImport.ts new file mode 100644 index 00000000..8a541a1e --- /dev/null +++ b/convex/domains/product/scratchnodeImport.ts @@ -0,0 +1,489 @@ +/** + * convex/domains/product/scratchnodeImport.ts — ScratchNode event → NodeBench + * WORKSPACE import (roadmap #3, slice 1). + * + * Pattern: public-data-only projection import. + * Prior art: + * - .claude/rules/scratchpad_first.md — structured output derives from a + * durable source; the published wiki IS that source here. + * - .claude/rules/layered_memory.md — anon-keyed per-user document layer. + * - convex/domains/product/documents.ts — the editable productDocuments / + * productDocumentBlocks / productDocumentSnapshots primitive we reuse. + * + * What this does + * -------------- + * Takes a published ScratchNode event wiki (public, no private notes) and + * materializes it as ONE editable NodeBench product document under a FRESH + * NodeBench-origin anonymous product identity (NOT the cross-domain + * sn_session_id). The document lives in the same productDocuments table the + * entity notebook uses, so the EXISTING merge-on-sign-in path + * (convex/domains/product/bootstrap.ts → claimAnonymousProductWorkspace) + * automatically re-owns it from `anon:` to `user:` when the + * visitor later signs in. No bespoke merge code. + * + * Privacy invariants (release-blocker) + * ------------------------------------ + * - PUBLIC-DATA-ONLY. We read ONLY the published wiki snapshot via + * `loadStructuredPublishedWiki` (published-only; private notes excluded at + * publish time). We NEVER read userNotes / liveEventNoteAnchors, and we + * NEVER write under another user's ownerKey. + * - The created document is owned by the resolved anon (or signed-in) + * product identity — never the ScratchNode host ownerKey. + * + * Idempotency (agentic_reliability: DETERMINISTIC) + * ------------------------------------------------ + * - A stable entity slug `scratchnode-event-` maps every + * re-import of the same event to the SAME entity + document. + * - A per-import key `hash(eventId|wikiVersion|ownerKey)` is recorded on the + * import event. Re-importing the SAME published version is a no-op that + * returns the existing document (`alreadyImported: true`). A NEWER + * published version writes a fresh snapshot/revision on the same document + * (reusing the existing revision/snapshot path — no duplicate doc). + * + * We intentionally do NOT do fuzzy company/person entity extraction here + * (avoids fabricated entities). Only the canonical event entity is created. + */ + +import { v } from "convex/values"; +import { mutation, query } from "../../_generated/server"; +import type { Doc, Id } from "../../_generated/dataModel"; +import { internal } from "../../_generated/api"; +import { + requireProductIdentity, + resolveProductIdentitySafely, + summarizeText, +} from "./helpers"; +import { + loadStructuredPublishedWiki, + type StructuredPublishedWiki, +} from "../../events"; +import { normalizeProductDocumentBlocks } from "./documents"; +import { composeSearchableText } from "../search/federatedHelpers"; + +// BOUND (agentic_reliability): hard ceilings so a hostile/huge wiki can never +// blow the mutation budget. The structured reader already caps answers/sources, +// but we cap again at the write boundary (defense in depth). +const MAX_IMPORT_ANSWERS = 20; +const MAX_IMPORT_SOURCES = 20; +const MAX_BLOCK_TEXT = 4_000; + +/** + * FNV-1a 32-bit — same stable, dependency-free hash used in convex/events.ts. + * Deterministic across runs (DETERMINISTIC invariant), good enough for + * collision-resistant idempotency keys at room scale. + */ +function stableHash(value: string): string { + let hash = 2166136261; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function importEntitySlug(eventId: string): string { + return `scratchnode-event-${stableHash(eventId)}`; +} + +function importKey(eventId: string, wikiVersion: number, ownerKey: string): string { + return stableHash(`${eventId}|${wikiVersion}|${ownerKey}`); +} + +/** + * Build the recap document body as normalized editable blocks from the + * structured published wiki. Deterministic: same wiki → same blocks → same + * markdown. No private data — input is the published snapshot only. + */ +function buildRecapBlocks(wiki: StructuredPublishedWiki) { + const raw: Array<{ + blockId: string; + order: number; + type: "paragraph" | "heading" | "bullet" | "quote" | "check" | "code"; + text: string; + markdown?: string; + }> = []; + let order = 0; + + raw.push({ + blockId: "recap-title", + order: order++, + type: "heading", + text: `${wiki.eventName} — recap`, + markdown: `# ${wiki.eventName} — recap`, + }); + raw.push({ + blockId: "recap-intro", + order: order++, + type: "paragraph", + text: "Imported from the published ScratchNode event wiki (public answers and public sources only). Private notes are not included.", + markdown: + "Imported from the published ScratchNode event wiki (public answers and public sources only). Private notes are not included.", + }); + + const answers = wiki.answers.slice(0, MAX_IMPORT_ANSWERS); + if (answers.length > 0) { + raw.push({ + blockId: "recap-qa-heading", + order: order++, + type: "heading", + text: "Q&A", + markdown: "## Q&A", + }); + answers.forEach((answer, index) => { + const question = String(answer.question || "").trim().slice(0, MAX_BLOCK_TEXT); + const body = String(answer.body || "").trim().slice(0, MAX_BLOCK_TEXT); + if (question) { + raw.push({ + blockId: `recap-q-${index}`, + order: order++, + type: "heading", + text: question, + markdown: `### ${question}`, + }); + } + if (body) { + raw.push({ + blockId: `recap-a-${index}`, + order: order++, + type: "paragraph", + text: body, + markdown: body, + }); + } + }); + } + + const sources = wiki.sources.slice(0, MAX_IMPORT_SOURCES); + if (sources.length > 0) { + raw.push({ + blockId: "recap-sources-heading", + order: order++, + type: "heading", + text: "Sources", + markdown: "## Sources", + }); + sources.forEach((source, index) => { + const title = String(source.title || "").trim().slice(0, MAX_BLOCK_TEXT); + const excerpt = String(source.excerpt || "").trim().slice(0, MAX_BLOCK_TEXT); + const label = title || source.uri || "Source"; + const text = excerpt ? `${label} — ${excerpt}` : label; + raw.push({ + blockId: `recap-source-${index}`, + order: order++, + type: "bullet", + text: text.slice(0, MAX_BLOCK_TEXT), + markdown: source.uri ? `- **${label}** — ${excerpt || source.uri}` : `- ${text}`, + }); + }); + } + + return normalizeProductDocumentBlocks(raw); +} + +function blocksToMarkdown(blocks: ReturnType) { + return blocks.map((block) => block.markdown || block.text).filter(Boolean).join("\n\n").trim(); +} + +function blocksToPlainText(blocks: ReturnType) { + return blocks.map((block) => block.text).filter(Boolean).join("\n\n").trim(); +} + +async function getImportDocument( + ctx: any, + ownerKey: string, + entitySlug: string, +): Promise | null> { + return await ctx.db + .query("productDocuments") + .withIndex("by_owner_entity_kind", (q: any) => + q.eq("ownerKey", ownerKey).eq("entitySlug", entitySlug).eq("kind", "entity_memory"), + ) + .first(); +} + +/** + * Has THIS owner already imported THIS (eventId, wikiVersion)? We look at the + * import-event ledger on the document. Bounded scan (most recent 25 events). + */ +async function findPriorImportEvent( + ctx: any, + documentId: Id<"productDocuments">, + key: string, +): Promise | null> { + const events = await ctx.db + .query("productDocumentEvents") + .withIndex("by_document_created", (q: any) => q.eq("documentId", documentId)) + .order("desc") + .take(25); // BOUND + for (const event of events) { + if (event.type === "imported" && event.metadata?.scratchnodeImportKey === key) { + return event; + } + } + return null; +} + +export const importPublishedWiki = mutation({ + args: { + slug: v.string(), + anonymousSessionId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + // Resolve the FRESH NodeBench-origin product identity (anon or signed-in). + // This NEVER trusts a client-supplied ownerKey and NEVER uses the + // cross-domain sn_session_id. requireProductIdentity throws if there is + // neither an auth session nor an anonymous session — HONEST_STATUS: no + // silent success without an owner to attribute the import to. + const identity = await requireProductIdentity(ctx, args.anonymousSessionId); + const ownerKey = identity.ownerKey; + + // PUBLIC-DATA-ONLY read of the published wiki snapshot. Returns null for + // unknown slug / unpublished / draft — those are honest no-ops below. + const wiki = await loadStructuredPublishedWiki(ctx, args.slug); + if (!wiki) { + return { + ok: false as const, + documentId: null, + created: false, + alreadyImported: false, + reason: "no_published_wiki" as const, + }; + } + + const now = Date.now(); + const entitySlug = importEntitySlug(wiki.eventId); + const key = importKey(wiki.eventId, wiki.wikiVersion, ownerKey); + const title = `${wiki.eventName} — recap`; + + // ── Idempotency check ───────────────────────────────────────────────── + const existingDocument = await getImportDocument(ctx, ownerKey, entitySlug); + if (existingDocument) { + const prior = await findPriorImportEvent(ctx, existingDocument._id, key); + if (prior) { + // Same owner, same event, SAME published version → no-op. Never + // rewrite the user's possibly-edited doc on a duplicate import. + return { + ok: true as const, + documentId: existingDocument._id, + entitySlug, + created: false, + alreadyImported: true, + wikiVersion: wiki.wikiVersion, + }; + } + } + + // ── Ensure the canonical event entity (one per imported event) ──────── + let entity = await ctx.db + .query("productEntities") + .withIndex("by_owner_slug", (q: any) => q.eq("ownerKey", ownerKey).eq("slug", entitySlug)) + .first(); + const entitySummary = summarizeText( + `Recap imported from the ScratchNode event "${wiki.eventName}" (room ${wiki.roomCode}).`, + `${wiki.eventName} recap`, + ); + if (!entity) { + const entityId = await ctx.db.insert("productEntities", { + ownerKey, + slug: entitySlug, + name: wiki.eventName, + entityType: "event", + summary: entitySummary, + savedBecause: "scratchnode recap", + latestRevision: 0, + reportCount: 0, + // Owner-private by default: an imported anon recap is the visitor's + // own working copy, not public-research-derived federated content. + visibility: "private", + searchableText: composeSearchableText([ + wiki.eventName, + entitySlug, + entitySummary, + "scratchnode recap", + ]), + createdAt: now, + updatedAt: now, + }); + // Schedule per-row embedding (same hook the entity upsert path uses). + await ctx.scheduler.runAfter( + 0, + internal.domains.search.embedRowOnUpdate.embedEntityRow, + { entityId }, + ); + entity = await ctx.db.get(entityId); + } + if (!entity) { + // HONEST_STATUS: surface a real failure rather than a fake success. + throw new Error("Could not create event entity for ScratchNode import"); + } + + // ── Build the document body from the published snapshot ─────────────── + const blocks = buildRecapBlocks(wiki); + const markdown = blocksToMarkdown(blocks); + const plainText = blocksToPlainText(blocks); + + const created = !existingDocument; + const nextRevision = (existingDocument?.latestRevision ?? 0) + 1; + + const documentId = + existingDocument?._id ?? + (await ctx.db.insert("productDocuments", { + ownerKey, + kind: "entity_memory", + title, + entityId: entity._id, + entitySlug, + markdown, + plainText, + latestRevision: 0, + createdAt: now, + updatedAt: now, + })); + + // Re-write the canonical block set. On a NEWER version this refreshes the + // body; the snapshot below preserves the prior revision for history. We + // do a full replace of the import-owned blocks (deterministic blockIds), + // mirroring documents.ts syncDocumentBlocks' replace semantics, but kept + // local so the import doc never entangles with entity-note evidence links. + const existingBlocks = await ctx.db + .query("productDocumentBlocks") + .withIndex("by_document_order", (q: any) => q.eq("documentId", documentId)) + .collect(); + for (const block of existingBlocks) { + await ctx.db.delete(block._id); + } + for (const block of blocks) { + await ctx.db.insert("productDocumentBlocks", { + ownerKey, + documentId, + blockId: block.blockId, + parentBlockId: block.parentBlockId, + order: block.order, + type: block.type, + depth: block.depth, + text: block.text, + markdown: block.markdown, + entityRefs: block.entityRefs, + sourceRefs: block.sourceRefs, + createdAt: now, + updatedAt: now, + }); + } + + const snapshotId = await ctx.db.insert("productDocumentSnapshots", { + ownerKey, + documentId, + revision: nextRevision, + markdown, + plainText, + blockCount: blocks.length, + summary: summarizeText(plainText, `${wiki.eventName} recap`), + createdAt: now, + }); + + await ctx.db.patch(documentId, { + title, + entityId: entity._id, + entitySlug, + markdown, + plainText, + latestRevision: nextRevision, + latestSnapshotId: snapshotId, + updatedAt: now, + }); + + // Import-event ledger row — carries the idempotency key so a future + // re-import of the SAME version is recognized as a no-op. + await ctx.db.insert("productDocumentEvents", { + ownerKey, + documentId, + type: "imported", + label: created + ? "Imported ScratchNode recap" + : `Updated ScratchNode recap to wiki v${wiki.wikiVersion}`, + summary: summarizeText(plainText, `${wiki.eventName} recap`), + metadata: { + scratchnodeImportKey: key, + scratchnodeEventId: wiki.eventId, + scratchnodeSlug: wiki.slug, + scratchnodeRoomCode: wiki.roomCode, + wikiVersion: wiki.wikiVersion, + answerCount: wiki.answers.length, + sourceCount: wiki.sources.length, + revision: nextRevision, + }, + createdAt: now, + }); + + return { + ok: true as const, + documentId, + entitySlug, + created, + alreadyImported: false, + wikiVersion: wiki.wikiVersion, + }; + }, +}); + +/** + * getScratchnodeImportStatus — read-only "has this owner imported this event's + * recap, and at what version?" for the frontend to render an honest state + * (not imported / imported v3 / a newer version is available). + * + * Uses resolveProductIdentitySafely so an anon caller with no owner simply + * gets `imported: false` instead of an error (read paths never throw on a + * missing identity). + */ +export const getScratchnodeImportStatus = query({ + args: { + slug: v.string(), + anonymousSessionId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const identity = await resolveProductIdentitySafely(ctx, args.anonymousSessionId); + const wiki = await loadStructuredPublishedWiki(ctx, args.slug); + if (!wiki) { + return { + published: false as const, + imported: false as const, + documentId: null, + entitySlug: null, + }; + } + const entitySlug = importEntitySlug(wiki.eventId); + if (!identity.ownerKey) { + return { + published: true as const, + imported: false as const, + documentId: null, + entitySlug, + latestWikiVersion: wiki.wikiVersion, + }; + } + const document = await getImportDocument(ctx, identity.ownerKey, entitySlug); + if (!document) { + return { + published: true as const, + imported: false as const, + documentId: null, + entitySlug, + latestWikiVersion: wiki.wikiVersion, + }; + } + const key = importKey(wiki.eventId, wiki.wikiVersion, identity.ownerKey); + const prior = await findPriorImportEvent(ctx, document._id, key); + const importedVersion = + typeof document.latestSnapshotId !== "undefined" ? document.latestRevision : null; + return { + published: true as const, + imported: true as const, + documentId: document._id, + entitySlug, + latestWikiVersion: wiki.wikiVersion, + // True when the owner has already imported the CURRENT published version. + upToDate: Boolean(prior), + importedRevision: importedVersion, + }; + }, +}); diff --git a/convex/events.ts b/convex/events.ts index f22fda5f..98e3f82f 100644 --- a/convex/events.ts +++ b/convex/events.ts @@ -1419,6 +1419,126 @@ export const getPublishedWikiBySlug = query({ }, }); +/** + * getPublishedWikiStructuredBySlug — the STRUCTURED public read for the + * NodeBench WORKSPACE importer (roadmap #3, slice 1). + * + * Why this exists: `getPublishedWikiBySlug` returns rendered `bodyHtml`, which + * is great for a read-only SSR page but lossy for the importer — we'd have to + * re-parse HTML back into blocks. This query returns the SAME published + * snapshot as structured Q&A + sources so the importer can build editable + * document blocks directly, with no HTML round-trip. + * + * Privacy / honesty contract (identical guarantees to getPublishedWikiBySlug): + * - PUBLISHED-only. Drafts / unpublished events / unknown slug -> null + * (no fabricated/empty recap). The published wiki version is the privacy + * boundary: `publishWiki` builds it from PUBLIC promoted /ask answers + + * public sources only — private notes (userNotes) are excluded at publish + * time, so there is nothing private to leak here. + * - We dereference ONLY the answer/source ids that the published snapshot + * already committed to (`wiki.sourceAnswerIds` / `wiki.sourceIds`). We do + * NOT scan liveEventAnswers/liveEventSources at large, and we NEVER touch + * userNotes / liveEventNoteAnchors (private). + * - Exposes only public-safe fields — never host ownerKey or internal ids + * beyond the answer/source ids needed for idempotent re-import provenance. + * + * BOUND (agentic_reliability): answers capped at MAX_WIKI_ANSWERS (20), + * sources capped at 20 — mirrors `buildWikiHtml` so the structured read can + * never be heavier than the HTML read it parallels. + */ +const MAX_STRUCTURED_WIKI_SOURCES = 20; + +export type StructuredPublishedWiki = { + eventId: string; + slug: string; + eventName: string; + roomCode: string; + eventStatus: string; + wikiVersion: number; + publishedAt: number | null; + answers: Array<{ question: string; body: string }>; + sources: Array<{ title: string; uri: string; excerpt: string }>; +}; + +/** + * Shared reader for the structured published wiki. Works with any ctx exposing + * `db` (query OR mutation), because every operation is a read. The public query + * below and the WORKSPACE importer (convex/domains/product/scratchnodeImport.ts) + * BOTH go through this so the privacy + BOUND contract can never drift between + * the read path and the write path. + * + * Returns null for: unknown slug, no published version. NEVER reads userNotes / + * liveEventNoteAnchors (private). Only dereferences ids the published snapshot + * already committed to. + */ +export async function loadStructuredPublishedWiki( + ctx: { db: any }, + slug: string, +): Promise { + const cleanSlug = String(slug || "").trim().toLowerCase(); + if (!cleanSlug || cleanSlug.length > 120) return null; + const event = await resolveEventBySlugOrRoomCode(ctx, cleanSlug); + if (!event) return null; + const rows = await ctx.db + .query("liveEventWikiVersions") + .withIndex("by_event_status", (q: any) => + q.eq("eventId", event._id).eq("status", "published"), + ) + .order("desc") + .take(1); + const wiki = rows[0]; + if (!wiki) return null; + + const answerIds = (Array.isArray(wiki.sourceAnswerIds) ? wiki.sourceAnswerIds : []).slice( + 0, + MAX_WIKI_ANSWERS, + ); + const sourceIdList = (Array.isArray(wiki.sourceIds) ? wiki.sourceIds : []).slice( + 0, + MAX_STRUCTURED_WIKI_SOURCES, + ); + + const answers: Array<{ question: string; body: string }> = []; + for (const answerId of answerIds) { + const answer = await ctx.db.get(answerId); + if (!answer) continue; // BOUND_READ-safe: skip dangling ids, never fabricate + answers.push({ + question: String(answer.question || "").slice(0, 1000), + body: String(answer.body || "").slice(0, MAX_ANSWER_BODY), + }); + } + + const sources: Array<{ title: string; uri: string; excerpt: string }> = []; + for (const sourceId of sourceIdList) { + const source = await ctx.db.get(sourceId); + if (!source) continue; + sources.push({ + title: String(source.title || "").slice(0, 200), + uri: String(source.uri || "").slice(0, 500), + excerpt: String(source.excerpt || "").slice(0, 500), + }); + } + + return { + eventId: String(event._id), + slug: event.slug, + eventName: event.name, + roomCode: event.roomCode, + eventStatus: event.status, + wikiVersion: wiki.version, + publishedAt: wiki.publishedAt ?? wiki.createdAt ?? null, + answers, + sources, + }; +} + +export const getPublishedWikiStructuredBySlug = query({ + args: { slug: v.string() }, + handler: async (ctx, { slug }) => { + return await loadStructuredPublishedWiki(ctx, slug); + }, +}); + /** * Shared /ask context loader — the SINGLE source of truth for question * integrity, semantic-cache lookup, source corpus, and the asker's prior turns. diff --git a/src/features/redesign/surfaces/ScratchnodeEventsSurface.tsx b/src/features/redesign/surfaces/ScratchnodeEventsSurface.tsx index 11e99564..73c5e41c 100644 --- a/src/features/redesign/surfaces/ScratchnodeEventsSurface.tsx +++ b/src/features/redesign/surfaces/ScratchnodeEventsSurface.tsx @@ -20,10 +20,11 @@ */ import { useMemo, useState } from "react"; -import { useQuery } from "convex/react"; +import { useMutation, useQuery } from "convex/react"; import { Link } from "react-router-dom"; import { api } from "../../../../convex/_generated/api"; import { useScratchnodeSessionId } from "../hooks/useScratchnodeSessionId"; +import { getAnonymousProductSessionId } from "../../product/lib/productIdentity"; type JoinedEvent = { eventId: string; @@ -310,6 +311,8 @@ function EventRow({ + + {expanded ? ( ); } + +/** + * ImportRecapButton — slice 1 of the ScratchNode event → NodeBench WORKSPACE + * import (roadmap #3). Imports the PUBLISHED public wiki recap as an editable + * NodeBench document. + * + * Honesty contract: + * - Only renders an action when a PUBLISHED wiki actually exists for the slug + * (getScratchnodeImportStatus.published). A joined-but-unpublished room + * shows nothing — never a fake "import" affordance. + * - Imports under a FRESH NodeBench-origin product anon identity + * (getAnonymousProductSessionId), NOT the cross-domain sn_session_id. On + * later sign-in, the existing bootstrap merge path re-owns the document. + * - Real pending / done / error states. The link to the created document is + * only shown once the importer returns a real documentId. + * + * `(api as any)` mirrors the rest of this surface: convex/domains/product/ + * scratchnodeImport.ts may not be in _generated/api.d.ts on this branch yet — + * CI codegen adds it on deploy. + */ +function ImportRecapButton({ eventSlug }: { eventSlug: string }) { + // Resolve the product anon session once per row. getAnonymousProductSessionId + // is idempotent (reads existing or persists a fresh one), so this is the same + // identity the rest of NodeBench (Reports, Inbox, Workspace) uses. + const [productSessionId] = useState(() => { + try { + return getAnonymousProductSessionId(); + } catch { + return null; + } + }); + const [phase, setPhase] = useState<"idle" | "importing" | "error">("idle"); + const [errorMessage, setErrorMessage] = useState(null); + + const status = useQuery( + (api as any).domains.product.scratchnodeImport.getScratchnodeImportStatus, + productSessionId ? { slug: eventSlug, anonymousSessionId: productSessionId } : "skip", + ) as + | { + published: boolean; + imported: boolean; + documentId: string | null; + entitySlug: string | null; + latestWikiVersion?: number; + upToDate?: boolean; + } + | undefined; + + const runImport = useMutation( + (api as any).domains.product.scratchnodeImport.importPublishedWiki, + ); + + // Honest gate: render nothing until we KNOW a published wiki exists. No + // session, still loading, or no published wiki → no affordance. + if (!productSessionId || status === undefined || !status.published) { + return null; + } + + const importedEntitySlug = status.entitySlug; + const alreadyUpToDate = status.imported && status.upToDate === true; + + const handleImport = async () => { + setPhase("importing"); + setErrorMessage(null); + try { + const result = await runImport({ slug: eventSlug, anonymousSessionId: productSessionId }); + if (!result?.ok) { + setPhase("error"); + setErrorMessage("This recap is no longer published."); + return; + } + setPhase("idle"); + } catch (err) { + setPhase("error"); + setErrorMessage(err instanceof Error ? err.message : "Import failed. Try again."); + } + }; + + return ( +
+ {status.imported && importedEntitySlug ? ( + <> + + {alreadyUpToDate + ? "Recap imported into NodeBench." + : "A newer published recap is available."} + + + Open recap → + + {!alreadyUpToDate ? ( + + ) : null} + + ) : ( + + )} + {phase === "error" ? ( + + {errorMessage} + + ) : null} +
+ ); +}