Skip to content

fix(app): make first-run setup fast, non-blocking, and configurable - #66

Merged
siracusa5 merged 12 commits into
mainfrom
claude/app-setup-resolving-hang-wfdj07
Jul 29, 2026
Merged

fix(app): make first-run setup fast, non-blocking, and configurable#66
siracusa5 merged 12 commits into
mainfrom
claude/app-setup-resolving-hang-wfdj07

Conversation

@siracusa5

@siracusa5 siracusa5 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fixes the first-run setup hang by indexing sources in background jobs and returning partial, explicitly marked graph results while work continues.
  • Runs the engine in an isolated Electron utilityProcess, keeping folder walks, parsing, tokenization, and MCP work off the UI thread.
  • Adds user-configurable indexing limits, a Files browser/editor, authenticated image/PDF previews, and safe typed-data Markdown rendering.
  • Reconciles the branch with current main, including the GitHub source adapter and its tests.
  • Updates the Mac app icon and the affected contributor, architecture, and manifest documentation.

Adversarial review fixes

  • Removed the per-launch API bearer from renderer process arguments. The engine now sends it to the main process over its message port, and the sandboxed preload retrieves it through exact-window, exact-origin trusted IPC.
  • Required an explicit trusted-command acknowledgement at the MCP mutation API, validated command/argument shapes, and made the no-shell spawn boundary explicit; direct manifests remain intentionally trusted MCP configuration.
  • Prevented stale editor sessions from overwriting external disk edits by adding revision-aware saves with 409 Conflict responses.
  • Prevented in-flight saves, file switches, sidebar navigation, and background refreshes from discarding or replacing an unsaved draft.
  • Made settings changes refresh the resolved cascade and fixed a graph/resolve-all race that could leave a stale indexing banner behind.
  • Added cooperative cancellation for timed-out, superseded, removed, and closed indexing jobs.
  • Enforced safety ceilings on environment-based indexing limits while retaining small values for constrained and test environments.
  • Bounded raw previews to 25 MB, restricted them to images/PDFs, streamed approved bytes, used the canonical guarded path, added nosniff/CSP headers, and forced direct SVG navigation to download.
  • Stopped oversized request bodies from continuing to accumulate after rejection.
  • Made the setup completion message distinguish a genuinely empty cascade from one still indexing.
  • Updated the site lockfile to patched PostCSS and SVGO releases; production dependency audits for console, desktop, and site now report zero vulnerabilities.

Behavior changes worth a reviewer's attention

Reads are partial by design. /api/graph and /api/resolve-all return what is currently indexed and set indexing / indexingSources. Clients render available data and poll. Tests or clients that require a settled snapshot can pass ?wait=<ms>.

Add-source validation is deliberately cheap. Missing paths and non-directory paths fail immediately. Large folders are accepted quickly, then become visible source errors with an actionable Settings path if they exceed configured limits.

Settings precedence is manifest > environment > default. App changes therefore take effect rather than being silently shadowed by an inherited environment variable.

An unexpected engine exit is fatal. A loaded window cannot be pointed at a replacement loopback origin, so the app uses the same clean dialog-and-exit path as a boot failure.

Affected area

  • Core engine / MCP / write path
  • Console
  • Desktop app
  • Site and dependency lockfile
  • Playground and local demo surfaces
  • Docs / specs / contributor workflow
  • Packs

Validation

  • npm test — all 13 root suites pass
  • npm run demo:verify
  • Console: typecheck, 123 tests, production build
  • Desktop: 34 unit tests, navigation, CLI-status, smoke, boot-failure smoke, and engine-isolation test
  • Site: production build and install-page verification
  • npm audit --omit=dev — zero production vulnerabilities in console, desktop, and site

The isolation test boots the real app against 2,500 temporary documents and fails if indexing stalls the Electron main loop. The setup robustness suite covers bounded walks, limit settings, partial reads, cancellation, editor revision conflicts, index refreshes, and usable-while-indexing behavior.

Compatibility

  • No root npm dependencies were added.
  • Root commands remain unchanged except for the added regression suite in npm test.
  • The console Markdown path produces typed data and React text nodes; it has no HTML-string sink.
  • Generated desktop icon artifacts are intentionally committed from the canonical brand source.

