Inspect & test

The HTTP inspect surface, the dashboard, voltro logs / voltro traces, voltro test, voltro e2e — the debugging + harness tools.

When something's wrong, these are the tools. Live inspection of a running app happens over an HTTP surface (and the dashboard that consumes it), not a dedicated CLI verb. The shell-facing debugging commands are voltro logs and voltro traces; the harness commands are voltro test and voltro e2e.

voltro inspect

voltro inspect <subcommand> is the ergonomic CLI wrapper over the HTTP surface below. It discovers every running api via ~/.voltro/runtime-registry.json, fans the matching GET/POST out to each, and renders the merged result — no curl + jq needed.

voltro inspect app                              # manifest meta (kind, name, store, …)
voltro inspect routes                           # web page tree (web apps only)
voltro inspect rpc                              # procedures + workflow descriptors (api only)
voltro inspect metrics                          # rolling rpc latency buckets
voltro inspect cache                            # web data-cache stats (web apps only)
voltro inspect schedules                        # cron registrations + coordination mode
voltro inspect schedules --failing              # only broken crons — exits 1 if any (see below)
voltro inspect aggregates                       # materialised aggregate views
voltro inspect invoke --tag users.list --input '{}'   # call a procedure over HTTP

Flags on every subcommand: --process <name> narrows to one api; --format pretty|json (default pretty). invoke additionally takes --tag <procedureTag> and --input <json>. With no subcommand it prints the endpoint map + the live processes it can reach.

schedules --failing — is any cron actually broken?

A schedule fires unattended: there is no user watching it fail. The plain schedules listing answers which crons exist and when they fire next — never whether they work. --failing rolls each schedule's recent runs (/_voltro/inspect/schedules/runs) into a verdict and prints only the broken ones:

$ voltro inspect schedules --failing
# schedules @api
  sprint.report  0 2 * * *  FAILING x87
    every recorded run failed (last 20)
    Workflow "sprint.report" was started with an invalid payload. missing required field(s): teamId

It exits 1 when anything is failing, so it works as a post-deploy gate and not only as something someone remembers to run:

voltro inspect schedules --failing || echo "broken cron — do not promote"

A trailing success ends a streak (a recovered job is not reported), and skipped / missed runs are ignored — those are coordination outcomes (another pod took the tick, the process was down), not handler verdicts. A schedule that has never run is not "failing".

Pair it with voltro logs --level error: a failing handler now logs at error level, so the two surfaces agree.

Targeting a deployed app

Every command in this family resolves its target from the local runtime registry — the apps running on this machine. Pass --url to point one at a deployed app instead:

voltro inspect app     --url https://api.example.com --token "$TOKEN"
voltro logs   --tail 100 --url https://api.example.com
voltro traces --errors   --url https://api.example.com
voltro check             --url https://api.example.com

--token (or VOLTRO_INSPECT_TOKEN) supplies the bearer; VOLTRO_INSPECT_URL sets a default target so you can drop the flag. Works for inspect, logs, traces, workflows, cluster and check.

Securing the local surface

voltro dev mints a per-project VOLTRO_INSPECT_TOKEN into .env.local, so the inspect surface is authenticated from the first boot — the dev server listens on every interface, and without a token anyone on the same network could read your rows, schema and logs. You don't have to wire it anywhere: the CLI picks the token up from the runtime registry (so the commands work from any directory), and the dashboard's server-side proxy supplies it for same-machine targets. Setting VOLTRO_INSPECT_TOKEN yourself always wins.

The inspect HTTP surface

