Metadata-Version: 2.5
Name: firedrill-cloud
Version: 0.1.0
Summary: Firedrill Python SDK — control client. Firedrill does not host or replace your agent. Your agent stays in your runner and connects to the world through MCP, HTTP, the SDK, or the CLI.
Project-URL: Homepage, https://firedrill.run
Project-URL: Documentation, https://docs.firedrill.run/sdk/cloud-python
Project-URL: Repository, https://github.com/firedrill-tools/firedrill-sdk-python
Project-URL: Issues, https://github.com/firedrill-tools/firedrill-sdk-python/issues
Author: Reload Tech Inc.
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2
Provides-Extra: aiohttp
Requires-Dist: httpx-aiohttp>=0.2.0; extra == 'aiohttp'
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: twine>=6; extra == 'dev'
Description-Content-Type: text/markdown

# Firedrill hosted API Python SDK

This package is the Python client for Firedrill Cloud. It complements the local
framework; it does not run customer agents or replace their test runner.

[Documentation](https://docs.firedrill.run/sdk/cloud-python) ·
[API reference](https://docs.firedrill.run/api-reference/overview)

```sh
python -m pip install firedrill-cloud
```

Start with the workflow that matches what you are building:

- [Connect to an isolated hosted world](#owned-session-quickstart) when your
  agent needs synthetic Tools and deterministic state.
- [Run a drill with your own agent](#run-a-drill-with-your-own-agent) when you
  need assertions, evidence, and a final verdict.
- [Manage source and inspect live state](#explicit-source-and-live-control) when
  you are building or operating a hosted project.

`Firedrill` and `AsyncFiredrill` map the hosted control API one-to-one. `connect()`
and `connect_async()` add owned-session lifecycle and context-manager cleanup.
`tail_evidence()` and `tail_evidence_async()` follow a world's durable evidence
journal, ignore page-boundary duplicates, and fail if a sequence is missing.
The default control API is `https://api.firedrill.run`; use `base_url=` only when
targeting a different environment.

## Explicit source and live control

`HostedControl(client, project_id)` and `AsyncHostedControl(client, project_id)`
add the latest public source and inspection routes without constructing another
HTTP client. They retain the supplied client's token supplier, custom transport,
headers and request options. These handwritten adapters live outside generated
source and return JSON dictionaries with the public camelCase field names.

```python
from uuid import uuid4
from firedrill_cloud import Firedrill, HostedControl

client = Firedrill(token="YOUR_CONTROL_CREDENTIAL")
control = HostedControl(client, "prj_...")
entries = [control.tool_library_entry(name) for name in
           ("tool-mailbox", "tool-work-queue")]
request_key = str(uuid4())  # Persist this and the exact request before dispatch.
created = control.create_source_draft(
    name="My selected Tools", idempotency_key=request_key,
    tools=[{"libraryEntryId": entry["libraryEntryId"],
            "expectedPackageArchiveDigest": entry["packageArchiveDigest"],
            "initialState": "empty"} for entry in entries],
)
draft = created["draft"]
publication = control.publish_source_draft(
    draft["sourceDraftId"], expected_source_digest=draft["sourceDigest"],
    idempotency_key=request_key + "-publish",
)
attempt = control.build_attempt(publication["attempt"]["buildAttemptId"])
```

The explicit `starter` selection opts into the listed synthetic rows; `empty`
does not. Publication queues a build, not Tool approval or a running environment.
Follow the operation and diagnostics, approve exact Tool artifacts, then use
the existing environment/session API. Source mutation retries use the same key
and exact request; the helper never retries a mutation under a new key.

`tool_library`, `source_drafts` and `export_local_reports` return one bounded
page, with `nextCursor` when another page exists. `source_draft` reads exact
revision metadata; `download_source` verifies the original gzip's digest/size
(maximum 8 MiB) without extracting, installing or executing it. Deletion needs
the exact draft confirmation, version and key; retained build history blocks it.

`run_activity(run_id, from_sequence=1, limit=50)` reads provisional exact-run
activity, not sealed or signed evidence. Reset/expiry can make it unavailable.
`export_local_reports(export_id, cursor=..., limit=50)` pages original uploaded
archives separately from hosted bundles; use `organization=True` explicitly for
organization scope. Their provenance remains user-produced and unattested.
All corresponding `AsyncHostedControl` methods are awaited and use the same
supplied `AsyncFiredrill` client, including its asynchronous token supplier.

```python
from firedrill_cloud import Firedrill, tail_evidence

client = Firedrill(token="fdc_...")

for entry in tail_evidence(
    client,
    project_id="prj_...",
    session_id="ses_...",
    max_entries=10,
):
    print(entry.sequence, entry.kind)
```

Generated source lives in `src/firedrill_cloud/generated/`, carries its source commit and OpenAPI digest, and must not be edited by hand.

## Run a drill with your own agent

An ordinary session, including one configured with a drill, does not itself run
your agent or produce a completed drill Result. Create a run from a ready
drill-configured session, or attach to an existing run:

For the full create/attach-and-complete workflow, the supplied client needs the
exact project's `drill.run`, `run.read`, `session.read`, and `project.read`
permissions. `project.read` is needed to observe the accepted completion operation
through sealing. Session creation and run cancellation require their separate
permissions; the helper does not broaden the credential.

```python
import os
import httpx
from firedrill_cloud import Firedrill, run_hosted_drill

with httpx.Client() as transport:
    client = Firedrill(base_url=os.environ["FIREDRILL_API_URL"],
                      token=os.environ["FIREDRILL_CREDENTIAL"], httpx_client=transport)
    run = run_hosted_drill(
        client, project_id=project_id, hosted_run_id=hosted_run_id,
        # Use session_id instead of hosted_run_id to create a run.
        wait_timeout_seconds=1800,
        on_checkpoint=checkpoint_store.save,
        run_interaction=lambda context: my_agent_runner.run(
            task=context.interaction.task,
            world=context.binding,
            cancel_event=context.cancel_event,
            deadline_monotonic=context.deadline_monotonic,
        ),
    )
    print(run.hosted_run_id, run.state)  # Inspect Results for the verdict.
```

`my_agent_runner` and `checkpoint_store` are your own implementations. The runner
returns `HostedTargetResult` or its public JSON object, for example
`{"schemaVersion": 1, "status": "completed", "output": {"answer": "actual agent output"}, "attachments": []}`.
It receives world-only access scoped to that interaction, never your control
credential. The helper uses exactly your supplied client for orchestration and
never runs a model or Tool implementation. Do not fabricate an agent result to
mark a drill complete. File uploads are explicit through callback `attach` or
enabled capture methods described below; local paths in TargetResult are rejected.

### Explicit files, logs, screenshots, and video

```python
from firedrill_cloud import HostedCaptureFileInput, run_hosted_drill

def run_interaction(context):
    result = my_agent_runner.run(context.interaction.task, context.binding)
    context.attach(HostedCaptureFileInput("artifacts/output.json", "application/json"))
    context.capture.log("Finished the caller-owned interaction")
    context.capture.screenshot(HostedCaptureFileInput("artifacts/screen.png", "image/png"))
    return result

run = run_hosted_drill(
    client, project_id=project_id, hosted_run_id=hosted_run_id,
    root=".", capture={"logs": "always", "screenshots": "retain-on-failure"},
    run_interaction=run_interaction, on_checkpoint=checkpoint_store.save,
)
```

`attach` retains an explicitly selected file. Optional `capture.file`,
`capture.screenshot`, `capture.video`, and `capture.log` follow `files`,
`screenshots`, `video`, and `logs` policies: `off` by default, `always`, or
`retain-on-failure`. The server applies retention after the drill verdict. Use
`capture.video(HostedCaptureFileInput("artifacts/recording.webm", "video/webm"))`
with video enabled for an existing recording. No browser or recording is created
automatically, and global console output is not intercepted.

`HostedCaptureDriver` accepts `screenshot`, `start_video`, `stop_video`, and
`dispose` callbacks. Register with `context.capture.register_driver(driver)` for
the sync helper or `await context.capture.register_driver_async(driver)` for
the async helper. File callbacks return `HostedCaptureFileInput`; `start_video`
requires `stop_video`. Hooks receive a cancellation event and monotonic deadline
(default 5 seconds; `driver_timeout_seconds` maximum 60). Synchronous Python hooks
must cooperate: they cannot safely be forcibly stopped. Async hooks are awaited
with a timeout. Optional capture errors are recorded separately from drill checks.

Paths must be relative to the explicit root, nonempty regular files without
symlinks or additional hard links. Limits are 64 MiB/file, 120 MiB/run, 32 files;
explicit files accept a bare MIME type (up to 200 characters), including PDF,
CSV and ZIP. Logs require plain text, screenshots PNG/JPEG/WebP, and video WebM.
Unknown or active file types may download as opaque attachments, not previews. Explicit
logs are limited to 16 KiB/message and 1 MiB/4096 messages per interaction. Bytes
are copied verbatim; redact secrets before capture. `redaction={"status":
"applied_by_caller","note":"..."}` is caller-provided metadata, not a server
redaction guarantee. Original files are never changed or removed.

The helper uploads private immutable snapshots through your exact configured
client and authenticated API origin, refusing byte-transfer redirects. Hosted
storage is Google Cloud Storage; configured local testing uses the filesystem
adapter. An `upload` checkpoint preserves the snapshot and exact keys so recovery
does not rerun the agent; retain its staging directory. A `complete` checkpoint
uses verified server attachment IDs, not local paths. Recovery also preserves
the capture root and policies. Caller-produced media is attached to the managed
run without claiming Firedrill independently observed your application. Uploading
a complete local report remains separate and explicitly unattested.

The high-level helper supplies upload headers automatically. If using the
generated raw method after admission, pass exact admitted bytes (not JSON or
base64), and retain the same upload key across retries:

```python
client.run_attachments_upload_content(
    project_id, hosted_run_id, attachment_id, request=content_bytes,
    request_options={"additional_headers": {"Idempotency-Key": upload_key}, "max_retries": 0},
)
# A claim-scoped CI credential uses its separate endpoint:
ci_client.ci_attachments_upload_content(
    ci_suite_id, ci_case_id, ci_claim_id, attachment_id, request=content_bytes,
    request_options={"additional_headers": {"Idempotency-Key": upload_key}, "max_retries": 0},
)
```

The raw method sends `application/octet-stream`; storage retains the admitted
descriptor's media type. The required header is supplied through request options,
not a dedicated argument. Missing headers are rejected. Uploading alone does not
complete byte verification or the interaction. Async clients use the same
arguments with `await`.

`run_hosted_drill_async` accepts `AsyncFiredrill`, an async `run_interaction`, and
an optional async `on_checkpoint`. Both drivers share one orchestration plan:
they wait for each new interaction, submit the actual caller result, and wait
through final sealing. Attaching to a terminal run never invokes the agent.
Waits default to 30 minutes, bounded to 24 hours; polling defaults to one second.
Sync callbacks must honor their cancellation event and monotonic deadline;
Python cannot safely forcibly stop an arbitrary synchronous runner. Async
callbacks receive deadline cancellation and must cooperate with it.

Persist every checkpoint before its callback returns. After
`HostedRunRecoveryError`, call the helper with `project_id`, `resume=error.checkpoint`
and your callbacks. A saved `complete` checkpoint replays the exact TargetResult
and key, not the agent. A `running` checkpoint has an unknown caller outcome and
is deliberately not replayed; recover the actual result from your runner, then
submit it at the saved run/interaction/revision. Async cancellation retains
`checkpoint` on `CancelledError` and is not swallowed. Checkpoints contain no
world credential, but can contain sensitive agent output; store them privately.
Python checkpoints use Python request field names and should be resumed through
the Python helper, not passed to another SDK's checkpoint API.

For a manual terminal workflow, the separately installed public CLI extension
provides `firedrill cloud run create`, `interaction`, `connect --format env`,
`complete --result FILE`, and `get`. Use the exact interaction/revision and retry
command printed by the CLI; your runner still owns all agent execution.

## Optional warm worlds

`HostedProject(client, project_id).create_session(environment_id=..., seed=...,
idempotency_key=..., warm_pool="prepare")` prepares one short-lived exact world
without issuing access. `warm_pool="prefer"` checks out an exact prepared setup
or creates cold. `AsyncHostedProject.create_session` has the same async surface.
Omitting the option preserves ordinary cold creation. Keep build, seed,
scenario/drill and receiver selection identical for a matching checkout.

Prepared sessions remain `warming` until checkout; a `warmPool.state` of `ready`
does not authorize an agent connection. Inspect their returned expiry and metadata
(`session.model_dump(by_alias=True)["warmPool"]`). The server bounds lifetime and
capacity and rechecks authority at checkout. Owned `connect`/`connect_async` and
`HostedProject.start` accept only `warm_pool="prefer"`, since they return usable
world access; preparing alone uses `create_session`.

## Owned-session quickstart

Your existing runner owns the agent and the HTTP client. The helper creates one
exact hosted session, waits for its operation and ready state, and issues the
selected actor's short-lived binding. Omit `scenario_id` and `drill_id` to serve
the build baseline; supplying both is an error. No agent is run automatically.

```python
import os
import httpx
from firedrill_cloud import Firedrill, connect

with httpx.Client() as http:
    client = Firedrill(token=os.environ["FIREDRILL_CREDENTIAL"], httpx_client=http)
    with connect(
        client,
        project_id="prj_...",
        environment_id="env_...",
        actor_id="operator",  # an actor declared in your selected build
        seed="42",
        # scenario_id="busy",  # optional named setup
        # build_hash="sha256:...",  # optional exact immutable build
        operation_timeout_seconds=120,
    ) as world:
        binding = world.binding
        # Pass binding.world_http_url or binding.world_mcp_url and its credential
        # to your agent's existing test configuration. Never pass client/token.
        # Invoke your agent through your own runner here.
        world.reset()  # initial setup restored; use world.binding again afterward
```

The async equivalent has the same options and methods:

```python
import os
import httpx
from firedrill_cloud import AsyncFiredrill, connect_async

async def test_world():
    async with httpx.AsyncClient() as http:
        client = AsyncFiredrill(token=os.environ["FIREDRILL_CREDENTIAL"], httpx_client=http)
        async with await connect_async(
            client, project_id="prj_...", environment_id="env_...",
            actor_id="operator", seed="42",
        ) as world:
            receipt = await world.advance_to(1_000_000, max_events=100)
            # Inspect receipt.status: interrupted/failed is not completed.
            assert receipt.status == "completed"
```

Context exit destroys the exact owned session on success, exceptions, and
cancellation. `connect()` without `with` (or `connect_async()` without `async
with`) transfers cleanup responsibility to you: call `world.destroy()` or
`await world.destroy()`. The helper never closes the supplied client, installs
signal handlers, changes your runner, or cancels a server operation implicitly.
Stop or join agent work before leaving the context; retaining an old binding
does not retain an active world.

### Control and recovery

| Handle method | Result |
| --- | --- |
| `reset(packages=...)` or `reset(hosted_snapshot_id=...)` | Restore selected state and rotate the binding; omit both for a full reset |
| `extend(ttl_ms)` | Return the server's session expiry; does not renew access credentials |
| `advance_to(target_us, max_events=...)` | Return the canonical durable time receipt, including partial/interrupted outcomes |
| `set_fault(package_id, fault_id, active)` | Return the canonical durable fault-control receipt |
| `destroy()` | Await destruction; repeated calls after success are no-ops |
| `resume_pending()` | Resolve the exact captured uncertain mutation and return its original result |

Every mutation optionally accepts `idempotency_key` and a `threading.Event` as
`cancel_event`. The helper generates a key when omitted. Mutations through one
handle are serialized. Cancellation of async tasks remains `CancelledError`;
synchronous cancellation uses `cancel_event` or the caller's ordinary
`KeyboardInterrupt`. Local cancellation does not mean the server stopped.

On an uncertain network error, timeout, cancellation, malformed receipt, or
selection mismatch, `world.pending_mutation` retains the exact request, key and
known operation ID. It contains no credentials. Other mutations and new binding
reads are blocked until that attempt is resolved. Call `resume_pending()` to
poll the known operation, or replay the original request with the same key if
its acknowledgement was lost. Never create a new key to retry uncertain work.
An initial canonical API rejection or terminal failed/cancelled operation is a
known outcome and releases that guard; later authorization failures cannot
erase an already uncertain attempt.

```python
try:
    world.extend(3_600_000)
except Exception:
    if world.pending_mutation is not None:
        print(world.pending_mutation.operation_id)  # safe recovery identifier
        world.resume_pending()  # exact original key, input, lease, and version
    else:
        raise
```

Context exit first resolves a pending mutation, then attempts destruction under
a fresh cleanup deadline. It never overwrites a pending intent with a different
lease. Cleanup failure is raised on an otherwise successful exit; when your
code already raised, that primary exception is preserved and its
`cleanup_error` attribute, also available as `world.cleanup_error`, reports the
cleanup failure. Inspect it and recover explicitly; expiry is the final safety
net, not a guarantee that cleanup succeeded. Repeated async cancellation is
shielded while bounded cleanup finishes.

Acquisition failures expose `error.pending_creation`, including the original
key and request. If creation was acknowledged, cleanup is best effort by
default (`cleanup_on_failure=False` explicitly disables it). If the create
response was lost, no resource ID is known: the helper never lists sessions or
guesses what to destroy. Retry `connect()` with the original options and
`idempotency_key=error.pending_creation.idempotency_key` to recover that exact
server-side intent. Persist recovery details if your runner must survive
process termination; handles and pending attempts are in-memory only.

`FiredrillLifecycleError.kind` identifies SDK orchestration failures, and
`.operation` retains an available canonical operation/error/receipt. Generated
HTTP errors remain `ApiError` with their server envelope; transport exceptions
remain HTTPX exceptions. Recovery attributes are additive. Neither exception
text nor session readiness is an agent-test verdict.

Deadlines cover acquisition and each mutation, including polling (default
120 seconds, polling every 0.5 seconds). Every HTTP request disables generated
automatic retries and receives the remaining timeout. Async requests are also
bounded by the lifecycle clock. Sync cancellation is observed between requests
and during polling; an in-flight sync request relies on HTTPX's phase timeouts.
A custom synchronous transport that ignores timeouts, or a continuously
streaming response, cannot be forcibly preempted. Keep the standard bounded
control API/transport contract; abrupt process termination cannot run cleanup.

## Start synthetic tools without a drill

`sessions_create()` accepts the build baseline (no selector), a named
`scenario_id`, or a `drill_id`. The selectors are mutually exclusive. Starting a
session serves the synthetic environment; it does not run or host your agent.

```python
created = client.sessions_create(
    "prj_...",
    "env_...",
    idempotency_key="tools-baseline-001",
    seed="42",
    # scenario_id="busy",  # optional; omission uses the build baseline
)
```

This returns a session and an asynchronous operation, not a ready binding.
Poll `operations_get(project_id, operation_id)` with a bounded deadline; only
after it succeeds and `sessions_get()` returns `ready`, call
`sessions_issue_access(..., actor_id=...)` and pass that short-lived binding to
your agent. Failed operations carry their canonical error envelope. The same
flow is available with `await` on `AsyncFiredrill`.

Use `sessions_reset()` to restore the selected initial setup or an explicit
snapshot, then obtain a new binding: old credentials are fenced by the changed
lease generation. Destroy the exact session with `sessions_destroy()` in your
cleanup path. Mutations require stable idempotency keys; after a timeout, retry
the same key and arguments or inspect the known operation, never assume the
remote work stopped. These generated methods do not automatically provision,
poll, reset or clean up on behalf of the caller; use the handwritten lifecycle
helpers above when you want owned-session orchestration.

`sessions_extend(project_id, session_id, idempotency_key=...,
expected_lease_generation=session.lease_generation,
expected_version=session.version, ttl_ms=3_600_000)` requests a deadline one hour
from server admission time. The session must be ready and unexpired, and the
new deadline must be later than its current one and within your allowance.
The response contains the session and an already-succeeded operation. Retry the
same key and arguments after interruption: a replay does not extend time again.
Existing access credentials and drill timeouts keep their original expiry.
`AsyncFiredrill` exposes the same operation with `await`.

## Saved browser tests and run evidence

These groups support `AsyncFiredrill` too (`await`, or `async for` for streams).
Use project-authorized control credentials, never world bindings. Retain each
mutation's exact key, body and expected versions before sending; retry uncertain
work unchanged. Example keys need replacing for new intents, and persisting for
cross-process recovery; generated methods retain no pending intent or cleanup.
Targets require public HTTPS on port 443. Keep credentials out of definitions,
URLs/tasks/messages. Agent input, runtime parameters (including resolved
`parameterSecretRefs`), fill actions and URL values suppress requested captures
unless the request explicitly sets `captureConsent="include-sensitive-content"`
(`capture_consent` in Python). Consent permits recording potentially sensitive
content; it is not a redaction guarantee. Screenshot masks also apply to live
frames, but do not redact video, traces, network traffic or values shown elsewhere.

```python
from pathlib import Path
from firedrill_cloud.generated import CreateBrowserRunRequestTestId
from firedrill_cloud.generated.browser_tests import CreateBrowserTestRequestDefinition

project_id, browser = "prj_...", client.browser_tests
request_options = {"max_retries": 0, "timeout": 30}
# Your canonical browser-test JSON, using synthetic test data.
definition = CreateBrowserTestRequestDefinition.model_validate_json(Path("smoke.browser.json").read_text())
saved = browser.browser_tests_create(
    project_id, idempotency_key="browser-save-001", definition=definition,
    request_options=request_options,
).test
run_request = CreateBrowserRunRequestTestId(
    test_id=saved.test_id, expected_test_version=saved.version, mode="replay", timeout_ms=120_000,
)
run = browser.browser_runs_create(
    project_id, idempotency_key="browser-run-001", request=run_request,
    request_options=request_options,
).run
run_id = run.run_id  # retain for recovery
events = browser.browser_runs_list_events(project_id, run_id, after=0, limit=100)
for event in events.items:
    print(event.sequence, event.type, event.message)
after = events.items[-1].sequence if events.items else 0
run = browser.browser_runs_get(project_id, run_id)
print(run.state, run.result)
if run.artifact is not None:
    with Path(f"{run_id}.tar.gz").open("xb") as output:
        for chunk in browser.browser_runs_download_artifact(project_id, run_id):
            output.write(chunk)
```

Poll with a bounded deadline; retain the last event sequence across empty pages.
`queued`/`running` are active; other states terminal. `completed` is not a passing
assertion verdict, and browser results never verify world state. Downloads are
integrity-checked bundles. `browser_tests_list()` uses opaque `next_cursor`→`after`.

For follow-up, create a separate `mode="agent", interactive=True` run with a
saved `task` and `authoring.run` permission. Use that active run's ID here:

```python
from firedrill_cloud.generated import BrowserRunMessageRequest_Message, BrowserRunMessageRequest_Finish

interactive_run_id = "brun_..."  # exact active interactive run
browser.browser_runs_send_message(
    project_id, interactive_run_id, idempotency_key="browser-follow-up-001",
    request=BrowserRunMessageRequest_Message(message="Check the confirmation panel."),
    request_options=request_options,
)
browser.browser_runs_send_message(
    project_id, interactive_run_id, idempotency_key="browser-finish-001",
    request=BrowserRunMessageRequest_Finish(), request_options=request_options,
)
# Alternatively, cancel this exact run, then poll its terminal state:
# browser.browser_runs_cancel(project_id, interactive_run_id, confirm=interactive_run_id,
#     idempotency_key="browser-cancel-001", request_options=request_options)
```

Acceptance is not completion; finish closes input for independent assertions.
`browser_runs_rerun(project_id, run_id, confirm=run_id, idempotency_key=...)` pins
original inputs, excluding prior messages. A new rerun gets a new key; retries do not.

## Browser pull-request checks without YAML

Configure a connected repository with exact saved versions, no secret parameters,
and an unauthenticated same-origin endpoint returning `{"revision":"<40-character SHA>"}`.

```python
from firedrill_cloud.generated.browser_ci import PutBrowserCiConfigRequestTestsItem

binding_id = "repo_..."  # your existing repository binding ID
head_revision = "0123456789abcdef0123456789abcdef01234567"  # exact current PR head
ci = client.browser_ci
current = ci.browser_ci_get_config(project_id, binding_id).config
rules = [PutBrowserCiConfigRequestTestsItem(
    test_id=saved.test_id, expected_test_version=saved.version, paths=["src/checkout/"],
)]
ci.browser_ci_put_config(
    project_id, binding_id, idempotency_key="browser-ci-config-001",
    expected_version=None if current is None else current.version,
    enabled=True, target_origin="https://preview.example.com", revision_path="/revision.json",
    selection_mode="change_aware", global_paths=["package.json"], tests=rules,
    request_options=request_options,
)
started = ci.browser_ci_start_run(
    project_id, binding_id, idempotency_key="browser-ci-start-001",
    pull_request_number=42, expected_head_revision=head_revision,
    request_options=request_options,
).run
status = ci.browser_ci_get_run(project_id, binding_id, started.browser_ci_run_id)
print(status.state, status.delivery, status.evidence_url)
```

Paths are literal files or `/`-terminated directory prefixes; uncertain coverage
selects all tests. `browser_ci_preview_selection()` previews the same PR/head.
Poll with a bounded deadline. CI rejects forks and checks deployment-reported
revision before/after replay, not cryptographic build attestation. Case verdicts
and GitHub delivery are separate. Configuration needs `repository.connect` plus
`authoring.read`; start needs `drill.run`, run reads `run.read`.

## Scenario source proposals and exact review

Presets are authored source, not compiled state. Reads, creation and publication
require a signed-in browser/CLI user with `authoring.read`, `authoring.run`, and
`authoring.publish`, respectively. Service credentials do not qualify.

```python
from firedrill_cloud.generated.scenario_authoring import CreateScenarioProposalRequestInput_Preset

base_revision = "0123456789abcdef0123456789abcdef01234567"  # bound branch's exact head
presets = client.scenario_authoring.scenario_presets_list(
    project_id, repository_binding_id=binding_id, base_revision=base_revision,
)
# Choose an actual returned scenario after reviewing its source and digest.
preset = next(item for item in presets.items if item.scenario_id == "busy")
created = client.scenario_authoring.scenario_proposals_create(
    project_id, idempotency_key="scenario-proposal-001",
    repository_binding_id=binding_id, base_revision=base_revision,
    input=CreateScenarioProposalRequestInput_Preset(
        preset_scenario_id=preset.scenario_id, scenario_id="busy-review",
    ),
    request_options=request_options,
)
session, proposal = created.session, created.proposal
review = client.authoring_get_proposal_diff(
    project_id, session.authoring_session_id, proposal.authoring_proposal_id,
)
print(review.unified_diff)  # stop here for exact-diff review
```

For direct JSON, use `CreateScenarioProposalRequestInput_Source(source=your_json)`
from the same module. Creation validates source/build; it neither records/redacts
data automatically nor publishes or runs an agent. Only after exact-diff approval:

```python
published = client.authoring_publish_proposal(
    project_id, session.authoring_session_id, proposal.authoring_proposal_id,
    idempotency_key="scenario-publish-001",
    expected_session_version=session.version,
    expected_proposal_version=review.proposal.version, confirm=proposal.authoring_proposal_id,
    request_options=request_options,
)
```

Poll `operations_get(project_id, published.operation.operation_id)` with a bounded
deadline, then `authoring_get_proposal()` for the PR URL or canonical failure.
Publication is not a merge, deployment or test verdict. A changed head/version
requires fresh review; uncertain retries keep the original key and versions.

## High-level hosted projects

`HostedProject` and `AsyncHostedProject` reuse the exact configured generated
client, including credentials, base URL, and transport. They never open a second
client or upload local files.

```python
from firedrill_cloud import Firedrill, HostedProject, HostedHistoryFilters

client = Firedrill(base_url=api_url, token=control_credential)
project = HostedProject(client, project_id)
tools = project.tools(build_hash)  # Contract metadata; no baseline records.
binding = project.connect(session_id=session_id, actor_id=actor_id)
page = project.history(HostedHistoryFilters(outcome="failed"), limit=20)
result = project.results(hosted_run_id)
```

`connect_session(client, ...)` and its async counterpart attach to an existing
ready session without creating/resetting/destroying it. Only give their returned
actor-scoped world binding to your external runner, never the control credential.
After `WorldAccessUnresolvedError`, retry with its exact `idempotency_key` and
original input. `project.start(environment_id=..., actor_id=..., seed=...)`
instead creates an owned lifecycle handle; use its context manager for cleanup.
Omit scenario/drill selectors for baseline. Selecting a drill setup does not run it.

History filters operate across all retained server history. Preserve them when
following cursors; timestamps use inclusive `created_from_ms` and exclusive
`created_before_ms`. Results preserve canonical verdict, runner, and orchestration
outcomes rather than treating admitted work as completed.

For an existing repository binding, use `agent_create`, `agent_send`,
`agent_sessions`, `agent_events`, `agent_proposal`, `agent_diff`, `agent_cancel`,
and `agent_publish`. Mutations require a caller-supplied stable `idempotency_key`.
Cancellation requires exact turn confirmation; publication requires exact proposal
confirmation and both reviewed versions. Creation/send accept bounded model,
effort, max turns, budget and timeout options. No helper discovers or bootstraps
repositories, merges PRs, or bypasses project permissions. `AsyncHostedProject`
provides the same methods as coroutines.

## Compare retained runs

The same configured client supplies both summary comparison and one bounded
page of original recorded details:

```python
project = HostedProject(client, project_id)
summary = project.compare(
    baseline_hosted_run_id=baseline_id, candidate_hosted_run_id=candidate_id,
)
page = project.compare_details(
    baseline_hosted_run_id=baseline_id, candidate_hosted_run_id=candidate_id,
    kind="assertions", limit=10,
)
```

`AsyncHostedProject` exposes the same methods with `await`. Lower-level clients
provide `runs_compare` and `runs_compare_details`. Kinds are `assertions`,
`state_changes`, and `operations`; page size defaults to 10 and is capped at 25.
Pass `next_cursor` as `cursor` with the same project, pair and kind to continue;
an expired cursor needs a fresh first-page request. Helpers never fetch every
page automatically or create a second credential context.

Matching summary measures do not guarantee identical recorded values. Missing
entries do not prove deletion; oversized values are explicitly omitted, not
partially displayed. Comparisons describe recorded differences without
reevaluating the agent or declaring an improvement.

## Prepare source for Python callers

This Python package does not provide the TypeScript `prepareHostedDrill` or
`createHostedCliBridge` convenience helpers. To use test-local setup, explicitly
publish a derived build with the shared CLI:

```sh
firedrill cloud publish --project "$PROJECT_ID" --root . \
  --idempotency-key derived-review-001 --drill review-case --setup ./setup.json
```

Use your source's actual drill ID and canonical data-only setup JSON. Review and
explicitly approve any new Tool artifacts, then pass the returned exact build
hash to Python session creation (`build_hash`) or `run_hosted_drills`. Publication
does not automatically approve Tools or promote an environment. Your Python
agent can use the issued HTTP/MCP world binding directly; the caller-owned CLI
bridge is a separate TypeScript helper, not a Python API or agent source rewrite.

## Run multiple hosted drills

`run_hosted_drills` and `run_hosted_drills_async` use your configured client and
existing caller-owned agent. They create ordinary sessions/runs, not a managed
suite resource, and never approve Tools or promote an environment.

```python
from firedrill_cloud import run_hosted_drills

results = run_hosted_drills(
    client, project_id=project_id, environment_id=environment_id,
    build_hash=build_hash, drills=["review-message", "reject-write"],
    trials=2, retries=1, concurrency=2, seed="42",
    root=".", capture={"logs": "always"},
    on_checkpoint=save_checkpoint_atomically,
    run_interaction=run_my_existing_agent,
)
print(results["observed"])
```

Select `suite="suite-id"` instead of `drills` to use declared drill IDs/tags,
or omit both for every drill in the exact build. Explicit options override suite
defaults; otherwise trial count comes from the suite or drill. The selected build
must already be approved for the environment. Each attempt creates a fresh
session and destroys that owned session after its terminal result; all ordinary
run/report IDs remain in Results/history. Required project scopes are
`build.read`, `project.read`, `session.create`, `session.read`, `session.destroy`, `drill.run`
and `run.read`.

Retries occur only after known nonpassing terminal outcomes. Mixed retry
outcomes yield an `inconclusive` logical trial. Returned `observed` counts are
raw observations, not confidence estimates or signed suite verdicts. Within
each drill, seeds increment from the selected unsigned 64-bit seed modulo 2^64;
retries retain that trial's seed. Every attempt links its own evidence bundle.

Persist checkpoint callbacks atomically in private storage; callback saves are
serialized. `HostedDrillsRecoveryError.checkpoint` retains exact mutation keys,
staged uploads and pending session/run identities. Resume the same selection
with `resume=checkpoint`. An uncertain `running` callback is never replayed;
reconcile its real outcome through single-run recovery first. Terminal batch
resume does not invoke the agent. Python checkpoints are language-specific and
must not be passed to the TypeScript helper.

Sync callbacks can run concurrently on worker threads and must cooperate with
their cancellation event/deadline; they cannot safely be forcibly killed. Async
callbacks and checkpoint writers must be awaitable. Use the async helper inside
an asyncio loop. The default wait limit is 30 minutes (maximum 24 hours). Planning
is bounded to 1,000 logical trials, 10,000 possible attempts and concurrency 64;
project quotas still apply. Your own runner owns file watching and scheduling.
To shard jobs, pass disjoint explicit drill lists with separate checkpoints;
intentional repeat runs use new batches and explicit build identities. No source
upload, compilation, approval or background watch starts implicitly.

## Local verification

Install with `python -m pip install -e '.[dev]'` and run `python -m pytest`.
The full repository test gate includes generated-client contracts, evidence
tailing, and sync/async lifecycle through a real loopback HTTP server. Browser,
CI and source-proposal contracts also exercise real HTTP, typed SSE, binary
downloads, exact-key retries and canonical failures. These fixtures script
control responses; they prove client serialization, parsing, polling, identity
checks, cleanup and recovery, not world-engine fidelity, customer-agent behavior,
live hosted authorization, or deployed availability. The same tests can run from
outside the checkout against a freshly installed wheel using
`python -m pytest --import-mode=importlib /absolute/path/to/this/repository/tests`.

### Export scenario seed source

`export_session_scenario(client, project_id, session_id, scenario_id="captured-state",
expected_lease_generation=lease, include_sensitive_data=True)` and
`export_session_scenario_async(...)` return canonical scenario JSON under `scenario`,
its digest and exact session/build/lease identity. Optional `packages=[...]` selects
Tools. The caller must explicitly acknowledge raw unredacted data and review it before
saving or sharing. These helpers do not write files or mutate/publish a world. Source
contains Tool records and baseline deletions, not actors, clock, faults, pending work
or history; unselected Tools inherit the destination baseline. Stale/unauthorized
reads and exports over 50,000 actions or 1 MiB fail without truncation.

`list_hosted_snapshots(client, project_id, session_id=..., limit=..., cursor=...)`
returns one bounded metadata page; `get_hosted_snapshot(client, project_id,
hosted_snapshot_id)` resolves one checkpoint. Both have `_async` equivalents.
`artifactAvailability: "not_checked"` is explicit: metadata discovery does not
verify stored bytes, and restore/fork performs that validation. No storage paths
or credentials are included.

Apache-2.0 · Copyright Reload Tech Inc.
