fix(app): make first-run setup fast, non-blocking, and configurable - #66
Conversation
…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
Code reviewSelf-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. SecurityCritical — the Markdown renderer built an HTML string and handed it to Replaced rather than defended:
CorrectnessThe index went stale — the worst bug here. Editing a file updated 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.
Polynomial backtracking in the table-divider regex ( UXA 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. TestsA 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 ( Open observations — not fixed, for a human call
Generated by Claude Code |
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).
Summary
utilityProcess, keeping folder walks, parsing, tokenization, and MCP work off the UI thread.main, including the GitHub source adapter and its tests.Adversarial review fixes
409 Conflictresponses.nosniff/CSP headers, and forced direct SVG navigation to download.Behavior changes worth a reviewer's attention
Reads are partial by design.
/api/graphand/api/resolve-allreturn what is currently indexed and setindexing/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
Validation
npm test— all 13 root suites passnpm run demo:verifynpm audit --omit=dev— zero production vulnerabilities in console, desktop, and siteThe 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
npm test.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.