tools/makepad_builder replaces tools/makepad_loader: one build target shared across app builds, workspace package selection, checkout progress on the public Git API, detached built apps with a completion state, waits for Windows security scans, manual retry after compiler locks, dedicated-folder installer checks, catalog and runtime fixes. tools/web_server and its scripts leave for github.com/makepad/webserver. Arch USB clone/restore scripts, the qwen38 box scripts and the G-belt serial test join tools/. docs/agents records the agent workflow and the remote-control handoff protocol; AGENTS.md forbids vendored sources and bulk imports. Cargo.toml lists apps/wm-dyn, libs/code_language, libs/search, libs/tar, libs/loader_bundle and tools/makepad_builder, and drops the two removed crates. Squashed from work: - Share Builder target across Makepad app builds - Fix Builder workspace package selection - Align Builder checkout progress with public Git API - Detach built apps and show completion state - Wait for Windows security scans - Offer manual retry after Windows compiler locks - docs: the agent workflow of record and the remote-control handoff protocol - builder: dedicated-folder installer checks, catalog and runtime fixes; Windows job objects hold c_void handles - tools: Arch USB clone/restore scripts, the qwen38 box scripts, and the G-belt serial test - tools: the web server moves to makepad/webserver - AGENTS.md: no vendored sources or bulk imports in the tree Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
194 lines
9.9 KiB
Python
194 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Restore an installed clone's model trees to an already mounted Btrfs USB.
|
|
|
|
Run as root on the Linux clone. This does not format or repartition anything.
|
|
Example: sudo python3 restore_models.py --destination /run/makepad-usb \
|
|
--seed /run/makepad-usb-seed --serial 03005423082225201936
|
|
The Btrfs send/receive copy retains shared extents, so repeated model files
|
|
do not expand beyond the USB's capacity. No model download is performed.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import time
|
|
|
|
|
|
def run(*args, **kwargs):
|
|
return subprocess.run([str(arg) for arg in args], check=True, **kwargs)
|
|
|
|
|
|
def output(*args):
|
|
return subprocess.check_output([str(arg) for arg in args], text=True).strip()
|
|
|
|
|
|
def inventory(root):
|
|
result = {}
|
|
for parent, directories, files in os.walk(root, followlinks=False):
|
|
for name in sorted(directories + files):
|
|
path = Path(parent)/name
|
|
info = path.lstat()
|
|
if stat.S_ISLNK(info.st_mode):
|
|
entry = ['link', os.readlink(path)]
|
|
elif stat.S_ISREG(info.st_mode):
|
|
entry = ['file', info.st_size]
|
|
elif stat.S_ISDIR(info.st_mode):
|
|
entry = ['directory']
|
|
else:
|
|
raise ValueError(f'Unexpected model file type: {path}')
|
|
result[str(path.relative_to(root))] = entry
|
|
return result
|
|
|
|
|
|
def check_links(root, weights):
|
|
count = 0
|
|
for name, entry in inventory(weights).items():
|
|
if entry[0] != 'link': continue
|
|
logical = Path('/home/arch/.makepad/weights')/name
|
|
target = Path(entry[1])
|
|
if not target.is_absolute(): target = logical.parent/target
|
|
target = Path(os.path.normpath(target))
|
|
if not target.is_relative_to('/home/arch'):
|
|
raise ValueError(f'Model link leaves the cloned home: {name} -> {target}')
|
|
# Absolute targets must be checked inside the USB, not the live host.
|
|
mapped = root/target.relative_to('/')
|
|
if not mapped.resolve().is_relative_to(root.resolve()) or not mapped.is_file():
|
|
raise ValueError(f'Model link does not resolve inside {root}: {name} -> {target}')
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--destination', type=Path, required=True)
|
|
parser.add_argument('--seed', type=Path, required=True)
|
|
parser.add_argument('--serial', required=True, help='Expected external USB serial from lsblk')
|
|
parser.add_argument('--source-home', type=Path, default=Path('/home/arch'))
|
|
args = parser.parse_args()
|
|
if os.geteuid() != 0: parser.error('Run as root on the Linux clone')
|
|
destination = args.destination.resolve(strict=True)
|
|
seed = args.seed.resolve(strict=True)
|
|
home = args.source_home.resolve(strict=True)
|
|
mount = json.loads(output('findmnt', '-J', '-M', destination))['filesystems'][0]
|
|
if mount['fstype'] != 'btrfs' or destination == Path('/'):
|
|
raise ValueError('Destination must be a separately mounted Btrfs USB root')
|
|
device = mount['source'].split('[', 1)[0]
|
|
parent = output('lsblk', '-ndo', 'PKNAME', device)
|
|
if not parent or output('lsblk', '-ndo', 'TRAN', '/dev/'+parent) != 'usb':
|
|
raise ValueError('Destination is not on a USB disk')
|
|
if output('lsblk', '-ndo', 'SERIAL', '/dev/'+parent) != args.serial:
|
|
raise ValueError('USB serial does not match the selected drive')
|
|
seed_mount = json.loads(output('findmnt', '-J', '-M', seed))['filesystems'][0]
|
|
if output('lsblk', '-ndo', 'PKNAME', seed_mount['source']) != parent or seed_mount['fstype'] != 'iso9660':
|
|
raise ValueError('Seed must be the read-only ISO partition on the same USB')
|
|
if not (seed/'clone.json').is_file() or not (seed/'ssh.sh').is_file():
|
|
raise ValueError('USB seed lacks the cloned runtime and new SSH setup')
|
|
if output('findmnt', '-no', 'FSTYPE', '-T', home) != 'btrfs':
|
|
raise ValueError('Source models must be on Btrfs to retain shared extents')
|
|
model_destination = destination/'home/arch/models'
|
|
weights_destination = destination/'home/arch/.makepad/weights'
|
|
for path in (model_destination, weights_destination):
|
|
if path.exists(): raise ValueError(f'Refusing to replace an existing model tree: {path}')
|
|
models = home/'models'
|
|
weights = home/'.makepad/weights'
|
|
check_links(Path('/'), weights)
|
|
run('btrfs', 'filesystem', 'resize', 'max', destination)
|
|
usage = output('btrfs', 'filesystem', 'du', '-s', '--raw', models).splitlines()[-1].split()
|
|
required = int(usage[1]) + int(usage[2]) + 35_000_000_000
|
|
free = shutil.disk_usage(destination).free
|
|
if free < required:
|
|
raise ValueError(f'Models plus OS reserve need {required} bytes; USB has {free} free')
|
|
state = Path('/var/lib/makepad-model-transfer')
|
|
stage = state/'payload'
|
|
receive_directory = destination/'var/lib/makepad-model-transfer'
|
|
if stage.exists() or receive_directory.exists():
|
|
raise ValueError('A previous transfer exists; inspect it before restarting')
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
receive_directory.mkdir(parents=True)
|
|
run('btrfs', 'subvolume', 'create', stage)
|
|
print('Creating a stable model snapshot using shared extents.', flush=True)
|
|
run('cp', '-a', '--reflink=always', models, stage/'models')
|
|
run('cp', '-a', '--reflink=always', weights, stage/'weights')
|
|
# Each booted machine generates its own AI Hub identity and lock file.
|
|
for name in ('node-key', 'service.lock'):
|
|
(stage/'weights'/name).unlink(missing_ok=True)
|
|
run('btrfs', 'property', 'set', '-ts', stage, 'ro', 'true')
|
|
expected = {'models': inventory(stage/'models'), 'weights': inventory(stage/'weights')}
|
|
print(f'Model snapshot ready; about {required-35_000_000_000:,} unique bytes plus model links.', flush=True)
|
|
(state/'inventory.json').write_text(json.dumps(expected, indent=2)+'\n')
|
|
started = time.monotonic()
|
|
last = started
|
|
transferred = 0
|
|
sender = subprocess.Popen(['btrfs', 'send', '--proto', '2', '--compressed-data', str(stage)], stdout=subprocess.PIPE)
|
|
receiver = subprocess.Popen(['btrfs', 'receive', str(receive_directory)], stdin=subprocess.PIPE)
|
|
try:
|
|
while block := sender.stdout.read(4*1024*1024):
|
|
receiver.stdin.write(block)
|
|
transferred += len(block)
|
|
now = time.monotonic()
|
|
if now-last >= 10:
|
|
print(f'Transferred {transferred/1e9:.1f} GB in {now-started:.0f}s ({transferred/(now-started)/1e6:.0f} MB/s).', flush=True)
|
|
last = now
|
|
receiver.stdin.close()
|
|
if sender.wait() != 0 or receiver.wait() != 0:
|
|
raise RuntimeError('Btrfs model transfer failed; source snapshot retained')
|
|
finally:
|
|
for process in (sender, receiver):
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
process.wait()
|
|
received = receive_directory/'payload'
|
|
for name in ('models', 'weights'):
|
|
if inventory(received/name) != expected[name]:
|
|
raise ValueError(f'Received {name} inventory differs from the source snapshot')
|
|
model_destination.parent.mkdir(parents=True, exist_ok=True)
|
|
weights_destination.parent.mkdir(parents=True, exist_ok=True)
|
|
owner = home.stat()
|
|
for path in (model_destination.parent, weights_destination.parent):
|
|
os.chown(path, owner.st_uid, owner.st_gid)
|
|
run('cp', '-a', '--reflink=always', received/'models', model_destination)
|
|
run('cp', '-a', '--reflink=always', received/'weights', weights_destination)
|
|
for name, path in (('models', model_destination), ('weights', weights_destination)):
|
|
if inventory(path) != expected[name]:
|
|
raise ValueError(f'Installed {name} inventory differs from the source snapshot')
|
|
links = check_links(destination, weights_destination)
|
|
print(f'Model paths and sizes match; all {links} model links resolve inside the USB.', flush=True)
|
|
# Install and enable the same units that first-boot provisioning uses.
|
|
# Conditions keep them inactive until package setup has completed.
|
|
for unit in ('makepad-wm.service', 'makepad-aihub.service'):
|
|
target = destination/'etc/systemd/system'/unit
|
|
shutil.copyfile(seed/unit, target)
|
|
target.chmod(0o644)
|
|
run('systemctl', '--root='+str(destination), 'enable', unit)
|
|
run('systemctl', '--root='+str(destination), 'is-enabled', unit)
|
|
for name in ('wm-session.sh', 'aihub-session.sh'):
|
|
target = destination/'usr/local/bin'/('makepad-'+name.removesuffix('.sh'))
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(seed/name, target)
|
|
target.chmod(0o755)
|
|
config = destination/'etc/makepad'
|
|
config.mkdir(parents=True, exist_ok=True)
|
|
for name in ('wm.env', 'aihub.env'):
|
|
shutil.copyfile(seed/name, config/name)
|
|
run('btrfs', 'subvolume', 'delete', received)
|
|
receive_directory.rmdir()
|
|
run('btrfs', 'filesystem', 'sync', destination)
|
|
print('Reading back the USB filesystem with a read-only Btrfs scrub.', flush=True)
|
|
run('btrfs', 'scrub', 'start', '-B', '-r', destination)
|
|
report = {'serial': args.serial, 'stream_bytes': transferred, 'model_links': links,
|
|
'model_files': sum(e[0] == 'file' for e in expected['models'].values()),
|
|
'model_logical_bytes': sum(e[1] for e in expected['models'].values() if e[0] == 'file'),
|
|
'wm_autostart': True, 'aihub_autostart': True,
|
|
'completed_at': time.strftime('%Y-%m-%dT%H:%M:%S%z')}
|
|
path = destination/'var/lib/makepad-model-restore.json'
|
|
path.write_text(json.dumps(report, indent=2)+'\n')
|
|
run('btrfs', 'filesystem', 'sync', destination)
|
|
run('btrfs', 'subvolume', 'delete', stage)
|
|
print('Model restoration verified. WM and AI Hub are enabled for boot.', flush=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|