Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/practice-deferred.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Practice deferred acceptance
on:
pull_request:
paths:
- 'src/castle-practice.js'
- 'src/app.js'
- 'tools/build*.cjs'
- 'tools/check-android-artifact.cjs'
- 'tests/*practice*.cjs'
- 'tests/browser_practice_deferred.py'
- '.github/workflows/practice-deferred.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
practice-deferred:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Capture immutable candidate inputs
env:
EXPECTED_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD"
mkdir -p test-results/practice-deferred
printf '{"sourceSha":"%s","treeSha":"%s"}\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^{tree})" > test-results/practice-deferred/source-head.json
git archive --format=tar --prefix=source/ HEAD | gzip -n > test-results/practice-deferred/source.tar.gz
sha256sum src/castle-practice.js src/app.js tools/build.cjs tools/build-android.cjs tools/check-android-artifact.cjs tests/browser_practice_deferred.py > test-results/practice-deferred/inputs.sha256
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '22'
cache: npm
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
- run: npm ci
- name: Require full pinned verification and original byte ceilings
run: |
set -o pipefail
npm run verify 2>&1 | tee test-results/practice-deferred/verify.log
- run: python -m pip install -r requirements-dev.txt
- name: Install Chromium with Ubuntu repositories
run: |
test -s /etc/apt/sources.list.d/ubuntu.sources
printf '%s\n' 'Dir::Etc::sourcelist "/etc/apt/sources.list.d/ubuntu.sources";' 'Dir::Etc::sourceparts "-";' | sudo tee /etc/apt/apt.conf.d/99alibi-ci-sources >/dev/null
python -m playwright install --with-deps chromium
- name: Native app-owned practice counts through failure and recovery
run: |
set -o pipefail
python tests/browser_practice_deferred.py 2>&1 | tee test-results/practice-deferred/browser.log
- name: Check exact source remained unchanged
if: ${{ !cancelled() }}
run: |
sha256sum --check test-results/practice-deferred/inputs.sha256
git diff --exit-code
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: practice-deferred-${{ github.event.pull_request.head.sha || github.sha }}
path: |
test-results/practice-deferred/
build-info.json
retention-days: 14
if-no-files-found: error
33 changes: 33 additions & 0 deletions docs/qa/2026-10-07-backlog/issue-389.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Issue 389 - deferred Vault follow-ups

2026-10-07, branch codex/backlog-389-20261007, based on f084aa6.

## Changed

Castle practice accepts the registered scalar listing metadata as the identity of a historical firstCompletedAt run while a Vault chunk is pending or failed. Loaded definitions still require the exact saved body; a current completion still requires the normal completion predicate. The build content revision now hashes both initial and deferred definition scripts. IDs, revisions, lazy loading and save format are unchanged.

Item 2 already has a pending route, loading indicator and timeout in current source. The existing deferred-route and deferred-loading regressions pass; no new app.js change was required.

## Verified

Coordinator npm.cmd run build succeeds. Focused Node suite with --test-isolation=none --test-concurrency=1 over castle-practice, content-manifest-revision, official-deferred, deferred-route, deferred-loading and budget passes 30/30, no skips/cancellations (112755ms). Emitted identity matches both payloads. Prettier on all four changed files passes.

Measured JavaScript gzip: 134941 bytes against the unchanged strict 134944-byte limit. Core offline bytes: 1892675. Integration has only three gzip bytes of headroom, so a combined exact-head build must re-prove the existing budget; this slice does not relax it.

## NOT verified

Physical Android/TalkBack and actual browser practice pending/failure rendering are unverified. No deployment, hosted CI or publication. Independent review remains pending. The first process-isolated test run used an incorrect budget filename and stale build; its emitted identity assertion failed. Rebuilding and the scoped no-isolation run above provide the final proof. The initial diagnosis of a hanging process was not sustained: the runner was consuming CPU validating the catalogue.

## Residual risk

Grok 4.7 high reached its 25-turn cap after the useful changes, so the coordinator owns completion. Muse lane creation was refused because current host kraspyon differs from registry owner desktop-ihkoojs; no bypass or Muse job ran. HUMAN_TODO.md remains authoritative and was not edited. Root owns central STATE updates and publication. Existing PR #572 practice interactions require root integration review.

## Final Android identity correction

The first independent Grok review found a HIGH producer/consumer defect: the Android runtime copied the complete web content revision, but its receipt and checker still expected only the startup script hash. A clean baseline build:android passed, while the added regression failed on receipt hash 95d1e511... versus complete revision 7a76937c... (exit 1). An earlier baseline attempt failed the clean-source precondition after a test edit; that attempt is not counted as regression proof.

The receipt now preserves the runtime revision. The checker computes the authoritative shared helper from exactly one startup and one deferred UTF-8 asset. The new regression compares web, Android runtime and receipt revisions, and requires the artifact inspector to accept the result. Other direct assumptions were searched: standalone preview-house hashes the complete catalogue JSON and needs no change.

