#Runs
A run is one execution of a scan against a project's repos. The
dispatcher mints a run id, fans out the static pass per repo on a
rayon pool, aggregates the results into a RunBundle, and writes a
row into the runs SQLite table. Every event the run produces
carries the run id so subscribers can group across the bus and the
DB without a side lookup.
Source: crates/nyx-agent-core/src/run/mod.rs,
crates/nyx-agent-core/src/store/run.rs,
crates/nyx-agent/src/main.rs (the binary's scan subcommand).
#Run id
run-<unix-ms-as-13-hex>-<counter-as-8-hex>
The counter is a process-local AtomicU64 bumped on every mint, so
two runs started inside the same millisecond still produce
different ids. Run ids sort lexicographically when minted from the
same process: the millisecond prefix dominates and the counter
breaks ties in order.
The minter lives at crates/nyx-agent-core/src/run/mod.rs:494.
Tests pin both the uniqueness and the monotonicity guarantee.
The id is intentionally not folded into the finding-id hash; see findings stability below.
#Lifecycle
A run walks one project at a time. scan --project p
(or scan against a single configured project) hands the
dispatcher:
- the resolved
Project, - a fresh
Run(Run::new()), - an
Arc<dyn ScanLane<Diag>>(the production lane wrapsnyx_agent_nyx::NyxRunner, which shells out tonyx scan), - a
Vec<WorkspaceHandle>, one per enabled repo.
RunDispatcher::dispatch_project runs synchronously on a
tokio::task::spawn_blocking worker. Inside, it:
- Emits
RunStartedthenProjectStartedon the event bus. - Builds a fresh rayon
ThreadPoolBuilder::new().num_threads(N)pool whereNis the resolved static-pass fan-out (see concurrency). - Maps every workspace into the pool with
into_par_iter().map(run_one_repo). Eachrun_one_repocall emitsRepoStarted, calls the lane'sscan_blocking, emitsRepoStaticDoneorRepoFailed, and always emitsRepoFinishedregardless of outcome. - Collects the per-repo outcomes into a
Vec<RepoBundle<D>>. - Builds a
CrossRepoCallgraphStublisting the repos that succeeded (edges are reserved for the cross-repo chain runner and stay empty today). - Emits
ProjectFinishedthenRunFinishedwith the per-tag counts and wall-clock duration.
After dispatch returns, the scan subcommand persists every
RepoOutcome::Success diag through persist_run_results
(crates/nyx-agent/src/main.rs:1243), then calls finalise_run
to update the runs row's status, finished-at, and wall clock.
Lane errors are recoverable: a panicking rayon worker or a sqlx
failure between dispatch and finalise still flips the runs row
off Running. The runs row is never left wedged at Running
across process restart by the dispatch path.
#Concurrency
Default fan-out is min(num_cpus / 2, repo_count), floored at 1.
available_parallelism failures fall back to 2 cores.
[performance]
# Override the per-run static-pass fan-out. Omit to let the
# dispatcher derive from CPU + repo count.
static_concurrency = 4
# Per-repo budget for the static pass. Default 1800 (30 minutes).
per_repo_timeout_secs = 600
A repo that exceeds its budget is recorded as
Inconclusive(StaticPassTimeout) and its RepoFailed event names
static-pass timeout after Ns. The slow repo never blocks the
rest of the run.
Configured static_concurrency = 0 is floored to 1 by both the
config layer and the dispatcher. See
docs/config.md for the full [performance] block.
The resolver lives at
crates/nyx-agent-core/src/run/mod.rs:475.
#Per-repo outcomes
pub enum RepoOutcome<D> {
Success(Vec<D>),
Inconclusive(InconclusiveReason),
Failed(String),
}
The only InconclusiveReason variant today is StaticPassTimeout;
the enum is shaped so the chain runner and sandbox lanes can add
their own variants without breaking serialised bundles.
The compressed RepoOutcomeTag (Success / Inconclusive /
Failed) rides on the RepoFinished event so a UI badge can
colour the row without deserialising the full bundle.
Counts roll up via RunBundle::counts() into a RunCounts
struct (succeeded, inconclusive, failed). The
RunFinished event carries these three numbers.
#Events
The dispatcher publishes through the shared
broadcast::Sender<AgentEvent> (crates/nyx-agent-types/src/event.rs).
Order is fixed:
RunStarted
ProjectStarted
RepoStarted (per repo, in pool order)
RepoStaticDone | RepoFailed
RepoFinished
ProjectFinished
RunFinished
Per-repo events always carry the project_id so the WebSocket
client can group by project without a GET /api/v1/repos/:name
side trip. A closed bus (no subscribers) is fine: the send error
is dropped and the static pass keeps running. WebSocket
subscribers attach lazily through
docs/api.md; the bus is created up
front with broadcast::channel(N) so an early subscriber sees
the very first RunStarted.
RepoDynamicDone is reserved for the sandbox publisher: the
static-pass dispatcher does not emit it. The variant lives in
RunEvent so the sandbox crate can publish on the same bus
without changing the event enum's shape.
#Persistence
Two tables touch each run:
| Table | Written by |
|---|---|
runs |
finalise_run (status, finished_at, wall_clock_ms) |
findings |
persist_run_results (one row per static-pass diag) |
business_logic_template_runs |
Business-logic template synthesis counts and skip reasons. |
route_models |
Semantic App Model v2 route discovery output for the run. |
verification_attempts |
Live HTTP/browser verifier rows. Browser attempts attach replay artifact paths. |
authz_matrix_entries |
Expected-vs-observed authorization decisions from role comparison and object ownership verification. |
verified_vulnerabilities |
User-facing confirmed vulnerabilities promoted from successful live attempts. |
exploration_memory |
Durable lessons from live verification, AI exploration audit, NoPlan outcomes, and rejected confirmations. |
attack_graph_nodes, attack_graph_edges |
Store dual-writes for route models, signals, candidates, verification attempts, authorization matrix entries, verified vulnerabilities, and chains. |
The runs row schema (see
crates/nyx-agent-core/src/store/run.rs:50):
| Column | Notes |
|---|---|
id |
The minted run id. |
started_at |
epoch ms. |
finished_at |
epoch ms, NULL while running. |
status |
Pending / Running / Succeeded / Failed / Halted. |
triggered_by |
Manual / Cron / Webhook / PR / UI. |
git_ref |
Optional git ref the scan ran against. |
parent_run_id |
Optional pointer to a prior run. |
wall_clock_ms |
Dispatcher wall clock. |
total_ai_spend_usd_micros |
Bumped by the AI runtime, not the dispatcher. |
RunStore::list_by_status("Running") is what
GET /api/v1/runs?status=Running reads; default with no query
string is Running. The endpoint also accepts project_id to keep
run lists scoped to one project. The full record shape is what
GET /api/v1/runs/:id returns. See
docs/api.md.
Graph rows are derivative and run-scoped. They let later consumers
walk from a verified vulnerability back to the evidence that produced
it, or from a route/object/role to the verified vulnerabilities that
touch it, without changing the existing finding and report shapes. See
attack-graph.md.
Authorization matrix rows are also run-scoped. For every successful
role-comparison or object-ownership verification attempt, the verifier
stores the allowed control and challenged access as separate rows. Each
row captures role, optional tenant, resource/object id, owner role,
action, endpoint, expected decision, observed decision, HTTP status,
body-marker result, confidence, candidate id, verification attempt id,
and evidence JSON. GET /api/v1/runs/:id/authz-matrix returns these
rows for the run detail UI.
route_models.model_blob stores the Semantic App Model v2 JSON. Backend
endpoints retain the original route fields (method, path, params,
middleware, auth_checks, role_checks, body_fields,
state_changing, confidence, and evidence) and add semantic fields:
framework, handler_name, query_params, request_fields,
response_hints, service_calls, model_names, resource_names,
tenant_fields, owner_fields, and side_effects. The extractor has
framework-aware paths for Express/Nest-style TypeScript and JavaScript,
FastAPI/Flask-style Python decorators, Rails and Laravel declarations,
OpenAPI/Swagger specs, plus regex fallbacks for older route-like code.
Browser verification attempts persist replay evidence under
<state>/traces/<run-id>/browser_verification/<attempt-id>/ and attach
those paths to the attempt row. Reports and the SPA surface those paths
through the vulnerability's verification_attempt_ids, so a human can
inspect screenshots, redacted DOM/console/timeline captures, and the
deterministic replay JSON/script for the proof.
exploration_memory is not a JSON log. Each row is queryable by
project, repo, run, result, and dedupe key. It stores the attempted
hypothesis, endpoint, role/object context, confirmed/rejected/
inconclusive/blocked result, reason, useful markers, auth/session
notes, follow-up ideas, and optional links to the candidate,
verification attempt, or AI trace that produced the lesson. Future AI
exploration loads the most relevant rows for the repo, compacts them
into the prompt as "learned from prior runs," and records that prompt
context on the Exploration trace. The SPA exposes the same rows on the
run detail view via GET /api/v1/runs/:id/exploration-memory.
#Stability across runs
Findings get stable, run-id-independent ids. Two runs over the same source tree produce the same finding ids, so the UI's last-seen / first-seen timestamps line up correctly and a finding that appears once stays correlated across re-scans.
The hash domain is (repo, path, Some(line), cap, rule). The run
id is intentionally not folded in. See
finding_id_hash at crates/nyx-agent-core/src/store/finding.rs:79
and the rerun_on_identical_sources_produces_identical_finding_ids
test in run/mod.rs:749.
#Workspaces and snapshots
Each repo lands inside the dispatcher as a WorkspaceHandle
(crates/nyx-agent-core/src/run/workspace.rs). The handle wraps
an Arc<IngestedRepo>, so clones are cheap and the snapshot
directory persists until the last clone drops. Production code
clones one handle into a name-keyed HashMap before dispatch so
the AI passes (payload synthesis, spec derivation, chain
reasoning) can read source after the dispatcher consumes the
original Vec. The dispatcher's bundle keeps only per-repo
names and outcomes; it does not extend snapshot lifetime.
Snapshot layout and cleanup live in
docs/state-dir.md. Repo ingestion details live
in docs/architecture.md.
#Triggers
Runs originate from one of five surfaces. The
triggered_by column records which:
Manual: operator rannyx-agent scanfrom the CLI.Cron: in-process scheduler fired a[[schedule]]entry. Seedocs/triggers/cron.md.Webhook:POST /webhook/gitwas verified. Seedocs/triggers/webhook.md.PR: the GitHub Action ran a scan against a pull request. Seedocs/ci/github-actions.md.UI: the SPA dispatched a scan through the API.
#Related
docs/cli.md#scan: every flag thescansubcommand accepts.docs/config.md: the full[performance]block.docs/api.md: the/runsroutes and the WebSocket event stream.docs/architecture.md: the broader dispatcher + AI-pass pipeline this page details.