Metadata-Version: 2.4
Name: osp-provider-runtime
Version: 0.10.0
Summary: Thin runtime harness for OSP providers (RabbitMQ transport + contract execution).
Author: OSP Team
License: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: loguru<1,>=0.7
Requires-Dist: osp-provider-contracts<0.10,>=0.9.0
Requires-Dist: pika<2,>=1.3
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == 'dev'
Requires-Dist: hatch<2,>=1.14; extra == 'dev'
Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
Requires-Dist: ruff>=0.15; extra == 'dev'
Requires-Dist: twine<7,>=6; extra == 'dev'
Requires-Dist: ty>=0.0.18; extra == 'dev'
Requires-Dist: uv<0.13,>=0.12.5; extra == 'dev'
Description-Content-Type: text/markdown

# osp-provider-runtime

Thin, boring runtime harness for OSP providers.

This package handles RabbitMQ message plumbing so provider implementations can
focus on business logic.

## What it does

- Parses a versioned request envelope.
- Builds provider `RequestContext`/`ProviderRequest` and calls `execute(...)`.
- Dispatches optional read-only provider assessment RPCs without task execution.
- Emits canonical asynchronous task updates and synchronous control-RPC replies.
- Applies explicit ack/requeue/dead-letter decisions.
- Emits structured logs for delivery decisions.
- Supports explicit runtime knobs for prefetch/concurrency/retries/transport timeouts/DLQ.
- Accepts wire-version-7 request envelopes with task identity and cleared Gate fingerprints separated
  from provider action payload.
- Provides `ProviderIdentity` so providers derive the same RabbitMQ
  queue/binding, routing prefix, and lane name from `PROVIDER`,
  `PROVIDER_INSTANCE`, and optional `PROVIDER_INSTANCE_ID`.

## Provider identity

Use a provider family for permissions and API requests, then add an instance
only when you need a separate runtime lane:

```python
from osp_provider_runtime import ProviderIdentity

identity = ProviderIdentity(
    provider="nrec",
    instance="pr",
    instance_id=37,
)

identity.routing_prefix   # "nrec.pr.37"
identity.request_queue    # "provider.nrec.pr.37"
identity.request_binding  # "nrec.pr.37"
```

The common case stays small: `ProviderIdentity("vmware", "dev")` gives the
`vmware.dev` routing prefix. Instance IDs refine routing within a lane.

## Result payload conventions

Provider results should keep `ProviderResult.data` focused on the **resolved**
values for the task. The runtime builds the full update payload envelope:

- `success`: provider outcome flag; `false` still represents a completed no-op
  or degraded outcome
- `resolved`: your `ProviderResult.data` (after runtime normalization)
- `provenance`: optional; extracted from `ProviderResult.data["provenance"]`
- `produced_resources`: typed provider-native references produced by the operation

Raise a `ProviderError` when the task should enter a failed lifecycle state.
`ProviderResult(success=False)` does not turn a completed outcome into a task
failure.

Special keys in `ProviderResult.data`:

- `progress_events` (list): lifted into the update payload and removed from
  `resolved` to avoid duplicate TaskEvent rows.
- `provenance` (dict): lifted into the top-level `provenance` field.

Providers may opt into live updates by accepting a keyword-only `progress`
argument on `execute`. Use `ProgressReporter` so the same action also works
without live delivery:

```python
from osp_provider_contracts import ProducedResource, ProviderResult
from osp_provider_runtime import ProgressReporter


def execute(self, request, context, *, progress=None):
    reporter = ProgressReporter(progress)
    reporter.report("Creating virtual machine", {"stage": "openstack_create"})
    vm = create_virtual_machine(request)
    return ProviderResult(
        success=True,
        message="Virtual machine created",
        produced_resources=(ProducedResource(kind="vm", target_ref=vm.id),),
        data={"progress_events": reporter.events},
    )
```

With a callback, the event is published live and `reporter.events` stays
empty. Without one, the event is returned through `progress_events` and the
runtime expands it from the terminal update. This keeps each event on one
delivery path. The callback is best-effort and transport-owned. `request.action`
is the sole action source.

## Cancellation

Cancellation RPCs (`.cancellation.rpc`) use their own
`<request_queue>.cancellation` queue, channel and one-worker executor, so a
slow resource listing or console read never delays a cancel.

- **Not started:** a task delivered to this runtime but still waiting for a
  task worker is revoked by the runtime itself; no provider hook is needed.
  The RPC answers `supported=true` with
  `cancellation={"cancellable": true, "requested": true}`. When a worker picks
  the delivery up it does not call `execute()`: it publishes the normal
  terminal cancelled update (`TaskReporter.STATUS_CANCELLED`) and ACKs the
  delivery.