Two follow-ups remain intentionally out of scope: tokenizer initialization still blocks its isolated engine process once, and the Files view remains edit-only (no create/delete), matching the existing write posture.

claude added 5 commits July 28, 2026 20:59
…source reads + add-time validation

The desktop setup wizard could hang at 'Resolving…' and freeze the whole
app: the engine runs on the Electron main process, and pointing a source
at a large folder ran an unbounded synchronous directory walk, then read
and BPE-tokenized every file twice per request. The console fetch had no
timeout, so the spinner never ended.

Engine:
- okf-local/files adapters now use async fs and one shared bounded walker
  (10k doc files / 150k scanned entries, env-overridable); loadConcept
  reads are async too
- /api/graph and /api/resolve-all read each source exactly once per
  request via an in-memory snapshot, yield to the event loop while they
  work, and put each source behind a 30s budget — a stalled source
  degrades to an errored source instead of a hung request
- /api/sources validates at add time: folders must exist and index within
  bounds (with ~ expansion and a docCount in the response), MCP commands
  are spawned and probed with tools/list before the manifest is written
- tokenizer: one-time ~800ms encoder init is pre-warmed at service boot,
  and oversized texts get a bounded prefix-extrapolated count

Console:
- apiFetch carries a default 60s abort deadline; timeouts surface as a
  typed, honest error message
- the wizard's finish step is bounded at 20s, server-side validation
  errors show inline on the form, and the review step shows the indexed
  doc count

Tests: new packages/core/tests/setup-robustness-test.sh (36 assertions:
caps, add-time validation, MCP probes, snapshot/per-concept equivalence,
and an event-loop responsiveness race — 80ms mid-build response vs 2.1s
before the pre-warm), wired into npm test; console suite covers the
timeout taxonomy and wizard error/count states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVRpkHFSihVxGQeKD9mgK7
The Dock/Finder icon still shipped the old three-tier cake illustration
while the site, console, and brand assets had moved to the flat strata
mark (personal amber / team teal / company indigo on a dark squircle).

- build/icon.icns and build/icon-master-1024.png are regenerated from
  the canonical assets/brand/contextcake-app-icon.svg — the same file
  the site and console favicons mirror
- new 'npm run icon' (scripts/generate-icon.mjs) makes regeneration
  reproducible on any platform: Electron's own Chromium renders the SVG
  offscreen and the script packs the icns directly (16→1024px, PNG-typed
  entries incl. retina aliases) — no iconutil, no new dependencies
- the stale build/icon.svg cake master is removed; docs now point icon
  changes at the brand SVG so the icns can't drift from the brand again

Verified: icns parses with all 11 entries at correct dimensions, RGBA
with transparent corners, legible at 32px; desktop tests (33), navigation,
CLI status, and the Electron smoke boot all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVRpkHFSihVxGQeKD9mgK7
Three related changes so setup never blocks and the limits are not env-only.

Background indexing — the app is usable immediately:
- each source is read by a background job; /api/graph and /api/resolve-all
  answer from whatever is indexed right now and report indexing/indexingSources
  with per-source phase and progress. A first graph request against a
  901-document folder now returns in ~6ms instead of waiting for the read
- index entries are keyed by layer config + settings, so adding a second
  source never re-indexes the first
- the console renders its shell as soon as the graph responds and streams
  concepts in, polling while sources index. The full-page
  'Resolving the cascade…' gate is gone — it blocked the whole UI on every
  launch, not just first run
- an over-cap folder is no longer a rejected form: it is added instantly and
  surfaces as a source error pointing at Settings. Only a missing folder or a
  non-folder still fails the add form
- reads are now partial by design, so completeness assertions pass ?wait=<ms>

Settings screen — no more editing env vars:
- new settings.mjs: manifest > env > default precedence with range validation
  and a UI catalog; GET/PATCH /api/settings persists to the manifest
- Settings → Indexing pane for documents-per-source, files-scanned-per-source
  and the per-source time budget, with reset-to-default
- limits reach the adapters, so raising one re-indexes and takes effect

