#!/usr/bin/env python3 """Prepare a disposable Linux CI stack from this checkout, then run the 60s gate.""" import argparse import json import os from pathlib import Path import secrets import shutil import signal import subprocess import sys import time import yaml from regression_gate import supervise from regression_suite import Suite from regression_traffic import owned_box_containers, read_endpoint_snapshot ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories # noqa: E402 def ready(url, processes, timeout=120): deadline = time.monotonic() + timeout while time.monotonic() < deadline: if any(p.poll() is not None for p in processes): raise RuntimeError('A smoke dependency exited; inspect service logs') try: read_endpoint_snapshot(url, timeout_seconds=1) return except (RuntimeError, OSError): time.sleep(0.2) raise RuntimeError(f'Service did not become ready: {url}') def run(args): if subprocess.check_output( ['docker', 'ps', '-a', '--filter', 'label=langbot.box=true', '--format', '{{.ID}}'], text=True, timeout=10 ).strip(): raise RuntimeError('CI needs a dedicated Docker daemon with no existing Box sandboxes') root = args.output.resolve() root.mkdir(parents=True, exist_ok=False, mode=0o700) private = root / '.private' private.mkdir(mode=0o700) processes, logs = [], [] key, runtime_token, box_token = (secrets.token_urlsafe(32) for _ in range(3)) env = dict( os.environ, LANGBOT_QA_API_KEY=key, LANGBOT_DATA_ROOT=str(private / 'data'), PYTHONPATH=str(ROOT / 'src'), LITELLM_LOCAL_MODEL_COST_MAP='True', PYTHONUNBUFFERED='1', LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=runtime_token, LANGBOT_BOX_CONTROL_TOKEN=box_token, ) def start(name, command): log = (root / f'{name}.log').open('w') logs.append(log) process = subprocess.Popen( command, cwd=private, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True ) processes.append(process) return process.pid try: config_path = create_minimal_config(private, port=15301) create_test_directories(private) config = yaml.safe_load(config_path.read_text()) config['api'].update(global_api_key=key, host='127.0.0.1') config['plugin'].update(enable=True, runtime_ws_url='ws://127.0.0.1:15340/control/ws') config['box'].update(enabled=True, backend='local', runtime={'endpoint': 'ws://127.0.0.1:15310/control/ws'}) config['box']['local'].update( host_root=str(private / 'box'), allowed_mount_roots=[str(private / 'box')], image='python:3.12-alpine' ) config_path.write_text(yaml.safe_dump(config)) profile_path = ROOT / 'tools/regression_profiles/smoke.json' profile = json.loads(profile_path.read_text()) cli_config = private / 'lbctl.yaml' cli_config.write_text( yaml.safe_dump( { 'version': 1, 'current_context': 'smoke', 'contexts': { 'smoke': { 'endpoint': 'http://127.0.0.1:15301', 'credential': {'type': 'env', 'name': 'LANGBOT_QA_API_KEY'}, } }, } ) ) profile['cli'] = {'executable': str(args.lbctl.resolve()), 'config': str(cli_config)} profile['vars'].update( mock_base_url='http://127.0.0.1:18080/v1', embedding_base_url='http://127.0.0.1:18080/v1', core_base_url='http://127.0.0.1:15301', core_health='http://127.0.0.1:15301/healthz', core_db=str(private / 'data/langbot.db'), mock_records=str(root / 'mock-requests.jsonl'), ) profile['plugins'] = [] # Two turns retain the history/isolation oracle while leaving room for setup and cleanup. profile['smoke']['group_turns'] = 2 for resource in profile['resources']: if resource['kind'] == 'model' and resource.get('type') == 'embedding': resource['body']['name'] = 'mock-embedding' for document in resource.get('documents', []): document['file'] = str((profile_path.parent / document['file']).resolve()) pids = {} pids['mock'] = start( 'mock', [ sys.executable, str(ROOT / 'tools/mock_llm.py'), '--port', '18080', '--record', str(root / 'mock-requests.jsonl'), ], ) pids['runtime'] = start( 'runtime', [ sys.executable, '-m', 'langbot_plugin.cli.__init__', 'rt', '--ws-control-port', '15340', '--ws-debug-port', '15341', ], ) pids['box'] = start( 'box', [ sys.executable, '-m', 'langbot_plugin.cli.__init__', 'box', '--host', '127.0.0.1', '--ws-control-port', '15310', ], ) pids['core'] = start( 'core', [sys.executable, str(ROOT / 'main.py'), '--standalone-runtime', '--standalone-box'] ) profile['observe'] = { 'health': { 'core': 'http://127.0.0.1:15301/healthz', 'box': 'http://127.0.0.1:15310/readyz', 'mock': 'http://127.0.0.1:18080/healthz', }, 'pids': pids, } for url in profile['observe']['health'].values(): ready(url, processes) preparation = root / 'preparation' preparation.mkdir() suite = Suite(profile, profile_path, preparation, env) identity = suite.call(['whoami']) # Bind the CLI to the newly bootstrapped Workspace before installing plugins. cli_data = yaml.safe_load(cli_config.read_text()) cli_data['contexts']['smoke']['expected_workspace_uuid'] = identity['workspace_uuid'] cli_config.write_text(yaml.safe_dump(cli_data)) for directory in ('Runner/LocalAgent', 'KnowledgeEngine/LangRAG', 'misc/GeneralParsers'): source = args.plugins / directory package_source = private / source.name shutil.copytree(source, package_source, ignore=shutil.ignore_patterns('.git', '.venv*', '__pycache__')) archive = shutil.make_archive(str(private / source.name), 'zip', root_dir=package_source) suite.call(['plugin', 'install', 'local', '--file', archive, '--wait']) generated = private / 'smoke.json' generated.write_text(json.dumps(profile, indent=2)) return supervise( [ sys.executable, str(ROOT / 'tools/regression_suite.py'), 'run', '--profile', str(generated), '--output', str(root / 'gate/suite'), ], root / 'gate', 60, pids, env, ) finally: for process in reversed(processes): if process.poll() is None: os.killpg(process.pid, signal.SIGTERM) try: process.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) process.wait() for log in logs: log.close() # This is a disposable CI daemon; still delete only this run's mounted sandboxes. for container in owned_box_containers(private / 'box'): subprocess.run(['docker', 'rm', '-f', container], check=True, capture_output=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--lbctl', type=Path, required=True) parser.add_argument('--plugins', type=Path, required=True) args = parser.parse_args() if args.output.exists(): parser.error('--output must be a new directory') try: result = run(args) except Exception as exc: verdict = args.output / 'gate' / 'gate.json' result = json.loads(verdict.read_text()) if verdict.exists() else {} result.update(passed=False, infrastructure_error=f'{type(exc).__name__}: {exc}') verdict.parent.mkdir(parents=True, exist_ok=True) verdict.write_text(json.dumps(result, indent=2) + '\n') raise print(json.dumps(result, indent=2)) raise SystemExit(0 if result['passed'] else 1) if __name__ == '__main__': main()