Skip to content

Latest commit

 

History

History
190 lines (150 loc) · 11.4 KB

AGENTS.md

File metadata and controls

190 lines (150 loc) · 11.4 KB

CLAUDE.md

Maple is an OpenTelemetry observability platform: a Foldkit (Vite) dashboard + Effect on the backend, ClickHouse/Tinybird as the warehouse, PlanetScale Postgres for relational state.

Workspace layout

  • apps/*: deployables (web, api, ai, ingest, alerting, cli, landing, sandbox, ...).
  • packages/*: shared code that knows Maple (domain, query-engine, backend, ui, db, ...). views is the Foldkit kit, charts and service map that apps/web and apps/local-ui share.
  • lib/*: zero Maple knowledge, could ship as a standalone OSS library. A lib/ package that imports @maple/domain must move to packages/. New packages default to packages/.

@maple-dev/effect-orm lives in the separate effect-orm repo; change it there.

Commands

bun dev                        # full alchemy dev stack at https://[<worktree>.]<app>.localhost
bun dev api web                # subset
bun --filter=@maple/web dev   # single app, raw port
bun run test                   # Vitest via turbo (NOT `bun test`)
bun typecheck
bun run tinybird:manifest      # after editing datasources.ts
bun run local-schema:bump <slug>   # local chDB schema bump for a datasources.ts change
bun db:up && bun db:migrate:local
bun run --cwd packages/db db:generate   # new migration (effect-orm)
bun run dev:signin             # one-shot signed-in Clerk link (dev user david+clerk_test@gmail.com)

Dev password fallback: Maple-Dev-Kx92qZ!. Toolchain is pinned in mise.toml; keep bun in sync with packageManager.

Backend architecture

  • apps/ai runs the public MCP server and the shared agent runtime (the ChatSession Durable Object) behind investigations and their follow-ups, PR review and the Slack/Discord bot. apps/api forwards /mcp, /api/chat/*, /internal/chat/* to it over a service binding; OAuth stays in apps/api.
  • An investigation is one agent turn ending in submit_diagnosis. Do not split it across planner/lanes/validator. Sub-agents are fine elsewhere (e.g. review fan-out) via SubagentHost.
  • LLM code (Effect AI + @yielded/agent) is only in apps/ai; Maple-specific wiring lives in apps/ai/src/platform/Llm.ts.
  • Shared services live in packages/backend, imported via @maple/backend/* subpaths.

Service conventions

  • Services acquire their dependencies in the constructor; public methods must not leak implementation services in their Effect requirements. Request-scoped context (transactions, tenant, actor) stays on the invocation, never captured at construction.
  • Each service owns static readonly layer = Layer.effect(this, this.make) with internal deps via Layer.provide. Env, Database, caches, and bindings come from the app root.

Warehouse queries

No Tinybird pipes/endpoints. All queries use the ClickHouse DSL in @maple/query-engine and run through WarehouseQueryService.compiledQuery(). Never fetch() /v0/sql.

Add a query in packages/query-engine/src/ch/queries/*.ts, export from src/ch/index.ts:

const rows = yield* warehouse.compiledQuery(tenant, CH.compile(CH.myQuery({ limit: 50 }), { orgId, startTime, endTime }), {
  profile: "list",
  context: "myQuery",
})
  • Every query must filter OrgId ($.OrgId.eq(param.string("orgId"))); enforced at runtime.
  • Pass the unrun CH.compile Effect; never Effect.orDie it at a call site.
  • Identity UInt64s (hashes/ids) must be toString()-wrapped in the SELECT. rowSchemas use CH.CHNumber, never Schema.Number.
  • Request fields that reach param.* or column comparisons are validated at the HTTP boundary (TinybirdDateTime, BucketSeconds, WarehouseDateTime) so bad input is a 400, not a 500.
  • The query-engine root barrel stays driver-free (web/cli import it).
  • packages/domain/src/tinybird/endpoints.ts is type-only.
  • Read docs/warehouse-rollups.md before adding a materialized view. Query perf work uses bun run bench:queries (see docs/query-benchmarking.md); empty-table timings prove nothing.

Application database (Postgres / effect-orm)

Tables in packages/db/src/tables/ (@maple/db/tables), reached via Hyperdrive binding MAPLE_DB. Queries use @maple-dev/effect-orm: database.execute((db) => db.run(PG.from(T)...)), db.transaction(effect). Rows are typed by the table: branded ids, literal unions, schema-typed jsonb, timestamps as epoch ms (PG.timestamptzMillis), so compare them with === null, never by truthiness.

  • No untyped SQL. A query the builder lacks is a PG.sql template with a typed result.
  • Readers that must tolerate old jsonb shapes select the column with PG.undecoded.
  • Change a table, then bun run --cwd packages/db db:generate --name <x>: it diffs the tables against the newest snapshot and writes packages/db/drizzle/<ts>_<x>/migration.sql. parity.test.ts fails when the tables and the migrations disagree.
  • One pool per invocation via withPgConnectionScope; connections never outlive the invocation. Fork DB work off a request only with forkRequestScoped.
  • Tests use createTestDb() (PGlite, packages/backend/src/platform/test-pglite.ts).
  • Migrations apply in the prd alchemy deploy. db:migrate is for local databases; never point it at prd.
  • PR previews (only with the preview label) get their own Neon branch, migrated by the deploy (docs/pr-previews.md).

Conventions

  • Schemas: Effect Schema, not Zod. Schema.optionalKey() for JSON HTTP/domain schemas, Schema.optional() only where undefined is a real value (search params, MCP tool params).
  • Errors: expected failures use Schema.TaggedError with a namespaced tag, message, and schema-backed context (Schema.Defect() for unknown causes). Keep them in the typed channel; remap with catchTag/catchTags. No plain Error, throws, or Data.TaggedError. Branded IDs in error context; undecodable input goes in a raw* string, never cast to a brand.
  • Types: Record<string, any> is a lint error (repo is at zero). Use Record<string, unknown>.
  • Library source: read node_modules/effect/src/, node_modules/@effect/*/src/, and node_modules/alchemy/src/ for APIs at the installed version.
  • Span status codes: Title case: "Ok", "Error", "Unset". Only 5xx is Error for server spans. Custom attributes use maple.*.
  • Self-tracing: the API traces itself through ingest; keep withTracerDisabledWhen() and avoid spans on hot paths like token validation.
  • UI: the dashboard (apps/web) and Maple Local (apps/local-ui) are Foldkit + @foldkit/ui + Tailwind 4, Nucleo icons. Dashboard pages are built from the kit only; read apps/web/CLAUDE.md before UI work (find the component in packages/views/src/ui/INDEX.md, build missing ones in the kit). Add icons to packages/views/src/ui/icons.generated.ts (element trees) from the local Nucleo DB:
    sqlite3 "~/Library/Application Support/Nucleo/icons/data.sqlite3" \
      "SELECT id, name, set_id FROM icons WHERE klass='outline' AND grid=24 AND name LIKE '%search%';"
  • @/ resolves to each app's source.
  • Tests: assert behavior, not source. test-slop/* rules (scripts/oxlint-plugins/test-slop.mjs) ban constant-vs-literal pins, existence-only/does-not-throw tests, Tailwind class pins and tests that read source files. Import and architecture boundaries are no-restricted-imports overrides in .oxlintrc.json, not tests.

MCP tools

Every tool is server.define({ parameters, output, hints, handler, render }) (apps/ai/src/mcp/tools/types.ts); the registry owns decode, aliases, errors, output encoding and rendering. registry.contract.test.ts enforces it across the catalog.

  • Parameters come from apps/ai/src/mcp/lib/params.ts (P.timeWindow, P.service, P.limit, P.oneOf...): one spelling per concept, retired names only as aliases.
  • Output is an Effect Schema in packages/domain/src/mcp-outputs/ (outputSchema / structuredContent, which apps/cli decodes). The model reads render(output), a ToolDoc whose next calls the registry validates.
  • Failures are typed (McpInvalidInputError, McpNotReadyError, McpUnavailableError, McpQueryBudgetError, McpQueryError); no isError literals in tools.

Repository sandbox

Internal-only agent tools (sandbox_*, apps/ai/src/mcp/tools/sandbox.ts) run commands against a git clone in Cloudflare's Sandbox container (apps/sandbox, prd only). Invariants:

  • Refs are validated (unsafeRef) and encoded before the GitHub call.
  • The clone credential never appears in command arguments.
  • Commands run unprivileged, read-only, no egress; refuse to run if the network namespace fails.

Test with bun run --cwd apps/sandbox verify:image (needs Docker); it catches what unit tests can't.

PR previews

A PR labelled preview deploys the full stack to app-pr-<n>.maple.dev / api-pr-<n>.maple.dev on the Clerk development instance (docs/pr-previews.md). Default to preview:web for dashboard, API or AI changes: it deploys only the Workers on the same seeded data, faster and without a VPC. Use preview only to test ingest, Electric live sync, landing, or a connect flow that sends telemetry. To sign in to either:

infisical run --env dev -- bun run preview:signin <n>   # one-shot signed-in link, valid 2 minutes

It signs in as the shared reviewer preview-reviewer+clerk_test@maple.dev in the Preview org, which the deploy's smoke test seeds with demo telemetry. Humans can instead use the "PR preview" form on the sign-in page with PREVIEW_SIGNIN_KEY (Infisical dev).

  • Never type the key into a form or print it: mint the link with the command above and open it.
  • {"enabled":false} from GET /api/auth/preview/sign-in means the deploy ran without the key; re-run the preview deploy.
  • A preview hostname that will not resolve locally right after deploy is a cached NXDOMAIN; it clears on its own (or use curl --resolve with dig @1.1.1.1).

Docs

Read the relevant doc before touching an area: error-issue-lifecycle.md (anything in packages/backend/src/services/errors/), warehouse-rollups.md, service-map-architecture.md, ingest-wal-durability.md, local-mode.md, infra.md, api-v2.md, otel-spec/, dashboard-grid.md (the dashboard canvas and lib/grid-engine).

This is NOT the Turborepo you know

Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the turbo package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run node -p "require.resolve('turbo/package.json')" from a workspace that depends on turbo.

Read docs/README.md inside that installed package first, then read the relevant pages from its docs/ directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access.

This block is written and re-added by turbo before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in crates/turborepo-cli/src/cli/agent_guidance.rs. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set "agentGuidance": false in the root turbo.json or turbo.jsonc to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation.