test(ci): add a 60-second core smoke gate and regression jobs (#2711)

* 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
This commit is contained in:
huanghuoguoguo
2026-10-23 16:24:26 +08:00
committed by GitHub
parent 38ec3cc9be
commit 5ec41f15a1
22 changed files with 6871 additions and 5 deletions
+113
View File
@@ -0,0 +1,113 @@
name: Core Smoke
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, develop]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: core-smoke-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tools:
name: Regression tools
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install PyYAML==6.0.3
- name: Check regression tools
run: timeout 60s python -m unittest discover -s tools -p 'test_*.py' -v
smoke:
name: Core smoke (60s)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install locked dependencies
run: uv sync --dev --frozen
- name: Read official plugin source pin
id: plugins
run: |
python - <<'PY'
import json, os
from pathlib import Path
revision = json.loads(Path('tests/fixtures/legacy_plugin_identity_sources.json').read_text())['revision']
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
stream.write(f'revision={revision}\n')
PY
- uses: actions/checkout@v4
with:
repository: langbot-app/langbot-plugins
ref: ${{ steps.plugins.outputs.revision }}
path: .smoke-plugins
persist-credentials: false
- name: Prepare CLI and sandbox image
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p .smoke-bin
gh release download v0.3.0 --repo langbot-app/langbot-cli --dir .smoke-bin \
--pattern lbctl_linux_amd64 --pattern checksums.txt
cd .smoke-bin
sha256sum --check --ignore-missing checksums.txt
chmod +x lbctl_linux_amd64
docker pull python:3.12-alpine
- name: Prepare real services and run the 60-second smoke
run: |
uv run --no-sync python tools/regression_ci.py \
--lbctl .smoke-bin/lbctl_linux_amd64 --plugins .smoke-plugins --output .qa/core-smoke
- name: Publish gate result
if: always()
run: |
python - <<'PY'
import json, os
from pathlib import Path
path = Path('.qa/core-smoke/gate/gate.json')
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out:
if path.exists():
data = json.loads(path.read_text())
out.write(f"Core smoke: {'PASS' if data['passed'] else 'FAIL'}; "
f"{data.get('elapsed_seconds', 0):.2f}/60 seconds; {data.get('requests', 0)} requests.\n")
if data.get('infrastructure_error'):
out.write('Preparation or teardown failed; inspect service logs.\n')
out.write('Service/dependency preparation is outside the 60-second execution budget.\n')
else:
out.write('Core smoke did not produce a verdict; inspect preparation failure.\n')
PY
- name: Preserve verdicts and resource samples
if: always()
uses: actions/upload-artifact@v4
with:
name: core-smoke-${{ github.run_id }}
path: |
.qa/core-smoke/gate/gate.json
.qa/core-smoke/gate/resources.jsonl
.qa/core-smoke/gate/suite.log
.qa/core-smoke/gate/suite/cases.json
.qa/core-smoke/gate/suite/summary.json
.qa/core-smoke/gate/suite/health.json
.qa/core-smoke/gate/suite/cleanup-checks.json
.qa/core-smoke/*.log
if-no-files-found: warn
retention-days: 7
+6 -5
View File
@@ -9,6 +9,10 @@ on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
ruff:
name: Ruff Lint & Format
@@ -25,14 +29,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Run ruff check
run: uv run ruff check src/langbot/ tests/ --output-format=concise
run: uvx ruff@0.14.14 check src/langbot/ tests/ tools/ --output-format=concise
- name: Run ruff format
run: uv run ruff format src --check
run: uvx ruff@0.14.14 format src tools --check
frontend:
name: Frontend Lint
+403
View File
@@ -0,0 +1,403 @@
# LangBot regression tools
Run the deterministic OpenAI Chat Completions endpoint with Python 3.11+, without
API credentials or third-party dependencies:
```sh
python3 tools/mock_llm.py --port 18080 --record /tmp/langbot-mock-requests.jsonl
```
In LangBot, create an `openai-chat-completions` Provider whose base URL is
`http://127.0.0.1:18080/v1` and whose API key is any test-only value. Use a model
from `GET /v1/models`. The loopback address works when Core and this tool run on
the same host; containers must use an address reachable from their own network.
| Model | Behavior |
| --- | --- |
| `mock-chat` | Returns `LANGBOT_CHAT_OK`, including SSE streaming |
| `mock-rag` | Requires the golden document sentinel in the model input, then returns `LANGBOT_RAG_OK` |
| `mock-embedding` | Returns deterministic normalized token-hash vectors from `/v1/embeddings` |
| `mock-box` | Calls `exec` twice to write/read `/workspace/mock-llm.txt`, then returns `LANGBOT_BOX_OK` |
| `mock-401`, `mock-429`, `mock-503` | Returns the selected HTTP failure |
| `mock-slow` | Delays the response by two seconds |
| `mock-slow-stream` | Delays each SSE chunk by 0.2 seconds |
`mock-rag` requires `aurora-parser-rag-9137` in the submitted messages. The
[golden HTML fixture](../skills/skills/langbot-testing/fixtures/rag/parser-golden.html)
contains this marker; retrieve it into the model context without including it
in the user's question.
For `mock-box`, enable Box in the Runner and expose the native `exec` tool. Each
tool result must contain `LANGBOT_BOX_SENTINEL` before the next step is returned.
Tool-call IDs and fragmented streaming arguments use the normal OpenAI protocol.
Scenario progress comes from each request's current conversation turn, so
concurrent sessions and retries do not consume a shared script counter.
Tagged group traffic additionally requires the previous user turn and assistant
acknowledgement in the model input, and rejects another group's history. Final
responses carry a per-turn acknowledgement checked by the traffic driver.
Add fixed response/tool sequences with `--scenarios path/to/scenarios.json`, using
`mock_llm_scenarios.json` as the example. `expect_tool_output` checks a substring
in the previous tool results; `expect_prompt` checks the submitted messages.
The server returns tool calls; it never executes
their commands. Request recordings contain prompts, tool results, responses,
status, and disconnects, but no HTTP headers. Use test data only.
Run the focused HTTP checks on the Linux test host:
```sh
python3 -m unittest discover -s tools -p test_mock_llm.py -v
```
These checks validate the mock server. A LangBot regression must additionally
verify real tool outputs and retrieved document IDs/content. Token usage is
synthetic, and this endpoint does not evaluate answer quality or embedding models.
`mock-embedding` accepts string/batch inputs, float/base64 encoding, and optional
dimensions (default 384). It tests the embedding protocol and RAG plumbing;
token hashes do not establish semantic retrieval quality.
## Normal traffic and resource collection
`regression_traffic.py` executes a JSON list of normal `lbctl` cases at a selected
rate. Each case has a name, CLI arguments, and an expected response substring:
```json
[{"name":"chat","args":["pipeline","run","PIPELINE_UUID","--message","Hello"],"expect":"LANGBOT_CHAT_OK"}]
```
Prepare a Workspace-bound CLI context and its API key environment variable, then
run on the Linux test host:
```sh
python3 tools/regression_traffic.py \
--lbctl /path/to/lbctl --config /path/to/lbctl.yaml \
--cases /path/to/cases.json --output /path/to/new-results-directory \
--duration 600 --rps 2 --workers 4 --cooldown 30 \
--pid core=CORE_PID --pid mock=MOCK_PID \
--health core=http://127.0.0.1:5300/healthz \
--health mock=http://127.0.0.1:18080/healthz
```
Add `--container CONTAINER_ID` for Docker CPU/memory/PID statistics. The executing
account must be able to inspect the selected process trees and Docker daemon.
Resource collection reuses `scripts/cloud_runtime_soak.py`; it does not require
cgroup v2. PID resource totals include descendants, so select disjoint trees.
The result directory contains `requests.jsonl`, `resources.jsonl`, `progress.json`,
and `summary.json`. The requested rate is a target: four workers bound concurrency,
and slow requests lower the achieved rate. Requests with unknown outcomes are
recorded without retry. A command fails its business check if it returns an error
or omits the expected response. Health/resource collection failures also make the
run fail. API key values are redacted from recorded CLI output.
Latency percentiles include the entire CLI invocation and use successful
requests only. Achieved request rate is `requests / load_elapsed_seconds`.
`health_samples` counts sampling rounds; `health_failures` counts rounds with
any failed health probe or process/container resource read.
## Configured regression suites
[`regression_suite.py`](regression_suite.py) turns a JSON profile into a repeatable
resource preparation, traffic, observation, verdict, and cleanup sequence. Start
with [`regression_profiles/groups.json`](regression_profiles/groups.json) for
continuous group conversations. The earlier
[`normal.json`](regression_profiles/normal.json) profile retains independent CLI
requests; parallel CLI workers alone do not simulate persistent groups.
Use a dedicated QA Workspace. First provision the instance with `lbctl install`
or your source deployment, enable Docker Box, start the mock and embedding
backends, and prepare a Workspace-bound `lbctl` config. Set the profile's CLI
config path, backend URLs, and three plugin archive paths. The sample embedding
endpoint must serve `all-MiniLM-L6-v2`; the mock serves generation only. Relative
paths resolve against the profile directory, so adjust them if you move it.
Run on the Linux test host:
```sh
python3 tools/regression_suite.py validate --profile tools/regression_profiles/groups.json
python3 tools/regression_suite.py run \
--profile tools/regression_profiles/groups.json --output .qa/regression/run-001
```
`validate` checks profile syntax and references without contacting the instance.
The output directory for `run` must be new. CLI options `--duration`, `--rps`, and
`--workers` override those values in every phase. No additional Python dependency
is needed.
The profile controls:
- `plugins`: local archives or GitHub/marketplace request files, installed with
`lbctl plugin install ... --wait`.
- `resources`: Providers, models, knowledge bases, document ingestion, Pipelines,
and Bots, created in dependency order. `${resource_id}` refers to a UUID
returned by an earlier creation; `${workspace_uuid}` is the current Workspace.
Pipeline config patches merge with actual server defaults. Independent CLI
cases can use existing resources supplied through `vars`; group mode creates
its own signed HTTP Bots.
- `cases`: CLI argument arrays or `http_group` settings and expected business text. Integer `weight`
values set the mix; `2/1/1/1` means `40/20/20/20`. An optional `body` object
becomes the case's request file. All workers share the weighted sequence.
- `phases`: each phase has a name, duration, target RPS, worker limit, and optional
cooldown. Multiple phases provide a step load. In-flight requests drain at the
end; the concurrency limit can reduce achieved RPS.
- `observe`: health URLs, host PIDs, PID files, and Docker container names/IDs.
Set PIDs for disjoint process trees to avoid counting descendants twice.
`box_data_root` discovers Box containers mounted below that deployment's data
directory on every sample, including sandboxes created as new groups speak.
The complete Job sets it automatically; on an existing instance, set it to
the instance's Box data directory when sampling dynamic Docker sandboxes.
- `thresholds`: minimum achieved/target RPS ratio and maximum P95 seconds per
case, in addition to the existing business and health/resource checks. The
sample limits are starting acceptance settings, not established product SLOs.
- `cleanup`: `on-success` (default), `always`, or `never`. Only recorded resources
created by this run are deleted, in reverse order. Plugins and the existing
instance remain installed. Failed/unknown creations are not automatically
retried; inspect the instance if a creation outcome is unknown.
Failed runs retain resources by default. After inspection, clean them with the
same profile and credentials:
```sh
python3 tools/regression_suite.py cleanup \
--profile tools/regression_profiles/groups.json --output .qa/regression/run-001
```
Cleanup checks the Workspace against `owned.json` and records remaining items
after partial failures. Keep `owned.json` until cleanup completes. Each run also
contains `profile.json`, resolved `resources.json`, and one directory per phase
with the original request/resource records. The root `summary.json` includes
target/achieved RPS, phase results, threshold failures, and the final verdict.
The `regression_suite.py` entry reuses an existing instance and backend processes.
Use the complete lifecycle Job below to own deployment, credentials and backends.
The independent `normal.json` cases use a new-session CLI entry. Group traffic
uses the signed HTTP Bot adapter instead, as described below. Real IM adapter
acceptance and resource-leak verdicts need separate scenarios. Test the suite
plumbing on Linux with:
```sh
python3 -m unittest discover -s tools -p test_regression_suite.py -v
```
### Persistent group traffic
The group profile creates three HTTP Bots routed to chat, Box, and RAG Pipelines.
Their signed `/bots/<uuid>/sync` ingress produces real `GroupMessage` events and
uses Core's session manager. Four groups follow a `2/1/1` case assignment;
members take turns within each group. The same member IDs appear in different
groups to exercise group isolation. Box session selection uses the group launcher ID.
In each group phase, `groups` selects the number of stable sessions and
`members_per_group` selects the rotating member identities. `rps` remains the
aggregate target, so a group's target speaking interval is `groups / rps`
seconds. `workers` bounds total in-flight requests independently of group count.
For example, 8 groups at 4 RPS target one message every 2 seconds per group.
Both the Suite and Job accept `--groups` and `--members-per-group` overrides.
The group count must cover the full weighted case sequence (4 in the sample).
Only one request is in flight per group. A failure or unknown outcome stops that
group without retry while other groups continue. Every selected group must
complete at least two turns in total for the phase to pass. The fake model checks
that the preceding user message and assistant acknowledgement survived in actual
model input, including when a different member speaks next. Cross-group markers
fail the oracle. The normal Box/tool-output and RAG-context checks still apply.
`group-state.json` carries group IDs, turn numbers and acknowledgements across
phases; existing groups keep their conversations when later phases add groups.
It refuses changed cases or a state left in progress by interruption. Do not
clear that marker to blindly resume: inspect the run and use a fresh output
directory when the last turn's outcome is unknown. Each phase summary includes
group-level turn counts and member IDs alongside case latency and health data.
The Suite generates the HTTP Bot signing secret and passes it through
`LANGBOT_QA_GROUP_SECRET`. Resource request bodies containing credentials are
stored under `.private/`; do not share that directory. Cases specify `http_group`
with `url`, `secret_env`, and `message` instead of CLI `args`. Core needs no code
changes. The `/sync` response collapses streaming parts: this tests multi-turn
group processing, not real IM delivery or callback streaming order.
Every selected group must also receive traffic in each phase. Group latency
measures the signed HTTP request through its completed reply; independent CLI
latency includes CLI startup. Compare results within the same traffic mode.
`lbctl` v0.3 may report Bot verification failure after a successful creation
because its safe projection omits event bindings. The Suite records the returned
Bot UUID before reading the full Core Bot API with the endpoint, Workspace and
environment credential confirmed by `lbctl whoami`. It then
checks the masked signing secret and effective route with a signed warmup turn
before load. The creation is not retried.
## Complete lifecycle Job
[`regression_job.py`](regression_job.py) owns the deployment as well as the suite.
Use [`regression_profiles/job.json`](regression_profiles/job.json) to pin the Core
Release and select the suite and plugin input directory. The job runs this chain:
1. `lbctl install --profile all` creates a fresh deployment inside the run directory.
2. A Compose override starts our mock backend on the deployment's private network
and injects a generated test-only API key. The Job binds its own CLI context.
3. The suite installs plugins, creates resources and ingests documents. Each case
runs once as warmup before measured traffic starts.
4. Configured load phases run with health, process-tree and container sampling.
5. Service-log tails and raw request/resource records are retained. Successful
Jobs stop the deployment, remove only sandboxes mounted under their own data
root, and use `lbctl uninstall --purge-data` to reclaim the deployment.
Both generation and embedding use our mock backend, with no external model
service or real model credentials. Plugin archives, the pinned Core image,
`lbctl`, Docker and Compose v2 are Job inputs/prerequisites. This wrapper uses
PyYAML, already included in LangBot's dependencies, to verify the CLI's deployment
record and recover ownership after an interrupted install.
Run on a Linux host with Docker access and permission to inspect the selected
container processes. Use an idle dedicated Docker daemon: current Box runtime
startup cleanup can remove sandboxes from another runtime. The Job refuses to
start when existing Box sandboxes are present. The current `lbctl` installer
also reserves host port 5401 and 2280–2285, independently of its HTTP port.
Set the plugin directory and the `lbctl` executable in `job.json`, then run:
```sh
uv run --no-sync python tools/regression_job.py validate --profile tools/regression_profiles/job.json
uv run --no-sync python tools/regression_job.py run \
--profile tools/regression_profiles/job.json --output .qa/regression/job-001
```
`--duration`, `--rps` and `--workers` override every load phase. Each output
directory must be new. Job cleanup policy is `on-success`, `always`, or `never`;
it governs the whole deployment, whereas suite cleanup governs API resources
when a suite runs on an existing deployment. `purge_data: false` preserves the
deployment data after removing containers. Failed Jobs retain their environment
by default; after inspection use:
```sh
uv run --no-sync python tools/regression_job.py cleanup \
--output .qa/regression/job-001
```
The root `summary.json` is the final Job verdict; `suite/` contains the measured
results, `mock/requests.jsonl` contains backend recordings, and `services.log`
contains up to 10,000 log lines per service. Generated credentials and deployment
inputs stay in `.private/`; share the result files, not that directory. A Job has
its own CLI locator/config and records its deployment identity before cleanup.
Changing that identity makes cleanup fail closed. Cleanup uses the saved Job
state and does not require the original profile or plugin input files.
The Job replaces the real embedding model with `mock-embedding`. This measures
LangBot/plugin/Box/RAG overhead, not real-model inference cost or retrieval
quality. Run the Job plumbing checks on Linux with:
```sh
uv run --no-sync python -m unittest discover -s tools -p 'test_*.py' -v
```
## Finite core smoke
Use [`regression_profiles/smoke.json`](regression_profiles/smoke.json) for a finite
functional check on an existing Core. It prepares 35 resources through `lbctl`,
ingests HTML and plain-text fixtures through GeneralParsers, then executes 28
cases / 43 requests. Reports group the verdicts by stage:
| Stage | Cases and independent checks |
| --- | --- |
| Configuration | Identity and plugins; provider, model, Pipeline and knowledge-base readback; document completed status and Workspace/KB ownership |
| Ingress | Missing and incorrect signatures return HTTP 401 / code 40101 without creating a group AgentRun; valid signatures are exercised by group conversations |
| Conversation | Ordinary and Unicode chat; chat, Box and RAG groups each use two interleaved groups with three turns and rotating members; model input must retain prior user/reply history without cross-group history |
| Model | Slow SSE reply; HTTP 401, 429 and 503 primary failures followed by successful fallback; recorded model/status/stream sequence must match |
| Tools | Docker write/read across rounds, two tool calls in one model response, and expected exit code 7 with separate stdout/stderr returned to the model |
| Delivery | ASCII and Unicode filenames/content delivered as one attachment with exact bytes |
| Retrieval | HTML and text parsing/ingestion, RAG model input, retrieval from two knowledge bases with distinct sentinels and KB ownership, and an empty knowledge base returning an empty list |
Cleanup is checked separately: delete journaled API resources and confirm their
UUIDs are absent from CLI list readback. Evidence goes to `cleanup-checks.json`;
cleanup failures affect the overall verdict.
Start a dedicated `mock_llm.py` with the bundled scenarios and `--record` as
shown above. Copy the smoke profile and configure the CLI executable/config,
service URLs, plugin archives, `vars.mock_records` to that mock recording file,
and `vars.core_db` to the target Core's existing SQLite `langbot.db`. The suite
must read both local files. Keep fixture paths valid relative to the copied
profile. Do not share the mock with other traffic during this run: fallback
checks compare requests appended during each case.
Set `plugins: []` to reuse installed plugins; LocalAgent, LangRAG and
GeneralParsers must still be enabled and initialized. The embedding endpoint
must serve the configured model (`all-MiniLM-L6-v2` by default). Generation is
deterministic mock traffic; Core, plugins, Box, document parsing and retrieval
execute normally.
```sh
uv run --no-sync python tools/regression_suite.py validate --profile /path/to/smoke.json
uv run --no-sync python tools/regression_suite.py run \
--profile /path/to/smoke.json --output .qa/smoke-001
```
A correct reply alone does not pass. Each Pipeline/group request must match one
new AgentRun in the authenticated workspace with `completed` status and a persisted
`run.completed` event. The ledger is opened read-only. This version requires
local SQLite access; remote-only Core and PostgreSQL ledgers are not supported.
The observation timeout does not extend the Runner deadline or retry requests.
Unknown outcomes fail the suite; later turns of that group are skipped while
independent cases continue. An expected tool exit code of 7 passes only when
its output is correct and the agent handles the result and completes normally.
`summary.json` contains the overall and per-stage verdicts; exit code 0 means all
checks and required cleanup passed. `cases.json` records per-request evidence,
`environment.json` records identity/capabilities/plugins, and `owned.json` tracks
created resources. Successful runs delete those API resources. Failures retain
them by default; after inspection use:
```sh
uv run --no-sync python tools/regression_suite.py cleanup \
--profile /path/to/smoke.json --output .qa/smoke-001
```
Box sessions and AgentRun records follow the reused service's normal retention
policy. Load override flags are rejected for smoke. `stage` labels cases;
`fields` compares exact response values by dotted path (including array indices),
`forbid` rejects response substrings, and `mock_requests` checks the actual
model/status/stream sequence. `box_exec` defaults to exit code 0; error-handling
cases can specify `exit_code` and `stderr` explicitly.
To use a fresh deployment, select this suite in `job.json`; the Job supplies its
SQLite/recording paths and uses mock embedding. Smoke skips load warmup and timed
phases. The dedicated Docker daemon requirement above still applies.
## PR smoke gate
`Core Smoke` runs regression-tool tests and a real Core smoke in parallel on
GitHub-hosted Linux runners. The smoke starts Core from the checked-out candidate,
uses the SDK from `uv.lock`, the existing official-plugin source pin, `lbctl`
v0.3.0, a Docker Box and mock generation/embedding. It needs no model credentials
or access to a developer's running environment.
Dependency installation, service startup, sandbox image download and plugin
installation are preparation. After readiness, the gate allows **60 seconds total**
for API resource creation, 28 cases / 37 requests (two turns per group), verdict
checks and API cleanup. A missing verdict, failed check or timeout fails the gate.
The ordinary expanded profile keeps three turns / 43 requests. The entire cold
workflow takes longer than a minute; it has a separate 15-minute timeout.
On an existing prepared stack, use:
```sh
uv run --no-sync python tools/regression_gate.py \
--profile /path/to/smoke.json --output .qa/gate-001
```
`gate.json` records the budget, measured duration, verdict and process resource
peaks. `resources.jsonl` samples configured process trees every second (RSS,
cumulative CPU time, threads and file descriptors); suite artifacts contain
per-case timings and business evidence. These are diagnostics, not calibrated
performance-regression thresholds. Docker sandbox resource sampling remains in
the longer load runner. A timeout kills the local Suite/CLI process group without
retrying requests; a reused remote service can still have an in-flight run, so
inspect its retained ownership journal before cleanup. CI tears down its own
service processes and owned sandboxes afterward.
The workflow always creates its two checks on PRs and cancels superseded runs.
After the workflow is merged, configure `Regression tools` and `Core smoke (60s)`
as required status checks in the repository ruleset. A green workflow alone does
not enforce merge protection. Longer load and soak tests use the existing Suite
and Job entry points with separately calibrated latency, throughput and resource
budgets; the one-minute smoke does not replace them.
+2
View File
@@ -0,0 +1,2 @@
The separate knowledge base sentinel is cedar-isolation-4826.
This document belongs only to the second smoke knowledge base.
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""A deterministic OpenAI Chat Completions server for local regression tests."""
import argparse
import base64
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import math
from pathlib import Path
import re
import struct
import threading
import time
import uuid
MAX_BODY_BYTES = 1024 * 1024
USAGE = {'prompt_tokens': 10, 'completion_tokens': 5, 'total_tokens': 15}
GROUP_TURN = re.compile(r'LB_GROUP=(\S+) LB_TURN=(\d+) LB_TOKEN=([a-f0-9]+) LB_PREVIOUS=(none|[a-f0-9]+)')
def group_ack(messages, start):
"""Require a real previous user turn and reply, with no other group's history."""
turns = []
for index, message in enumerate(messages):
if message.get('role') == 'user':
for match in GROUP_TURN.finditer(str(message.get('content', ''))):
turns.append((index, *match.groups()))
if not turns:
return ''
position, group, turn, token, previous = turns[-1]
if position != start or any(item[1] != group for item in turns):
raise ValueError('group history contains another group or lacks the current turn')
if int(turn) == 1:
if len(turns) != 1 or previous != 'none':
raise ValueError('first group turn must have an empty history')
else:
if len(turns) < 2:
raise ValueError('previous group turn is missing from model history')
prior_position, _, prior_turn, prior_token, _ = turns[-2]
replies = messages[prior_position + 1 : start]
if (
int(prior_turn) != int(turn) - 1
or prior_token != previous
or not any(
message.get('role') == 'assistant' and f'GROUP_ACK={previous}' in str(message.get('content', ''))
for message in replies
)
):
raise ValueError('previous group turn or assistant acknowledgement does not match')
return f' GROUP_ACK={token}'
def mock_embeddings(body):
"""Return deterministic normalized token hashes, not semantic model inference."""
inputs = body.get('input')
inputs = [inputs] if isinstance(inputs, str) else inputs
if (
not isinstance(inputs, list)
or not 1 <= len(inputs) <= 128
or any(not isinstance(text, str) or not text for text in inputs)
):
raise ValueError('embedding input must contain 1..128 non-empty strings')
dimensions = body.get('dimensions', 384)
if isinstance(dimensions, bool) or not isinstance(dimensions, int) or not 1 <= dimensions <= 4096:
raise ValueError('embedding dimensions must be an integer in 1..4096')
encoding = body.get('encoding_format', 'float')
if encoding not in ('float', 'base64'):
raise ValueError('embedding encoding_format must be float or base64')
data = []
for index, text in enumerate(inputs):
vector = [0.0] * dimensions
for token in re.findall(r'\w+', text.casefold()) or [text]:
digest = hashlib.sha256(token.encode()).digest()
vector[int.from_bytes(digest[:4], 'little') % dimensions] += 1 if digest[4] % 2 else -1
norm = math.sqrt(sum(value * value for value in vector))
if not norm:
vector[0], norm = 1.0, 1.0
vector = [value / norm for value in vector]
embedding = (
base64.b64encode(struct.pack(f'<{dimensions}f', *vector)).decode() if encoding == 'base64' else vector
)
data.append({'object': 'embedding', 'index': index, 'embedding': embedding})
return {'object': 'list', 'model': body['model'], 'data': data, 'usage': {'prompt_tokens': 10, 'total_tokens': 10}}
def select_message(body, scenario):
messages = body.get('messages')
if not isinstance(messages, list) or not messages or any(not isinstance(m, dict) for m in messages):
raise ValueError('messages must be a non-empty array of objects')
if not isinstance(body.get('stream', False), bool):
raise ValueError('stream must be a boolean')
if body.get('stream_options') is not None and not isinstance(body['stream_options'], dict):
raise ValueError('stream_options must be an object')
# Select from the current turn's history, never a shared request counter.
start = next(
(i for i in range(len(messages) - 1, -1, -1) if messages[i].get('role') == 'user'),
None,
)
if start is None:
raise ValueError('a user message is required')
acknowledgement = group_ack(messages, start)
turn = messages[start:]
calls = [i for i, m in enumerate(turn) if m.get('role') == 'assistant' and m.get('tool_calls')]
for index, position in enumerate(calls):
end = calls[index + 1] if index + 1 < len(calls) else len(turn)
requested = {call['id'] for call in turn[position]['tool_calls']}
returned = [m.get('tool_call_id') for m in turn[position + 1 : end] if m.get('role') == 'tool']
if len(returned) != len(requested) or set(returned) != requested:
raise ValueError('each tool call must have exactly one matching tool result')
steps = scenario['steps']
if len(calls) >= len(steps):
raise ValueError('tool history exceeds the configured scenario')
step = steps[len(calls)]
if expected_prompt := step.get('expect_prompt'):
if expected_prompt not in json.dumps(messages, ensure_ascii=False):
raise ValueError(f'model input must contain {expected_prompt!r}')
expected = step.get('expect_tool_output')
if expected is not None:
results = turn[calls[-1] + 1 :] if calls else []
result_text = '\n'.join(str(m.get('content', '')) for m in results if m.get('role') == 'tool')
if expected not in result_text:
raise ValueError(f'tool result must contain {expected!r}')
content = step.get('content')
message = {'role': 'assistant', 'content': content + acknowledgement if content is not None else None}
if tool_calls := step.get('tool_calls'):
tools = body.get('tools', [])
if not isinstance(tools, list) or any(
not isinstance(t, dict) or not isinstance(t.get('function'), dict) for t in tools
):
raise ValueError('tools must be an array of objects')
available = {t.get('function', {}).get('name') for t in tools if t.get('type') == 'function'}
if body.get('tool_choice') == 'none' or any(call['name'] not in available for call in tool_calls):
raise ValueError('scenario tools must be advertised and enabled by the caller')
message['tool_calls'] = [
{
'id': f'call_mock_{len(calls)}_{i}',
'type': 'function',
'function': {
'name': call['name'],
'arguments': json.dumps(call['arguments']),
},
}
for i, call in enumerate(tool_calls)
]
return message
class MockServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address, scenarios, record_path=None):
super().__init__(address, Handler)
self.scenarios = scenarios
self.record_path = record_path
self.record_lock = threading.Lock()
def record(self, entry):
if self.record_path:
with (
self.record_lock,
self.record_path.open('a', encoding='utf-8') as stream,
):
stream.write(json.dumps(entry, ensure_ascii=False) + '\n')
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def json_response(self, status, payload):
data = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path == '/healthz':
self.json_response(200, {'status': 'ok'})
elif self.path == '/v1/models':
self.json_response(
200,
{
'object': 'list',
'data': [
{
'id': name,
'object': 'model',
'created': 0,
'owned_by': 'mock',
}
for name in [*self.server.scenarios, 'mock-embedding']
],
},
)
else:
self.json_response(
404,
{
'error': {
'message': 'unknown endpoint',
'type': 'invalid_request_error',
}
},
)
def do_POST(self):
entry = {'request_id': uuid.uuid4().hex, 'time': time.time(), 'path': self.path, 'status': 400}
try:
if self.path not in ('/v1/chat/completions', '/v1/embeddings'):
entry['status'] = 404
self.json_response(
404,
{
'error': {
'message': 'unknown endpoint',
'type': 'invalid_request_error',
}
},
)
return
self.connection.settimeout(10)
length = int(self.headers.get('Content-Length', 0))
if self.headers.get('Transfer-Encoding') or not 0 < length <= MAX_BODY_BYTES:
raise ValueError('a Content-Length between 1 and 1048576 is required')
body = json.loads(self.rfile.read(length))
if not isinstance(body, dict) or not isinstance(body.get('model'), str):
raise ValueError('request must be an object with a model name')
entry['request'] = body
if self.path == '/v1/embeddings':
if body['model'] != 'mock-embedding':
raise ValueError('embedding model must be mock-embedding')
payload = mock_embeddings(body)
entry.update(status=200, response=payload)
self.json_response(200, payload)
return
scenario = self.server.scenarios.get(body['model'])
if scenario is None:
raise ValueError('unknown model; use GET /v1/models')
message = select_message(body, scenario)
time.sleep(scenario.get('delay_seconds', 0))
status = scenario.get('status', 200)
entry['status'] = status
if status != 200:
self.json_response(
status,
{
'error': {
'message': 'injected mock failure',
'type': 'api_error',
'code': str(status),
}
},
)
return
entry['response'] = message
base = {
'id': 'chatcmpl-' + entry['request_id'],
'created': int(time.time()),
'model': body['model'],
}
finish = 'tool_calls' if message.get('tool_calls') else 'stop'
if body.get('stream'):
self.stream_response(body, scenario, message, base, finish)
else:
self.json_response(
200,
{
**base,
'object': 'chat.completion',
'choices': [{'index': 0, 'message': message, 'finish_reason': finish}],
'usage': USAGE,
},
)
except (ValueError, KeyError, TypeError, TimeoutError) as exc:
entry.update(status=400, error=str(exc))
self.json_response(400, {'error': {'message': str(exc), 'type': 'invalid_request_error'}})
except (BrokenPipeError, ConnectionResetError):
entry['disconnected'] = True
finally:
self.server.record(entry)
def stream_response(self, body, scenario, message, base, finish):
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
def send(delta, reason=None):
payload = {
**base,
'object': 'chat.completion.chunk',
'choices': [{'index': 0, 'delta': delta, 'finish_reason': reason}],
}
self.wfile.write(('data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n').encode())
self.wfile.flush()
time.sleep(scenario.get('chunk_delay_seconds', 0))
send({'role': 'assistant'})
content = message.get('content') or ''
for offset in range(0, len(content), 8):
send({'content': content[offset : offset + 8]})
for index, call in enumerate(message.get('tool_calls', [])):
send(
{
'tool_calls': [
{
'index': index,
'id': call['id'],
'type': 'function',
'function': {
'name': call['function']['name'],
'arguments': '',
},
}
]
}
)
arguments = call['function']['arguments']
for offset in range(0, len(arguments), 16):
send(
{
'tool_calls': [
{
'index': index,
'function': {'arguments': arguments[offset : offset + 16]},
}
]
}
)
send({}, finish)
if (body.get('stream_options') or {}).get('include_usage'):
payload = {
**base,
'object': 'chat.completion.chunk',
'choices': [],
'usage': USAGE,
}
self.wfile.write(('data: ' + json.dumps(payload) + '\n\n').encode())
self.wfile.write(b'data: [DONE]\n\n')
self.wfile.flush()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', default='127.0.0.1')
parser.add_argument('--port', type=int, default=18080)
parser.add_argument(
'--scenarios',
type=Path,
default=Path(__file__).with_name('mock_llm_scenarios.json'),
)
parser.add_argument(
'--record',
type=Path,
help='Append request/response JSONL without HTTP headers; use test data only',
)
args = parser.parse_args()
scenarios = json.loads(args.scenarios.read_text(encoding='utf-8'))
if not isinstance(scenarios, dict) or not scenarios:
parser.error('scenarios must be a non-empty object keyed by model name')
for name, scenario in scenarios.items():
if not isinstance(scenario, dict) or not isinstance(scenario.get('steps'), list) or not scenario['steps']:
parser.error(f'{name}: steps must be a non-empty array')
if any(not isinstance(step, dict) for step in scenario['steps']):
parser.error(f'{name}: each step must be an object')
for step in scenario['steps']:
if not isinstance(step.get('content'), str) and not step.get('tool_calls'):
parser.error(f'{name}: each step needs content or tool_calls')
if 'expect_tool_output' in step and not isinstance(step['expect_tool_output'], str):
parser.error(f'{name}: expect_tool_output must be a string')
if 'expect_prompt' in step and not isinstance(step['expect_prompt'], str):
parser.error(f'{name}: expect_prompt must be a string')
if 'tool_calls' in step:
calls = step['tool_calls']
if not isinstance(calls, list) or any(
not isinstance(call, dict)
or not isinstance(call.get('name'), str)
or not isinstance(call.get('arguments'), dict)
for call in calls
):
parser.error(f'{name}: tool_calls need a name and arguments object')
if scenario.get('status', 200) not in (200, 401, 429, 500, 503):
parser.error(f'{name}: unsupported injected HTTP status')
for key in ('delay_seconds', 'chunk_delay_seconds'):
value = scenario.get(key, 0)
if not isinstance(value, (int, float)) or not 0 <= value <= 60:
parser.error(f'{name}: {key} must be between 0 and 60')
with MockServer((args.host, args.port), scenarios, args.record) as server:
print(f'Mock LLM: http://{args.host}:{server.server_port}/v1', flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == '__main__':
main()
+169
View File
@@ -0,0 +1,169 @@
{
"mock-chat": {
"steps": [{"content": "LANGBOT_CHAT_OK"}]
},
"mock-rag": {
"steps": [{"expect_prompt": "aurora-parser-rag-9137", "content": "LANGBOT_RAG_OK"}]
},
"mock-box": {
"steps": [
{"tool_calls": [{"name": "exec", "arguments": {"command": "printf LANGBOT_BOX_SENTINEL > /workspace/mock-llm.txt; cat /workspace/mock-llm.txt"}}]},
{"expect_tool_output": "LANGBOT_BOX_SENTINEL", "tool_calls": [{"name": "exec", "arguments": {"command": "cat /workspace/mock-llm.txt"}}]},
{"expect_tool_output": "LANGBOT_BOX_SENTINEL", "content": "LANGBOT_BOX_OK"}
]
},
"mock-401": {"status": 401, "steps": [{"content": "unused"}]},
"mock-429": {"status": 429, "steps": [{"content": "unused"}]},
"mock-503": {"status": 503, "steps": [{"content": "unused"}]},
"mock-slow": {"delay_seconds": 2, "steps": [{"content": "LANGBOT_SLOW_OK"}]},
"mock-slow-stream": {"chunk_delay_seconds": 0.2, "steps": [{"content": "LANGBOT_SLOW_STREAM_OK"}]},
"mock-smoke-box": {
"steps": [
{
"tool_calls": [
{
"name": "exec",
"arguments": {
"command": "printf LANGBOT_BOX_SENTINEL > /tmp/langbot-smoke.txt; cat /tmp/langbot-smoke.txt"
}
}
]
},
{
"expect_tool_output": "LANGBOT_BOX_SENTINEL",
"tool_calls": [
{
"name": "exec",
"arguments": {
"command": "cat /tmp/langbot-smoke.txt"
}
}
]
},
{
"expect_tool_output": "LANGBOT_BOX_SENTINEL",
"content": "LANGBOT_BOX_OK"
}
]
},
"mock-smoke-export": {
"steps": [
{
"tool_calls": [
{
"name": "exec",
"arguments": {
"command": "printf LANGBOT_EXPORT_SENTINEL > /tmp/langbot-smoke-output.txt; cat /tmp/langbot-smoke-output.txt"
}
}
]
},
{
"expect_tool_output": "LANGBOT_EXPORT_SENTINEL",
"tool_calls": [
{
"name": "export_box_files",
"arguments": {
"paths": [
"/tmp/langbot-smoke-output.txt"
]
}
}
]
},
{
"expect_tool_output": "accepted",
"content": "LANGBOT_EXPORT_OK"
}
]
},
"mock-smoke-unicode": {
"steps": [
{
"expect_prompt": "请原样处理:你好,世界 🌍",
"content": "你好,世界 🌍 LANGBOT_UNICODE_OK"
}
]
},
"mock-smoke-exec-error": {
"steps": [
{
"tool_calls": [
{
"name": "exec",
"arguments": {
"command": "printf EXPECTED_STDOUT; printf EXPECTED_STDERR >&2; exit 7"
}
}
]
},
{
"expect_tool_output": "EXPECTED_STDERR",
"content": "LANGBOT_EXEC_ERROR_HANDLED"
}
]
},
"mock-smoke-parallel-tools": {
"steps": [
{
"tool_calls": [
{
"name": "exec",
"arguments": {
"command": "printf PARALLEL_OK_A"
}
},
{
"name": "exec",
"arguments": {
"command": "printf PARALLEL_OK_B"
}
}
]
},
{
"expect_tool_output": "PARALLEL_OK_B",
"content": "LANGBOT_PARALLEL_TOOLS_OK"
}
]
},
"mock-smoke-export-unicode": {
"steps": [
{
"tool_calls": [
{
"name": "exec",
"arguments": {
"command": "printf '你好,文件 🌍' > /tmp/输出.txt; cat /tmp/输出.txt"
}
}
]
},
{
"expect_tool_output": "你好,文件 🌍",
"tool_calls": [
{
"name": "export_box_files",
"arguments": {
"paths": [
"/tmp/输出.txt"
]
}
}
]
},
{
"expect_tool_output": "accepted",
"content": "LANGBOT_UNICODE_EXPORT_OK"
}
]
},
"mock-smoke-rag-other": {
"steps": [
{
"expect_prompt": "cedar-isolation-4826",
"content": "LANGBOT_OTHER_RAG_OK"
}
]
}
}
+235
View File
@@ -0,0 +1,235 @@
#!/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()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Bound a prepared-environment smoke run and record process resource samples."""
import argparse
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
from regression_suite import Suite, validate
from regression_traffic import read_process_snapshot
def supervise(command, output, budget, pids, env=None):
output.mkdir(parents=True, exist_ok=False, mode=0o700)
started = time.monotonic()
samples, errors = [], []
timed_out = False
with (output / 'suite.log').open('w') as log:
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, env=env, start_new_session=True)
try:
while process.poll() is None:
remaining = budget - (time.monotonic() - started)
if remaining <= 0:
timed_out = True
os.killpg(process.pid, signal.SIGKILL)
process.wait()
break
sample = {'elapsed_seconds': time.monotonic() - started, 'processes': {}}
for name, pid in pids.items():
try:
sample['processes'][name] = read_process_snapshot(pid)
except (RuntimeError, OSError) as exc:
errors.append(f'{name}: {exc}')
samples.append(sample)
remaining = budget - (time.monotonic() - started)
try:
process.wait(timeout=max(0, min(1, remaining)))
except subprocess.TimeoutExpired:
pass
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
elapsed = time.monotonic() - started
(output / 'resources.jsonl').write_text(''.join(json.dumps(sample) + '\n' for sample in samples))
summary_path = output / 'suite' / 'summary.json'
summary = json.loads(summary_path.read_text()) if summary_path.exists() else {}
result = {
'passed': process.returncode == 0
and summary.get('passed') is True
and elapsed <= budget
and not timed_out
and not errors,
'budget_seconds': budget,
'elapsed_seconds': elapsed,
'timed_out': timed_out,
'exit_code': process.returncode,
'resource_errors': errors,
'requests': summary.get('requests'),
'status': summary.get('status'),
'stages': summary.get('stages'),
'resource_peaks': {
name: {
metric: max((s['processes'].get(name, {}).get(metric, 0) for s in samples), default=0)
for metric in ('rss_bytes', 'threads', 'open_fds')
}
for name in pids
},
}
(output / 'gate.json').write_text(json.dumps(result, indent=2) + '\n')
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--profile', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
profile = json.loads(args.profile.read_text())
validate(profile)
if 'smoke' not in profile:
parser.error('a smoke profile is required')
suite = Suite(profile, args.profile.resolve(), args.output / 'suite')
pids = dict(profile.get('observe', {}).get('pids', {}))
for name, path in profile.get('observe', {}).get('pid_files', {}).items():
pids[name] = int(Path(suite.path(path)).read_text().strip())
result = supervise(
[
sys.executable,
str(Path(__file__).with_name('regression_suite.py')),
'run',
'--profile',
str(args.profile.resolve()),
'--output',
str(args.output.resolve() / 'suite'),
],
args.output,
60,
pids,
)
print(json.dumps(result, indent=2))
raise SystemExit(0 if result['passed'] else 1)
if __name__ == '__main__':
main()
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""Own one complete lbctl deployment, mock backend, regression, and teardown."""
import argparse
import copy
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import subprocess
import time
import urllib.error
import urllib.request
import yaml
from regression_suite import Suite, number, resolve, validate
from regression_traffic import owned_box_containers
KEY_ENV = 'LANGBOT_QA_API_KEY'
def write_json(path, data):
temporary = path.with_suffix(path.suffix + '.tmp')
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2))
temporary.chmod(0o600)
temporary.replace(path)
def load_profile(path):
profile = json.loads(path.read_text())
if not isinstance(profile, dict) or profile.get('version') != 1:
raise ValueError('job profile version must be 1')
if profile.keys() - {'version', 'lbctl', 'suite', 'deployment', 'vars', 'cleanup', 'purge_data', 'mock_image'}:
raise ValueError('unknown job profile field')
deployment = profile.get('deployment', {})
if not isinstance(deployment, dict) or deployment.keys() - {'version', 'port'}:
raise ValueError('deployment accepts version and port')
version = deployment.get('version', '')
if not isinstance(version, str) or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', version):
raise ValueError('deployment.version must pin a stable vX.Y.Z Release')
number(deployment.get('port', 5300), 'deployment port', minimum=1, integer=True)
if deployment.get('port', 5300) > 65535:
raise ValueError('deployment port must be <= 65535')
if profile.get('cleanup', 'on-success') not in ('always', 'on-success', 'never'):
raise ValueError('cleanup must be always/on-success/never')
if not isinstance(profile.get('purge_data', True), bool):
raise ValueError('purge_data must be boolean')
suite_path = Path(profile['suite']).expanduser()
suite_path = suite_path if suite_path.is_absolute() else path.parent / suite_path
suite = json.loads(suite_path.read_text())
suite.setdefault('vars', {}).update(profile.get('vars', {}))
suite['vars'].update(
mock_base_url='http://regression_mock:18080/v1', embedding_base_url='http://regression_mock:18080/v1'
)
suite['cli'] = {'config': 'job-generated.yaml'}
for item in suite.get('resources', []):
if item['kind'] == 'model' and item.get('type') == 'embedding':
item['body']['name'] = 'mock-embedding'
validate(suite)
return profile, suite, suite_path.resolve()
class Job:
def __init__(self, profile, suite_profile, suite_path, output):
self.profile = profile
self.suite_profile = copy.deepcopy(suite_profile)
self.suite_path = suite_path
self.output = output.resolve()
self.private = self.output / '.private'
self.deploy = self.private / 'deployment'
self.state_path = self.output / 'state.json'
self.cli_config = self.private / 'lbctl.yaml'
self.env = {**os.environ, 'XDG_CONFIG_HOME': str(self.private / 'config')}
self.cli = [profile.get('lbctl', 'lbctl'), '--config', str(self.cli_config), '-o', 'json']
self.redactions = []
self.state = {'job_id': secrets.token_hex(8), 'record': None, 'bound': False, 'phase': 'created'}
def redact(self, value):
for secret in self.redactions:
value = value.replace(secret, '[REDACTED]')
return value
def save(self):
write_json(self.state_path, self.state)
def recover_record(self):
if (self.private / 'job-id').read_text().strip() != self.state['job_id']:
raise RuntimeError('Job ownership marker differs from state.json')
path = self.deploy / 'deployment.yaml'
if not path.exists():
if self.state.get('record'):
raise RuntimeError('deployment record disappeared; refusing cleanup')
return
if path.is_symlink():
raise RuntimeError('deployment record must not be a symlink')
try:
record = yaml.safe_load(path.read_text())
except yaml.YAMLError as exc:
raise ValueError('invalid deployment YAML') from exc
if not isinstance(record, dict) or not record.get('deployment_id'):
raise RuntimeError('invalid deployment record')
if (
Path(record['compose_file']).resolve().parent != self.deploy
or Path(record['data_dir']).resolve() != self.deploy / 'data'
or Path(record['env_file']).resolve().parent != self.deploy
):
raise RuntimeError('deployment record is outside this Job')
if self.state.get('record') and record['deployment_id'] != self.state['record']['deployment_id']:
raise RuntimeError('deployment identity changed; refusing cleanup')
self.state['record'] = record
self.save()
def load_redactions(self):
if self.state.get('record'):
path = Path(self.state['record']['env_file'])
if path.exists():
for line in path.read_text().splitlines():
name, separator, value = line.partition('=')
if separator and any(word in name for word in ('TOKEN', 'KEY')) and len(value) >= 16:
self.redactions.append(value)
def command(self, args, timeout=900):
result = subprocess.run(args, capture_output=True, text=True, env=self.env, timeout=timeout)
return result.returncode, self.redact(result.stdout), self.redact(result.stderr)
def checked(self, args, timeout=900):
code, stdout, stderr = self.command(args, timeout)
if code:
raise RuntimeError((stderr + stdout)[-4000:])
return stdout
def cli_call(self, args):
code, stdout, stderr = self.command([*self.cli, *args])
result = json.loads(stdout) if stdout.strip() else {}
data = result.get('data', {})
if args[0] == 'install' and isinstance(data, dict) and data.get('record'):
record = data['record']
if (
Path(record['compose_file']).resolve().parent != self.deploy
or Path(record['data_dir']).resolve() != self.deploy / 'data'
):
raise RuntimeError('install returned a deployment outside this Job')
self.state['record'] = record
self.save() # Installation may have changed state even when its final health check failed.
if code:
raise RuntimeError((stderr + stdout)[-4000:])
return data
def compose(self, *args, override=False):
record = self.state['record']
command = ['docker', 'compose', '--project-name', record['compose_project'], '--file', record['compose_file']]
if override:
command += ['--file', str(self.private / 'compose.override.json')]
command += ['--env-file', record['env_file'], '--profile', 'all', *args]
return self.checked(command)
def project_containers(self):
project = self.state['record']['compose_project']
output = self.checked(
[
'docker',
'ps',
'-a',
'--filter',
f'label=com.docker.compose.project={project}',
'--format',
'{{.ID}} {{.Label "com.docker.compose.service"}}',
]
)
return {service: container for container, service in (line.split() for line in output.splitlines() if line)}
def address(self, container):
networks = json.loads(
self.checked(['docker', 'inspect', '--format', '{{json .NetworkSettings.Networks}}', container])
)
addresses = [network['IPAddress'] for network in networks.values() if network.get('IPAddress')]
if len(addresses) != 1:
raise RuntimeError('expected one private container network')
return addresses[0]
@staticmethod
def ready(url):
deadline = time.monotonic() + 120
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
while time.monotonic() < deadline:
try:
with opener.open(url, timeout=2) as response:
payload = json.load(response)
if response.status == 200 and payload.get('ready') is not False and payload.get('code', 0) == 0:
return
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
pass
time.sleep(0.5)
raise RuntimeError(f'readiness deadline exceeded: {url}')
def prepare(self):
variables = {
**self.suite_profile.get('vars', {}),
'profile_dir': str(self.suite_path.parent),
'run_dir': str(self.output),
}
inputs = [plugin['file'] for plugin in self.suite_profile.get('plugins', [])]
inputs += [
document['file']
for item in self.suite_profile.get('resources', [])
for document in item.get('documents', [])
]
for name in inputs:
path = Path(resolve(name, variables)).expanduser()
path = path if path.is_absolute() else self.suite_path.parent / path
if not path.is_file():
raise ValueError(f'Job input file is missing: {path}')
self.state['phase'] = 'install'
self.save()
# Current Box runtimes reap sandboxes from previous runtime IDs on startup.
# Do not start one alongside another user's live sandbox on this daemon.
boxes = self.checked(['docker', 'ps', '-a', '--filter', 'label=langbot.box=true', '--format', '{{.ID}}'])
if boxes.strip():
raise RuntimeError('Docker daemon has existing Box sandboxes; use an idle dedicated test daemon')
key = secrets.token_urlsafe(32)
self.env[KEY_ENV] = key
self.env['LANGBOT_API_KEY'] = key
self.redactions.append(key)
(self.private / 'api-key').write_text(key)
(self.private / 'api-key').chmod(0o600)
deployment = self.profile['deployment']
self.cli_call(
[
'install',
'--dir',
str(self.deploy),
'--version',
deployment['version'],
'--profile',
'all',
'--port',
str(deployment.get('port', 5300)),
]
)
self.recover_record()
self.load_redactions()
mock_results = self.output / 'mock'
mock_results.mkdir(mode=0o700)
override = {
'services': {
'langbot': {
'environment': {'API__GLOBAL_API_KEY': key},
'volumes': [f'{self.deploy / "data/box"}:{self.deploy / "data/box"}'],
},
'regression_mock': {
'image': self.profile.get('mock_image', 'python:3.12-alpine'),
'volumes': [f'{Path(__file__).parent.resolve()}:/regression:ro', f'{mock_results}:/results'],
'command': [
'python',
'/regression/mock_llm.py',
'--host',
'0.0.0.0',
'--port',
'18080',
'--record',
'/results/requests.jsonl',
],
'networks': ['langbot_network'],
'labels': {'langbot.regression_job': self.state['job_id']},
},
},
}
write_json(self.private / 'compose.override.json', override)
self.compose('up', '-d', override=True)
containers = self.project_containers()
for service in ('langbot', 'langbot_plugin_runtime', 'langbot_box', 'regression_mock'):
if service not in containers:
raise RuntimeError(f'missing deployment service: {service}')
endpoint = self.state['record']['endpoint']
health = {
'core': endpoint + '/healthz',
'mock': f'http://{self.address(containers["regression_mock"])}:18080/healthz',
'box': f'http://{self.address(containers["langbot_box"])}:5410/readyz',
}
for url in health.values():
self.ready(url)
identity = self.cli_call(['--endpoint', endpoint, 'whoami'])
self.cli_call(
[
'context',
'add',
'regression',
'--endpoint',
endpoint,
'--api-key-env',
KEY_ENV,
'--expect-workspace',
identity['workspace_uuid'],
]
)
self.cli_call(['context', 'use', 'regression'])
self.state.update(
bound=True, workspace_uuid=identity['workspace_uuid'], containers=containers, phase='resources'
)
self.save()
profile = self.suite_profile
profile.setdefault('vars', {})['core_base_url'] = endpoint
if 'smoke' in profile:
if 'mock_records' in profile['smoke']:
profile['smoke']['mock_records'] = str(self.output / 'mock' / 'requests.jsonl')
profile['smoke']['ledger_db'] = str(Path(self.state['record']['data_dir']) / 'langbot.db')
profile['cli'] = {'executable': self.cli[0], 'config': str(self.cli_config)}
profile['observe'] = {'health': health, 'pids': {}, 'containers': list(containers.values())}
for service, container in containers.items():
pid = int(self.checked(['docker', 'inspect', '--format', '{{.State.Pid}}', container]).strip())
profile['observe']['pids'][service] = pid
destination = self.output / 'suite'
destination.mkdir(mode=0o700)
suite = Suite(profile, self.suite_path, destination, env=self.env)
self.redactions.extend(suite.secrets)
suite.write('profile.json', profile)
suite.prepare()
if 'smoke' in profile:
return suite
# Prime every configured business case before measuring steady load.
for case in profile['cases']:
case = resolve(case, suite.variables)
if 'http_group' in case:
continue # Suite preparation already verifies signed group ingress and the masked secret.
data = suite.call(case['args'], case.get('body'))
if case['expect'] not in json.dumps(data, ensure_ascii=False):
raise RuntimeError(f'warmup oracle failed: {case["name"]}')
profile['observe']['box_data_root'] = str(Path(self.state['record']['data_dir']) / 'box')
suite.write('profile.json', profile)
return suite
def owned_sandboxes(self):
record = self.state.get('record')
if not record:
return []
return owned_box_containers(Path(record['data_dir']) / 'box', self.checked)
def collect(self):
self.recover_record()
self.load_redactions()
if self.state.get('record'):
logs = self.compose('logs', '--no-color', '--tail', '10000')
(self.output / 'services.log').write_text(logs)
def cleanup(self):
self.recover_record()
self.load_redactions()
record = self.state.get('record')
if self.state['phase'] == 'cleaned':
if record and self.project_containers():
raise RuntimeError('containers appeared after Job cleanup; inspect deployment before acting')
return
if record:
if (
Path(record['compose_file']).resolve().parent != self.deploy
or Path(record['data_dir']).resolve() != self.deploy / 'data'
):
raise RuntimeError('cleanup refused: deployment is outside this Job')
if self.state['bound']:
self.cli_call(['stop'])
else:
self.compose('stop', override=(self.private / 'compose.override.json').exists())
sandboxes = self.owned_sandboxes()
if sandboxes:
self.checked(['docker', 'rm', '-f', *sandboxes])
if self.state['bound']:
self.cli_call(
['uninstall', '--yes', *(['--purge-data'] if self.profile.get('purge_data', True) else [])]
)
else:
self.compose('down', '--remove-orphans', override=(self.private / 'compose.override.json').exists())
if self.profile.get('purge_data', True) and (self.deploy / 'data').exists():
shutil.rmtree(self.deploy / 'data')
for path in (self.private / 'api-key', self.private / 'compose.override.json'):
path.unlink(missing_ok=True)
self.state['phase'] = 'cleaned'
self.save()
def run(self):
self.output.mkdir(parents=True, exist_ok=False, mode=0o700)
self.private.mkdir(mode=0o700)
(self.private / 'job-id').write_text(self.state['job_id'])
write_json(self.output / 'job-profile.json', self.profile)
self.save()
result = {'errors': [], 'completed': False}
try:
suite = self.prepare()
self.state['phase'] = 'traffic'
self.save()
summary = suite.run()
summary['passed'] = not summary['errors']
suite.write('summary.json', summary)
result.update(completed=True, suite=summary, errors=list(summary['errors']))
self.state['phase'] = 'finished'
self.save()
except KeyboardInterrupt:
result['errors'].append('Job interrupted; in-flight outcomes may be unknown')
except (RuntimeError, ValueError, OSError, KeyError, subprocess.TimeoutExpired) as exc:
result['errors'].append(self.redact(str(exc)))
finally:
if not result['completed'] and not result['errors']:
result['errors'].append('Job aborted before completion')
try:
self.collect()
except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired) as exc:
result['errors'].append('artifact collection: ' + self.redact(str(exc)))
policy = self.profile.get('cleanup', 'on-success')
if policy == 'always' or (policy == 'on-success' and not result['errors']):
try:
self.cleanup()
except (RuntimeError, ValueError, OSError, KeyError, subprocess.TimeoutExpired) as exc:
result['errors'].append('cleanup: ' + self.redact(str(exc)))
result.update(passed=not result['errors'], phase=self.state['phase'])
write_json(self.output / 'summary.json', result)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=('validate', 'run', 'cleanup'))
parser.add_argument('--profile', type=Path)
parser.add_argument('--output', type=Path)
parser.add_argument('--duration', type=float)
parser.add_argument('--rps', type=float)
parser.add_argument('--workers', type=int)
parser.add_argument('--groups', type=int)
parser.add_argument('--members-per-group', type=int)
args = parser.parse_args()
try:
if args.action == 'cleanup':
if args.output is None:
raise ValueError('--output is required for cleanup')
profile = json.loads((args.output / 'job-profile.json').read_text())
job = Job(profile, {}, args.output / 'unused-profile.json', args.output)
job.state = json.loads(job.state_path.read_text())
if (job.private / 'api-key').exists():
key = (job.private / 'api-key').read_text()
job.env[KEY_ENV] = key
job.env['LANGBOT_API_KEY'] = key
job.redactions.append(key)
job.cleanup()
return
if args.profile is None:
raise ValueError('--profile is required for run/validate')
profile, suite, path = load_profile(args.profile.resolve())
if 'smoke' in suite and any(
getattr(args, flag) is not None for flag in ('duration', 'rps', 'workers', 'groups', 'members_per_group')
):
raise ValueError('Load overrides are not applicable to smoke mode')
for phase in suite.get('phases', []):
for flag, field in [
('duration', 'duration_seconds'),
('rps', 'rps'),
('workers', 'workers'),
('groups', 'groups'),
('members_per_group', 'members_per_group'),
]:
if getattr(args, flag) is not None:
phase[field] = getattr(args, flag)
validate(suite)
if args.action == 'validate':
print('Job profile valid; no deployment or backend changed.')
return
if args.output is None:
raise ValueError('--output is required for run')
job = Job(profile, suite, path, args.output)
result = job.run()
print(json.dumps(result, ensure_ascii=False, indent=2))
if not result['passed']:
raise SystemExit(1)
except (RuntimeError, ValueError, OSError, KeyError, subprocess.TimeoutExpired) as exc:
parser.exit(1, f'{type(exc).__name__}: {exc}\n')
if __name__ == '__main__':
main()
+464
View File
@@ -0,0 +1,464 @@
{
"version": 1,
"cli": {
"executable": "lbctl",
"config": "./lbctl.yaml"
},
"vars": {
"plugin_dir": "/path/to/plugin-zips",
"mock_base_url": "http://127.0.0.1:18080/v1",
"embedding_base_url": "http://127.0.0.1:15410/v1",
"core_health": "http://127.0.0.1:5300/healthz",
"core_base_url": "http://127.0.0.1:5300"
},
"plugins": [
{
"source": "local",
"file": "${plugin_dir}/LocalAgent.zip"
},
{
"source": "local",
"file": "${plugin_dir}/LangRAG.zip"
},
{
"source": "local",
"file": "${plugin_dir}/GeneralParsers.zip"
}
],
"resources": [
{
"id": "mock_provider",
"kind": "provider",
"body": {
"name": "regression mock",
"requester": "openai-chat-completions",
"base_url": "${mock_base_url}",
"api_keys": [
"test-only"
]
}
},
{
"id": "embedding_provider",
"kind": "provider",
"body": {
"name": "regression embedding",
"requester": "openai-chat-completions",
"base_url": "${embedding_base_url}",
"api_keys": [
"test-only"
]
}
},
{
"id": "chat_model",
"kind": "model",
"type": "llm",
"body": {
"name": "mock-chat",
"provider_uuid": "${mock_provider}",
"abilities": [
"func_call"
],
"context_length": 8192,
"extra_args": {
"request_policy": {
"retries": 0
}
}
}
},
{
"id": "box_model",
"kind": "model",
"type": "llm",
"body": {
"name": "mock-box",
"provider_uuid": "${mock_provider}",
"abilities": [
"func_call"
],
"context_length": 8192,
"extra_args": {
"request_policy": {
"retries": 0
}
}
}
},
{
"id": "rag_model",
"kind": "model",
"type": "llm",
"body": {
"name": "mock-rag",
"provider_uuid": "${mock_provider}",
"abilities": [
"func_call"
],
"context_length": 8192,
"extra_args": {
"request_policy": {
"retries": 0
}
}
}
},
{
"id": "embedding_model",
"kind": "model",
"type": "embedding",
"body": {
"name": "all-MiniLM-L6-v2",
"provider_uuid": "${embedding_provider}"
}
},
{
"id": "knowledge_base",
"kind": "knowledge-base",
"body": {
"name": "regression parser golden",
"knowledge_engine_plugin_id": "langbot-team/LangRAG",
"creation_settings": {
"embedding_model_uuid": "${embedding_model}",
"index_type": "chunk",
"chunk_size": 512,
"overlap": 50
},
"retrieval_settings": {
"search_type": "vector",
"top_k": 5,
"query_rewrite": "off",
"rerank": "off"
}
},
"documents": [
{
"file": "../../skills/skills/langbot-testing/fixtures/rag/parser-golden.html",
"parser_plugin_id": "langbot-team/GeneralParsers"
}
]
},
{
"id": "chat_pipeline",
"kind": "pipeline",
"body": {
"name": "regression chat",
"description": "Normal deterministic chat",
"config": {
"ai": {
"runner": {
"id": "plugin:langbot-team/LocalAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/LocalAgent/default": {
"model": {
"primary": "${chat_model}",
"fallbacks": []
},
"prompt": [
{
"role": "system",
"content": "You are a QA regression assistant."
}
],
"knowledge-bases": [],
"box-enabled": false,
"timeout": 30,
"max-round": 20,
"box-session-id-template": "{launcher_type}_{launcher_id}"
}
}
},
"safety": {
"rate-limit": {
"limitation": 10000
}
},
"output": {
"force-delay": {
"min": 0,
"max": 0
},
"misc": {
"at-sender": false,
"quote-origin": false
}
},
"trigger": {
"group-respond-rules": {
"prefix": [
"ai "
]
}
}
}
}
},
{
"id": "box_pipeline",
"kind": "pipeline",
"body": {
"name": "regression Box",
"description": "Real Box write and read",
"config": {
"ai": {
"runner": {
"id": "plugin:langbot-team/LocalAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/LocalAgent/default": {
"model": {
"primary": "${box_model}",
"fallbacks": []
},
"prompt": [
{
"role": "system",
"content": "You are a QA regression assistant."
}
],
"knowledge-bases": [],
"box-enabled": true,
"box-session-id-template": "{launcher_type}_{launcher_id}",
"timeout": 30,
"max-tool-iterations": 5,
"max-round": 20
}
}
},
"safety": {
"rate-limit": {
"limitation": 10000
}
},
"output": {
"force-delay": {
"min": 0,
"max": 0
},
"misc": {
"at-sender": false,
"quote-origin": false
}
},
"trigger": {
"group-respond-rules": {
"prefix": [
"ai "
]
}
}
}
}
},
{
"id": "rag_pipeline",
"kind": "pipeline",
"body": {
"name": "regression RAG",
"description": "Retrieved model input oracle",
"config": {
"ai": {
"runner": {
"id": "plugin:langbot-team/LocalAgent/default",
"expire-time": 0
},
"runner_config": {
"plugin:langbot-team/LocalAgent/default": {
"model": {
"primary": "${rag_model}",
"fallbacks": []
},
"prompt": [
{
"role": "system",
"content": "You are a QA regression assistant."
}
],
"knowledge-bases": [
"${knowledge_base}"
],
"box-enabled": false,
"timeout": 30,
"retrieval-top-k": 5,
"max-round": 20,
"box-session-id-template": "{launcher_type}_{launcher_id}"
}
}
},
"safety": {
"rate-limit": {
"limitation": 10000
}
},
"output": {
"force-delay": {
"min": 0,
"max": 0
},
"misc": {
"at-sender": false,
"quote-origin": false
}
},
"trigger": {
"group-respond-rules": {
"prefix": [
"ai "
]
}
}
}
}
},
{
"id": "chat_bot",
"kind": "bot",
"body": {
"name": "regression group chat",
"description": "Synthetic signed group ingress",
"adapter": "http_bot",
"enable": true,
"adapter_config": {
"inbound_secret": "${group_secret}",
"signature_required": true,
"callback_url": "http://127.0.0.1:9/unused-sync-only",
"default_session_type": "group",
"callback_timeout": 20,
"callback_max_retries": 0
},
"event_bindings": [
{
"event_pattern": "message.received",
"target_type": "pipeline",
"target_uuid": "${chat_pipeline}",
"enabled": true,
"filters": []
}
],
"plugin_processors": []
}
},
{
"id": "box_bot",
"kind": "bot",
"body": {
"name": "regression group box",
"description": "Synthetic signed group ingress",
"adapter": "http_bot",
"enable": true,
"adapter_config": {
"inbound_secret": "${group_secret}",
"signature_required": true,
"callback_url": "http://127.0.0.1:9/unused-sync-only",
"default_session_type": "group",
"callback_timeout": 20,
"callback_max_retries": 0
},
"event_bindings": [
{
"event_pattern": "message.received",
"target_type": "pipeline",
"target_uuid": "${box_pipeline}",
"enabled": true,
"filters": []
}
],
"plugin_processors": []
}
},
{
"id": "rag_bot",
"kind": "bot",
"body": {
"name": "regression group rag",
"description": "Synthetic signed group ingress",
"adapter": "http_bot",
"enable": true,
"adapter_config": {
"inbound_secret": "${group_secret}",
"signature_required": true,
"callback_url": "http://127.0.0.1:9/unused-sync-only",
"default_session_type": "group",
"callback_timeout": 20,
"callback_max_retries": 0
},
"event_bindings": [
{
"event_pattern": "message.received",
"target_type": "pipeline",
"target_uuid": "${rag_pipeline}",
"enabled": true,
"filters": []
}
],
"plugin_processors": []
}
}
],
"cases": [
{
"name": "chat",
"weight": 2,
"http_group": {
"url": "${core_base_url}/bots/${chat_bot}/sync",
"secret_env": "LANGBOT_QA_GROUP_SECRET",
"message": "ai What is the documented parser-rag sentinel?"
},
"expect": "LANGBOT_CHAT_OK"
},
{
"name": "box",
"weight": 1,
"http_group": {
"url": "${core_base_url}/bots/${box_bot}/sync",
"secret_env": "LANGBOT_QA_GROUP_SECRET",
"message": "ai What is the documented parser-rag sentinel?"
},
"expect": "LANGBOT_BOX_OK"
},
{
"name": "rag_chat",
"weight": 1,
"http_group": {
"url": "${core_base_url}/bots/${rag_bot}/sync",
"secret_env": "LANGBOT_QA_GROUP_SECRET",
"message": "ai What is the documented parser-rag sentinel?"
},
"expect": "LANGBOT_RAG_OK"
}
],
"phases": [
{
"name": "normal",
"duration_seconds": 600,
"rps": 2,
"workers": 4,
"cooldown_seconds": 30,
"groups": 4,
"members_per_group": 3
}
],
"observe": {
"health": {
"core": "${core_health}",
"mock": "http://127.0.0.1:18080/healthz",
"box": "http://127.0.0.1:5410/readyz"
},
"pids": {},
"pid_files": {},
"containers": []
},
"cleanup": "on-success",
"thresholds": {
"min_rps_ratio": 0,
"max_p95_seconds": {
"chat": 5,
"box": 10,
"rag_chat": 6
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"version": 1,
"lbctl": "lbctl",
"suite": "groups.json",
"deployment": {
"version": "v4.11.0",
"port": 15351
},
"vars": {
"plugin_dir": "/path/to/plugin-zips"
},
"mock_image": "python:3.12-alpine",
"cleanup": "on-success",
"purge_data": true
}
+111
View File
@@ -0,0 +1,111 @@
{
"version": 1,
"cli": {"executable": "lbctl", "config": "./lbctl.yaml"},
"vars": {
"plugin_dir": "/path/to/plugin-zips",
"mock_base_url": "http://127.0.0.1:18080/v1",
"embedding_base_url": "http://127.0.0.1:15410/v1",
"core_health": "http://127.0.0.1:5300/healthz"
},
"plugins": [
{"source": "local", "file": "${plugin_dir}/LocalAgent.zip"},
{"source": "local", "file": "${plugin_dir}/LangRAG.zip"},
{"source": "local", "file": "${plugin_dir}/GeneralParsers.zip"}
],
"resources": [
{"id": "mock_provider", "kind": "provider", "body": {
"name": "regression mock", "requester": "openai-chat-completions",
"base_url": "${mock_base_url}", "api_keys": ["test-only"]
}},
{"id": "embedding_provider", "kind": "provider", "body": {
"name": "regression embedding", "requester": "openai-chat-completions",
"base_url": "${embedding_base_url}", "api_keys": ["test-only"]
}},
{"id": "chat_model", "kind": "model", "type": "llm", "body": {
"name": "mock-chat", "provider_uuid": "${mock_provider}",
"abilities": ["func_call"], "context_length": 8192,
"extra_args": {"request_policy": {"retries": 0}}
}},
{"id": "box_model", "kind": "model", "type": "llm", "body": {
"name": "mock-box", "provider_uuid": "${mock_provider}",
"abilities": ["func_call"], "context_length": 8192,
"extra_args": {"request_policy": {"retries": 0}}
}},
{"id": "rag_model", "kind": "model", "type": "llm", "body": {
"name": "mock-rag", "provider_uuid": "${mock_provider}",
"abilities": ["func_call"], "context_length": 8192,
"extra_args": {"request_policy": {"retries": 0}}
}},
{"id": "embedding_model", "kind": "model", "type": "embedding", "body": {
"name": "all-MiniLM-L6-v2", "provider_uuid": "${embedding_provider}"
}},
{"id": "knowledge_base", "kind": "knowledge-base", "body": {
"name": "regression parser golden", "knowledge_engine_plugin_id": "langbot-team/LangRAG",
"creation_settings": {"embedding_model_uuid": "${embedding_model}", "index_type": "chunk", "chunk_size": 512, "overlap": 50},
"retrieval_settings": {"search_type": "vector", "top_k": 5, "query_rewrite": "off", "rerank": "off"}
}, "documents": [{
"file": "../../skills/skills/langbot-testing/fixtures/rag/parser-golden.html",
"parser_plugin_id": "langbot-team/GeneralParsers"
}]},
{"id": "chat_pipeline", "kind": "pipeline", "body": {
"name": "regression chat", "description": "Normal deterministic chat",
"config": {
"ai": {
"runner": {"id": "plugin:langbot-team/LocalAgent/default", "expire-time": 0},
"runner_config": {"plugin:langbot-team/LocalAgent/default": {
"model": {"primary": "${chat_model}", "fallbacks": []},
"prompt": [{"role": "system", "content": "You are a QA regression assistant."}],
"knowledge-bases": [], "box-enabled": false, "timeout": 30
}}
},
"safety": {"rate-limit": {"limitation": 10000}},
"output": {"force-delay": {"min": 0, "max": 0}}
}
}},
{"id": "box_pipeline", "kind": "pipeline", "body": {
"name": "regression Box", "description": "Real Box write and read",
"config": {
"ai": {
"runner": {"id": "plugin:langbot-team/LocalAgent/default", "expire-time": 0},
"runner_config": {"plugin:langbot-team/LocalAgent/default": {
"model": {"primary": "${box_model}", "fallbacks": []},
"prompt": [{"role": "system", "content": "You are a QA regression assistant."}],
"knowledge-bases": [], "box-enabled": true, "box-session-id-template": "{global}",
"timeout": 30, "max-tool-iterations": 5
}}
},
"safety": {"rate-limit": {"limitation": 10000}},
"output": {"force-delay": {"min": 0, "max": 0}}
}
}},
{"id": "rag_pipeline", "kind": "pipeline", "body": {
"name": "regression RAG", "description": "Retrieved model input oracle",
"config": {
"ai": {
"runner": {"id": "plugin:langbot-team/LocalAgent/default", "expire-time": 0},
"runner_config": {"plugin:langbot-team/LocalAgent/default": {
"model": {"primary": "${rag_model}", "fallbacks": []},
"prompt": [{"role": "system", "content": "You are a QA regression assistant."}],
"knowledge-bases": ["${knowledge_base}"], "box-enabled": false,
"timeout": 30, "retrieval-top-k": 5
}}
},
"safety": {"rate-limit": {"limitation": 10000}},
"output": {"force-delay": {"min": 0, "max": 0}}
}
}}
],
"cases": [
{"name": "chat", "weight": 2, "args": ["pipeline", "run", "${chat_pipeline}", "--message", "Run the normal QA scenario", "--timeout", "90s"], "expect": "LANGBOT_CHAT_OK"},
{"name": "box", "weight": 1, "args": ["pipeline", "run", "${box_pipeline}", "--message", "Run the normal QA scenario", "--timeout", "90s"], "expect": "LANGBOT_BOX_OK"},
{"name": "rag_chat", "weight": 1, "args": ["pipeline", "run", "${rag_pipeline}", "--message", "What is the documented parser-rag sentinel?", "--timeout", "90s"], "expect": "LANGBOT_RAG_OK"},
{"name": "rag_retrieve", "weight": 1, "args": ["knowledge-base", "retrieve", "${knowledge_base}"], "body": {"query": "What is the parser-rag golden sentinel and parser-engine pair?", "retrieval_settings": {"search_type": "vector", "top_k": 5}}, "expect": "aurora-parser-rag-9137"}
],
"phases": [{"name": "normal", "duration_seconds": 600, "rps": 2, "workers": 4, "cooldown_seconds": 30}],
"observe": {
"health": {"core": "${core_health}", "mock": "http://127.0.0.1:18080/healthz", "box": "http://127.0.0.1:5410/readyz"},
"pids": {}, "pid_files": {}, "containers": []
},
"cleanup": "on-success",
"thresholds": {"min_rps_ratio": 0.95, "max_p95_seconds": {"chat": 3, "box": 6, "rag_chat": 4, "rag_retrieve": 1}}
}
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
"""Run finite CLI/group smoke cases with an independent read-only AgentRun oracle."""
from collections import Counter
from contextlib import closing
import base64
import json
from pathlib import Path
import sqlite3
import time
import urllib.error
import urllib.request
from uuid import uuid4
from regression_traffic import group_request, read_endpoint_snapshot
class UnresolvedRun(RuntimeError):
"""The request cannot yet be matched to one authoritative terminal run."""
def completed_run(connection, workspace, conversation, after_id, timeout):
deadline = time.monotonic() + timeout
while True:
rows = connection.execute(
'SELECT run_id, status, status_reason, finished_at FROM agent_run '
'WHERE workspace_id=? AND conversation_id=? AND id>? ORDER BY id',
(workspace, conversation, after_id),
).fetchall()
if len(rows) > 1:
raise UnresolvedRun('More than one AgentRun matches this request')
if rows and (rows[0]['finished_at'] or rows[0]['status'] in {'completed', 'failed', 'timeout', 'cancelled'}):
return dict(rows[0])
if time.monotonic() >= deadline:
raise UnresolvedRun('No unique terminal AgentRun before the observation deadline; not retried')
time.sleep(0.1)
def check_run(connection, run, case):
if run['status'] != 'completed':
raise ValueError(f'AgentRun {run["run_id"]} ended as {run["status"]}: {run["status_reason"]}')
events = [
(kind, json.loads(data or '{}'))
for kind, data in connection.execute(
'SELECT type, data_json FROM agent_run_event WHERE run_id=? ORDER BY sequence', (run['run_id'],)
)
]
if any(not isinstance(data, dict) for _kind, data in events):
raise ValueError('AgentRun event payload must be an object')
if not any(kind == 'run.completed' for kind, _data in events):
raise ValueError('Completed AgentRun is missing its persisted run.completed event')
checks = {'terminal_event': True}
if expected := case.get('box_exec'):
calls = [
data.get('result', {})
for kind, data in events
if kind == 'tool.call.completed' and data.get('tool_name') == 'exec'
]
if len(calls) != expected['count'] or any(
not isinstance(item, dict)
or item.get('backend') != expected['backend']
or item.get('exit_code') != expected.get('exit_code', 0)
or not isinstance(item.get('stdout'), str)
or expected['stdout'] not in item['stdout']
or ('stderr' in expected and expected['stderr'] not in str(item.get('stderr', '')))
for item in calls
):
raise ValueError('Box execution count/backend/exit code/output did not match the oracle')
checks['box_exec'] = {'calls': len(calls), 'backend': expected['backend'], 'output_matched': True}
return checks
def check_attachment(data, expected):
replies = data.get('replies')
if not isinstance(replies, list) or any(
not isinstance(reply, dict)
or not isinstance(reply.get('message_chain'), list)
or any(not isinstance(part, dict) for part in reply['message_chain'])
for reply in replies
):
raise ValueError('Malformed file delivery response')
attachments = [part for reply in replies for part in reply.get('message_chain', []) if part.get('type') == 'File']
if len(attachments) != 1 or attachments[0].get('name') != expected['name']:
raise ValueError('Expected exactly one file attachment with the configured name')
encoded = attachments[0].get('base64')
if not isinstance(encoded, str):
raise ValueError('Attachment must contain base64 bytes')
content = base64.b64decode(encoded, validate=True)
if content != expected['text'].encode('utf-8'):
raise ValueError('Exported attachment bytes differ from the expected fixture')
return {'name': expected['name'], 'bytes': len(content), 'content_matched': True}
def check_fields(data, expected):
for path, value in expected.items():
actual = data
try:
for key in path.split('.'):
actual = actual[int(key)] if isinstance(actual, list) else actual[key]
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise ValueError(f'Response field {path} is missing or malformed') from exc
if actual != value:
raise ValueError(f'Response field {path} differs from the expected value')
return expected
def check_mock_requests(path, offset, expected):
deadline = time.monotonic() + 3
while True:
with path.open('rb') as stream:
stream.seek(offset)
lines = stream.read().splitlines()
records = [json.loads(line) for line in lines]
actual = [
{'model': row['request']['model'], 'status': row['status'], 'stream': row['request'].get('stream', False)}
for row in records
if row['path'] == '/v1/chat/completions'
]
if len(actual) >= len(expected) or time.monotonic() >= deadline:
if actual != expected:
raise ValueError(f'Model request sequence differs: expected {expected}, got {actual}')
return actual
time.sleep(0.05)
def rejected_group(case, group):
settings = case['http_group']
headers = {'Content-Type': 'application/json'}
if case['reject_group'] == 'bad_signature':
headers.update({'X-LB-Timestamp': str(int(time.time())), 'X-LB-Signature': 'sha256=' + '0' * 64})
request = urllib.request.Request(
settings['url'],
headers=headers,
data=json.dumps(
{
'session_id': group,
'session_type': 'group',
'sender': {'id': 'qa-rejected'},
'message': [{'type': 'Plain', 'text': settings['message']}],
}
).encode(),
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(request, timeout=10):
raise ValueError('Unsigned/tampered group message was accepted')
except urllib.error.HTTPError as exc:
payload = json.load(exc)
if exc.code != 401 or not isinstance(payload, dict) or payload.get('code') != 40101:
raise ValueError('Expected HTTP 401 with bad_signature code 40101') from exc
return {'http_status': exc.code, 'code': payload['code']}
def verify_cleanup(suite, deleted):
checks = []
for kind, model_type in dict.fromkeys((item['kind'], item.get('type')) for item in deleted):
args = [kind, 'list'] + (['--type', model_type] if model_type else [])
data = suite.call(args)
key = {
'provider': 'providers',
'model': 'models',
'pipeline': 'pipelines',
'bot': 'bots',
'knowledge-base': 'bases',
}[kind]
items = data.get(key) if isinstance(data, dict) else None
if not isinstance(items, list) or any(
not isinstance(item, dict) or not isinstance(item.get('uuid'), str) for item in items
):
raise ValueError(f'Malformed {kind} list response during cleanup verification')
visible = {item['uuid'] for item in items}
expected_absent = {item['uuid'] for item in deleted if item['kind'] == kind and item.get('type') == model_type}
remaining = sorted(expected_absent & visible)
checks.append({'kind': kind, 'type': model_type, 'deleted_count': len(expected_absent), 'remaining': remaining})
suite.write('cleanup-checks.json', checks)
if any(row['remaining'] for row in checks):
raise ValueError('Deleted resources remain visible in CLI list readback')
def run_smoke(suite):
# Imported here because Suite dispatches to this module after profile validation.
from regression_suite import resolve
settings = suite.profile['smoke']
path = Path(suite.path(settings['ledger_db'])).resolve()
if not path.is_file():
raise ValueError('smoke.ledger_db must be an existing target Core SQLite database')
mock_path = Path(suite.path(settings['mock_records'])) if settings.get('mock_records') else None
rows, errors, health = [], [], []
started = time.monotonic()
def redact(value):
for secret in suite.secrets:
value = value.replace(secret, '[REDACTED]')
return value
def sample_health(phase):
for name, url in resolve(suite.profile['observe']['health'], suite.variables).items():
row = {'phase': phase, 'endpoint': name}
try:
row['result'] = read_endpoint_snapshot(url, timeout_seconds=3)
except (RuntimeError, OSError, ValueError) as exc:
row['error'] = redact(str(exc))
errors.append(f'{phase}/{name}: health check failed')
health.append(row)
suite.write('health.json', health)
sample_health('before')
# shortcut: local SQLite only; use a terminal-run API when lbctl exposes one.
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=5)) as connection:
connection.row_factory = sqlite3.Row
connection.execute('PRAGMA query_only=ON')
for original in suite.profile['cases']:
case = resolve(original, suite.variables)
is_group = 'http_group' in case
rejected = 'reject_group' in case
runner = not rejected and (is_group or case.get('args', [])[:2] == ['pipeline', 'run'])
groups = (
[f'qa-smoke-{uuid4().hex}' for _ in range(settings.get('groups_per_case', 2))]
if is_group and not rejected
else [f'qa-smoke-{uuid4().hex}' if rejected else None]
)
previous = {group: 'none' for group in groups}
retired = set()
for turn in range(1, (settings.get('group_turns', 2) if is_group and not rejected else 1) + 1):
for group in groups:
row = {
'case': case['name'],
'stage': case.get('stage', 'execution'),
'status': 'failed',
'requires_runner': runner,
}
if is_group:
row.update(group_id=group, turn=turn, member_id=f'qa-member-{turn % 2:03d}')
if group in retired:
row.update(status='skipped', error='Earlier turn failed or is unknown; group not retried')
rows.append(row)
continue
before = time.monotonic()
after_id = connection.execute('SELECT COALESCE(MAX(id),0) FROM agent_run').fetchone()[0]
token = uuid4().hex
offset = mock_path.stat().st_size if case.get('mock_requests') and mock_path.exists() else 0
try:
if rejected:
data = rejected_group(case, group)
count = connection.execute(
'SELECT COUNT(*) FROM agent_run WHERE workspace_id=? AND conversation_id=? AND id>?',
(suite.variables['workspace_uuid'], 'group_' + group, after_id),
).fetchone()[0]
if count:
raise ValueError('Rejected ingress created an AgentRun')
elif is_group:
data = group_request(
case, group, row['member_id'], turn, token, previous[group], suite.env
)['data']
else:
data = suite.call(case['args'], case.get('body'))
if case['expect'] not in json.dumps(data, ensure_ascii=False):
raise ValueError('Expected business response was not returned')
row['output'] = data
if any(value in json.dumps(data, ensure_ascii=False) for value in case.get('forbid', [])):
raise ValueError('Response contains forbidden content from another knowledge base')
row['checks'] = {}
if case.get('fields'):
row['checks']['fields'] = check_fields(data, case['fields'])
if runner:
if not isinstance(data, dict):
raise ValueError('Runner response must be an object')
session = data.get('session_id')
if not isinstance(session, str) or not session:
raise UnresolvedRun('Reply has no session ID for AgentRun reconciliation')
run = completed_run(
connection,
suite.variables['workspace_uuid'],
'group_' + session if is_group else session,
after_id,
settings.get('timeout_seconds', 30),
)
row['run'] = run
row['checks'].update(check_run(connection, run, case))
if case.get('attachment'):
row['checks']['attachment'] = check_attachment(data, case['attachment'])
if case.get('mock_requests'):
row['checks']['mock_requests'] = check_mock_requests(
mock_path, offset, case['mock_requests']
)
row['status'] = 'passed'
previous[group] = token
except urllib.error.HTTPError as exc:
row.update(
status='unknown' if exc.code >= 500 else 'failed', error=f'HTTP {exc.code}; not retried'
)
except (UnresolvedRun, urllib.error.URLError, TimeoutError) as exc:
row.update(status='unknown', error=redact(str(exc)))
except (RuntimeError, ValueError, OSError, KeyError, IndexError, sqlite3.Error) as exc:
message = redact(str(exc))
row.update(
status='unknown'
if 'outcome unknown' in message or 'result_unknown' in message
else 'failed',
error=message,
)
row['seconds'] = time.monotonic() - before
if row['status'] != 'passed':
errors.append(f'{case["name"]}: {row["status"]}: {row["error"]}')
if is_group:
retired.add(group)
rows.append(row)
suite.write('cases.json', rows)
suite.write(
'progress.json',
{'completed_requests': len(rows), 'status': dict(Counter(x['status'] for x in rows))},
)
suite.write('cases.json', rows)
sample_health('after')
return {
'mode': 'smoke',
'phases': [],
'cases': rows,
'errors': errors,
'elapsed_seconds': time.monotonic() - started,
'status': dict(Counter(row['status'] for row in rows)),
'stages': {
stage: dict(Counter(row['status'] for row in rows if row['stage'] == stage))
for stage in dict.fromkeys(row['stage'] for row in rows)
},
'case_count': len(suite.profile['cases']),
'requests': len(rows),
}
+717
View File
@@ -0,0 +1,717 @@
#!/usr/bin/env python3
"""Prepare an lbctl regression from JSON, run load phases, and clean owned resources."""
import argparse
import copy
import json
import math
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
import urllib.error
import urllib.request
from uuid import uuid4
from regression_traffic import group_request
KINDS = {'provider', 'model', 'pipeline', 'knowledge-base', 'bot'}
REFERENCE = re.compile(r'\$\{([a-zA-Z][a-zA-Z0-9_-]*)\}')
def resolve(value, variables):
if isinstance(value, dict):
return {key: resolve(item, variables) for key, item in value.items()}
if isinstance(value, list):
return [resolve(item, variables) for item in value]
if isinstance(value, str):
return REFERENCE.sub(lambda match: str(variables[match[1]]), value)
return value
def merge(base, patch):
result = copy.deepcopy(base)
for key, value in patch.items():
result[key] = (
merge(result[key], value) if isinstance(value, dict) and isinstance(result.get(key), dict) else value
)
return result
def contains_fields(actual, expected):
"""Check submitted fields while allowing server-generated defaults."""
if isinstance(expected, dict):
return isinstance(actual, dict) and all(
key in actual and contains_fields(actual[key], value) for key, value in expected.items()
)
if isinstance(expected, list):
return (
isinstance(actual, list)
and len(actual) == len(expected)
and all(contains_fields(left, right) for left, right in zip(actual, expected))
)
return actual == expected
def number(value, label, *, minimum=0, integer=False):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError(f'{label} must be a finite number')
if value < minimum or (integer and not isinstance(value, int)):
raise ValueError(f'{label} must be {"an integer " if integer else ""}>= {minimum}')
def validate(profile):
if not isinstance(profile, dict) or profile.get('version') != 1:
raise ValueError('profile version must be 1')
allowed = {
'version',
'cli',
'vars',
'plugins',
'resources',
'cases',
'phases',
'observe',
'cleanup',
'thresholds',
'smoke',
}
if extra := profile.keys() - allowed:
raise ValueError(f'unknown profile fields: {sorted(extra)}')
for field in ('cli', 'vars', 'observe', 'thresholds'):
if field in profile and not isinstance(profile[field], dict):
raise ValueError(f'{field} must be an object')
for field in ('plugins', 'resources', 'cases', 'phases'):
if field in profile and (
not isinstance(profile[field], list) or any(not isinstance(item, dict) for item in profile[field])
):
raise ValueError(f'{field} must be an array of objects')
cli = profile.get('cli', {})
if not isinstance(cli, dict) or cli.keys() - {'config', 'executable'}:
raise ValueError('cli accepts config and executable')
if not isinstance(cli.get('config'), str) or not cli['config']:
raise ValueError('cli.config must name a Workspace-bound lbctl config')
variables = {'profile_dir': '.', 'run_dir': '.', 'workspace_uuid': 'workspace', 'group_secret': 'private'}
supplied = profile.get('vars', {})
if not isinstance(supplied, dict) or variables.keys() & supplied.keys():
raise ValueError('vars must be an object without reserved names')
if any(not isinstance(value, (str, int, float)) or '${' in str(value) for value in supplied.values()):
raise ValueError('vars values must be scalar literals, without nested references')
variables.update(supplied)
for plugin in profile.get('plugins', []):
if plugin.keys() - {'source', 'file'}:
raise ValueError('plugins accept source and file')
if plugin.get('source') not in {'local', 'github', 'marketplace'} or not plugin.get('file'):
raise ValueError('plugins need source local/github/marketplace and file')
resolve(plugin, variables)
for item in profile.get('resources', []):
if item.keys() - {'id', 'kind', 'type', 'body', 'documents'}:
raise ValueError('unknown resource field')
name = item.get('id', '')
if not re.fullmatch(r'[a-zA-Z][a-zA-Z0-9_-]*', name) or name in variables:
raise ValueError(f'invalid or duplicate resource id: {name}')
if item.get('kind') not in KINDS or not isinstance(item.get('body'), dict):
raise ValueError(f'{name}: resource needs kind and body')
if 'uuid' in item['body']:
raise ValueError(f'{name}: resources must create new objects; body.uuid is forbidden')
if item['kind'] == 'bot' and (
item['body'].get('adapter') != 'http_bot'
or not isinstance(item['body'].get('adapter_config'), dict)
or item['body'].get('adapter_config', {}).get('inbound_secret') != '${group_secret}'
or not any(
isinstance(case.get('http_group'), dict) and '${' + name + '}' in case['http_group'].get('url', '')
for case in profile.get('cases', [])
)
):
raise ValueError('Bot resources require http_bot, ${group_secret}, and a matching http_group case')
if item['kind'] == 'model' and item.get('type') not in {'llm', 'embedding', 'rerank'}:
raise ValueError(f'{name}: model type must be llm/embedding/rerank')
resolve(item['body'], variables) # References must point backwards; never guess missing UUIDs.
variables[name] = name
documents = item.get('documents', [])
if not isinstance(documents, list) or any(not isinstance(doc, dict) for doc in documents):
raise ValueError(f'{name}: documents must be an array of objects')
for document in documents:
if item['kind'] != 'knowledge-base' or not document.get('file') or not document.get('parser_plugin_id'):
raise ValueError(f'{name}: documents need file and parser_plugin_id on a knowledge-base')
resolve(document, variables)
cases = profile.get('cases')
if not isinstance(cases, list) or not cases:
raise ValueError('cases must be a non-empty array')
names = set()
for case in cases:
if case.keys() - {
'name',
'weight',
'args',
'expect',
'body',
'http_group',
'box_exec',
'attachment',
'stage',
'mock_requests',
'fields',
'forbid',
'reject_group',
}:
raise ValueError('unknown case field')
if not isinstance(case.get('name'), str) or not case['name'] or case['name'] in names:
raise ValueError('case names must be non-empty and unique')
names.add(case['name'])
if 'stage' in case and (not isinstance(case['stage'], str) or not case['stage']):
raise ValueError('case stage must be a non-empty string')
if any(key in case for key in ('mock_requests', 'fields', 'forbid', 'reject_group')) and 'smoke' not in profile:
raise ValueError('extended case checks require smoke mode')
if 'fields' in case and (not isinstance(case['fields'], dict) or not case['fields']):
raise ValueError('fields must be a non-empty mapping of dotted paths to exact values')
if 'fields' in case and any(not isinstance(key, str) or not key for key in case['fields']):
raise ValueError('field paths must be non-empty strings')
if 'forbid' in case and (
not isinstance(case['forbid'], list)
or not case['forbid']
or any(not isinstance(item, str) or not item for item in case['forbid'])
):
raise ValueError('forbid must contain non-empty response substrings')
if 'mock_requests' in case:
requests = case['mock_requests']
if not isinstance(requests, list) or not requests or not profile.get('smoke', {}).get('mock_records'):
raise ValueError('mock_requests requires expected requests and smoke.mock_records')
for request in requests:
if not isinstance(request, dict) or set(request) != {'model', 'status', 'stream'}:
raise ValueError('mock_requests entries require model, status and stream')
if (
not isinstance(request['model'], str)
or not request['model']
or not isinstance(request['stream'], bool)
):
raise ValueError('invalid mock request model/stream')
number(request['status'], 'mock request status', minimum=100, integer=True)
if 'reject_group' in case and (
'http_group' not in case
or case['reject_group'] not in ('missing_signature', 'bad_signature')
or any(key in case for key in ('box_exec', 'attachment', 'mock_requests'))
):
raise ValueError('reject_group must name a signature rejection on an HTTP group case')
if 'http_group' in case:
settings = case['http_group']
if (
'args' in case
or 'body' in case
or not isinstance(settings, dict)
or set(settings) != {'url', 'secret_env', 'message'}
or any(not isinstance(value, str) or not value for value in settings.values())
or not resolve(settings['url'], variables).startswith(('http://', 'https://'))
or settings['secret_env'] != 'LANGBOT_QA_GROUP_SECRET'
):
raise ValueError(
'http_group needs url, message and secret_env=LANGBOT_QA_GROUP_SECRET; no CLI args/body'
)
elif (
not isinstance(case.get('args'), list)
or not case['args']
or not all(isinstance(x, str) for x in case['args'])
):
raise ValueError('case args must be a non-empty string array')
if not isinstance(case.get('expect'), str) or not case['expect']:
raise ValueError('case expect must be a non-empty string')
number(case.get('weight', 1), 'case weight', minimum=1, integer=True)
resolve(case, variables)
if 'body' in case and (not isinstance(case['body'], dict) or '--file' in case['args']):
raise ValueError('case body must be an object and cannot coexist with --file')
if 'box_exec' in case:
check = case['box_exec']
if (
not isinstance(check, dict)
or not {'count', 'backend', 'stdout'} <= set(check)
or check.keys() - {'count', 'backend', 'stdout', 'exit_code', 'stderr'}
):
raise ValueError('box_exec requires count, backend and stdout')
if 'exit_code' in check:
number(check['exit_code'], 'box_exec.exit_code', minimum=0, integer=True)
if 'stderr' in check and not isinstance(check['stderr'], str):
raise ValueError('box_exec.stderr must be a string')
number(check['count'], 'box_exec.count', minimum=1, integer=True)
if any(not isinstance(check[k], str) or not check[k] for k in ('backend', 'stdout')):
raise ValueError('box_exec backend/stdout must be non-empty strings')
if 'smoke' not in profile:
raise ValueError('box_exec checks require smoke mode')
if 'http_group' not in case and case.get('args', [])[:2] != ['pipeline', 'run']:
raise ValueError('box_exec requires a Pipeline or group case')
if 'attachment' in case:
check = case['attachment']
if (
'smoke' not in profile
or case.get('args', [])[:2] != ['pipeline', 'run']
or not isinstance(check, dict)
or set(check) != {'name', 'text'}
or any(not isinstance(value, str) or not value for value in check.values())
):
raise ValueError('attachment requires name/text on a smoke Pipeline case')
if sum(case.get('weight', 1) for case in cases) > 1000:
raise ValueError('total case weight must be <= 1000')
smoke = profile.get('smoke')
if 'smoke' in profile:
if not isinstance(smoke, dict) or smoke.keys() - {
'ledger_db',
'mock_records',
'timeout_seconds',
'group_turns',
'groups_per_case',
'required_plugins',
}:
raise ValueError('invalid smoke settings')
if not isinstance(smoke.get('ledger_db'), str) or not smoke['ledger_db']:
raise ValueError('smoke.ledger_db must name the target Core SQLite database')
if 'mock_records' in smoke and (not isinstance(smoke['mock_records'], str) or not smoke['mock_records']):
raise ValueError('smoke.mock_records must name the dedicated mock recording file')
resolve(smoke, variables)
number(smoke.get('timeout_seconds', 30), 'smoke timeout_seconds', minimum=0.001)
number(smoke.get('group_turns', 2), 'smoke group_turns', minimum=2, integer=True)
number(smoke.get('groups_per_case', 2), 'smoke groups_per_case', minimum=2, integer=True)
if smoke.get('group_turns', 2) > 10 or smoke.get('groups_per_case', 2) > 10:
raise ValueError('smoke group_turns/groups_per_case must be <= 10')
required = smoke.get('required_plugins', [])
if not isinstance(required, list) or any(not isinstance(p, str) or not p for p in required):
raise ValueError('smoke.required_plugins must be an array of plugin IDs')
if profile.get('phases') or profile.get('thresholds'):
raise ValueError('smoke uses fixed case counts; load phases/thresholds are not applicable')
phases = profile.get('phases', [])
if not isinstance(phases, list) or (smoke is None and not phases):
raise ValueError('phases must be a non-empty array')
names = set()
for phase in phases:
name = phase.get('name', '')
if not re.fullmatch(r'[a-zA-Z][a-zA-Z0-9_-]*', name) or name in names:
raise ValueError('phase names must be unique simple names')
names.add(name)
if phase.keys() - {
'name',
'duration_seconds',
'rps',
'workers',
'cooldown_seconds',
'groups',
'members_per_group',
}:
raise ValueError(f'{name}: unknown phase field')
number(phase.get('duration_seconds'), 'duration_seconds', minimum=0.001)
number(phase.get('rps'), 'rps', minimum=0.001)
number(phase.get('workers'), 'workers', minimum=1, integer=True)
number(phase.get('cooldown_seconds', 0), 'cooldown_seconds')
if phase['workers'] > 32:
raise ValueError('workers must be <= 32')
number(phase.get('groups', 0), 'groups', integer=True)
number(phase.get('members_per_group', 3), 'members_per_group', minimum=1, integer=True)
if phase.get('groups', 0) > 4096 or phase.get('members_per_group', 3) > 1000:
raise ValueError('groups must be <= 4096 and members_per_group <= 1000')
group_cases = ['http_group' in case for case in cases]
if any(group_cases) and (
not all(group_cases) or phase.get('groups', 0) < sum(case.get('weight', 1) for case in cases)
):
raise ValueError('group phases need only http_group cases and enough groups for all weighted cases')
if not any(group_cases) and phase.get('groups', 0):
raise ValueError('groups require http_group cases')
if profile.get('cleanup', 'on-success') not in {'always', 'on-success', 'never'}:
raise ValueError('cleanup must be always/on-success/never')
thresholds = profile.get('thresholds', {})
if extra := thresholds.keys() - {'min_rps_ratio', 'max_p95_seconds'}:
raise ValueError(f'unknown thresholds: {sorted(extra)}')
number(thresholds.get('min_rps_ratio', 0), 'min_rps_ratio')
if thresholds.get('min_rps_ratio', 0) > 1:
raise ValueError('min_rps_ratio must be <= 1')
if not isinstance(thresholds.get('max_p95_seconds', {}), dict):
raise ValueError('max_p95_seconds must map case names to seconds')
for name, value in thresholds.get('max_p95_seconds', {}).items():
if name not in {case['name'] for case in cases}:
raise ValueError(f'unknown case in max_p95_seconds: {name}')
number(value, 'max_p95_seconds', minimum=0.001)
observed = resolve(profile.get('observe', {}), variables)
if not isinstance(observed, dict) or observed.keys() - {
'health',
'pids',
'pid_files',
'containers',
'box_data_root',
}:
raise ValueError('observe accepts health, pids, pid_files, containers, box_data_root')
if 'box_data_root' in observed and (
not isinstance(observed['box_data_root'], str) or not observed['box_data_root']
):
raise ValueError('observe.box_data_root must name the deployment Box data directory')
if not isinstance(observed.get('health'), dict) or not observed['health']:
raise ValueError('observe.health must contain at least one health endpoint')
for name, url in observed['health'].items():
if not isinstance(name, str) or not isinstance(url, str) or not url.startswith(('http://', 'https://')):
raise ValueError('health endpoints must map names to HTTP(S) URLs')
for field in ('pids', 'pid_files'):
if not isinstance(observed.get(field, {}), dict):
raise ValueError(f'observe.{field} must be an object')
for pid in observed.get('pids', {}).values():
number(pid, 'PID', minimum=1, integer=True)
if not isinstance(observed.get('containers', []), list) or any(
not isinstance(item, str) or not item for item in observed.get('containers', [])
):
raise ValueError('observe.containers must be an array of container names/IDs')
class Suite:
def __init__(self, profile, profile_path, output, env=None):
self.profile = profile
self.output = output.resolve()
self.variables = {
**profile.get('vars', {}),
'profile_dir': str(profile_path.parent.resolve()),
'run_dir': str(self.output),
}
self.cli = [
profile.get('cli', {}).get('executable', 'lbctl'),
'--config',
self.path(profile['cli']['config']),
'-o',
'json',
]
self.owned = []
self.connection = {}
self.env = dict(os.environ if env is None else env)
if any('http_group' in case for case in profile['cases']):
self.variables['group_secret'] = secrets.token_urlsafe(32)
self.env['LANGBOT_QA_GROUP_SECRET'] = self.variables['group_secret']
self.secrets = [
value for key, value in self.env.items() if ('API_KEY' in key or key == 'LANGBOT_QA_GROUP_SECRET') and value
]
def path(self, value):
path = Path(resolve(value, self.variables)).expanduser()
return str(path if path.is_absolute() else Path(self.variables['profile_dir']) / path)
def write(self, name, value):
path = self.output / name
if name == 'request.json':
private = self.output / '.private'
private.mkdir(exist_ok=True, mode=0o700)
path = private / name
path.write_text(json.dumps(value, ensure_ascii=False, indent=2))
path.chmod(0o600)
return str(path)
def call(self, args, body=None, *, defer_bot_readback=False):
command = [*self.cli, *args]
if body is not None:
command += ['--file', self.write('request.json', body)]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=360, env=self.env)
except subprocess.TimeoutExpired as exc:
raise RuntimeError('lbctl timed out; outcome unknown, no automatic retry') from exc
if result.returncode:
if defer_bot_readback and args == ['bot', 'create']:
response = json.loads(result.stdout)
data = response.get('data', {})
if (
response.get('error', {}).get('type') == 'verification_failed'
and data.get('operation') == 'bot.create'
and data.get('uuid')
and data.get('bot', {}).get('uuid') == data['uuid']
):
return data
error = result.stdout + result.stderr
for secret in self.secrets:
error = error.replace(secret, '[REDACTED]')
raise RuntimeError(error[:3000])
response = json.loads(result.stdout)
if args == ['whoami']:
self.connection = response.get('meta') or {}
return response['data']
def read_bot(self, bot_uuid):
"""Read full Bot fields using the endpoint and environment credential confirmed by lbctl."""
source = self.connection.get('credential_source', '')
key = self.env.get(source.removeprefix('env:')) if source.startswith('env:') else None
if not key or not self.connection.get('endpoint'):
raise RuntimeError('Group Bot readback requires environment-backed lbctl credentials')
self.secrets.append(key)
request = urllib.request.Request(
self.connection['endpoint'].rstrip('/') + '/api/v1/platform/bots/' + bot_uuid,
headers={'Authorization': 'Bearer ' + key, 'X-Workspace-Id': self.variables['workspace_uuid']},
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(request, timeout=30) as response:
payload = json.load(response)
except (urllib.error.URLError, OSError) as exc:
raise RuntimeError('Bot readback request failed; the creation was not retried') from exc
if (
not isinstance(payload, dict)
or payload.get('code') != 0
or not isinstance(payload.get('data'), dict)
or not isinstance(payload['data'].get('bot'), dict)
):
raise RuntimeError('Bot readback did not return a successful Bot resource')
return payload['data']['bot']
def journal(self):
self.write('owned.json', {'workspace_uuid': self.variables['workspace_uuid'], 'resources': self.owned})
def prepare(self):
if 'smoke' in self.profile and not Path(self.path(self.profile['smoke']['ledger_db'])).is_file():
raise ValueError('smoke.ledger_db must be an existing target Core SQLite database')
identity = self.call(['whoami'])
self.variables['workspace_uuid'] = identity['workspace_uuid']
self.journal()
for plugin in self.profile.get('plugins', []):
self.call(['plugin', 'install', plugin['source'], '--file', self.path(plugin['file']), '--wait'])
if 'smoke' in self.profile:
capabilities = self.call(['capabilities'])
plugins = self.call(['plugin', 'list'])
ready = {
f'{item["author"]}/{item["name"]}'
for item in plugins['plugins']
if item.get('enabled') and item.get('status') == 'initialized'
}
missing = set(self.profile['smoke'].get('required_plugins', [])) - ready
self.write('environment.json', {'identity': identity, 'capabilities': capabilities, 'plugins': plugins})
if missing:
raise RuntimeError(f'Required plugins are not initialized: {sorted(missing)}')
for item in self.profile.get('resources', []):
kind = item['kind']
body = resolve(item['body'], self.variables)
config = body.pop('config', None) if kind == 'pipeline' else None
args = [kind, 'apply' if kind == 'pipeline' else 'create']
if kind == 'model':
args += ['--type', item['type']]
created = self.call(args, body, defer_bot_readback=True) if kind == 'bot' else self.call(args, body)
created = created.get('pipeline', created) if kind == 'pipeline' else created
uuid = created['uuid']
self.variables[item['id']] = uuid
self.owned.append({'kind': kind, 'uuid': uuid, **({'type': item['type']} if kind == 'model' else {})})
self.journal() # Record ownership before any later configuration or ingestion can fail.
if kind == 'bot':
# lbctl 0.3's Bot projection omits event bindings; verify the existing Core API instead.
actual = self.read_bot(uuid)
expected = copy.deepcopy(body)
expected['adapter_config'].pop('inbound_secret')
if not contains_fields(actual, expected):
raise RuntimeError(f'Bot readback differs from submitted non-secret fields: {uuid}')
if config is not None:
current = self.call(['pipeline', 'get', uuid])
current = current.get('pipeline', current)
self.call(
['pipeline', 'apply'],
{
'uuid': uuid,
'name': current['name'],
'description': current['description'],
'config': merge(current['config'], config),
},
)
for document in item.get('documents', []):
self.call(
[
'knowledge-base',
'ingest',
uuid,
'--file',
self.path(document['file']),
'--parser-plugin-id',
document['parser_plugin_id'],
'--wait',
]
)
self.write(
'resources.json',
{
key: value
for key, value in self.variables.items()
if key not in self.profile.get('vars', {}) and key != 'group_secret'
},
)
if 'smoke' in self.profile:
return
warmups = []
for case in self.profile['cases']:
if 'http_group' in case:
case = resolve(case, self.variables)
group = f'qa-warmup-{uuid4().hex[:12]}'
response = group_request(case, group, 'qa-member-000', 1, uuid4().hex, 'none', self.env)
warmups.append({'case': case['name'], 'output': response})
self.write('warmup.json', warmups)
def cleanup(self):
saved = json.loads((self.output / 'owned.json').read_text())
identity = self.call(['whoami'])
if saved['workspace_uuid'] != identity['workspace_uuid']:
raise RuntimeError('cleanup refused: Workspace differs from owned.json')
failures = []
original = saved['resources'].copy()
for resource in reversed(original):
args = [resource['kind'], 'delete', resource['uuid'], '--yes']
if resource['kind'] == 'model':
args += ['--type', resource['type']]
try:
self.call(args)
except (RuntimeError, ValueError, OSError) as exc:
failures.append(str(exc))
else:
saved['resources'].remove(resource)
self.write('owned.json', saved)
if 'smoke' in self.profile:
from regression_smoke import verify_cleanup
try:
verify_cleanup(self, [item for item in original if item not in saved['resources']])
except (RuntimeError, ValueError, OSError, KeyError) as exc:
failures.append(f'cleanup readback failed: {exc}')
return failures
def run(self):
if 'smoke' in self.profile:
from regression_smoke import run_smoke
return run_smoke(self)
cases = []
for original in self.profile['cases']:
case = resolve(original, self.variables)
if 'body' in case:
case['args'] += ['--file', self.write(f'case-{len(cases)}.json', case.pop('body'))]
cases.extend(
[{key: case[key] for key in ('name', 'args', 'expect', 'http_group') if key in case}]
* case.get('weight', 1)
)
cases_path = self.write('cases.json', cases)
observed = resolve(self.profile.get('observe', {}), self.variables)
observe_args = []
for name, pid in observed.get('pids', {}).items():
observe_args += ['--pid', f'{name}={int(pid)}']
for name, path in observed.get('pid_files', {}).items():
observe_args += ['--pid', f'{name}={int(Path(self.path(path)).read_text().strip())}']
for name, url in observed.get('health', {}).items():
observe_args += ['--health', f'{name}={url}']
for container in observed.get('containers', []):
observe_args += ['--container', container]
if observed.get('box_data_root'):
observe_args += ['--box-data-root', self.path(observed['box_data_root'])]
results = []
errors = []
for phase in self.profile['phases']:
destination = self.output / phase['name']
command = [
sys.executable,
str(Path(__file__).with_name('regression_traffic.py')),
'--lbctl',
self.cli[0],
'--config',
self.cli[2],
'--cases',
cases_path,
'--output',
str(destination),
'--duration',
str(phase['duration_seconds']),
'--rps',
str(phase['rps']),
'--workers',
str(phase['workers']),
'--cooldown',
str(phase.get('cooldown_seconds', 0)),
'--shared-case-sequence',
'--groups',
str(phase.get('groups', 0)),
'--members-per-group',
str(phase.get('members_per_group', 3)),
'--group-state',
str(self.output / 'group-state.json'),
*observe_args,
]
with (self.output / f'{phase["name"]}.log').open('w') as log:
result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, env=self.env)
summary = json.loads((destination / 'summary.json').read_text())
summary['name'] = phase['name']
summary['achieved_rps'] = summary['requests'] / summary['load_elapsed_seconds']
results.append(summary)
if result.returncode:
errors.append(f'{phase["name"]}: business or health/resource checks failed')
thresholds = self.profile.get('thresholds', {})
if summary['achieved_rps'] < phase['rps'] * thresholds.get('min_rps_ratio', 0):
errors.append(f'{phase["name"]}: achieved RPS below configured threshold')
for name, maximum in thresholds.get('max_p95_seconds', {}).items():
p95 = summary['cases'][name]['p95_seconds']
if p95 is None or p95 > maximum:
errors.append(f'{phase["name"]}/{name}: P95 missing or above configured threshold')
return {'phases': results, 'errors': errors}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=('validate', 'run', 'cleanup'))
parser.add_argument('--profile', required=True, type=Path)
parser.add_argument('--output', type=Path, help='New run directory; existing run directory for cleanup')
parser.add_argument('--duration', type=float, help='Override duration_seconds in every phase')
parser.add_argument('--rps', type=float, help='Override RPS in every phase')
parser.add_argument('--workers', type=int, help='Override workers in every phase')
parser.add_argument('--groups', type=int, help='Override group count in every group phase')
parser.add_argument('--members-per-group', type=int)
args = parser.parse_args()
try:
profile = json.loads(args.profile.read_text())
if 'smoke' in profile and any(
getattr(args, flag) is not None for flag in ('duration', 'rps', 'workers', 'groups', 'members_per_group')
):
raise ValueError('Load overrides are not applicable to smoke mode')
for phase in profile.get('phases', []):
for flag, field in [
('duration', 'duration_seconds'),
('rps', 'rps'),
('workers', 'workers'),
('groups', 'groups'),
('members_per_group', 'members_per_group'),
]:
if getattr(args, flag) is not None:
phase[field] = getattr(args, flag)
validate(profile)
if args.action == 'validate':
print('Profile valid (no services or resources changed).')
return
if args.output is None:
raise ValueError('--output is required for run/cleanup')
suite = Suite(profile, args.profile.resolve(), args.output)
if args.action == 'cleanup':
errors = suite.cleanup()
if errors:
raise RuntimeError('\n'.join(errors))
return
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
suite.write('profile.json', profile)
result = {'phases': [], 'errors': []}
completed = False
try:
suite.prepare()
result = suite.run()
completed = True
except KeyboardInterrupt:
result['errors'].append('Interrupted; in-flight outcomes may be unknown. Inspect retained resources.')
except (RuntimeError, ValueError, OSError, KeyError) as exc:
result['errors'].append(str(exc))
finally:
if not completed and not result['errors']:
result['errors'].append('Run aborted before completion; inspect logs and retained resources.')
policy = profile.get('cleanup', 'on-success')
if policy == 'always' or (policy == 'on-success' and not result['errors']):
if (args.output / 'owned.json').exists():
try:
result['errors'].extend(suite.cleanup())
except (RuntimeError, ValueError, OSError, KeyError) as exc:
result['errors'].append(f'cleanup failed: {exc}')
result['passed'] = not result['errors']
suite.write('summary.json', result)
print(json.dumps(result, ensure_ascii=False, indent=2))
if not result['passed']:
raise SystemExit(1)
except (RuntimeError, ValueError, OSError, KeyError) as exc:
parser.exit(1, f'{type(exc).__name__}: {exc}\n')
if __name__ == '__main__':
main()
+449
View File
@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""Drive independent CLI requests or persistent group conversations with Linux resource sampling."""
import argparse
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
import json
import hashlib
import hmac
import math
import os
from pathlib import Path
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
from cloud_runtime_soak import read_endpoint_snapshot, read_process_snapshot
def percentile(values, fraction):
return sorted(values)[max(0, math.ceil(len(values) * fraction) - 1)] if values else None
def owned_box_containers(data_root, checked=None):
"""Discover sandboxes mounted below one run's data root, including newly created groups."""
if checked is None:
def checked(command):
return subprocess.check_output(command, text=True, timeout=10)
candidates = checked(['docker', 'ps', '-a', '--filter', 'label=langbot.box=true', '--format', '{{.ID}}']).split()
if not candidates:
return []
root = Path(data_root).resolve()
descriptions = checked(['docker', 'inspect', '--format', '{{json .}}', *candidates])
owned = []
for line in descriptions.splitlines():
if not line:
continue
entry = json.loads(line)
if any(
Path(mount['Source']).resolve().is_relative_to(root) for mount in entry['Mounts'] if mount.get('Source')
):
owned.append(entry['Id'])
return owned
def group_request(case, group_id, member_id, turn, token, previous, env=None):
"""Send one signed GroupMessage through the existing HTTP Bot adapter."""
settings = case['http_group']
secret = (os.environ if env is None else env)[settings['secret_env']]
marker = f'LB_GROUP={group_id} LB_TURN={turn} LB_TOKEN={token} LB_PREVIOUS={previous}'
body = json.dumps(
{
'session_id': group_id,
'session_type': 'group',
'sender': {'id': member_id, 'name': member_id, 'group_name': group_id},
'message': [{'type': 'Plain', 'text': settings['message'] + ' ' + marker}],
}
).encode()
stamp = str(int(time.time()))
signature = hmac.new(secret.encode(), stamp.encode() + b'.' + body, hashlib.sha256).hexdigest()
request = urllib.request.Request(
settings['url'],
data=body,
headers={
'Content-Type': 'application/json',
'X-LB-Timestamp': stamp,
'X-LB-Signature': 'sha256=' + signature,
'X-LB-Idempotency-Key': token,
},
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=90) as response:
payload = json.load(response)
if response.status != 200 or not isinstance(payload, dict) or payload.get('code') != 0:
raise ValueError('group ingress did not return a completed response')
data = payload.get('data', {})
if not isinstance(data, dict):
raise ValueError('group ingress response data must be an object')
parts = data.get('message')
if not isinstance(parts, list) or any(not isinstance(part, dict) for part in parts):
raise ValueError('group reply must contain message components')
text = ''.join(str(part.get('text', '')) for part in parts if part.get('type') == 'Plain')
if data.get('session_id') != group_id or not data.get('reply_to'):
raise ValueError('group reply session or message correlation is missing')
if case['expect'] not in text or f'GROUP_ACK={token}' not in text:
raise ValueError('group reply failed its business or conversation-history oracle')
return payload
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--lbctl', required=True)
parser.add_argument('--config', required=True)
parser.add_argument(
'--cases', type=Path, required=True, help='JSON cases with name, expect, and args or http_group'
)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--duration', type=float, default=600)
parser.add_argument('--rps', type=float, default=2)
parser.add_argument('--workers', type=int, default=4)
parser.add_argument(
'--groups', type=int, default=0, help='Stable group sessions; zero uses independent CLI requests'
)
parser.add_argument('--members-per-group', type=int, default=3)
parser.add_argument(
'--group-state', type=Path, help='Carry group histories across load phases; refuses interrupted runs'
)
parser.add_argument('--cooldown', type=float, default=30)
parser.add_argument('--pid', action='append', default=[], metavar='NAME=PID')
parser.add_argument('--health', action='append', default=[], metavar='NAME=URL')
parser.add_argument('--container', action='append', default=[])
parser.add_argument('--box-data-root', type=Path, help='Dynamically sample Box containers mounted under this root')
parser.add_argument(
'--shared-case-sequence', action='store_true', help='Share the weighted case sequence across workers'
)
args = parser.parse_args()
if (
not all(math.isfinite(value) for value in (args.duration, args.rps, args.cooldown))
or args.duration <= 0
or args.rps <= 0
or not 1 <= args.workers <= 32
or args.cooldown < 0
):
parser.error('duration/rps must be positive, workers 1..32, cooldown nonnegative')
if not 0 <= args.groups <= 4096 or not 1 <= args.members_per_group <= 1000:
parser.error('groups must be 0..4096 (0 selects CLI mode), members-per-group 1..1000')
cases = json.loads(args.cases.read_text())
if (
not isinstance(cases, list)
or not cases
or any(
not isinstance(case, dict)
or not isinstance(case.get('name'), str)
or (
not args.groups
and (not isinstance(case.get('args'), list) or not all(isinstance(item, str) for item in case['args']))
)
or not isinstance(case.get('expect'), str)
or not case['expect']
for case in cases
)
):
parser.error('cases must contain name, string args, and expected response text')
if args.groups:
if args.groups < len(cases):
parser.error('groups must cover the complete weighted case sequence')
for case in cases:
settings = case.get('http_group', {})
if (
not isinstance(settings, dict)
or set(settings) != {'url', 'secret_env', 'message'}
or not all(isinstance(value, str) and value for value in settings.values())
or not settings['url'].startswith(('http://', 'https://'))
or not os.environ.get(settings['secret_env'])
):
parser.error('group cases need HTTP(S) url, message and a populated secret_env')
args.output.mkdir(parents=True, exist_ok=False)
pids = {name: int(pid) for name, pid in (value.split('=', 1) for value in args.pid)}
endpoints = dict(value.split('=', 1) for value in args.health)
rows = []
sampling_errors = []
lock = threading.Lock()
group_condition = threading.Condition(lock)
stop = threading.Event()
phase = ['load']
next_case = 0
started = time.monotonic()
deadline = started + args.duration
redactions = [value for name, value in os.environ.items() if 'API_KEY' in name and value]
group_state = None
groups = []
if args.groups:
redactions += [os.environ[case['http_group']['secret_env']] for case in cases]
state_path = args.group_state or args.output / 'group-state.json'
fingerprint = [(case['name'], case['http_group']) for case in cases]
group_state = (
json.loads(state_path.read_text())
if state_path.exists()
else {
'run_id': uuid.uuid4().hex[:12],
'cases': fingerprint,
'groups': [],
'in_progress': False,
}
)
if group_state.get('in_progress') or json.dumps(group_state['cases']) != json.dumps(fingerprint):
parser.error('group state is interrupted or belongs to different cases; inspect it before reuse')
for index in range(len(group_state['groups']), args.groups):
group_state['groups'].append(
{
'id': f'qa-{group_state["run_id"]}-g{index:04d}',
'case': index % len(cases),
'turn': 1,
'previous': 'none',
'failed': False,
}
)
groups = [
dict(item, due=started + index / args.rps, busy=False)
for index, item in enumerate(group_state['groups'][: args.groups])
]
group_state['in_progress'] = True
state_path.write_text(json.dumps(group_state, indent=2))
state_path.chmod(0o600)
def redact(text):
for secret in redactions:
text = text.replace(secret, '[REDACTED]')
return text
def sample():
with (args.output / 'resources.jsonl').open('w') as stream:
while not stop.is_set():
row = {
'time': time.time(),
'elapsed': time.monotonic() - started,
'phase': phase[0],
'targets': {},
'health': {},
}
for name, pid in pids.items():
try:
row['targets'][name] = read_process_snapshot(pid)
except (OSError, RuntimeError) as exc:
row['targets'][name] = {'error': str(exc)}
for name, url in endpoints.items():
try:
row['health'][name] = read_endpoint_snapshot(url, timeout_seconds=3)
except RuntimeError as exc:
row['health'][name] = {'error': str(exc)}
containers = set(args.container)
if args.box_data_root:
containers.update(owned_box_containers(args.box_data_root))
if containers:
result = subprocess.run(
['docker', 'stats', '--no-stream', '--format', '{{json .}}', *sorted(containers)],
capture_output=True,
text=True,
timeout=10,
)
row['containers'] = [json.loads(line) for line in result.stdout.splitlines() if line]
if result.returncode:
row['container_error'] = result.stderr.strip()
stream.write(json.dumps(row) + '\n')
stream.flush()
with lock:
progress = {
'elapsed': row['elapsed'],
'phase': phase[0],
'requests': len(rows),
'status': dict(Counter(item['status'] for item in rows)),
}
(args.output / 'progress.json').write_text(json.dumps(progress))
stop.wait(3)
def worker(index):
nonlocal next_case
iteration = 0
due = started + index / args.rps
while due < deadline:
if stop.wait(max(0, due - time.monotonic())) or time.monotonic() >= deadline:
break
if args.shared_case_sequence:
with lock:
case = cases[next_case % len(cases)]
next_case += 1
else:
case = cases[(iteration * args.workers + index) % len(cases)]
before = time.monotonic()
row = {'case': case['name'], 'time': time.time(), 'elapsed': before - started, 'status': 'failed'}
try:
result = subprocess.run(
[args.lbctl, '--config', args.config, '-o', 'json', *case['args']],
capture_output=True,
text=True,
timeout=90,
)
row['exit_code'] = result.returncode
row['output'] = json.loads(redact(result.stdout))
if result.returncode == 0 and case['expect'] in json.dumps(row['output'], ensure_ascii=False):
row['status'] = 'ok'
elif 'result_unknown' in result.stdout:
row['status'] = 'unknown'
else:
row['error'] = redact(result.stderr)[:2000] or 'exit code or response oracle failed'
except subprocess.TimeoutExpired:
row.update(status='unknown', error='CLI timed out; server outcome unknown; request was not retried')
except (OSError, ValueError) as exc:
row['error'] = str(exc)
row['seconds'] = time.monotonic() - before
with lock:
rows.append(row)
with (args.output / 'requests.jsonl').open('a') as stream:
stream.write(json.dumps(row, ensure_ascii=False) + '\n')
iteration += 1
due = max(due + args.workers / args.rps, time.monotonic())
def group_worker(_index):
while not stop.is_set():
with group_condition:
while True:
now = time.monotonic()
if stop.is_set() or now >= deadline:
return
available = [group for group in groups if not group['busy'] and not group['failed']]
if not available:
if not any(group['busy'] for group in groups):
return
group_condition.wait(0.1)
continue
group = min(available, key=lambda item: item['due'])
if group['due'] > now:
group_condition.wait(min(1, group['due'] - now))
continue
group['busy'] = True
break
case = cases[group['case']]
before = time.monotonic()
token = uuid.uuid4().hex
member = f'qa-member-{(group["turn"] - 1) % args.members_per_group:03d}'
row = {
'case': case['name'],
'group_id': group['id'],
'member_id': member,
'turn': group['turn'],
'time': time.time(),
'elapsed': before - started,
'status': 'failed',
}
try:
row['output'] = group_request(case, group['id'], member, group['turn'], token, group['previous'])
row['status'] = 'ok'
except urllib.error.HTTPError as exc:
row.update(http_status=exc.code, error=redact(exc.read(2000).decode(errors='replace')))
if exc.code >= 500:
row['status'] = 'unknown'
except (urllib.error.URLError, TimeoutError, OSError) as exc:
row.update(status='unknown', error=redact(str(exc)) + '; not retried')
except (ValueError, KeyError) as exc:
row['error'] = redact(str(exc))
row['seconds'] = time.monotonic() - before
with group_condition:
rows.append(row)
with (args.output / 'requests.jsonl').open('a') as stream:
stream.write(json.dumps(row, ensure_ascii=False) + '\n')
group.update(busy=False, failed=row['status'] != 'ok')
if row['status'] == 'ok':
group.update(turn=group['turn'] + 1, previous=token)
group['due'] = max(group['due'] + args.groups / args.rps, time.monotonic())
group_condition.notify_all()
def sampling_guard():
try:
sample()
except Exception as exc:
sampling_errors.append(f'{type(exc).__name__}: {exc}')
stop.set()
sampler = threading.Thread(target=sampling_guard)
sampler.start()
try:
with ThreadPoolExecutor(max_workers=args.workers) as pool:
list(pool.map(group_worker if args.groups else worker, range(args.workers)))
stop.wait(max(0, deadline - time.monotonic()))
load_elapsed = time.monotonic() - started
phase[0] = 'cooldown'
stop.wait(args.cooldown)
finally:
stop.set()
sampler.join()
summary = {
'duration_requested_seconds': args.duration,
'load_elapsed_seconds': load_elapsed,
'target_rps': args.rps,
'workers': args.workers,
'cooldown_seconds': args.cooldown,
'requests': len(rows),
'status': dict(Counter(row['status'] for row in rows)),
'cases': {},
}
samples = [json.loads(line) for line in (args.output / 'resources.jsonl').read_text().splitlines()]
summary['health_samples'] = len(samples)
summary['health_failures'] = sum(
any('error' in target for target in [*sample['targets'].values(), *sample['health'].values()])
or bool(sample.get('container_error'))
for sample in samples
)
summary['sampling_errors'] = sampling_errors
if args.groups:
for index, group in enumerate(groups):
group_state['groups'][index] = {key: value for key, value in group.items() if key not in {'due', 'busy'}}
group_state['in_progress'] = False
temporary = state_path.with_suffix('.tmp')
temporary.write_text(json.dumps(group_state, indent=2))
temporary.chmod(0o600)
temporary.replace(state_path)
summary['groups'] = {
'configured': args.groups,
'members_per_group': args.members_per_group,
'target_rps_per_group': args.rps / args.groups,
'sessions': {
group['id']: {
'case': cases[group['case']]['name'],
'successful_turns_total': group['turn'] - 1,
'failed': group['failed'],
'requests_this_phase': sum(row.get('group_id') == group['id'] for row in rows),
'members_this_phase': sorted(
{row['member_id'] for row in rows if row.get('group_id') == group['id']}
),
}
for group in groups
},
}
summary['group_failures'] = [
group_id
for group_id, result in summary['groups']['sessions'].items()
if result['failed'] or result['successful_turns_total'] < 2 or not result['requests_this_phase']
]
for name in dict.fromkeys(case['name'] for case in cases):
selected = [row for row in rows if row['case'] == name]
times = [row['seconds'] for row in selected if row['status'] == 'ok']
summary['cases'][name] = {
'requests': len(selected),
'status': dict(Counter(row['status'] for row in selected)),
'p50_seconds': percentile(times, 0.5),
'p95_seconds': percentile(times, 0.95),
'p99_seconds': percentile(times, 0.99),
}
(args.output / 'summary.json').write_text(json.dumps(summary, indent=2))
print(json.dumps(summary, indent=2), flush=True)
if (
not rows
or any(row['status'] != 'ok' for row in rows)
or summary['health_failures']
or sampling_errors
or summary.get('group_failures')
):
raise SystemExit(1)
if __name__ == '__main__':
main()
+209
View File
@@ -0,0 +1,209 @@
"""Exercise the real HTTP server without LangBot, credentials or dependencies."""
from concurrent.futures import ThreadPoolExecutor
import base64
import json
from pathlib import Path
import tempfile
import struct
import threading
import unittest
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from mock_llm import MockServer
class MockLLMTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.record = Path(cls.tmp.name) / 'requests.jsonl'
scenarios = json.loads(Path(__file__).with_name('mock_llm_scenarios.json').read_text())
cls.server = MockServer(('127.0.0.1', 0), scenarios, cls.record)
cls.thread = threading.Thread(target=cls.server.serve_forever)
cls.thread.start()
cls.url = f'http://127.0.0.1:{cls.server.server_port}'
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()
cls.tmp.cleanup()
def post(self, model='mock-chat', **kwargs):
body = {
'model': model,
'messages': [{'role': 'user', 'content': 'test'}],
**kwargs,
}
request = Request(
self.url + '/v1/chat/completions',
data=json.dumps(body).encode(),
headers={
'Content-Type': 'application/json',
'Authorization': 'Bearer must-not-be-recorded',
},
)
with urlopen(request, timeout=5) as response:
return response.read().decode()
def test_chat_and_model_discovery(self):
with urlopen(self.url + '/v1/models') as response:
models = json.load(response)
self.assertIn('mock-box', [m['id'] for m in models['data']])
self.assertEqual(
json.loads(self.post())['choices'][0]['message']['content'],
'LANGBOT_CHAT_OK',
)
def test_stream_and_usage(self):
events = self.post(stream=True, stream_options={'include_usage': True}).splitlines()
self.assertEqual(events[-2], 'data: [DONE]')
chunks = [json.loads(line[6:]) for line in events if line.startswith('data: {')]
content = ''.join(c['choices'][0]['delta'].get('content', '') for c in chunks if c['choices'])
self.assertEqual(content, 'LANGBOT_CHAT_OK')
self.assertEqual(chunks[-1]['usage']['total_tokens'], 15)
def test_group_history_requires_previous_user_reply_and_isolation(self):
first = {'role': 'user', 'content': 'LB_GROUP=group-a LB_TURN=1 LB_TOKEN=aaa LB_PREVIOUS=none'}
second = {'role': 'user', 'content': 'LB_GROUP=group-a LB_TURN=2 LB_TOKEN=bbb LB_PREVIOUS=aaa'}
reply = json.loads(self.post(messages=[first]))['choices'][0]['message']
self.assertIn('GROUP_ACK=aaa', reply['content'])
result = json.loads(self.post(messages=[first, reply, second]))['choices'][0]['message']
self.assertIn('GROUP_ACK=bbb', result['content'])
other = {'role': 'user', 'content': 'LB_GROUP=group-b LB_TURN=1 LB_TOKEN=ccc LB_PREVIOUS=none'}
for messages in ([second], [first, second], [other, reply, second]):
with self.subTest(messages=messages), self.assertRaises(HTTPError) as failure:
self.post(messages=messages)
self.assertEqual(failure.exception.code, 400)
def test_embedding_http_float_base64_and_validation(self):
def embed(**patch):
body = {'model': 'mock-embedding', 'input': ['repeatable fixture', 'another fixture'], **patch}
request = Request(
self.url + '/v1/embeddings',
data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'},
)
with urlopen(request, timeout=5) as response:
return json.load(response)
first = embed()['data']
self.assertEqual(first, embed()['data'])
self.assertEqual([item['index'] for item in first], [0, 1])
self.assertEqual(len(first[0]['embedding']), 384)
self.assertAlmostEqual(sum(value * value for value in first[0]['embedding']), 1)
encoded = embed(encoding_format='base64')['data'][0]['embedding']
decoded = struct.unpack('<384f', base64.b64decode(encoded))
for actual, expected in zip(decoded, first[0]['embedding']):
self.assertAlmostEqual(actual, expected, places=6)
for patch in ({'input': []}, {'dimensions': 0}, {'encoding_format': 'bad'}, {'model': 'unknown'}):
with self.subTest(patch=patch), self.assertRaises(HTTPError) as caught:
embed(**patch)
self.assertEqual(caught.exception.code, 400)
def box_turn(self, streaming=False):
messages = [{'role': 'user', 'content': 'run box checks'}]
tools = [
{
'type': 'function',
'function': {'name': 'exec', 'parameters': {'type': 'object'}},
}
]
commands = []
for _ in range(2):
wire = self.post('mock-box', messages=messages, tools=tools, stream=streaming)
if streaming:
deltas = [
json.loads(line[6:])['choices'][0]['delta']
for line in wire.splitlines()
if line.startswith('data: {')
]
call = next(d['tool_calls'][0] for d in deltas if d.get('tool_calls') and 'id' in d['tool_calls'][0])
arguments = ''.join(d['tool_calls'][0]['function']['arguments'] for d in deltas if d.get('tool_calls'))
call = {
**call,
'function': {**call['function'], 'arguments': arguments},
}
call.pop('index')
message = {'role': 'assistant', 'content': None, 'tool_calls': [call]}
else:
message = json.loads(wire)['choices'][0]['message']
call = message['tool_calls'][0]
commands.append(json.loads(call['function']['arguments'])['command'])
messages.extend(
[
message,
{
'role': 'tool',
'tool_call_id': call['id'],
'content': 'LANGBOT_BOX_SENTINEL',
},
]
)
final = json.loads(self.post('mock-box', messages=messages, tools=tools))
self.assertEqual(final['choices'][0]['message']['content'], 'LANGBOT_BOX_OK')
self.assertIn('printf LANGBOT_BOX_SENTINEL', commands[0])
self.assertEqual(commands[1], 'cat /workspace/mock-llm.txt')
def test_tool_rounds_and_fragmented_stream(self):
self.box_turn()
self.box_turn(streaming=True)
def test_concurrent_turns_do_not_share_progress(self):
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(lambda _: self.box_turn(), range(8)))
def test_rag_requires_expected_model_input(self):
with self.assertRaises(HTTPError) as caught:
self.post('mock-rag')
self.assertEqual(caught.exception.code, 400)
response = json.loads(
self.post(
'mock-rag',
messages=[
{'role': 'system', 'content': 'Retrieved document: aurora-parser-rag-9137'},
{'role': 'user', 'content': 'What is the documented sentinel?'},
],
)
)
self.assertEqual(response['choices'][0]['message']['content'], 'LANGBOT_RAG_OK')
def test_errors_and_invalid_requests(self):
for status in (401, 429, 503):
with self.subTest(status=status), self.assertRaises(HTTPError) as caught:
self.post(f'mock-{status}')
self.assertEqual(caught.exception.code, status)
for kwargs in (
{'model': 'unknown'},
{'messages': []},
{'stream_options': []},
{'model': 'mock-box', 'tools': []},
):
with self.subTest(kwargs=kwargs), self.assertRaises(HTTPError) as caught:
self.post(**kwargs)
self.assertEqual(caught.exception.code, 400)
messages = [
{'role': 'user', 'content': 'test'},
{'role': 'assistant', 'tool_calls': [{'id': 'call1'}]},
]
with self.assertRaises(HTTPError) as caught:
self.post('mock-box', messages=messages)
self.assertEqual(caught.exception.code, 400)
messages.append({'role': 'tool', 'tool_call_id': 'call1', 'content': 'wrong output'})
with self.assertRaises(HTTPError) as caught:
self.post('mock-box', messages=messages)
self.assertEqual(caught.exception.code, 400)
def test_record_omits_authorization(self):
self.post()
with self.server.record_lock:
recorded = self.record.read_text()
self.assertNotIn('must-not-be-recorded', recorded)
self.assertIn('"request_id"', recorded)
if __name__ == '__main__':
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
"""A gate must fail closed on timeout, missing verdict, and inner failure."""
import os
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
from regression_gate import supervise
class GateTest(unittest.TestCase):
def test_success_records_elapsed_time_and_resources(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / 'gate'
code = (
'from pathlib import Path; import json; '
f'p=Path({str(output / "suite")!r}); p.mkdir(); '
'(p/"summary.json").write_text(json.dumps({"passed":True,"requests":1}))'
)
result = supervise([sys.executable, '-c', code], output, 5, {'test': os.getpid()})
self.assertTrue(result['passed'])
self.assertLess(result['elapsed_seconds'], 5)
self.assertGreater(result['resource_peaks']['test']['rss_bytes'], 0)
def test_timeout_is_failed_without_retry(self):
with tempfile.TemporaryDirectory() as directory:
result = supervise([sys.executable, '-c', 'import time; time.sleep(30)'], Path(directory) / 'gate', 0.1, {})
self.assertFalse(result['passed'])
self.assertTrue(result['timed_out'])
self.assertLess(result['elapsed_seconds'], 2)
def test_teardown_failure_overrides_successful_smoke_verdict(self):
from regression_ci import main
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / 'ci'
def fail_teardown(args):
verdict = args.output / 'gate' / 'gate.json'
verdict.parent.mkdir(parents=True)
verdict.write_text(json.dumps({'passed': True}))
raise RuntimeError('sandbox teardown failed')
with (
patch('regression_ci.run', side_effect=fail_teardown),
patch.object(
sys, 'argv', ['regression_ci', '--output', str(output), '--lbctl', 'unused', '--plugins', 'unused']
),
self.assertRaises(RuntimeError),
):
main()
result = json.loads((output / 'gate/gate.json').read_text())
self.assertFalse(result['passed'])
self.assertIn('sandbox teardown failed', result['infrastructure_error'])
def test_zero_exit_without_business_verdict_is_failed(self):
with tempfile.TemporaryDirectory() as directory:
result = supervise([sys.executable, '-c', 'pass'], Path(directory) / 'gate', 5, {})
self.assertFalse(result['passed'])
if __name__ == '__main__':
unittest.main()
+240
View File
@@ -0,0 +1,240 @@
"""Exercise signed group traffic, member rotation, phase continuity and fail-closed history."""
import hashlib
import hmac
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from regression_suite import Suite, validate
class GroupTests(unittest.TestCase):
def test_bot_readback_uses_the_cli_confirmed_endpoint_and_workspace(self):
received = []
class Readback(BaseHTTPRequestHandler):
def do_GET(self):
received.append((self.path, self.headers.get('Authorization'), self.headers.get('X-Workspace-Id')))
body = json.dumps({'code': 0, 'data': {'bot': {'uuid': 'owned-bot', 'event_bindings': []}}}).encode()
self.send_response(200)
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Readback)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
with tempfile.TemporaryDirectory() as folder:
current = json.loads((Path(__file__).with_name('regression_profiles') / 'groups.json').read_text())
suite = Suite(current, Path(folder) / 'profile.json', Path(folder), env={'TEST_API_KEY': 'test-key'})
suite.variables['workspace_uuid'] = 'bound-workspace'
suite.connection = {
'endpoint': f'http://127.0.0.1:{server.server_port}',
'credential_source': 'env:TEST_API_KEY',
}
self.assertEqual(suite.read_bot('owned-bot')['uuid'], 'owned-bot')
self.assertEqual(received, [('/api/v1/platform/bots/owned-bot', 'Bearer test-key', 'bound-workspace')])
finally:
server.shutdown()
server.server_close()
thread.join()
def test_groups_profile_and_invalid_modes(self):
path = Path(__file__).with_name('regression_profiles') / 'groups.json'
profile = json.loads(path.read_text())
validate(profile)
for field, value in [('groups', 3), ('members_per_group', 0), ('groups', 4097)]:
current = json.loads(path.read_text())
current['phases'][0][field] = value
with self.subTest(field=field), self.assertRaises(ValueError):
validate(current)
def test_signed_groups_continue_across_phases_and_stop_after_bad_reply(self):
secret = 'test-group-signing-key'
histories = {}
failures = []
active = set()
lock = threading.Lock()
bad_reply = False
class Ingress(BaseHTTPRequestHandler):
def do_GET(self):
self.respond({'code': 0})
def respond(self, payload, status=200):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
raw = self.rfile.read(int(self.headers['Content-Length']))
stamp = self.headers['X-LB-Timestamp']
expected = (
'sha256=' + hmac.new(secret.encode(), stamp.encode() + b'.' + raw, hashlib.sha256).hexdigest()
)
payload = json.loads(raw)
group = payload['session_id']
fields = dict(re.findall(r'LB_(GROUP|TURN|TOKEN|PREVIOUS)=(\S+)', payload['message'][0]['text']))
with lock:
prior = histories.get(group, [])
if (
self.headers['X-LB-Signature'] != expected
or group in active
or payload['session_type'] != 'group'
or fields['GROUP'] != group
or int(fields['TURN']) != len(prior) + 1
or fields['PREVIOUS'] != (prior[-1]['token'] if prior else 'none')
):
failures.append(payload)
active.add(group)
time.sleep(0.01)
with lock:
histories.setdefault(group, []).append(
{'token': fields['TOKEN'], 'member': payload['sender']['id']}
)
active.remove(group)
ack = 'missing' if bad_reply and group.endswith('g0000') else fields['TOKEN']
self.respond(
{
'code': 0,
'data': {
'session_id': group,
'reply_to': 'incoming-' + fields['TOKEN'],
'message': [
{'type': 'Plain', 'text': 'TEST_OK GROUP_'},
{'type': 'Plain', 'text': 'ACK=' + ack},
],
},
},
status=500 if bad_reply == 'unknown' and group.endswith('g0000') else 200,
)
def log_message(self, *_args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Ingress)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
cases = root / 'cases.json'
cases.write_text(
json.dumps(
[
{
'name': 'group',
'expect': 'TEST_OK',
'http_group': {
'url': f'http://127.0.0.1:{server.server_port}/bots/test/sync',
'secret_env': 'LANGBOT_QA_GROUP_SECRET',
'message': 'ai Test group history',
},
}
]
)
)
state = root / 'group-state.json'
command = [
sys.executable,
str(Path(__file__).with_name('regression_traffic.py')),
'--lbctl',
'unused',
'--config',
'unused',
'--cases',
str(cases),
'--duration',
'0.8',
'--rps',
'40',
'--workers',
'3',
'--cooldown',
'0',
'--members-per-group',
'2',
'--health',
f'core=http://127.0.0.1:{server.server_port}/healthz',
]
env = {**os.environ, 'LANGBOT_QA_GROUP_SECRET': secret}
summaries = []
for groups in (4, 8):
output = root / f'phase-{groups}'
result = subprocess.run(
[*command, '--groups', str(groups), '--group-state', str(state), '--output', str(output)],
env=env,
capture_output=True,
text=True,
timeout=15,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
summaries.append(json.loads((output / 'summary.json').read_text()))
first = summaries[0]['groups']['sessions']
second = summaries[1]['groups']['sessions']
self.assertEqual(len(first), 4)
self.assertEqual(len(second), 8)
for group in first:
self.assertGreater(second[group]['successful_turns_total'], first[group]['successful_turns_total'])
self.assertEqual(len(second[group]['members_this_phase']), 2)
self.assertFalse(failures)
self.assertNotIn(secret, state.read_text())
bad_reply = True
output = root / 'failed'
result = subprocess.run(
[*command, '--groups', '4', '--output', str(output)],
env=env,
capture_output=True,
text=True,
timeout=15,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
summary = json.loads((output / 'summary.json').read_text())
failed = next(item for group, item in summary['groups']['sessions'].items() if group.endswith('g0000'))
self.assertTrue(failed['failed'])
self.assertEqual(failed['requests_this_phase'], 1)
bad_reply = 'unknown'
result = subprocess.run(
[*command, '--groups', '4', '--output', str(root / 'unknown')],
env=env,
capture_output=True,
text=True,
timeout=15,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
unknown = json.loads((root / 'unknown/summary.json').read_text())
self.assertEqual(unknown['status'].get('unknown'), 1)
interrupted = json.loads(state.read_text())
interrupted['in_progress'] = True
state.write_text(json.dumps(interrupted))
result = subprocess.run(
[*command, '--groups', '4', '--group-state', str(state), '--output', str(root / 'interrupted')],
env=env,
capture_output=True,
text=True,
timeout=15,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn('interrupted', result.stderr)
finally:
server.shutdown()
server.server_close()
thread.join()
if __name__ == '__main__':
unittest.main()
+195
View File
@@ -0,0 +1,195 @@
"""Exercise Job ownership and lifecycle with controlled CLI/Docker responses."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
import yaml
from regression_job import Job, main
from test_regression_suite import FAKE_CLI, profile
class ControlledJob(Job):
def __init__(self, manifest, suite, path, output, health, fail_install=False):
super().__init__(manifest, suite, path, output)
self.health = health
self.fail_install = fail_install
self.calls = []
self.env['LBCTL_TEST_ROOT'] = str(output.parent)
def command(self, args, timeout=900):
self.calls.append(args)
if args[: len(self.cli)] == self.cli:
operation = args[len(self.cli) :]
if operation[0] == 'install':
self.deploy.mkdir()
(self.deploy / 'data').mkdir()
(self.deploy / 'compose.yaml').write_text('services: {}')
(self.deploy / '.env').write_text(
'LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=' + 't' * 32 + '\nLANGBOT_BOX_ENABLED=true\n'
)
record = {
'deployment_id': 'owned-deployment',
'compose_project': 'test-project',
'compose_file': str(self.deploy / 'compose.yaml'),
'data_dir': str(self.deploy / 'data'),
'env_file': str(self.deploy / '.env'),
'endpoint': self.health,
}
(self.deploy / 'deployment.yaml').write_text(yaml.safe_dump(record))
# Simulate a timeout/failed final probe with no returned record.
return (
(1, json.dumps({'data': {}}), 'install failed')
if self.fail_install
else (0, json.dumps({'data': {'record': record}}), '')
)
if operation[0] == '--endpoint':
return 0, json.dumps({'data': {'workspace_uuid': 'test-workspace'}}), ''
if operation[0] == 'uninstall':
shutil.rmtree(self.deploy / 'data')
return 0, json.dumps({'data': {}}), ''
if args[:2] == ['docker', 'ps']:
if 'label=langbot.box=true' in args:
return 0, '', ''
return (
0,
'\n'.join(
f'id-{name} {name}'
for name in ('langbot', 'langbot_plugin_runtime', 'langbot_box', 'regression_mock')
),
'',
)
if args[:2] == ['docker', 'inspect']:
if '{{.State.Pid}}' in args:
return 0, str(os.getpid()), ''
return 0, json.dumps({'network': {'IPAddress': '127.0.0.1'}}), ''
if args[:2] == ['docker', 'compose']:
if 'logs' in args:
return 0, self.redact(self.env.get('LANGBOT_QA_API_KEY', '') + '\n' + 't' * 32), ''
return 0, '', ''
raise AssertionError(args)
@staticmethod
def ready(url):
pass
def prepare(self):
suite = super().prepare()
# Real traffic driver and HTTP health probe; infrastructure calls are controlled above.
suite.profile['observe'] = {'health': {'core': self.health}}
return suite
class JobTests(unittest.TestCase):
def test_lifecycle_success_and_partial_install_recovery(self):
class Health(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"code":0}')
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Health)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
cli = root / 'lbctl-test'
cli.write_text(f'#!{sys.executable}\n' + FAKE_CLI)
cli.chmod(0o700)
manifest = {'lbctl': str(cli), 'deployment': {'version': 'v4.11.0', 'port': 15351}}
suite = profile()
suite['resources'] = [{'id': 'chat', 'kind': 'pipeline', 'body': {'name': 'QA', 'config': {'ai': {}}}}]
suite['cases'][0].update(args=['pipeline', 'run', '${chat}'], expect='TEST_OK')
suite['phases'][0].update(duration_seconds=0.5, rps=4, workers=2)
health = f'http://127.0.0.1:{server.server_port}'
job = ControlledJob(manifest, suite, root / 'profile.json', root / 'success', health)
result = job.run()
self.assertTrue(result['passed'], result)
self.assertEqual(result['phase'], 'cleaned')
self.assertFalse((job.deploy / 'data').exists())
self.assertFalse((job.private / 'api-key').exists())
self.assertIn('--purge-data', job.calls[-1])
self.assertNotIn('t' * 32, (job.output / 'services.log').read_text())
job = ControlledJob(manifest, suite, root / 'profile.json', root / 'failed', health, fail_install=True)
result = job.run()
self.assertFalse(result['passed'])
self.assertEqual(job.state['record']['deployment_id'], 'owned-deployment')
self.assertTrue((job.deploy / 'data').exists())
self.assertFalse(any('uninstall' in call for call in job.calls))
with (
patch.object(sys, 'argv', ['regression_job.py', 'cleanup', '--output', str(job.output)]),
patch('regression_job.Job', return_value=job),
):
main()
self.assertFalse((job.deploy / 'data').exists())
self.assertEqual(job.state['phase'], 'cleaned')
finally:
server.shutdown()
server.server_close()
thread.join()
def test_cleanup_refuses_changed_deployment(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
job = Job({}, profile(), root / 'profile.json', root)
job.private.mkdir()
job.deploy.mkdir()
(job.private / 'job-id').write_text(job.state['job_id'])
record = {
'deployment_id': 'old',
'compose_file': str(job.deploy / 'compose.yaml'),
'data_dir': str(job.deploy / 'data'),
'env_file': str(job.deploy / '.env'),
}
job.state['record'] = record
(job.deploy / 'deployment.yaml').write_text(yaml.safe_dump({**record, 'deployment_id': 'new'}))
with self.assertRaisesRegex(RuntimeError, 'identity changed'):
job.cleanup()
def test_sandbox_cleanup_uses_owned_mount_roots(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
job = Job({}, profile(), root / 'profile.json', root)
job.state['record'] = {'data_dir': str(job.deploy / 'data')}
containers = ['ours', 'other']
def checked(args, timeout=900):
if args[1] == 'ps':
return '\n'.join(containers)
return '\n'.join(
json.dumps(
{
'Id': container,
'Mounts': [
{
'Source': str(job.deploy / 'data/box/tenant')
if container != 'other'
else '/unrelated/box/tenant'
}
],
}
)
for container in args[4:]
)
job.checked = checked
self.assertEqual(job.owned_sandboxes(), ['ours'])
containers.append('later')
self.assertEqual(job.owned_sandboxes(), ['ours', 'later'])
if __name__ == '__main__':
unittest.main()
+205
View File
@@ -0,0 +1,205 @@
"""Challenge the smoke verdict independently of a successful model reply."""
import base64
import copy
import json
from pathlib import Path
import sqlite3
import tempfile
from types import SimpleNamespace
import unittest
from regression_smoke import (
UnresolvedRun,
check_attachment,
check_fields,
check_mock_requests,
check_run,
completed_run,
verify_cleanup,
)
from regression_suite import validate
class SmokeOracleTest(unittest.TestCase):
def setUp(self):
self.db = sqlite3.connect(':memory:')
self.db.row_factory = sqlite3.Row
self.addCleanup(self.db.close)
self.db.executescript("""
CREATE TABLE agent_run (id INTEGER, run_id TEXT, workspace_id TEXT,
conversation_id TEXT, status TEXT, status_reason TEXT, finished_at TEXT);
CREATE TABLE agent_run_event (run_id TEXT, sequence INTEGER, type TEXT, data_json TEXT);
INSERT INTO agent_run VALUES (1, 'run', 'workspace', 'conversation', 'completed', '', 'now');
INSERT INTO agent_run_event VALUES ('run', 1, 'run.completed', '{}');
""")
def run_row(self, workspace='workspace', after=0):
return completed_run(self.db, workspace, 'conversation', after, 0)
def test_terminal_status_and_event_are_both_required(self):
self.assertTrue(check_run(self.db, self.run_row(), {})['terminal_event'])
self.db.execute("UPDATE agent_run SET status='failed'")
with self.assertRaises(ValueError):
check_run(self.db, self.run_row(), {})
self.db.execute("UPDATE agent_run SET status='completed'")
self.db.execute('DELETE FROM agent_run_event')
with self.assertRaises(ValueError):
check_run(self.db, self.run_row(), {})
def test_missing_stale_wrong_workspace_and_ambiguous_runs_are_unknown(self):
for workspace, after in [('other', 0), ('workspace', 1)]:
with self.subTest(workspace=workspace, after=after), self.assertRaises(UnresolvedRun):
self.run_row(workspace, after)
self.db.execute("UPDATE agent_run SET status='running', finished_at=NULL")
with self.assertRaises(UnresolvedRun):
self.run_row()
self.db.execute('INSERT INTO agent_run SELECT * FROM agent_run')
with self.assertRaises(UnresolvedRun):
self.run_row()
def test_box_requires_actual_backend_success_and_output(self):
case = {'box_exec': {'count': 1, 'backend': 'docker', 'stdout': 'sentinel'}}
good = {'backend': 'docker', 'exit_code': 0, 'stdout': 'sentinel'}
for result, passes in [
(good, True),
({**good, 'backend': 'host'}, False),
({**good, 'exit_code': 1}, False),
({**good, 'stdout': ''}, False),
(None, False),
]:
self.db.execute('DELETE FROM agent_run_event WHERE sequence=2')
self.db.execute(
'INSERT INTO agent_run_event VALUES (?, ?, ?, ?)',
('run', 2, 'tool.call.completed', json.dumps({'tool_name': 'exec', 'result': result})),
)
with self.subTest(result=result):
if passes:
check_run(self.db, self.run_row(), case)
else:
with self.assertRaises(ValueError):
check_run(self.db, self.run_row(), case)
with self.assertRaises(ValueError):
check_run(self.db, self.run_row(), {'box_exec': {**case['box_exec'], 'count': 2}})
def test_attachment_requires_exact_bytes_and_single_file(self):
expected = {'name': 'out.txt', 'text': 'sentinel'}
part = {'type': 'File', 'name': 'out.txt', 'base64': base64.b64encode(b'sentinel').decode()}
data = {'replies': [{'message_chain': [part]}]}
self.assertTrue(check_attachment(data, expected)['content_matched'])
for parts in [
[],
[part, part],
[{**part, 'base64': '!!!'}],
[{**part, 'base64': 'd3Jvbmc='}],
[{**part, 'name': 'other'}],
[None],
]:
with self.subTest(parts=parts), self.assertRaises(ValueError):
check_attachment({'replies': [{'message_chain': parts}]}, expected)
def test_fields_check_empty_results_and_missing_paths(self):
check_fields({'results': []}, {'results': []})
check_fields({'results': [{'metadata': {'kb': 'owned'}}]}, {'results.0.metadata.kb': 'owned'})
for data in [{'results': [1]}, {}, {'results': None}]:
with self.subTest(data=data), self.assertRaises(ValueError):
check_fields(data, {'results': []})
with self.assertRaises(ValueError):
check_fields({'results': None}, {'results.0.metadata.kb': 'owned'})
def test_fallback_must_call_failed_primary_then_streaming_backup(self):
expected = [
{'model': 'primary', 'status': 429, 'stream': True},
{'model': 'backup', 'status': 200, 'stream': True},
]
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'requests.jsonl'
prefix = b'earlier request ignored\n'
for rows in [
expected,
list(reversed(expected)),
[expected[0], {**expected[1], 'stream': False}],
[expected[0], {**expected[1], 'status': 503}],
]:
path.write_bytes(
prefix
+ b''.join(
(
json.dumps(
{
'path': '/v1/chat/completions',
'status': row['status'],
'request': {'model': row['model'], 'stream': row['stream']},
}
)
+ '\n'
).encode()
for row in rows
)
)
if rows == expected:
self.assertEqual(check_mock_requests(path, len(prefix), expected), expected)
else:
with self.assertRaises(ValueError):
check_mock_requests(path, len(prefix), expected)
def test_expected_tool_failure_requires_exact_exit_and_stderr(self):
case = {'box_exec': {'count': 1, 'backend': 'docker', 'stdout': 'out', 'exit_code': 7, 'stderr': 'err'}}
for exit_code, stderr in [(7, 'err'), (0, 'err'), (7, '')]:
self.db.execute('DELETE FROM agent_run_event WHERE sequence=2')
self.db.execute(
'INSERT INTO agent_run_event VALUES (?, ?, ?, ?)',
(
'run',
2,
'tool.call.completed',
json.dumps(
{
'tool_name': 'exec',
'result': {'backend': 'docker', 'stdout': 'out', 'exit_code': exit_code, 'stderr': stderr},
}
),
),
)
if exit_code == 7 and stderr == 'err':
check_run(self.db, self.run_row(), case)
else:
with self.assertRaises(ValueError):
check_run(self.db, self.run_row(), case)
def test_cleanup_readback_rejects_resources_still_visible(self):
deleted = [{'kind': 'pipeline', 'uuid': 'owned'}]
evidence = {}
for visible in ['unrelated', 'owned']:
suite = SimpleNamespace(
call=lambda args: {'pipelines': [{'uuid': visible}]},
write=lambda name, value: evidence.update({name: value}),
)
if visible == 'owned':
with self.assertRaises(ValueError):
verify_cleanup(suite, deleted)
else:
verify_cleanup(suite, deleted)
self.assertEqual(evidence['cleanup-checks.json'][0]['remaining'], [])
suite.call = lambda args: {'unexpected': []}
with self.assertRaises(ValueError):
verify_cleanup(suite, deleted)
def test_profile_supports_mixed_cases_and_rejects_ignored_oracles(self):
profile = json.loads(Path(__file__).with_name('regression_profiles').joinpath('smoke.json').read_text())
validate(profile)
for changes in [{'thresholds': {'min_rps_ratio': 1}}, {'smoke': {'ledger_db': ''}}, {'phases': None}]:
with self.subTest(changes=changes), self.assertRaises(ValueError):
validate({**profile, **changes})
wrong = copy.deepcopy(profile)
next(case for case in wrong['cases'] if case['name'] == 'rag_retrieve')['box_exec'] = {
'count': 1,
'backend': 'docker',
'stdout': 'x',
}
with self.assertRaises(ValueError):
validate(wrong)
if __name__ == '__main__':
unittest.main()
+309
View File
@@ -0,0 +1,309 @@
"""Focused checks for profile validation, ownership and the CLI preparation sequence."""
import copy
from contextlib import redirect_stdout
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
from regression_suite import Suite, main, merge, resolve, validate
FAKE_CLI = r"""
import json, os, sys
from pathlib import Path
args = sys.argv[5:]
root = Path(os.environ['LBCTL_TEST_ROOT'])
state_path = root / 'cli-state.json'
state = json.loads(state_path.read_text()) if state_path.exists() else {}
body = json.loads(Path(args[args.index('--file') + 1]).read_text()) if '--file' in args else {}
with (root / 'cli-calls.jsonl').open('a') as log:
log.write(json.dumps({'args': args, 'body': body}) + '\n')
if args == ['whoami']:
data = {'workspace_uuid': 'test-workspace'}
elif args[:2] == ['pipeline', 'run']:
assert args[2] in state
data = {'status': 'completed', 'reply': 'TEST_OK'}
elif args[1] == 'delete':
del state[args[2]]
state_path.write_text(json.dumps(state))
data = {}
elif args[:2] == ['pipeline', 'get']:
data = state[args[2]]
elif args[1] in ['create', 'apply']:
uuid = body.get('uuid', f'created-{len(state)}')
data = {'uuid': uuid, 'name': body.get('name', 'test'), 'description': '', 'config': body.get('config', {'ai': {'default': True}})}
state[uuid] = data
state_path.write_text(json.dumps(state))
else:
raise RuntimeError(args)
print(json.dumps({'ok': True, 'data': data}))
"""
def profile():
return {
'version': 1,
'cli': {'config': 'lbctl.yaml'},
'cases': [{'name': 'chat', 'args': ['pipeline', 'run', 'existing'], 'expect': 'OK'}],
'phases': [{'name': 'normal', 'duration_seconds': 1, 'rps': 2, 'workers': 4}],
'observe': {'health': {'core': 'http://127.0.0.1:5300/healthz'}},
}
class ProfileTests(unittest.TestCase):
def test_created_bot_verification_failure_can_only_defer_bot_readback(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
suite = Suite(profile(), root / 'profile.json', root)
response = {
'data': {'operation': 'bot.create', 'uuid': 'owned-bot', 'bot': {'uuid': 'owned-bot'}},
'error': {'type': 'verification_failed'},
}
result = subprocess.CompletedProcess([], 1, json.dumps(response), '')
with patch('regression_suite.subprocess.run', return_value=result):
with self.assertRaises(RuntimeError):
suite.call(['bot', 'create'], {'name': 'QA'})
self.assertEqual(
suite.call(['bot', 'create'], {'name': 'QA'}, defer_bot_readback=True)['uuid'], 'owned-bot'
)
with self.assertRaises(RuntimeError):
suite.call(['pipeline', 'apply'], {}, defer_bot_readback=True)
def test_group_bot_is_owned_before_raw_readback_and_warmed_before_load(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
current = profile()
current['resources'] = [
{
'id': 'chat_bot',
'kind': 'bot',
'body': {
'name': 'QA',
'adapter': 'http_bot',
'enable': True,
'adapter_config': {'inbound_secret': '${group_secret}'},
'event_bindings': [{'target_type': 'pipeline', 'target_uuid': 'pipeline-a'}],
},
}
]
current['cases'] = [
{
'name': 'chat',
'expect': 'OK',
'http_group': {
'url': 'http://localhost/bots/${chat_bot}/sync',
'message': 'ai Test',
'secret_env': 'LANGBOT_QA_GROUP_SECRET',
},
}
]
current['phases'][0]['groups'] = 1
validate(current)
for correct_route in (False, True):
suite = Suite(current, root / 'profile.json', root)
def call(args, body=None, **kwargs):
if args == ['whoami']:
return {'workspace_uuid': 'workspace-a'}
if args == ['bot', 'create']:
self.assertTrue(kwargs['defer_bot_readback'])
return {'uuid': 'owned-bot'}
raise AssertionError(args)
suite.call = call
actual = copy.deepcopy(current['resources'][0]['body'])
actual['adapter_config']['inbound_secret'] = '***'
if not correct_route:
actual['event_bindings'][0]['target_uuid'] = 'wrong-pipeline'
with (
patch.object(suite, 'read_bot', return_value=actual),
patch('regression_suite.group_request', return_value={'code': 0}) as warmup,
):
if correct_route:
suite.prepare()
warmup.assert_called_once()
self.assertNotIn(suite.variables['group_secret'], (root / 'resources.json').read_text())
else:
with self.assertRaisesRegex(RuntimeError, 'Bot readback differs'):
suite.prepare()
warmup.assert_not_called()
self.assertEqual(
json.loads((root / 'owned.json').read_text())['resources'],
[{'kind': 'bot', 'uuid': 'owned-bot'}],
)
def test_normal_profile_and_overrides_contract(self):
validate(json.loads(Path(__file__).with_name('regression_profiles').joinpath('normal.json').read_text()))
bad = profile()
for field, value in [('rps', float('nan')), ('workers', 33), ('duration_seconds', -1)]:
with self.subTest(field=field):
current = copy.deepcopy(bad)
current['phases'][0][field] = value
with self.assertRaises(ValueError):
validate(current)
def test_missing_refs_and_existing_resource_overwrite_are_rejected(self):
current = profile()
current['cases'][0]['args'][-1] = '${missing}'
with self.assertRaises(KeyError):
validate(current)
current = profile()
current['resources'] = [{'id': 'pipeline', 'kind': 'pipeline', 'body': {'uuid': 'existing'}}]
with self.assertRaisesRegex(ValueError, 'uuid is forbidden'):
validate(current)
def test_merge_keeps_defaults_but_replaces_lists(self):
base = {'ai': {'enabled': True, 'models': ['old']}, 'output': {'x': 1}}
actual = merge(base, {'ai': {'models': ['new']}})
self.assertEqual(actual, {'ai': {'enabled': True, 'models': ['new']}, 'output': {'x': 1}})
self.assertEqual(base['ai']['models'], ['old'])
self.assertEqual(resolve(['${id}', {'id': '${id}'}], {'id': 'created'}), ['created', {'id': 'created'}])
def test_ownership_recorded_before_configuration_failure(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
current = profile()
current['resources'] = [{'id': 'chat', 'kind': 'pipeline', 'body': {'name': 'QA', 'config': {'ai': {}}}}]
suite = Suite(current, root / 'profile.json', root)
calls = []
def call(args, body=None):
calls.append((args, body))
if args == ['whoami']:
return {'workspace_uuid': 'workspace-a'}
if args == ['pipeline', 'apply'] and 'uuid' not in body:
return {'uuid': 'owned-pipeline'}
if args[:2] == ['pipeline', 'get']:
return {'uuid': 'owned-pipeline', 'name': 'QA', 'description': '', 'config': {'ai': {'old': 1}}}
if args == ['pipeline', 'apply']:
raise RuntimeError('configuration failed')
return {}
suite.call = call
with self.assertRaisesRegex(RuntimeError, 'configuration failed'):
suite.prepare()
self.assertEqual(
json.loads((root / 'owned.json').read_text())['resources'],
[{'kind': 'pipeline', 'uuid': 'owned-pipeline'}],
)
self.assertEqual(suite.cleanup(), [])
self.assertEqual(calls[-1][0], ['pipeline', 'delete', 'owned-pipeline', '--yes'])
self.assertEqual(json.loads((root / 'owned.json').read_text())['resources'], [])
def test_cleanup_refuses_another_workspace(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / 'owned.json').write_text(json.dumps({'workspace_uuid': 'other', 'resources': []}))
suite = Suite(profile(), root / 'profile.json', root)
suite.call = lambda args: {'workspace_uuid': 'current'}
with self.assertRaisesRegex(RuntimeError, 'Workspace differs'):
suite.cleanup()
def test_aborted_run_is_not_treated_as_successful_cleanup(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
config = root / 'profile.json'
config.write_text(json.dumps(profile()))
for error_type in (KeyboardInterrupt, TypeError):
output = root / error_type.__name__
def interrupt(suite):
suite.variables['workspace_uuid'] = 'test-workspace'
suite.journal()
raise error_type
argv = ['regression_suite.py', 'run', '--profile', str(config), '--output', str(output)]
with (
patch.object(sys, 'argv', argv),
patch.object(Suite, 'prepare', interrupt),
patch.object(Suite, 'cleanup') as cleanup,
redirect_stdout(io.StringIO()),
):
with self.assertRaises(SystemExit if error_type is KeyboardInterrupt else TypeError):
main()
cleanup.assert_not_called()
self.assertFalse(json.loads((output / 'summary.json').read_text())['passed'])
def test_subprocess_phases_and_cleanup_with_controlled_cli(self):
class Health(BaseHTTPRequestHandler):
def do_GET(self):
payload = b'{"code":0}'
self.send_response(200)
self.send_header('Content-Length', str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Health)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
cli = root / 'lbctl-test'
cli.write_text(f'#!{sys.executable}\n' + FAKE_CLI)
cli.chmod(0o700)
current = profile()
current['cli']['executable'] = str(cli)
current['resources'] = [
{'id': 'provider', 'kind': 'provider', 'body': {'name': 'test'}},
{
'id': 'pipeline',
'kind': 'pipeline',
'body': {'name': 'test', 'config': {'ai': {'model': '${provider}'}}},
},
]
current['cases'] = [
{'name': 'chat', 'args': ['pipeline', 'run', '${pipeline}'], 'expect': 'TEST_OK', 'weight': 2},
{'name': 'box', 'args': ['pipeline', 'run', '${pipeline}'], 'expect': 'TEST_OK', 'weight': 1},
]
current['phases'] = [
{'name': name, 'duration_seconds': 0.5, 'rps': 12, 'workers': 3} for name in ('low', 'high')
]
current['observe'] = {'health': {'core': f'http://127.0.0.1:{server.server_port}/healthz'}}
current['cleanup'] = 'always'
config = root / 'profile.json'
command = [
sys.executable,
str(Path(__file__).with_name('regression_suite.py')),
'run',
'--profile',
str(config),
]
environment = {**os.environ, 'LBCTL_TEST_ROOT': folder}
for enforce_latency in (False, True):
current['thresholds'] = {'max_p95_seconds': {'chat': 0.001}} if enforce_latency else {}
config.write_text(json.dumps(current))
output = root / ('failure' if enforce_latency else 'success')
result = subprocess.run(
[*command, '--output', str(output)], env=environment, capture_output=True, text=True, timeout=30
)
summary = json.loads((output / 'summary.json').read_text())
self.assertEqual(result.returncode, int(enforce_latency), result.stdout + result.stderr)
self.assertEqual(summary['passed'], not enforce_latency)
self.assertEqual(len(summary['phases']), 2)
for phase in summary['phases']:
self.assertGreater(phase['achieved_rps'], 0)
self.assertEqual(phase['health_failures'], 0)
self.assertGreaterEqual(phase['cases']['chat']['requests'], phase['cases']['box']['requests'])
self.assertEqual(json.loads((output / 'owned.json').read_text())['resources'], [])
self.assertEqual(json.loads((root / 'cli-state.json').read_text()), {})
finally:
server.shutdown()
server.server_close()
thread.join()
if __name__ == '__main__':
unittest.main()