Files view — read and edit your context files:
- the layer file APIs move from the playground into the engine
  (layer-files.mjs), so the desktop app has them; they now cover markdown
  folder layers, which the playground copy left invisible
- new console Files view: per-source file tree, rendered/raw Markdown toggle,
  save with dirty state and ⌘S
- markdown.ts is a dependency-free, escape-first renderer: input is escaped
  before any markup, only self-written tags are emitted, and link/image URLs
  go through a scheme allowlist

Tests: setup-robustness-test.sh rewritten around the new contract (usable
while indexing, settings→behavior, file editing, index reuse); new console
suites for the load state, the Files view, the settings pane and renderer
escaping. Engine 12/12, console 103 tests, desktop tests + Electron smoke all
pass; verified end to end against a 901-document folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVRpkHFSihVxGQeKD9mgK7
Background indexing made a frozen window unlikely; this makes it
structurally impossible. The engine walks folders, parses markdown, runs a
BPE tokenizer and spawns foreign MCP servers — all of which shared an event
loop with the window that draws.

Measured on a 2,500-document corpus, worst-case main-loop stall:
  ~694ms sharing the loop  ->  ~30ms isolated

- src/main/engine-process.mjs: the engine's own utilityProcess. No electron
  module; talks to the parent over its message port
- src/main/service-host.mjs: now a supervisor. Same
  { origin, token, reload(), close() } handle, so main.mjs is barely touched;
  reload() became an acknowledged round-trip, so callers await it
