diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 915216b38..1fa57eff7 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -8,8 +8,9 @@ on: paths: - 'demo/**' - 'demo-agents/**' - - 'docs/assets/agent-follows-feedback.gif' + - 'docs/assets/**' - 'docs/index.html' + - 'scripts/assemble-site.sh' - 'sop-arena/**' - '.github/workflows/deploy-demo.yml' workflow_dispatch: # Allows manual trigger from the GitHub Actions UI @@ -107,34 +108,9 @@ jobs: shell: bash run: | set -euo pipefail - rm -rf _site - mkdir -p _site/arena _site/agents - # Technical demo (WASM) lives at the site root: joltrinhq.com/ - cp demo/index.html demo/sop.wasm demo/wasm_exec.js demo/404.html demo/favicon.svg demo/favicon.ico demo/og-image.png demo/logo-mark.png _site/ - # The documentation index lives at joltrinhq.com/docs/ - mkdir -p _site/docs - cp docs/index.html _site/docs/ - # The homepage shows this replay from ./assets/ - mkdir -p _site/assets - cp docs/assets/agent-follows-feedback.gif _site/assets/ - # SOP Arena lives at a subpath of the same site: joltrinhq.com/arena/ - cp -r sop-arena/dist/. _site/arena/ - # Agent verification barrier (verify in WASM) lives at joltrinhq.com/agents/ - cp demo-agents/index.html demo-agents/sop-agents.wasm demo-agents/wasm_exec.js demo-agents/favicon.svg demo-agents/favicon.ico demo-agents/og-image.png demo-agents/logo-mark.png _site/agents/ - # Preserve custom domain (e.g. joltrinhq.com) if CNAME exists - if [ -f "CNAME" ]; then - cp CNAME _site/CNAME - elif [ -f "demo/CNAME" ]; then - cp demo/CNAME _site/CNAME - fi - # Fail the deploy if the homepage points at a local file that is not in the site. - for ref in $(grep -oE '(src|href)="\./[^"#?]+"' _site/index.html | sed -E 's/^(src|href)="\.\///; s/"$//'); do - if [ ! -e "_site/${ref}" ]; then - echo "Error: _site/index.html references ./${ref} but it is not in the site" >&2 - exit 1 - fi - done - ls -lh _site _site/arena _site/agents + # The same script the pull request check runs, so what a PR proves is what ships. + scripts/assemble-site.sh _site + ls -lh _site _site/arena _site/agents _site/assets - name: Setup GitHub Pages uses: actions/configure-pages@v5 diff --git a/.github/workflows/site-check.yml b/.github/workflows/site-check.yml new file mode 100644 index 000000000..3e1d4dac1 --- /dev/null +++ b/.github/workflows/site-check.yml @@ -0,0 +1,58 @@ +name: Site Check + +# Builds the site the way the Pages deploy does and fails if any file a page +# names (video, poster, image, script) is missing from it. The deploy once +# published a homepage whose video files were never copied, and the local +# tests did not notice because they assemble the site a different way. +on: + pull_request: + paths: + - 'demo/**' + - 'demo-agents/**' + - 'docs/**' + - 'sop-arena/**' + - 'scripts/assemble-site.sh' + - '.github/workflows/deploy-demo.yml' + - '.github/workflows/site-check.yml' + +permissions: + contents: read + +jobs: + site-assembles: + name: Site assembles with every referenced file + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + cache: true + + - name: Build the WebAssembly demos + shell: bash + run: | + set -euo pipefail + GOROOT="$(go env GOROOT)" + src="${GOROOT}/lib/wasm/wasm_exec.js" + [ -f "$src" ] || src="${GOROOT}/misc/wasm/wasm_exec.js" + cp "$src" demo/wasm_exec.js + cp "$src" demo-agents/wasm_exec.js + (cd demo && GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o sop.wasm .) + (cd demo-agents && GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o sop-agents.wasm .) + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: 'sop-arena/package-lock.json' + + - name: Build SOP Arena + working-directory: ./sop-arena + run: | + npm ci || npm install + npm run build + + - name: Assemble the site and check every referenced file + run: scripts/assemble-site.sh _site diff --git a/README.md b/README.md index fa1226d65..12f3a7a1e 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Joltrin (formerly SOP) is an open-source Go library that checks an AI agent's ac **What Joltrin does.** The agent emits intent. Joltrin checks the intent against the trace and the runbook's rules, outside the agent's context window. The check and the commit are one call, so a step is recorded only if it passed. A step whose required state is missing is refused. A claim that something happened is not evidence that it happened.

- - A 43 second silent demo: an agent proposes dropping a production database, Joltrin blocks it, the agent runs the missing steps, and the same action is then allowed. Click to play. + + Joltrin demo. Click to watch the video.

diff --git a/demo/index.html b/demo/index.html index cf58b420a..8b8330c5f 100644 --- a/demo/index.html +++ b/demo/index.html @@ -477,29 +477,29 @@

-
- - +
+
// db-maintenance runbook
+
+
drop_prod_db BLOCKED backup_validated missing
+
validate_backup BLOCKED backup_taken missing
+
take_backup ALLOWED
+
validate_backup ALLOWED
+
drop_prod_db ALLOWED
-
- 43 seconds, silent, real output from sop-mcp-server demo. -
- Text version -
    -
  1. drop_prod_db BLOCKED, backup_validated is missing from the trace
  2. -
  3. take_backup ALLOWED
  4. -
  5. validate_backup ALLOWED
  6. -
  7. drop_prod_db ALLOWED, the evidence now exists
  8. -
