Skip to content

[Feat]: tighten KB Indexed badge + folder cascade delete + pmid attribution - #47

Merged
FireTable merged 8 commits into
mainfrom
fix/kb-mention-ux
Jul 23, 2026
Merged

FireTable merged 8 commits into
mainfrom
fix/kb-mention-ux

Conversation

@FireTable

@FireTable FireTable commented Jul 23, 2026 •

Copy link
Copy Markdown
Owner

Problem

  • KB "Indexed" badge reported success based on kb_chunk.status='success'
    alone, but pgvector hadn't received the embedding yet — the doc was
    vector-less when the badge said "Indexed".
  • kb_observability spans for concurrent KB uploads were getting tagged
    with the wrong parent_message_id (the next user message, not the
    trigger). Root cause was a shared instance field in
    CapturingHandler.
  • DELETE /api/kb/folders/[id] refused with 409 NON_EMPTY on any non-empty
    folder; no path for the user to clean up a whole folder.

What changed

fix(observability) — 9e39a9c

  • CapturingHandler.handleChainStart resolves parent_message_id per-call
    from metadata.parent_message_id (LangGraph-configurable passthrough,
    set per-run via runs.create({config:{configurable:{parent_message_id}}}))
    with lastHumanMessageId(inputs.messages) as fallback. Dropped the
    shared instance field.
  • findLatestParentMessageId now calls langGraphClient.threads.getState
    and parses state.values.messages via lastHumanMessageId — the
    observability_spans column is not read for backfill.

feat(kb) — feb73bb

  • successChunks redefined: counts chunks with
    status='success' AND embedding IS NOT NULL. New
    embeddingPendingChunks counts status='success' AND embedding IS NULL.
  • "Indexed" badge now requires vectors AND
    entityCount > 0 OR relationshipCount > 0 (hybrid-search third leg
    needs graph rows to score against). Two intermediate states:
    Extracting (X/Y) (OCR in flight) and Embedding (X/Y) (vector +
    graph extract in flight).
  • Surfaced entityCount + relationshipCount through 4 type files
    (components/tool-ui/kb/types.ts, components/settings/kb-view/types.ts,
    backend/tool/kb/types.ts, backend/tool/kb/list-documents.ts) and the
    ChunksStatusBadge prop list.

feat(kb) — a73a179

  • splitFileToPageNode + pageToMarkdownNode skip on retryFailed /
    retryFailedChunks (was chunksOnly only); prepareKBDataNode reads
    config.configurable.parent_message_id first.
  • DELETE /api/kb/folders/[id]?deleteAll=true cascades through every
    doc in the folder (chunks, embeddings, attachments). Settings dialog
    adds a switch + typed "delete all" confirmation.
  • DocDeleteDialog / DocReprocessDialog close themselves via
    onOpenChange(false) before firing onDeleted / onReprocessed;
    long titles get break-all. FolderSidebar callbacks no longer reset
    selection on submit (the dialog does it via onOpenChange).
  • ObservabilityPopover no longer auto-closes on row click — the Sheet
    is the singleton entry.
  • kb-mention popover: scrollable body + sticky header padding tweak.

fix(kb) — 9616f02

  • Badge elements get shrink-0 on icons + min-w-0 max-w-full on
    shell + truncate min-w-0 on label so the fixed-width badge column
    in DocRow doesn't push siblings off-grid on long status text.

Docs

  • docs/APIS.md: new indexing-count fields on GET /api/kb/documents
    • GET /api/kb/documents/[id], chunks[].hasEmbedding, folder
      ?deleteAll=true cascade.
  • docs/OBSERVABILITY.md: per-call pmid resolution from metadata +
    langGraphClient.threads.getState fallback.
  • docs/TOOLS.md: expanded list_documents payload shape shared with
    the Settings badges.

Tests

  • pnpm typecheck: clean
  • pnpm lint: clean
  • pnpm test: 131 files / 1367 passed
  • Added coverage: tests/api/kb/folder.test.ts (cascade delete) +
    tests/api/kb/reprocess.test.ts (retryFailedChunks pipeline skips
    OCR + preserves doc.status='success').

🤖 Generated with Claude Code


Summary by cubic

Tightened the Knowledge Base “Indexed” badge to require embeddings, added entity/relationship counts across APIs, enabled safe folder cascade delete (with attachment cleanup), and fixed parent_message_id attribution with a 500ms-bounded backfill. Also corrected OCR retry modes and applied small UI polish.

  • New Features

    • Badge and counts: “Indexed” now requires all chunks have embeddings; shows “Extracting (X/Y)” and “Embedding (X/Y)”. entityCount/relationshipCount are surfaced but not required for “Indexed”. Added embeddingPendingChunks, entityCount, and relationshipCount to GET /api/kb/documents, GET /api/kb/documents/[id], and list_documents; doc detail exposes chunks[].hasEmbedding.
    • Deletion and reprocess: DELETE /api/kb/folders/[id]?deleteAll=true cascades through docs, chunks, embeddings, attachments, and related threads; dialog adds a toggle and typed confirm. Reprocess modes: retryFailedChunks and chunksOnly skip OCR; retryFailed re-OCRs failed pages. prepareKBDataNode prefers config.configurable.parent_message_id.
  • Bug Fixes

    • Observability: resolve parent_message_id per call from metadata with a langGraphClient.threads.getState fallback capped at 500ms and fetched in parallel. Removed shared handler state to prevent cross-run clobbering.
    • Counts and UI: independent queries fix entityCount/relationshipCount inflation; tighter badge truncation and mixed-state logic (don’t mark as failed while embeddings are pending); ObservabilityPopover stays open on row click; kb-mention popover height fixed; dialogs close before callbacks.