- the bearer token is generated in the child and passed UP the message port
  rather than down through argv, which is readable by other local users
  via   PID TTY          TIME CMD
    1 ?        00:00:02 process_api
    2 ?        00:00:00 kthreadd
    3 ?        00:00:00 pool_workqueue_release
    4 ?        00:00:00 kworker/R-rcu_gp
    5 ?        00:00:00 kworker/R-sync_wq
    6 ?        00:00:00 kworker/R-kvfree_rcu_reclaim
    7 ?        00:00:00 kworker/R-slub_flushwq
    8 ?        00:00:00 kworker/R-netns
    9 ?        00:00:00 kworker/0:0-mm_percpu_wq
   10 ?        00:00:00 kworker/0:0H-events_highpri
   12 ?        00:00:00 kworker/u16:0-flush-254:0
   13 ?        00:00:00 kworker/R-mm_percpu_wq
   14 ?        00:00:00 ksoftirqd/0
   15 ?        00:00:00 rcu_preempt
   16 ?        00:00:00 rcu_exp_par_gp_kthread_worker/0
   17 ?        00:00:00 rcu_exp_gp_kthread_worker
   18 ?        00:00:00 migration/0
   19 ?        00:00:00 cpuhp/0
   20 ?        00:00:00 cpuhp/1
   21 ?        00:00:00 migration/1
   22 ?        00:00:00 ksoftirqd/1
   23 ?        00:00:00 kworker/1:0-mm_percpu_wq
   24 ?        00:00:00 kworker/1:0H-events_highpri
   25 ?        00:00:00 cpuhp/2
   26 ?        00:00:00 migration/2
   27 ?        00:00:00 ksoftirqd/2
   29 ?        00:00:00 kworker/2:0H-events_highpri
   30 ?        00:00:00 cpuhp/3
   31 ?        00:00:00 migration/3
   32 ?        00:00:00 ksoftirqd/3
   33 ?        00:00:00 kworker/3:0-mm_percpu_wq
   34 ?        00:00:00 kworker/3:0H-kblockd
   36 ?        00:00:00 kworker/u16:1-iou_exit
   37 ?        00:00:00 kworker/u16:2-iou_exit
   38 ?        00:00:00 kworker/u16:3-ext4-rsv-conversion
   39 ?        00:00:00 kdevtmpfs
   40 ?        00:00:00 kworker/R-inet_frag_wq
   41 ?        00:00:00 rcu_tasks_kthread
   42 ?        00:00:00 rcu_tasks_trace_kthread
   43 ?        00:00:00 kauditd
   44 ?        00:00:00 khungtaskd
   45 ?        00:00:00 oom_reaper
   46 ?        00:00:00 kworker/R-writeback
   47 ?        00:00:00 kcompactd0
   48 ?        00:00:00 ksmd
   49 ?        00:00:00 khugepaged
   50 ?        00:00:00 kworker/R-kblockd
   51 ?        00:00:00 kworker/R-kintegrityd
   53 ?        00:00:00 kworker/2:1-mm_percpu_wq
   54 ?        00:00:00 watchdogd
   55 ?        00:00:00 kworker/R-quota_events_unbound
   56 ?        00:00:00 kworker/R-rpciod
   57 ?        00:00:00 kworker/0:1H-kblockd
   58 ?        00:00:00 kworker/R-xprtiod
   67 ?        00:00:00 kworker/u16:4-iou_exit
   89 ?        00:00:00 kswapd0
   91 ?        00:00:00 kworker/R-nfsiod
   96 ?        00:00:00 kworker/R-xfsalloc
   97 ?        00:00:00 kworker/R-xfs_mru_cache
   99 ?        00:00:00 kworker/R-kthrotld
  101 ?        00:00:00 irq/24-ACPI:Ged
  102 ?        00:00:00 irq/25-ACPI:Ged
  156 ?        00:00:00 hwrng
  202 ?        00:00:00 kworker/R-iscsi_conn_cleanup
  224 ?        00:00:00 kworker/R-kstrp
  461 ?        00:00:00 kworker/u16:5-iou_exit
  526 ?        00:00:00 kworker/u17:0
  529 ?        00:00:00 kworker/1:1H-kblockd
  535 ?        00:00:00 kworker/2:1H-kblockd
  541 ?        00:00:00 kworker/1:2-events
  542 ?        00:00:00 kworker/R-ext4-rsv-conversion
  543 ?        00:00:00 kworker/3:1H-kblockd
  545 ?        00:00:00 kworker/R-ext4-rsv-conversion
  546 ?        00:00:00 kworker/R-ext4-rsv-conversion
  548 ?        00:00:00 kworker/2:2-events
  549 ?        00:00:00 sh
  553 ?        00:00:02 environment-man
  567 ?        00:00:31 claude
 3105 ?        00:00:00 kworker/u16:6-ext4-rsv-conversion
 3106 ?        00:00:00 kworker/u16:7-iou_exit
 3107 ?        00:00:00 kworker/u16:8-iou_exit
 3110 ?        00:00:00 kworker/3:2-events
 3112 ?        00:00:00 kworker/0:2-events
 8965 ?        00:00:00 kworker/u16:9-iou_exit
 8966 ?        00:00:00 kworker/u16:10-iou_exit
 8967 ?        00:00:00 kworker/u16:11-iou_exit
 8970 ?        00:00:00 kworker/u16:12
 9188 ?        00:00:00 kworker/2:0
11024 ?        00:00:00 bash
11381 ?        00:00:00 ps
- boot failures still fail fast and now report the child's real cause; an
  unexpected engine exit after boot is fatal, since a loaded window cannot be
  re-pointed at a new port
- every app-ending path goes through shutdownEngine(): app.exit() skips
  before-quit, so without it a normal shutdown was misreported as a crash

Tests: new  boots the real app against a 2,500-doc
corpus in a temp user-data dir and fails if main-loop lag exceeds 400ms — a
threshold that sits between the measured isolated and shared-loop numbers, so
it genuinely catches the work moving back. Wired into CI. The smoke check now
reports lag and indexing state.

Spec: distribution design §3 documented the engine as living in the main
process; updated, along with §4 for the layer file APIs that moved into the
engine earlier in this branch.

Validation: engine 12/12 suites, console typecheck + 103 tests + build, site
build, desktop unit/navigation/cli-status/smoke/bootfail/isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVRpkHFSihVxGQeKD9mgK7
Self-review of this branch plus a critical CodeQL alert on the changed code.

Security — no HTML string, no sanitizer (likely the CodeQL finding):
The Markdown renderer built an HTML string and handed it to
dangerouslySetInnerHTML. Even with escape-first ordering, that is a
hand-rolled sanitizer on untrusted file content — the pattern static
analysis flags, and rightly. Replaced rather than defended:
- markdown.ts now parses to DATA (typed blocks/inlines) and never emits HTML
- components/Markdown.tsx renders that data as React elements, so document
  text reaches the DOM as text nodes and cannot be markup by construction