- **Running:** stopping live work needs provider support. The runtime forwards
  to the optional `task_cancellation(task_ref)` / `cancel_task(task_ref)`
  hooks, which recheck backend state and stop the work (cooperative
  checkpoints or a native backend cancel). Providers without the hooks report
  unsupported cancellation.
- **Unknown to this runtime** (still in the broker, or held by another
  replica): treated like a running task.

`requested=true` only records the stop request. The task's terminal update is
the confirmation: a revoked task's update follows once a task worker frees up,
and a running task must finish with `TaskCancelled(ProviderResult(...))` after
confirmed termination, retaining any surviving resource references.

Revocations live in memory. A revoked result enters the idempotency cache, so
a redelivery of the same dispatch replays the cancellation. After a restart,
unacknowledged deliveries return and run normally; the orchestrator still
waits for a terminal update and a repeated cancel is safe.

## Task logging

The runtime binds the task context once around `execute()` and emits one
`task.summary` event when the delivery finishes. Provider log sinks must use
JSON serialization so these fields are retained:

- `task_ref`: stable provider execution UUID
- `dispatch_id`: one delivery attempt
- `provider` and `task_type`
- `step`, `outcome`, and `duration_ms`
- `error.type`, `error.message`, and `error.code` when applicable

Use `task_step("external.operation")` around meaningful external operations.
It records the step in the summary trail and logs a full traceback before
re-raising failures. Task code must not bind correlation fields itself.

```python
from osp_provider_runtime import task_step

with task_step("openstack.create"):
    server = openstack.create_server(...)
```

Provider data is preserved inside `result.resolved`; its keys cannot override
the shared result fields. Runtime extracts the existing `progress_events` and
`provenance` metadata into their dedicated result fields.

The orchestrator already owns the original task request. Provider updates do
not echo it; HTTP and CLI read models compose request and result when needed.

## What it does not do

- No provider framework.
- No plugin system.
- No workflow orchestration.

## Install

```bash
pip install osp-provider-runtime
```

## Development

```bash
env -u VIRTUAL_ENV uv sync --extra dev
hatch shell
hatch run dev:check
hatch run dev:build
hatch run dev:verify
```

## Runtime Knobs

Set these via `RuntimeConfig` in your provider `runtime_app.py`:

- `prefetch_count` (default `1`)
- `concurrency` (default `1`)
- `max_attempts` (default `5`)
- `idempotency_cache_max_entries` (default `1024`)
- `snapshot_cache_ttl_seconds` (default `3600`)
- `dead_letter_exchange` (optional)
- `dead_letter_routing_key` (optional)
- `heartbeat_seconds` (default `60`)
- `blocked_connection_timeout_seconds` (default `30`)

## Update Emission

Use `TaskReporter` for provider task lifecycle updates. The runtime keeps
transport details compatible with orchestrator consumers.

Docs:
- `docs/runtime-contract.md`
- `docs/provider-updates.md`
- `docs/migration-task-reporter.md`
- `docs/runtime-upgrade-checklist.md`
- `docs/release-notes-task-reporter.md`

Tag and push:

```bash
git tag v0.2.0
git push origin v0.2.0
```

### Execution build evidence

The runtime adds `payload.execution` to live and terminal task updates. Each
actual invocation gets a fresh `attempt_id`; cached duplicate deliveries replay
the original update and identity. `dispatch_id` links the invocation to its
orchestrator dispatch. Approval resumes and broker retries can run another build.

Deployment supplies `OSP_IMAGE_DIGEST` (registry manifest digest),
`OSP_GIT_COMMIT` (the image's OCI revision label), and optional
`OSP_RELEASE_VERSION` (a production release tag). Missing values are omitted,
not inferred from moving tags or a package version. The progress callback captures
the attempt evidence before it crosses worker-thread boundaries.

Providers with external data wrap execution in `data_snapshot(path)` and use
`pinned_data_dir()` in their data path resolver. The deployment publisher writes
`.osp-revision` into a retained immutable directory and switches its current
symlink atomically. The runtime records that revision only when such a snapshot
is used. Plain local working copies remain unversioned. This identifies code and
provider data, not secrets, live infrastructure, inventory, or promotion state.

`RuntimeConfig.control_concurrency` sizes the independent control-RPC worker
pool and its broker prefetch (default 1). Providers expose this as
`CONTROL_CONCURRENCY`; 4 allows overlapping reads without increasing task
execution concurrency. Delivery logs include worker wait, handler and
finalization timings, correlated with the orchestrator by `rpc_id`.

## Unexpected execution failures

`unexpected_provider_error(exc, produced_resources=...)` is the public conversion
for adapter boundaries that must attach surviving resources before re-raising.
It uses the runtime's task-step trail for `failed_step`, marks state uncertain,
and forbids automatic retries. The transport catch-all uses the same function.
