Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ jobs:
working-directory: apps/desktop
run: npm run smoke:bootfail

# Guards the process split: engine work must never land back on the UI
# thread, which is what froze the app during first-run setup.
- name: Engine isolation (UI thread stays responsive while indexing)
working-directory: apps/desktop
run: npm run test:isolation

required:
name: required
runs-on: ubuntu-latest
Expand Down
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# Run all tests
npm test
# or directly (mirrors the npm test chain):
bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh
bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh

# Run the MCP server (cascade mode)
node mcp-server.mjs --manifest layers.json
Expand Down Expand Up @@ -59,7 +59,7 @@ cd apps/desktop && npm run smoke # headless boot + token-guard check

See `docs/architecture/README.md` for the full design. Short version:

- **Storage is federated** — each layer is a `source` behind a uniform adapter: an `okf-local` bundle (git repo of OKF markdown) or an `mcp` foreign graph translated into OKF at read time.
- **Storage is federated** — each layer is a `source` behind a uniform adapter: an `okf-local` bundle, a plain `files` folder, a remote `github` repository, or an `mcp` foreign graph translated into OKF at read time.
- **Reading is unified** — `resolver.mjs` stitches the sources into one effective OKF concept at read time.
- **Layer precedence** — Personal (3) > Team (2) > Company (0). Higher wins per section. Levels are configurable per layer in the manifest.
- **Section/field merge** — not whole-document replacement. A higher layer speaks to what it knows; the rest is inherited. Where layers disagree, the primary value carries per-section `conflicts[]` (dissenting layer + date) — surfaced, not hidden.
Expand All @@ -71,6 +71,10 @@ Key files:
| File | Role |
|------|------|
| `packages/core/src/resolver.mjs` | Core cascade engine: section merge, precedence, provenance, conflict surfacing |
| `packages/core/src/service.mjs` | Embeddable HTTP service: read API, background index, sources CRUD, settings, file APIs, console mount |
| `packages/core/src/settings.mjs` | User-configurable engine limits (manifest > env > default) + validation and the UI catalog |
| `packages/core/src/layer-files.mjs` | Layer file explorer/editor APIs (`/api/files`, `/api/file`, `/api/section`), sandboxed to layer roots |
| `packages/core/src/http-util.mjs` | Shared HTTP internals: CSRF/loopback guard, containment guard, json/readBody helpers |
| `packages/core/src/mcp-server.mjs` | stdio MCP server; resolves via resolver.mjs; renders conflicts in markdown |
| `packages/core/src/sources/okf-local.mjs` | OKF-local source adapter: reads OKF markdown bundles from disk. Owns `withDocumentDate` (the section-date fallback chain, shared with every other adapter) and dates undated sections from git history (author dates, batched + TTL-memoized), never the mtime |
| `packages/core/src/sources/mcp.mjs` | MCP source adapter: spawns a foreign stdio MCP server, translates to OKF |
Expand All @@ -83,7 +87,7 @@ Key files:
| `packages/core/src/team-activity.mjs` | Aggregates live-layer captures + telemetry NDJSON into control-surface feed and reuse metrics (cross-brain hits) |
| `packages/core/fixtures/capture-policy.json` | Routing policy for agent-session captures (kinds → team_candidate; review keywords warn; dominant scratch signals → ignore) |
| `examples/team-sync-pack/` | Capture pack: Claude Code plugin (skill + Stop-hook nudge), Cursor rules, Copilot snippet, operator runbook |
| `packages/core/src/sources/index.mjs` | Source factory: builds adapters from a manifest (`okf-local` default, `files`, or `mcp`) |
| `packages/core/src/sources/index.mjs` | Source factory: builds adapters from a manifest (`okf-local` default, `files`, `github`, or `mcp`) |
| `examples/mock-mcp-source/server.mjs` | Runnable non-OKF foreign MCP server for integration tests |
| `packages/core/src/classify-context.mjs` | Classifies repo events into ignore / local / team_candidate / review_required |
| `packages/core/src/ingest.mjs` | Batch classifier: events → signals.json |
Expand All @@ -94,7 +98,7 @@ Key files:
| `apps/okf-browser/` | OKF graph browser |
| `apps/playground/` | Interactive playground: dependency-free HTTP server (`server.mjs`) over the engine + canvas/files/sources UI, merge resolver, per-source token budget. See `apps/playground/README.md`. |
| `apps/console/` | React + Vite + TS web UI (ContextCake Console) — its own npm package with a build step, deployed to Cloudflare Pages. See `apps/console/README.md` + `apps/console/CLAUDE.md`. |
| `apps/desktop/` | ContextCake for Mac: Electron shell — engine in the main process behind a token-guarded loopback service, console build as renderer, `contextcake` CLI shim, electron-updater. See `apps/desktop/CLAUDE.md` + `specs/contextcake-distribution/design.md`. |
| `apps/desktop/` | ContextCake for Mac: Electron shell — engine in an isolated utility process behind a token-guarded loopback service, console build as renderer, `contextcake` CLI shim, electron-updater. See `apps/desktop/CLAUDE.md` + `specs/contextcake-distribution/design.md`. |
| `apps/site/` | Public product site (Astro + Starlight). Spec + boundaries: `specs/contextcake-site/`. Site deps live in `apps/site/package.json` only — the engine stays dependency-free. |
| `specs/contextcake-packs/` | Public spec and private-repo template for ContextCake Packs, a separately sold product line whose paid content lives outside this public-bound engine repo. |
| `docs/architecture/README.md` | Historical design spec (partially superseded — see note at top) |
Expand All @@ -113,3 +117,8 @@ Key files:
- The engine (`packages/core/src/`) is dependency-free — plain Node.js built-ins only. Do not add npm dependencies without discussion. The exceptions are `apps/console/`, `apps/site/`, and `apps/desktop/` — self-contained npm packages. Console and site never import from the engine; the desktop app imports engine modules by path (one-way: app → engine, never the reverse) and must never cause a dependency to leak into `packages/core`.
- `apps/console/` and `apps/site/` each have their own `package.json`, build, and tests; run their commands from that subdirectory, not the repo root. Console CI lives at `.github/workflows/console-*.yml`, path-filtered to `apps/console/**` (production deploys on `console-v*` tags).
- Tests create temp directories and clean up with `trap`. Run from the repo root.
- **Disk-backed source walks are async and bounded** (`walkDocs` in `okf-local.mjs`). Never reintroduce sync fs walks or unbounded per-source reads. The desktop app isolates the engine in an Electron utility process so engine work cannot freeze the main/UI process; bounded work still matters because the engine API must remain responsive.
- **Aggregate reads come from a background index and are often partial.** `/api/graph` and `/api/resolve-all` answer from whatever is indexed right now and report `indexing` / `indexingSources`; clients render what they have and poll. Any assertion about completeness (tests, scripts) must pass `?wait=<ms>`. `/api/resolve` deliberately reads one concept live. An index entry is keyed by its layer config + settings, so adding a source never re-indexes the existing ones.
- **The indexing limits are user settings, not env-only** (`settings.mjs`, `GET`/`PATCH /api/settings`, Settings → Indexing in the console). Precedence is manifest > env > default — the manifest has to win or the settings UI would silently do nothing. Env vars (`CONTEXTCAKE_MAX_DOC_FILES`, `CONTEXTCAKE_MAX_SCAN_ENTRIES`, `CONTEXTCAKE_SOURCE_BUDGET_MS`) remain the headless/CI fallback.
- **Add-source validation is deliberately cheap**: only "folder is missing" and "that's a file" fail the form. A too-big folder is a normal thing to add — it becomes a visible source error after indexing, with a pointer at Settings. Don't reintroduce a full walk on the add path. MCP sources still probe (`tools/list`) at add time, which is bounded and catches a wrong command.
- **Layer file APIs live in the engine, not the playground** (`layer-files.mjs`), so the desktop app can browse and edit context files. They cover `files`-kind layers too — the playground's old copy only mapped `okf-local` roots, which made markdown folders invisible in the editor.
15 changes: 14 additions & 1 deletion apps/console/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ and `npm test`; CI runs both. dev/build/typecheck/test all regenerate
`resolveConflict`, `send`, view/selection setters) in one context. Callbacks
read the freshest values through refs so they don't re-subscribe. State is
in-memory only — reloads reset it.
- **Views** — `src/views/` (Canvas, Overview, Triage, Conflicts, Concepts).
- **Views** — `src/views/` (Canvas, Overview, Triage, Conflicts, Concepts, Files).
`App.tsx` is the shell: topbar + subbar + routed view, plus the Triage
S/R/D keyboard handler. The canvas view stays full-height inside the chrome.
Files is live-mode only: it browses and edits the real files behind each
layer through the engine's `/api/files` + `/api/file`, with a rendered/raw
toggle for Markdown.
- **Theming** — every color is a CSS variable in `src/styles.css` (light
soft-control-plane default, dark primary surface under
`:root[data-theme="dark"]`). `C` in `src/theme.ts` holds the variable references; `css()` parses inline
Expand Down Expand Up @@ -78,5 +81,15 @@ Key files: `src/store.tsx` (state), `src/theme.ts` (`css()` + tokens),
build won't ship until typecheck is clean.
- **Dark-first** — default theme is dark, persisted in `localStorage` under
`cc-theme`. Don't assume light.
- **Never block the shell on data.** `store.load.shell` is true only until the
graph responds (milliseconds); concepts resolve after the UI is up, and the
store polls while the engine reports `indexing`. The full-page
"Resolving the cascade…" gate was the first-run hang — don't add another one.
A failed *background* refresh must not clear a working page.
- **`src/markdown.ts` parses to typed data and has no dependencies.** It never
emits HTML; `components/Markdown.tsx` renders document strings as React text
nodes, so source content cannot become markup. Link/image URLs still go
through a scheme allowlist. Preserve that no-HTML boundary when extending it
— see `markdown.test.ts` and `components/Markdown.test.tsx`.
- `project/` holds the original Claude Design handoff (prototype HTML, chat,
assets). It's provenance, not part of the build — don't import from it.
2 changes: 1 addition & 1 deletion apps/console/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('App settings surface', () => {

it('does not open Settings over the Connect Agent dialog', async () => {
window.__CC_DESKTOP = {
token: 'test',
getApiToken: async () => 'test',
version: '0.1.0',
authState: { signedIn: false },
cli: {
Expand Down
33 changes: 30 additions & 3 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Overview } from './views/Overview'
import { Triage } from './views/Triage'
import { Conflicts } from './views/Conflicts'
import { Concepts } from './views/Concepts'
import { Files } from './views/Files'
import { ChatPanel } from './components/ChatPanel'
import { SetupWizard } from './components/SetupWizard'
import { ConnectAgentDialog } from './components/ConnectAgentDialog'
Expand All @@ -20,6 +21,9 @@ const ERROR_COPY: Record<LiveErrorKind, (msg: string) => string> = {
'bad-shape': (msg) => msg,
}

// Shown only while the source topology is unknown — a few milliseconds, since
// the engine answers /api/graph from its background index. Reading sources
// happens after the shell is up (see the indexing banner), never in front of it.
function LoadingState() {
return (
<div style={css(`display:grid; place-items:center; height:100vh; width:100%; background:${C.page};`)}>
Expand All @@ -32,12 +36,33 @@ function LoadingState() {
/>
))}
</div>
<div style={css(`font-family:${MONO}; font-size:12.5px; color:${C.caption}; letter-spacing:0.02em;`)}>Resolving the cascade…</div>
<div style={css(`font-family:${MONO}; font-size:12.5px; color:${C.caption}; letter-spacing:0.02em;`)}>Starting ContextCake…</div>
</div>
</div>
)
}

/**
* A non-blocking status strip: the app is fully usable while sources are read.
* It names what is still being indexed so the count changing under the user is
* explained rather than mysterious.
*/
function IndexingBanner({ names }: { names: string[] }) {
const label = names.length === 1
? `Indexing ${names[0]}`
: `Indexing ${names.length} sources (${names.slice(0, 3).join(', ')}${names.length > 3 ? ', …' : ''})`
return (
<div role="status" style={css(`display:flex; align-items:center; gap:9px; padding:7px 16px; background:${C.tealFill}; border-bottom:1px solid ${C.tealStroke}; font-size:12px; color:${C.tealText};`)}>
<span aria-hidden="true" style={css('display:flex; gap:3px;')}>
{[0, 1, 2].map((i) => (
<span key={i} style={css(`width:5px; height:5px; border-radius:999px; background:${C.tealStroke}; animation:ccPulse 1.1s ease-in-out ${i * 0.15}s infinite;`)} />
))}
</span>
<span>{label} — everything here stays usable, and results fill in as they land.</span>
</div>
)
}

function ErrorState({ kind, message, reload }: { kind: LiveErrorKind; message: string; reload: () => void }) {
const text = ERROR_COPY[kind](message)
return (
Expand All @@ -59,7 +84,7 @@ function ErrorState({ kind, message, reload }: { kind: LiveErrorKind; message: s
}

export function App() {
const { view, chatOpen, route, loading, error, reload, mode, sources, loadErrors } = useStore()
const { view, chatOpen, route, loading, load, error, reload, mode, sources, loadErrors } = useStore()
// Undefined = not yet decided by the auto-trigger effect below; true/false
// once the user (or the trigger) has taken an explicit stance. Kept separate
// from `needsSetup` so the wizard's own Success step stays visible even
Expand Down Expand Up @@ -177,6 +202,7 @@ export function App() {
/>
<div className="cc-content">
<Header onOpenMenu={() => setDrawerOpen(true)} />
{load.indexingSources.length > 0 && <IndexingBanner names={load.indexingSources} />}
{loadErrors.length > 0 && (
<div role="status" style={css(`display:flex; align-items:center; gap:8px; padding:8px 16px; background:${C.amberFill}; border-bottom:1px solid ${C.amberStroke}; font-size:12px; color:${C.amberText};`)}>
<span aria-hidden="true">⚠</span>
Expand All @@ -196,6 +222,7 @@ export function App() {
{view === 'triage' && <Triage />}
{view === 'conflicts' && <Conflicts />}
{view === 'concepts' && <Concepts />}
{view === 'files' && <Files />}
</main>
)}
</div>
Expand All @@ -208,7 +235,7 @@ export function App() {
return (
<>
<div className="cc-app-layer" aria-hidden={settingsOpen || undefined} inert={settingsOpen || undefined}>{body}</div>
{settingsOpen && <SettingsView appMode={mode} onClose={closeSettings} />}
{settingsOpen && <SettingsView appMode={mode} onClose={closeSettings} onIndexingChange={reload} />}
{showWizard && <SetupWizard addingSource={sources.length > 0} onClose={closeWizard} onConnectAgent={isDesktop ? () => {
setSourceSetupComplete(true)
setWizardOpen(false)
Expand Down
39 changes: 38 additions & 1 deletion apps/console/src/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
adaptConcept, adaptConflicts, adaptSources, LiveDataError, selectMode,
adaptConcept, adaptConflicts, adaptSources, apiFetch, LiveDataError, selectMode,
} from './api'
import type { GraphSummary, ResolvedConcept } from './types'

Expand Down Expand Up @@ -64,6 +64,17 @@ describe('LiveSource error taxonomy', () => {
await expect(source.graph()).rejects.toBeInstanceOf(LiveDataError)
})

it('reports a request timeout honestly — an eternal "Resolving…" is never an option', async () => {
const { createDataSource } = await import('./api')
vi.mocked(fetch).mockRejectedValue(new DOMException('The operation timed out', 'TimeoutError'))
const source = createDataSource('live')

await expect(source.graph()).rejects.toMatchObject({
kind: 'unreachable',
message: expect.stringContaining('took too long'),
})
})

it('throws a LiveDataError with kind "bad-status" and the status on non-ok', async () => {
const { createDataSource } = await import('./api')
vi.mocked(fetch).mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as Response)
Expand Down Expand Up @@ -94,6 +105,32 @@ describe('LiveSource error taxonomy', () => {
})
})

describe('desktop API credential transport', () => {
afterEach(() => {
delete window.__CC_DESKTOP
vi.unstubAllGlobals()
})

it('gets the bearer through trusted IPC instead of renderer process arguments', async () => {
window.__CC_DESKTOP = {
getApiToken: vi.fn().mockResolvedValue('launch-secret'),
version: '0.2.0',
authState: { signedIn: false },
cli: { getStatus: vi.fn(), install: vi.fn() },
}
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')))

await apiFetch('/api/graph')

expect(window.__CC_DESKTOP.getApiToken).toHaveBeenCalledOnce()
expect(fetch).toHaveBeenCalledWith('/api/graph', expect.objectContaining({
headers: expect.any(Headers),
}))
const [, init] = vi.mocked(fetch).mock.calls[0]
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer launch-secret')
})
})

// ---- Adapters: raw engine types -> console view model -------------------

describe('adaptConcept', () => {
Expand Down
Loading