282 lines
15 KiB
Python
282 lines
15 KiB
Python
"""Explicit native-v4 launch/checkpoint helper. Never operates GUI or compiles.
|
|
|
|
Launch/check/closed require the corresponding root-observed boundary. Merely
|
|
copying a snapshot never claims Apply, Save, recovery or model acceptance.
|
|
"""
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
BASE = Path(__file__).resolve().parent
|
|
TRIAL = BASE / 'trial'
|
|
FIXTURES = BASE / 'fixtures'
|
|
ORIGINAL = ROOT / '.local/text-navigation-native-20260911-preparation'
|
|
GATES = ROOT / '.local/text-repair-save-20260911'
|
|
ZIP_SHA = '213fdb39ea7c3601b4baac674c3ab3ecc863ac44db1b092d922d3e24f26887b7'
|
|
SOURCE_COMMIT = '33c6d83e9ba9a5b1310b1b627d7c40affcbcf15c'
|
|
FIXTURE_MANIFEST_SHA = '7bccf622c8e2ff42f9e71ac8e5d08ea27b3493c9ebc689ce5f7fbac9aa63f851'
|
|
ORIGINAL_MANIFEST_SHA = 'edd4157fc31823f4494dee91862728b87f12ef51c49b8d1fd86a954bc9ecc1ed'
|
|
DLL_SHA = 'f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4'
|
|
ORACLE_SHA = '967d9ba3c6fb3a7efb410833903ec94556ca2d48a67d5072c8bb160f40a00f9c'
|
|
RUNTIME = ROOT / '.local/toolchain/llvm-mingw-20260826-ucrt-x86_64/bin/libunwind.dll'
|
|
ORACLE = ROOT / '.local/staged-source-oracle-20260911/staged-source-authoring-contract.exe'
|
|
MODELS = ('valid', 'release1', 'ambiguous', 'missing-include', 'recovery')
|
|
JOURNAL = '.arcadia-save-project.arcadia.journal'
|
|
COMMANDS = {
|
|
'desktop-tests': ['cargo', 'test', '--locked', '--offline', '-p', 'oa-desktop', '--bin', 'arcadia-studio', '--', '--test-threads=1'],
|
|
'desktop-clippy': ['cargo', 'clippy', '--locked', '--offline', '-p', 'oa-desktop', '--all-targets', '--', '-D', 'warnings'],
|
|
'scoped-fmt': ['rustfmt', '--check', '--edition', '2024', '--config', 'skip_children=true', 'crates/oa-desktop/src/app.rs', 'crates/oa-desktop/src/document.rs', 'crates/oa-desktop/src/app/repair_save_state_tests.rs'],
|
|
'diff-check': ['git', 'diff', '--check'],
|
|
'gui-build': ['cargo', 'build', '--locked', '--offline', '-p', 'oa-desktop', '--bin', 'arcadia-studio', '--message-format=json-render-diagnostics'],
|
|
}
|
|
|
|
|
|
def now():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def ordinary(path):
|
|
path = Path(path).absolute()
|
|
for p in (path, *path.parents):
|
|
if p.exists():
|
|
assert not p.is_symlink() and not (getattr(p.lstat(), 'st_file_attributes', 0) & 0x400), str(p)
|
|
return path
|
|
|
|
|
|
def sha(path):
|
|
with ordinary(path).open('rb') as stream:
|
|
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
|
|
|
|
def load(path):
|
|
return json.loads(ordinary(path).read_text(encoding='utf-8'))
|
|
|
|
|
|
def record(path, value):
|
|
ordinary(path)
|
|
with path.open('x', encoding='utf-8', newline='\n') as out:
|
|
json.dump(value, out, ensure_ascii=False, indent=2)
|
|
out.write('\n')
|
|
|
|
|
|
def inventory(folder):
|
|
ordinary(folder)
|
|
result = {}
|
|
for p in folder.rglob('*'):
|
|
ordinary(p)
|
|
if p.is_file():
|
|
result[p.relative_to(folder).as_posix()] = sha(p)
|
|
assert len({p.casefold() for p in result}) == len(result)
|
|
return result
|
|
|
|
|
|
def copy_exact(source, destination):
|
|
before = inventory(source)
|
|
assert not destination.exists()
|
|
shutil.copytree(source, destination)
|
|
assert inventory(source) == inventory(destination) == before
|
|
return before
|
|
|
|
|
|
def git(*args):
|
|
return subprocess.check_output(['git', *args], cwd=ROOT)
|
|
|
|
|
|
def fixture_admission():
|
|
assert sha(ORIGINAL / 'fixture-manifest.json') == ORIGINAL_MANIFEST_SHA
|
|
for item in load(ORIGINAL / 'fixture-manifest.json'):
|
|
path = (ORIGINAL / item['path']).resolve()
|
|
assert path.is_relative_to(ORIGINAL)
|
|
assert sha(path) == item['sha256'] and path.stat().st_size == item['bytes']
|
|
assert sha(BASE / 'fixture-manifest.json') == FIXTURE_MANIFEST_SHA
|
|
frozen = load(BASE / 'fixture-manifest.json')
|
|
expected = {item['path']: item['sha256'] for item in frozen['files']}
|
|
assert len(expected) == len(frozen['files']) and inventory(FIXTURES) == expected
|
|
mapping = {'valid': 'valid-baseline', 'release1': 'release1-baseline', 'ambiguous': 'ambiguous-open', 'missing-include': 'missing-include-open'}
|
|
for destination, original in mapping.items():
|
|
assert inventory(FIXTURES / destination) == inventory(ORIGINAL / original)
|
|
after = FIXTURES / 'recovery-expected-after'
|
|
before = FIXTURES / 'recovery-expected-before'
|
|
assert inventory(after) == inventory(FIXTURES / 'valid')
|
|
a = (after / 'project.arcadia').read_bytes()
|
|
b = (before / 'project.arcadia').read_bytes()
|
|
title = '"Navigation — 救援"'.encode('utf-8')
|
|
old_title = '"Before interrupted save — 救援"'.encode('utf-8')
|
|
assert a.count(title) == 1 and b == a.replace(title, old_title, 1)
|
|
assert {p: h for p, h in inventory(before).items() if p != 'project.arcadia'} == {p: h for p, h in inventory(after).items() if p != 'project.arcadia'}
|
|
recovery = FIXTURES / 'recovery'
|
|
assert load(recovery / JOURNAL / 'journal.json') == {'version': 1, 'entry': 'project.arcadia', 'files': [{'path': 'project.arcadia', 'before': True, 'write': True}]}
|
|
assert (recovery / JOURNAL / '0.before').read_bytes() == b
|
|
assert (recovery / JOURNAL / '0.after').read_bytes() == a
|
|
assert {p: h for p, h in inventory(recovery).items() if not p.startswith(JOURNAL + '/')} == inventory(after)
|
|
return frozen
|
|
|
|
|
|
def gate_admission(source_commit):
|
|
assert source_commit == SOURCE_COMMIT
|
|
assert git('rev-parse', 'HEAD').decode().strip() == source_commit
|
|
manifest = load(GATES / 'inputs1.json')
|
|
assert manifest['archiveSha256'] == sha(GATES / 'inputs1.zip') == ZIP_SHA
|
|
entries = {e['path']: e for e in manifest['files']}
|
|
assert len(entries) == len(manifest['files']) == 500
|
|
selectors = ['crates', '.cargo', 'Cargo.toml', 'Cargo.lock', 'docs', 'examples', 'harness/fixtures']
|
|
assert not git('status', '--porcelain', '--', *selectors).strip(), 'Source must be committed before launch.'
|
|
paths = git('ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', *selectors).decode().split('\0')[:-1]
|
|
assert sorted(set(paths)) == sorted(entries)
|
|
with zipfile.ZipFile(GATES / 'inputs1.zip') as archive:
|
|
assert len(archive.namelist()) == len(entries) and set(archive.namelist()) == set(entries)
|
|
for name, item in entries.items():
|
|
path = (ROOT / name).resolve()
|
|
assert path.is_relative_to(ROOT)
|
|
raw = ordinary(path).read_bytes()
|
|
assert len(raw) == item['bytes'] and hashlib.sha256(raw).hexdigest() == item['sha256']
|
|
assert archive.read(name) == raw
|
|
complete = load(GATES / 'complete.json')
|
|
assert complete['archiveSha256'] == ZIP_SHA and complete['inputsUnchanged'] is True
|
|
assert complete['gates'] == list(COMMANDS) and complete['sourceHead'] == manifest['head']
|
|
receipts = {}
|
|
for name, command in COMMANDS.items():
|
|
receipt = load(GATES / (name + '-result.json'))
|
|
assert receipt['exitCode'] == 0 and receipt['inputsUnchanged'] is True and receipt['command'] == command
|
|
assert sha(GATES / (name + '-stdout.log')) == receipt['stdoutSha256']
|
|
assert sha(GATES / (name + '-stderr.log')) == receipt['stderrSha256']
|
|
receipts[name] = sha(GATES / (name + '-result.json'))
|
|
summaries = re.findall(r'test result: ok\. (\d+) passed; (\d+) failed; (\d+) ignored; (\d+) measured; (\d+) filtered out;', (GATES / 'desktop-tests-stdout.log').read_text(encoding='utf-8'))
|
|
assert summaries == [('755', '0', '1', '0', '0')]
|
|
build = load(GATES / 'gui-build-result.json')['binary']
|
|
artifact = build['artifact']
|
|
assert artifact['target']['name'] == 'arcadia-studio' and artifact['profile']['test'] is False
|
|
assert artifact['profile']['opt_level'] == '0' and artifact['profile']['debug_assertions'] is True
|
|
assert Path(artifact['executable']).resolve() == Path(build['path']).resolve()
|
|
assert Path(build['path']).resolve().is_relative_to(ROOT / '.local/target-gnullvm')
|
|
binary = GATES / 'gui-repair.exe'
|
|
assert sha(binary) == build['sha256'] and binary.stat().st_size == build['bytes']
|
|
assert sha(RUNTIME) == DLL_SHA and sha(ORACLE) == ORACLE_SHA
|
|
return binary, build, receipts
|
|
|
|
|
|
def environment():
|
|
parent = {k.upper(): v for k, v in os.environ.items()}
|
|
names = ('SystemRoot', 'SystemDrive', 'ProgramData', 'ALLUSERSPROFILE', 'PUBLIC')
|
|
standard = {name: parent[name.upper()] for name in names}
|
|
assert all(value and '%' not in value for value in standard.values())
|
|
system = standard['SystemRoot']
|
|
return {**standard, 'WINDIR': system, 'PATH': system + '\\System32;' + system,
|
|
'USERPROFILE': str(TRIAL / 'profile'), 'LOCALAPPDATA': str(TRIAL / 'profile/local'),
|
|
'APPDATA': str(TRIAL / 'profile/roaming'), 'TEMP': str(TRIAL / 'tmp'), 'TMP': str(TRIAL / 'tmp')}
|
|
|
|
|
|
def launch(args):
|
|
assert not TRIAL.exists(), 'A previous trial must remain untouched.'
|
|
frozen = fixture_admission()
|
|
binary, build, receipts = gate_admission(args.source_commit)
|
|
ordinary(BASE)
|
|
TRIAL.mkdir()
|
|
for name in ('bin', 'profile/local', 'profile/roaming', 'profile/Desktop', 'tmp', 'screenshots', 'checkpoints'):
|
|
(TRIAL / name).mkdir(parents=True)
|
|
for source, name, checksum in ((binary, 'arcadia-studio.exe', build['sha256']), (RUNTIME, 'libunwind.dll', DLL_SHA)):
|
|
with source.open('rb') as src, (TRIAL / 'bin' / name).open('xb') as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
assert sha(source) == sha(TRIAL / 'bin' / name) == checksum
|
|
sources = {model: copy_exact(FIXTURES / model, TRIAL / model) for model in MODELS}
|
|
record(TRIAL / 'source-before.json', sources)
|
|
record(TRIAL / 'binary-before.json', inventory(TRIAL / 'bin'))
|
|
record(TRIAL / 'admission.json', {'atUtc': now(), 'sourceCommit': args.source_commit, 'inputArchiveSha256': ZIP_SHA,
|
|
'gateReceipts': receipts, 'completeReceiptSha256': sha(GATES / 'complete.json'), 'fixtureManifestSha256': sha(BASE / 'fixture-manifest.json'),
|
|
'originalFixtureManifestSha256': ORIGINAL_MANIFEST_SHA, 'binary': build, 'scope': 'Gate/fixture admission only, not native acceptance.'})
|
|
env = environment()
|
|
startup = subprocess.STARTUPINFO()
|
|
startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startup.wShowWindow = 0
|
|
with (TRIAL / 'stdout.log').open('xb') as out, (TRIAL / 'stderr.log').open('xb') as err:
|
|
process = subprocess.Popen([str(TRIAL / 'bin/arcadia-studio.exe')], cwd=TRIAL / 'bin', env=env, stdout=out, stderr=err, startupinfo=startup)
|
|
receipt = {'atUtc': now(), 'pid': process.pid, 'sourceCommit': args.source_commit, 'binarySha256': build['sha256'],
|
|
'guiBuildReceiptSha256': receipts['gui-build'], 'environment': env, 'modelEntries': {m: str(TRIAL / m / 'project.arcadia') for m in MODELS},
|
|
'scope': 'Explicitly launched identified Windows GUI; native results pending.'}
|
|
record(TRIAL / 'launch.json', receipt)
|
|
print(json.dumps(receipt, ensure_ascii=False, indent=2))
|
|
|
|
|
|
def checkpoint(args, check):
|
|
assert args.label and re.fullmatch('[a-z0-9][a-z0-9-]{0,79}', args.label)
|
|
if check:
|
|
assert args.after_visible_save, 'Oracle check requires root-observed successful Save.'
|
|
fixture_admission()
|
|
launch_receipt = load(TRIAL / 'launch.json')
|
|
before_bin = load(TRIAL / 'binary-before.json')
|
|
assert inventory(TRIAL / 'bin') == before_bin and before_bin['arcadia-studio.exe'] == launch_receipt['binarySha256']
|
|
folder = TRIAL / 'checkpoints' / args.label
|
|
assert not folder.exists()
|
|
folder.mkdir()
|
|
source = TRIAL / args.model
|
|
files = copy_exact(source, folder / 'sources')
|
|
journals = {p: h for p, h in files.items() if p.split('/')[0].startswith('.arcadia-save-')}
|
|
state = {'atUtc': now(), 'label': args.label, 'model': args.model, 'files': files,
|
|
'arcadiaFiles': sum(p.endswith('.arcadia') for p in files), 'journalFiles': journals,
|
|
'pendingJournalExists': (source / JOURNAL).exists(), 'binaryFilesUnchanged': True,
|
|
'scope': 'On-disk snapshot only; no accepted graph, Apply, Save or recovery success inferred.'}
|
|
record(folder / 'snapshot.json', state)
|
|
if not check:
|
|
print(json.dumps(state, ensure_ascii=False, indent=2))
|
|
return
|
|
assert not journals, 'Final saved oracle cannot bypass a retained transaction journal.'
|
|
assert sha(ORACLE) == ORACLE_SHA
|
|
expected = FIXTURES / ('release1' if args.model == 'release1' else 'recovery-expected-after' if args.model == 'recovery' else 'valid')
|
|
env = environment()
|
|
env['PATH'] = str(TRIAL / 'bin') + ';' + env['PATH']
|
|
command = [str(ORACLE), 'same', str(expected / 'project.arcadia'), str(folder / 'sources/project.arcadia')]
|
|
result = subprocess.run(command, capture_output=True, env=env)
|
|
with (folder / 'oracle.stdout.log').open('xb') as out:
|
|
out.write(result.stdout)
|
|
with (folder / 'oracle.stderr.log').open('xb') as err:
|
|
err.write(result.stderr)
|
|
receipt = {'atUtc': now(), 'command': command, 'exitCode': result.returncode, 'oracleSha256': ORACLE_SHA,
|
|
'afterVisibleSaveConfirmedByCaller': True, 'model': args.model, 'expected': str(expected), 'files': files,
|
|
'binaryFilesUnchanged': inventory(TRIAL / 'bin') == before_bin,
|
|
'scope': 'Actual saved oracle result; complete Project/identity/reachable and physical Arcadia equality only if exitCode is zero.'}
|
|
record(folder / 'oracle.json', receipt)
|
|
print(result.stdout.decode('utf-8', errors='replace'))
|
|
print(json.dumps(receipt, ensure_ascii=False, indent=2))
|
|
assert receipt['binaryFilesUnchanged']
|
|
if result.returncode:
|
|
print(result.stderr.decode('utf-8', errors='replace'))
|
|
raise SystemExit(result.returncode)
|
|
|
|
|
|
def closed(args):
|
|
assert args.observed_absent, 'Root must first verify the process/window is absent.'
|
|
before = load(TRIAL / 'binary-before.json')
|
|
after = inventory(TRIAL / 'bin')
|
|
record(TRIAL / 'binary-after-close.json', {'atUtc': now(), 'files': after, 'unchanged': before == after,
|
|
'absenceObservedByCaller': True, 'scope': 'Binary readback only, not a process/exit-code observation by this helper.'})
|
|
assert before == after
|
|
print(json.dumps({'unchanged': True, 'files': len(after)}))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
sys.stderr.reconfigure(encoding='utf-8')
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('action', choices=('launch', 'snapshot', 'check', 'closed'))
|
|
parser.add_argument('--source-commit')
|
|
parser.add_argument('--model', choices=MODELS, default='valid')
|
|
parser.add_argument('--label')
|
|
parser.add_argument('--after-visible-save', action='store_true')
|
|
parser.add_argument('--observed-absent', action='store_true')
|
|
arguments = parser.parse_args()
|
|
if arguments.action == 'launch':
|
|
launch(arguments)
|
|
elif arguments.action in ('snapshot', 'check'):
|
|
checkpoint(arguments, arguments.action == 'check')
|
|
else:
|
|
closed(arguments)
|