- dangerouslySetInnerHTML is gone from the codebase; the escaping helper is
  gone with it. URL filtering stays — a javascript: href is dangerous even
  in a properly created element

Two real bugs found while reviewing:

1. The index went stale. Editing a file updated /api/resolve but NOT
   /api/graph or /api/resolve-all: the manifest is untouched by an edit, so
   the index key was unchanged and the pre-edit snapshot was reused forever.
   Edits made outside the app never appeared at all, a regression from the
   read-on-every-request behavior. Writes now invalidate the affected layer,
   and disk-backed layers are watched (debounced, best effort) so external
   edits and new files land on their own. The previous snapshot keeps serving
   during a re-index, so nothing blinks empty.

2. A failed background poll left the console's Indexing… banner running
   forever with nothing left to clear it — which reads as exactly the hang
   this work removes. Transient failures now retry with backoff, then stop
   claiming work is in flight.

Also fixed, both caught by the new tests:
- parseInline recursed on a module-level /g regex, so the inner call reset
  lastIndex and the outer scan restarted forever — an infinite loop on any
  nested emphasis or link
- **bold *with italic* inside** did not parse; the strong pattern could not
  span a single asterisk
- the table-divider regex had ambiguous repetition that backtracks
  polynomially on a long divider

Tests: markdown coverage rewritten against the parser plus a real-DOM suite
asserting no script/img element is ever created from a payload; store tests
for poll retry and banner teardown; engine assertions that in-app edits,
external edits and new files all reach the indexed reads.

Engine 12/12, console 117 tests, desktop unit/navigation/cli-status/smoke/
isolation all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVRpkHFSihVxGQeKD9mgK7

Copy link
Copy Markdown
Collaborator Author

Code review

Self-review of this branch, plus the critical CodeQL alert on the changed code. Seven issues found; all are fixed in b45f157. Four open observations are recorded at the end for a human to weigh — none of them block.

Security

Critical — the Markdown renderer built an HTML string and handed it to dangerouslySetInnerHTML. File content is untrusted input, so this was a hand-rolled sanitizer feeding an injection sink. The escaping ordering was correct as far as I could test it, but "my sanitizer looks right" is the argument that ages badly, and it is almost certainly what CodeQL flagged.

Replaced rather than defended: markdown.ts now parses to typed data and never emits HTML; components/Markdown.tsx renders that data as React elements. Document text reaches the DOM as text nodes, so it cannot be markup by construction. dangerouslySetInnerHTML no longer appears anywhere in the codebase, and the escaping helper is gone with it. URL filtering stays, since a javascript: href is dangerous even inside a properly created element.

Markdown.test.tsx asserts against a real DOM that a <script> payload creates no script element, an onerror payload creates no img element, and a crafted link title cannot add an attribute.

I could not read the alert body directly — there is no code-scanning tool in this environment and the alerts page requires auth — so the mapping to the CodeQL rule is inferred. If the alert survives this push it is something else (most likely js/path-injection on the layer file APIs, which are guarded by the pre-existing assertInsideRoot realpath check and were flagged only because the code moved in this PR).

Correctness

The index went stale — the worst bug here. Editing a file updated /api/resolve but not /api/graph or /api/resolve-all. A file edit does not change the manifest, so the index key was unchanged and the pre-edit snapshot was reused indefinitely. Edits made outside the app never appeared at all, which is a regression from the previous read-on-every-request behavior. Confirmed by experiment before fixing:

-- after in-app edit:      /api/resolve → EDITED     /api/resolve-all → ORIGINAL
-- after external edit:    /api/resolve → EXTERNAL   /api/resolve-all → ORIGINAL

Writes now invalidate the affected layer, and disk-backed layers are watched (debounced) so external edits and new files land on their own. The previous snapshot keeps serving during a re-index, so nothing blinks empty.

