Skip to content

Development

Terminal window
cd web && npm ci && npm run build && cd .. # embedded assets
go test -p 1 ./... # unit tests
TEST_DATABASE_URL=postgres://... go test -p 1 ./... # + integration tests
cd web && npm run dev # Vite dev server, proxies /api and /ws to :8080

-p 1 matters: the integration tests share one database and migrate it, so running packages in parallel makes them fight over the schema.

Path What lives there
cmd/parley main — config, boot, graceful shutdown
internal/api HTTP router, identity, spaces, sessions, passcodes
internal/auth OpenID Connect relying party
internal/poker, internal/standup the session kinds
internal/session the registry the kinds plug into, plus CSV
internal/hub WebSocket fan-out and presence
internal/store Postgres queries
internal/db/migrations numbered SQL, applied at boot
web Vite + React frontend, embedded into the binary
plugins Ceremony plugins that must not change the host (plugins/retrospective)
sdk @parley/plugin-sdk, @parley/plugin-ui, and the frozen ABI
site this documentation site

Session kinds are self-contained packages. Adding one touches three places and nothing else — no router surgery, no constraint change, no frontend ternaries.

  1. Write the package and register it. Give it a Kind() constructor returning a session.Kind — its Name, a State function returning only client-safe data (it’s broadcast to every participant, so nothing one participant shouldn’t see may be included), NewConfig, an optional CSV, and its Actions dispatch table. Add it to the slice in internal/api/router.go:

    for _, k := range []session.Kind{poker.Kind(), standup.Kind(), retro.Kind()} {

    Actions are resolved against the session’s own kind, so two kinds may name an action the same thing. The shared dispatcher already rejects any write on an ended session with a 409, so an action does not need to check that itself.

    State is handed the server’s *pgxpool.Pool directly:

    func buildState(ctx context.Context, pool *pgxpool.Pool, sess store.Session) (any, error)

    That concrete pool is a deliberate choice, not an oversight. A narrower SessionReader interface was considered so registry dispatch could be tested without Postgres, and rejected: every state builder issues its own arbitrary SQL — poker joins stories to votes and aggregates the voter ids, standup reads sessions and standup_entries — so the interface would have to expose Query and QueryRow, which still hand you pgx.Rows and pgx.Row. It renames the dependency rather than removing it, while making a convincing fake mean reimplementing pgx.Rows. BuildEnvelope needs the pool for the roster regardless, so narrowing State alone would not have bought a single database-free test. Write state-builder tests against a real database; internal/session pins this signature so changing it is a deliberate revisit of the trade-off rather than a drive-by refactor.

  2. Seed the kind row. Migrations are append-only, so add a new numbered file in internal/db/migrations/ rather than editing one that shipped:

    -- 0011_retro_kind.sql
    insert into session_kinds (kind, provider, display)
    values ('retro', 'core', 'Retrospective');

    Skip this step and the kind still registers, but creating a session of it fails the foreign key and the API answers 400 with “that session kind is missing its session_kinds row”.

    sessions.kind is a foreign key on delete restrict, so a kind that has sessions can never be deleted. Withdraw one by setting retired_at instead: new sessions are refused, the create dialog stops offering it, and existing ones keep working.

  3. Add the frontend entry. Append a KindDef to KINDS in web/src/lib/kinds.tsid matching the kind column, label, the Room component, and fields if the create dialog should offer config options. Give the kind a glyph in KindChip too, or its chip stays text-only. The glyph tests pick it up automatically and will hold it to the same legibility floor as the existing ones.

A plugin can provide a whole ceremony rather than a panel, and the point of that is that it works against the extension points as they are. scripts/check-core-untouched.sh holds the line: a pull request that touches plugins/ may not also change internal/, cmd/ or web/src. It runs as the core untouched by plugins CI leg on every pull request.

Core work on its own is unaffected — the rule only fires on the combination. If a ceremony plugin genuinely needs the host to change, that is a finding about the extension points: land the core change as its own pull request first, then rebase the plugin onto it. Folding it in quietly is exactly what the check exists to prevent, because a plugin system that keeps needing core commits is a plugin system that does not work yet.

The kinds a plugin provides are declared on the uploaded package as kinds (kind, display, actions). The install route persists them on first install and on upgrade; a name or verb the host will not accept is 400 and writes nothing.

An offered plugin kind is not a KindDef in web/src/lib/kinds.ts. The envelope names the install (plugin.name, plugin.version, plugin.grants) and SessionPage frames it in the full-room slot with the same sandboxed iframe and MessageChannel a nested poker panel uses. Nested panels stay h-64 inside Poker; a plugin-owned ceremony fills the room chrome. Toolbar, nav and export-menu slots are the same iframe in host chrome: a plugin that does not declare a slot is not drawn there. kindUnavailable is still asked first: a disabled plugin is a switched-off ceremony, not an unknown kind.

The marketing and README screenshots live in site/src/assets/ and docs/, and site/src/assets/screenshots.json records, per asset, the commit it was last shot against (shot_at) and the web/src files whose UI it depicts (depicts). Nothing else ties the two together, which is how the shipped set once ended up several releases behind: four features shipped, every pull request was green, and none had any reason to open the assets directory.

scripts/check-screenshot-freshness.sh reads that manifest and counts, per asset, the commits since its stamp that touched what it depicts. CI runs it on any pull request touching web/src and writes the result to the job summary. It is advisory and cannot fail a build — a screenshot lagging the UI is not a reason to block a code change, and a count of one commit is usually noise. A count that keeps climbing is not.

The one thing it does fail on is a manifest it cannot read: an unparseable file or a missing .assets array exits nonzero rather than reporting an all-clear, because a report that says the set is fresh has to mean it looked. An asset with an empty depicts is called out for the same reason — nothing could ever mark it stale — and so is a depicts path that no longer exists at HEAD, which would otherwise read as fresh forever the moment somebody renamed or deleted the component.

depicts entries are matched literally, not as globs: web/src/pages/*.tsx is reported as a path that does not exist rather than quietly standing in for a whole directory, so what the report prints is what it matched. List the files.

When you re-shoot, update shot_at for the assets you replaced to the commit the new frames land in, and add a depicts entry for any new surface a shot now covers. A new screenshot belongs in the manifest in the same pull request that adds it.

Use Discussions for support and designs that are not yet settled. Issues track accepted, actionable work. The repository’s CONTRIBUTING.md, GOVERNANCE.md, SUPPORT.md, and CODE_OF_CONDUCT.md contain the full community policy.

A few things that make review quick:

  • Open an issue before a large change, so nobody builds the wrong thing twice.
  • go test -p 1 ./... and npm run lint pass.
  • A behaviour change comes with a test that fails without it.
  • Migrations are additive and numbered; never edit one that has shipped.
  • Every commit includes a Developer Certificate of Origin sign-off. Use git commit -s; CI checks each pull-request commit.