Every voltro dev / voltro start instance exposes a read-only introspection surface under /_voltro/inspect/*. The Voltro Dashboard consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with curl (the voltro inspect subcommands above are the thin wrapper over exactly these).

PORT=4000   # from the app's app.config.ts

curl -s localhost:$PORT/_voltro/inspect/routes  | jq    # web: page tree + render-mode flags
curl -s localhost:$PORT/_voltro/inspect/rpc     | jq    # api: every query / mutation / action / workflow
curl -s localhost:$PORT/_voltro/inspect/metrics | jq    # rolling per-tag latency + invocation count

There is no /_voltro/inspect/queries endpoint. The registered GET surface is app, routes (web-only), cache (web-only), rpc (api-only), metrics, and subscriptions (api-only) — rpc is the procedure list, routes is the web page tree.

What the surface reads:

  • The endpoints serve in-process state from the running instance — no separate daemon.
  • The default target is http://localhost:<port> based on the cwd's app.config.ts.
  • GET responses are JSON; pipe into jq.

Routes & RPC

curl -s localhost:$PORT/_voltro/inspect/routes | jq    # web: page tree + render-mode flags
curl -s localhost:$PORT/_voltro/inspect/rpc    | jq    # api: query / mutation / action / workflow list

Subscriptions panel

curl -s localhost:$PORT/_voltro/inspect/subscriptions | jq

Shows every active subscriber with:

  • Query name + input
  • Subject (who's subscribed)
  • Read set (which tables / rows are tracked)
  • Frame buffer depth (backpressure indicator)
  • Connection age

For "why isn't this updating?" — check the read set. If your mutation writes to a table not in the read set, the subscription doesn't invalidate.

Workflow runs — voltro workflows

voltro workflows is the primary surface for inspecting + operating runs:

voltro workflows list                          # recent runs
voltro workflows show <runId>                  # one run's steps + events
voltro workflows retry <runId>
voltro workflows cancel <runId>
voltro workflows suspend <runId>
voltro workflows resume <runId>
voltro workflows signal <runId> --name approval        # inject a named signal
voltro workflows update <runId> --name
voltro workflows children <parentExecutionId>

Underneath, workflow state lives in the _voltro_workflow_runs + _voltro_workflow_run_steps tables and is surfaced live by the dashboard's Workflows panel. The same data is reachable over HTTP:

curl -s localhost:$PORT/_voltro/inspect/workflows/runs | jq            # recent runs
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/steps" | jq   # step-by-step
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/events" | jq  # the run's event log

Each run row carries ID, name, status (running / succeeded / failed / dead), step count, last completed step, and duration. The per-run action endpoint matches …/workflows/runs/<runId>/<action> for cancel / retry / suspend / resume / signal — the voltro workflows subcommands and the dashboard's run-detail buttons both POST to these:

curl -s -X POST localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/retry
curl -s -X POST localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/cancel

For dead-letter triage, filter runs by status, or open the dashboard's Workflows tab. See Workflows / Debugging.

voltro logs

The fastest way to see what a running instance is doing. Buffers the last 2000 server-side log records plus every browser console line the dev console bridge forwarded.

voltro logs                                # last 100 from every running process
voltro logs --tail 50 --level error        # only errors, last 50
voltro logs --since 30s                    # last 30 seconds
voltro logs --filter 'notes.summarise'     # message substring
voltro logs --trace <traceId>              # the WHOLE causal chain for one request
voltro logs --format json | jq '.records'  # machine-parseable

Run this BEFORE grepping source — the buffer carries the real error, stack, and rpc tag. The full flag set (--scope, --source, --process, --no-color, …) is documented in Traces & logs from the shell — the canonical reference for both commands.

voltro traces

Mirror of voltro logs for the distributed-trace buffer.

voltro traces                              # 20 newest traces, pretty
voltro traces --errors                     # only traces containing an errored span
voltro traces --id <traceId>               # one trace, span waterfall
voltro traces --errors --format json | jq '.traces[]'

Workflow: voltro traces --errors --format json to find a failure, then voltro logs --trace <id> --format json for the full chain (frontend → api → api, in order). The full flag set (--min-duration, --status, --process, …) lives in Traces & logs from the shell.

voltro cluster

voltro cluster status gives a clustering snapshot of every running api — one row per instance with its replicaId, runner address, dialect, CDC flavour, coordination mode, and server_id, plus a flag for any SQL runner stuck on localhost (a common misconfiguration that silently breaks cross-instance work). Use it to confirm a multi-instance deployment actually formed a cluster rather than N isolated nodes.

voltro cluster status                 # pretty table across every running api
voltro cluster status --json          # machine-readable (also --format json)
voltro cluster status --process api   # narrow to one named process

Like the rest of the inspect family it reads the live /_voltro/inspect/* surface, so an api has to be running.

voltro test

voltro test
voltro test path/to/file.test.ts
voltro test --filter notes

Runs Vitest with the framework's preset:

  • Auto-loaded global setup (test context, mock providers).
  • STORE=memory by default for fast isolation.
  • Plays well with @voltro/testing helpers (mock stores).

Every vitest flag is forwarded

Anything the command does not interpret itself goes straight to vitest — parsed by vitest's own CLI parser, not a list this wrapper maintains:

voltro test --coverage
voltro test --reporter=junit --outputFile=reports/junit.xml
voltro test --coverage --reporter=junit --outputFile=reports/junit.xml

That covers coverage numbers and a JUnit report for a merge-request widget, which is what most pipelines want beyond the exit code.

The framework keeps three decisions for itself and they win over a forwarded flag: the root (a positional that is an existing directory, which vitest would otherwise read as a filter), --watch, and passWithNoTests — an explicit filter that matches no file is an error, which vitest cannot decide because it does not know which positional was treated as a root.

An unrecognised flag is ignored rather than fatal, and a flag vitest cannot parse at all degrades to "run without the extra flags" with a warning instead of taking the run down.

The actual test runner is Vitest; this command is a thin wrapper that injects the framework's config. You can run vitest directly if you prefer:

pnpm vitest

voltro e2e

voltro e2e            # current dir
voltro e2e apps/web   # explicit web app directory

voltro e2e takes only an optional path to the web app; it parses no other flags. Boots:

  1. voltro dev for the api app.
  2. voltro dev for the web app.
  3. Playwright runs *.e2e.ts files.
  4. Tear down: stops the boot processes.

The e2e files use Playwright's API directly:

// apps/web/tests/signup.e2e.ts
import { test, expect } from '@playwright/test'

test('signup creates a user', async ({ page }) => {
  await page.goto('/signup')
  await page.fill('[name=email]', 'a@b.com')
  await page.fill('[name=password]', 'correct horse battery staple')
  await page.click('button[type=submit]')
  await expect(page).toHaveURL(/\/dashboard/)
})

The framework's voltro e2e handles the lifecycle for you: it boots the api + web siblings, runs Playwright against them, and tears the processes down on exit. Per-test isolation, fixtures, reporters, and sharding are configured in your Playwright config — voltro e2e itself forwards no flags to Playwright.

Securing the inspect surface

The surface is fail-closed: with no VOLTRO_INSPECT_TOKEN configured, every endpoint answers 401. The absence of a secret is not consent — a check you were configured not to perform is a refusal, not a pass.

That is why voltro dev mints one for you (above) and why nothing mints outside dev: in production a missing secret must stay a boot-time decision rather than an invented value. So a production voltro start serves nothing here until you set the token yourself:

VOLTRO_INSPECT_TOKEN=$(openssl rand -hex 32)
voltro start apps/api

Then every request must carry the token:

curl -s -H "Authorization: Bearer $VOLTRO_INSPECT_TOKEN" \
  localhost:4000/_voltro/inspect/rpc | jq

Or disable the surface entirely: VOLTRO_INSPECT=off. The dashboard then can't introspect the production instance — that's intentional.

Anti-patterns

  • Assuming the surface is open because you didn't configure it. It is the reverse: no token means 401, not "everyone". If a production dashboard suddenly stops introspecting, the missing VOLTRO_INSPECT_TOKEN is the first thing to check — not a network problem.
  • voltro test against STORE=postgres by default. Slower + flaky (test isolation harder). Use postgres only for integration tests that NEED it.
  • Skipping voltro e2e because "it's slow". It catches integration bugs that unit tests miss. Run it in CI on every PR; locally for changes that touch queries.

See also