Infinite loop on nested inline markup. parseInline recursed on a module-level /g regex, so the inner call reset lastIndex and the outer scan restarted from zero forever. Any nested emphasis or link inside a link would hang the renderer. Caught by the new tests (the suite hung), now pinned by an explicit regression test.

**bold *with italic* inside** did not parse — the strong pattern could not span a single asterisk, so it fell back to literal asterisks.

Polynomial backtracking in the table-divider regex ([\s:|-]+\|[\s:|-]* — the delimiter is inside the repeated class). Rewritten to a single unambiguous class, with a timing test on a 4,000-character divider.

UX

A failed background poll left the "Indexing…" banner running forever with nothing left to clear it — which reads as exactly the hang this branch removes. Transient failures now retry with backoff, then stop claiming work is in flight.

Tests

A fixture collision — new freshness assertions wrote into a folder a later test counted documents in, making that test order-dependent. Given its own folder.

Test coverage otherwise looks sound to me: the two headline claims are measured rather than asserted (/api/graph answering in ~6ms mid-index; main-loop lag ~30ms isolated vs ~694ms sharing the loop), and the isolation threshold sits between the two measurements so it genuinely fails if the work moves back.

Open observations — not fixed, for a human call

  1. Index memory is now resident. Snapshots hold every parsed concept for the life of the process; previously they were per-request and collected. The 1,500-document fixture indexes to ~861k tokens of retained text — order tens of MB, and roughly linear in corpus size. It sits in the engine process now so it cannot take the UI down, and maxDocFiles bounds it, but a large real corpus is worth measuring before release.
  2. CLOSE_GRACE_MS (15s) is shorter than the default source budget (30s). When the adapter set is rebuilt, an in-flight index holding the old adapters can have its MCP child killed at 15s even though its budget allows 30. Narrow — it needs an MCP source indexing longer than 15s while an unrelated manifest edit lands — and the entry is usually discarded anyway, but the two constants should probably be related rather than independent.
  3. fs.watch recursion is platform-dependent. macOS (the desktop target) and Windows watch recursively; Linux does not, so a nested external edit is missed there. Writes through the API always invalidate explicitly, so the watcher is a convenience rather than the only path to freshness — noted in the code.
  4. The ~800ms tokenizer init is still a synchronous block, now inside the engine process at boot rather than on the UI thread. Harmless where it is; it would matter if the engine ever needed to answer during startup.

Generated by Claude Code

Comment thread packages/core/src/sources/mcp.mjs Dismissed
siracusa5 and others added 2 commits July 28, 2026 22:31
Integrates the github source adapter (#65), git-history section dates (#67)
and remote source health (#68) with background indexing and the utilityProcess
engine. Four conflicts, all union rather than either-or:

- okf-local: keeps main's commit-date history AND the async/bounded walk.
  documentDate's mtime fallback is now an async stat, since it runs in the
  read path the desktop engine must not block on.
- files: keeps the shared parseDocument seam AND the async parseFile. Dates go
  through localDate, so a folder source and a repo source agree on the calendar
  day instead of one of them slicing UTC.
- service: index state and adapter health are orthogonal, so both are reported.
  A source is 'indexing' until read, then 'degraded' if its adapter says its
  last request failed, then 'ok'. Resolution runs over anything with a
  snapshot, so a degraded source keeps contributing — main's warn-and-continue
  intent, expressed through the snapshot model.
- types.ts: GraphSource carries indexing progress and the health timestamps.

Two integration defects the merge exposed, both fixed:

- httpError lost its `detail` argument when the HTTP internals moved to
  http-util.mjs, so a failed remote Sync answered with a bare message instead
  of ok:false and the health timestamps the API promises.
- github-source-test asserted a settled source status straight after a Sync.
  Sync re-indexes, and reads answer from the background index, so the
  assertion raced the re-read. It now passes ?wait=, the same contract the
  service and playground suites use.

Verified on the merged tree: engine 13/13 suites, console typecheck + 125
tests + build, site build, desktop unit (34) + navigation + cli-status +
smoke + isolation (2ms main-loop lag while indexing 2,500 documents).
@siracusa5
siracusa5 merged commit 7fa62c2 into main Jul 29, 2026
7 checks passed
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.

3 participants