Debugging & inspection
The dashboard's Workflows panel, inspect endpoints, CLI controls, workflow tables, and testing helpers.
Voltro records workflow runs, step attempts, and lifecycle events into framework tables. The dashboard, CLI, and inspect endpoints all read the same data.
What is recorded
| Table | Contents |
|---|---|
_voltro_workflow_start_contexts |
One row per started execution id: starter subject, trace id, source, parent execution id, parent-close policy, and creation time. Used for cross-runner context handoff. |
_voltro_workflow_runs |
One row per run: id, tag, executionId, status, payload, workflowVersion, workflowPatches, output, error fields, subject, start source, timing, trace id, parent execution id, parent-close policy. |
_voltro_workflow_run_steps |
One row per step attempt: step name, attempt number, recorded input, retry metadata, output or error, duration. |
_voltro_workflow_run_events |
Lifecycle events: run-started, run-succeeded, run-failed, run-suspended, run-resumed, run-cancelled, timer-set, timer-fired, signal-awaited, signal-sent, signal-received, update-requested, update-received, update-completed, update-failed. |
_voltro_workflow_events |
Domain events emitted through ctx.events.emit(...): event id, name, payload, source, subject, trace id, occurred time. |
_voltro_workflow_event_deliveries |
One row per workflow trigger delivery: event id, trigger id, workflow name, execution id, idempotency key, status, error. |
Run status is running, succeeded, failed, cancelled, or suspended.
Dashboard
When voltro dev is running, the Workflows panel lists recent runs, their status, start source, timing, payload, output/error, step attempts, and events. The run detail view is the fastest way to answer:
- Which step is currently running?
- Was the run started by an RPC,
ctx.workflows, a schedule, an incoming webhook, or inspect tooling? - Is this run a child, and will it be cancelled, terminated, or abandoned when the parent closes?
- Which attempt failed?
- What input did the step receive?
- What output or typed error did it produce?
- Did a timer or signal fire?
The panel also exposes run controls for users with the right capability: cancel, retry, suspend, resume, send signal, and send a tracked update.
Run filters can be saved as named views. The selected view and ad-hoc
filters are mirrored into the URL query string, so a teammate can open the
same filtered run list. The Workflows panel also includes dedicated
Incoming and Flow tabs: Incoming groups runs whose source is
incoming:<id> beside incoming-sourced domain events, while Flow groups
queued/running/waiting work by workflow lane and start source.
CLI
voltro workflows list --status running --tail 50
voltro workflows list --tag notes.summarise --format json
voltro workflows start notes.summarise --payload '{"noteId":"note_123"}'
voltro workflows show wfrun_01H...
voltro workflows retry wfrun_01H...
voltro workflows cancel wfrun_01H...
voltro workflows suspend wfrun_01H...
voltro workflows resume wfrun_01H...
voltro workflows signal wfrun_01H... --name approval --payload '{"approved":true}'
voltro workflows update wfrun_01H... --name approve --payload '{"decision":true}'
voltro workflows children exec_01H...The CLI discovers live API processes the same way as voltro logs and voltro traces, then calls inspect endpoints.
Inspect endpoints
GET /_voltro/inspect/workflows/runs
GET /_voltro/inspect/workflows/runs?tag=notes.summarise&status=failed
GET /_voltro/inspect/workflows/runs/:id/steps
GET /_voltro/inspect/workflows/runs/:id/events
GET /_voltro/inspect/workflows/events
GET /_voltro/inspect/workflows/events/:id/deliveries
GET /_voltro/inspect/workflows/children?parentExecutionId=exec_01H...
POST /_voltro/inspect/workflows/runs/:id/cancel
POST /_voltro/inspect/workflows/runs/:id/retry
POST /_voltro/inspect/workflows/runs/:id/suspend
POST /_voltro/inspect/workflows/runs/:id/resume
POST /_voltro/inspect/workflows/runs/:id/signal
POST /_voltro/inspect/workflows/runs/:id/updateSignal body:
{
"signalName": "approval",
"payload": { "approved": true }
}Update body:
{
"updateName": "approve",
"payload": { "decision": true },
"timeoutMs": 30000
}Retry normally uses the original payload. The inspect handler also supports payload override for operator tooling.
SQL inspection
Recent failed runs:
SELECT id, tag, status, source, "errorTag", "errorMessage", "startedAt", "completedAt"
FROM "_voltro_workflow_runs"
WHERE status = 'failed'
ORDER BY "startedAt" DESC
LIMIT 50;Slow steps:
SELECT "stepName", percentile_cont(0.95) WITHIN GROUP (ORDER BY "durationMs")
FROM "_voltro_workflow_run_steps"
WHERE status = 'succeeded'
AND "startedAt" > now() - interval '24 hours'
GROUP BY "stepName"
ORDER BY 2 DESC
LIMIT 10;Events for one run:
SELECT "eventType", payload, "occurredAt"
FROM "_voltro_workflow_run_events"
WHERE "runId" = 'wfrun_01H...'
ORDER BY "occurredAt" ASC;Inspecting from code
inspectWorkflow(idOrExecutionId, store) assembles one run into the same shape used by the dashboard detail view:
import { inspectWorkflow } from '@voltro/workflow'
const state = await inspectWorkflow(runId, ctx.store)It groups step attempts by step name, so a step that failed twice and succeeded on attempt three reports attempts: 3 with the latest output/error surfaced.
Testing workflows
Use makeWorkflowRunner from @voltro/testing to run a workflow in-process over the in-memory workflow engine and an in-memory recorder:
import { makeTestContext, makeWorkflowRunner } from '@voltro/testing'
import { SummariseNote } from '../workflows/notes.summarise.workflow'
import buildSummariseNote from '../workflows/notes.summarise.workflow'
test('notes.summarise retries the LLM step', async () => {
const ctx = makeTestContext()
const runner = makeWorkflowRunner({
ctx,
workflows: [{
workflow: SummariseNote as never,
execute: buildSummariseNote(ctx) as never,
}],
})
const result = await runner.start('notes.summarise', { noteId: 'n_1' })
expect(result.status).toBe('succeeded')
expect(result.steps.find((s) => s.name === 'summarise-with-llm')?.attempts).toBe(3)
})The result includes { status, output, error, steps, runId }. runner.inspect(runId) returns the assembled run later without re-running it.
A start with the wrong payload
ctx.workflows.start(name, payload) validates the payload against the workflow's
payload schema before the engine sees it. A mismatch throws a
WorkflowPayloadError naming three things:
Workflow "sprint.report" was started with an invalid payload. missing required
field(s): teamId. { readonly teamId: string } └─ ["teamId"] is missingThe error also carries them structurally — workflowName, missingFields,
_tag: 'WorkflowPayloadError' — so a handler can branch on it.
This matters most where nobody is watching. A cron whose payload drifted from
the workflow's schema fails on every single firing; the schedule run is recorded
failed in _voltro_schedule_runs and the log line is an error, not a warn,
so voltro logs --level error and any alert wired to it see it. A nightly job
that has been dead since a refactor is the exact failure this pair of behaviours
exists to surface.
An unknown workflow name lists the registered ones, so a rename reads differently from a deletion:
Unknown workflow: sprint.reports. Registered workflows: billing.run, sprint.report.Validation runs on the decoded value, so a Schema.Date payload accepts a
Date — passing the already-domain-shaped value is correct and is not rejected.
Anti-patterns
- Querying old table names. Use
_voltro_workflow_runs,_voltro_workflow_run_steps, and_voltro_workflow_run_events. - Filtering for
status=dead. Current failed runs usestatus=failed. - Expecting replay-from-step APIs. Current operator retry starts a new execution from the workflow payload; completed steps are not selectively replayed through a public API.
- Logging giant step inputs/outputs. Step input/output is persisted for inspection. Store references to huge blobs instead of the blob itself.