diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 078d92fd..d323109a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -54,12 +54,40 @@ jobs:
with:
modules: bx-cli
+ # On its first run CommandBox installs its own modules (commandbox-cfconfig, commandbox-dotenv, the
+ # testbox commands and the packages they need) from ForgeBox. A failed download there (a timeout, a
+ # 502, a connection reset) does not fail the run, but it leaves modules missing or half-installed, and
+ # a later box command then fails with 'Command "testbox run ..." cannot be resolved'. So do that first
+ # run here, and accept it only when nothing failed: no failed install, no module that failed to load,
+ # and the testbox command resolves. Otherwise start over from a clean CommandBox home, with a backoff.
+ - name: Warm Up CommandBox
+ run: |
+ for attempt in 1 2 3 4 5; do
+ out=$( { box version; box testbox run help; } 2>&1 || true )
+ if ! grep -qE "× \||ERROR \(|cannot be resolved|failed to load" <<< "$out"; then
+ echo "CommandBox is ready (attempt $attempt)"
+ exit 0
+ fi
+ echo "$out" | grep -E "× \||ERROR \(|cannot be resolved|failed to load|ForgeBox returned|Request Timeout|Bad Gateway|Connection" || true
+ echo "::warning::CommandBox did not finish installing its modules (attempt $attempt), retrying from a clean CommandBox home"
+ rm -rf "$HOME/.CommandBox"
+ sleep $(( attempt * 15 ))
+ done
+ echo "::error::CommandBox could not install its modules after 5 attempts"
+ exit 1
+
+ # ForgeBox can time out (408) or fail (502) on any one download, so retry the installs a few times
- name: Install Dependencies
run: |
- box install
- cd test-harness
- box package set dependencies.coldbox=${{ matrix.coldboxVersion }}
- box install
+ for attempt in 1 2 3; do
+ if box install && ( cd test-harness && box package set dependencies.coldbox=${{ matrix.coldboxVersion }} && box install ); then
+ exit 0
+ fi
+ echo "::warning::Installing dependencies failed (attempt $attempt), retrying"
+ sleep $(( attempt * 20 ))
+ done
+ echo "::error::Installing dependencies failed after 3 attempts"
+ exit 1
- name: Start ${{ matrix.cfengine }} Server
run: |
diff --git a/build/video/Demo.bx b/build/video/Demo.bx
index d8e46515..766b8ab4 100644
--- a/build/video/Demo.bx
+++ b/build/video/Demo.bx
@@ -50,11 +50,12 @@ class {
return
}
variables.settings.rulebooks[ "fraudcheck" ] = {
+ description : "Screens an order for fraud before it is approved.",
source : [
- { "name" : "velocityCheck", "priority" : 30, "when" : { "predicate" : "tooManyOrders" }, "then" : [ { "action" : "flag" } ] },
- { "name" : "callFraudService", "priority" : 20, "then" : [ { "action" : "scoreWithFraudService" } ] },
- { "name" : "addressMismatch", "priority" : 10, "when" : { "neq" : [ "billingCountry", "US" ] }, "then" : [ { "action" : "flag" } ] },
- { "name" : "approveOrder", "priority" : 0, "then" : [ { "action" : "flag" } ] }
+ { "name" : "velocityCheck", "description" : "Flags a customer with too many recent orders", "priority" : 30, "when" : { "predicate" : "tooManyOrders" }, "then" : [ { "action" : "flag" } ] },
+ { "name" : "callFraudService", "description" : "Scores the order with the external fraud service", "priority" : 20, "then" : [ { "action" : "scoreWithFraudService" } ] },
+ { "name" : "addressMismatch", "description" : "Flags a billing country outside the US", "priority" : 10, "when" : { "neq" : [ "billingCountry", "US" ] }, "then" : [ { "action" : "flag" } ] },
+ { "name" : "approveOrder", "description" : "Approves the order", "priority" : 0, "then" : [ { "action" : "flag" } ] }
],
predicates : {
// Slow on purpose, so it tops the Slowest rules panel
diff --git a/build/video/README.md b/build/video/README.md
index 3d25c3f2..a3a90188 100644
--- a/build/video/README.md
+++ b/build/video/README.md
@@ -4,8 +4,8 @@ Tooling for `docs/assets/video/rulebox-visualizer-intro.mp4`, the one-minute tou
| File | Purpose |
| --- | --- |
-| `record.js` | Playwright script. Title card; Dashboard with its Problem and Slowest rules panels; the chain view of a failing rulebook; Dry Run; Metrics with rule health, sorting and a rule's errors and stack traces; the Live Tracker with traffic and an opened failure; outro and end screen. Serves CDN and font files from memory (fetched once with curl) so every page renders styled. Saves the raw `.webm`, a `poster.png` and `marks.json` (approximate scene start times) into `out/`. |
-| `Demo.bx` | Temporary handler that runs the harness JSON rulebooks with random facts, plus a `fraudcheck` rulebook it declares on the first run, with a slow rule and a rule that sometimes fails (a timeout caused by a socket error, or a bad response). The screens get rule health to show and the Live Tracker gets traffic. |
+| `record.js` | Playwright script. Title card; Dashboard with its Problem and Slowest rules panels; the chain view of a failing rulebook; a rulebook's description and declared facts; Dry Run with the facts form and an enforced-facts rejection; Metrics with rule health, sorting and a rule's errors and stack traces; the Live Tracker with traffic and an opened failure; outro and end screen. Serves CDN and font files from memory (fetched once with curl) so every page renders styled. Saves the raw `.webm`, a `poster.png` and `marks.json` (approximate scene start times) into `out/`. |
+| `Demo.bx` | Temporary handler that runs the harness JSON rulebooks with random facts, plus a described `fraudcheck` rulebook it declares on the first run, with a slow rule and a rule that sometimes fails (a timeout caused by a socket error, or a bad response). The screens get rule health to show and the Live Tracker gets traffic. |
| `edit.sh` | ffmpeg cut: trims, speeds the tour up 1.1x (`TOUR_SPEED`), crossfades, fades in and out, adds the music, encodes H.264 + AAC. |
| `music.py` | Original background track, synthesized with numpy (no samples, no third-party audio). `edit.sh` passes the length and the outro's start, so the breakdown lands on the outro card. |
| `*-icon-full.svg` | ColdBox and BoxLang marks for the end screen. The RuleBox icon on the cards comes from `docs/assets/brand/rulebox-icon-full.svg`. |
diff --git a/build/video/edit.sh b/build/video/edit.sh
index c1f3196b..03cc183b 100755
--- a/build/video/edit.sh
+++ b/build/video/edit.sh
@@ -21,9 +21,9 @@ TOUR_SPEED="${TOUR_SPEED:-1.1}"
# start:end:speed, in raw recording seconds
SEGMENTS=(
"0:3.9:1" # title card
- "4.0:56.85:$TOUR_SPEED" # Dashboard, Chain, Dry Run, Metrics and errors, Live Tracker
- "56.95:61.05:1" # outro: turn it on in one setting
- "61.12:67.3:1" # end screen: rulebox.coldbox.org
+ "4.0:64.8:$TOUR_SPEED" # Dashboard, Chain, Facts, Dry Run, Metrics and errors, Live Tracker
+ "64.9:68.85:1" # outro: turn it on in one setting
+ "68.95:75.2:1" # end screen: rulebox.coldbox.org
)
XF=0.6 # crossfade length
FADE_IN=0.8
diff --git a/build/video/record.js b/build/video/record.js
index 8caf7b81..a4d02755 100644
--- a/build/video/record.js
+++ b/build/video/record.js
@@ -124,7 +124,7 @@ async function traffic( n ){
} );
// Load every screen once in a throwaway tab, so the cache above is full before recording starts
const warm = await context.newPage();
- for( const a of [ "index", "chain?name=fraudcheck", "dryrun", "metrics", "live" ] ){
+ for( const a of [ "index", "chain?name=fraudcheck", "chain?name=loanapproval", "dryrun?name=loanapproval", "metrics", "live" ] ){
await warm.goto( a === "index" ? VIZ : VIZ + "/" + a, { waitUntil: "networkidle", timeout: 90000 } ).catch( () => {} );
}
await warm.setContent( card( "warm", "warm" ), { waitUntil: "networkidle", timeout: 90000 } ).catch( () => {} );
@@ -167,28 +167,45 @@ async function traffic( n ){
await glide( page, page.locator( ".card", { hasText: "callFraudService" } ).first() );
await sleep( 2600 );
- // 4. Dry run
- const dryLink = page.getByRole( "link", { name: /dry run/i } ).first();
+ // 4. A rulebook that describes itself and declares the facts it takes
+ const bookSelect = page.locator( 'select[name="name"]' ).first();
+ await glide( page, bookSelect );
+ await Promise.all( [ page.waitForNavigation( { waitUntil: "load" } ), bookSelect.selectOption( "loanapproval" ) ] );
+ await caption( page, "Facts and descriptions", "A rulebook can describe itself and declare the facts it takes" );
+ await sleep( 1400 );
+ await glide( page, page.locator( ".rb-facts-table tbody tr" ).first() );
+ await sleep( 1200 );
+ await glide( page, page.locator( ".rb-facts-table tbody tr" ).nth( 2 ) );
+ await sleep( 1600 );
+
+ // 5. Dry run, with a form built from those facts
+ const dryLink = page.locator( "a.btn", { hasText: /dry run/i } ).first();
await glide( page, dryLink );
await dryLink.click();
await page.waitForLoadState( "load" );
- await caption( page, "Dry Run", "Try any facts. Nothing executes, nothing is recorded." );
- const select = page.locator( 'select[x-model="rulebookName"]' ).first();
- await glide( page, select );
- await select.selectOption( "loanapproval" );
- await sleep( 600 );
- const facts = page.locator( 'textarea[x-model="factsText"]' ).first();
- await glide( page, facts );
- await facts.click();
- await facts.fill( "" );
- await facts.pressSequentially( '{ "creditScore": 640 }', { delay: 70 } );
- await sleep( 500 );
- const runBtn = page.locator( "button", { hasText: /run/i } ).first();
+ await page.locator( "#fact-creditScore" ).waitFor( { timeout: 15000 } );
+ await caption( page, "Dry Run", "A form built from the declared facts. Nothing executes, nothing is recorded." );
+ const score = page.locator( "#fact-creditScore" );
+ await glide( page, score );
+ await score.click();
+ await score.fill( "" );
+ await score.pressSequentially( "640", { delay: 110 } );
+ await sleep( 400 );
+ const runBtn = page.locator( "button", { hasText: /run dry run/i } ).first();
+ await glide( page, runBtn );
+ await runBtn.click();
+ await sleep( 2600 );
+ // The rulebook enforces its facts: leave out the required one and the run is rejected
+ await glide( page, score );
+ await score.click();
+ await score.fill( "" );
+ await sleep( 300 );
await glide( page, runBtn );
await runBtn.click();
- await sleep( 3000 );
+ await caption( page, "Enforced facts", "A missing or invalid fact is rejected before any rule runs" );
+ await sleep( 2800 );
- // 5. Metrics: rule health, sorting, then one rule's errors and stack traces
+ // 6. Metrics: rule health, sorting, then one rule's errors and stack traces
const metricsLink = page.getByRole( "link", { name: /metrics/i } ).first();
await glide( page, metricsLink );
await metricsLink.click();
@@ -219,7 +236,7 @@ async function traffic( n ){
await raw.click();
await sleep( 2200 );
- // 6. Live tracker, with traffic flowing; open a failed evaluation
+ // 7. Live tracker, with traffic flowing; open a failed evaluation
const liveLink = page.getByRole( "link", { name: /live/i } ).first();
await glide( page, liveLink );
await liveLink.click();
@@ -245,12 +262,12 @@ async function traffic( n ){
await sleep( 3200 );
mark( "tourEnd" );
- // 7. Outro (marks go after setContent: a card is only on screen once it has loaded)
+ // 8. Outro (marks go after setContent: a card is only on screen once it has loaded)
await page.setContent( card( 'Turn it on in one setting', "visualizer = { enabled = true }", '