-
-
- +

The order is enforced by the barrier, whatever the agent says it already did.

+
+
+ + + +
+
+
@@ -1763,39 +1763,12 @@

Open Source &a - - diff --git a/tests/homepage.spec.ts b/tests/homepage.spec.ts index e9fde4321..279c7e8a3 100644 --- a/tests/homepage.spec.ts +++ b/tests/homepage.spec.ts @@ -26,8 +26,9 @@ test.describe('Homepage', () => { test('hero shows the blocked then allowed sequence', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded' }); const ex = page.locator('#hero-example'); - await expect(ex).toContainText('backup_validated'); - await expect(ex.getByText('BLOCKED')).toHaveCount(1); + await expect(ex).toContainText('backup_validated missing'); + await expect(ex).toContainText('backup_taken missing'); + await expect(ex.getByText('BLOCKED')).toHaveCount(2); await expect(ex.getByText('ALLOWED')).toHaveCount(3); }); @@ -58,68 +59,13 @@ test.describe('Homepage', () => { } }); - test('the demo video is in the hero, silent, with a poster, and loads nothing until the page has', async ({ page }) => { - // Record what the video element looks like when the document has parsed, - // which is before the page's load event. - await page.addInitScript(() => { - document.addEventListener('DOMContentLoaded', () => { - const v = document.getElementById('demo-video-el'); - (window as any).__videoAtParse = { src: v?.getAttribute('src'), preload: v?.getAttribute('preload') }; - }); - }); - await page.goto('/', { waitUntil: 'load' }); - const video = page.locator('#demo-video video'); - await expect(video).toHaveCount(1); - await expect(video).toHaveJSProperty('muted', true); - await expect(video).toHaveAttribute('playsinline', ''); - await expect(video).toHaveAttribute('preload', 'none'); - await expect(video).toHaveAttribute('poster', /joltrin-barrier-poster-540\.jpg/); - await expect(video).toHaveAttribute('aria-label', /43 second demo/); - const atParse = await page.evaluate(() => (window as any).__videoAtParse); - expect(atParse, 'the video must not have a source when the document is parsed').toEqual({ src: null, preload: 'none' }); - // It is part of the hero: above the first content section, not buried below it. - const videoY = (await video.boundingBox())!.y; - const barrierY = (await page.locator('#verification-barrier').boundingBox())!.y; - expect(videoY).toBeLessThan(barrierY); - }); - - test('the demo video plays when it is on screen and has a working Pause and Play button', async ({ page }) => { - await page.goto('/', { waitUntil: 'load' }); - const video = page.locator('#demo-video-el'); - const toggle = page.locator('#demo-video-toggle'); - // On a phone the video sits below the headline, so it starts once scrolled to. - await video.scrollIntoViewIfNeeded(); - await expect.poll(() => video.evaluate((v: HTMLVideoElement) => !v.paused), { timeout: 15000 }).toBe(true); - await expect(toggle).toContainText('Pause'); - await toggle.click(); - await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true); - await expect(toggle).toContainText('Play'); - await expect(toggle).toHaveAttribute('aria-label', /Play the demo video/); - }); - - test('a visitor who prefers reduced motion gets the poster and no autoplay', async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('/', { waitUntil: 'load' }); - await page.waitForTimeout(1500); - const video = page.locator('#demo-video-el'); - await expect(video).toHaveJSProperty('paused', true); - expect(await video.getAttribute('src')).toBeNull(); - // They can still start it. - await page.locator('#demo-video-toggle').click(); - await expect.poll(() => video.evaluate((v: HTMLVideoElement) => !v.paused), { timeout: 15000 }).toBe(true); - }); - - test('the video files and posters the page names all exist and are small', async ({ request }) => { - const sizes: Record = { - '/assets/joltrin-barrier.mp4': 2_500_000, - '/assets/joltrin-barrier-720.mp4': 1_500_000, - '/assets/joltrin-barrier-poster-540.jpg': 80_000, - }; - for (const [url, max] of Object.entries(sizes)) { - const res = await request.get(url); - expect(res.status(), url).toBe(200); - expect((await res.body()).length, `${url} should stay under ${max} bytes`).toBeLessThan(max); - } + test('intro video sits below the hero and autoplays muted', async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + const frame = page.locator('iframe[title="Joltrin intro video"]'); + await expect(frame).toHaveAttribute('src', /youtube-nocookie\.com\/embed\/F0jYkBHJluI\?.*autoplay=1.*mute=1/); + const frameY = (await frame.boundingBox())!.y; + const heroY = (await page.getByRole('heading', { level: 1 }).boundingBox())!.y; + expect(frameY).toBeGreaterThan(heroY); }); test('header has a Watch demo button that jumps to the video', async ({ page }) => { @@ -127,7 +73,7 @@ test.describe('Homepage', () => { const btn = page.locator('#watch-demo-btn'); await expect(btn).toBeVisible(); await expect(btn).toHaveAttribute('href', '#demo-video'); - await expect(page.locator('#demo-video video')).toHaveCount(1); + await expect(page.locator('#demo-video iframe')).toHaveCount(1); }); test('explains how to test the barrier with your own AI agent', async ({ page }) => {