REST API

Reference for the FastAPI endpoints exposed by test_server.py on port 8083. All endpoints accept and return JSON unless noted.

Auth model. In the multi-stand deployment every request enters through the gateway (gateway_service/app.py) — the only publicly exposed port. Every endpoint except the gateway allowlist — /, /health, /api/auth/login, /api/auth/logout, and /novnc/* — requires a bearer session token (header Authorization: Bearer <token>) obtained from POST /api/auth/login. The gateway authenticates the caller, forwards the identity to the per-stand worker (test_server.py), and proxies the worker endpoints documented below. See the dedicated Gateway section, Environments & the Gateway and Accounts, Roles & Audit.

Health

GET /health → {"status":"ok"}

Gateway

These endpoints are served by the gateway itself (gateway_service/app.py), not proxied to a worker. They handle login, central account / stand / env-profile management, and run dispatch onto a worker pool. Cross-ref: Environments & the Gateway and Accounts, Roles & Audit.

Auth

Method Path Purpose

POST

/api/auth/login

Authenticate. Body {username, password}. Returns {token, user:{username, role, stand}}. Allowlisted (no token required); rate-limited (5 fails / 60 s per user+IP → 429).

POST

/api/auth/logout

Invalidate the caller’s session token. Allowlisted.

GET

/api/auth/me

Current session identity {username, role, stand, …}. Requires a token.

Users (admin)

Method Path Purpose

GET

/api/users

List all accounts.

POST

/api/users

Create an account. Body {username, password, role?, stand?} (role defaults to editor).

POST

/api/users/{username}/reset-password

Set a new password. Body {password}.

DELETE

/api/users/{username}

Delete an account.

Audit (admin)

Method Path Purpose

GET

/api/activity

Central activity / audit log. Query: username?, action?, limit (default 200), offset.

Env Profiles

Profiles are named bundles of worker settings (emulator_url, ACS URL, …) that the gateway materialises onto a worker at dispatch time.

Method Path Purpose

GET

/api/gateway/profiles

List env profiles (any authenticated user).

POST

/api/gateway/profiles

Create / replace a profile. Body {name, settings:{}} (admin). A blank emulator_mcp_url is filled with the worker’s bundled sidecar address; nothing is provisioned.

GET

/api/gateway/profiles/{name}

Full profile settings (admin).

DELETE

/api/gateway/profiles/{name}

Delete a profile (admin). Nothing else is torn down: the emulator MCP sidecars are bundled per worker.

Stands / Workers (admin)

Method Path Purpose

GET

/api/gateway/stands

List worker stands.

POST

/api/gateway/stands

Register / upsert a stand. Body {name, base_url, display?, novnc_url?, enabled?}.

PATCH

/api/gateway/stands/{name}

Update a stand (e.g. {enabled} to toggle).

DELETE

/api/gateway/stands/{name}

Deregister a stand. ?teardown=true also best-effort removes the provisioned tr-worker-<name> stack.

POST

/api/gateway/workers

Auto-provision a new worker stack via Portainer. Body {name?} (auto-named w<N> when omitted). 503 when provisioning is not configured.

Run Dispatch

Method Path Purpose

POST

/api/gateway/run

Materialise env’s settings onto a free worker, then start a single run there. Body `{env, test_file, model?, dry_run?}. 409 when that env’s emulator is busy, 503 when all workers are busy.

POST

/api/gateway/run/batch

Same, for a batch. Body {env, test_files:[], order?, model?, dry_run?}.

POST

/api/live/ticket

Open the caller’s live view: the worker running the caller’s own run (or a running batch of theirs) plus a one-time ticket for its noVNC stream. Returns {stand, ticket} or {stand: null}. The ticket is bound to the caller and that worker, expires after 60 s (GATEWAY_LIVE_TICKET_TTL) and opens one WS /novnc/{stand}/websockify?ticket=…​ only; the session token is not accepted there.

GET

/api/live/stand

Name of the worker whose live browser the caller may watch right now (their own active run, or a running batch of theirs). Returns `{stand: name

null}`.

POST

/api/runs/{run_id}/ws-ticket

A one-time ticket for the caller’s progress stream of that run (see WebSocket). Returns {run_id, ticket}. The ticket is bound to the caller’s account, role and that run, expires after 60 s (GATEWAY_LIVE_TICKET_TTL) and opens one WS /ws/run/{run_id}?ticket=…​ only. Nothing about the run is checked at issue: the worker decides at connect, under the ticket’s identity.

GET

/api/gateway/batches

The gateway proxies the worker endpoints in the sections below per-caller and owner-scopes them (see Runs). Batch-status, per-run reads, WebSocket and stop are fanned out across the worker pool so they reach the caller’s run on whichever worker it landed.
Which worker answers, and why it matters

A batch lives on exactly ONE worker, so every per-batch read and write reaches the worker that holds that batch, never the first one. The sub-routes of a saved report — /api/run/batch/reports/{batch_id}/… (comments, the result-row PATCH, the export) — are addressed to the worker whose reports contain that id, looked up as the caller and cached per id; PATCH /api/run/batch/{batch_id} is fanned out instead, and the one worker that holds a live batch of that id applies it (503 when the worker that would hold it could not be reached, so a lost edit is never reported as a 404). POST /api/run/batch/reports/import-xml is the exception that needs no lookup: an imported run has no worker yet, so it is created on the primary one.

Case routes take the other path. Each worker keeps its own test-cases volume, so the catch-all proxy sends /api/tests…, /api/trash…, the history, attachment and case-XML routes to the caller’s own stand (a pinned stand, an admin’s X-Stand header, the session’s active stand, else the first enabled one) — a write that landed anywhere else would never be read back. GET /api/tests/{filename} is the one deliberate exception: the case body comes from the primary worker, and its last_run is fanned out across the fleet, because a case’s runs are spread over whichever worker served each of them.

Test Cases

Method Path Purpose

GET

/api/tests

List all YAML cases. A row is {file, id, title, portal, base_url, step_count, steps_preview, project, runs, section, reference, priority, type, required_isp, claims, created_by, edited_by, edited_at}, plus blocked / blocked_categories on blocked cases only. Query: q (substring of id / title / reference / section), project, section, portal, type, priority (whole-field, case-insensitive), reference (substring), isp (1 / 2 / none), run (a TestRail run id or none; a non-numeric value → 400), sort (id / title / type / priority / reference / section / edited, unknown → 400), order (asc / desc, unknown → 400). Every one of those parameters is applied by the server, for API callers. The SPA does not use them: it fetches the whole corpus once, with no query string, and narrows and orders its own copy in the browser — see Test Management → Search, Sort and Filters.

GET

/api/tests/{filename}

Parsed JSON view of a single case, plus meta ({info, history}) and last_run — the caller’s newest finished run of THIS case as {id, status, date, elapsed_ms}, or null. Owner-scoped like every other history read; through the gateway it is the newest across every worker, since a case’s runs are spread over whichever worker served each of them.

GET

/api/tests/{filename}/yaml

Raw YAML text of a case.

POST

/api/tests

Create a new case from a JSON body.

PUT

/api/tests/{filename}

Replace an existing case.

POST

/api/tests/{filename}/regenerate

Regenerate emulator_setup from current text.

POST

/api/tests/upload-yaml

Upload a hand-edited YAML file.

DELETE

/api/tests/{filename}

Move a case to the trash (restorable) — see Test Management.

DELETE

/api/tests

Move all cases to the trash; ?section= narrows it to one section.

GET

/api/sections

Distinct values of section (for filters).

Case Organization

Projects and per-case org fields (project, references, priority, type) used to group and filter cases in the editor. See Organizing Cases: Projects & Sections for the user-facing workflow.

Method Path Purpose

GET

/api/projects

List projects, each with a case_count (any authenticated user).

POST

/api/projects

Create a project. Body {name, description?} (admin / editor).

PATCH

/api/projects/{name}

Rename ({new_name}) and/or edit description; rewrites the project field of every affected case (admin / editor).

DELETE

/api/projects/{name}

Delete a project; its cases are orphaned to Unassigned (project="") (admin / editor).

POST

/api/tests/assign-project

Bulk-assign cases to a project. Body {project, files:[]}. Returns {assigned} (admin / editor).

GET

/api/ui-config

Read-only, non-secret UI config (currently {jira_base_url}) for rendering reference links.

POST

/api/testrail/backfill-fields

Backfill org fields (project, references, priority, type) from TestRail into local YAMLs — no conversion / Stage 2 / cache bump. Body {project_id, suite_id, section_id?, dry_run?, ref_prefix?}; dry_run defaults to true (admin / editor).

Test Management

Trash, versioned history, duplicates, bulk edits, attachments and case XML. See Test Management: Trash, History, Attachments & Run Reports for the user-facing workflow.

Case Lifecycle

Method Path Purpose

GET

/api/trash

Trashed cases, newest delete first: [{file, test_id, title, deleted_by, deleted_at, reason}] (any authenticated user).

POST

/api/trash/{filename}/restore

Move a trashed case back; 409 when a live case of that name exists (admin / editor).

DELETE

/api/trash/{filename}

Purge one trashed case for good — this is what also drops its attachments and its version snapshots; a trash move and a restore keep both (admin / editor).

DELETE

/api/trash

Empty the trash: purge every case in it, attachments and history included. Returns {purged}. Admin only — the one unrecoverable step.

POST

/api/tests/{filename}/duplicate

Copy a case under a fresh id. Body {id?, title?}; drops testrail_runs, copies attachments. Returns {file, id, title}; 409 on an id or filename clash (admin / editor).

POST

/api/tests/bulk-update

Change one metadata field across many cases. Body {files:[], field, mode, find?, replace?, value?, dry_run?}; field is one of title / section / project / priority / type / reference, mode is replace or set, dry_run defaults to true. 400 on an unknown field or mode, an empty files, or replace without find. A file that cannot be read is counted in errors, not skipped silently; steps are never rewritten. Returns {changed, unchanged, errors, items:[{file, before, after, changed, error?}], dry_run} (admin / editor).

QA-Verified Records

Method Path Purpose

GET

/api/qa-verified

The QA-verified records: {records:[{test_id, by, entered_by, at, note, run?, source, history}]} (any authenticated user).

PUT

/api/qa-verified

Set or clear QA-verified records (admin only). Body {test_ids:[], by, note?, clear?, runs?} with at most 100 ids. Returns {changed:[ids]}.

A QA-verified record freezes the case. The record is not a sign-off.

runs is optional. It maps a test id from test_ids to the run that QA used: {"C123": {"run_id": "ab12cd34", "stand": "live", "at": "2026-09-20T09:30:00Z", "git_sha": null}}.

  • run_id has 8 lowercase hex characters.

  • stand is the name of the worker.

  • at is a UTC time in ISO 8601 form with a trailing Z.

  • git_sha has 7 to 40 hex characters. Send null when the build is unknown.

The test agent answers 400 and writes nothing for a bad run, an id that is not in test_ids, or runs together with clear.

A repeated call without runs keeps the run of the record when by is the same. If by changes, the test agent drops the run. The history entry and the audit line name the result.

History

Method Path Purpose

GET

/api/tests/{filename}/history

Version rows [{version, ts, username, action, changes, reference, build, has_snapshot}], newest first (any authenticated user).

GET

/api/tests/{filename}/history/{version}

{version, yaml, diff} — that snapshot and its unified diff against the current file; 404 when no snapshot exists.

POST

/api/tests/{filename}/history/{version}/restore

Write the snapshot back as the current case, logging restored (admin / editor).

Attachments

Method Path Purpose

GET

/api/tests/{filename}/attachments

List a case’s files: [{name, size, mtime, uploaded_by}] (any authenticated user).

POST

/api/tests/{filename}/attachments

Upload one (multipart file, 20 MB cap, basename sanitised). Returns {name, size, replaced}; 413 when too large (admin / editor).

GET

/api/tests/{filename}/attachments/{name}

Download one file.

DELETE

/api/tests/{filename}/attachments/{name}

Remove one file (admin / editor).

Case XML

Method Path Purpose

POST

/api/tests/export-xml

Export cases as one native ft-test-cases document. Body {files:[]} (empty or absent = every case); answers an application/xml attachment named test-cases-<YYYY-MM-DD>.xml (any authenticated user).

POST

/api/tests/import-xml

Import cases (multipart file ≤ 64 MB → else 413, form on_conflict=skip (default) / overwrite, anything else 400). Optional form llm_mode=full | off | cache_only, anything else 400: the LLM mode of a TestRail export’s import job. Without it, the job takes the stand’s default mode. A native root ignores it. A DOCTYPE is refused. A native root returns {imported, skipped, overwritten, errors, with_attachments}. A non-native root starts a TestRail import job and returns {job_id, status, total} (see /api/import-xml-batch) (admin / editor).

Batch Reports

Method Path Purpose

GET

/api/run/batch/reports/{batch_id}/comments

The batch’s comments, oldest first (any authenticated user; owner-scoped — a batch you do not own is 404).

POST

/api/run/batch/reports/{batch_id}/comments

Add one. Body {text} (trimmed, ≤ 2000 chars, empty → 400) → {id, ts, username, text} (admin / editor).

DELETE

/api/run/batch/reports/{batch_id}/comments/{id}

Remove one (admin / editor); 403 unless you are its author or an admin.

PATCH

/api/run/batch/reports/{batch_id}/results/{run_id}

Set {comment?, defects?} on a result row, mirrored into that run’s own JSON. Neither field → 400, an unknown run_id → 404, 409 while the batch is still running / paused / starting (admin / editor).

GET

/api/run/batch/reports/{batch_id}/export

Export the report. ?format=csv (default) / xlsx / xml, anything else 400; xlsx answers 501 without openpyxl. Any authenticated user.

POST

/api/run/batch/reports/import-xml

Import an exported run (multipart file, ≤ 20 MB, root tag <ft-test-run>, a DOCTYPE refused) as a new report owned by the importer. Returns {batch_id, imported_from, total} (admin / editor).

PATCH

/api/run/batch/{batch_id}

Edit a batch in flight. Body {add:[files], remove:[files]} — removals first, so one call can swap a case out for another. Owner-scoped, running / paused only (else 409); nothing is all-or-nothing, every refused file comes back in rejected with its reason. Returns {added, removed, rejected, total} (admin / editor).

Reading a report and exporting it are open to every role, but writing one is not: commenting, annotating a row and importing a run all require admin or editor. Owner scoping does not stand in for that rule — import-xml mints a report owned by whoever posts it, so ownership can never refuse a viewer.

Trees

XML tree files live under /opt/mcp-shared/ on the test-runner host and are referenced from each YAML’s emulator_setup.tree_file. The editor’s Tree File dropdown is populated from these endpoints.

Method Path Purpose

GET

/api/trees

List *.xml files in /opt/mcp-shared/. Returns [{name, size, mtime}] (mtime as Unix epoch seconds).

POST

/api/trees/upload

Upload an .xml tree (multipart file=@path/to/tree.xml). Server validates the payload via ET.fromstring, sanitises the basename (rejects path-traversal, slashes, dots), and writes it into /opt/mcp-shared/. Returns {file, size, replaced} — replaced=true means an existing file with the same name was overwritten.

DELETE

/api/trees/{filename}

Delete an .xml tree from /opt/mcp-shared/. Filename is reduced to its basename and required to end with .xml; 400 on invalid input, 404 if the file is missing.

# List
curl -s http://ai.friendly-tech.com:8083/api/trees | python -m json.tool

# Upload (or replace)
curl -F "file=@./181.xml" http://ai.friendly-tech.com:8083/api/trees/upload
Uploads are accepted only for .xml. Path traversal attempts (../, \\, /) and dot-aliases (., ..) are rejected with HTTP 400.

Runs

Method Path Purpose

POST

/api/run

Start a single run. Query parameters: test_file (required), model (default opus), dry_run (default false), force (start an infra-blocked case anyway). Requires admin or editor. Returns {run_id, test_id, title} — or, for a blocked case, {run_id, status:"blocked", error}: a blocked case is recorded as a finished run, not refused with a 409. 409 while another run is active here or a batch is running / paused here — including the moment between two of its cases (a dry_run skips this check).

POST

/api/run/stop

Force-stop the caller’s own run (owner-scoped, no body). Not "all runs" — through the gateway it fans out to every worker so it reaches the caller’s run wherever it landed.

POST

/api/run/batch

Start a batch. Body: {test_files:[], model?, dry_run?, order?, name?, project?, section?, source_batch?} — name is ≤ 120 chars and the identity fields are stored on the report — order is auto (default) / preserve / reverse / shuffle. Draft and explore files are dropped and reported as skipped_drafts. Returns {batch_id, test_files, total, skipped_drafts}. Requires admin or editor; 409 while another batch is running or paused here (a paused batch resumes by itself, so it keeps the worker).

GET

/api/run/batch

Current batch status: {status, id?, total?, current_index?, run_ids?, test_files?, model?, paused_until?, pause_reason?, pause_count?}. Status is running / paused / stopped / completed / error / idle / none — read paused_until before concluding a batch has finished.

GET

/api/run/batch/report

Latest in-memory batch report JSON.

GET

/api/run/batch/summary

Compact status of the batch running here — deliberately not owner-scoped: this is what the cross-account Wave in progress card and the Workers Busy column read. Returns {status, id, owner, model, total, done, counts:{passed,failed,inconclusive,blocked,error}, current_index, current, started_at, elapsed_ms, eta_ms, paused_until, pause_reason}.

GET

/api/run/batch/reports

Archived batch reports as {reports:[…]} (an object, not a bare list). Each row adds owner, stop_reason and file; the summary now also carries inconclusive, blocked, judged, measured, llm_usage and environment.

GET

/api/run/batch/reports/{batch_id}

Full archived batch report (summary + per-test results).

DELETE

/api/run/batch/reports

Wipe archived reports on disk.

GET

/api/runs

One page of the caller’s completed runs, newest first, from memory and disk. Query: limit / offset (the page), q (substring of case id or title), status (passed / failed / error / inconclusive / blocked, or passed-unmeasured = a green nothing measured), portal, test_id (an exact case id — the per-case execution history, so C1 never answers with C10), and all=true — an admin-only escape hatch the worker understands and the gateway does not forward. Filtering runs over everything the caller may see, not over one page. On a worker limit defaults to 100 and is capped at 1000; the gateway defaults to 200, caps at 1000 and refuses an offset past that. A row is {id, test_id, title, portal, owner, status, error, reason, judge, elapsed_ms, model_alias, environment, date} — no nested report, no phase.

GET

/api/runs/stats

Per-UTC-day counters behind the Overview KPIs, over every run the caller may see. Query days (default 14, clamped to 1..90); all=true (admin) is again worker-only. Returns {days, buckets:{"YYYY-MM-DD": {passed, failed, error, inconclusive, blocked, judged, measured, elapsed_ms:[…]}}}. Counted on the server because the browser only ever holds one page of the history. The gateway adds every worker’s buckets together day by day.

GET

/api/runs/active

The caller’s own currently running run as a single object, or null — not a list of ids. Used by the UI to re-attach after a refresh.

GET

/api/known-issues

The registry of reds that are already understood: {issues:{<case_id>:{bucket,note,action,signature}}}. Drives the known badge.

GET

/api/runs/{run_id}

Full report for one run.

GET

/api/runs/{run_id}/failure-screenshot

Image of the viewport at the first failing step, PNG or JPEG (owner-only; 404 when the run has none).

GET

/api/runs/{run_id}/inconclusive-screenshot

JPEG of the screen at the first inconclusive step (owner-only; 404 when the run has none).

GET

/api/runs/{run_id}/failure-tree

Device parameter tree captured at failure (owner-only; written for a failed run that applied emulator parameters; FT_FAILURE_TREE_SNAPSHOT=0 switches it off).

POST

/api/runs/{run_id}/progress

Live step-progress hook called by the generated Playwright script. Authenticated by the per-run X-Progress-Token, not by a session. 204.

DELETE

/api/runs

Clear the run registry. Spares a live batch’s runs, and removes each run’s failure screenshot / tree sidecars.

/api/runs/active is owner-scoped — never use it to decide a stand is idle

It answers about your runs only. A colleague’s wave occupying the stand is invisible to it. To ask "is anything running here, whoever owns it", use GET /api/run/batch/summary, GET /api/gateway/batches, or the worker’s own access log.

Owner isolation. Run reads, batch reports, WebSocket attach and stop are owner-scoped — a run is visible only to the account that started it (_run_visible_to). The one admin exemption is GET /api/runs?all=true, which lets an admin list every account’s completed runs; all other run / report / WS / stop endpoints stay strictly owner-scoped even for admins. Consequently POST /api/run/stop stops only the caller’s own run, not every running test.

POST /api/run uses query parameters, not a JSON body. Curl example:
curl -X POST "http://ai.friendly-tech.com:8083/api/run?test_file=C7677.yaml&model=opus"

GET /api/runs/{run_id} is the canonical poll endpoint. Note mcp_log is snake_case and is withheld while the run is still running; the two failure_*_file fields appear only in this detail response, never in the list. Returns:

{
  "run_id":"...",
  "test_id":"C7677",
  "title":"...",
  "status":"passed|failed|error|running|blocked",
  "judge":"programmatic|keyword|llm|none",
  "environment":"default-oracle",
  "elapsed_ms":42031,
  "phase":"loading|pages|executing|parsing|done",
  "steps":[{"step":1,"status":"pass|fail|inconclusive|skip","judge":"programmatic","description":"..."}],
  "report":{"status":"passed|failed|inconclusive", "steps":[...]},
  "output":"...",
  "mcp_log":[...],
  "failure_screenshot_file":"<run_id>-failshot.png",
  "failure_tree_file":"<run_id>-failtree.xml"
}

WebSocket

WS /ws/run/{run_id} — streams live updates for a run.

Through the gateway (every deployed stand) the stream opens with a one-time ticket, never the session token: first POST /api/runs/{run_id}/ws-ticket with the usual Authorization: Bearer header, then connect to /ws/run/{run_id}?ticket=<ticket>. The ticket is bound to the caller’s account, role and that run, expires after 60 s and is spent by its first use, so each (re)connect needs a new one. The server logs every WebSocket URL with its query string; a ticket found there has already been spent. The gateway refuses with 4401 a missing, unknown, spent or expired ticket, one issued for another run or for the live view, one whose account was disabled, deleted or given another role in the meantime, and a ?token=<session token> (no longer accepted there). It relays to the worker with the ticket holder’s forwarded identity and no query string; the worker still refuses a run that is not the caller’s. The gateway has already accepted the client socket by then, so the client sees a normal close rather than the worker’s 4403.

A worker reached directly, without the gateway (TRUST_GATEWAY unset, local development only), still authenticates the stream with ?token=<session token>, but the SPA no longer sends one and the worker has no ws-ticket route: an SPA served that way follows runs by polling only.

Frames are {type, data}, not the shape of GET /api/runs/{run_id}. Types: phase, info, output, step, ai_step, nav, mcp_call, result, status, error, image/png, explore, batch_progress, tree_coverage, tree_coverage_intent, ping. A step frame carries the step’s judge; a result frame carries the final status, the run judge and its summary; a terminal status frame is how a blocked run is announced.

TestRail Integration

Method Path Purpose

GET

/api/testrail/status

Connection check.

GET

/api/testrail/projects

List projects.

GET

/api/testrail/suites

Query: ?project_id=N.

GET

/api/testrail/sections

Query: ?project_id=N&suite_id=M.

POST

/api/testrail/preview

Preview converter output without writing.

POST

/api/testrail/import

Batch import. Body: {case_ids:[…​], model?, use_knowledge?, force_refresh?, overwrite?, assign_template_trees?, run_id?, record_sync_target?}. A requested case the suite (or section) does not hold is an error result of the job, not dropped. run_id imports the cases of a TestRail run. record_sync_target: false keeps the suite the sync follows. A value other than true, false or null is 400. The job skips an adapted copy if its source case is on the stand or in the same job. The result of the copy is skipped with the reason duplicate of C<id>. The job also drops the entry of a skipped copy from the sync record. The job converts a copy already on the stand as usual.

POST

/api/testrail/reimport

Single reimport, synchronous. Body: {case_id, use_knowledge?, force_refresh?, llm_mode?, llm_model?, emulator_serial?}. Returns {success, case_id, files, changes, job_id}, plus skipped and reason for a cache-only miss, or error on a failure. job_id names the one-case reimport job in the import journal: GET /api/converter/jobs/<job_id>. If the case has no files on the stand and its adapted-from source is on the stand, the reimport skips the case. The answer is then skipped with the reason duplicate of C<id>.

POST

/api/testrail/reimport/batch

Bulk reimport as one background job on the import slot of the worker. Body: {cases: [{case_id, emulator_serial?}], llm_mode?, llm_model?, force_refresh?, use_knowledge?}. case_id is a positive number or C<digits>; the job reimports a repeated case once. At most 1000 cases (_REIMPORT_BATCH_MAX), else 400; a malformed list is 400, and 409 while another import runs on the worker. Returns {job_id, status, total}; poll GET /api/testrail/import/jobs/<job_id>. The journal records one reimport job with one result for each case. If a case has no files on the stand and its adapted-from source is on the stand, the job skips the case. Its result is skipped with the reason duplicate of C<id>. The job continues when the browser closes (admin / editor).

POST

/api/testrail/sync

Start an import job for the cases of the last imported suite that changed in TestRail since their import. Cases never imported are only counted, not imported. The job skips an adapted copy without case files if its source case is on the stand. The result of the copy is skipped with the reason duplicate of C<id>. The job drops the entry of such a copy from the sync record. The next sync then counts the copy in new_in_testrail. Returns {job_id, status, total, updated, new_in_testrail, unchanged}; job_id is null when nothing changed. 409 while another import runs on the worker, or while a sync runs or starts on any worker. A sync that finds nothing changed updates last_sync. Body {"dry_run": true}: the same checks and counts, no job. It returns {job_id: null, status: "dry_run", updated, new_in_testrail, unchanged, llm_model}; a non-boolean dry_run is 400. Body {"max_cases": N}: the count a user confirmed. More than N changed cases start no import; the answer is {job_id: null, status: "refused", total: 0, updated, new_in_testrail, unchanged, llm_model} with the new count. A max_cases other than a non-negative integer is 400. Body {"llm_mode": "full" | "off" | "cache_only"} picks the LLM mode of the job; another value is 400; without it the job uses the stand default. Every answer carries llm_mode, the effective mode. The Converter tab shows the count of a dry run, asks for a confirmation and posts the real sync with the confirmed count and the chosen LLM mode. A refused answer opens the confirmation again with the new count. Settings → Auto-sync Interval drives the periodic sync, and the journal records it under the user auto-sync. A periodic sync with more than 10 changed cases (_AUTO_SYNC_MAX_CASES) starts no import: the journal records an auto-sync job with status error and the count. A later periodic sync with the same count adds no job to the journal, unless a sync job started after that error job. Without a project and a suite in the sync record, the periodic sync does nothing.

Converter

Read-only routes behind the Converter tab (scripts/converter_api.py). The journal lives on the shared case volume, so each worker answers for the jobs of all workers. See Converter Tab.

Method Path Purpose

GET

/api/converter/overview

The state of the stand: {converter_version, llm_mode_default, cases: {total, stale, unstamped, blocked}, sync: {interval_min, has_target, last_sync, project_id, suite_id, tracked_cases}, auto_reimport_stale, running}. cases.* count scenario files. sync.interval_min is the setting of the answering worker. running lists the running jobs of every worker.

GET

/api/converter/jobs

The newest jobs of the last three months, newest first, without per-case results; result_count gives their number. Query: limit, default 50, from 1 to 200. source: one of testrail, xml, sync, auto-sync, reimport, auto-reimport; another value is 400. Returns {jobs: […​]}.

GET

/api/converter/jobs/{job_id}

One job of the last 12 months with its per-case results: {job_id, source, username, worker, status, started_ts, finished_ts, last_ts, total, done, current, created, skipped, errors, error, options, llm_usage, results}. status is running, done, error or lost. 404 for an unknown job.

GET

/api/converter/cases/{case_id}/timeline

The history of one TestRail case over 12 months: its journal results and its Editor history, newest first. case_id is C123, c123 or 123; the timeline covers every scenario of the case. Returns {case_id, items}; each item has kind journal or history. 400 for another id.

GET

/api/converter/compare

A TestRail run or suite compared with the stand, case by case. Query: run_id or suite_id, exactly one. refresh=1 reads TestRail again; without it, the worker reuses its TestRail answer for up to 5 minutes. A saved TestRail setting drops these answers. Every call reads the stand, the sync record and the journal again. Returns {source: {kind, id, name, project_id, suite_id, fetched_at}, counts: {not_imported, changed, cannot_tell, old_converter, current, blocked, duplicate, total}, cases: [{id, number, title, state, reason, files, stale, blocked, blocked_categories, copies, duplicate_of, adapted_from, can_import, can_reimport}], extra, pinned, missing, converter_version}. state is not_imported, changed, cannot_tell, old_converter or current. adapted_from names the source case of an adapted copy (C<id>), or is null. counts.blocked counts the blocked cases; counts.duplicate counts the cases with copies or a duplicate_of. extra / pinned: stand cases labelled with the run, although the run no longer holds them; pinned holds the cases with a run label set on the stand. missing: the ids of run cases outside the suite of the run; always empty for a suite. 400 without exactly one valid id, or when TestRail is not configured; 422 for a non-boolean refresh; 502 when TestRail fails. 502 also when TestRail has no such run or suite, or the account cannot read it.

XML Import (API only — no UI)

The XML paste / batch modals were removed from the Editor. A TestRail XML export only names the cases: the import reads each one from TestRail, so it needs a working TestRail connection. See Importing Tests → XML Import API.

Method Path Purpose

POST

/api/parse-xml

Convert a single <case> XML to YAML preview. Body: {xml}.

POST

/api/import-xml-batch

Start an import job for the cases a TestRail XML export names; each case is read from TestRail and converted like an import from the TestRail modal. Returns {job_id, status, total}; poll GET /api/testrail/import/jobs/<job_id>. A case missing from its TestRail suite is an error result; an export whose suite name differs from TestRail’s suite of the same id is refused with 400. The import does not change the suite the sync follows. Body: {xml, emulator_serial?, overwrite?, llm_model?, backend_rag?, section_filter?, use_knowledge?, force_refresh?, llm_mode?, assign_templates?} (server defaults: overwrite=true, llm_model="opus", backend_rag=false; the other options as for /api/testrail/import).

POST

/api/run-xml

Convert + run a <case> in one shot. Query parameters: model (default opus), dry_run (default false). Body: {xml}.

Tools

Method Path Purpose

POST

/api/generate-tree

NL → TR-181 tree via bbf-tree-mcp. Body: {case_id, description?}. Returns {success: bool, tree_file?: str, error?: str}.

POST

/api/simulate-score

Run score_simulator.py against a parameter set. Body: {parameters:{}, kpi_type, expected_scores?}. Returns {computed, user_experience, match, details, warnings}.

GET

/api/mcp-status

Probe each MCP server. Returns {mcps: {<name>: {ok: bool, …​}}, connected: bool}.

GET

/api/mcp-diag

Detailed MCP diagnostic dump (per-server URLs, last error, last successful tool list).

Settings

Method Path Purpose

GET

/api/settings

Current persisted settings.

PUT

/api/settings

Replace settings. Body: full settings JSON.

DELETE

/api/settings

Wipe UI overrides; effective values fall back to docker-compose env vars and then to hardcoded defaults.

Admin / Ops

Ops endpoints used by the test-runner UI and by the host-side credential refresh script. They lean on Portainer (PORTAINER_URL / PORTAINER_USERNAME / PORTAINER_PASSWORD env vars) and return 503 when that wiring is missing.

Method Path Purpose

POST

/api/admin/restart-mcp-emul[?force=true]

Restart the sibling mcp-tr-emul container via Portainer. Refuses while runs are active unless force=true. Returns {restarted, active_runs_at_request, forced, …​}. 502 on Portainer error, 409 on active runs without force.

GET

/api/admin/restart-mcp-emul/available

Lightweight probe so the UI can hide the Restart button when Portainer env is unset. Returns {available: bool, endpoint_id}.

GET

/api/admin/credentials-status

Inspect on-disk Claude CLI credentials. Returns {present, valid_json, size_bytes, expiresAt, now_ms, delta_h, expired, subscriptionType, scopes} (no token bytes leaked).

POST

/api/admin/refresh-credentials

Push fresh ~/.claude/.credentials.json into the container. Auth via X-Refresh-Token header (shared secret = PORTAINER_PASSWORD). Body is the raw credentials JSON. Written atomically (tmp + rename + fsync). Called every 4 h by test_runner/refresh-credentials.ps1 on the host.

POST

/api/admin/sync-stack-env[?force=true]

Push the current settings.json values that map to compose env substitutions (TR069_EMULATOR_URL, TR069_ACS_URL, …) into the Portainer stack’s Env array and trigger a redeploy. Refuses on active runs unless force=true. 502 on Portainer GET/PUT error.

Validation Against TestRail

Method Path Purpose

GET

/api/validation

Every stored validation report: {reports:{case_id:{score, generated_at, sections, llm_pass}}}.

GET

/api/validation/{case_id}

One report.

POST

/api/validate

Validate up to 50 cases. Body {case_ids[], llm?, refresh?, model?}.

POST

/api/validate/scan

Scan a whole project. Body {project_id, suite_id, llm?}. 409 while a scan is already running.

GET

/api/validate/scan/status

{active, done, total, started_at, errors}.

GET

/api/testrail/case/{case_id}

The case as TestRail holds it today — preconditions and steps, HTML stripped. This is the endpoint behind TestRail source in the editor, and the way to audit a green against the original text.

Explore and Drafts

Method Path Purpose

POST

/api/explore

Start a read-only survey run. Body {start, portal?, budget?, model?}. Produces a report, never a pass/fail.

POST

/api/explore/{run_id}/drafts

Turn an explore report into 1–3 proposed QA cases. Body {model?, portal?}.

GET

/api/tests/{filename}/promotable

Which ai_step steps have run stably enough to be compiled into deterministic steps.

POST

/api/tests/{filename}/promote

Compile one of them. Body {step, llm_model?}.

DRAFT- and EXPLORE- files are excluded from the test list, from batches and from the pass-rate maths. Stale drafts auto-purge after 60 days.

Diagnosis / Learning

All five require FT_LLM_DIAGNOSE=1 on the worker; with the flag off the Learning screen has nothing to show.

Method Path Purpose

GET

/api/diagnosis/candidates

{enabled, candidates, nav_heals, nav_override_candidates}.

POST

/api/diagnosis/promote

Accept a diagnosis. Body {case_id}.

POST

/api/diagnosis/dismiss

Dismiss one. Body {case_id}.

POST

/api/diagnosis/nav-promote

Accept a learned navigation override. Body {key}.

POST

/api/diagnosis/nav-dismiss

Dismiss one. Body {key}.

Knowledge Procedures

Method Path Purpose

GET

/api/knowledge/procedures

Proxy to knowledge-mcp. Query status, portal.

GET

/api/knowledge/procedures/{name}

One procedure.

POST

/api/knowledge/procedures/{name}/{action}

Admin only; action is approve / reject / deprecate.