Written for commit d70418b. Summary will update on new commits.

Review in cubic

Greptile Summary

The PR tightens KB indexing status, adds folder cascade deletion, corrects observability attribution, and repairs failed-page OCR retries.

  • Counts only successfully embedded chunks as indexed and exposes graph/indexing counts through APIs and UI.
  • Adds confirmed deletion of folders and their documents, including transactional attachment-row cleanup.
  • Resolves observability parent-message attribution per run rather than through shared handler state.
  • Reuses stored pages while rerunning OCR only where failed during retry processing.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failures remain in the fixes associated with the previous review threads.

Important Files Changed

Filename Overview
lib/kb/queries.ts Folder and document deletion now remove associated attachment rows transactionally while preserving content-addressed R2 objects.
app/api/kb/documents/[id]/route.ts Independent, user-scoped entity and relationship queries correctly avoid Cartesian count inflation.
backend/node/kb/page-to-markdown-node.ts Failed-page retries now rerun OCR while preserving successful cached pages.
backend/node/kb/split-file-page-node.ts Reprocessing modes reuse pages loaded from persisted document state instead of splitting the source again.
components/tool-ui/kb/status-badge.tsx Indexing badges distinguish extraction, embedding, failure, and fully indexed states using embedding-aware chunk counts.
lib/observability/callback.ts Parent-message attribution is resolved per invocation to avoid shared-state interference between concurrent runs.

Reviews (5): Last reviewed commit: "test(observability): mock langGraphClien..." | Re-trigger Greptile

… drop shared instance state

CapturingHandler.currentParentMessageId was a single instance field overwritten by every outermost handleChainStart. Concurrent invokes (main chat + a background_agent / kbAgent dispatched mid-turn) clobbered each other, and inner spans inherited whichever message arrived last — kb-upload inner spans got attributed to the user's NEXT message instead of the trigger.

Drop the instance field. handleChainStart now resolves parent_message_id per-call: prefer metadata.parent_message_id (per-run, set by runs.create on kbAgent / background_agent dispatches), fallback to lastHumanMessageId(inputs.messages) for the chat streaming path. Inner LLM / tool spans that don't get either source stay null at capture; bulkInsertSpans' state-fallback backfill fills them at INSERT time from langGraphClient.threads.getState. No shared state, no clobber race.
…hip rows

ChunksStatusBadge used to flip to 'Indexed' as soon as every chunk had status='success'. status='success' alone doesn't mean the chunk is queryable: pgvector needs the vector, and the entity-leg of hybrid search needs kb_entity / kb_relationship rows. A doc with all vectors done but no entities yet (extraction still running) used to be 'Indexed' — now it sits in 'Embedding'.

