diff --git a/README.md b/README.md index ed20b05dc9..42b18f4ddd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ A tested platform you can run, inspect, and build on: - **Dual-backend persistence**: SQLite (single-node default) and PostgreSQL (multi-instance), conformance-tested for parity, with in-process yoyo schema migrations and ISO 4217 currency stamping on every cost-bearing row. - **Provider layer**: any LLM via LiteLLM with built-in retry and rate-limit handling; local model management for Ollama and LM Studio. - **Configuration and templates**: define a company in YAML; importable/shareable agent, department, and company templates with personality presets. -- **Subsystem libraries, tested as components**: the engine, memory, budget, security, coordination, and intake modules exist as importable, unit-tested code. Note honestly: these are exercised by their test suites, not yet by a running agent (see below). +- **Subsystem libraries, tested as components**: the engine, memory, budget, security, and coordination modules exist as importable, unit-tested code. Note honestly: these are exercised by their test suites, not yet by a running agent (see below). +- **Client-simulation intake runtime**: the intake engine is wired into boot via the client-simulation runtime and driven end-to-end by a deterministic simulation harness (synthetic clients, scripted provider, zero LLM spend), which is also the acceptance substrate for the runtime work in flight. - **Operations**: structured logging with redaction and correlation, Prometheus metrics and OTLP, HttpOnly-cookie multi-user sessions with CSRF protection, Chainguard distroless images with Trivy + Grype scanning, cosign signatures, and SLSA L3 provenance. - **Distributed dispatch plumbing**: NATS JetStream queue and a worker pool. The dispatch path exists; the task-execute endpoint currently advances task state and does not yet invoke an agent. @@ -44,7 +45,7 @@ These are the capabilities that make SynthOrg an autonomous studio. They are des - **Best-in-class operate tier**: a golden-company benchmark, mission control with run replay, a cost forecast/kill-switch dial, a measurable learning curve, deterministic replay, run narratives, and an adversarial red-team. - **Agent capability layer**: a knowledge and provenance retrieval substrate, research mode, continual improvement, governed external API access, headless-browser and virtual-desktop testing, and more. -Until the runtime lands, multi-agent coordination, coordination metrics, the intake engine, autonomy/trust enforcement on a live run, and the self-improvement loop are designed and unit-tested but not exercised end to end. The design for each lives in the [Design Specification](https://synthorg.io/docs/design/). +Until the agent runtime lands, multi-agent coordination, coordination metrics, autonomy/trust enforcement on a live run, and the self-improvement loop are designed and unit-tested but not exercised end to end. The design for each lives in the [Design Specification](https://synthorg.io/docs/design/). ## Quick Start diff --git a/data/runtime_stats.yaml b/data/runtime_stats.yaml index 1c93ab4dd1..c8874e6028 100644 --- a/data/runtime_stats.yaml +++ b/data/runtime_stats.yaml @@ -1,15 +1,15 @@ schema_version: 1 -last_generated_utc: '2026-05-17T15:57:53Z' -generator_revision: dae8de769 +last_generated_utc: '2026-05-18T11:58:09Z' +generator_revision: 958c3bae6 stats: tests: - raw: 31118 + raw: 31200 rounded: 31000 display: 31,000+ mem0_stars: - raw: 55942 - rounded: 55000 - display: 55k+ + raw: 56017 + rounded: 56000 + display: 56k+ providers_curated: raw: 20 display: '20' diff --git a/docs/design/client-simulation.md b/docs/design/client-simulation.md index 8e89886fa4..8f2cb34e43 100644 --- a/docs/design/client-simulation.md +++ b/docs/design/client-simulation.md @@ -5,10 +5,6 @@ description: Synthetic client framework for generating task requirements, review # Client Simulation -!!! warning "Designed behaviour; runtime in active development" - - This page is the source of truth for the **designed** behaviour of this subsystem. The intake engine and simulation runtime are not wired into the shipped product yet; this is in active development (see the [Roadmap](../roadmap/index.md)). The code described here is built and unit-tested as components but not yet run end to end. - The client simulation subsystem generates synthetic workloads that exercise the full task lifecycle end-to-end. Simulated clients (AI-driven, human, or hybrid) submit task requirements through an intake pipeline and review completed deliverables via a @@ -195,6 +191,23 @@ through `TASK_CREATED`. It routes requests to a configured `IntakeStrategy`: - **AgentIntake**: routes to an intake agent (PM/Account Manager) for triage, scoping, and approval before task creation. +### Boot wiring + +`synthorg.client.runtime_builder.build_client_simulation_runtime` +constructs the `IntakeEngine` (plus a single-stage `ReviewPipeline` +of `InternalReviewStage`) during app construction whenever a +`TaskEngine` is present, and `create_app` attaches the resulting +`ClientSimulationState` so `has_simulation_runtime` is true and the +`/simulations` + `/requests` controllers register. The strategy is +selected from the `simulations` settings namespace +(`intake_strategy` ∈ {`direct`, `agent`}, `intake_model`) via the +bootstrap resolver (env > registered default); the choice is baked +in at startup (`read_only_post_init`). The default `direct` strategy +makes no LLM calls, so the runtime comes online for an empty company. +A selected `agent` strategy that cannot be satisfied (no provider or +no model) degrades to `direct` with a warning rather than failing +boot. + --- ## Task Source Tracking @@ -272,6 +285,7 @@ discriminator rather than silently falling back to a default. | `ReportConfig.strategy` | `build_report_strategy()` | `summary` → `SummaryReport`, `detailed` → `DetailedReport`, `json_export` → `JsonExportReport`, `metrics_only` → `MetricsOnlyReport` | | `ClientPoolConfig.selection_strategy` | `build_client_pool_strategy()` | `round_robin` → `RoundRobinStrategy`, `weighted_random` → `WeightedRandomStrategy`, `domain_matched` → `DomainMatchedStrategy` | | `adapter` arg (intake entry point) | `build_entry_point_strategy(adapter, *, project_id=None)` | `direct` → `DirectAdapter`, `project` → `ProjectAdapter`, `intake` → `IntakeAdapter` | +| `IntakeConfig.strategy` | `build_intake_strategy(config, *, task_engine, provider=None, cost_tracker=None)` | `direct` → `DirectIntake`, `agent` → `AgentIntake` | The factories follow the project-wide pluggable-subsystems pattern (protocol + strategy + factory + config discriminator). No silent diff --git a/scripts/_ghost_wiring_manifest.txt b/scripts/_ghost_wiring_manifest.txt index 2835075fd2..80620b8000 100644 --- a/scripts/_ghost_wiring_manifest.txt +++ b/scripts/_ghost_wiring_manifest.txt @@ -27,4 +27,4 @@ ENFORCED AgentEngine #1956 -- runtime root; construct at boot behind the provide PENDING build_coordinator #1958 -- call at boot to populate app_state.coordinator PENDING BaselineStore #1959 -- construct at boot (window from budget.baseline_window_size) PENDING CoordinationMetricsCollector #1959 -- construct at boot, thread into execution -PENDING IntakeEngine #1961 -- wire via the client-simulation runtime +ENFORCED IntakeEngine #1961 -- wired at boot via client/runtime_builder.build_client_simulation_runtime diff --git a/src/synthorg/api/app.py b/src/synthorg/api/app.py index a5ac052fa7..65fe388b49 100644 --- a/src/synthorg/api/app.py +++ b/src/synthorg/api/app.py @@ -886,17 +886,32 @@ def create_app( # noqa: C901, PLR0912, PLR0913, PLR0915 service="a2a_gateway", ) - # Default to a fresh ``ClientSimulationState()`` so the - # always-registered ``ClientController`` can serve an empty - # ``/clients`` list instead of 503ing on every dashboard poll. - # Callers wanting the full intake / review pipeline pass a - # configured state via the kwarg. + # Client-simulation runtime. An explicit kwarg always wins (test + # doubles / bespoke wiring). Otherwise, when a TaskEngine is + # present, build the live runtime (real IntakeEngine + review + # pipeline) so ``has_simulation_runtime`` is true and the + # ``/simulations`` + ``/requests`` controllers register; the + # default ``direct`` intake strategy makes no LLM calls and works + # for an empty company. With no TaskEngine the intake engine has + # nothing to create tasks against, so fall back to a fresh empty + # ``ClientSimulationState()`` -- the always-registered + # ``ClientController`` still serves an empty ``/clients`` list + # instead of 503ing on every dashboard poll. This mirrors the + # ``review_gate_service`` "build it whenever task_engine exists" + # gate above. if client_simulation_state is None: - from synthorg.client.simulation_state import ( # noqa: PLC0415 - ClientSimulationState as _ClientSimulationState, - ) + if task_engine is not None: + from synthorg.client.runtime_builder import ( # noqa: PLC0415 + build_client_simulation_runtime, + ) + + client_simulation_state = build_client_simulation_runtime(app_state) + else: + from synthorg.client.simulation_state import ( # noqa: PLC0415 + ClientSimulationState as _ClientSimulationState, + ) - client_simulation_state = _ClientSimulationState() + client_simulation_state = _ClientSimulationState() app_state.set_client_simulation_state(client_simulation_state) # Optional controllers gated on their primary collaborator service. diff --git a/src/synthorg/client/__init__.py b/src/synthorg/client/__init__.py index 87abd2b54b..550bed1e89 100644 --- a/src/synthorg/client/__init__.py +++ b/src/synthorg/client/__init__.py @@ -11,6 +11,7 @@ ClientSimulationConfig, ContinuousModeConfig, FeedbackConfig, + IntakeConfig, ReportConfig, RequirementGeneratorConfig, SimulationRunnerConfig, @@ -21,6 +22,7 @@ build_client_pool_strategy, build_entry_point_strategy, build_feedback_strategy, + build_intake_strategy, build_report_strategy, build_requirement_generator, ) @@ -79,6 +81,7 @@ "HybridClient", "HybridRouter", "InMemoryHumanInputQueue", + "IntakeConfig", "PendingRequirement", "PendingReview", "PoolConstraints", @@ -97,6 +100,7 @@ "build_client_pool_strategy", "build_entry_point_strategy", "build_feedback_strategy", + "build_intake_strategy", "build_report_strategy", "build_requirement_generator", "default_router", diff --git a/src/synthorg/client/config.py b/src/synthorg/client/config.py index 1c7943e8c3..4b7936882d 100644 --- a/src/synthorg/client/config.py +++ b/src/synthorg/client/config.py @@ -5,12 +5,18 @@ pools, requirement generators, and feedback strategies. """ -from typing import Self +from typing import Final, Self from pydantic import BaseModel, ConfigDict, Field, model_validator from synthorg.core.types import NotBlankStr # noqa: TC001 +# Acceptance band for the AI/human/hybrid pool-ratio sum: floating +# point makes an exact == 1.0 check brittle, so a +/-1% tolerance is +# allowed around the unit sum. +_RATIO_SUM_TOLERANCE_LOW: Final[float] = 0.99 +_RATIO_SUM_TOLERANCE_HIGH: Final[float] = 1.01 + class RequirementGeneratorConfig(BaseModel): """Configuration for requirement generation strategy. @@ -123,9 +129,7 @@ class ClientPoolConfig(BaseModel): def _validate_ratio_sum(self) -> Self: """Ensure ratios sum to approximately 1.0.""" total = self.ai_ratio + self.human_ratio + self.hybrid_ratio - _tolerance_low = 0.99 - _tolerance_high = 1.01 - if not (_tolerance_low <= total <= _tolerance_high): + if not (_RATIO_SUM_TOLERANCE_LOW <= total <= _RATIO_SUM_TOLERANCE_HIGH): msg = ( f"Ratios must sum to approximately 1.0, got " f"{self.ai_ratio} + {self.human_ratio} + " @@ -207,6 +211,31 @@ class ReportConfig(BaseModel): ) +class IntakeConfig(BaseModel): + """Configuration for the intake strategy. + + Attributes: + strategy: Strategy identifier dispatched by + ``build_intake_strategy``: ``direct`` (no LLM, creates a + task per accepted request) or ``agent`` (LLM-driven triage + via a completion provider). + model: Model identifier passed to the agent intake strategy. + Only consulted when ``strategy == "agent"``; ignored by + the ``direct`` strategy. + """ + + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") + + strategy: NotBlankStr = Field( + default="direct", + description="Intake strategy identifier (direct or agent)", + ) + model: NotBlankStr | None = Field( + default=None, + description="Model id for the agent intake strategy", + ) + + class ClientSimulationConfig(BaseModel): """Top-level client simulation configuration. @@ -219,6 +248,7 @@ class ClientSimulationConfig(BaseModel): report: Report format configuration. runner: Simulation runner configuration. continuous: Continuous mode configuration. + intake: Intake strategy configuration. """ model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") @@ -247,3 +277,7 @@ class ClientSimulationConfig(BaseModel): default_factory=ContinuousModeConfig, description="Continuous mode configuration", ) + intake: IntakeConfig = Field( + default_factory=IntakeConfig, + description="Intake strategy configuration", + ) diff --git a/src/synthorg/client/factory.py b/src/synthorg/client/factory.py index 5b80f2c65c..66245d5f37 100644 --- a/src/synthorg/client/factory.py +++ b/src/synthorg/client/factory.py @@ -8,7 +8,7 @@ """ from pathlib import Path -from typing import ClassVar +from typing import ClassVar, NoReturn from synthorg.client.adapters import ( DirectAdapter, @@ -53,6 +53,7 @@ from synthorg.client.config import ( # noqa: E402, TC001 ClientPoolConfig, FeedbackConfig, + IntakeConfig, ReportConfig, RequirementGeneratorConfig, ) @@ -63,6 +64,8 @@ ReportStrategy, RequirementGenerator, ) +from synthorg.engine.intake.protocol import IntakeStrategy # noqa: E402, TC001 +from synthorg.engine.task_engine import TaskEngine # noqa: E402, TC001 from synthorg.providers.protocol import CompletionProvider # noqa: E402, TC001 _GENERATOR_STRATEGIES: frozenset[str] = frozenset( @@ -80,6 +83,8 @@ _ENTRY_POINT_STRATEGIES: frozenset[str] = frozenset( {"direct", "project", "intake"}, ) +_INTAKE_STRATEGIES: frozenset[str] = frozenset({"direct", "agent"}) +_INTAKE_FACTORY = "intake_strategy" class UnknownStrategyError(ValidationError): @@ -118,6 +123,24 @@ def _require_non_blank( raise UnknownStrategyError(msg) from exc +def _raise_unknown_strategy( + *, + label: str, + factory: str, + strategy: str, + expected: frozenset[str], +) -> NoReturn: + """Log and raise :class:`UnknownStrategyError` for a bad discriminator.""" + logger.warning( + CLIENT_FACTORY_UNKNOWN_STRATEGY, + factory=factory, + strategy=strategy, + expected=sorted(expected), + ) + msg = f"unknown {label} strategy {strategy!r}; expected one of {sorted(expected)}" + raise UnknownStrategyError(msg) + + _REQ_GEN_FACTORY = "requirement_generator" @@ -201,34 +224,16 @@ def build_requirement_generator( model: NotBlankStr | None = None, cost_tracker: CostTracker | None = None, ) -> RequirementGenerator: - """Construct a ``RequirementGenerator`` from configuration. - - Dispatches on ``config.strategy``: - - * ``template`` -> ``TemplateGenerator`` - * ``llm`` -> ``LLMGenerator`` (requires ``provider`` + ``model``; - threads ``cost_tracker`` through so the chokepoint records each - generated batch). - * ``dataset`` -> ``DatasetGenerator`` (requires ``dataset_path``) - * ``procedural`` -> ``ProceduralGenerator`` - * ``hybrid`` is **intentionally excluded** from factory dispatch: - ``HybridGenerator`` composes multiple generators with weights - and has no single-argument factory, so it must be constructed - manually. Passing ``strategy="hybrid"`` here raises - ``UnknownStrategyError``. - - Args: - config: Strategy + per-strategy configuration. - provider: LLM provider used by the ``llm`` strategy. - Ignored by other strategies. - model: Model identifier used by the ``llm`` strategy when - the config does not pin one. Ignored by other strategies. - cost_tracker: Optional :class:`CostTracker` propagated to - the ``llm`` strategy so cost-recording (`CostRecord`) is - emitted on each generated batch. Ignored by all other - strategies (template / dataset / procedural don't talk - to a provider). When ``None``, the ``llm`` strategy - still works -- the chokepoint just stays silent. + """Construct a ``RequirementGenerator`` from ``config.strategy``. + + ``template`` -> ``TemplateGenerator``; ``llm`` -> ``LLMGenerator`` + (needs ``provider`` + ``model``; ``cost_tracker`` threaded through + so the chokepoint records each batch); ``dataset`` -> + ``DatasetGenerator`` (needs ``dataset_path``); ``procedural`` -> + ``ProceduralGenerator``. ``hybrid`` is intentionally excluded + (``HybridGenerator`` composes weighted generators and has no + single-argument factory); passing it raises + :class:`UnknownStrategyError`. """ strategy = str(config.strategy) if strategy == "template": @@ -247,17 +252,12 @@ def build_requirement_generator( return ProceduralGenerator() if strategy == "hybrid": return _reject_hybrid_generator(config, strategy) - logger.warning( - CLIENT_FACTORY_UNKNOWN_STRATEGY, + _raise_unknown_strategy( + label="requirement generator", factory=_REQ_GEN_FACTORY, strategy=strategy, - expected=sorted(_GENERATOR_STRATEGIES), - ) - msg = ( - f"unknown requirement generator strategy {strategy!r}; " - f"expected one of {sorted(_GENERATOR_STRATEGIES)}" + expected=_GENERATOR_STRATEGIES, ) - raise UnknownStrategyError(msg) def build_feedback_strategy( @@ -398,3 +398,73 @@ def build_entry_point_strategy( f"expected one of {sorted(_ENTRY_POINT_STRATEGIES)}" ) raise UnknownStrategyError(msg) + + +def _build_agent_intake( + config: IntakeConfig, + *, + task_engine: TaskEngine, + provider: CompletionProvider | None, + cost_tracker: CostTracker | None, +) -> IntakeStrategy: + """Build the LLM-triage ``AgentIntake`` (needs provider + model).""" + from synthorg.engine.intake import AgentIntake # noqa: PLC0415 + + if provider is None: + logger.warning( + CLIENT_FACTORY_UNKNOWN_STRATEGY, + factory=_INTAKE_FACTORY, + strategy="agent", + missing="provider", + ) + msg = "agent intake strategy requires a completion provider" + raise UnknownStrategyError(msg) + model = _require_non_blank( + config.model, + factory=_INTAKE_FACTORY, + strategy="agent", + field="model", + ) + return AgentIntake( + task_engine=task_engine, + provider=provider, + model=NotBlankStr(model), + cost_tracker=cost_tracker, + ) + + +def build_intake_strategy( + config: IntakeConfig, + *, + task_engine: TaskEngine, + provider: CompletionProvider | None = None, + cost_tracker: CostTracker | None = None, +) -> IntakeStrategy: + """Construct an ``IntakeStrategy`` from ``config.strategy``. + + ``direct`` -> :class:`DirectIntake` (no LLM). ``agent`` -> + :class:`AgentIntake` (LLM triage; needs ``provider`` and a + non-blank ``config.model``). Misconfiguration fails loudly with + :class:`UnknownStrategyError`; the caller decides whether to + degrade. ``cost_tracker`` is threaded into ``AgentIntake``. + """ + # Lazy: synthorg.engine.intake pulls the provider/prompt-safety + # graph; keep it off the synthorg.client package-import path. + from synthorg.engine.intake import DirectIntake # noqa: PLC0415 + + strategy = config.strategy + if strategy == "direct": + return DirectIntake(task_engine=task_engine) + if strategy == "agent": + return _build_agent_intake( + config, + task_engine=task_engine, + provider=provider, + cost_tracker=cost_tracker, + ) + _raise_unknown_strategy( + label="intake", + factory=_INTAKE_FACTORY, + strategy=strategy, + expected=_INTAKE_STRATEGIES, + ) diff --git a/src/synthorg/client/runtime_builder.py b/src/synthorg/client/runtime_builder.py new file mode 100644 index 0000000000..12261e71b4 --- /dev/null +++ b/src/synthorg/client/runtime_builder.py @@ -0,0 +1,170 @@ +"""Boot-time builder for the client-simulation runtime. + +This is the shipped construction site for the :class:`IntakeEngine`. +``create_app`` calls :func:`build_client_simulation_runtime` from its +construction phase (when a ``TaskEngine`` is present and no explicit +``client_simulation_state`` was injected) so ``has_simulation_runtime`` +becomes true and the ``/simulations`` + ``/requests`` controllers +register. + +The intake strategy is selected at the boot site via the bootstrap +resolver (env > registered default), matching how ``app.py`` reads +other construction-phase settings: ``ConfigResolver`` is not wired +until on-startup, so the database tier is intentionally not consulted +for this baked-in-at-startup choice (``read_only_post_init`` in the +registry). The default ``direct`` strategy needs no provider and makes +no LLM calls, so the runtime comes online for an empty company. +""" + +import os +from collections.abc import Mapping # noqa: TC003 +from typing import TYPE_CHECKING + +from synthorg.client.config import IntakeConfig +from synthorg.client.factory import UnknownStrategyError, build_intake_strategy +from synthorg.client.simulation_state import ClientSimulationState +from synthorg.engine.intake.engine import IntakeEngine +from synthorg.engine.review.pipeline import ReviewPipeline +from synthorg.engine.review.stages.internal import InternalReviewStage +from synthorg.observability import get_logger, safe_error_description +from synthorg.observability.events.client import CLIENT_SIMULATION_RUNTIME_WIRED +from synthorg.settings.bootstrap_resolver import resolve_init_value +from synthorg.settings.enums import SettingNamespace + +if TYPE_CHECKING: + from synthorg.api.state import AppState + from synthorg.budget.tracker import CostTracker + from synthorg.engine.intake.protocol import IntakeStrategy + from synthorg.engine.task_engine import TaskEngine + from synthorg.providers.protocol import CompletionProvider + +logger = get_logger(__name__) + +_INTAKE_STRATEGY_KEY = "intake_strategy" +_INTAKE_MODEL_KEY = "intake_model" +_DEFAULT_STRATEGY = "direct" + + +def _select_provider(app_state: AppState) -> CompletionProvider | None: + """Return the first registered provider, or ``None`` (empty company). + + Mirrors the worker-execution-service builder's provider selection: + ``has_active_provider`` is the single source of truth for the + provider-present switch, and the first registered provider backs + the boot agent-intake strategy when ``agent`` is selected. + """ + if not app_state.has_active_provider: + return None + registry = app_state.provider_registry + names = registry.list_providers() + if not names: + return None + return registry.get(names[0]) + + +def _resolve_intake_settings(env: Mapping[str, str]) -> tuple[str, str | None]: + """Resolve ``(strategy, model)`` from the ``simulations`` namespace. + + Boot-site read (env > registered default) via the bootstrap + resolver; ``ConfigResolver`` is not wired at construction. + """ + strategy = str( + resolve_init_value( + SettingNamespace.SIMULATIONS, _INTAKE_STRATEGY_KEY, env=env + ).value + ) + raw_model = resolve_init_value( + SettingNamespace.SIMULATIONS, _INTAKE_MODEL_KEY, env=env + ).value + model = None if raw_model is None else str(raw_model).strip() or None + return strategy, model + + +def _build_intake_with_fallback( + *, + requested_strategy: str, + model: str | None, + task_engine: TaskEngine, + provider: CompletionProvider | None, + cost_tracker: CostTracker | None, +) -> tuple[IntakeStrategy, str]: + """Build the requested intake strategy, degrading ``agent`` to ``direct``. + + A non-default strategy that cannot be satisfied (no provider / no + model) degrades to ``direct`` with a WARNING so a misconfigured + choice never bricks boot. A ``direct`` failure is a real defect + and propagates unchanged. + """ + try: + strategy = build_intake_strategy( + IntakeConfig(strategy=requested_strategy, model=model), + task_engine=task_engine, + provider=provider, + cost_tracker=cost_tracker, + ) + except UnknownStrategyError as exc: + if requested_strategy == _DEFAULT_STRATEGY: + logger.error( + CLIENT_SIMULATION_RUNTIME_WIRED, + requested_strategy=requested_strategy, + effective_strategy=_DEFAULT_STRATEGY, + reason="default direct strategy failed during boot", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise + logger.warning( + CLIENT_SIMULATION_RUNTIME_WIRED, + requested_strategy=requested_strategy, + effective_strategy=_DEFAULT_STRATEGY, + reason="requested strategy unsatisfiable; degraded to direct", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + fallback = build_intake_strategy( + IntakeConfig(strategy=_DEFAULT_STRATEGY), + task_engine=task_engine, + ) + return fallback, _DEFAULT_STRATEGY + else: + return strategy, requested_strategy + + +def build_client_simulation_runtime( + app_state: AppState, + *, + env: Mapping[str, str] = os.environ, +) -> ClientSimulationState: + """Construct the boot client-simulation runtime state. + + Default ``direct`` intake makes no LLM call (works for an empty + company). The review pipeline is ``InternalReviewStage`` only: + ``ClientReviewStage`` needs a per-request client, unavailable + generically at boot. ``app_state.task_engine`` must be set (the + caller gates on this); ``provider_registry`` / ``cost_tracker`` + are consulted when present. ``env`` overrides ``os.environ`` for + tests. + """ + task_engine = app_state.task_engine + requested_strategy, model = _resolve_intake_settings(env) + provider = _select_provider(app_state) + cost_tracker = app_state.cost_tracker if app_state.has_cost_tracker else None + strategy, effective_strategy = _build_intake_with_fallback( + requested_strategy=requested_strategy, + model=model, + task_engine=task_engine, + provider=provider, + cost_tracker=cost_tracker, + ) + review_pipeline = ReviewPipeline(stages=(InternalReviewStage(),)) + logger.info( + CLIENT_SIMULATION_RUNTIME_WIRED, + requested_strategy=requested_strategy, + effective_strategy=effective_strategy, + has_provider=provider is not None, + review_stages=list(review_pipeline.stage_names), + ) + return ClientSimulationState( + intake_engine=IntakeEngine(strategy=strategy), + review_pipeline=review_pipeline, + ) diff --git a/src/synthorg/engine/intake/engine.py b/src/synthorg/engine/intake/engine.py index 900cba2243..d4303944bb 100644 --- a/src/synthorg/engine/intake/engine.py +++ b/src/synthorg/engine/intake/engine.py @@ -8,6 +8,8 @@ CLIENT_REQUEST_APPROVED, CLIENT_REQUEST_REJECTED, CLIENT_REQUEST_SCOPED, + CLIENT_REQUEST_TRANSITION_CONFIG_ERROR, + CLIENT_REQUEST_TRANSITION_INVALID, CLIENT_REQUEST_TRIAGING, ) from synthorg.observability.events.review_pipeline import ( @@ -38,6 +40,16 @@ def __init__(self, *, strategy: IntakeStrategy) -> None: """ self._strategy = strategy + @property + def strategy(self) -> IntakeStrategy: + """Return the configured intake strategy. + + Read-only introspection seam used by the client-simulation + runtime builder and its tests to assert which strategy was + wired at boot without reaching into a private attribute. + """ + return self._strategy + async def process( self, request: ClientRequest, @@ -57,6 +69,14 @@ async def process( ValueError: If ``request.status`` is not ``SUBMITTED``. """ if request.status is not RequestStatus.SUBMITTED: + logger.warning( + CLIENT_REQUEST_TRANSITION_INVALID, + request_id=request.request_id, + client_id=request.client_id, + from_status=request.status.value, + expected=RequestStatus.SUBMITTED.value, + operation="process", + ) msg = ( "IntakeEngine.process requires SUBMITTED request, " f"got {request.status.value!r}" @@ -105,6 +125,14 @@ async def finalize_scoped( ValueError: If ``request.status`` is not ``SCOPING``. """ if request.status is not RequestStatus.SCOPING: + logger.warning( + CLIENT_REQUEST_TRANSITION_INVALID, + request_id=request.request_id, + client_id=request.client_id, + from_status=request.status.value, + expected=RequestStatus.SCOPING.value, + operation="finalize_scoped", + ) msg = ( "IntakeEngine.finalize_scoped requires SCOPING request, " f"got {request.status.value!r}" @@ -121,6 +149,12 @@ def _finalize_accepted( result: IntakeResult, ) -> tuple[ClientRequest, IntakeResult]: if result.task_id is None: + logger.error( + CLIENT_REQUEST_TRANSITION_CONFIG_ERROR, + request_id=request.request_id, + client_id=request.client_id, + reason="accepted intake result missing task_id", + ) msg = "Accepted intake result missing task_id" raise ValueError(msg) approved = request.with_status(RequestStatus.APPROVED) diff --git a/src/synthorg/observability/events/client.py b/src/synthorg/observability/events/client.py index 683e299882..4f8ff07f03 100644 --- a/src/synthorg/observability/events/client.py +++ b/src/synthorg/observability/events/client.py @@ -29,6 +29,10 @@ CLIENT_FACTORY_UNKNOWN_STRATEGY: Final[str] = "client.factory.unknown_strategy" +# Client-simulation runtime boot wiring (emitted by the builder that +# constructs the IntakeEngine + ReviewPipeline at app construction). +CLIENT_SIMULATION_RUNTIME_WIRED: Final[str] = "client.simulation.runtime_wired" + CLIENT_REQUEST_TRANSITION: Final[str] = "client.request.transition" CLIENT_REQUEST_TRANSITION_INVALID: Final[str] = "client.request.transition_invalid" CLIENT_REQUEST_TRANSITION_CONFIG_ERROR: Final[str] = ( diff --git a/src/synthorg/ontology/__init__.py b/src/synthorg/ontology/__init__.py index 7294037b3f..538ee44238 100644 --- a/src/synthorg/ontology/__init__.py +++ b/src/synthorg/ontology/__init__.py @@ -1,9 +1,16 @@ -"""Semantic ontology subsystem for the SynthOrg framework. +"""Semantic ontology subsystem public API. -Re-exports the public API: models, decorator, protocol, config, -errors, service, and versioning factory. +``OntologyService`` and ``OntologyEntityRepository`` are exported +lazily (PEP 562) so importing the lightweight ``@ontology_entity`` +decorator does not pull ``persistence`` -> ``security`` at package +import time; that eager edge closes a cross-package import cycle. +``from synthorg.ontology import OntologyService`` still works, +resolved and cached on first access. """ +import threading +from typing import TYPE_CHECKING, Final + from synthorg.ontology.config import ( DelegationGuardConfig, DriftDetectionConfig, @@ -39,8 +46,50 @@ EntitySource, EntityTier, ) -from synthorg.ontology.service import OntologyService -from synthorg.persistence.ontology_protocol import OntologyEntityRepository + +if TYPE_CHECKING: + from synthorg.ontology.service import OntologyService + from synthorg.persistence.ontology_protocol import OntologyEntityRepository + +# name -> (module path, attribute) for PEP 562 lazy resolution. +_LAZY_EXPORTS: dict[str, tuple[str, str]] = { + "OntologyService": ("synthorg.ontology.service", "OntologyService"), + "OntologyEntityRepository": ( + "synthorg.persistence.ontology_protocol", + "OntologyEntityRepository", + ), +} + + +_LAZY_EXPORT_LOCK: Final[threading.Lock] = threading.Lock() + + +def __getattr__(name: str) -> object: + """Resolve and cache a lazily-exported symbol on first access (PEP 562). + + The cache write into ``globals()`` is guarded so concurrent + first-access from multiple threads cannot double-import the heavy + submodule or overwrite the cached object mid-write (mirrors + :mod:`synthorg.tools.mcp`). + """ + if name not in _LAZY_EXPORTS: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + import importlib # noqa: PLC0415 + + with _LAZY_EXPORT_LOCK: + if name in globals(): + return globals()[name] + module_path, attr = _LAZY_EXPORTS[name] + value = getattr(importlib.import_module(module_path), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """Include the lazily-exported names in ``dir()`` / autocomplete.""" + return sorted(__all__) + __all__ = [ "AgentDrift", diff --git a/src/synthorg/settings/definitions/simulations.py b/src/synthorg/settings/definitions/simulations.py index 20337b351b..e79b840279 100644 --- a/src/synthorg/settings/definitions/simulations.py +++ b/src/synthorg/settings/definitions/simulations.py @@ -28,6 +28,46 @@ ) ) +_r.register( + SettingDefinition( + namespace=SettingNamespace.SIMULATIONS, + key="intake_strategy", + type=SettingType.ENUM, + default="direct", + enum_values=("direct", "agent"), + description=( + "Intake strategy wired into the client-simulation runtime at" + " boot. 'direct' creates a task per accepted request with no" + " LLM call; 'agent' routes each request through an LLM triage" + " step using the registered completion provider. Baked in at" + " process startup." + ), + group="Intake", + level=SettingLevel.ADVANCED, + read_only_post_init=True, + restart_required=True, + ) +) + +_r.register( + SettingDefinition( + namespace=SettingNamespace.SIMULATIONS, + key="intake_model", + type=SettingType.STRING, + default=None, + description=( + "Model identifier passed to the agent intake strategy. Only" + " consulted when simulations.intake_strategy is 'agent';" + " ignored by the 'direct' strategy. Baked in at process" + " startup." + ), + group="Intake", + level=SettingLevel.ADVANCED, + read_only_post_init=True, + restart_required=True, + ) +) + _r.register( SettingDefinition( namespace=SettingNamespace.SIMULATIONS, diff --git a/tests/e2e/test_client_simulation_boot_wired_e2e.py b/tests/e2e/test_client_simulation_boot_wired_e2e.py new file mode 100644 index 0000000000..0035e155cf --- /dev/null +++ b/tests/e2e/test_client_simulation_boot_wired_e2e.py @@ -0,0 +1,268 @@ +"""Acceptance: the boot-wired client-simulation runtime, end to end. + +Two layers: + +* HTTP: ``create_app`` with a ``TaskEngine`` and NO + ``client_simulation_state`` kwarg boot-wires the real runtime, so + ``/capabilities`` reports the subsystem on and the controllers + register (the empty-company ``direct`` path, no provider). +* Harness: the ``SimulationRunner`` is driven directly against the + runtime that ``build_client_simulation_runtime`` produces (the exact + object ``create_app`` attaches), proving generated requirements flow + through the real ``IntakeEngine`` into the real ``TaskEngine`` with + deterministic, asserted metrics and zero real LLM spend -- under + both the ``direct`` and the scripted ``agent`` strategy. Driving the + runner directly (rather than the fire-and-forget HTTP background + task) keeps the metric assertion deterministic and non-flaky. + +The kwarg-override path stays covered by +``tests/e2e/test_client_simulation_e2e.py``. +""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any + +import pytest +from litestar.testing import TestClient + +from synthorg.api.app import create_app +from synthorg.api.state import AppState +from synthorg.budget.tracker import CostTracker +from synthorg.client.ai_client import AIClient +from synthorg.client.config import SimulationRunnerConfig +from synthorg.client.feedback.binary import BinaryFeedback +from synthorg.client.generators.procedural import ProceduralGenerator +from synthorg.client.models import ClientProfile, SimulationConfig +from synthorg.client.protocols import ClientInterface +from synthorg.client.runner import SimulationRunner +from synthorg.client.runtime_builder import build_client_simulation_runtime +from synthorg.config.schema import RootConfig +from synthorg.engine.intake.strategies import AgentIntake, DirectIntake +from synthorg.engine.task_engine import TaskEngine +from synthorg.providers.drivers.scripted import ScriptedDriver, SingleResponseStrategy +from synthorg.providers.enums import FinishReason +from synthorg.providers.models import CompletionResponse, TokenUsage +from synthorg.providers.registry import ProviderRegistry +from tests._shared import mock_of +from tests.unit.api.conftest import ( + _make_test_auth_service, + _seed_test_users, + make_auth_headers, +) +from tests.unit.api.fakes import FakeMessageBus, FakePersistenceBackend + +pytestmark = pytest.mark.e2e + +_TEST_JWT_SECRET = "integration-test-secret-at-least-32-characters" +_TEST_SETTINGS_KEY = "lKzZcMznksIF8A_2HFFUnKxhxhz9_bxTvVJoZ6mvZrk=" + + +@pytest.fixture(autouse=True) +def _required_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SYNTHORG_JWT_SECRET", _TEST_JWT_SECRET) + monkeypatch.setenv("SYNTHORG_SETTINGS_KEY", _TEST_SETTINGS_KEY) + + +@pytest.fixture +async def fake_persistence() -> AsyncGenerator[FakePersistenceBackend]: + backend = FakePersistenceBackend() + await backend.connect() + yield backend + await backend.disconnect() + + +@pytest.fixture +async def fake_message_bus() -> AsyncGenerator[FakeMessageBus]: + bus = FakeMessageBus() + await bus.start() + yield bus + await bus.stop() + + +@pytest.fixture +async def task_engine( + fake_persistence: FakePersistenceBackend, +) -> AsyncGenerator[TaskEngine]: + engine = TaskEngine(persistence=fake_persistence) + await engine.start() + yield engine + await engine.stop() + + +def _deterministic_client(client_id: str) -> ClientInterface: + """A no-LLM client: procedural generator + binary feedback.""" + profile = ClientProfile( + client_id=client_id, + name="Boot Client", + persona="Deterministic boot-wired simulation operator", + expertise_domains=("backend",), + strictness_level=0.5, + ) + return AIClient( + profile=profile, + generator=ProceduralGenerator(seed=7), + feedback=BinaryFeedback(client_id=client_id), + ) + + +def _accepting_scripted_provider() -> ScriptedDriver: + """Scripted provider whose every completion accepts the request.""" + response = CompletionResponse( + content='{"accepted": true}', + finish_reason=FinishReason.STOP, + usage=TokenUsage(input_tokens=1, output_tokens=1, cost=0.0), + model="test-model-001", + ) + return ScriptedDriver( + "test-provider", + strategy=SingleResponseStrategy(response=response), + ) + + +_SIM_CONFIG = SimulationConfig( + simulation_id="boot-sim", + project_id="boot-project", + rounds=2, + clients_per_round=1, + requirements_per_client=3, +) + + +@pytest.fixture +def direct_client( + fake_persistence: FakePersistenceBackend, + fake_message_bus: FakeMessageBus, + task_engine: TaskEngine, +) -> Generator[TestClient[Any]]: + """Boot-wired app, default ``direct`` intake (no provider).""" + auth_service = _make_test_auth_service() + _seed_test_users(fake_persistence, auth_service) + app = create_app( + config=RootConfig(company_name="boot-direct"), + persistence=fake_persistence, + message_bus=fake_message_bus, + cost_tracker=CostTracker(), + auth_service=auth_service, + task_engine=task_engine, + ) + with TestClient(app) as client: + yield client + + +class TestBootWiredHttpSurface: + """The HTTP surface reflects the boot-wired runtime.""" + + def test_capabilities_and_routes_on( + self, + direct_client: TestClient[Any], + ) -> None: + headers = make_auth_headers("ceo") + caps = direct_client.get("/api/v1/capabilities/", headers=headers) + assert caps.status_code == 200, caps.text + data = caps.json()["data"] + assert data["simulations"] is True + assert data["requests"] is True + # Routes are registered (200, not the 404 of an unwired runtime). + assert ( + direct_client.get("/api/v1/simulations", headers=headers).status_code == 200 + ) + assert direct_client.get("/api/v1/requests", headers=headers).status_code == 200 + + +class TestBootWiredDirectIntakeHarness: + """``SimulationRunner`` against the boot-wired ``direct`` runtime.""" + + async def test_run_drives_requirements_into_real_task_engine( + self, + task_engine: TaskEngine, + ) -> None: + app_state = mock_of[AppState]( + task_engine=task_engine, + has_task_engine=True, + has_active_provider=False, + has_cost_tracker=False, + ) + state = build_client_simulation_runtime(app_state, env={}) + assert state.intake_engine is not None + assert isinstance(state.intake_engine.strategy, DirectIntake) + + runner = SimulationRunner( + config=SimulationRunnerConfig(), + intake_engine=state.intake_engine, + ) + metrics, _ = await runner.run( + sim_config=_SIM_CONFIG, + clients=(_deterministic_client("boot-client"),), + ) + + # 2 rounds x 1 client -> 1 requirement per round (AIClient yields + # the first generated requirement). DirectIntake accepts every + # request, so every requirement becomes a real task; the review + # stage then runs on each created task. + assert metrics.total_requirements == 2, ( + f"expected 2 requirements (2 rounds x 1 client), got " + f"{metrics.total_requirements}" + ) + assert metrics.total_tasks_created == 2, ( + f"intake should create a task per requirement, got " + f"{metrics.total_tasks_created}" + ) + assert metrics.tasks_accepted + metrics.tasks_rejected == 2, ( + f"every created task should be reviewed, got " + f"{metrics.tasks_accepted} accepted + {metrics.tasks_rejected} " + f"rejected" + ) + + +class TestBootWiredAgentIntakeHarness: + """``SimulationRunner`` against the boot-wired scripted ``agent``.""" + + async def test_scripted_agent_intake_accepts_deterministically( + self, + task_engine: TaskEngine, + ) -> None: + registry = ProviderRegistry( + {"test-provider": _accepting_scripted_provider()}, + ) + app_state = mock_of[AppState]( + task_engine=task_engine, + has_task_engine=True, + has_active_provider=True, + provider_registry=registry, + has_cost_tracker=False, + ) + state = build_client_simulation_runtime( + app_state, + env={ + "SYNTHORG_SIMULATIONS_INTAKE_STRATEGY": "agent", + "SYNTHORG_SIMULATIONS_INTAKE_MODEL": "test-model-001", + }, + ) + assert state.intake_engine is not None + assert isinstance(state.intake_engine.strategy, AgentIntake) + + runner = SimulationRunner( + config=SimulationRunnerConfig(), + intake_engine=state.intake_engine, + ) + metrics, _ = await runner.run( + sim_config=_SIM_CONFIG, + clients=(_deterministic_client("agent-client"),), + ) + + # The scripted provider returns ``{"accepted": true}`` for every + # triage call, so AgentIntake accepts every requirement and + # creates a task -- deterministic, zero real LLM spend. + assert metrics.total_requirements == 2, ( + f"expected 2 requirements (2 rounds x 1 client), got " + f"{metrics.total_requirements}" + ) + assert metrics.total_tasks_created == 2, ( + f"intake should create a task per requirement, got " + f"{metrics.total_tasks_created}" + ) + assert metrics.tasks_accepted + metrics.tasks_rejected == 2, ( + f"every created task should be reviewed, got " + f"{metrics.tasks_accepted} accepted + {metrics.tasks_rejected} " + f"rejected" + ) diff --git a/tests/e2e/test_client_simulation_runtime_seam.py b/tests/e2e/test_client_simulation_runtime_seam.py new file mode 100644 index 0000000000..9fa9ed7449 --- /dev/null +++ b/tests/e2e/test_client_simulation_runtime_seam.py @@ -0,0 +1,89 @@ +"""Acceptance: the client-simulation runtime is wired at the seam. + +Drives a real ``ClientRequest`` through the ``IntakeEngine`` built by +the production ``build_client_simulation_runtime`` (the exact code +``create_app`` runs at construction) against a real ``TaskEngine`` -- +not a stub strategy. Proves the gate-relevant construction works: +the default ``direct`` strategy walks a SUBMITTED request to +TASK_CREATED and a real task lands in the task engine, with no +provider configured and zero LLM spend. + +The full HTTP boot path is covered by +``tests/e2e/test_client_simulation_e2e.py``; this isolates the +builder so the assertion is on the runtime itself. +""" + +from collections.abc import AsyncGenerator + +import pytest + +from synthorg.api.state import AppState +from synthorg.client.models import ClientRequest, RequestStatus, TaskRequirement +from synthorg.client.runtime_builder import build_client_simulation_runtime +from synthorg.client.simulation_state import ClientSimulationState +from synthorg.engine.intake.strategies import DirectIntake +from synthorg.engine.task_engine import TaskEngine +from tests._shared import mock_of +from tests.unit.api.fakes import FakePersistenceBackend + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +async def persistence() -> AsyncGenerator[FakePersistenceBackend]: + backend = FakePersistenceBackend() + await backend.connect() + yield backend + await backend.disconnect() + + +@pytest.fixture +async def task_engine( + persistence: FakePersistenceBackend, +) -> AsyncGenerator[TaskEngine]: + engine = TaskEngine(persistence=persistence) + await engine.start() + yield engine + await engine.stop() + + +async def test_builder_wires_intake_engine_to_real_task_engine( + task_engine: TaskEngine, +) -> None: + app_state = mock_of[AppState]( + task_engine=task_engine, + has_task_engine=True, + has_active_provider=False, + has_cost_tracker=False, + ) + + state = build_client_simulation_runtime(app_state, env={}) + + # Gate-relevant construction: a populated runtime state. + assert isinstance(state, ClientSimulationState) + assert state.intake_engine is not None + assert state.review_pipeline is not None + assert state.review_pipeline.stage_names == ("internal",) + # Default strategy is the no-LLM DirectIntake. + assert isinstance(state.intake_engine.strategy, DirectIntake) + + request = ClientRequest( + client_id="seam-client", + requirement=TaskRequirement( + title="Seam feature", + description="Drive a request through the wired intake engine.", + ), + ) + assert request.status is RequestStatus.SUBMITTED + + final, result = await state.intake_engine.process(request) + + # The request walked to the terminal TASK_CREATED state and a real + # task was created in the task engine (not a stubbed id). + assert final.status is RequestStatus.TASK_CREATED + assert result.accepted is True + assert result.task_id is not None + created = await task_engine.get_task(result.task_id) + assert created is not None + assert created.id == result.task_id + assert created.title == "Seam feature" diff --git a/tests/unit/api/controllers/test_capabilities.py b/tests/unit/api/controllers/test_capabilities.py index e0d45171fc..981a5ffc13 100644 --- a/tests/unit/api/controllers/test_capabilities.py +++ b/tests/unit/api/controllers/test_capabilities.py @@ -40,9 +40,12 @@ def test_capabilities_endpoint_returns_full_flag_matrix( assert set(data.keys()) == expected_flags for key in expected_flags: assert isinstance(data[key], bool), key - # Each flag matches the fixture: ontology + tunnel are auto-wired on startup. - assert data["simulations"] is False - assert data["requests"] is False + # The shared test app is built with a TaskEngine, so the + # client-simulation runtime is boot-wired (DirectIntake + + # InternalReviewStage); simulations + requests are therefore + # on. ontology + tunnel are auto-wired on startup. + assert data["simulations"] is True + assert data["requests"] is True assert data["ontology"] is True assert data["tunnel"] is True assert data["webhooks"] is False @@ -50,43 +53,45 @@ def test_capabilities_endpoint_returns_full_flag_matrix( assert data["telemetry"] is False assert data["integrations"] is False - def test_capabilities_reflects_unconfigured_simulations( + def test_capabilities_reflects_wired_simulation_runtime( self, test_client: TestClient[Any], ) -> None: - """Test conftest does not wire client_simulation_state.""" + """A TaskEngine-backed boot wires the simulation runtime. + + ``create_app`` builds the runtime via + ``build_client_simulation_runtime`` whenever a TaskEngine is + present (the shared fixture supplies one), so both the + simulations and requests capability flags are True and the + dashboard knows to poll those endpoints. + """ resp = test_client.get( "/api/v1/capabilities/", headers=_HEADERS, ) assert resp.status_code == 200 data = resp.json()["data"] - # client_simulation_state is not wired in the test fixture so - # both the simulations and requests flags must be False -- the - # dashboard then knows to skip polling those endpoints. - assert data["simulations"] is False - assert data["requests"] is False + assert data["simulations"] is True + assert data["requests"] is True - def test_simulations_route_returns_404_when_unconfigured( + def test_simulations_route_registered_when_runtime_wired( self, test_client: TestClient[Any], ) -> None: - """Unconfigured simulations route does not exist. + """The simulations route is registered once the runtime is wired. - Without ``client_simulation_state`` wired the simulation - controller is not registered at all, so the dashboard's - polling against ``/api/v1/simulations`` lands at 404 (route - not found) instead of 503 (service unavailable). Combined - with the frontend reading ``/capabilities`` to gate polling - in the first place, the 503-spam from the audit log is gone. + With the boot-wired ``client_simulation_state`` the simulation + controller is registered, so ``GET /api/v1/simulations`` + resolves (200 with an empty paginated list) instead of the + 404 returned when no TaskEngine gates the runtime off. """ resp = test_client.get( "/api/v1/simulations", headers=_HEADERS, ) - assert resp.status_code == 404 + assert resp.status_code == 200, resp.text - def test_requests_route_returns_404_when_unconfigured( + def test_requests_route_registered_when_runtime_wired( self, test_client: TestClient[Any], ) -> None: @@ -94,4 +99,4 @@ def test_requests_route_returns_404_when_unconfigured( "/api/v1/requests", headers=_HEADERS, ) - assert resp.status_code == 404 + assert resp.status_code == 200, resp.text diff --git a/tests/unit/client/test_config.py b/tests/unit/client/test_config.py index f7cb0d4ee7..72913a1152 100644 --- a/tests/unit/client/test_config.py +++ b/tests/unit/client/test_config.py @@ -8,6 +8,7 @@ ClientSimulationConfig, ContinuousModeConfig, FeedbackConfig, + IntakeConfig, RequirementGeneratorConfig, SimulationRunnerConfig, ) @@ -111,6 +112,33 @@ def test_interval_positive(self) -> None: ContinuousModeConfig(request_interval_sec=0.0) +class TestIntakeConfig: + """Tests for IntakeConfig.""" + + def test_defaults(self) -> None: + config = IntakeConfig() + assert config.strategy == "direct" + assert config.model is None + + def test_agent_strategy_with_model(self) -> None: + config = IntakeConfig(strategy="agent", model="test-model-001") + assert config.strategy == "agent" + assert config.model == "test-model-001" + + def test_frozen(self) -> None: + config = IntakeConfig() + with pytest.raises(ValidationError): + config.strategy = "agent" # type: ignore[misc] + + def test_extra_forbid(self) -> None: + with pytest.raises(ValidationError): + IntakeConfig(unknown="x") # type: ignore[call-arg] + + def test_blank_strategy_rejected(self) -> None: + with pytest.raises(ValidationError): + IntakeConfig(strategy=" ") + + class TestClientSimulationConfig: """Tests for the top-level ClientSimulationConfig.""" @@ -121,6 +149,8 @@ def test_defaults(self) -> None: assert isinstance(config.feedback, FeedbackConfig) assert isinstance(config.runner, SimulationRunnerConfig) assert isinstance(config.continuous, ContinuousModeConfig) + assert isinstance(config.intake, IntakeConfig) + assert config.intake.strategy == "direct" def test_frozen(self) -> None: config = ClientSimulationConfig() diff --git a/tests/unit/client/test_runtime_builder.py b/tests/unit/client/test_runtime_builder.py new file mode 100644 index 0000000000..6aa456967d --- /dev/null +++ b/tests/unit/client/test_runtime_builder.py @@ -0,0 +1,178 @@ +"""Unit tests for the client-simulation intake factory and builder. + +Covers ``build_intake_strategy`` dispatch (direct / agent / unknown / +agent-missing-collaborators) and ``build_client_simulation_runtime`` +(strategy selection from settings, agent fallback, task-engine gating). +""" + +import pytest +import structlog + +from synthorg.client.config import IntakeConfig +from synthorg.client.factory import UnknownStrategyError, build_intake_strategy +from synthorg.client.simulation_state import ClientSimulationState +from synthorg.engine.intake.strategies import AgentIntake, DirectIntake +from synthorg.engine.review.stages.internal import InternalReviewStage +from synthorg.engine.task_engine import TaskEngine +from synthorg.observability.events.client import CLIENT_SIMULATION_RUNTIME_WIRED +from synthorg.providers.drivers.scripted import ScriptedDriver +from synthorg.providers.registry import ProviderRegistry +from tests._shared import mock_of + +pytestmark = pytest.mark.unit + + +class TestBuildIntakeStrategy: + """Dispatch behaviour of ``build_intake_strategy``.""" + + def test_direct_strategy(self) -> None: + task_engine = mock_of[TaskEngine]() + strategy = build_intake_strategy( + IntakeConfig(strategy="direct"), + task_engine=task_engine, + ) + assert isinstance(strategy, DirectIntake) + + def test_agent_strategy(self) -> None: + task_engine = mock_of[TaskEngine]() + provider = ScriptedDriver("test-provider") + strategy = build_intake_strategy( + IntakeConfig(strategy="agent", model="test-model-001"), + task_engine=task_engine, + provider=provider, + ) + assert isinstance(strategy, AgentIntake) + + def test_unknown_strategy_raises(self) -> None: + task_engine = mock_of[TaskEngine]() + with pytest.raises(UnknownStrategyError): + build_intake_strategy( + IntakeConfig(strategy="nonsense"), + task_engine=task_engine, + ) + + def test_agent_without_provider_raises(self) -> None: + task_engine = mock_of[TaskEngine]() + with pytest.raises(UnknownStrategyError): + build_intake_strategy( + IntakeConfig(strategy="agent", model="test-model-001"), + task_engine=task_engine, + ) + + def test_agent_without_model_raises(self) -> None: + task_engine = mock_of[TaskEngine]() + provider = ScriptedDriver("test-provider") + with pytest.raises(UnknownStrategyError): + build_intake_strategy( + IntakeConfig(strategy="agent"), + task_engine=task_engine, + provider=provider, + ) + + +class TestBuildClientSimulationRuntime: + """Boot-wiring behaviour of ``build_client_simulation_runtime``.""" + + def test_returns_populated_state_with_direct_default(self) -> None: + from synthorg.api.state import AppState + from synthorg.client.runtime_builder import ( + build_client_simulation_runtime, + ) + + task_engine = mock_of[TaskEngine]() + app_state = mock_of[AppState]( + task_engine=task_engine, + has_task_engine=True, + has_active_provider=False, + ) + state = build_client_simulation_runtime(app_state, env={}) + assert isinstance(state, ClientSimulationState) + assert state.intake_engine is not None + assert state.review_pipeline is not None + assert state.review_pipeline.stage_names == ("internal",) + + def test_agent_selected_without_provider_falls_back_to_direct(self) -> None: + from synthorg.api.state import AppState + from synthorg.client.runtime_builder import ( + build_client_simulation_runtime, + ) + + task_engine = mock_of[TaskEngine]() + app_state = mock_of[AppState]( + task_engine=task_engine, + has_task_engine=True, + has_active_provider=False, + ) + with structlog.testing.capture_logs() as cap: + state = build_client_simulation_runtime( + app_state, + env={"SYNTHORG_SIMULATIONS_INTAKE_STRATEGY": "agent"}, + ) + assert state.intake_engine is not None + assert isinstance(state.intake_engine.strategy, DirectIntake) + # The degradation must be observable: a WARNING naming the + # requested vs effective strategy, not a silent swap. + degrade = [ + e + for e in cap + if e.get("event") == CLIENT_SIMULATION_RUNTIME_WIRED + and e.get("log_level") == "warning" + ] + assert degrade, "agent->direct degradation did not emit a WARNING" + assert degrade[0]["requested_strategy"] == "agent" + assert degrade[0]["effective_strategy"] == "direct" + + def test_agent_selected_with_provider_uses_agent_intake(self) -> None: + from synthorg.api.state import AppState + from synthorg.client.runtime_builder import ( + build_client_simulation_runtime, + ) + + task_engine = mock_of[TaskEngine]() + provider = ScriptedDriver("test-provider") + registry = ProviderRegistry({"test-provider": provider}) + app_state = mock_of[AppState]( + task_engine=task_engine, + has_task_engine=True, + has_active_provider=True, + provider_registry=registry, + ) + state = build_client_simulation_runtime( + app_state, + env={ + "SYNTHORG_SIMULATIONS_INTAKE_STRATEGY": "agent", + "SYNTHORG_SIMULATIONS_INTAKE_MODEL": "test-model-001", + }, + ) + assert state.intake_engine is not None + assert isinstance(state.intake_engine.strategy, AgentIntake) + + def test_default_strategy_build_failure_propagates( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A failed *default* strategy build is a real bug: it must not + be swallowed by the agent->direct degrade path.""" + from synthorg.api.state import AppState + from synthorg.client import runtime_builder + + def _boom(*_args: object, **_kwargs: object) -> object: + msg = "forced direct-strategy build failure" + raise UnknownStrategyError(msg) + + monkeypatch.setattr(runtime_builder, "build_intake_strategy", _boom) + app_state = mock_of[AppState]( + task_engine=mock_of[TaskEngine](), + has_task_engine=True, + has_active_provider=False, + ) + # Default strategy is "direct"; the except branch must re-raise + # rather than recurse into a fallback. + with pytest.raises(UnknownStrategyError): + runtime_builder.build_client_simulation_runtime(app_state, env={}) + + +def test_internal_stage_name_contract() -> None: + # The builder asserts on this name; lock it so a rename of the + # stage cannot silently change the boot pipeline shape. + assert InternalReviewStage().name == "internal" diff --git a/tests/unit/ontology/test_lazy_exports.py b/tests/unit/ontology/test_lazy_exports.py new file mode 100644 index 0000000000..d50e7f39c1 --- /dev/null +++ b/tests/unit/ontology/test_lazy_exports.py @@ -0,0 +1,46 @@ +"""PEP 562 lazy-export contract for ``synthorg.ontology``. + +``OntologyService`` / ``OntologyEntityRepository`` are exported via +``__getattr__`` to keep the heavy persistence/security graph off the +ontology package-import path (breaks a cross-package cycle). These +tests lock the resolve-and-cache behaviour and the unknown-name +contract so a regression in the lazy machinery fails loudly. +""" + +import pytest + +from synthorg import ontology + +pytestmark = pytest.mark.unit + + +def test_unknown_attribute_raises_attribute_error() -> None: + with pytest.raises(AttributeError, match="has no attribute"): + _ = ontology.NotARealOntologyExport + + +def test_lazy_export_resolves_and_caches() -> None: + from synthorg.ontology.service import OntologyService as _Direct + + resolved = ontology.OntologyService + assert resolved is _Direct + # Second access is served from the module ``globals()`` cache, so + # it must return the identical object (proves the cache write). + assert ontology.OntologyService is resolved + assert "OntologyService" in vars(ontology) + + +def test_lazy_export_entity_repository_resolves() -> None: + from synthorg.persistence.ontology_protocol import ( + OntologyEntityRepository as _Direct, + ) + + assert ontology.OntologyEntityRepository is _Direct + + +def test_dir_lists_lazy_names() -> None: + names = dir(ontology) + assert "OntologyService" in names + assert "OntologyEntityRepository" in names + # __dir__ returns the sorted public surface. + assert names == sorted(names) diff --git a/tests/unit/settings/test_intake_settings.py b/tests/unit/settings/test_intake_settings.py new file mode 100644 index 0000000000..e8f0494dc5 --- /dev/null +++ b/tests/unit/settings/test_intake_settings.py @@ -0,0 +1,97 @@ +"""Coverage for the ``simulations`` intake settings. + +``simulations.intake_strategy`` / ``simulations.intake_model`` are +read at app construction (boot) via the bootstrap resolver, so they +are ``read_only_post_init`` (mutation through ``SettingsService.set`` +is rejected) yet still resolve env > default through the standard +chain. +""" + +from unittest.mock import AsyncMock + +import pytest + +from synthorg.persistence.settings_protocol import SettingsRepository +from synthorg.settings import definitions as _settings_definitions # noqa: F401 +from synthorg.settings.bootstrap_resolver import resolve_init_value +from synthorg.settings.enums import SettingNamespace, SettingSource, SettingType +from synthorg.settings.registry import get_registry +from synthorg.settings.service import SettingsService +from tests._shared import mock_of + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def service() -> SettingsService: + repo = mock_of[SettingsRepository]( + get=AsyncMock(spec=SettingsRepository.get, return_value=None), + get_namespace=AsyncMock(spec=SettingsRepository.get_namespace, return_value=()), + list_items=AsyncMock(spec=SettingsRepository.list_items, return_value=()), + ) + return SettingsService(repository=repo, registry=get_registry()) + + +def test_intake_strategy_registered() -> None: + defn = get_registry().get("simulations", "intake_strategy") + assert defn is not None + assert defn.type is SettingType.ENUM + assert defn.default == "direct" + assert defn.enum_values == ("direct", "agent") + assert defn.read_only_post_init is True + assert defn.restart_required is True + + +def test_intake_model_registered() -> None: + defn = get_registry().get("simulations", "intake_model") + assert defn is not None + assert defn.type is SettingType.STRING + assert defn.default is None + assert defn.read_only_post_init is True + assert defn.restart_required is True + + +async def test_intake_strategy_default_resolves(service: SettingsService) -> None: + result = await service.get("simulations", "intake_strategy") + assert result.value == "direct" + assert result.source is SettingSource.DEFAULT + + +def test_intake_strategy_bootstrap_default() -> None: + resolved = resolve_init_value( + SettingNamespace.SIMULATIONS, + "intake_strategy", + env={}, + ) + assert resolved.value == "direct" + assert resolved.source is SettingSource.DEFAULT + + +def test_intake_strategy_bootstrap_env_override() -> None: + resolved = resolve_init_value( + SettingNamespace.SIMULATIONS, + "intake_strategy", + env={"SYNTHORG_SIMULATIONS_INTAKE_STRATEGY": "agent"}, + ) + assert resolved.value == "agent" + assert resolved.source is SettingSource.ENVIRONMENT + + +def test_intake_model_bootstrap_default_is_empty() -> None: + resolved = resolve_init_value( + SettingNamespace.SIMULATIONS, + "intake_model", + env={}, + ) + assert resolved.value == "" + assert resolved.source is SettingSource.DEFAULT + + +def test_intake_model_bootstrap_env_override() -> None: + resolved = resolve_init_value( + SettingNamespace.SIMULATIONS, + "intake_model", + env={"SYNTHORG_SIMULATIONS_INTAKE_MODEL": "test-model-001"}, + ) + assert resolved.value == "test-model-001" + assert resolved.source is SettingSource.ENVIRONMENT diff --git a/web/src/api/endpoints/clients.ts b/web/src/api/endpoints/clients.ts index c9d97c9b36..399afedb12 100644 --- a/web/src/api/endpoints/clients.ts +++ b/web/src/api/endpoints/clients.ts @@ -2,107 +2,64 @@ import axios from 'axios' import { apiClient, unwrap, unwrapPaginated, type PaginatedResult } from '../client' import type { ApiResponse, PaginatedResponse, PaginationParams } from '../types/http' - -// ── Types ─────────────────────────────────────────────────────── - -export interface ClientProfile { - client_id: string - name: string - persona: string - expertise_domains: readonly string[] - strictness_level: number -} - -export interface TaskRequirement { - title: string - description: string - task_type: string - priority: string - estimated_complexity: string - acceptance_criteria?: readonly string[] -} - -export type RequestStatus = - | 'submitted' - | 'triaging' - | 'scoping' - | 'approved' - | 'task_created' - | 'cancelled' - -export interface ClientRequest { - request_id: string - client_id: string - requirement: TaskRequirement - status: RequestStatus - created_at: string - metadata: Record -} - -export interface SimulationConfig { - simulation_id: string - project_id: string - rounds: number - clients_per_round: number - requirements_per_client: number -} - -export interface SimulationMetrics { - total_requirements: number - total_tasks_created: number - tasks_accepted: number - tasks_rejected: number - tasks_reworked: number - avg_review_rounds: number - round_metrics: readonly Record[] - acceptance_rate: number - rework_rate: number -} - -export interface SimulationStatus { +import type { + ClientProfile, + ClientRequest, + CreateClientRequest, + CreateRequestPayload, + PipelineResult, + RequestStatus, + ReviewStageResult, + SatisfactionHistory, + ScopingPayload, + SimulationConfig, + SimulationStatusResponse, + StageDecisionPayload, + StageDecisionResult, + UpdateClientRequest, +} from '@/api/types' + +// DTO shapes are owned by the generated barrel (`@/api/types`, +// regenerated from the backend OpenAPI schema). Re-export the ones +// callers consume so the import site stays `@/api/endpoints/clients` +// without hand-maintaining the shapes here. +export type { + ClientProfile, + ClientRequest, + CreateClientRequest, + CreateRequestPayload, + PipelineResult, + RequestStatus, + ReviewStageResult, + SatisfactionHistory, + SatisfactionPoint, + ScopingPayload, + SimulationConfig, + SimulationMetrics, + SimulationStatusResponse, + StageDecisionPayload, + StageDecisionResult, + TaskRequirement, + UpdateClientRequest, +} from '@/api/types' + +// Derived from the generated stage result; not a hand-maintained +// duplicate (the verdict literal union has no standalone DTO). +export type StageVerdict = ReviewStageResult['verdict'] + +// The report endpoint returns a transport-shaped dict (no Pydantic +// DTO), so this view shape is intentionally local, not generated. +export interface SimulationReport { + format: string simulation_id: string status: string - config: SimulationConfig - metrics: SimulationMetrics - progress: number - started_at: string | null - completed_at: string | null - error: string | null -} - -export interface ReviewStageResult { - stage_name: string - verdict: 'pass' | 'fail' | 'skip' - reason: string | null - duration_ms: number - metadata: Record -} - -export interface PipelineResult { - task_id: string - final_verdict: 'pass' | 'fail' | 'skip' - stage_results: readonly ReviewStageResult[] - total_duration_ms: number - reviewed_at: string + totals: Record + rates: Record + [key: string]: unknown } // ── Clients ───────────────────────────────────────────────────── -export interface CreateClientRequestBody { - client_id: string - name: string - persona: string - expertise_domains?: readonly string[] - strictness_level?: number -} - -export interface UpdateClientRequestBody { - name?: string - persona?: string - expertise_domains?: readonly string[] - strictness_level?: number -} - export async function listClients( params?: PaginationParams, ): Promise> { @@ -121,7 +78,7 @@ export async function getClient(clientId: string): Promise { } export async function createClient( - data: CreateClientRequestBody, + data: CreateClientRequest, ): Promise { const response = await apiClient.post>( '/clients/', @@ -132,7 +89,7 @@ export async function createClient( export async function updateClient( clientId: string, - data: UpdateClientRequestBody, + data: UpdateClientRequest, ): Promise { const response = await apiClient.patch>( `/clients/${encodeURIComponent(clientId)}`, @@ -145,22 +102,6 @@ export async function deleteClient(clientId: string): Promise { await apiClient.delete(`/clients/${encodeURIComponent(clientId)}`) } -export interface SatisfactionPoint { - feedback_id: string - task_id: string - accepted: boolean - score: number - created_at: string -} - -export interface SatisfactionHistory { - client_id: string - total_reviews: number - acceptance_rate: number - average_score: number - history: readonly SatisfactionPoint[] -} - export async function getClientSatisfaction( clientId: string, ): Promise { @@ -172,11 +113,6 @@ export async function getClientSatisfaction( // ── Requests ──────────────────────────────────────────────────── -export interface SubmitRequestBody { - client_id: string - requirement: TaskRequirement -} - export async function listRequests( params?: PaginationParams & { status?: RequestStatus }, ): Promise> { @@ -195,7 +131,7 @@ export async function getRequest(requestId: string): Promise { } export async function submitRequest( - data: SubmitRequestBody, + data: CreateRequestPayload, ): Promise { const response = await apiClient.post>( '/requests/', @@ -222,16 +158,9 @@ export async function rejectRequest( return unwrap(response) } -export interface ScopeRequestBody { - notes: string - refined_title?: string - refined_description?: string - refined_acceptance_criteria?: readonly string[] -} - export async function scopeRequest( requestId: string, - data: ScopeRequestBody, + data: ScopingPayload, ): Promise { const response = await apiClient.post>( `/requests/${encodeURIComponent(requestId)}/scope`, @@ -244,18 +173,17 @@ export async function scopeRequest( export async function listSimulations( params?: PaginationParams, -): Promise> { - const response = await apiClient.get>( - '/simulations', - { params }, - ) - return unwrapPaginated(response) +): Promise> { + const response = await apiClient.get< + PaginatedResponse + >('/simulations', { params }) + return unwrapPaginated(response) } export async function getSimulation( simulationId: string, -): Promise { - const response = await apiClient.get>( +): Promise { + const response = await apiClient.get>( `/simulations/${encodeURIComponent(simulationId)}`, ) return unwrap(response) @@ -276,12 +204,11 @@ function configsEqual(a: SimulationConfig, b: SimulationConfig): boolean { export async function startSimulation( config: SimulationConfig, -): Promise { +): Promise { try { - const response = await apiClient.post>( - '/simulations/', - { config }, - ) + const response = await apiClient.post< + ApiResponse + >('/simulations/', { config }) return unwrap(response) } catch (err) { // The backend returns HTTP 409 when a simulation with @@ -304,22 +231,13 @@ export async function startSimulation( export async function cancelSimulation( simulationId: string, -): Promise { - const response = await apiClient.post>( +): Promise { + const response = await apiClient.post>( `/simulations/${encodeURIComponent(simulationId)}/cancel`, ) return unwrap(response) } -export interface SimulationReport { - format: string - simulation_id: string - status: string - totals: Record - rates: Record - [key: string]: unknown -} - export async function getSimulationReport( simulationId: string, fmt: 'summary' | 'detailed' = 'summary', @@ -342,31 +260,17 @@ export async function getReviewPipeline( return unwrap(response) } -export type StageVerdict = 'pass' | 'fail' | 'skip' - -export interface StageDecisionBody { - verdict: StageVerdict - reason?: string -} - -export interface StageDecisionResult { - task_id: string - stage_name: string - stage_result: ReviewStageResult - pipeline_result: PipelineResult -} - export async function decideReviewStage( taskId: string, stageName: string, - data: StageDecisionBody, + data: StageDecisionPayload, ): Promise { // The backend treats `reason` as NotBlankStr | None: an empty or // whitespace-only string fails Pydantic validation. Trim and // coerce to undefined so callers can pass raw form state without // tripping a 422 at the API boundary. const trimmedReason = data.reason?.trim() - const payload: StageDecisionBody = { + const payload: StageDecisionPayload = { verdict: data.verdict, ...(trimmedReason ? { reason: trimmedReason } : {}), } diff --git a/web/src/api/types/dtos.gen.ts b/web/src/api/types/dtos.gen.ts index d65ef36360..b062d03b1d 100644 --- a/web/src/api/types/dtos.gen.ts +++ b/web/src/api/types/dtos.gen.ts @@ -46,6 +46,7 @@ export type CapabilitiesResponseEnvelope = components['schemas']['ApiResponse_Ca export type CatalogEntryEnvelope = components['schemas']['ApiResponse_CatalogEntry_'] export type CheckpointRecordEnvelope = components['schemas']['ApiResponse_CheckpointRecord_'] export type ClientProfileEnvelope = components['schemas']['ApiResponse_ClientProfile_'] +export type ClientRequestEnvelope = components['schemas']['ApiResponse_ClientRequest_'] export type CollaborationScoreResultEnvelope = components['schemas']['ApiResponse_CollaborationScoreResult_'] export type ConnectionEnvelope = components['schemas']['ApiResponse_Connection_'] export type CookieSessionResponseEnvelope = components['schemas']['ApiResponse_CookieSessionResponse_'] @@ -93,6 +94,7 @@ export type SetupCompanyResponseEnvelope = components['schemas']['ApiResponse_Se export type SetupCompleteResponseEnvelope = components['schemas']['ApiResponse_SetupCompleteResponse_'] export type SetupNameLocalesResponseEnvelope = components['schemas']['ApiResponse_SetupNameLocalesResponse_'] export type SetupStatusResponseEnvelope = components['schemas']['ApiResponse_SetupStatusResponse_'] +export type SimulationStatusResponseEnvelope = components['schemas']['ApiResponse_SimulationStatusResponse_'] export type StageDecisionResultEnvelope = components['schemas']['ApiResponse_StageDecisionResult_'] export type SyncModelsResponseEnvelope = components['schemas']['ApiResponse_SyncModelsResponse_'] export type TaskEnvelope = components['schemas']['ApiResponse_Task_'] @@ -157,6 +159,7 @@ export type ChatRequest = components['schemas']['ChatRequest'] export type CheckpointRecord = components['schemas']['CheckpointRecord'] export type ClarificationGateConfig = components['schemas']['ClarificationGateConfig'] export type ClientProfile = components['schemas']['ClientProfile'] +export type ClientRequest = components['schemas']['ClientRequest'] export type CloudPreset = components['schemas']['CloudPreset'] export type CollaborationScoreResult = components['schemas']['CollaborationScoreResult'] export type Company = components['schemas']['Company'] @@ -188,6 +191,7 @@ export type CreateFromPresetRequest = components['schemas']['CreateFromPresetReq export type CreatePresetRequest = components['schemas']['CreatePresetRequest'] export type CreateProjectRequest = components['schemas']['CreateProjectRequest'] export type CreateProviderRequest = components['schemas']['CreateProviderRequest'] +export type CreateRequestPayload = components['schemas']['CreateRequestPayload'] export type CreateSubworkflowRequest = components['schemas']['CreateSubworkflowRequest'] export type CreateTaskRequest = components['schemas']['CreateTaskRequest'] export type CreateTeamRequest = components['schemas']['CreateTeamRequest'] @@ -283,6 +287,7 @@ export type CatalogEntryPage = components['schemas']['PaginatedResponse_CatalogE export type ChannelPage = components['schemas']['PaginatedResponse_Channel_'] export type CheckpointRecordPage = components['schemas']['PaginatedResponse_CheckpointRecord_'] export type ClientProfilePage = components['schemas']['PaginatedResponse_ClientProfile_'] +export type ClientRequestPage = components['schemas']['PaginatedResponse_ClientRequest_'] export type ConnectionPage = components['schemas']['PaginatedResponse_Connection_'] export type CoordinationMetricsRecordPage = components['schemas']['PaginatedResponse_CoordinationMetricsRecord_'] export type DepartmentPage = components['schemas']['PaginatedResponse_Department_'] @@ -308,6 +313,7 @@ export type ScalingSignalResponsePage = components['schemas']['PaginatedResponse export type ScalingStrategyResponsePage = components['schemas']['PaginatedResponse_ScalingStrategyResponse_'] export type SettingEntryPage = components['schemas']['PaginatedResponse_SettingEntry_'] export type SetupAgentSummaryPage = components['schemas']['PaginatedResponse_SetupAgentSummary_'] +export type SimulationStatusResponsePage = components['schemas']['PaginatedResponse_SimulationStatusResponse_'] export type SinkInfoResponsePage = components['schemas']['PaginatedResponse_SinkInfoResponse_'] export type SubworkflowSummaryPage = components['schemas']['PaginatedResponse_SubworkflowSummary_'] export type TaskPage = components['schemas']['PaginatedResponse_Task_'] @@ -360,6 +366,7 @@ export type RedundancyRate = components['schemas']['RedundancyRate'] export type RegisterExperimentVariantRequest = components['schemas']['RegisterExperimentVariantRequest'] export type RejectDecision = components['schemas']['RejectDecision'] export type RejectRequest = components['schemas']['RejectRequest'] +export type RejectionPayload = components['schemas']['RejectionPayload'] export type RemoveAllowlistEntryRequest = components['schemas']['RemoveAllowlistEntryRequest'] export type ReorderAgentsRequest = components['schemas']['ReorderAgentsRequest'] export type ReorderDepartmentsRequest = components['schemas']['ReorderDepartmentsRequest'] @@ -385,6 +392,7 @@ export type SatisfactionPoint = components['schemas']['SatisfactionPoint'] export type ScalingDecisionResponse = components['schemas']['ScalingDecisionResponse'] export type ScalingSignalResponse = components['schemas']['ScalingSignalResponse'] export type ScalingStrategyResponse = components['schemas']['ScalingStrategyResponse'] +export type ScopingPayload = components['schemas']['ScopingPayload'] export type SecretRef = components['schemas']['SecretRef'] export type SecurityConfigExportResponse = components['schemas']['SecurityConfigExportResponse'] export type SecurityConfigImportRequest = components['schemas']['SecurityConfigImportRequest'] @@ -403,12 +411,16 @@ export type SetupNameLocalesRequest = components['schemas']['SetupNameLocalesReq export type SetupNameLocalesResponse = components['schemas']['SetupNameLocalesResponse'] export type SetupRequest = components['schemas']['SetupRequest'] export type SetupStatusResponse = components['schemas']['SetupStatusResponse'] +export type SimulationConfig = components['schemas']['SimulationConfig'] +export type SimulationMetrics = components['schemas']['SimulationMetrics'] +export type SimulationStatusResponse = components['schemas']['SimulationStatusResponse'] export type SinkInfoResponse = components['schemas']['SinkInfoResponse'] export type SinkRotationResponse = components['schemas']['SinkRotationResponse'] export type Skill = components['schemas']['Skill'] export type SkillSet = components['schemas']['SkillSet'] export type StageDecisionPayload = components['schemas']['StageDecisionPayload'] export type StageDecisionResult = components['schemas']['StageDecisionResult'] +export type StartSimulationPayload = components['schemas']['StartSimulationPayload'] export type StragglerGap = components['schemas']['StragglerGap'] export type StrategyUpdateRequest = components['schemas']['StrategyUpdateRequest'] export type SubmitDecisionRequest = components['schemas']['SubmitDecisionRequest'] @@ -416,6 +428,7 @@ export type SubworkflowSummary = components['schemas']['SubworkflowSummary'] export type SyncModelsRequest = components['schemas']['SyncModelsRequest'] export type SyncModelsResponse = components['schemas']['SyncModelsResponse'] export type Task = components['schemas']['Task'] +export type TaskRequirement = components['schemas']['TaskRequirement'] export type Team = components['schemas']['Team'] export type TeamResponse = components['schemas']['TeamResponse'] export type TemplateInfoResponse = components['schemas']['TemplateInfoResponse'] diff --git a/web/src/api/types/enum-values.gen.ts b/web/src/api/types/enum-values.gen.ts index fcb1e51a20..64299ad83a 100644 --- a/web/src/api/types/enum-values.gen.ts +++ b/web/src/api/types/enum-values.gen.ts @@ -522,6 +522,16 @@ export const REPORT_PERIOD_VALUES = [ ] as const export type ReportPeriod = (typeof REPORT_PERIOD_VALUES)[number] +export const REQUEST_STATUS_VALUES = [ + 'submitted', + 'triaging', + 'scoping', + 'approved', + 'task_created', + 'cancelled', +] as const +export type RequestStatus = (typeof REQUEST_STATUS_VALUES)[number] + export const RESUME_DECISION_VALUES = [ 'approve', 'reject', diff --git a/web/src/api/types/openapi.gen.ts b/web/src/api/types/openapi.gen.ts index 15ce40510f..e3444e8ee0 100644 --- a/web/src/api/types/openapi.gen.ts +++ b/web/src/api/types/openapi.gen.ts @@ -3059,6 +3059,92 @@ export type paths = { readonly patch?: never; readonly trace?: never; }; + readonly "/api/v1/requests": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** ListRequests */ + readonly get: operations["ApiV1RequestsListRequests"]; + readonly put?: never; + /** SubmitRequest */ + readonly post: operations["ApiV1RequestsSubmitRequest"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/api/v1/requests/{request_id}": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** GetRequest */ + readonly get: operations["ApiV1RequestsRequestIdGetRequest"]; + readonly put?: never; + readonly post?: never; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/api/v1/requests/{request_id}/approve": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** ApproveRequest */ + readonly post: operations["ApiV1RequestsRequestIdApproveApproveRequest"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/api/v1/requests/{request_id}/reject": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** RejectRequest */ + readonly post: operations["ApiV1RequestsRequestIdRejectRejectRequest"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/api/v1/requests/{request_id}/scope": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** ScopeRequest */ + readonly post: operations["ApiV1RequestsRequestIdScopeScopeRequest"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; readonly "/api/v1/reviews/{task_id}/pipeline": { readonly parameters: { readonly query?: never; @@ -3623,6 +3709,75 @@ export type paths = { readonly patch?: never; readonly trace?: never; }; + readonly "/api/v1/simulations": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** ListSimulations */ + readonly get: operations["ApiV1SimulationsListSimulations"]; + readonly put?: never; + /** StartSimulation */ + readonly post: operations["ApiV1SimulationsStartSimulation"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/api/v1/simulations/{simulation_id}": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** GetSimulation */ + readonly get: operations["ApiV1SimulationsSimulationIdGetSimulation"]; + readonly put?: never; + readonly post?: never; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/api/v1/simulations/{simulation_id}/cancel": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** CancelSimulation */ + readonly post: operations["ApiV1SimulationsSimulationIdCancelCancelSimulation"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/api/v1/simulations/{simulation_id}/report": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** GetReport */ + readonly get: operations["ApiV1SimulationsSimulationIdReportGetReport"]; + readonly put?: never; + readonly post?: never; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; readonly "/api/v1/subworkflows": { readonly parameters: { readonly query?: never; @@ -4754,6 +4909,14 @@ export type components = { /** @description Whether the request succeeded (derived from ``error``). */ readonly success: boolean; }; + /** ApiResponse[ClientRequest] */ + readonly ApiResponse_ClientRequest_: { + readonly data: components["schemas"]["ClientRequest"] | null; + readonly error: string | null; + readonly error_detail: components["schemas"]["ErrorDetail"] | null; + /** @description Whether the request succeeded (derived from ``error``). */ + readonly success: boolean; + }; /** ApiResponse[CollaborationScoreResult] */ readonly ApiResponse_CollaborationScoreResult_: { readonly data: components["schemas"]["CollaborationScoreResult"] | null; @@ -5198,6 +5361,14 @@ export type components = { /** @description Whether the request succeeded (derived from ``error``). */ readonly success: boolean; }; + /** ApiResponse[SimulationStatusResponse] */ + readonly ApiResponse_SimulationStatusResponse_: { + readonly data: components["schemas"]["SimulationStatusResponse"] | null; + readonly error: string | null; + readonly error_detail: components["schemas"]["ErrorDetail"] | null; + /** @description Whether the request succeeded (derived from ``error``). */ + readonly success: boolean; + }; /** ApiResponse[StageDecisionResult] */ readonly ApiResponse_StageDecisionResult_: { readonly data: components["schemas"]["StageDecisionResult"] | null; @@ -6162,6 +6333,24 @@ export type components = { */ readonly strictness_level: number; }; + /** ClientRequest */ + readonly ClientRequest: { + /** @description ID of the submitting client */ + readonly client_id: string; + /** + * Format: date-time + * @description Timestamp of request creation + */ + readonly created_at: string; + /** @description Additional request metadata */ + readonly metadata: { + readonly [key: string]: unknown; + }; + /** @description Unique request identifier */ + readonly request_id: string; + readonly requirement: components["schemas"]["TaskRequirement"]; + readonly status: components["schemas"]["RequestStatus"]; + }; /** CloudPreset */ readonly CloudPreset: { readonly auth_type: components["schemas"]["AuthType"]; @@ -6830,6 +7019,12 @@ export type components = { /** @default false */ readonly tos_accepted: boolean; }; + /** CreateRequestPayload */ + readonly CreateRequestPayload: { + /** @description Requesting client id */ + readonly client_id: string; + readonly requirement: components["schemas"]["TaskRequirement"]; + }; /** CreateSubworkflowRequest */ readonly CreateSubworkflowRequest: { /** @default */ @@ -8674,6 +8869,21 @@ export type components = { /** @description Whether the request succeeded (derived from ``error``). */ readonly success: boolean; }; + /** PaginatedResponse[ClientRequest] */ + readonly PaginatedResponse_ClientRequest_: { + /** @default [] */ + readonly data: readonly components["schemas"]["ClientRequest"][]; + /** + * @description Data sources that failed gracefully (partial data) + * @default [] + */ + readonly degraded_sources: readonly string[]; + readonly error: string | null; + readonly error_detail: components["schemas"]["ErrorDetail"] | null; + readonly pagination: components["schemas"]["PaginationMeta"]; + /** @description Whether the request succeeded (derived from ``error``). */ + readonly success: boolean; + }; /** PaginatedResponse[Connection] */ readonly PaginatedResponse_Connection_: { /** @default [] */ @@ -9083,6 +9293,21 @@ export type components = { /** @description Whether the request succeeded (derived from ``error``). */ readonly success: boolean; }; + /** PaginatedResponse[SimulationStatusResponse] */ + readonly PaginatedResponse_SimulationStatusResponse_: { + /** @default [] */ + readonly data: readonly components["schemas"]["SimulationStatusResponse"][]; + /** + * @description Data sources that failed gracefully (partial data) + * @default [] + */ + readonly degraded_sources: readonly string[]; + readonly error: string | null; + readonly error_detail: components["schemas"]["ErrorDetail"] | null; + readonly pagination: components["schemas"]["PaginationMeta"]; + /** @description Whether the request succeeded (derived from ``error``). */ + readonly success: boolean; + }; /** PaginatedResponse[SinkInfoResponse] */ readonly PaginatedResponse_SinkInfoResponse_: { /** @default [] */ @@ -9955,6 +10180,11 @@ export type components = { */ readonly type: "reject"; }; + /** RejectionPayload */ + readonly RejectionPayload: { + /** @description Reason for rejection */ + readonly reason: string; + }; /** RejectRequest */ readonly RejectRequest: { readonly reason: string; @@ -10039,6 +10269,21 @@ export type components = { */ readonly start: string; }; + /** + * RequestStatus + * @description Lifecycle status of a client request through the intake pipeline. + * + * Independent from ``TaskStatus`` -- tracks the intake process + * before a task is created in the task engine. + * + * Transitions:: + * + * SUBMITTED -> TRIAGING -> SCOPING -> APPROVED -> TASK_CREATED + * Any non-terminal state -> CANCELLED + * @default submitted + * @enum {string} + */ + readonly RequestStatus: "submitted" | "triaging" | "scoping" | "approved" | "task_created" | "cancelled"; /** ResilienceConfig */ readonly ResilienceConfig: { /** @default true */ @@ -10368,6 +10613,14 @@ export type components = { /** @description Priority rank */ readonly priority: number; }; + /** ScopingPayload */ + readonly ScopingPayload: { + /** @description Scoping notes from the reviewer */ + readonly notes: string; + readonly refined_acceptance_criteria?: readonly string[] | null; + readonly refined_description?: string | null; + readonly refined_title?: string | null; + }; /** SecretRef */ readonly SecretRef: { readonly backend: string; @@ -10660,6 +10913,73 @@ export type components = { readonly needs_admin: boolean; readonly needs_setup: boolean; }; + /** SimulationConfig */ + readonly SimulationConfig: { + /** + * @description Clients participating per round + * @default 5 + */ + readonly clients_per_round: number; + /** @description Project to simulate against */ + readonly project_id: string; + /** + * @description Requirements each client generates per round + * @default 1 + */ + readonly requirements_per_client: number; + /** + * @description Number of simulation rounds + * @default 1 + */ + readonly rounds: number; + /** @description Unique simulation identifier */ + readonly simulation_id: string; + }; + /** SimulationMetrics */ + readonly SimulationMetrics: { + /** @description Proportion of accepted tasks (0.0-1.0) */ + readonly acceptance_rate: number; + /** @default 0 */ + readonly avg_review_rounds: number; + /** @description Proportion of reworked tasks (0.0-1.0) */ + readonly rework_rate: number; + /** + * @description Per-round metric snapshots + * @default [] + */ + readonly round_metrics: readonly { + readonly [key: string]: unknown; + }[]; + /** @default 0 */ + readonly tasks_accepted: number; + /** @default 0 */ + readonly tasks_rejected: number; + /** @default 0 */ + readonly tasks_reworked: number; + /** @default 0 */ + readonly total_requirements: number; + /** @default 0 */ + readonly total_tasks_created: number; + }; + /** SimulationStatusResponse */ + readonly SimulationStatusResponse: { + /** + * Format: date-time + * @description datetime with the constraint that the value must have timezone info + */ + readonly completed_at: string | null; + readonly config: components["schemas"]["SimulationConfig"]; + readonly error: string | null; + readonly metrics: components["schemas"]["SimulationMetrics"]; + readonly progress: number; + readonly simulation_id: string; + /** + * Format: date-time + * @description datetime with the constraint that the value must have timezone info + */ + readonly started_at: string | null; + readonly status: string; + }; /** SinkInfoResponse */ readonly SinkInfoResponse: { readonly enabled: boolean; @@ -10764,6 +11084,10 @@ export type components = { readonly stage_result: components["schemas"]["ReviewStageResult"]; readonly task_id: string; }; + /** StartSimulationPayload */ + readonly StartSimulationPayload: { + readonly config: components["schemas"]["SimulationConfig"]; + }; /** StragglerGap */ readonly StragglerGap: { /** @description Relative gap (gap / mean) */ @@ -10914,6 +11238,24 @@ export type components = { readonly title: string; readonly type: components["schemas"]["TaskType"]; }; + /** + * TaskRequirement + * @description The task requirement being requested + */ + readonly TaskRequirement: { + /** + * @description Criteria for task acceptance + * @default [] + */ + readonly acceptance_criteria: readonly string[]; + /** @description Detailed requirement description */ + readonly description: string; + readonly estimated_complexity: components["schemas"]["Complexity"]; + readonly priority: components["schemas"]["Priority"]; + readonly task_type: components["schemas"]["TaskType"]; + /** @description Short requirement title */ + readonly title: string; + }; /** * TaskSource * @description Origin of a task within the system. @@ -10961,6 +11303,7 @@ export type components = { /** * TaskType * @description Classification of the kind of work a task represents. + * @default development * @enum {string} */ readonly TaskType: "development" | "design" | "research" | "review" | "meeting" | "admin"; @@ -19194,13 +19537,18 @@ export interface operations { readonly 503: components["responses"]["ServiceUnavailable"]; }; }; - readonly ApiV1ReviewsTaskIdPipelineGetPipeline: { + readonly ApiV1RequestsListRequests: { readonly parameters: { - readonly query?: never; - readonly header?: never; - readonly path: { - readonly task_id: string; + readonly query?: { + /** @description Opaque pagination cursor returned by the previous page */ + readonly cursor?: string | null; + /** @description Page size (default 50, max 200) */ + readonly limit?: number; + /** @description Filter to requests in this status. */ + readonly status?: "submitted" | "triaging" | "scoping" | "approved" | "task_created" | "cancelled" | null; }; + readonly header?: never; + readonly path?: never; readonly cookie?: never; }; readonly requestBody?: never; @@ -19211,30 +19559,26 @@ export interface operations { readonly [name: string]: unknown; }; content: { - readonly "application/json": components["schemas"]["ApiResponse_PipelineResult_"]; + readonly "application/json": components["schemas"]["PaginatedResponse_ClientRequest_"]; }; }; readonly 400: components["responses"]["BadRequest"]; readonly 401: components["responses"]["Unauthorized"]; - readonly 404: components["responses"]["NotFound"]; readonly 429: components["responses"]["TooManyRequests"]; readonly 500: components["responses"]["InternalError"]; readonly 503: components["responses"]["ServiceUnavailable"]; }; }; - readonly ApiV1ReviewsTaskIdStagesStageNameDecideDecideStage: { + readonly ApiV1RequestsSubmitRequest: { readonly parameters: { readonly query?: never; readonly header?: never; - readonly path: { - readonly stage_name: string; - readonly task_id: string; - }; + readonly path?: never; readonly cookie?: never; }; readonly requestBody: { readonly content: { - readonly "application/json": components["schemas"]["StageDecisionPayload"]; + readonly "application/json": components["schemas"]["CreateRequestPayload"]; }; }; readonly responses: { @@ -19244,27 +19588,215 @@ export interface operations { readonly [name: string]: unknown; }; content: { - readonly "application/json": components["schemas"]["ApiResponse_StageDecisionResult_"]; + readonly "application/json": components["schemas"]["ApiResponse_ClientRequest_"]; }; }; readonly 400: components["responses"]["BadRequest"]; readonly 401: components["responses"]["Unauthorized"]; readonly 403: components["responses"]["Forbidden"]; - readonly 404: components["responses"]["NotFound"]; readonly 409: components["responses"]["Conflict"]; readonly 429: components["responses"]["TooManyRequests"]; readonly 500: components["responses"]["InternalError"]; readonly 503: components["responses"]["ServiceUnavailable"]; }; }; - readonly ApiV1RolesRoleNameVersionsListVersions: { + readonly ApiV1RequestsRequestIdGetRequest: { readonly parameters: { - readonly query?: { - /** @description Opaque pagination cursor returned by the previous page */ - readonly cursor?: string | null; - /** @description Page size (default 50, max 200) */ - readonly limit?: number; - }; + readonly query?: never; + readonly header?: never; + readonly path: { + readonly request_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Request fulfilled, document follows */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_ClientRequest_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 404: components["responses"]["NotFound"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1RequestsRequestIdApproveApproveRequest: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path: { + readonly request_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Document created, URL follows */ + readonly 201: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_ClientRequest_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 403: components["responses"]["Forbidden"]; + readonly 404: components["responses"]["NotFound"]; + readonly 409: components["responses"]["Conflict"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1RequestsRequestIdRejectRejectRequest: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path: { + readonly request_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": components["schemas"]["RejectionPayload"]; + }; + }; + readonly responses: { + /** @description Document created, URL follows */ + readonly 201: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_ClientRequest_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 403: components["responses"]["Forbidden"]; + readonly 404: components["responses"]["NotFound"]; + readonly 409: components["responses"]["Conflict"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1RequestsRequestIdScopeScopeRequest: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path: { + readonly request_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": components["schemas"]["ScopingPayload"]; + }; + }; + readonly responses: { + /** @description Document created, URL follows */ + readonly 201: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_ClientRequest_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 403: components["responses"]["Forbidden"]; + readonly 404: components["responses"]["NotFound"]; + readonly 409: components["responses"]["Conflict"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1ReviewsTaskIdPipelineGetPipeline: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path: { + readonly task_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Request fulfilled, document follows */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_PipelineResult_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 404: components["responses"]["NotFound"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1ReviewsTaskIdStagesStageNameDecideDecideStage: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path: { + readonly stage_name: string; + readonly task_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": components["schemas"]["StageDecisionPayload"]; + }; + }; + readonly responses: { + /** @description Document created, URL follows */ + readonly 201: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_StageDecisionResult_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 403: components["responses"]["Forbidden"]; + readonly 404: components["responses"]["NotFound"]; + readonly 409: components["responses"]["Conflict"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1RolesRoleNameVersionsListVersions: { + readonly parameters: { + readonly query?: { + /** @description Opaque pagination cursor returned by the previous page */ + readonly cursor?: string | null; + /** @description Page size (default 50, max 200) */ + readonly limit?: number; + }; readonly header?: never; readonly path: { readonly role_name: string; @@ -20279,6 +20811,155 @@ export interface operations { readonly 503: components["responses"]["ServiceUnavailable"]; }; }; + readonly ApiV1SimulationsListSimulations: { + readonly parameters: { + readonly query?: { + /** @description Opaque pagination cursor returned by the previous page */ + readonly cursor?: string | null; + /** @description Page size (default 50, max 200) */ + readonly limit?: number; + }; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Request fulfilled, document follows */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["PaginatedResponse_SimulationStatusResponse_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1SimulationsStartSimulation: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": components["schemas"]["StartSimulationPayload"]; + }; + }; + readonly responses: { + /** @description Document created, URL follows */ + readonly 201: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_SimulationStatusResponse_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 403: components["responses"]["Forbidden"]; + readonly 409: components["responses"]["Conflict"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1SimulationsSimulationIdGetSimulation: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path: { + readonly simulation_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Request fulfilled, document follows */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_SimulationStatusResponse_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 404: components["responses"]["NotFound"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1SimulationsSimulationIdCancelCancelSimulation: { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path: { + readonly simulation_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Document created, URL follows */ + readonly 201: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_SimulationStatusResponse_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 403: components["responses"]["Forbidden"]; + readonly 404: components["responses"]["NotFound"]; + readonly 409: components["responses"]["Conflict"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; + readonly ApiV1SimulationsSimulationIdReportGetReport: { + readonly parameters: { + readonly query?: { + readonly fmt?: string; + }; + readonly header?: never; + readonly path: { + readonly simulation_id: string; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Request fulfilled, document follows */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": components["schemas"]["ApiResponse_dict_str_Any_"]; + }; + }; + readonly 400: components["responses"]["BadRequest"]; + readonly 401: components["responses"]["Unauthorized"]; + readonly 404: components["responses"]["NotFound"]; + readonly 429: components["responses"]["TooManyRequests"]; + readonly 500: components["responses"]["InternalError"]; + readonly 503: components["responses"]["ServiceUnavailable"]; + }; + }; readonly ApiV1SubworkflowsListSubworkflows: { readonly parameters: { readonly query?: { diff --git a/web/src/components/ui/request-card.stories.tsx b/web/src/components/ui/request-card.stories.tsx index b35a116e80..d871f38d6d 100644 --- a/web/src/components/ui/request-card.stories.tsx +++ b/web/src/components/ui/request-card.stories.tsx @@ -17,9 +17,10 @@ function buildRequest( title: 'Add CSV export to the dashboard', description: 'Operators need a CSV download for the past-30-days task table.', - task_type: 'feature', + task_type: 'development', priority: 'medium', estimated_complexity: 'medium', + acceptance_criteria: ['CSV download returns the past-30-days rows'], }, status, created_at: '2026-04-29T12:00:00Z', diff --git a/web/src/mocks/handlers/clients.ts b/web/src/mocks/handlers/clients.ts index c09744339c..4f673b82e6 100644 --- a/web/src/mocks/handlers/clients.ts +++ b/web/src/mocks/handlers/clients.ts @@ -17,7 +17,7 @@ import type { listSimulations, rejectRequest, scopeRequest, - SimulationStatus, + SimulationStatusResponse, startSimulation, submitRequest, TaskRequirement, @@ -60,8 +60,8 @@ export function buildRequest(overrides: Partial = {}): ClientRequ } export function buildSimulation( - overrides: Partial = {}, -): SimulationStatus { + overrides: Partial = {}, +): SimulationStatusResponse { return { simulation_id: 'sim-default', status: 'idle', @@ -187,7 +187,9 @@ export const clientsHandlers = [ ), ), http.post('/api/v1/simulations/', async ({ request }) => { - const body = (await request.json()) as { config?: SimulationStatus['config'] } + const body = (await request.json()) as { + config?: SimulationStatusResponse['config'] + } return HttpResponse.json( successFor( buildSimulation({ diff --git a/web/src/pages/SimulationDashboardPage.tsx b/web/src/pages/SimulationDashboardPage.tsx index 6c42478794..5264bdda2b 100644 --- a/web/src/pages/SimulationDashboardPage.tsx +++ b/web/src/pages/SimulationDashboardPage.tsx @@ -7,7 +7,7 @@ import { getSimulationReport, listSimulations, type SimulationReport, - type SimulationStatus, + type SimulationStatusResponse, } from '@/api/endpoints/clients' import { Button } from '@/components/ui/button' import { EmptyState } from '@/components/ui/empty-state' @@ -34,7 +34,7 @@ export default function SimulationDashboardPage() { loading: capLoading, error: capError, } = useCapabilities() - const [runs, setRuns] = useState([]) + const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [report, setReport] = useState(null)