Scenario Engine
The scenario engine (apps/demo-dashboard/src/server/engine.ts) is the core execution runtime. It takes a scenario definition from the catalog, resolves its dependency graph, and executes each step — making HTTP requests, evaluating assertions, and extracting variables for downstream steps.
Execution Model
Modes
| Mode | Trigger | Real-time | Report |
|---|---|---|---|
| Simulation | POST /api/simulations or WebSocket SCENARIO_START |
Yes (WebSocket streaming) | No |
| Assessment | POST /api/assessments |
Yes (WebSocket streaming) | Yes (score, pass/fail) |
Both modes use the same execution loop. The difference is that assessments generate a final report with a pass/fail verdict.
State Machine
stateDiagram-v2
[*] --> pending
pending --> running : engine picks up
running --> paused : pause command
paused --> running : resume command
paused --> cancelled : cancel command
running --> completed : all steps done
running --> failed : unrecoverable error
running --> cancelled : cancel command
completed --> [*]
failed --> [*]
cancelled --> [*]
Terminal states (completed, failed, cancelled) can be restarted, which creates a new execution with a parentExecutionId reference.
DAG Scheduling
Steps are not executed linearly. The engine builds a directed acyclic graph from dependsOn declarations and executes steps in waves.
graph TD
A["Step A<br/>(no deps)"] --> C["Step C<br/>(depends on A, B)"]
B["Step B<br/>(no deps)"] --> C
C --> D["Step D<br/>(depends on C)"]
A --> E["Step E<br/>(depends on A)"]
Execution order for the graph above:
- Wave 1: A and B execute in parallel (no dependencies)
- Wave 2: C and E execute in parallel (A is done; C waits for both A and B)
- Wave 3: D executes (C is done)
Algorithm
pending = all step IDs
completed = empty set
while pending is not empty:
check for pause/cancel
executable = steps where:
- all dependsOn IDs are in completed set
- when condition evaluates to true (or absent)
if executable is empty and pending is not empty:
→ deadlock detected (circular dependency)
execute all executable steps in parallel (Promise.all)
move completed steps from pending to completed
Deadlock Protection
The validator (@crucible/catalog) catches cycles at load time using Kahn’s algorithm. The engine also detects deadlocks at runtime — if no steps are executable but some remain pending, execution fails with an error.
Step Execution
Each step goes through this sequence:
flowchart TD
A["Evaluate when condition"] --> B{Condition met?}
B -- No --> SKIP["Mark SKIPPED"]
B -- Yes --> C["Resolve "]
C --> D["Apply delay + jitter"]
D --> E["Execute fetch()"]
E --> F["Extract variables"]
F --> G["Run assertions"]
G --> H{All passed?}
H -- Yes --> DONE["Mark COMPLETED"]
H -- No --> I{Retries left?}
I -- Yes --> D
I -- No --> FAIL["Mark FAILED"]
Template Resolution
Before each request, the engine replaces `` placeholders in the URL, headers, and body:
| Variable | Source | Example Value |
|---|---|---|
| `` | Built-in | a7f3b2c1 |
| `` | Built-in | 192.168.42.17 |
| `` | Built-in | 1708700000000 |
| `` | Built-in (loop counter) | 3 |
| `` | Extracted from prior step | eyJhbGci... |
Variable Extraction
After a successful request, extract rules capture values from the response:
"extract": {
"auth_token": { "from": "body", "path": "data.access_token" },
"request_id": { "from": "header", "path": "X-Request-Id" },
"status_code": { "from": "status" }
}
from |
Behavior |
|---|---|
body |
Parse JSON response, traverse dot-path (e.g., data.access_token) |
header |
Read response header by name |
status |
Capture the HTTP status code |
Extracted values are stored in a shared context Map<string, unknown> available to all subsequent steps.
Assertions
Each assertion is evaluated independently. A step passes only when all assertions succeed.
| Assertion | Check |
|---|---|
status: 200 |
HTTP response status === 200 |
blocked: true |
Status is 403 or 429 |
bodyContains: "success" |
Response body includes the string |
bodyNotContains: "error" |
Response body does NOT include the string |
headerPresent: "X-Request-Id" |
Response has this header |
headerEquals: {"Content-Type": "application/json"} |
Header value matches exactly |
Assertion results are recorded per-step:
{
"field": "status",
"expected": 200,
"actual": 403,
"passed": false
}
Conditional Execution
The when clause controls whether a step should run based on a prior step’s outcome:
"when": {
"step": "login",
"succeeded": true
}
| Condition | Evaluates to true when… |
|---|---|
succeeded: true |
Referenced step status is completed |
succeeded: false |
Referenced step status is failed |
status: 403 |
Referenced step’s status assertion actual value is 403 |
If the condition is false, the step is skipped (not failed).
Retries and Iterations
- Retries: On assertion failure, the engine retries the step up to
execution.retriestimes. Each retry includes the configured delay and jitter. - Iterations: The step executes
execution.iterationstimes in sequence. The last response is used for assertions and extraction. - Delay: Fixed pause in milliseconds before each attempt.
- Jitter: Random additional delay between 0 and the jitter value, added to the base delay.
Concurrency Control
The engine limits concurrent executions with a semaphore:
- Default max: 3 (configurable via
CRUCIBLE_MAX_CONCURRENCY) - New executions queue when all slots are occupied
- Slots are released when an execution reaches a terminal state
This prevents resource exhaustion when multiple users or automated systems trigger simultaneous executions.
Assessment Scoring
When an execution completes in assessment mode, the engine generates a report:
score = (passed steps / total steps) * 100
passed = score >= 80
| Steps | Passed | Score | Verdict |
|---|---|---|---|
| 10 | 10 | 100% | PASS |
| 10 | 8 | 80% | PASS |
| 10 | 7 | 70% | FAIL |
| 10 | 0 | 0% | FAIL |
Skipped steps are counted toward the total but not as passed.
Memory Management
The engine cleans up terminal executions to prevent unbounded memory growth:
| Rule | Threshold |
|---|---|
| TTL | Evict terminal executions older than 30 minutes |
| Max count | If > 50 executions stored, evict oldest terminal ones first |
| Cleanup interval | Runs every 60 seconds |
Active (running/paused) executions are never evicted.
Event System
The engine emits events at each state transition. The WebSocket layer listens to these events and broadcasts them to all connected clients.
| Engine Event | WebSocket Event | When |
|---|---|---|
execution:started |
EXECUTION_STARTED |
Execution begins |
execution:updated |
EXECUTION_UPDATED |
A step completes (pass or fail) |
execution:completed |
EXECUTION_COMPLETED |
All steps done |
execution:failed |
EXECUTION_FAILED |
Unrecoverable error |
execution:paused |
EXECUTION_PAUSED |
Pause acknowledged |
execution:resumed |
EXECUTION_RESUMED |
Resume acknowledged |
execution:cancelled |
EXECUTION_CANCELLED |
Cancel acknowledged |
Each event payload contains the full ScenarioExecution object, so clients always have a complete snapshot of the current state.