mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 17:35:19 +08:00
* test(tools): add deterministic lbctl regression job * test(tools): simulate persistent multi-group conversations * test(tools): add finite core smoke with terminal run and file oracles * test(tools): expand smoke coverage across execution stages * ci: add a timed real core smoke and lightweight tool checks * fix(ci): keep infrastructure failures visible in smoke verdicts
236 lines
8.9 KiB
Python
236 lines
8.9 KiB
Python
#!/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()
|