Coordinator checks on the correction: npm.cmd run build:android exit 0; npm.cmd run check:android exit 0; Android-build, Android-boot-target, platform-identity, content-manifest-revision and budget suites with no isolation and concurrency 1 pass 24/24, zero skips/cancellations (49824ms). Prettier and diff check pass. Measured JavaScript gzip remains 134941 bytes; core offline bytes 1892676. No budget ceilings, save formats, IDs or database versions change.

Second and final independent review: fresh read-only GPT-6.1 Sol medium reviewed only the three-file fix diff, found no CRITICAL/HIGH blockers or additional actionable findings. Its static check confirms unique asset selection, UTF-8 input order, receipt/runtime agreement and the regression. It ran no tests or physical-device checks. The subsequent amendment only adds this evidence; exact-head artifact regeneration/check remains the final local step. HUMAN_TODO.md remains authoritative and unchanged.
11 changes: 10 additions & 1 deletion src/castle-practice.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,16 @@
}

function sameDefinition(left, right) {
return !!left && !!right && stable(left) === stable(right);
// A pending or failed Vault chunk leaves a listing: no solution, and its public fields are
// scalars. A loaded definition still has to match exactly, including its body.
return (
!!left &&
!!right &&
(stable(left) === stable(right) ||
(!right.solution &&
right.id &&
Object.keys(right).every((key) => left[key] === right[key])))
);
}

function dateMarker(value) {
Expand Down
21 changes: 21 additions & 0 deletions tests/android-build.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { ANDROID_DIST, deriveAndroidPayload, sourceSha } = require('../tools/buil
const { inspectAndroidArtifact } = require('../tools/check-android-artifact.cjs');
const { checkPublicPayload } = require('../tools/sync-android.cjs');
const { readIdentity, payloadDigest } = require('../tools/platform-identity.cjs');
const { contentManifestRevision } = require('../tools/build.cjs');

const ROOT = path.resolve(__dirname, '..');
const WEB_DIST = path.join(ROOT, 'dist');
Expand All @@ -36,6 +37,26 @@ test('generated Android payload satisfies the closed artifact contract', () => {
assert.ok(result.bytes > 0);
});

test('web, Android runtime and receipt share the complete content revision', () => {
const assetNames = fs.readdirSync(path.join(WEB_DIST, 'assets'));
const read = (pattern) => {
const names = assetNames.filter((name) => pattern.test(name));
assert.equal(names.length, 1);
return fs.readFileSync(path.join(WEB_DIST, 'assets', names[0]), 'utf8');
};
const expected = contentManifestRevision(
read(/^official-content\.[0-9a-f]{12}\.js$/),
read(/^official-deferred\.[0-9a-f]{12}\.js$/),
);
const web = readIdentity(WEB_DIST).identity;
const android = readIdentity(ANDROID_DIST).identity;
const receipt = readJson(path.join(ANDROID_DIST, 'android-build-identity.json'));
assert.equal(web.contentManifestRevision, expected);
assert.equal(android.contentManifestRevision, expected);
assert.equal(receipt.contentManifestRevision, expected);
assert.deepEqual(inspectAndroidArtifact().errors, []);
});

test('Android replaces the web runtime identity with explicit browser-preview capabilities', () => {
const web = readIdentity(WEB_DIST);
const android = readIdentity(ANDROID_DIST);
Expand Down
177 changes: 177 additions & 0 deletions tests/browser_practice_deferred.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Emitted practice adapter and native IndexedDB across deferred delivery states.

A disposable local origin, not a physical-device or deployed-service test.
"""
import hashlib
import json
import os
import subprocess
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

from playwright.sync_api import sync_playwright

ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'test-results' / 'practice-deferred'
FIXTURE = b'''<!doctype html><title>Synthetic practice seed</title>
<script src="/__source__/core.js"></script><script src="/__source__/engines.js"></script>'''


class Handler(SimpleHTTPRequestHandler):
def do_GET(self):
if self.path == '/__seed__':
data, mime = FIXTURE, 'text/html'
elif self.path in ('/__source__/core.js', '/__source__/engines.js'):
data = (ROOT / 'src' / self.path.rsplit('/', 1)[1]).read_bytes()
mime = 'text/javascript'
else:
return super().do_GET()
self.send_response(200)
self.send_header('Content-Type', mime)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)

def log_message(self, *_):
pass


def snapshot(page):
value = page.evaluate('()=>AlibiDiagnostics.getPracticeSnapshot()')
assert value['available'], value
assert value['rooms']['observatory']['completed'] == 1, value['rooms']['observatory']
assert len(value['rooms']) == 13
return value['rooms']['observatory']


def stored(page, key):
return page.evaluate('''key=>new Promise((resolve,reject)=>{
const request=indexedDB.open('alibi-device',1);
request.onerror=()=>reject(request.error);
request.onsuccess=()=>{
const db=request.result,tx=db.transaction('runs'),read=tx.objectStore('runs').get(key);
let result;
read.onsuccess=()=>result=read.result;
tx.oncomplete=()=>{db.close();resolve(result);};
tx.onabort=()=>{db.close();reject(tx.error);};
};
})''', key)


def scenario(browser, base, width, puzzle, report):
context = browser.new_context(viewport={'width': width, 'height': 900},
service_workers='block', reduced_motion='reduce')
held = []
pattern = '**/official-deferred.*.js'
context.route(pattern, lambda route: held.append(route))
try:
page = context.new_page()
page.set_default_timeout(15000)
page.goto(base + '/__seed__')
original = page.evaluate('''async puzzle=>{
const value={schemaVersion:1,key:puzzle.id+'@'+puzzle.revision,rev:4,puzzle,
state:AlibiCore.registry[puzzle.type].initial(puzzle),undo:[],redo:[],moves:0,
hints:0,elapsed:0,completedAt:null,firstCompletedAt:'2026-09-01T10:00:00.000Z',
updatedAt:'2026-09-01T10:00:00.000Z',note:'synthetic historical completion'};
await new Promise((resolve,reject)=>{
const request=indexedDB.open('alibi-device',1);
request.onupgradeneeded=()=>{
for(const name of ['runs','packs','meta'])request.result.createObjectStore(name,{keyPath:'key'});
};
request.onerror=()=>reject(request.error);
request.onsuccess=()=>{
const db=request.result,tx=db.transaction('runs','readwrite');
tx.objectStore('runs').put({key:value.key,value});
tx.oncomplete=()=>{db.close();resolve();};
tx.onabort=()=>{db.close();reject(tx.error);};
};
});
return {key:value.key,value};
}''', puzzle)
page.goto(base + '/#/home', wait_until='domcontentloaded')
page.wait_for_function('()=>!!globalThis.AlibiDiagnostics?.getPracticeSnapshot')
page.evaluate('''()=>{
window.definitionResult=null;
ALIBI_DEFERRED.ensure().then(()=>window.definitionResult='loaded',
error=>window.definitionResult=error.message);
}''')
page.wait_for_function('()=>Array.from(document.scripts).some(s=>s.src.includes("official-deferred."))')
assert held, 'the real deferred script request must be pending'
assert page.evaluate('ALIBI_DEFERRED.ready') is False
pending = snapshot(page)
assert stored(page, original['key']) == original
report['cases'].append({'width': width, 'phase': 'pending', 'room': pending})
for route in held:
route.abort('failed')
held.clear()
page.wait_for_function('()=>typeof window.definitionResult==="string"')
assert page.evaluate('definitionResult') == 'Puzzle definitions did not load.'
assert page.evaluate('ALIBI_DEFERRED.ready') is False
failed = snapshot(page)
assert failed == pending
assert stored(page, original['key']) == original
report['cases'].append({'width': width, 'phase': 'failed', 'room': failed})
context.unroute(pattern)
page.evaluate('async()=>await ALIBI_DEFERRED.ensure()')
assert page.evaluate('ALIBI_DEFERRED.ready') is True
loaded = snapshot(page)
assert loaded == pending
assert stored(page, original['key']) == original
report['cases'].append({'width': width, 'phase': 'loaded', 'room': loaded})
# The same app-owned snapshot survives a new page after definitions load.
page.reload(wait_until='domcontentloaded')
page.wait_for_function('()=>!!globalThis.AlibiDiagnostics?.getPracticeSnapshot')
page.evaluate('async()=>await ALIBI_DEFERRED.ensure()')
assert snapshot(page) == pending
assert stored(page, original['key']) == original
report['cases'].append({'width': width, 'phase': 'reload', 'room': pending})
finally:
context.close()


def run():
OUT.mkdir(parents=True, exist_ok=True)
report = {'passed': False, 'scope': 'emitted app-owned adapter, native IndexedDB, real deferred request',
'cases': [], 'sourceSha256': {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest()
for p in ('src/castle-practice.js', 'src/app.js', 'tools/build.cjs',
'tools/build-android.cjs', 'tools/check-android-artifact.cjs')}}
server = ThreadingHTTPServer(('127.0.0.1', 0), partial(Handler, directory=str(ROOT / 'dist')))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
report['identity'] = json.loads((ROOT / 'build-info.json').read_text())['platformBuild']
puzzle = json.loads(subprocess.check_output([
'node', '-e', "console.log(JSON.stringify(require('./tools/official-catalogue.cjs').load(process.cwd()).puzzles.find(p=>p.id==='vault-binary-01')))"
], cwd=ROOT, text=True))
assert puzzle['type'] == 'binary' and puzzle['solution']
with sync_playwright() as pw:
options = {'headless': True, 'args': ['--no-sandbox']}
executable = os.environ.get('CHROMIUM_PATH')
if not executable and Path('/usr/bin/chromium').exists():
executable = '/usr/bin/chromium'
if executable:
options['executable_path'] = executable
browser = pw.chromium.launch(**options)
try:
report['browserVersion'] = browser.version
for width in (390, 1280):
scenario(browser, f'http://127.0.0.1:{server.server_port}', width, puzzle, report)
finally:
browser.close()
assert len(report['cases']) == 8
report['passed'] = True
except Exception as error:
report['failure'] = f'{type(error).__name__}: {error}'
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
(OUT / 'results.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print(json.dumps(report, indent=2), flush=True)
return 0 if report['passed'] else 1


if __name__ == '__main__':
raise SystemExit(run())
Loading
Loading