Redefine successChunks to mean status='success' AND embedding IS NOT NULL. Add embeddingPendingChunks + entityCount + relationshipCount aggregates (two new subqueries on kb_entity / kb_relationship grouped by document_id). Badge collapses three in-flight lanes into 'Extracting' (chunks being OCR'd / split) and 'Embedding' (chunks done, vector or graph-extract still running); 'Indexed' now requires all three legs of hybrid search populated.
- skipFileToPageNode + pageToMarkdownNode also skip on retryFailed /
  retryFailedChunks (was chunksOnly only); doc-status preservation test
- prepareKBDataNode reads config.configurable.parent_message_id first
  with lastHumanMessageId fallback
- DELETE /api/kb/folders/[id] accepts ?deleteAll=true to cascade-delete
  every doc in the folder (each doc's chunks/embeddings/attachments)
  before the folder row; Settings dialog adds a switch + typed
  "delete all" confirmation
- DocDeleteDialog / DocReprocessDialog close themselves via onOpenChange
  before firing onDeleted / onReprocessed; long titles use break-all
- ObservabilityPopover no longer auto-closes on row click — the Sheet
  is the singleton entry, not the popover
- kb-mention popover tweaks: scrollable body, sticky header, padding
- folder-sidebar callbacks no longer reset selection on submit (the
  dialog does it via onOpenChange)
- docs: APIS.md documents the new indexing-count fields on
  GET /api/kb/documents + GET /api/kb/documents/[id], the
  chunks[].hasEmbedding flag, and the folder ?deleteAll cascade.
  OBSERVABILITY.md documents per-call pmid resolution from
  metadata.parent_message_id + langGraphClient.threads.getState
  fallback. TOOLS.md documents the expanded list_documents payload
  shape shared with the Settings badges.
- add shrink-0 to icon + min-w-0 max-w-full to badge shell so the
  fixed-width badge column in DocRow doesn't push siblings off-grid
- truncate min-w-0 on the label span so long status text ("Parsing
  (12/34)") ellipses instead of forcing horizontal scroll
Comment thread lib/kb/queries.ts Resolved
Comment thread app/api/kb/documents/[id]/route.ts Resolved
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review summary

Big PR — three meaningful changes (KB Indexed badge tightening + folder cascade delete + observability pmid
attribution) plus a handful of UI tidy-ups. The shape is right; below are the issues that need to land
before merge, ranked by severity.

Critical (block merge)

  1. Leftover debug console.warn(2222, …) in bulkInsertSpans — lib/observability/queries.ts:130-133. This
    prints every span's threadId + parentMessageId to stdout on every bulk insert (including in production).
    The 2222 is a sentinel ID — clearly a temporary debugging artifact. Remove it. Production logs will
    be noisy and could leak user-turn IDs in aggregated logs.

  2. Doc-detail entity/relationship count is wrong — app/api/kb/documents/[id]/route.ts:32-41. The query
    `FROM kb_entity LEFT JOIN kb_relationship ON kb_entity.document_id = kb_relationship.document_id`
    inflates entityCount by the number of relationships per doc (a 1-entity-5-relationship doc returns
    `entityCount=5`), and silently under-counts relationshipCount to 0 when the doc has only relationships
    and zero entities (the LEFT JOIN starts from kbEntity, so a relationship with no entities is dropped).
    The list endpoint got this right with separate subqueries (lib/kb/queries.ts groups the diff). Mirror
    that pattern here. The badge gate `isCompleted = success === total && hasGraph` will mis-fire on docs
    that have only entities OR only relationships, and the UI will show the wrong "Indexed" state.

  3. Stale comment in bulkInsertSpans — lib/observability/queries.ts:114-120. The comment says
    "look up the column (not the meta key — the column is the canonical source)" but the actual code now
    calls findLatestParentMessageId which reads langGraphClient.threads.getState. Either rewrite the
    comment to match the new state-based fallback, or this will rot. (The same paragraph on the OBSERVABILITY
    doc line is the one that is updated — the inline comment wasn't.)

High (should fix before merge)

  1. Stale comment in CapturingHandler (lib/observability/callback.ts:253-259) — "parent_message_id is
    resolved per-call in start() from each span's own `partial.input.messages`." That's wrong twice:
    the resolution happens in handleChainStart, not start(); and the primary source is
    metadata.parent_message_id, not partial.input.messages. The start()-side fallback is documented
    separately a few lines below. Either delete the comment or rewrite it.

  2. Folder cascade delete test is too thin — tests/api/kb/folder.test.ts:152-173. The test only
    asserts the folder + its kb_document rows are gone. It does not assert that:

    • kb_chunk rows for those docs are cascade-deleted (FK ON DELETE CASCADE);
    • kb_entity / kb_relationship / kb_theme rows are cascade-deleted;
    • the threads rows are deleted (the implementation does await tx.delete(threads) for each doc).
      The new turbo-button UX is one click away from "did the cascades actually run?" — add coverage for at
      least the threads row + kb_chunk row going away.
  3. Docs/APIS.md claims ?deleteAll=true deletes R2 records — it doesn't. Line 306 says
    "(each `DELETE` against `/api/kb/documents/[id]` semantics — chunks, embeddings, R2 records)" and the
    query-param table on line 310 says "each doc's chunks, embeddings, attachments". The actual
    implementation matches the established single-doc DELETE behavior: R2 objects are intentionally NOT
    deleted (they're reference-counted by sha, see kb_document route comment + deleteKbDocumentForUser
    docs). The PR description acknowledges this in passing, but the new docs contradict it. Pick one
    narrative and align all three (PR description, APIS.md, in-file comment).

Medium (nice-to-fix)

  1. mimeShortLabel change silently widens the visible mime — lib/kb/source-kind.ts:73. The old
    fallback was `TYPE_LABEL[subtype] ?? (subtype.toUpperCase() || mimeType)` — so application/json → "JSON".
    The new fallback is TYPE_LABEL[subtype] ?? mimeType — so application/json → "application/json".
    Unknown subtypes now show the full mime. Combined with doc-table.tsx's `.toLowerCase()`, the row
    displays "application/json" instead of "JSON". Probably fine, but it's a UX change to the doc-table
    type chip — worth calling out in the PR description and verifying the Add-dialog supports-hint also
    still reads OK (it uses the same helper).

  2. isInProgress label semantics are inconsistent with the tooltip — components/tool-ui/kb/status-badge.tsx:215-239.
    When hasInflightChunks=false (no pending/parsing chunks) but embeddingPending > 0, the label is
    `Embedding (${success}/${total})` but the tooltip reports `${success} chunks embedded, ${embeddingPending}
    awaiting vector`. The X/Y numerator is `success` (= `total - embeddingPending`), so an X/Y of
    `Extracting (9/10)` + tooltip "9 embedded, 1 awaiting vector" is internally consistent. But an
    X/Y of `Embedding (9/10)` + tooltip "9 embedded, 1 awaiting vector" — the user is told the doc is
    "Embedding" while the badges around the room still show progress. Worth a comment or a unit test
    that pins the X/Y semantics for the "Embedding" branch (currently no tests hit this state).

Low

  1. findLatestParentMessageId keeps the older comment claim — lib/observability/queries.ts:147-154
    now describes the state-based fallback correctly; the comment around toRow/toCapturedSpan (lines
    58-83, 85-109) still describes the column as "the canonical source". The column is now a materialized
    projection — the meta key is the source of truth and the DB column is filled from it. Tighten the
    vocabulary here too.

  2. DocRow mounts all three dialogs (Delete/Reprocess/Detail) unconditionally — components/settings/kb-view/doc-table.tsx:333-349.
    Old code guarded each with the open flag; new code mounts them all. Each Dialog lives in the DOM,
    so all three of them register setError/setMode reset effects. Modest waste, but the DocReprocessDialog
    effect resets mode to "full" on every render where open flips. If you intentionally did this to
    avoid the onDeleted/onReprocessed stale-closure races (the diff comment says "the dialog does it
    via onOpenChange"), say so in the PR description; otherwise the old guarded form was correct.

Good

  • The pmid metadata fast-path + lastHumanMessageId fallback is the right fix for the concurrent-invokes
    attribution bug. The new test in callback-collector.test.ts:251-318 is a solid guard.
  • The folder cascade delete UI flow (switch + typed-confirmation of delete all) is well-considered.
  • The Docs updates for successChunks / embeddingPendingChunks / entityCount / relationshipCount
    are aligned with the code in the list endpoint. The doc-detail doc entry is consistent too.

🤖 Generated with Claude Code

Comment thread lib/observability/queries.ts Resolved
Comment thread lib/observability/queries.ts Resolved
Comment thread app/api/kb/documents/[id]/route.ts Resolved
Comment thread docs/APIS.md Resolved
Comment thread tests/api/kb/folder.test.ts Resolved
Comment thread lib/kb/source-kind.ts Resolved

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 30 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread lib/observability/queries.ts Resolved
Comment thread components/tool-ui/kb/status-badge.tsx Resolved
Comment thread app/api/kb/documents/[id]/route.ts Resolved
Comment thread docs/APIS.md Resolved
Comment thread docs/OBSERVABILITY.md Resolved
- lib/kb/queries.ts: deleteKbDocumentForUser + deleteKbFolderForUser
  ({deleteAllDocs}) now drop the attachment rows for each cascaded
  doc. R2 objects still stay (sha-keyed, ref-counted by future same-
  content uploads — see CLAUDE.md / docs/ATTACHMENTS.md). The orphan
  attachment pointer was the only thing missing from the cascade.
- app/api/kb/documents/[id]/route.ts: replace the leftJoin(kbEntity,
  kbRelationship) graph-counts query with two single-table subqueries.
  The leftJoin cartesian-multiplied both counts whenever a doc had rows
  in both tables, and reported 0/0 when a doc had relationships but no
  entities (the from-table was empty). The subqueries count each
  independently.
- backend/node/kb/page-to-markdown-node.ts: remove retryFailed from
  the cached-pages pass-through guard. retryFailed's whole point is to
  re-OCR the failed pages, so this node has to run. chunksOnly and
  retryFailedChunks still skip (their pages have valid cached markdown).
- tests: add 7 cases — folder cascade attachment cleanup, graph-count
  join corruption (3 shapes: empty / both / rel-only), pageToMarkdown
  per-mode skip/run assertions (chunksOnly / retryFailedChunks skip;
  retryFailed runs OCR).
Comment thread lib/observability/queries.ts Outdated
// thread implicitly closes the wait gap that opened on the previous
// tool's interrupt.

console.warn(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 This looks like leftover debug code that needs to be removed before merge.

console.warn(
  2222,
  rows.map((item) => ({ threadId: item.threadId, parentMessageId: item.parentMessageId })),
);

Two problems:

  1. 2222 is a magic marker with no function — clearly a personal "find me later" note from the author. Won't survive pnpm lint once configured for no-console / no magic numbers, and pollutes production logs forever.
  2. The payload itself is noise — emitting every batch's threadId + parentMessageId to stderr on every span flush will fire many times per turn and across every background invoke. None of it has a consumer; the human-readable test would be easier with a single labeled log.

Also worth noting: parentMessageId on this row set is now the post-backfill value (backfillParentMessageIds mutated r.parentMessageId in place above this line on line 121), so the dump won't even surface "captured vs filled" deltas — it's just confirming the backfill did its job on the happy path.

Recommended fix: delete the three lines entirely. If a trace is needed, log a count (e.g. console.warn("[observability] bulkInsert", rows.length, "spans")) without any identifiers.

Comment thread components/assistant-ui/kb-mention.tsx Outdated
char="@"
adapter={bundle.adapter}
className="aui-composer-trigger-popover bg-popover text-popover-foreground absolute bottom-full left-0 z-50 mb-4 w-72 max-h-72 overflow-y-auto rounded-xl border bg-background p-1 shadow-lg flex flex-col gap-0.5"
className="aui-composer-trigger-popover bg-popover text-popover-foreground absolute bottom-full left-0 z-50 mb-4 w-72 max-h-68 overflow-y-auto rounded-xl border bg-background px-1 shadow-lg flex flex-col gap-0.5 pb-1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 max-h-68 is not in the Tailwind v4 default spacing scale — the scale goes ..., 56, 60, 64, 72, 80, 96. With no arbitrary value config this class generates no CSS, so the popover won't constrain its height and can overflow the composer area on short viewports.

The pre-PR value max-h-72 (18rem) was correct. The commit message in 9616f02 (status-badge column layout) doesn't mention this popover file, so this looks like an unrelated near-typo from the concurrent edits.

Suggested change
className="aui-composer-trigger-popover bg-popover text-popover-foreground absolute bottom-full left-0 z-50 mb-4 w-72 max-h-68 overflow-y-auto rounded-xl border bg-background px-1 shadow-lg flex flex-col gap-0.5 pb-1"
className="aui-composer-trigger-popover bg-popover text-popover-foreground absolute bottom-full left-0 z-50 mb-4 w-72 max-h-72 overflow-y-auto rounded-xl border bg-background px-1 shadow-lg flex flex-col gap-0.5 pb-1"

// outputs ready: vector + entity/relationship. If chunks all have
// vectors but no entities/relationships yet, that's still "Embedding"
// — the entity-leg of hybrid search has nothing to score against.
const terminal = success + failed + embeddingPending;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Subtle behavioral change worth double-checking with a real example:

Old logic: terminal = success + failed, so a doc with success=5, failed=2, embeddingPending=3, total=10 had terminal=7 → fell into the Indexing (5/10) in-progress path.

New logic (this PR): terminal = success + failed + embeddingPending = 10. Combined with isFailed = failed > 0 && terminal === total, that same doc now flips to the 2 failed badge — even though only 2 of the 10 chunks actually errored, and the other 3 just haven't been vectored yet. The tooltip only mentions the 2 failed chunks and drops the 3 embedding-pending ones.

Net effect: a doc that was "still indexing, 5/10 done" becomes "2 failed", which is more alarming than accurate. The same happens for any mixed state where the user has at least one failed chunk.

Two options:

  1. Refine isFailed so a non-zero embeddingPending keeps the doc in the in-progress lane and the badge reflects "mixed: X done, Y embedding-pending, Z failed" — e.g. only set isFailed when embeddingPending === 0.
  2. Update the failed-state tooltip to enumerate both buckets so the count in failed matches the user's mental model: failed + embeddingPending chunks outstanding.

The fresh tests cover OCR-failure recovery (good), but there's no test for the mixed (failed>0, embeddingPending>0, total>success) case. Adding one before this lands would prevent regressions either way.

const missingThreads = new Set<string>();
for (const r of rows) {
if (r.parentMessageId == null) missingThreads.add(r.threadId);
if (r.parentMessageId == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔁 This fallback is dead code on the happy path. toRow() (lines 65-66) already lifts meta?.parent_message_id into r.parentMessageId whenever it's a non-empty string:

const rawPmid = meta?.parent_message_id;
const parentMessageId =
  typeof rawPmid === "string" && rawPmid.length > 0 ? rawPmid : null;

So at this point in backfillParentMessageIds, r.parentMessageId == null ⇒ meta?.parent_message_id was either missing, non-string, or empty-string. The only case the new check rescues vs. the existing logic is the empty-string case (""), and zeroing out an explicit empty-string pmid back to "" doesn't improve anything — toCapturedSpan would rehydrate it as null regardless.

Suggested cleanup: drop the inner if (typeof r.meta?.parent_message_id === "string") branch entirely; if a row has parentMessageId == null here, just queue the thread for the state-fallback lookup. Otherwise the comment "looks like real fallback coverage" without delivering it.

Suggested change
if (r.parentMessageId == null) {
for (const r of rows) {
if (r.parentMessageId == null) {
missingThreads.add(r.threadId);
}
}

// spans stealing the pmid of the next user message). Returns null on
// fetch error so bulkInsertSpans can proceed with null pmids rather
// than crash the run.
export async function findLatestParentMessageId(threadId: string): Promise<string | null> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance / latency note: findLatestParentMessageId used to be a single indexed Postgres SELECT. It is now a synchronous HTTP call to langGraphClient.threads.getState(threadId), which goes through the proxy at LANGGRAPH_API_URL (lib/langgraph/client.ts — default http://localhost:2024).

bulkInsertSpans is called from handleChainEnd on the request hot path. The new code does one network call per missing thread per batch, sequentially:

for (const tid of missingThreads) {
  const latest = await findLatestParentMessageId(tid);
  ...
}

In a worst-case batch (one span from N different threads that all bypassed the metadata path — e.g. a chat-side regenerate + multiple interrupts while the langgraph dev server is rebuilding), this serially blocks the End hook for N round-trips. Two concrete risks:

  1. Trace latency — every chat turn that hits this path adds N × dev-server-RTT to the visible user response time (it's awaited before INSERT).
  2. bulkInsertSpans timeout — if the dev server is busy enough that the call stalls beyond the SDK's default timeout, every span in the batch gets parentMessageId=null and the per-turn panel 404s (which the design intentionally accepts, but as a side-effect of this slowdown, not the cold-path).

Suggested hardening before this lands:

  • Run the per-thread lookups in Promise.all (one batch, parallel goroutines, deduped by thread) — at least removes the serial wall-clock.
  • Add a circuit-breaker / short timeout so a stuck dev server doesn't block the entire INSERT (e.g. Promise.race([getState(...), timeout(500).then(()=>null)])).
  • Or skip the fallback entirely when the batch came from a chat streaming path that doesn't need it (chat uses metadata.parent_message_id already per the new handleChainStart).

The current change is correct for isolation (no shared state, no cross-thread bleed), but it's strictly more expensive than what it replaced.


const chunks: KbChunkPreview[] =
doc.status === "success" ? await findKbChunksContentByDocumentId(user.id, doc.id) : [];
const [chunks, entityCount, relationshipCount] = await Promise.all([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Good fix on the cartesian-inflation issue. The two independent subqueries + coalesce(...,0) correctly give entityCount and relationshipCount as their true values, including when one side has zero rows. Confirmed via tests/api/kb/documents/[id].test.ts cases 1–3.

Two adjacent points worth flagging while this file is open, even if out of scope for the PR:

  1. status === "success" guard on findKbChunksContentByDocumentId is the only status filter — if a doc is in parsing/failed, chunks returns [], but entityCount/relationshipCount still query. For parsing/failed docs that's fine (the badge shows "Pending"/"Not Indexed" anyway), but the badge condition in components/tool-ui/kb/status-badge.tsx uses entityCount === 0 && relationshipCount === 0 → hasGraph=false to flip the badge to "Embedding". For a fresh doc with 0 chunks yet, entityCount/relationshipCount will both be 0, but the badge will render based on the doc-status branch (pending/parsing) above this and show "Pending" — which is correct by accident, not by design. A status-conditional gating on the count query would make the intent explicit.

  2. The Promise.all([...]).then((rows) => rows[0]?.n ?? 0) micro-promise pattern works but count(*) always returns a row in PG, so rows[0]?.n ?? 0 is paranoid — the ?? 0 is unreachable. Dropping it (or using Number(rows[0]!.n)) removes dead fallback code.

state: KbAgentStateShape,
): Promise<Partial<KbAgentStateShape>> {
if (state.mode === "chunksOnly") {
if (state.mode === "chunksOnly" || state.mode === "retryFailedChunks") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Quick clarification on the comment-vs-code matchup on line 24:

if (state.mode === "chunksOnly" || state.mode === "retryFailedChunks") {
  // ... `retryFailed` is intentionally NOT in this list — its whole point is
  // to re-OCR the failed pages, so this node has to run.

state.retryFailed falls through to the OCR branch, so the comment is accurate. ✅

But the OCR branch's per-page skip —

if ((p.markdown ?? "").trim().length > 0 && !p.errorMessage) {
  return p;
}

— only skips successful pages. Pages in status: "failed" (with empty markdown) get sent through OCR again, which is what we want for retryFailed. However, pages with markdown.length > 0 but status: "failed" will be silently skipped (the !p.errorMessage guard passes either way). Worth confirming: in retryFailed we want to re-OCR any page whose prior markdown is empty or whose prior attempt errored — the current logic does this, but pages where the OCR returned non-empty markdown AND set an errorMessage are treated as fine. Tiny edge case, but probably worth a sentence in the mode comment.

Lower priority: split-file-page-node.ts is also skipped for retryFailed, so the pre-condition is that pagesByDocId is already populated by prepareKBDataNode → pulled from doc.pages. The pages column may include mixed-status pages; the new test in tests/backend/node/kb/page-to-markdown-node.test.ts exercises just one failed page — a test that mixes successful + failed pages in pagesByDocId would prove the per-page skip preserves the cached ones while re-OCRing only the failed ones.

Comment thread lib/kb/queries.ts
@@ -1442,12 +1516,38 @@ export async function deleteKbDocumentForUser(
export async function deleteKbFolderForUser(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ The cascade is sound — kb_chunk / kb_entity / kb_relationship / kb_theme all ON DELETE CASCADE off kb_document, so the DELETE kb_document … WHERE folder_id = ? step transitively drops every row of child data. Attachments are dropped explicitly here rather than relying on the ON DELETE SET NULL FK, which is the right call (orphan pointers would be the only thing they'd leave behind).

Two subtle points worth knowing even if the current tests cover the happy path:

  1. R2 objects are deliberately retained — both this PR and deleteKbDocumentForUser keep the u/<uid>/upload/<sha>.<ext> objects and the per-page PNGs in R2 (sha-keyed dedup, see docs/ATTACHMENTS.md). The new test deletes attachment rows for every doc when deleteAll=true (R2 objects stay) covers the row side. There's no test asserting "re-uploading the same bytes re-uses the existing R2 objects after a cascade delete" — that's the refcount guarantee the design rests on. Probably belongs in tests/api/kb/folder.test.ts next to the new ones.

  2. threads table cleanup precedes kb_document delete. If a child cascade failed midway (e.g. an unenforced FK surfaces), the transaction rolls back, the threads stay attached to their docId strings, and the next attempt re-runs. Correct. But the order is worth a one-liner comment explaining why thread cleanup happens before doc deletion (so a half-finished cascade doesn't leave orphaned threads pointing at docs that the next pass will eventually delete).

</div>
)}

<div className="flex items-center justify-between rounded-lg border p-3 shadow-xs">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The destructive-confirmation copy is a bit too verbose at the expense of safety:

  1. Confirmation string delete all is case-insensitive via confirmText.trim().toLowerCase() !== "delete all" — meaning DELETE ALL, Delete All, and DELETE ALL\t all satisfy. That's fine UX-wise, but the "Copy" button pastes literally "delete all" (lowercase) into the input via navigator.clipboard.writeText("delete all") — so a user who clicks "Copy", then retypes, will hit the same lowercased string. Someone who copies and pastes into a chat to share will only see the lowercase form. Consider pasting the canonical phrase ("delete all") so copy/paste round-trips correctly.

  2. Cosmetic but legal-ish copy: the destructive-card text says "Also destroys all documents, chunks, and vector embeddings in this folder" — but R2 source objects (u/<uid>/upload/<sha>.<ext>) are intentionally retained and not part of the cascade (see the in-line note in lib/kb/queries.ts:1499-1503). The Settings list / DLQ dialog in DocDeleteDialog (line 89-90) explicitly calls this out: "Source PDF + rendered page PNGs stay in R2". The folder-delete copy doesn't carry the same caveat, so a user cascade-deleting a folder will reasonably assume the PDFs are gone. Either:

    • add a one-line note "PDFs and rendered page images remain in R2 until the retention sweep" to the folder dialog, or
    • move the existing "stay in R2" disclaimer into a shared helper so both DocDeleteDialog and FolderDeleteDialog reference it consistently.

The destructive flow itself (typed confirmation + switch, fired only after explicit gate) is solid. Just want the language to match the actual blast radius.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Code review — PR #47

Walked through the diff and the surrounding files. The Intent here is sound — tighten the Indexed badge, fix parent_message_id concurrency, give users a destructive folder clear — and the test coverage (tests/api/kb/documents/[id].test.ts, tests/backend/node/kb/page-to-markdown-node.test.ts, expanded folder.test.ts + reprocess.test.ts + queries.test.ts) covers the stated regressions. The Greptile-flagged issues (cartesian graph-counts, retryFailed re-OCR skip) are also resolved. Inline comments below call out the remaining items I think need attention before merge.

Blocking

  1. lib/observability/queries.ts:130-133 — leftover console.warn(2222, rows.map(...)) debug line dumps every batch's threadId + parentMessageId to console with a magic-number marker. Must be removed before merge (and a bare [observability] bulkInsert N spans is fine if anything is needed at all). discussion

Worth fixing before merge

  1. components/assistant-ui/kb-mention.tsx:291 — max-h-68 isn't in the Tailwind v4 default scale (it goes …56,60,64,72,80,96) so the class generates no CSS, breaking the popover height limit. Looks like a 72→68 typo that slipped in. Reset to max-h-72. discussion

  2. components/tool-ui/kb/status-badge.tsx:198 — adding embeddingPending to terminal flips mixed (success<total, embeddingPending>0, failed>0) docs from "Indexing (X/Y)" to "X failed" even though 3 of the 10 chunks just need a vector, not a retry. Tooltip also stops mentioning embedding-pending. Either narrow isFailed to also require embeddingPending === 0, or extend the tooltip to enumerate both buckets. discussion

  3. lib/observability/queries.ts:155 — findLatestParentMessageId is now a per-thread HTTP call (was a single indexed SELECT). bulkInsertSpans runs on the End hook on the request hot path, so N missing threads in a batch serially block N dev-server round-trips before INSERT. Parallelize + add a short timeout / circuit-breaker, otherwise a slow dev server will turn every chat-side regenerate into a 404-on-the-observability-panel side-effect. discussion

  4. lib/observability/queries.ts:177 — the inner if (typeof r.meta?.parent_message_id === "string") branch in backfillParentMessageIds is dead code on the happy path: toRow (line 65-66) already lifted it into r.parentMessageId whenever it was a non-empty string. The only case this rescues is an empty-string "", and that ends up null again at rehydrate time anyway. Drop it. discussion

  5. components/settings/kb-view/dialogs.tsx:440-457 — the folder cascade dialog's destructive copy ("Also destroys all documents, chunks, and vector embeddings") doesn't mention that R2 source objects + rendered PNGs are intentionally retained (see lib/kb/queries.ts:1499-1503). The single-doc DocDeleteDialog:89-90 has the same "stay in R2" caveat; lift it to a shared helper so both destructive dialogs mention the same blast radius. discussion

Confirmed-correct (no action)

  • ✅ app/api/kb/documents/[id]/route.ts:25-44 — independent subqueries + coalesce(...,0) correctly avoids cartesian inflation; both Greptile test cases (3+3 entities × 2 relationships) and the "entities absent, relationships present" case are covered. discussion
  • ✅ backend/node/kb/page-to-markdown-node.ts:24 — comment matches code (retryFailed is not in the skip list, so OCR runs). New tests/backend/node/kb/page-to-markdown-node.test.ts proves it. discussion
  • ✅ lib/kb/queries.ts:1516-1551 — folder cascade transaction is correct: per-doc thread + attachment cleanup, then DELETE kb_document cascades to chunks/entities/relationships/themes via the schema FKs, then folder delete. R2 object retention matches deleteKbDocumentForUser. discussion
  • ✅ lib/observability/callback.ts:266-296 — per-call parent_message_id resolution (no instance cache) is exactly the right fix for the concurrent-invoke clobber. Concurrent invokes test (tests/backend/observability/callback-collector.test.ts) confirms h-A and h-B no longer overwrite each other.
  • ✅ prepareKBDataNode preferring config.configurable.parent_message_id over lastHumanMessageId(state.messages) plugs the next-attribution bleed.
  • ✅ app/api/kb/documents/route.ts + components/tool-ui/kb/types.ts + backend/tool/kb/types.ts are coherent — every new field flows KbDocument → ListDocumentsDoc → Settings row + chat card. Same story for KbChunkPreviewLocal + KbChunkPreview.

Tests / docs compliance

  • docs/APIS.md, docs/OBSERVABILITY.md, docs/TOOLS.md were updated in lockstep with the wire changes ✓
  • New tests both for the route surface (tests/api/kb/documents/[id].test.ts) and the node path (tests/backend/node/kb/page-to-markdown-node.test.ts) — good coverage for what the PR claims
  • No documented CLAUDE.md rule violation spotted. withAuth wrapping, kb_documents ON DELETE RESTRICT honored, no NEXT_PUBLIC_* leak.

Quick stats

  • 30 files / +1061 / −208
  • All pnpm typecheck / lint / test results reported clean in the PR body (131 files / 1367 passed)
  • No commits pushed during review (read-only).

- lib/observability/queries.ts: remove the leftover
  console.warn(2222, ...) debug artifact in bulkInsertSpans; rewrite
  the stale "look up the column" comment to describe the actual
  langGraphClient.threads.getState + lastHumanMessageId fallback
  path (the column is no longer the canonical source).
- lib/kb/source-kind.ts: mimeShortLabel fallback restored — upper-
  cased subtype (e.g. "application/json" → "JSON") instead of the
  raw mime, so unmapped types still render readably in the table.
- components/tool-ui/kb/status-badge.tsx: drop the hasGraph gate
  from isCompleted. Vectors being complete is the "Indexed" signal;
  the graph leg is supplementary and may legitimately produce zero
  rows (plain-text docs). Tooltip still surfaces entity/relationship
  counts for transparency.
- docs/APIS.md: cascade-delete narrative aligns with the actual
  semantics — attachment rows ARE deleted (kb cascade); R2 objects
  stay (sha-keyed, ref-counted).
- docs/OBSERVABILITY.md: correct the pmid propagation path — kb
  ingest passes parent_message_id via the top-level runs.create
  metadata arg, NOT config.configurable. The handler reads it from
  metadata.parent_message_id on the LC callback surface.
- tests/api/kb/folder.test.ts: extend cascade coverage — verify
  kb_chunk + kb_entity + kb_relationship FK CASCADE + the explicit
  threads-row delete in deleteKbFolderForUser's deleteAllDocs branch.
CI fix (round 3 primary):
- lib/observability/queries.ts: bound the state-fallback lookup in
  backfillParentMessageIds to a 500ms timeout AND parallelize the
  per-thread fetches via Promise.all. CI was hanging 5s on 9
  bulkInsertSpans tests because langGraphClient.threads.getState
  never resolves in a test env (no dev server). Worst-case batch
  latency is now ~500ms, not N × RTT. Rows that time out stay
  pmid-less and the per-turn panel 404s them by design.
- lib/observability/queries.ts: drop the dead fallback branch — toRow
  already lifts meta.parent_message_id into r.parentMessageId; the
  "typeof meta?.parent_message_id === 'string'" guard rescued only
  the empty-string case which gets re-nulled by toCapturedSpan anyway.

Round 3 review fixes (Claude + cubic):
- components/tool-ui/kb/status-badge.tsx: isFailed now requires
  embeddingPending === 0 — otherwise a doc with failed + embedding-
  pending chunks collapses into the "X failed" badge even though
  only X of the in-flight chunks actually errored. The mixed state
  stays in the in-progress lane.
- components/assistant-ui/kb-mention.tsx: revert max-h-68 → max-h-72
  (68 isn't in Tailwind's default scale, so the class generated no
  CSS and the popover overflowed on short viewports).
- app/api/kb/documents/[id]/route.ts: drop the ?? 0 paranoia —
  count(*) always returns a row in PG, the fallback was unreachable.
- lib/kb/queries.ts: add a comment explaining why thread cleanup
  precedes kb_document delete (so a mid-cascade FK failure rolls
  back without orphaning threads).
- components/settings/kb-view/dialogs.tsx: folder-delete dialog
  now states "Source PDFs + rendered page images stay in R2 until
  the retention sweep" so the destructive copy matches the actual
  blast radius (matches the DocDeleteDialog caveat).

Test:
- tests/backend/node/kb/page-to-markdown-node.test.ts: new case —
  mixed pagesByDocId under retryFailed preserves the cached
  successful page verbatim and re-OCRs only the failed one.
- bulkInsertSpans → backfillParentMessageIds → findLatestParentMessageId
  hits langGraphClient.threads.getState. CI has no langgraph dev
  server, so the call hangs and 9 bulkInsertSpans tests time out at
  5s. Stub the HTTP layer in the two affected test files so the
  state fetch returns immediately (no human messages → null pmid →
  row persists pmid-less, the panel 404s those by design).
- The 500ms timeout in backfillParentMessageIds is kept as
  defense-in-depth for the real prod server hanging — but the test
  env no longer depends on it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 10 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="components/tool-ui/kb/status-badge.tsx">

<violation number="1" location="components/tool-ui/kb/status-badge.tsx:209">
P2: With this change, a doc that has some permanently failed chunks alongside chunks still awaiting vector embedding now shows the 'Embedding (X/Y)' badge instead of 'Z failed', but the tooltip for that branch never surfaces the failed count (unlike the hasInflightChunks branch, which does include `${failed} chunks failed`). Users lose visibility into failed chunks whenever embeddingPending > 0, which is the opposite problem the comment says this fix is solving.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// "X failed" badge for a doc that's really "X failed, Y still
// embedding" and the user can't reconcile the count with the
// tooltip breakdown.
const isFailed = failed > 0 && embeddingPending === 0 && terminal === total;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: With this change, a doc that has some permanently failed chunks alongside chunks still awaiting vector embedding now shows the 'Embedding (X/Y)' badge instead of 'Z failed', but the tooltip for that branch never surfaces the failed count (unlike the hasInflightChunks branch, which does include ${failed} chunks failed). Users lose visibility into failed chunks whenever embeddingPending > 0, which is the opposite problem the comment says this fix is solving.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/tool-ui/kb/status-badge.tsx, line 209:

<comment>With this change, a doc that has some permanently failed chunks alongside chunks still awaiting vector embedding now shows the 'Embedding (X/Y)' badge instead of 'Z failed', but the tooltip for that branch never surfaces the failed count (unlike the hasInflightChunks branch, which does include `${failed} chunks failed`). Users lose visibility into failed chunks whenever embeddingPending > 0, which is the opposite problem the comment says this fix is solving.</comment>

<file context>
@@ -199,7 +199,14 @@ export function ChunksStatusBadge({
+      // "X failed" badge for a doc that's really "X failed, Y still
+      // embedding" and the user can't reconcile the count with the
+      // tooltip breakdown.
+      const isFailed = failed > 0 && embeddingPending === 0 && terminal === total;
       const hasInflightChunks = pending > 0 || parsing > 0;
       const hasInflightExtraction = entities === 0 && relationships === 0;
</file context>

@FireTable
FireTable merged commit 1a71cc9 into main Jul 23, 2026
14 of 15 checks passed
@FireTable
FireTable deleted the fix/kb-mention-ux branch July 23, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant