Skip to content

Latest commit

 

History

History
297 lines (230 loc) · 13 KB

AGENTS.md

File metadata and controls

297 lines (230 loc) · 13 KB

AGENTS.md — Working in the beryl codebase

Project Summary

beryl is a Gleam library for type-safe real-time channels and presence on the Erlang/BEAM runtime. It's a trellis-managed monorepo with three releasable packages, plus runnable examples and an Astro/Starlight documentation website.

Essential Commands

just deps              # install all deps (gleam + pnpm workspaces)
just check             # type check all packages
just test              # run all tests (scope: `just test beryl_mist`)
just build-strict      # build with Gleam warnings as errors
just format            # format all packages
just format-check      # check formatting (CI uses this)
just lint              # glinter with warnings_as_errors
just beam-check        # Dialyzer and Xref checks for Erlang FFI
just ci                # full CI: format-check, BEAM analysis, tests, docs, examples
just pr                # alias for ci — run before creating/updating PRs
just main              # just ci plus documentation checks
just change P K "B"    # create changelog fragment (trellis changelog new)
just doctor            # validate workspace invariants

Scope tests to one package with just test beryl_mist, or run a package directly with cd packages/beryl && gleam test. unitest supports filters after --, such as gleam test -- --test module_name.test_name or gleam test -- --tag slow.

just test runs the parallel-safe core tests with unitest's automatic worker count, capped at four BEAM schedulers to avoid nested oversubscription, then runs tests tagged serial in a sequential lane. Tag a core test serial when it observes or changes process-wide state, such as :telemetry handlers, the Palabres capture handler, or Palabres's global logging level, or when it deliberately exercises VM-sensitive restart exhaustion or PubSub scope recovery. Also tag tests whose correctness depends on tightly controlled actor restart or delayed-ack scheduling. Direct gleam test remains a full sequential run.

Do not run just check before a same-scope build or test; both already type-check. Use just build-strict when Gleam warnings must fail the check.

Workspace Layout and Package Boundaries

packages/
  beryl/           # core library (app dispatch, presence, pubsub, wire, transport SPI)
  beryl_mist/      # Mist WebSocket transport (depends on beryl via path)
  beryl_ewe/       # Ewe WebSocket transport (depends on beryl via path)
examples/          # runnable example apps (workspace members, excluded from release)
website/           # Astro/Starlight docs site (NOT a workspace member)
docs/              # ADRs, design docs, architecture deck, security docs

Strict Import Boundaries

Transport packages (beryl_mist, beryl_ewe) MUST only import from the public beryl/transport SPI module. They must never import:

  • beryl/connection_limit
  • beryl/internal
  • beryl/log
  • beryl/rate_limit
  • beryl/runtime
  • beryl/telemetry

These modules are declared as internal_modules in packages/beryl/gleam.toml and are hidden from the public API. Functions in beryl/transport that are consumed by transport packages carry // nolint: unused_exports annotations because they appear unused within beryl itself.

beryl/channel MUST stay layered on beryl's public core APIs. Do not import beryl/runtime or the other internal modules listed above. Keep its private routing machinery behind the module's internal seam, and preserve the raw-dispatch/channel Phoenix parity matrix in packages/beryl/test/.

Architecture (Non-Obvious)

Typed App Dispatch

beryl.child_spec captures the app's generic model, msg, init, and update types in monomorphic closures behind the opaque Sockets handle. The runtime stores each socket's typed model and delivers Join, Message, Binary, Closed, and typed Info events without socket-dispatch casts or Dynamic round-trips. Dynamic is limited to decoded wire payloads.

JoinRef values are single-pending-join capabilities with unique runtime identity. Never reconstruct or compare their wire fields; return the exact ref in AcceptJoin or RejectJoin.

Transport SPI Contract

A transport implementation must follow this lifecycle:

  1. Admit: call transport.acquire_connection_slot + transport.bind_connection_slot
  2. Own: capture transport.connection_owner and monitor an OwnerAlive pid
  3. Register atomically: call transport.admit_socket with the captured owner and closer; close on failure
  4. Route: decode inbound frames with transport.active_codec, route via transport.route_decoded / transport.route_binary, shed over-rate frames with transport.new_message_limiter / transport.take_token
  5. Disconnect: transport.socket_disconnected + transport.release_connection_slot

Wire Protocol

The built-in codec (wire.phoenix_codec()) implements the Phoenix wire format. beryl.config takes the codec as a required argument — there is no implicit default.

  • Text frames: [join_ref, ref, topic, event, payload] JSON arrays
  • Binary frames: Phoenix V2 binary framing
  • Heartbeats: topic "phoenix", event "heartbeat"; replied to with phx_reply on the same topic

PubSub Wire Contract

PubSub broadcasts are sent raw between BEAM nodes via pg as the scope atom followed by the four pubsub.Message(payload) fields. This five-element tuple is a frozen wire contract. The scoped and previously unscoped shapes do not interoperate during a rolling upgrade.

Opaque Types with Builder Pattern

Configuration types (Config, LoggingConfig, PubSubConfig, and presence Config) are opaque. Construct them with their factory functions and adjust them with with_* builders. Never expose or match on their internal fields.

Testing Gotchas

BEAM Mailbox State Matters

This is the most critical testing gotcha. Tests that run near PubSub, heartbeat, or WebSocket assertions must:

  • Select the exact message shape they expect
  • Consume or drain messages they create
  • Never use broad "any message" selectors — a stale queued message from a prior test can cause nondeterministic failures

Polling Over Sleeping

Use test_helper.wait_until(check, timeout_ms, interval_ms) instead of process.sleep(N) for asynchronous assertions (presence replication, broadcast delivery, etc.).

Test Scope and Framework

  • Tests use unitest as the runner and gleeunit/should for assertions
  • Public socket/event behavior changes need integration coverage through the runtime and transport paths, not only pure helper tests
  • When replacing WebSocket transports, preserve existing Phoenix/WebSocket contract coverage by repurposing tests rather than deleting them

Erlang Test FFI

Test directories contain .erl files for test-only FFI helpers (e.g., beryl_log_capture.erl, beryl_supervisor_test_ffi.erl, beryl_mist_transport_test_ffi.erl). These provide BEAM-level capabilities not available in pure Gleam.

Treat Erlang FFI declarations as representation boundaries. Do not identity-coerce an external tuple into a Gleam custom type unless it already has the exact Gleam constructor tag and field layout. Return a tuple and construct the value in Gleam, or use a small Erlang converter. Run just beam-check after changes to Erlang FFI or the Gleam code that declares it.

Code Conventions

  • Result types over exceptions — all fallible APIs return Result
  • No recoverable-path assertions — released library code must not use let assert, panic, or check-then-assert for recoverable or fallible paths; match and propagate the error or use the established logging and recovery path
  • Exhaustive pattern matching — Gleam enforces this; handle all cases
  • Sentence-case website headings — use sentence case for page titles, section headings, callout titles, and sidebar labels
  • Lowercase product name — write the product name as beryl in prose, including at sentence starts and in headings; preserve exact code identifiers and quoted text
  • /// doc comments on all public functions
  • @internal annotation — hides functions from public docs while keeping them accessible to sibling modules within the same package
  • App dispatch changes — follow the existing event and effect handling patterns in beryl/event and beryl/runtime
  • glinter is enforced with warnings_as_errors = true. Key rules: thrown_away_error, discarded_result, unused_exports, deep_nesting, prefer_guard_clause, error_context_lost, unqualified_import, stringly_typed_error, short_variable_name, string_inspect

BEAM code reviews

For code reviews that include Gleam (.gleam) or Erlang (.erl, .hrl), delegate the BEAM-specific review to beam-vm-reviewer, unless you are already running as that agent. Include every changed Gleam and Erlang file in the review scope, including tests and examples. This review supplements normal correctness and API review; it does not replace them.

Edit .apm/agents/beam-vm-reviewer.agent.md, then run apm install --frozen --only apm to deploy it. apm.yml selects both Claude and Copilot. APM generates .github/agents/beam-vm-reviewer.agent.md and .claude/agents/beam-vm-reviewer.md; do not edit those copies directly. To request a review in either tool, ask: Use beam-vm-reviewer to review the current diff.

Commit and Release Workflow

Required GitHub Actions jobs must always start and report success. When path-filtering, skip only expensive steps and run a successful no-op step; do not skip the whole required job.

Before creating or updating a PR, fetch the current default branch and validate the branch against it. PR CI tests the merge result, so local checks on an outdated branch can miss failures.

Conventional Commits

Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, release, revert. Keep type/scope lowercase. Header max 72 chars. Body max line length 100 chars.

Changelog Fragments

Create a fragment with just change <package> <kind> "<body>". Kinds: Initial Release (major), Added/Changed/Removed (minor), Fixed/Performance/Deprecated/Security/Dependencies (patch).

A fragment applies only to what a released package ships — its src/, gleam.toml, or manifest.toml. The fragment describes what a consumer of the published package receives. Write a fragment when you change a released package's:

  • public API (types, functions, labels, or signatures)
  • observable runtime behavior, or a bug in that behavior
  • dependency requirements in gleam.toml or manifest.toml
  • /// or //// doc comments, because these ship in the API reference

Do not write a fragment for anything a package does not ship. The website, docs/, examples, and dev tooling reach no consumer, so a fragment for them bumps a version that nobody sees:

  • website/ content, components, styles, or generator scripts
  • docs/ ADRs, design notes, and decks
  • examples/ (release-excluded workspace members)
  • root config, justfile, .github/ workflows, and audit or lint settings
  • packages/*/test/, internal refactors, and formatting that keep the consumer-visible behavior the same

All three packages under packages/ are releasable, so each one takes its own fragments. Only examples/** is release-excluded.

PR CI enforces fragments via trellis changelog check. Preview the effect of the current fragments with just version-plan.

Release Flow

  1. Push commits with conventional messages + changelog fragments
  2. trellis release pr maintains a release PR (branch release/pending)
  3. Merge release PR → per-package tags (beryl-v1.2.3) + GitHub releases
  4. Hex.pm publishing is temporarily disabled

Staging Discipline

Do NOT stage: generated Playwright output, test-results/ directories, erl_crash.dump, temporary PR body files. Example Playwright runs create untracked artifacts — clean before staging.

Tooling

Tool Versions

.mise.toml pins Erlang 28, Gleam 1.18.1, just 1.50.0, trellis, rebar3, and licence_audit for local development and CI.

CI additionally matrix-tests Erlang 27 and 28 (see .github/workflows/ci.yml).

Dependency Rules

  • Never declare the same package in both [dependencies] and [dev-dependencies]
  • Keep shared requirements in the runtime dependency with the stricter range
  • When changing dependency files, update gleam.toml and manifest.toml consistently with Gleam tooling (don't hand-edit one side)

Trellis Exclusions

Only examples/** is excluded from release (@release key in the root gleam.toml). Examples are built and tested, but they get no changelog, version, tag, or fragment. All three packages under packages/ are releasable on the git_only lifecycle: they get tags and GitHub releases, but Hex.pm publishing is disabled.

Examples

Examples are workspace members with their own gleam.toml, package.json, and Playwright configs. They depend on beryl via path. The collab_docs/client is a nested workspace member (a JS client). Examples without a test/ directory are excluded from trellis run test in gleam.toml.