q-runtime-core 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qruntime/core/model.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from enum import Enum
5
+ from typing import Iterable
6
+
7
+
8
+ class StepStatus(str, Enum):
9
+ PENDING = "PENDING"
10
+ RESERVED = "RESERVED"
11
+ PAID = "PAID"
12
+ DONE = "DONE"
13
+
14
+
15
+ class TaskStatus(str, Enum):
16
+ AUTHORIZED = "AUTHORIZED"
17
+ RUNNING = "RUNNING"
18
+ COMPLETED = "COMPLETED"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class StepSpec:
23
+ id: str
24
+ max_cost: int # integer minor units
25
+ actual_cost: int
26
+ required: bool = True
27
+ dependencies: tuple[str, ...] = ()
28
+ required_evidence: tuple[str, ...] = ()
29
+ actor: str = "agent"
30
+ allowed_actors: tuple[str, ...] = ("agent",)
31
+ allow_ambiguous_retry: bool = False
32
+ allow_missing_evidence_spend: bool = False
33
+ allow_unauthorized_execution: bool = False
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class WorkflowSpec:
38
+ name: str
39
+ currency: str
40
+ budget: int
41
+ steps: tuple[StepSpec, ...]
42
+ max_states: int = 10_000
43
+
44
+ def step(self, step_id: str) -> StepSpec:
45
+ for step in self.steps:
46
+ if step.id == step_id:
47
+ return step
48
+ raise KeyError(step_id)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RuntimeState:
53
+ task_status: TaskStatus
54
+ spent: int
55
+ reserved: int
56
+ step_statuses: tuple[tuple[str, StepStatus], ...]
57
+ attempts: tuple[tuple[str, int], ...]
58
+ settled_intents: tuple[str, ...] = ()
59
+ evidence: tuple[str, ...] = ()
60
+ ambiguous_intents: tuple[str, ...] = ()
61
+ unauthorized_effects: tuple[str, ...] = ()
62
+ evidence_bypasses: tuple[str, ...] = ()
63
+
64
+ @staticmethod
65
+ def initial(spec: WorkflowSpec) -> "RuntimeState":
66
+ return RuntimeState(
67
+ task_status=TaskStatus.AUTHORIZED,
68
+ spent=0,
69
+ reserved=0,
70
+ step_statuses=tuple((s.id, StepStatus.PENDING) for s in spec.steps),
71
+ attempts=tuple((s.id, 0) for s in spec.steps),
72
+ )
73
+
74
+ def status(self, step_id: str) -> StepStatus:
75
+ return dict(self.step_statuses)[step_id]
76
+
77
+ def attempt(self, step_id: str) -> int:
78
+ return dict(self.attempts)[step_id]
79
+
80
+ def with_step_status(self, step_id: str, status: StepStatus) -> "RuntimeState":
81
+ data = dict(self.step_statuses)
82
+ data[step_id] = status
83
+ return replace(self, step_statuses=tuple(sorted(data.items())))
84
+
85
+ def with_attempt(self, step_id: str, attempt: int) -> "RuntimeState":
86
+ data = dict(self.attempts)
87
+ data[step_id] = attempt
88
+ return replace(self, attempts=tuple(sorted(data.items())))
89
+
90
+ def with_evidence(self, evidence_id: str) -> "RuntimeState":
91
+ return replace(self, evidence=tuple(sorted(set(self.evidence) | {evidence_id})))
92
+
93
+ def with_settled_intent(self, intent_id: str) -> "RuntimeState":
94
+ # Deliberately retain duplicates: the invariant is responsible for rejecting them.
95
+ return replace(self, settled_intents=tuple(sorted(self.settled_intents + (intent_id,))))
96
+
97
+ def with_ambiguous_intent(self, intent_id: str) -> "RuntimeState":
98
+ return replace(self, ambiguous_intents=tuple(sorted(set(self.ambiguous_intents) | {intent_id})))
99
+
100
+ def with_unauthorized_effect(self, detail: str) -> "RuntimeState":
101
+ return replace(self, unauthorized_effects=tuple(sorted(self.unauthorized_effects + (detail,))))
102
+
103
+ def with_evidence_bypass(self, step_id: str) -> "RuntimeState":
104
+ return replace(self, evidence_bypasses=tuple(sorted(self.evidence_bypasses + (step_id,))))
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Action:
109
+ kind: str
110
+ step_id: str | None = None
111
+ detail: str = ""
112
+
113
+ def label(self) -> str:
114
+ if self.step_id:
115
+ return f"{self.kind}({self.step_id}){': ' + self.detail if self.detail else ''}"
116
+ return f"{self.kind}{': ' + self.detail if self.detail else ''}"
117
+
118
+
119
+ def all_required_done(spec: WorkflowSpec, state: RuntimeState) -> bool:
120
+ return all((not step.required) or state.status(step.id) == StepStatus.DONE for step in spec.steps)
121
+
122
+
123
+ def dependencies_done(step: StepSpec, state: RuntimeState) -> bool:
124
+ return all(state.status(dep) == StepStatus.DONE for dep in step.dependencies)
125
+
126
+
127
+ def evidence_present(step: StepSpec, state: RuntimeState) -> bool:
128
+ available = set(state.evidence)
129
+ return all(e in available for e in step.required_evidence)
130
+
131
+
132
+ def unresolved_required_cost(spec: WorkflowSpec, state: RuntimeState, exclude: Iterable[str] = ()) -> int:
133
+ excluded = set(exclude)
134
+ total = 0
135
+ for step in spec.steps:
136
+ if not step.required or step.id in excluded:
137
+ continue
138
+ if state.status(step.id) == StepStatus.PENDING:
139
+ total += step.max_cost
140
+ return total
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ from .model import (
6
+ Action,
7
+ RuntimeState,
8
+ StepStatus,
9
+ TaskStatus,
10
+ WorkflowSpec,
11
+ all_required_done,
12
+ dependencies_done,
13
+ evidence_present,
14
+ )
15
+
16
+
17
+ def enabled_actions(spec: WorkflowSpec, state: RuntimeState) -> list[Action]:
18
+ if state.task_status == TaskStatus.COMPLETED:
19
+ return []
20
+
21
+ actions: list[Action] = []
22
+ for step in spec.steps:
23
+ status = state.status(step.id)
24
+ if status == StepStatus.PENDING and dependencies_done(step, state):
25
+ has_evidence = evidence_present(step, state)
26
+ actor_allowed = step.actor in step.allowed_actors
27
+ if has_evidence and actor_allowed:
28
+ actions.append(Action("reserve", step.id))
29
+ elif not has_evidence and step.allow_missing_evidence_spend:
30
+ actions.append(Action("reserve_without_evidence", step.id))
31
+ elif not actor_allowed and step.allow_unauthorized_execution:
32
+ actions.append(Action("reserve_unauthorized", step.id, f"actor={step.actor}"))
33
+ elif status == StepStatus.RESERVED:
34
+ actions.append(Action("settle", step.id))
35
+ elif status == StepStatus.PAID:
36
+ actions.append(Action("deliver", step.id))
37
+ if step.allow_ambiguous_retry:
38
+ actions.append(Action("retry_ambiguous", step.id, "payment outcome treated as retryable before reconciliation"))
39
+
40
+ if all_required_done(spec, state):
41
+ actions.append(Action("complete"))
42
+ return actions
43
+
44
+
45
+ def _reserve(spec: WorkflowSpec, state: RuntimeState, step_id: str) -> RuntimeState:
46
+ step = spec.step(step_id)
47
+ next_state = state.with_step_status(step.id, StepStatus.RESERVED)
48
+ return replace(
49
+ next_state,
50
+ task_status=TaskStatus.RUNNING,
51
+ reserved=state.reserved + step.max_cost,
52
+ )
53
+
54
+
55
+ def apply_action(spec: WorkflowSpec, state: RuntimeState, action: Action) -> RuntimeState:
56
+ if action.kind == "complete":
57
+ return replace(state, task_status=TaskStatus.COMPLETED)
58
+
59
+ assert action.step_id is not None
60
+ step = spec.step(action.step_id)
61
+
62
+ if action.kind == "reserve":
63
+ return _reserve(spec, state, step.id)
64
+
65
+ if action.kind == "reserve_without_evidence":
66
+ return _reserve(spec, state, step.id).with_evidence_bypass(step.id)
67
+
68
+ if action.kind == "reserve_unauthorized":
69
+ detail = f"{step.id}:{step.actor}"
70
+ return _reserve(spec, state, step.id).with_unauthorized_effect(detail)
71
+
72
+ if action.kind == "settle":
73
+ next_state = state.with_step_status(step.id, StepStatus.PAID)
74
+ next_state = next_state.with_attempt(step.id, state.attempt(step.id) + 1)
75
+ next_state = next_state.with_settled_intent(step.id)
76
+ return replace(
77
+ next_state,
78
+ reserved=state.reserved - step.max_cost,
79
+ spent=state.spent + step.actual_cost,
80
+ )
81
+
82
+ if action.kind == "deliver":
83
+ next_state = state.with_step_status(step.id, StepStatus.DONE)
84
+ return next_state.with_evidence(f"delivered:{step.id}")
85
+
86
+ if action.kind == "retry_ambiguous":
87
+ # Models the dangerous recovery path: execution is reopened while the prior
88
+ # payment outcome is still unresolved. The invariant should reject this immediately.
89
+ next_state = state.with_step_status(step.id, StepStatus.PENDING)
90
+ return next_state.with_ambiguous_intent(step.id)
91
+
92
+ raise ValueError(f"unknown action: {action.kind}")
qruntime/exceptions.py ADDED
@@ -0,0 +1,132 @@
1
+ class QRuntimeError(RuntimeError):
2
+ """Base exception for public Q Runtime operational errors."""
3
+
4
+
5
+ class PaymentSafetyError(QRuntimeError):
6
+ """Base exception for payment safety refusals."""
7
+
8
+
9
+ class IntentInFlightError(PaymentSafetyError):
10
+ """The same logical payment intent is already running elsewhere."""
11
+
12
+
13
+ class IntentResourceMismatchError(PaymentSafetyError):
14
+ """An existing intent is already bound to another resource."""
15
+
16
+
17
+ class SettlementPendingError(PaymentSafetyError):
18
+ """A settlement is unresolved and must be reconciled before retry."""
19
+
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ *,
24
+ transaction: str | None = None,
25
+ network: str | None = None,
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.transaction = transaction
29
+ self.network = network
30
+
31
+
32
+ class DuplicateIntentError(PaymentSafetyError):
33
+ """The logical intent already produced a confirmed economic effect."""
34
+
35
+
36
+ class PaymentRequirementRejectedError(PaymentSafetyError):
37
+ """No offered payment requirement satisfies the runtime policy."""
38
+
39
+
40
+ class PaymentRequirementMismatchError(PaymentSafetyError):
41
+ """The payment requirement is not bound to the controlled resource."""
42
+
43
+
44
+ class SettlementReceiptError(QRuntimeError):
45
+ """The settlement receipt is missing or inconsistent."""
46
+
47
+
48
+ class SettlementFailedError(QRuntimeError):
49
+ """Settlement reached a terminal failure."""
50
+
51
+ def __init__(
52
+ self,
53
+ message: str,
54
+ *,
55
+ reason: str | None = None,
56
+ ) -> None:
57
+ super().__init__(message)
58
+ self.reason = reason
59
+
60
+
61
+ class PaidResourceError(QRuntimeError):
62
+ """Payment settled, but the paid resource request failed."""
63
+
64
+ def __init__(
65
+ self,
66
+ message: str,
67
+ *,
68
+ status_code: int,
69
+ transaction: str | None = None,
70
+ network: str | None = None,
71
+ ) -> None:
72
+ super().__init__(message)
73
+ self.status_code = status_code
74
+ self.transaction = transaction
75
+ self.network = network
76
+
77
+
78
+ class SettlementStoreError(QRuntimeError):
79
+ """Persistent settlement state could not be safely read or updated."""
80
+
81
+
82
+ class ReconciliationError(QRuntimeError):
83
+ """Settlement reconciliation infrastructure failed."""
84
+
85
+
86
+ class PolicyDeniedError(PaymentSafetyError):
87
+ """Policy evaluation denied an economic operation."""
88
+
89
+ def __init__(self, decision: "PolicyDecision") -> None:
90
+ self.decision = decision
91
+ self.reason_codes = tuple(
92
+ decision.reason_codes
93
+ )
94
+
95
+ message = (
96
+ "policy denied economic operation"
97
+ )
98
+
99
+ if self.reason_codes:
100
+ message += ": " + ", ".join(
101
+ self.reason_codes
102
+ )
103
+
104
+ super().__init__(message)
105
+
106
+ class BudgetDeniedError(PaymentSafetyError):
107
+ """Aggregate spending budget refused the payment."""
108
+
109
+ def __init__(self, decision):
110
+ self.decision = decision
111
+ self.reason = getattr(
112
+ decision,
113
+ "reason",
114
+ None,
115
+ )
116
+ self.projected_atomic = getattr(
117
+ decision,
118
+ "projected_atomic",
119
+ None,
120
+ )
121
+ self.limit_atomic = getattr(
122
+ decision,
123
+ "limit_atomic",
124
+ None,
125
+ )
126
+
127
+ super().__init__(
128
+ "budget denied payment: "
129
+ f"{self.reason or 'aggregate_budget_exceeded'}; "
130
+ f"projected={self.projected_atomic}; "
131
+ f"limit={self.limit_atomic}"
132
+ )
File without changes
@@ -0,0 +1,94 @@
1
+ """Optional official x402 SDK demo server for Q Runtime v0.6.
2
+
3
+ This module uses the x402 Foundation Python SDK and mirrors the current official
4
+ FastAPI example's EVM/Base-Sepolia path. It is testnet-only by design.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ def build_app(
10
+ *,
11
+ pay_to: str,
12
+ price_usd: str = "0.01",
13
+ facilitator_url: str = "https://x402.org/facilitator",
14
+ ):
15
+ try:
16
+ from fastapi import FastAPI
17
+ from x402.http import FacilitatorConfig, HTTPFacilitatorClient, PaymentOption
18
+ from x402.http.middleware.fastapi import PaymentMiddlewareASGI
19
+ from x402.http.types import RouteConfig
20
+ from x402.mechanisms.evm.exact import ExactEvmServerScheme
21
+ from x402.server import x402ResourceServer
22
+ except ImportError as exc: # pragma: no cover - exercised in user's optional integration env
23
+ raise RuntimeError(
24
+ "official x402 integration dependencies are missing. Install with: "
25
+ 'python -m pip install -e ".[official-x402]"'
26
+ ) from exc
27
+
28
+ if not pay_to.startswith("0x") or len(pay_to) != 42:
29
+ raise ValueError("--pay-to must be a 20-byte EVM address (0x + 40 hex characters)")
30
+ try:
31
+ int(pay_to[2:], 16)
32
+ except ValueError as exc:
33
+ raise ValueError("--pay-to is not valid hexadecimal") from exc
34
+
35
+ try:
36
+ amount = float(price_usd)
37
+ except ValueError as exc:
38
+ raise ValueError("--price-usd must be numeric, e.g. 0.01") from exc
39
+ if amount <= 0:
40
+ raise ValueError("--price-usd must be positive")
41
+
42
+ # Current official example uses Base Sepolia for its EVM testnet server path.
43
+ network = "eip155:84532"
44
+
45
+ app = FastAPI(title="Q Runtime v0.6 — Official x402 SDK Capture Demo")
46
+ facilitator = HTTPFacilitatorClient(FacilitatorConfig(url=facilitator_url))
47
+ server = x402ResourceServer(facilitator)
48
+ server.register(network, ExactEvmServerScheme())
49
+
50
+ routes = {
51
+ "GET /weather": RouteConfig(
52
+ accepts=[
53
+ PaymentOption(
54
+ scheme="exact",
55
+ pay_to=pay_to,
56
+ price=f"${amount:.2f}",
57
+ network=network,
58
+ )
59
+ ],
60
+ mime_type="application/json",
61
+ description="Weather report — Q Runtime x402 capture demo",
62
+ )
63
+ }
64
+ app.add_middleware(PaymentMiddlewareASGI, routes=routes, server=server)
65
+
66
+ @app.get("/health")
67
+ async def health() -> dict[str, str]:
68
+ return {"status": "ok", "network": network}
69
+
70
+ @app.get("/weather")
71
+ async def weather() -> dict[str, object]:
72
+ return {"report": {"weather": "sunny", "temperature": 70}}
73
+
74
+ return app
75
+
76
+
77
+ def serve(
78
+ *,
79
+ pay_to: str,
80
+ price_usd: str = "0.01",
81
+ host: str = "127.0.0.1",
82
+ port: int = 4021,
83
+ facilitator_url: str = "https://x402.org/facilitator",
84
+ ) -> None:
85
+ try:
86
+ import uvicorn
87
+ except ImportError as exc: # pragma: no cover
88
+ raise RuntimeError(
89
+ "uvicorn is missing. Install with: "
90
+ 'python -m pip install -e ".[official-x402]"'
91
+ ) from exc
92
+
93
+ app = build_app(pay_to=pay_to, price_usd=price_usd, facilitator_url=facilitator_url)
94
+ uvicorn.run(app, host=host, port=port)
qruntime/loader.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import yaml
5
+
6
+ from qruntime.core.model import StepSpec, WorkflowSpec
7
+
8
+
9
+ def money_to_minor(value: float | int | str) -> int:
10
+ return int(round(float(value) * 100))
11
+
12
+
13
+ def load_workflow(path: str | Path) -> WorkflowSpec:
14
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
15
+ steps = []
16
+ for item in raw.get("steps", []):
17
+ max_cost = money_to_minor(item["max_cost"])
18
+ actor = str(item.get("actor", "agent"))
19
+ allowed_actors = tuple(str(x) for x in item.get("allowed_actors", [actor]))
20
+ steps.append(StepSpec(
21
+ id=item["id"],
22
+ max_cost=max_cost,
23
+ actual_cost=money_to_minor(item.get("actual_cost", item["max_cost"])),
24
+ required=bool(item.get("required", True)),
25
+ dependencies=tuple(item.get("dependencies", [])),
26
+ required_evidence=tuple(item.get("required_evidence", [])),
27
+ actor=actor,
28
+ allowed_actors=allowed_actors,
29
+ allow_ambiguous_retry=bool(item.get("allow_ambiguous_retry", False)),
30
+ allow_missing_evidence_spend=bool(item.get("allow_missing_evidence_spend", False)),
31
+ allow_unauthorized_execution=bool(item.get("allow_unauthorized_execution", False)),
32
+ ))
33
+ return WorkflowSpec(
34
+ name=raw["workflow"],
35
+ currency=raw.get("currency", "USD"),
36
+ budget=money_to_minor(raw["budget"]),
37
+ steps=tuple(steps),
38
+ max_states=int(raw.get("max_states", 10_000)),
39
+ )
File without changes
@@ -0,0 +1 @@
1
+ """Delegated-authority policy primitives will move here in the next increment."""
@@ -0,0 +1 @@
1
+ """Budget policy primitives will move here as the v0.1 model expands."""
@@ -0,0 +1 @@
1
+ """Evidence requirements will move here in the next increment."""