diff --git a/README.md b/README.md
index cdd852d..337307e 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,8 @@ checkpointed. Executions can pause, survive process restart, resume, replay, and
The LLM may reason while the graph controls execution.
+
+
## Why this is a graph rather than a pipeline
The workflow cannot be reduced to a fixed sequence of LLM calls. Investigation expands dynamically
@@ -34,6 +36,8 @@ application design instead of incidental framework behavior.
- `get_state`, `get_state_history`, replay, and controlled `update_state` forks;
- an atomic Redis `SET NX` boundary for idempotent simulated remediations;
- v3 state streaming, structured logs, and opt-in metadata-only OpenTelemetry;
+- provider-portable structured-output schemas with application-enforced collection bounds;
+- terminal reasoning failures modeled as serializable `WorkflowError` values and `FAILED` state;
- a production LLM adapter that knows only the provider-neutral Governed LLM Gateway client.
## Engineering evidence map
@@ -48,6 +52,7 @@ application design instead of incidental framework behavior.
| Typed update-and-route decisions with `Command` | [`VerificationGraph`](src/stateops/graphs/verification/graph.py) |
| History, replay, controlled fork, and nested state reads | [`GraphIncidentRuntime`](src/stateops/graphs/runtime.py) |
| Durable checkpoints and restricted deserialization | [`Redis checkpointer`](src/stateops/adapters/persistence/checkpointer.py) |
+| Metadata-safe reasoning failure state | [`graphs/failures.py`](src/stateops/graphs/failures.py) |
| Atomic effect deduplication | [`Redis remediation ledger`](src/stateops/adapters/remediation/redis_executor.py) |
| Real restart/resume proof | [`Redis integration test`](tests/integration/test_redis_runtime.py) |
@@ -112,6 +117,8 @@ STATEOPS_LLM_WORKLOAD=stateops.incident.reasoning
The external Gateway/Policy Model Router must authorize that dotted workload. StateOps does not
assume that it is authorized and does not fall back to a direct provider when it is denied.
+Terminal Gateway failures are reduced to stable error metadata and a `FAILED` phase; raw client or
+provider exceptions are never added to checkpoint state.
## Quick start
@@ -122,7 +129,13 @@ uv sync --frozen --all-groups --extra observability
docker compose up --build
```
-Create an incident:
+Open [http://127.0.0.1:8000/ui](http://127.0.0.1:8000/ui) to use the **StateOps Control
+Room**. The frontend is served by the FastAPI process, uses no CDN or JavaScript framework, and
+follows the LangGraph state stream. It can start or load a thread, visualize investigation fan-out,
+approve or reject the selected action, and show the verified recovery. **Capture mode** hides the
+input controls and expands the dashboard for screenshots.
+
+To drive the same demonstration through the API, create an incident:
```bash
curl --request POST http://127.0.0.1:8000/incidents \
@@ -187,6 +200,7 @@ dedicated node and the atomic Redis claim makes re-execution safe.
## History, replay, fork, and streaming
```text
+GET /ui
GET /incidents/{incident_id}/history
POST /incidents/{incident_id}/replay/{checkpoint_id}
POST /incidents/{incident_id}/fork
diff --git a/README.pt-br.md b/README.pt-br.md
index 3dee870..67337ca 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -12,6 +12,8 @@ processo, ser retomadas, reproduzidas e bifurcadas.
O LLM pode raciocinar enquanto o grafo controla a execução.
+
+
## Por que isto é um grafo, e não uma pipeline
O workflow não pode ser reduzido a uma sequência fixa de chamadas a LLMs. A investigação se expande
@@ -35,6 +37,10 @@ do design da aplicação, em vez de serem comportamentos incidentais do framewor
- `get_state`, `get_state_history`, replay e forks controlados com `update_state`;
- uma barreira Redis atômica com `SET NX` para remediações simuladas idempotentes;
- streaming de estado v3, logs estruturados e OpenTelemetry opcional somente com metadados;
+- schemas de saída estruturada portáveis entre providers, com limites de coleção aplicados pela
+ aplicação;
+- falhas terminais de raciocínio modeladas como valores `WorkflowError` serializáveis e estado
+ `FAILED`;
- um adapter de LLM para produção que conhece apenas o cliente provider-neutral do Governed LLM
Gateway.
@@ -50,6 +56,7 @@ do design da aplicação, em vez de serem comportamentos incidentais do framewor
| Decisões tipadas de atualização e rota com `Command` | [`VerificationGraph`](src/stateops/graphs/verification/graph.py) |
| Histórico, replay, fork controlado e leitura de estado aninhado | [`GraphIncidentRuntime`](src/stateops/graphs/runtime.py) |
| Checkpoints duráveis e desserialização restrita | [`Checkpointer Redis`](src/stateops/adapters/persistence/checkpointer.py) |
+| Estado de falha de raciocínio seguro para metadados | [`graphs/failures.py`](src/stateops/graphs/failures.py) |
| Deduplicação atômica de efeitos | [`Ledger de remediação Redis`](src/stateops/adapters/remediation/redis_executor.py) |
| Prova real de reinicialização e retomada | [`Teste de integração Redis`](tests/integration/test_redis_runtime.py) |
@@ -115,6 +122,8 @@ STATEOPS_LLM_WORKLOAD=stateops.incident.reasoning
O Gateway/Policy Model Router externo deve autorizar esse workload em formato pontuado. O StateOps não
presume que ele esteja autorizado e não recorre diretamente a um provider quando a solicitação é
negada.
+Falhas terminais do Gateway são reduzidas a metadados estáveis de erro e à fase `FAILED`; exceções
+brutas do cliente ou do provider nunca são adicionadas ao estado persistido em checkpoint.
## Início rápido
@@ -125,7 +134,13 @@ uv sync --frozen --all-groups --extra observability
docker compose up --build
```
-Crie um incidente:
+Abra [http://127.0.0.1:8000/ui](http://127.0.0.1:8000/ui) para usar o **StateOps Control
+Room**. O frontend é servido pelo próprio processo FastAPI, não usa CDN nem framework JavaScript e
+acompanha o stream de estados do LangGraph. Ele permite iniciar ou carregar uma thread, observar o
+fan-out das investigações, aprovar ou rejeitar a ação selecionada e conferir a recuperação. O botão
+**Modo captura** remove os controles de entrada e expande o painel para screenshots.
+
+Para operar a mesma demonstração pela API, crie um incidente:
```bash
curl --request POST http://127.0.0.1:8000/incidents \
@@ -192,6 +207,7 @@ em um nó dedicado, e a reivindicação atômica no Redis torna a reexecução s
## Histórico, replay, fork e streaming
```text
+GET /ui
GET /incidents/{incident_id}/history
POST /incidents/{incident_id}/replay/{checkpoint_id}
POST /incidents/{incident_id}/fork
diff --git a/docs/DEMO.md b/docs/DEMO.md
index 2799d69..44e2784 100644
--- a/docs/DEMO.md
+++ b/docs/DEMO.md
@@ -2,16 +2,17 @@
## Five-minute path
-1. Run `docker compose up --build`.
-2. Create the checkout incident from the README.
-3. Observe three dynamically created investigation branches and `waiting_approval`.
-4. Approve `rollback-primary` and observe `resolved`.
-5. Read `/incidents/INC-2026-00817/history` and choose the checkpoint whose next node is
+1. Run `docker compose up --build` and open `http://127.0.0.1:8000/ui`.
+2. Start the prefilled checkout incident in the StateOps Control Room.
+3. Observe the dynamically created investigation branches and `waiting_approval`.
+4. Capture the interrupted state, approve the selected action, and observe `resolved`.
+5. Enable **Capture mode** for a clean screenshot of the resolved graph.
+6. Read `/incidents/INC-2026-00817/history` and choose the checkpoint whose next node is
`verification`.
-6. Fork it with `selected_action_id=restart-secondary`.
-7. Approve the restart. Synthetic verification fails, the graph replans, and a new approval interrupt
+7. Fork it with `selected_action_id=restart-secondary`.
+8. Approve the restart. Synthetic verification fails, the graph replans, and a new approval interrupt
recommends rollback.
-8. Approve rollback. The original idempotency key is detected and the prior simulated effect evidence
+9. Approve rollback. The original idempotency key is detected and the prior simulated effect evidence
is reused instead of executing it again.
## Process crash and resume
diff --git a/docs/assets/stateops-control-room.png b/docs/assets/stateops-control-room.png
new file mode 100644
index 0000000..53da3c4
Binary files /dev/null and b/docs/assets/stateops-control-room.png differ
diff --git a/src/stateops/adapters/llm/governed_gateway.py b/src/stateops/adapters/llm/governed_gateway.py
index 73e47fa..9cefe7b 100644
--- a/src/stateops/adapters/llm/governed_gateway.py
+++ b/src/stateops/adapters/llm/governed_gateway.py
@@ -6,6 +6,11 @@
from typing import cast
from governed_llm_gateway_client import GatewayClient
+from governed_llm_gateway_client.errors import (
+ GatewayClientError,
+ GatewayHTTPError,
+ GatewayTransportError,
+)
from governed_llm_gateway_contracts import (
DataClassification,
Message,
@@ -14,7 +19,9 @@
StructuredOutputSchema,
WorkloadRequirements,
)
+from governed_llm_gateway_contracts import ExecutionStatus as GatewayExecutionStatus
+from stateops.application.ports.reasoner import IncidentReasoningError
from stateops.domain.enums import ActionKind, Severity
from stateops.domain.models import (
Evidence,
@@ -26,9 +33,19 @@
)
-class GatewayReasoningError(RuntimeError):
+class GatewayReasoningError(IncidentReasoningError):
"""Raised when a governed response does not satisfy the StateOps contract."""
+ def __init__(
+ self,
+ message: str,
+ *,
+ code: str = "invalid_gateway_response",
+ retryable: bool = False,
+ ) -> None:
+ """Retain stable Gateway metadata without preserving transport objects."""
+ super().__init__(message, code=code, retryable=retryable)
+
_HYPOTHESES_SCHEMA: dict[str, object] = {
"type": "object",
@@ -36,7 +53,6 @@ class GatewayReasoningError(RuntimeError):
"hypotheses": {
"type": "array",
"minItems": 1,
- "maxItems": 8,
"items": {
"type": "object",
"properties": {
@@ -83,13 +99,23 @@ class GatewayReasoningError(RuntimeError):
"actions": {
"type": "array",
"minItems": 1,
- "maxItems": 5,
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"kind": {"type": "string"},
- "parameters": {"type": "object", "additionalProperties": {"type": "string"}},
+ "parameters": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "value": {"type": "string"},
+ },
+ "required": ["name", "value"],
+ "additionalProperties": False,
+ },
+ },
"risk": {"type": "string"},
},
"required": ["id", "kind", "parameters", "risk"],
@@ -131,6 +157,18 @@ def _items(value: object, *, field: str) -> list[Mapping[str, object]]:
return [_mapping(item, field=field) for item in value]
+def _bounded_items(
+ value: object, *, field: str, minimum: int, maximum: int
+) -> list[Mapping[str, object]]:
+ """Validate collection limits outside provider-specific JSON Schema dialects."""
+ values = _items(value, field=field)
+ if not minimum <= len(values) <= maximum:
+ raise GatewayReasoningError(
+ f"gateway field {field!r} must contain between {minimum} and {maximum} items"
+ )
+ return values
+
+
def _text(value: object, *, field: str) -> str:
if not isinstance(value, str) or not value or len(value) > 2000:
raise GatewayReasoningError(f"gateway field {field!r} must be bounded text")
@@ -179,19 +217,46 @@ async def _generate(
role=MessageRole.USER,
content=f"{instruction}\nINPUT_JSON={json.dumps(payload, separators=(',', ':'))}",
)
- response = await self._client.generate(
- workload=self._workload,
- messages=(message,),
- risk_level=RiskLevel.HIGH,
- data_classification=DataClassification.INTERNAL,
- requirements=WorkloadRequirements(structured_output=True),
- structured_output=StructuredOutputSchema(name=schema_name, schema=schema),
- context_tokens_estimated=1000,
- max_output_tokens=2000,
- provider_timeout_seconds=self._timeout_seconds,
- )
+ try:
+ response = await self._client.generate(
+ workload=self._workload,
+ messages=(message,),
+ risk_level=RiskLevel.HIGH,
+ data_classification=DataClassification.PUBLIC,
+ requirements=WorkloadRequirements(structured_output=True),
+ structured_output=StructuredOutputSchema(name=schema_name, schema=schema),
+ context_tokens_estimated=1000,
+ max_output_tokens=2000,
+ provider_timeout_seconds=self._timeout_seconds,
+ )
+ except GatewayHTTPError as exc:
+ raise GatewayReasoningError(
+ "gateway rejected the reasoning request",
+ code=exc.code,
+ ) from exc
+ except GatewayTransportError as exc:
+ raise GatewayReasoningError(
+ "gateway transport failed",
+ code="gateway_transport_error",
+ retryable=True,
+ ) from exc
+ except GatewayClientError as exc:
+ raise GatewayReasoningError(
+ "gateway client failed",
+ code="gateway_client_error",
+ ) from exc
+ if response.status is not GatewayExecutionStatus.SUCCEEDED:
+ error = response.error
+ raise GatewayReasoningError(
+ "gateway reasoning execution failed",
+ code=error.code if error is not None else "gateway_execution_failed",
+ retryable=error.retryable if error is not None else False,
+ )
if response.content is None:
- raise GatewayReasoningError("gateway returned no structured content")
+ raise GatewayReasoningError(
+ "gateway returned no structured content",
+ code="gateway_content_missing",
+ )
try:
decoded = cast(object, json.loads(response.content))
except json.JSONDecodeError as exc:
@@ -208,6 +273,9 @@ async def generate_hypotheses(
schema_name="stateops_hypotheses",
schema=_HYPOTHESES_SCHEMA,
)
+ hypothesis_items = _bounded_items(
+ payload.get("hypotheses"), field="hypotheses", minimum=1, maximum=8
+ )
hypotheses = tuple(
Hypothesis(
id=_text(item.get("id"), field="id"),
@@ -215,10 +283,8 @@ async def generate_hypotheses(
rationale=_text(item.get("rationale"), field="rationale"),
confidence=_confidence(item.get("confidence")),
)
- for item in _items(payload.get("hypotheses"), field="hypotheses")
+ for item in hypothesis_items
)
- if not hypotheses:
- raise GatewayReasoningError("gateway returned no hypotheses")
return hypotheses
async def investigate(
@@ -287,17 +353,22 @@ async def propose_remediations(
schema=_ACTIONS_SCHEMA,
)
actions: list[RemediationAction] = []
- for item in _items(payload.get("actions"), field="actions"):
- parameters = _mapping(item.get("parameters"), field="parameters")
+ action_items = _bounded_items(payload.get("actions"), field="actions", minimum=1, maximum=5)
+ for item in action_items:
+ parameter_items = _bounded_items(
+ item.get("parameters"), field="parameters", minimum=0, maximum=16
+ )
normalized = tuple(
sorted(
(
- _text(key, field="parameter_name"),
- _text(value, field="parameter_value"),
+ _text(parameter.get("name"), field="parameter_name"),
+ _text(parameter.get("value"), field="parameter_value"),
)
- for key, value in parameters.items()
+ for parameter in parameter_items
)
)
+ if len({name for name, _ in normalized}) != len(normalized):
+ raise GatewayReasoningError("gateway returned duplicate parameter names")
try:
kind = ActionKind(_text(item.get("kind"), field="kind"))
risk = Severity(_text(item.get("risk"), field="risk"))
@@ -311,6 +382,4 @@ async def propose_remediations(
risk=risk,
)
)
- if not actions:
- raise GatewayReasoningError("gateway returned no remediation actions")
return tuple(actions)
diff --git a/src/stateops/application/ports/reasoner.py b/src/stateops/application/ports/reasoner.py
index 9d38c66..71e4971 100644
--- a/src/stateops/application/ports/reasoner.py
+++ b/src/stateops/application/ports/reasoner.py
@@ -12,6 +12,16 @@
)
+class IncidentReasoningError(RuntimeError):
+ """Metadata-safe failure raised by an incident-reasoning implementation."""
+
+ def __init__(self, message: str, *, code: str, retryable: bool = False) -> None:
+ """Retain only stable failure metadata that is safe to checkpoint."""
+ super().__init__(message)
+ self.code = code
+ self.retryable = retryable
+
+
class IncidentReasoner(Protocol):
"""Reason about incidents without exposing model or provider selection."""
diff --git a/src/stateops/entrypoints/api/app.py b/src/stateops/entrypoints/api/app.py
index 90d30b3..6931460 100644
--- a/src/stateops/entrypoints/api/app.py
+++ b/src/stateops/entrypoints/api/app.py
@@ -4,11 +4,13 @@
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from dataclasses import asdict
+from pathlib import Path
from typing import Literal, cast
from fastapi import FastAPI, HTTPException
from fastapi.encoders import jsonable_encoder
-from fastapi.responses import StreamingResponse
+from fastapi.responses import FileResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -26,6 +28,18 @@
from stateops.graphs.incident_graph import build_incident_graph
from stateops.graphs.runtime import GraphIncidentRuntime
+_UI_ROOT = Path(__file__).with_name("static")
+_UI_SECURITY_HEADERS = {
+ "Cache-Control": "no-store",
+ "Content-Security-Policy": (
+ "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'self'; "
+ "frame-ancestors 'none'"
+ ),
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+}
+
class StateOpsSettings(BaseSettings):
"""Process configuration with no provider credentials."""
@@ -119,6 +133,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
description="Durable, replayable incident-response state machine.",
lifespan=lifespan,
)
+ app.mount("/ui/assets", StaticFiles(directory=_UI_ROOT), name="ui-assets")
+
+ @app.get("/ui", include_in_schema=False)
+ @app.get("/ui/", include_in_schema=False)
+ async def operator_console() -> FileResponse:
+ """Serve the dependency-free local demonstration console."""
+ return FileResponse(
+ _UI_ROOT / "index.html",
+ media_type="text/html",
+ headers=_UI_SECURITY_HEADERS,
+ )
@app.get("/healthz")
async def health() -> dict[str, str]:
diff --git a/src/stateops/entrypoints/api/static/app.css b/src/stateops/entrypoints/api/static/app.css
new file mode 100644
index 0000000..0c3751f
--- /dev/null
+++ b/src/stateops/entrypoints/api/static/app.css
@@ -0,0 +1,1427 @@
+:root {
+ --bg: #050810;
+ --bg-soft: #090e18;
+ --panel: rgba(12, 18, 30, 0.88);
+ --panel-strong: #0d1421;
+ --line: rgba(157, 177, 206, 0.14);
+ --line-strong: rgba(157, 177, 206, 0.28);
+ --text: #edf4ff;
+ --muted: #78869a;
+ --muted-bright: #a8b4c7;
+ --mint: #6fffc8;
+ --mint-soft: rgba(111, 255, 200, 0.12);
+ --blue: #70aaff;
+ --blue-soft: rgba(112, 170, 255, 0.13);
+ --amber: #ffbd66;
+ --amber-soft: rgba(255, 189, 102, 0.12);
+ --red: #ff7185;
+ --red-soft: rgba(255, 113, 133, 0.12);
+ --radius: 14px;
+ --mono: "SFMono-Regular", "Cascadia Code", "Roboto Mono", monospace;
+ --sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+html {
+ min-width: 320px;
+ background: var(--bg);
+}
+
+body {
+ min-height: 100vh;
+ margin: 0;
+ overflow-x: hidden;
+ color: var(--text);
+ background:
+ linear-gradient(rgba(95, 132, 184, 0.025) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(95, 132, 184, 0.025) 1px, transparent 1px),
+ radial-gradient(circle at 28% -15%, rgba(58, 117, 183, 0.12), transparent 38%),
+ var(--bg);
+ background-size: 42px 42px, 42px 42px, auto, auto;
+ font-family: var(--sans);
+ font-feature-settings: "ss01", "cv11";
+ -webkit-font-smoothing: antialiased;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ color: inherit;
+}
+
+button:focus-visible,
+input:focus-visible {
+ outline: 2px solid var(--mint);
+ outline-offset: 2px;
+}
+
+.ambient {
+ position: fixed;
+ z-index: -1;
+ width: 420px;
+ height: 420px;
+ border-radius: 50%;
+ filter: blur(110px);
+ opacity: 0.09;
+ pointer-events: none;
+}
+
+.ambient-one {
+ top: 18%;
+ left: 5%;
+ background: #327dcc;
+}
+
+.ambient-two {
+ right: 4%;
+ bottom: 0;
+ background: #2fd4a0;
+}
+
+.topbar {
+ position: relative;
+ z-index: 10;
+ display: grid;
+ grid-template-columns: 330px 1fr auto;
+ align-items: center;
+ height: 76px;
+ padding: 0 28px;
+ border-bottom: 1px solid var(--line);
+ background: rgba(5, 8, 16, 0.82);
+ backdrop-filter: blur(20px);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 13px;
+ width: fit-content;
+ color: var(--text);
+ text-decoration: none;
+}
+
+.brand-mark {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(2, 7px);
+ grid-template-rows: repeat(2, 7px);
+ gap: 7px;
+ width: 34px;
+ height: 34px;
+ padding: 3px;
+}
+
+.brand-mark::before,
+.brand-mark::after {
+ position: absolute;
+ z-index: -1;
+ content: "";
+ background: rgba(111, 255, 200, 0.34);
+}
+
+.brand-mark::before {
+ top: 9px;
+ left: 8px;
+ width: 18px;
+ height: 1px;
+}
+
+.brand-mark::after {
+ top: 8px;
+ left: 16px;
+ width: 1px;
+ height: 18px;
+}
+
+.brand-mark span {
+ border: 1px solid var(--mint);
+ border-radius: 2px;
+ background: var(--bg);
+ box-shadow: 0 0 10px rgba(111, 255, 200, 0.28);
+}
+
+.brand-mark span:first-child {
+ background: var(--mint);
+}
+
+.brand strong {
+ display: block;
+ letter-spacing: 0.14em;
+ font-size: 14px;
+ line-height: 1.1;
+}
+
+.brand small {
+ display: block;
+ margin-top: 4px;
+ color: var(--muted);
+ letter-spacing: 0.13em;
+ font-family: var(--mono);
+ font-size: 8px;
+}
+
+.topbar-context {
+ padding-left: 25px;
+ border-left: 1px solid var(--line);
+}
+
+.topbar-context span,
+.workspace-meta span,
+.outcome-metrics span {
+ display: block;
+ color: var(--muted);
+ letter-spacing: 0.12em;
+ font-family: var(--mono);
+ font-size: 8px;
+}
+
+.topbar-context strong {
+ display: block;
+ max-width: 480px;
+ margin-top: 5px;
+ overflow: hidden;
+ color: var(--muted-bright);
+ letter-spacing: 0.04em;
+ font-family: var(--mono);
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.health-pill,
+.ghost-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ height: 36px;
+ padding: 0 13px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: rgba(12, 18, 30, 0.7);
+ color: var(--muted-bright);
+ letter-spacing: 0.04em;
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.pulse-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--amber);
+ box-shadow: 0 0 0 4px rgba(255, 189, 102, 0.1);
+}
+
+.health-pill[data-status="online"] .pulse-dot {
+ background: var(--mint);
+ box-shadow: 0 0 0 4px var(--mint-soft), 0 0 12px rgba(111, 255, 200, 0.6);
+}
+
+.health-pill[data-status="offline"] .pulse-dot {
+ background: var(--red);
+ box-shadow: 0 0 0 4px var(--red-soft);
+}
+
+.ghost-button {
+ cursor: pointer;
+ transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
+}
+
+.ghost-button:hover,
+.ghost-button[aria-pressed="true"] {
+ border-color: rgba(111, 255, 200, 0.38);
+ background: var(--mint-soft);
+ color: var(--mint);
+}
+
+.capture-icon {
+ position: relative;
+ width: 13px;
+ height: 10px;
+ border: 1px solid currentColor;
+ border-radius: 2px;
+}
+
+.capture-icon::before {
+ position: absolute;
+ top: 2px;
+ left: 4px;
+ width: 3px;
+ height: 3px;
+ border: 1px solid currentColor;
+ border-radius: 50%;
+ content: "";
+}
+
+.shell {
+ display: grid;
+ grid-template-columns: 310px minmax(0, 1fr);
+ gap: 20px;
+ width: min(1800px, 100%);
+ margin: 0 auto;
+ padding: 20px 28px 32px;
+}
+
+.panel {
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: linear-gradient(145deg, rgba(14, 21, 34, 0.94), rgba(9, 14, 24, 0.91));
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.14);
+}
+
+.control-panel {
+ position: sticky;
+ top: 20px;
+ align-self: start;
+ min-height: calc(100vh - 116px);
+ padding: 22px;
+}
+
+.panel-heading,
+.card-heading,
+.workspace-header,
+.title-row,
+.metric-label,
+.metric-values {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.section-index {
+ color: var(--blue);
+ letter-spacing: 0.15em;
+ font-family: var(--mono);
+ font-size: 8px;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ margin-bottom: 0;
+ font-size: 20px;
+ font-weight: 570;
+ letter-spacing: -0.025em;
+}
+
+h2 {
+ margin: 6px 0 0;
+ font-size: 21px;
+ font-weight: 560;
+ letter-spacing: -0.025em;
+}
+
+h3 {
+ margin: 5px 0 0;
+ font-size: 15px;
+ font-weight: 570;
+ letter-spacing: -0.01em;
+}
+
+.icon-button {
+ display: grid;
+ width: 30px;
+ height: 30px;
+ place-items: center;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+}
+
+.icon-button:hover {
+ border-color: var(--line-strong);
+ color: var(--mint);
+}
+
+.panel-copy {
+ margin: 20px 0 22px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.field {
+ display: block;
+ margin-bottom: 15px;
+}
+
+.field > span:first-child {
+ display: block;
+ margin-bottom: 7px;
+ color: var(--muted-bright);
+ letter-spacing: 0.02em;
+ font-size: 10px;
+ font-weight: 550;
+}
+
+.field input {
+ width: 100%;
+ height: 40px;
+ padding: 0 12px;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: rgba(4, 8, 15, 0.72);
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 11px;
+ transition: border-color 150ms ease, background 150ms ease;
+}
+
+.field input:hover,
+.field input:focus {
+ border-color: rgba(112, 170, 255, 0.38);
+ background: rgba(8, 14, 25, 0.92);
+}
+
+.field-pair {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.input-suffix {
+ position: relative;
+ display: block;
+}
+
+.input-suffix input {
+ padding-right: 29px;
+}
+
+.input-suffix b {
+ position: absolute;
+ top: 13px;
+ right: 11px;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ font-weight: 500;
+ pointer-events: none;
+}
+
+.critical-input input {
+ color: var(--red);
+}
+
+.primary-button,
+.secondary-button,
+.approve-button,
+.reject-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 7px;
+ letter-spacing: 0.02em;
+ font-size: 11px;
+ font-weight: 650;
+ cursor: pointer;
+ transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
+}
+
+.primary-button {
+ width: 100%;
+ height: 44px;
+ margin-top: 5px;
+ border: 0;
+ background: var(--mint);
+ color: #04110d;
+ box-shadow: 0 10px 28px rgba(111, 255, 200, 0.14);
+}
+
+.primary-button:hover,
+.approve-button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 13px 32px rgba(111, 255, 200, 0.22);
+}
+
+.primary-button:disabled,
+.approve-button:disabled,
+.reject-button:disabled,
+.secondary-button:disabled {
+ cursor: wait;
+ opacity: 0.48;
+ transform: none;
+}
+
+.button-spark {
+ margin-right: 9px;
+ font-size: 7px;
+}
+
+.button-arrow {
+ margin-left: auto;
+ padding-right: 3px;
+ font-size: 16px;
+}
+
+.secondary-button {
+ width: 100%;
+ height: 38px;
+ margin-top: 9px;
+ border: 1px solid var(--line);
+ background: transparent;
+ color: var(--muted-bright);
+}
+
+.secondary-button:hover {
+ border-color: var(--line-strong);
+ background: rgba(112, 170, 255, 0.06);
+}
+
+.runtime-note {
+ display: flex;
+ gap: 11px;
+ margin-top: 22px;
+ padding-top: 18px;
+ border-top: 1px solid var(--line);
+}
+
+.runtime-icon {
+ display: grid;
+ flex: 0 0 28px;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border: 1px solid rgba(255, 113, 133, 0.3);
+ border-radius: 7px;
+ background: var(--red-soft);
+ color: var(--red);
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.runtime-note strong {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--muted-bright);
+ font-size: 10px;
+}
+
+.runtime-note p {
+ margin-bottom: 0;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.5;
+}
+
+.workspace {
+ min-width: 0;
+}
+
+.workspace-header {
+ min-height: 62px;
+ padding: 0 4px 14px;
+}
+
+.title-row {
+ justify-content: flex-start;
+ gap: 12px;
+}
+
+.phase-badge,
+.card-status,
+.risk-badge,
+.live-indicator {
+ display: inline-flex;
+ align-items: center;
+ min-height: 20px;
+ padding: 0 8px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: rgba(120, 134, 154, 0.06);
+ color: var(--muted);
+ letter-spacing: 0.09em;
+ font-family: var(--mono);
+ font-size: 7px;
+ font-weight: 650;
+}
+
+.phase-badge[data-phase="running"] {
+ border-color: rgba(112, 170, 255, 0.26);
+ background: var(--blue-soft);
+ color: var(--blue);
+}
+
+.phase-badge[data-phase="waiting_approval"] {
+ border-color: rgba(255, 189, 102, 0.3);
+ background: var(--amber-soft);
+ color: var(--amber);
+}
+
+.phase-badge[data-phase="resolved"] {
+ border-color: rgba(111, 255, 200, 0.3);
+ background: var(--mint-soft);
+ color: var(--mint);
+}
+
+.phase-badge[data-phase="failed"],
+.phase-badge[data-phase="escalated"] {
+ border-color: rgba(255, 113, 133, 0.3);
+ background: var(--red-soft);
+ color: var(--red);
+}
+
+.workspace-meta {
+ display: flex;
+ gap: 25px;
+}
+
+.workspace-meta div {
+ min-width: 58px;
+}
+
+.workspace-meta strong {
+ display: block;
+ margin-top: 5px;
+ color: var(--muted-bright);
+ letter-spacing: 0.04em;
+ font-family: var(--mono);
+ font-size: 11px;
+ text-transform: uppercase;
+}
+
+.workspace-meta strong.critical {
+ color: var(--red);
+}
+
+.graph-panel {
+ position: relative;
+ padding: 18px 18px 16px;
+ overflow: hidden;
+}
+
+.graph-panel::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 1px;
+ content: "";
+ background: linear-gradient(90deg, transparent, rgba(112, 170, 255, 0.34), transparent);
+}
+
+.graph-track {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-width: 880px;
+}
+
+.graph-node {
+ position: relative;
+ display: flex;
+ flex: 0 0 88px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 85px;
+ padding: 10px 7px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: rgba(5, 10, 18, 0.62);
+ text-align: center;
+ transition: border-color 250ms ease, background 250ms ease, box-shadow 250ms ease;
+}
+
+.node-number {
+ position: absolute;
+ top: 5px;
+ left: 7px;
+ color: #465166;
+ font-family: var(--mono);
+ font-size: 6px;
+}
+
+.node-icon {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ margin-bottom: 7px;
+ place-items: center;
+ border: 1px solid var(--line-strong);
+ border-radius: 6px;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.graph-node strong {
+ color: var(--muted-bright);
+ letter-spacing: 0.08em;
+ font-family: var(--mono);
+ font-size: 7px;
+}
+
+.graph-node small {
+ margin-top: 4px;
+ color: #58657a;
+ font-size: 7px;
+}
+
+.graph-node small b {
+ color: inherit;
+}
+
+.graph-node.complete {
+ border-color: rgba(111, 255, 200, 0.22);
+ background: rgba(111, 255, 200, 0.04);
+}
+
+.graph-node.complete .node-icon {
+ border-color: rgba(111, 255, 200, 0.32);
+ background: var(--mint-soft);
+ color: var(--mint);
+}
+
+.graph-node.complete strong {
+ color: #bcebd9;
+}
+
+.graph-node.active {
+ border-color: rgba(112, 170, 255, 0.62);
+ background: var(--blue-soft);
+ box-shadow: 0 0 0 1px rgba(112, 170, 255, 0.08), 0 0 30px rgba(74, 137, 226, 0.12);
+}
+
+.graph-node.active .node-icon {
+ border-color: var(--blue);
+ background: rgba(112, 170, 255, 0.12);
+ color: var(--blue);
+ box-shadow: 0 0 14px rgba(112, 170, 255, 0.22);
+}
+
+.graph-node.active strong {
+ color: var(--blue);
+}
+
+.graph-node[data-node="approval"].active {
+ border-color: rgba(255, 189, 102, 0.68);
+ background: var(--amber-soft);
+ box-shadow: 0 0 30px rgba(255, 189, 102, 0.1);
+}
+
+.graph-node[data-node="approval"].active .node-icon,
+.graph-node[data-node="approval"].active strong {
+ border-color: var(--amber);
+ color: var(--amber);
+}
+
+.graph-node[data-node="verify"].resolved {
+ border-color: rgba(111, 255, 200, 0.62);
+ background: var(--mint-soft);
+ box-shadow: 0 0 30px rgba(111, 255, 200, 0.1);
+}
+
+.graph-node[data-node="verify"].resolved .node-icon,
+.graph-node[data-node="verify"].resolved strong {
+ border-color: var(--mint);
+ color: var(--mint);
+}
+
+.graph-edge {
+ position: relative;
+ flex: 1 1 18px;
+ min-width: 10px;
+ max-width: 40px;
+ height: 1px;
+ margin: 0 5px;
+ background: var(--line-strong);
+}
+
+.graph-edge::after {
+ position: absolute;
+ top: -2px;
+ right: -1px;
+ width: 4px;
+ height: 4px;
+ border-top: 1px solid var(--muted);
+ border-right: 1px solid var(--muted);
+ content: "";
+ transform: rotate(45deg);
+}
+
+.graph-edge.complete {
+ background: rgba(111, 255, 200, 0.45);
+}
+
+.branch-dots {
+ position: absolute;
+ bottom: -5px;
+ display: flex;
+ gap: 3px;
+}
+
+.branch-dots i {
+ width: 5px;
+ height: 5px;
+ border: 1px solid rgba(112, 170, 255, 0.5);
+ border-radius: 50%;
+ background: var(--panel-strong);
+}
+
+.branch-dots i.complete {
+ border-color: var(--mint);
+ background: var(--mint);
+ box-shadow: 0 0 6px rgba(111, 255, 200, 0.5);
+}
+
+.approval-panel,
+.outcome-panel {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ margin-top: 14px;
+ padding: 14px 16px;
+}
+
+.approval-panel {
+ border-color: rgba(255, 189, 102, 0.32);
+ background: linear-gradient(100deg, rgba(255, 189, 102, 0.1), rgba(14, 21, 34, 0.94) 58%);
+}
+
+.approval-signal,
+.outcome-check {
+ display: grid;
+ flex: 0 0 38px;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border: 1px solid rgba(255, 189, 102, 0.42);
+ border-radius: 9px;
+ background: var(--amber-soft);
+ color: var(--amber);
+ font-family: var(--mono);
+ font-size: 15px;
+}
+
+.approval-copy {
+ min-width: 0;
+}
+
+.approval-copy > span,
+.outcome-panel > div > span {
+ color: var(--amber);
+ letter-spacing: 0.12em;
+ font-family: var(--mono);
+ font-size: 7px;
+}
+
+.approval-copy h3,
+.outcome-panel h3 {
+ margin: 3px 0;
+}
+
+.approval-copy p,
+.outcome-panel p {
+ max-width: 680px;
+ margin: 0;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.approval-actions {
+ display: flex;
+ gap: 8px;
+ margin-left: auto;
+}
+
+.approve-button,
+.reject-button {
+ height: 35px;
+ padding: 0 16px;
+}
+
+.approve-button {
+ gap: 9px;
+ border: 0;
+ background: var(--mint);
+ color: #04110d;
+}
+
+.reject-button {
+ border: 1px solid var(--line-strong);
+ background: rgba(5, 10, 18, 0.45);
+ color: var(--muted-bright);
+}
+
+.reject-button:hover {
+ border-color: rgba(255, 113, 133, 0.42);
+ color: var(--red);
+}
+
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ margin-top: 14px;
+}
+
+.metric-card {
+ position: relative;
+ min-height: 105px;
+ padding: 15px 17px;
+ overflow: hidden;
+}
+
+.metric-label span {
+ color: var(--muted);
+ letter-spacing: 0.11em;
+ font-family: var(--mono);
+ font-size: 7px;
+}
+
+.metric-label b {
+ padding: 3px 5px;
+ border-radius: 3px;
+ background: var(--mint-soft);
+ color: var(--mint);
+ letter-spacing: 0.08em;
+ font-family: var(--mono);
+ font-size: 6px;
+ font-weight: 600;
+}
+
+.metric-values {
+ justify-content: flex-start;
+ gap: 11px;
+ margin-top: 12px;
+}
+
+.metric-values strong {
+ font-family: var(--mono);
+ font-size: 23px;
+ font-weight: 500;
+ letter-spacing: -0.06em;
+}
+
+.metric-delta {
+ padding: 3px 5px;
+ border-radius: 3px;
+ background: var(--red-soft);
+ color: var(--red);
+ letter-spacing: 0.06em;
+ font-family: var(--mono);
+ font-size: 6px;
+}
+
+.metric-delta.recovered {
+ background: var(--mint-soft);
+ color: var(--mint);
+}
+
+.metric-delta.neutral {
+ background: var(--blue-soft);
+ color: var(--blue);
+}
+
+.sparkline {
+ position: absolute;
+ right: 10px;
+ bottom: 11px;
+ left: 17px;
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+ height: 22px;
+ opacity: 0.5;
+}
+
+.sparkline i {
+ flex: 1;
+ min-width: 2px;
+ border-radius: 1px 1px 0 0;
+ background: var(--red);
+}
+
+.sparkline i:nth-child(1) { height: 12%; }
+.sparkline i:nth-child(2) { height: 17%; }
+.sparkline i:nth-child(3) { height: 14%; }
+.sparkline i:nth-child(4) { height: 22%; }
+.sparkline i:nth-child(5) { height: 29%; }
+.sparkline i:nth-child(6) { height: 82%; }
+.sparkline i:nth-child(7) { height: 100%; }
+.sparkline i:nth-child(8) { height: 94%; }
+
+.sparkline.recovered i:nth-child(7),
+.sparkline.recovered i:nth-child(8) {
+ height: 15%;
+ background: var(--mint);
+}
+
+.branch-meter {
+ display: flex;
+ gap: 5px;
+ margin-top: 10px;
+}
+
+.branch-meter i {
+ height: 4px;
+ flex: 1;
+ border-radius: 2px;
+ background: #1a2638;
+}
+
+.branch-meter i.complete {
+ background: var(--blue);
+ box-shadow: 0 0 8px rgba(112, 170, 255, 0.22);
+}
+
+.confidence-track {
+ height: 3px;
+ margin-top: 12px;
+ overflow: hidden;
+ border-radius: 3px;
+ background: #1a2638;
+}
+
+.confidence-track span {
+ display: block;
+ width: 0;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--blue), var(--mint));
+ box-shadow: 0 0 9px rgba(111, 255, 200, 0.32);
+ transition: width 450ms ease;
+}
+
+.details-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(250px, 0.8fr) minmax(285px, 0.9fr);
+ gap: 14px;
+ margin-top: 14px;
+}
+
+.detail-card {
+ min-width: 0;
+ min-height: 230px;
+ padding: 17px;
+}
+
+.card-heading {
+ align-items: flex-start;
+ margin-bottom: 15px;
+}
+
+.card-status.ready {
+ border-color: rgba(111, 255, 200, 0.24);
+ background: var(--mint-soft);
+ color: var(--mint);
+}
+
+.detail-summary {
+ display: -webkit-box;
+ margin-bottom: 16px;
+ overflow: hidden;
+ color: #bdc8d8;
+ font-size: 10px;
+ line-height: 1.65;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 6;
+}
+
+.detail-summary.muted {
+ color: var(--muted);
+}
+
+.evidence-strip {
+ display: flex;
+ gap: 6px;
+ margin-top: auto;
+}
+
+.evidence-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 22px;
+ padding: 0 7px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 7px;
+}
+
+.evidence-chip::before {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--red);
+ content: "";
+}
+
+.evidence-chip.supports {
+ border-color: rgba(111, 255, 200, 0.18);
+ color: #9ebdac;
+}
+
+.evidence-chip.supports::before {
+ background: var(--mint);
+}
+
+.risk-badge.medium,
+.risk-badge.high {
+ border-color: rgba(255, 189, 102, 0.28);
+ background: var(--amber-soft);
+ color: var(--amber);
+}
+
+.risk-badge.low {
+ border-color: rgba(111, 255, 200, 0.24);
+ background: var(--mint-soft);
+ color: var(--mint);
+}
+
+.action-empty {
+ display: flex;
+ min-height: 130px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: #465166;
+}
+
+.action-empty span {
+ font-family: var(--mono);
+ font-size: 22px;
+}
+
+.action-empty p {
+ margin: 8px 0 0;
+ font-size: 9px;
+}
+
+.action-name {
+ display: block;
+ margin-bottom: 14px;
+ color: var(--blue);
+ font-family: var(--mono);
+ font-size: 12px;
+ font-weight: 550;
+ word-break: break-word;
+}
+
+.parameter-list {
+ margin: 0;
+}
+
+.parameter-row {
+ display: grid;
+ grid-template-columns: 92px minmax(0, 1fr);
+ gap: 8px;
+ padding: 7px 0;
+ border-top: 1px solid var(--line);
+ font-family: var(--mono);
+ font-size: 7px;
+ line-height: 1.45;
+}
+
+.parameter-row dt {
+ color: var(--muted);
+}
+
+.parameter-row dd {
+ margin: 0;
+ overflow: hidden;
+ color: var(--muted-bright);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.live-indicator {
+ border: 0;
+ background: transparent;
+ color: var(--mint);
+}
+
+.live-indicator i {
+ width: 4px;
+ height: 4px;
+ margin-right: 5px;
+ border-radius: 50%;
+ background: var(--mint);
+ box-shadow: 0 0 7px rgba(111, 255, 200, 0.7);
+}
+
+.timeline-list {
+ position: relative;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.timeline-list::before {
+ position: absolute;
+ top: 7px;
+ bottom: 7px;
+ left: 4px;
+ width: 1px;
+ background: var(--line);
+ content: "";
+}
+
+.timeline-item {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ padding: 0 0 11px 18px;
+}
+
+.timeline-item:last-child {
+ padding-bottom: 0;
+}
+
+.timeline-item::before {
+ position: absolute;
+ top: 4px;
+ left: 1px;
+ width: 7px;
+ height: 7px;
+ border: 2px solid var(--panel-strong);
+ border-radius: 50%;
+ background: var(--blue);
+ content: "";
+ box-shadow: 0 0 7px rgba(112, 170, 255, 0.42);
+}
+
+.timeline-item:last-child::before {
+ background: var(--mint);
+ box-shadow: 0 0 7px rgba(111, 255, 200, 0.52);
+}
+
+.timeline-event {
+ overflow: hidden;
+ color: var(--muted-bright);
+ font-family: var(--mono);
+ font-size: 7px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.timeline-phase,
+.timeline-time {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 6px;
+}
+
+.timeline-phase {
+ display: block;
+ margin-top: 2px;
+}
+
+.timeline-empty {
+ padding: 20px 0 0 18px;
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.outcome-panel {
+ border-color: rgba(111, 255, 200, 0.3);
+ background: linear-gradient(100deg, rgba(111, 255, 200, 0.1), rgba(14, 21, 34, 0.94) 58%);
+}
+
+.outcome-check {
+ border-color: rgba(111, 255, 200, 0.42);
+ background: var(--mint-soft);
+ color: var(--mint);
+}
+
+.outcome-panel > div > span {
+ color: var(--mint);
+}
+
+.outcome-metrics {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-left: auto;
+}
+
+.outcome-metrics div {
+ min-width: 55px;
+}
+
+.outcome-metrics strong {
+ display: block;
+ margin-top: 3px;
+ font-family: var(--mono);
+ font-size: 15px;
+}
+
+.outcome-metrics div:last-child strong {
+ color: var(--mint);
+}
+
+.outcome-arrow {
+ color: var(--mint);
+}
+
+.status-message {
+ min-height: 18px;
+ margin: 10px 4px 0;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 8px;
+}
+
+.status-message.error {
+ color: var(--red);
+}
+
+.status-message.running {
+ color: var(--blue);
+}
+
+.is-busy .graph-node.active .node-icon {
+ animation: breathe 1.5s ease-in-out infinite;
+}
+
+.capture-mode .topbar {
+ grid-template-columns: 260px 1fr auto;
+}
+
+.capture-mode .shell {
+ grid-template-columns: 1fr;
+}
+
+.capture-mode .control-panel {
+ display: none;
+}
+
+.capture-mode .workspace-header {
+ padding-top: 4px;
+}
+
+@keyframes breathe {
+ 0%, 100% { box-shadow: 0 0 8px rgba(112, 170, 255, 0.12); }
+ 50% { box-shadow: 0 0 20px rgba(112, 170, 255, 0.42); }
+}
+
+@media (max-width: 1240px) {
+ .graph-panel {
+ overflow-x: auto;
+ }
+
+ .details-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .timeline-card {
+ grid-column: 1 / -1;
+ min-height: 180px;
+ }
+}
+
+@media (max-width: 900px) {
+ .topbar {
+ grid-template-columns: 1fr auto;
+ padding: 0 18px;
+ }
+
+ .topbar-context,
+ .health-pill {
+ display: none;
+ }
+
+ .shell {
+ grid-template-columns: 1fr;
+ padding: 16px 18px 28px;
+ }
+
+ .control-panel {
+ position: static;
+ min-height: auto;
+ }
+
+ .metrics-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 640px) {
+ .topbar {
+ height: 66px;
+ }
+
+ .ghost-button {
+ width: 36px;
+ padding: 0;
+ justify-content: center;
+ font-size: 0;
+ }
+
+ .workspace-header {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ .workspace-meta {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .details-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .timeline-card {
+ grid-column: auto;
+ }
+
+ .approval-panel,
+ .outcome-panel {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .approval-actions,
+ .outcome-metrics {
+ width: 100%;
+ margin-left: 53px;
+ }
+
+ .approval-copy p,
+ .outcome-panel p {
+ white-space: normal;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/src/stateops/entrypoints/api/static/app.js b/src/stateops/entrypoints/api/static/app.js
new file mode 100644
index 0000000..3e4e028
--- /dev/null
+++ b/src/stateops/entrypoints/api/static/app.js
@@ -0,0 +1,572 @@
+"use strict";
+
+const byId = (id) => document.getElementById(id);
+
+const ui = {
+ actionContent: byId("action-content"),
+ actionEmpty: byId("action-empty"),
+ actionName: byId("action-name"),
+ approvalDescription: byId("approval-description"),
+ approvalPanel: byId("approval-panel"),
+ approveButton: byId("approve-button"),
+ attemptValue: byId("attempt-value"),
+ branchCount: byId("branch-count"),
+ branchDots: byId("branch-dots"),
+ branchMeter: byId("branch-meter"),
+ captureButton: byId("capture-button"),
+ checkpointValue: byId("checkpoint-value"),
+ confidenceFill: byId("confidence-fill"),
+ confidenceValue: byId("confidence-value"),
+ currentError: byId("current-error"),
+ deployment: byId("deployment"),
+ errorAfter: byId("error-after"),
+ errorBefore: byId("error-before"),
+ errorDelta: byId("error-delta"),
+ evidenceCount: byId("evidence-count"),
+ evidenceStrip: byId("evidence-strip"),
+ form: byId("incident-form"),
+ headerIncident: byId("header-incident"),
+ healthLabel: byId("health-label"),
+ healthPill: byId("health-pill"),
+ hypothesisCount: byId("hypothesis-count"),
+ incidentId: byId("incident-id"),
+ loadButton: byId("load-button"),
+ outcomeAfter: byId("outcome-after"),
+ outcomeBefore: byId("outcome-before"),
+ outcomeDescription: byId("outcome-description"),
+ outcomePanel: byId("outcome-panel"),
+ parameterList: byId("parameter-list"),
+ phaseBadge: byId("phase-badge"),
+ reasoningStatus: byId("reasoning-status"),
+ rejectButton: byId("reject-button"),
+ resetButton: byId("reset-button"),
+ riskBadge: byId("risk-badge"),
+ rootHypothesis: byId("root-hypothesis"),
+ rootSummary: byId("root-summary"),
+ service: byId("service"),
+ severityValue: byId("severity-value"),
+ sparkline: document.querySelector(".sparkline"),
+ startButton: byId("start-button"),
+ statusMessage: byId("status-message"),
+ timelineList: byId("timeline-list"),
+};
+
+const graphNodes = Array.from(document.querySelectorAll(".graph-node"));
+const graphEdges = Array.from(document.querySelectorAll(".graph-edge"));
+
+const phasePosition = {
+ received: 0,
+ enriching: 0,
+ classified: 0,
+ hypotheses_generated: 1,
+ investigating: 2,
+ evidence_ready: 3,
+ planning: 4,
+ waiting_approval: 5,
+ executing: 6,
+ verifying: 7,
+ resolved: 8,
+ escalated: 8,
+ failed: 8,
+};
+
+let latestState = null;
+let busy = false;
+
+function makeIncidentId() {
+ const now = new Date();
+ const date = now.toISOString().slice(0, 10).replaceAll("-", "");
+ const time = now.toTimeString().slice(0, 8).replaceAll(":", "");
+ return `INC-DEMO-${date}-${time}`;
+}
+
+function setText(element, value) {
+ element.textContent = value === null || value === undefined || value === "" ? "—" : String(value);
+}
+
+function setStatus(message, kind = "") {
+ setText(ui.statusMessage, message);
+ ui.statusMessage.className = `status-message ${kind}`.trim();
+}
+
+function setBusy(nextBusy, message = "") {
+ busy = nextBusy;
+ document.body.classList.toggle("is-busy", nextBusy);
+ ui.startButton.disabled = nextBusy;
+ ui.loadButton.disabled = nextBusy;
+ ui.approveButton.disabled = nextBusy;
+ ui.rejectButton.disabled = nextBusy;
+ if (message) {
+ setStatus(message, nextBusy ? "running" : "");
+ }
+}
+
+function formatPercent(value) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? `${numeric.toFixed(numeric < 1 ? 1 : 1)}%` : "—";
+}
+
+function formatPhase(phase) {
+ return String(phase || "idle").replaceAll("_", " ").toUpperCase();
+}
+
+function formatTime(value) {
+ if (!value) {
+ return "—";
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "—";
+ }
+ return new Intl.DateTimeFormat("pt-BR", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ }).format(date);
+}
+
+function normalizedParameters(parameters) {
+ if (!Array.isArray(parameters)) {
+ return [];
+ }
+ return parameters.flatMap((parameter) => {
+ if (Array.isArray(parameter) && parameter.length >= 2) {
+ return [{ name: parameter[0], value: parameter[1] }];
+ }
+ if (parameter && typeof parameter === "object" && "name" in parameter && "value" in parameter) {
+ return [{ name: parameter.name, value: parameter.value }];
+ }
+ return [];
+ });
+}
+
+function createBranchIndicators(hypotheses, evidence) {
+ const completed = new Set(evidence.map((item) => item.hypothesis_id));
+ ui.branchDots.replaceChildren();
+ ui.branchMeter.replaceChildren();
+
+ hypotheses.forEach((hypothesis) => {
+ const dot = document.createElement("i");
+ dot.classList.toggle("complete", completed.has(hypothesis.id));
+ dot.title = String(hypothesis.id || "branch");
+ ui.branchDots.append(dot);
+
+ const bar = document.createElement("i");
+ bar.classList.toggle("complete", completed.has(hypothesis.id));
+ ui.branchMeter.append(bar);
+ });
+}
+
+function renderGraph(phase) {
+ const position = phasePosition[phase] ?? -1;
+ const terminal = phase === "resolved";
+
+ graphNodes.forEach((node, index) => {
+ node.classList.toggle("complete", terminal || index < position);
+ node.classList.toggle("active", !terminal && index === position);
+ node.classList.toggle("resolved", terminal && index === graphNodes.length - 1);
+ });
+ graphEdges.forEach((edge, index) => {
+ edge.classList.toggle("complete", terminal || index < position);
+ });
+}
+
+function renderEvidence(evidence) {
+ ui.evidenceStrip.replaceChildren();
+ evidence.slice(0, 5).forEach((item) => {
+ const chip = document.createElement("span");
+ chip.className = "evidence-chip";
+ chip.classList.toggle("supports", item.supports === true);
+ chip.textContent = `${item.hypothesis_id || item.id || "E"} ${item.supports ? "SUPPORTS" : "REFUTES"}`;
+ ui.evidenceStrip.append(chip);
+ });
+}
+
+function renderAction(action) {
+ const hasAction = Boolean(action && action.id);
+ ui.actionEmpty.hidden = hasAction;
+ ui.actionContent.hidden = !hasAction;
+ ui.parameterList.replaceChildren();
+
+ if (!hasAction) {
+ setText(ui.actionName, null);
+ setText(ui.riskBadge, null);
+ ui.riskBadge.className = "risk-badge";
+ return;
+ }
+
+ setText(ui.actionName, action.kind);
+ setText(ui.riskBadge, String(action.risk || "unknown").toUpperCase());
+ ui.riskBadge.className = `risk-badge ${String(action.risk || "").toLowerCase()}`;
+
+ normalizedParameters(action.parameters).slice(0, 4).forEach((parameter) => {
+ const row = document.createElement("div");
+ row.className = "parameter-row";
+ const term = document.createElement("dt");
+ const description = document.createElement("dd");
+ setText(term, parameter.name);
+ setText(description, parameter.value);
+ description.title = String(parameter.value);
+ row.append(term, description);
+ ui.parameterList.append(row);
+ });
+}
+
+function renderTimeline(timeline) {
+ ui.timelineList.replaceChildren();
+ if (timeline.length === 0) {
+ const empty = document.createElement("li");
+ empty.className = "timeline-empty";
+ empty.textContent = "Os eventos aparecerão durante a execução.";
+ ui.timelineList.append(empty);
+ return;
+ }
+
+ timeline.slice(-7).forEach((item) => {
+ const entry = document.createElement("li");
+ entry.className = "timeline-item";
+ const eventBox = document.createElement("div");
+ const event = document.createElement("span");
+ const phase = document.createElement("span");
+ const time = document.createElement("time");
+ event.className = "timeline-event";
+ phase.className = "timeline-phase";
+ time.className = "timeline-time";
+ setText(event, item.event);
+ setText(phase, formatPhase(item.phase));
+ setText(time, formatTime(item.occurred_at));
+ if (item.occurred_at) {
+ time.dateTime = String(item.occurred_at);
+ }
+ eventBox.append(event, phase);
+ entry.append(eventBox, time);
+ ui.timelineList.append(entry);
+ });
+}
+
+function renderState(state) {
+ if (!state || typeof state !== "object") {
+ return;
+ }
+ latestState = state;
+
+ const phase = String(state.phase || "idle");
+ const incident = state.incident && typeof state.incident === "object" ? state.incident : {};
+ const hypotheses = Array.isArray(state.hypotheses) ? state.hypotheses : [];
+ const evidence = Array.isArray(state.evidence) ? state.evidence : [];
+ const rootCause = state.root_cause && typeof state.root_cause === "object" ? state.root_cause : null;
+ const verification = state.verification_result && typeof state.verification_result === "object"
+ ? state.verification_result
+ : null;
+ const candidates = Array.isArray(state.candidate_actions) ? state.candidate_actions : [];
+ const action = state.selected_action || candidates[0] || null;
+ const timeline = Array.isArray(state.timeline) ? state.timeline : [];
+
+ setText(ui.headerIncident, state.incident_id || ui.incidentId.value);
+ setText(ui.phaseBadge, formatPhase(phase));
+ ui.phaseBadge.dataset.phase = ["resolved", "waiting_approval", "failed", "escalated"].includes(phase)
+ ? phase
+ : "running";
+ setText(ui.severityValue, String(state.severity || "pending").toUpperCase());
+ ui.severityValue.classList.toggle("critical", state.severity === "critical");
+ setText(ui.attemptValue, state.attempts ?? 0);
+
+ const displayError = verification?.error_rate_after ?? incident.error_rate_after;
+ setText(ui.currentError, formatPercent(displayError));
+ if (verification?.recovered) {
+ setText(ui.errorDelta, "RECOVERED");
+ ui.errorDelta.className = "metric-delta recovered";
+ ui.sparkline.classList.add("recovered");
+ } else if (incident.error_rate_after !== undefined && incident.error_rate_before !== undefined) {
+ const delta = Number(incident.error_rate_after) - Number(incident.error_rate_before);
+ setText(ui.errorDelta, `+${delta.toFixed(1)} PTS`);
+ ui.errorDelta.className = "metric-delta";
+ ui.sparkline.classList.remove("recovered");
+ }
+
+ setText(ui.hypothesisCount, hypotheses.length);
+ setText(ui.branchCount, hypotheses.length);
+ setText(ui.evidenceCount, `${evidence.length} EVIDENCES`);
+ createBranchIndicators(hypotheses, evidence);
+
+ if (rootCause) {
+ const confidence = Number(rootCause.confidence || 0);
+ setText(ui.confidenceValue, `${Math.round(confidence * 100)}%`);
+ setText(ui.rootHypothesis, rootCause.hypothesis_id || "SELECTED");
+ ui.confidenceFill.style.width = `${Math.max(0, Math.min(100, confidence * 100))}%`;
+ setText(ui.rootSummary, rootCause.summary);
+ ui.rootSummary.classList.remove("muted");
+ setText(ui.reasoningStatus, "SYNTHESIZED");
+ ui.reasoningStatus.classList.add("ready");
+ }
+ renderEvidence(evidence);
+ renderAction(action);
+ renderTimeline(timeline);
+ renderGraph(phase);
+
+ const waiting = phase === "waiting_approval";
+ ui.approvalPanel.hidden = !waiting;
+ if (waiting && action) {
+ setText(
+ ui.approvalDescription,
+ `${action.kind} · risk ${action.risk || "unknown"} · revision ${action.revision || 1}`,
+ );
+ }
+
+ const resolved = phase === "resolved";
+ ui.outcomePanel.hidden = !resolved;
+ if (resolved && verification) {
+ setText(ui.outcomeBefore, formatPercent(verification.error_rate_before));
+ setText(ui.outcomeAfter, formatPercent(verification.error_rate_after));
+ const effect = state.execution_result?.synthetic_effect || "simulated action";
+ setText(ui.outcomeDescription, `${effect} · recovery confirmed · idempotent execution`);
+ }
+
+ if (phase === "failed") {
+ const errors = Array.isArray(state.errors) ? state.errors : [];
+ const latestError = errors.at(-1);
+ setStatus(latestError?.message || "O workflow terminou em estado de falha.", "error");
+ } else if (waiting) {
+ setStatus("Checkpoint persistido. Aguardando decisão humana.");
+ } else if (resolved) {
+ setStatus("Execução concluída e recuperação verificada.");
+ } else if (busy) {
+ setStatus(`Processando fase ${formatPhase(phase)}…`, "running");
+ }
+}
+
+async function responseError(response) {
+ const body = await response.text();
+ try {
+ const parsed = JSON.parse(body);
+ return parsed.detail || parsed.message || `HTTP ${response.status}`;
+ } catch {
+ return body || `HTTP ${response.status}`;
+ }
+}
+
+async function fetchJson(url, options = {}) {
+ const response = await fetch(url, options);
+ if (!response.ok) {
+ throw new Error(await responseError(response));
+ }
+ return response.json();
+}
+
+async function refreshHistory(incidentId) {
+ try {
+ const history = await fetchJson(`/incidents/${encodeURIComponent(incidentId)}/history`);
+ setText(ui.checkpointValue, Array.isArray(history) ? history.length : 0);
+ } catch {
+ setText(ui.checkpointValue, "—");
+ }
+}
+
+function parseSseBlock(block) {
+ const data = block
+ .split("\n")
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => line.slice(5).trimStart())
+ .join("\n");
+ if (!data) {
+ return;
+ }
+ renderState(JSON.parse(data));
+}
+
+async function consumeStateStream(response) {
+ if (!response.body) {
+ throw new Error("Este navegador não disponibilizou o stream da resposta.");
+ }
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { value, done } = await reader.read();
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
+ let boundary = buffer.indexOf("\n\n");
+ while (boundary >= 0) {
+ parseSseBlock(buffer.slice(0, boundary));
+ buffer = buffer.slice(boundary + 2);
+ boundary = buffer.indexOf("\n\n");
+ }
+ if (done) {
+ break;
+ }
+ }
+ if (buffer.trim()) {
+ parseSseBlock(buffer);
+ }
+}
+
+function incidentPayload() {
+ return {
+ incident_id: ui.incidentId.value.trim(),
+ service: ui.service.value.trim(),
+ error_rate_before: Number(ui.errorBefore.value),
+ error_rate_after: Number(ui.errorAfter.value),
+ deployment: ui.deployment.value.trim(),
+ started_at: new Date().toISOString(),
+ };
+}
+
+async function startIncident(event) {
+ event.preventDefault();
+ if (!ui.form.reportValidity()) {
+ return;
+ }
+ const payload = incidentPayload();
+ setBusy(true, "Abrindo stream do grafo…");
+ ui.outcomePanel.hidden = true;
+ ui.approvalPanel.hidden = true;
+ setText(ui.headerIncident, payload.incident_id);
+
+ try {
+ const response = await fetch(`/incidents/${encodeURIComponent(payload.incident_id)}/events`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(await responseError(response));
+ }
+ await consumeStateStream(response);
+ await refreshHistory(payload.incident_id);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Falha ao iniciar incidente.", "error");
+ } finally {
+ setBusy(false);
+ }
+}
+
+async function loadIncident() {
+ const incidentId = ui.incidentId.value.trim();
+ if (!incidentId) {
+ ui.incidentId.focus();
+ return;
+ }
+ setBusy(true, "Carregando checkpoint persistido…");
+ try {
+ const state = await fetchJson(`/incidents/${encodeURIComponent(incidentId)}`);
+ renderState(state);
+ await refreshHistory(incidentId);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Thread não encontrada.", "error");
+ } finally {
+ setBusy(false);
+ }
+}
+
+async function submitDecision(decision) {
+ const incidentId = String(latestState?.incident_id || ui.incidentId.value).trim();
+ const candidates = Array.isArray(latestState?.candidate_actions) ? latestState.candidate_actions : [];
+ const action = latestState?.selected_action || candidates[0];
+ if (!incidentId || !action?.id) {
+ setStatus("A execução não possui uma ação selecionada.", "error");
+ return;
+ }
+
+ setBusy(true, decision === "approved" ? "Retomando execução aprovada…" : "Replanejando…");
+ try {
+ const result = await fetchJson(`/incidents/${encodeURIComponent(incidentId)}/approval`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ decision,
+ action_id: action.id,
+ comment: "Decision submitted from StateOps Control Room",
+ }),
+ });
+ renderState(result.state);
+ await refreshHistory(incidentId);
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Falha ao enviar decisão.", "error");
+ } finally {
+ setBusy(false);
+ }
+}
+
+function resetConsole() {
+ latestState = null;
+ ui.form.reset();
+ ui.incidentId.value = makeIncidentId();
+ ui.service.value = "checkout-service";
+ ui.errorBefore.value = "0.2";
+ ui.errorAfter.value = "18.0";
+ ui.deployment.value = "v2.31";
+ setText(ui.headerIncident, "NO ACTIVE INCIDENT");
+ setText(ui.phaseBadge, "IDLE");
+ ui.phaseBadge.dataset.phase = "idle";
+ setText(ui.severityValue, null);
+ setText(ui.checkpointValue, 0);
+ setText(ui.attemptValue, 0);
+ setText(ui.currentError, null);
+ setText(ui.errorDelta, "NO SIGNAL");
+ ui.errorDelta.className = "metric-delta";
+ setText(ui.hypothesisCount, 0);
+ setText(ui.evidenceCount, "0 EVIDENCES");
+ setText(ui.branchCount, 0);
+ setText(ui.confidenceValue, null);
+ setText(ui.rootHypothesis, "PENDING");
+ ui.confidenceFill.style.width = "0";
+ setText(ui.rootSummary, "Inicie um incidente para visualizar a síntese das evidências produzidas pelos ramos.");
+ ui.rootSummary.classList.add("muted");
+ setText(ui.reasoningStatus, "PENDING");
+ ui.reasoningStatus.classList.remove("ready");
+ renderEvidence([]);
+ createBranchIndicators([], []);
+ renderAction(null);
+ renderTimeline([]);
+ renderGraph("idle");
+ ui.approvalPanel.hidden = true;
+ ui.outcomePanel.hidden = true;
+ ui.sparkline.classList.remove("recovered");
+ setStatus("Pronto para iniciar uma nova execução.");
+}
+
+function setCaptureMode(enabled) {
+ document.body.classList.toggle("capture-mode", enabled);
+ ui.captureButton.setAttribute("aria-pressed", String(enabled));
+ ui.captureButton.title = enabled ? "Sair do modo captura" : "Expandir para captura";
+}
+
+function toggleCaptureMode() {
+ setCaptureMode(!document.body.classList.contains("capture-mode"));
+}
+
+async function checkHealth() {
+ try {
+ const result = await fetchJson("/healthz");
+ const online = result.status === "ok";
+ ui.healthPill.dataset.status = online ? "online" : "offline";
+ setText(ui.healthLabel, online ? "API online" : "API degradada");
+ } catch {
+ ui.healthPill.dataset.status = "offline";
+ setText(ui.healthLabel, "API offline");
+ }
+}
+
+ui.form.addEventListener("submit", startIncident);
+ui.loadButton.addEventListener("click", loadIncident);
+ui.approveButton.addEventListener("click", () => submitDecision("approved"));
+ui.rejectButton.addEventListener("click", () => submitDecision("rejected"));
+ui.resetButton.addEventListener("click", resetConsole);
+ui.captureButton.addEventListener("click", toggleCaptureMode);
+
+async function initialize() {
+ resetConsole();
+ const parameters = new URLSearchParams(window.location.search);
+ const incidentId = parameters.get("incident");
+ if (parameters.get("capture") === "1") {
+ setCaptureMode(true);
+ }
+ await checkHealth();
+ if (incidentId && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(incidentId)) {
+ ui.incidentId.value = incidentId;
+ await loadIncident();
+ }
+}
+
+initialize();
diff --git a/src/stateops/entrypoints/api/static/index.html b/src/stateops/entrypoints/api/static/index.html
new file mode 100644
index 0000000..0fa77c7
--- /dev/null
+++ b/src/stateops/entrypoints/api/static/index.html
@@ -0,0 +1,300 @@
+
+
+
+
+
+
+
+ StateOps Control Room
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 01
+ ↗
+ INTAKE
+ enrich + classify
+
+
+
+ 02
+ ◇
+ HYPOTHESES
+ structured output
+
+
+
+ 03
+ ⌘
+ FAN-OUT
+ Send × 0
+
+
+
+
+ 04
+ ≡
+ REDUCE
+ evidence synthesis
+
+
+
+ 05
+ △
+ PLAN
+ select action
+
+
+
+ 06
+ Ⅱ
+ HUMAN GATE
+ interrupt
+
+
+
+ 07
+ ▷
+ EXECUTE
+ idempotent effect
+
+
+
+ 08
+ ✓
+ VERIFY
+ Command route
+
+
+
+
+
+ !
+
+
HUMAN-IN-THE-LOOP
+
A execução está pausada
+
Uma decisão humana é necessária para continuar.
+
+
+
+
+
+
+
+
+
+ ERROR RATELIVE
+
+ —
+ NO SIGNAL
+
+
+
+
+
+
+
+ PARALLEL BRANCHESSEND
+
+ 0
+ 0 EVIDENCES
+
+
+
+
+
+ ROOT CAUSE CONFIDENCELLM
+
+ —
+ PENDING
+
+
+
+
+
+
+
+
+
+ 03 / REASONING
+
Root cause
+
+
PENDING
+
+
+ Inicie um incidente para visualizar a síntese das evidências produzidas pelos ramos.
+
+
+
+
+
+
+
+ 04 / DECISION
+
Selected action
+
+
—
+
+
+
△
+
Nenhuma ação selecionada
+
+
+
+
+
+
+
+ 05 / AUDIT TRAIL
+
State timeline
+
+
LIVE
+
+
+ - Os eventos aparecerão durante a execução.
+
+
+
+
+
+ ✓
+
+
WORKFLOW COMPLETE
+
Incident resolved
+
A ação foi executada e a recuperação foi confirmada.
+
+
+
+
+
+
+
+
+
diff --git a/src/stateops/graphs/failures.py b/src/stateops/graphs/failures.py
new file mode 100644
index 0000000..69ff68f
--- /dev/null
+++ b/src/stateops/graphs/failures.py
@@ -0,0 +1,74 @@
+"""Translate reasoning failures into durable metadata-only graph state."""
+
+from stateops.application.ports.clock import Clock
+from stateops.application.ports.reasoner import IncidentReasoningError
+from stateops.domain.enums import IncidentPhase
+from stateops.domain.models import WorkflowError
+from stateops.domain.transitions import transition
+from stateops.graphs.events import timeline_event
+from stateops.graphs.state import IncidentState
+
+
+def reasoning_error(
+ *,
+ incident_id: str,
+ node: str,
+ error: IncidentReasoningError,
+ discriminator: str = "0",
+) -> WorkflowError:
+ """Build a stable serializable error without persisting an exception object."""
+ return WorkflowError(
+ id=f"{incident_id}:reasoning.failed:{node}:{discriminator}:{error.code}",
+ category=error.code,
+ node=node,
+ retryable=error.retryable,
+ )
+
+
+def failed_reasoning_state(
+ clock: Clock,
+ state: IncidentState,
+ *,
+ node: str,
+ error: IncidentReasoningError,
+ discriminator: str = "0",
+) -> IncidentState:
+ """Terminate a reasoning step as explicit failed state."""
+ phase = transition(state["phase"], IncidentPhase.FAILED)
+ return IncidentState(
+ phase=phase,
+ errors=[
+ reasoning_error(
+ incident_id=state["incident_id"],
+ node=node,
+ error=error,
+ discriminator=discriminator,
+ )
+ ],
+ timeline=[
+ timeline_event(
+ clock,
+ incident_id=state["incident_id"],
+ phase=phase,
+ event="reasoning.failed",
+ discriminator=f"{node}:{discriminator}:{error.code}",
+ )
+ ],
+ )
+
+
+def failed_state_from_branch_errors(clock: Clock, state: IncidentState) -> IncidentState:
+ """Terminate after parallel investigation when any branch recorded an error."""
+ phase = transition(state["phase"], IncidentPhase.FAILED)
+ return IncidentState(
+ phase=phase,
+ timeline=[
+ timeline_event(
+ clock,
+ incident_id=state["incident_id"],
+ phase=phase,
+ event="reasoning.failed",
+ discriminator="investigation-branches",
+ )
+ ],
+ )
diff --git a/src/stateops/graphs/incident_graph.py b/src/stateops/graphs/incident_graph.py
index c500c1e..0a19d0e 100644
--- a/src/stateops/graphs/incident_graph.py
+++ b/src/stateops/graphs/incident_graph.py
@@ -74,6 +74,20 @@ def _after_verification(state: IncidentState) -> Literal["remediation"] | str:
return END
+def _after_investigation(state: IncidentState) -> Literal["remediation"] | str:
+ """Stop the parent graph when the investigation failed."""
+ if IncidentPhase(state["phase"]) is IncidentPhase.FAILED:
+ return END
+ return "remediation"
+
+
+def _after_remediation(state: IncidentState) -> Literal["verification"] | str:
+ """Stop the parent graph when remediation planning failed."""
+ if IncidentPhase(state["phase"]) is IncidentPhase.FAILED:
+ return END
+ return "verification"
+
+
def build_incident_graph(
*,
reasoner: IncidentReasoner,
@@ -97,8 +111,16 @@ def build_incident_graph(
builder.add_edge(START, "enrich")
builder.add_edge("enrich", "classify")
builder.add_edge("classify", "investigation")
- builder.add_edge("investigation", "remediation")
- builder.add_edge("remediation", "verification")
+ builder.add_conditional_edges(
+ "investigation",
+ _after_investigation,
+ {"remediation": "remediation", END: END},
+ )
+ builder.add_conditional_edges(
+ "remediation",
+ _after_remediation,
+ {"verification": "verification", END: END},
+ )
builder.add_conditional_edges(
"verification",
_after_verification,
diff --git a/src/stateops/graphs/investigation/graph.py b/src/stateops/graphs/investigation/graph.py
index 61d476f..2658a82 100644
--- a/src/stateops/graphs/investigation/graph.py
+++ b/src/stateops/graphs/investigation/graph.py
@@ -6,11 +6,16 @@
from langgraph.types import Send
from stateops.application.ports.clock import Clock
-from stateops.application.ports.reasoner import IncidentReasoner
+from stateops.application.ports.reasoner import IncidentReasoner, IncidentReasoningError
from stateops.domain.enums import IncidentPhase
from stateops.domain.models import Hypothesis
from stateops.domain.transitions import transition
from stateops.graphs.events import timeline_event
+from stateops.graphs.failures import (
+ failed_reasoning_state,
+ failed_state_from_branch_errors,
+ reasoning_error,
+)
from stateops.graphs.state import IncidentState
@@ -33,8 +38,18 @@ def __init__(self, reasoner: IncidentReasoner, clock: Clock) -> None:
async def generate_hypotheses(self, state: IncidentState) -> IncidentState:
"""Generate hypotheses, then persist them before fan-out."""
+ try:
+ hypotheses = await self._reasoner.generate_hypotheses(
+ state["incident"], state["context"]
+ )
+ except IncidentReasoningError as exc:
+ return failed_reasoning_state(
+ self._clock,
+ state,
+ node="generate_hypotheses",
+ error=exc,
+ )
phase = transition(state["phase"], IncidentPhase.HYPOTHESES_GENERATED)
- hypotheses = await self._reasoner.generate_hypotheses(state["incident"], state["context"])
return IncidentState(
phase=phase,
hypotheses=list(hypotheses),
@@ -87,7 +102,28 @@ async def investigate_hypothesis(self, state: InvestigationTaskState) -> Inciden
incident = cast(IncidentRecord, state["incident"])
context = cast(IncidentContext, state["context"])
hypothesis = state["hypothesis"]
- evidence = await self._reasoner.investigate(incident, context, hypothesis)
+ try:
+ evidence = await self._reasoner.investigate(incident, context, hypothesis)
+ except IncidentReasoningError as exc:
+ return IncidentState(
+ errors=[
+ reasoning_error(
+ incident_id=state["incident_id"],
+ node="investigate_hypothesis",
+ error=exc,
+ discriminator=hypothesis.id,
+ )
+ ],
+ timeline=[
+ timeline_event(
+ self._clock,
+ incident_id=state["incident_id"],
+ phase=IncidentPhase.INVESTIGATING,
+ event="reasoning.failed",
+ discriminator=f"investigate_hypothesis:{hypothesis.id}:{exc.code}",
+ )
+ ],
+ )
return IncidentState(
evidence=[evidence],
timeline=[
@@ -103,9 +139,19 @@ async def investigate_hypothesis(self, state: InvestigationTaskState) -> Inciden
async def synthesize(self, state: IncidentState) -> IncidentState:
"""Reduce accumulated evidence into one probable root cause."""
- root_cause = await self._reasoner.synthesize(
- tuple(state["hypotheses"]), tuple(state["evidence"])
- )
+ if state["errors"]:
+ return failed_state_from_branch_errors(self._clock, state)
+ try:
+ root_cause = await self._reasoner.synthesize(
+ tuple(state["hypotheses"]), tuple(state["evidence"])
+ )
+ except IncidentReasoningError as exc:
+ return failed_reasoning_state(
+ self._clock,
+ state,
+ node="synthesize_evidence",
+ error=exc,
+ )
phase = transition(state["phase"], IncidentPhase.EVIDENCE_READY)
return IncidentState(
root_cause=root_cause,
@@ -130,8 +176,19 @@ def build_investigation_graph(reasoner: IncidentReasoner, clock: Clock) -> Any:
builder.add_node("investigate_hypothesis", nodes.investigate_hypothesis)
builder.add_node("synthesize_evidence", nodes.synthesize)
builder.add_edge(START, "generate_hypotheses")
- builder.add_edge("generate_hypotheses", "mark_investigating")
+ builder.add_conditional_edges(
+ "generate_hypotheses",
+ _after_hypotheses,
+ {"mark_investigating": "mark_investigating", END: END},
+ )
builder.add_conditional_edges("mark_investigating", nodes.fan_out)
builder.add_edge("investigate_hypothesis", "synthesize_evidence")
builder.add_edge("synthesize_evidence", END)
return builder.compile()
+
+
+def _after_hypotheses(state: IncidentState) -> str:
+ """Stop the subgraph when hypothesis generation failed."""
+ if IncidentPhase(state["phase"]) is IncidentPhase.FAILED:
+ return END
+ return "mark_investigating"
diff --git a/src/stateops/graphs/remediation/graph.py b/src/stateops/graphs/remediation/graph.py
index a67f3d1..23625d9 100644
--- a/src/stateops/graphs/remediation/graph.py
+++ b/src/stateops/graphs/remediation/graph.py
@@ -8,11 +8,12 @@
from stateops.application.approvals import apply_approval, parse_approval
from stateops.application.idempotency import remediation_idempotency_key
from stateops.application.ports.clock import Clock
-from stateops.application.ports.reasoner import IncidentReasoner
+from stateops.application.ports.reasoner import IncidentReasoner, IncidentReasoningError
from stateops.application.ports.remediation import RemediationExecutor
from stateops.domain.enums import ActionKind, ApprovalOutcome, IncidentPhase, Severity
from stateops.domain.transitions import transition
from stateops.graphs.events import timeline_event
+from stateops.graphs.failures import failed_reasoning_state
from stateops.graphs.state import IncidentState
@@ -29,8 +30,19 @@ def __init__(
async def propose_actions(self, state: IncidentState) -> IncidentState:
"""Generate allowlisted candidates after evidence synthesis or replan."""
+ try:
+ actions = await self._reasoner.propose_remediations(
+ state["incident"], state["root_cause"]
+ )
+ except IncidentReasoningError as exc:
+ return failed_reasoning_state(
+ self._clock,
+ state,
+ node="propose_actions",
+ error=exc,
+ discriminator=str(state["attempts"]),
+ )
phase = transition(state["phase"], IncidentPhase.PLANNING)
- actions = await self._reasoner.propose_remediations(state["incident"], state["root_cause"])
return IncidentState(
phase=phase,
candidate_actions=list(actions),
@@ -178,7 +190,18 @@ def build_remediation_graph(
builder.add_node("execute_action", nodes.execute_action)
builder.add_node("replan", nodes.replan)
builder.add_edge(START, "propose_actions")
- builder.add_edge("propose_actions", "select_action")
+ builder.add_conditional_edges(
+ "propose_actions",
+ _after_proposal,
+ {"select_action": "select_action", END: END},
+ )
builder.add_edge("select_action", "human_approval")
builder.add_edge("execute_action", END)
return builder.compile(checkpointer=checkpointer)
+
+
+def _after_proposal(state: IncidentState) -> str:
+ """Stop the subgraph when action generation failed."""
+ if IncidentPhase(state["phase"]) is IncidentPhase.FAILED:
+ return END
+ return "select_action"
diff --git a/src/stateops/graphs/runtime.py b/src/stateops/graphs/runtime.py
index 2396b77..ca71945 100644
--- a/src/stateops/graphs/runtime.py
+++ b/src/stateops/graphs/runtime.py
@@ -146,3 +146,4 @@ async def stream_values(
async for value in stream.values:
yield _as_mapping(value)
await stream.output()
+ yield await self._latest(_config(incident_id))
diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py
index b4ad462..82bece2 100644
--- a/tests/unit/test_api.py
+++ b/tests/unit/test_api.py
@@ -117,6 +117,27 @@ def test_api_drives_incident_approval_history_and_time_travel() -> None:
assert invalid.status_code == 422
+def test_operator_console_is_local_packaged_and_csp_protected() -> None:
+ with TestClient(create_app(runtime=FakeRuntime())) as client:
+ page = client.get("/ui")
+ assert page.status_code == 200
+ assert page.headers["content-type"].startswith("text/html")
+ assert page.headers["content-security-policy"].startswith("default-src 'none'")
+ assert page.headers["x-content-type-options"] == "nosniff"
+ assert "StateOps Control Room" in page.text
+ assert '' in page.text
+
+ stylesheet = client.get("/ui/assets/app.css")
+ assert stylesheet.status_code == 200
+ assert stylesheet.headers["content-type"].startswith("text/css")
+
+ script = client.get("/ui/assets/app.js")
+ assert script.status_code == 200
+ assert "innerHTML" not in script.text
+ assert "incidents/${encodeURIComponent(incidentId)}/approval" in script.text
+ assert "URLSearchParams" in script.text
+
+
def test_api_validates_incidents_and_streams_v3_value_projection_as_sse() -> None:
with TestClient(create_app(runtime=FakeRuntime())) as client:
invalid = _payload()
diff --git a/tests/unit/test_governed_gateway.py b/tests/unit/test_governed_gateway.py
index 5d56a22..0744579 100644
--- a/tests/unit/test_governed_gateway.py
+++ b/tests/unit/test_governed_gateway.py
@@ -8,13 +8,15 @@
import pytest
from governed_llm_gateway_client import GatewayClient
+from governed_llm_gateway_client.errors import GatewayTransportError
+from governed_llm_gateway_contracts import ExecutionStatus, GatewayError, StructuredOutputSchema
from stateops.adapters.llm.governed_gateway import (
GatewayReasoningError,
GovernedGatewayIncidentReasoner,
)
from stateops.domain.enums import ActionKind
-from stateops.domain.models import IncidentContext, IncidentRecord, Signal
+from stateops.domain.models import IncidentContext, IncidentRecord, RootCause, Signal
class FakeGatewayClient:
@@ -27,7 +29,11 @@ def __init__(self, responses: list[str | None]) -> None:
async def generate(self, **kwargs: Any) -> object:
self.calls.append(kwargs)
- return SimpleNamespace(content=self.responses.pop(0))
+ return SimpleNamespace(
+ status=ExecutionStatus.SUCCEEDED,
+ content=self.responses.pop(0),
+ error=None,
+ )
async def aclose(self) -> None:
self.closed = True
@@ -64,7 +70,7 @@ async def scenario() -> None:
{
"id": "rollback-1",
"kind": "rollback_deployment",
- "parameters": {"target_version": "v2.30"},
+ "parameters": [{"name": "target_version", "value": "v2.30"}],
"risk": "high",
}
]
@@ -84,6 +90,131 @@ async def scenario() -> None:
assert fake.closed
assert all(call["workload"] == "stateops.incident.reasoning" for call in fake.calls)
assert all("provider" not in call and "model" not in call for call in fake.calls)
+ hypotheses_schema = cast(StructuredOutputSchema, fake.calls[0]["structured_output"])
+ actions_schema = cast(StructuredOutputSchema, fake.calls[3]["structured_output"])
+ hypotheses_properties = cast(dict[str, object], hypotheses_schema.schema["properties"])
+ hypotheses_array = cast(dict[str, object], hypotheses_properties["hypotheses"])
+ assert "maxItems" not in hypotheses_array
+ actions_properties = cast(dict[str, object], actions_schema.schema["properties"])
+ actions_array = cast(dict[str, object], actions_properties["actions"])
+ assert "maxItems" not in actions_array
+ action_items = actions_array["items"]
+ action_properties = cast(dict[str, object], action_items)["properties"]
+ parameters = cast(dict[str, object], action_properties)["parameters"]
+ parameter_items = cast(dict[str, object], parameters)["items"]
+ assert cast(dict[str, object], parameter_items)["additionalProperties"] is False
+
+ asyncio.run(scenario())
+
+
+def test_gateway_reasoner_preserves_failed_terminal_metadata() -> None:
+ class FailedGatewayClient(FakeGatewayClient):
+ async def generate(self, **kwargs: Any) -> object:
+ self.calls.append(kwargs)
+ return SimpleNamespace(
+ status=ExecutionStatus.FAILED,
+ content=None,
+ error=GatewayError(
+ code="invalid_request",
+ message="provider rejected the schema",
+ retryable=False,
+ ),
+ )
+
+ async def scenario() -> None:
+ reasoner = GovernedGatewayIncidentReasoner(cast(GatewayClient, FailedGatewayClient([])))
+ with pytest.raises(GatewayReasoningError) as raised:
+ await reasoner.generate_hypotheses(_incident(), _context())
+
+ assert raised.value.code == "invalid_request"
+ assert not raised.value.retryable
+
+ asyncio.run(scenario())
+
+
+def test_gateway_reasoner_marks_transport_failures_retryable() -> None:
+ class TransportFailureGatewayClient(FakeGatewayClient):
+ async def generate(self, **kwargs: Any) -> object:
+ self.calls.append(kwargs)
+ raise GatewayTransportError("gateway unavailable")
+
+ async def scenario() -> None:
+ reasoner = GovernedGatewayIncidentReasoner(
+ cast(GatewayClient, TransportFailureGatewayClient([]))
+ )
+ with pytest.raises(GatewayReasoningError) as raised:
+ await reasoner.generate_hypotheses(_incident(), _context())
+
+ assert raised.value.code == "gateway_transport_error"
+ assert raised.value.retryable
+
+ asyncio.run(scenario())
+
+
+def test_gateway_reasoner_enforces_collection_limits_after_decoding() -> None:
+ async def scenario() -> None:
+ too_many = {
+ "hypotheses": [
+ {
+ "id": f"hyp-{index}",
+ "title": "candidate",
+ "rationale": "signal",
+ "confidence": 0.5,
+ }
+ for index in range(9)
+ ]
+ }
+ reasoner = GovernedGatewayIncidentReasoner(
+ cast(GatewayClient, FakeGatewayClient([json.dumps(too_many)]))
+ )
+ with pytest.raises(GatewayReasoningError, match="between 1 and 8"):
+ await reasoner.generate_hypotheses(_incident(), _context())
+
+ asyncio.run(scenario())
+
+
+def test_gateway_reasoner_enforces_action_limits_after_decoding() -> None:
+ async def scenario() -> None:
+ too_many = {
+ "actions": [
+ {
+ "id": f"action-{index}",
+ "kind": "restart_service",
+ "parameters": [],
+ "risk": "medium",
+ }
+ for index in range(6)
+ ]
+ }
+ reasoner = GovernedGatewayIncidentReasoner(
+ cast(GatewayClient, FakeGatewayClient([json.dumps(too_many)]))
+ )
+ with pytest.raises(GatewayReasoningError, match="between 1 and 5"):
+ await reasoner.propose_remediations(_incident(), RootCause("hyp-1", "regression", 0.9))
+
+ asyncio.run(scenario())
+
+
+def test_gateway_reasoner_rejects_duplicate_action_parameter_names() -> None:
+ async def scenario() -> None:
+ response = {
+ "actions": [
+ {
+ "id": "rollback-1",
+ "kind": "rollback_deployment",
+ "parameters": [
+ {"name": "target_version", "value": "v2.30"},
+ {"name": "target_version", "value": "v2.29"},
+ ],
+ "risk": "high",
+ }
+ ]
+ }
+ reasoner = GovernedGatewayIncidentReasoner(
+ cast(GatewayClient, FakeGatewayClient([json.dumps(response)]))
+ )
+ with pytest.raises(GatewayReasoningError, match="duplicate parameter names"):
+ await reasoner.propose_remediations(_incident(), RootCause("hyp-1", "regression", 0.9))
asyncio.run(scenario())
diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py
index 2bf3be5..4ea0706 100644
--- a/tests/unit/test_graph.py
+++ b/tests/unit/test_graph.py
@@ -4,15 +4,26 @@
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
+import pytest
from langgraph.types import Command
from stateops.adapters.incidents.synthetic_signals import SyntheticIncidentSignals
from stateops.adapters.llm.deterministic import DeterministicIncidentReasoner
from stateops.adapters.persistence.checkpointer import memory_checkpointer
from stateops.adapters.remediation.simulated_executor import SimulatedRemediationExecutor
+from stateops.application.ports.reasoner import IncidentReasoningError
from stateops.application.runtime_models import RunResult
from stateops.domain.enums import IncidentPhase
-from stateops.domain.models import ExecutionResult, IncidentRecord, RootCause
+from stateops.domain.models import (
+ Evidence,
+ ExecutionResult,
+ Hypothesis,
+ IncidentContext,
+ IncidentRecord,
+ RemediationAction,
+ RootCause,
+ WorkflowError,
+)
from stateops.graphs.incident_graph import build_incident_graph
from stateops.graphs.remediation.graph import build_remediation_graph
from stateops.graphs.runtime import GraphIncidentRuntime
@@ -31,6 +42,45 @@ def now(self) -> datetime:
return value
+class FailingReasoner(DeterministicIncidentReasoner):
+ """Raise a metadata-safe failure at one selected reasoning stage."""
+
+ def __init__(self, stage: str) -> None:
+ self._stage = stage
+
+ def _raise_if_selected(self, stage: str) -> None:
+ if self._stage == stage:
+ raise IncidentReasoningError(
+ "reasoning failed",
+ code=f"{stage}_failed",
+ retryable=stage == "investigate",
+ )
+
+ async def generate_hypotheses(
+ self, incident: IncidentRecord, context: IncidentContext
+ ) -> tuple[Hypothesis, ...]:
+ self._raise_if_selected("generate")
+ return await super().generate_hypotheses(incident, context)
+
+ async def investigate(
+ self, incident: IncidentRecord, context: IncidentContext, hypothesis: Hypothesis
+ ) -> Evidence:
+ self._raise_if_selected("investigate")
+ return await super().investigate(incident, context, hypothesis)
+
+ async def synthesize(
+ self, hypotheses: tuple[Hypothesis, ...], evidence: tuple[Evidence, ...]
+ ) -> RootCause:
+ self._raise_if_selected("synthesize")
+ return await super().synthesize(hypotheses, evidence)
+
+ async def propose_remediations(
+ self, incident: IncidentRecord, root_cause: RootCause
+ ) -> tuple[RemediationAction, ...]:
+ self._raise_if_selected("propose")
+ return await super().propose_remediations(incident, root_cause)
+
+
def _incident() -> IncidentRecord:
return IncidentRecord("checkout", 0.2, 18.0, "v2.31", datetime(2026, 1, 1, tzinfo=UTC))
@@ -89,6 +139,24 @@ async def scenario() -> None:
asyncio.run(scenario())
+def test_value_stream_ends_with_the_persisted_interrupt_state() -> None:
+ async def scenario() -> None:
+ runtime, _ = _runtime()
+
+ snapshots = [
+ snapshot async for snapshot in runtime.stream_values("INC-STREAM", _incident())
+ ]
+
+ assert snapshots
+ assert snapshots[-1]["phase"] is IncidentPhase.WAITING_APPROVAL
+ evidence = snapshots[-1]["evidence"]
+ assert isinstance(evidence, list)
+ assert len(evidence) == 3
+ assert snapshots[-1]["selected_action"] is not None
+
+ asyncio.run(scenario())
+
+
def test_fork_selects_a_controlled_alternative_without_erasing_original_history() -> None:
async def scenario() -> None:
runtime, executor = _runtime()
@@ -155,3 +223,30 @@ async def scenario() -> None:
assert replayed["execution_result"].duplicate
asyncio.run(scenario())
+
+
+@pytest.mark.parametrize("stage", ["generate", "investigate", "synthesize", "propose"])
+def test_reasoning_failures_become_terminal_serializable_state(stage: str) -> None:
+ async def scenario() -> None:
+ clock = FakeClock()
+ graph = build_incident_graph(
+ reasoner=FailingReasoner(stage),
+ signals=SyntheticIncidentSignals(),
+ executor=SimulatedRemediationExecutor(clock),
+ clock=clock,
+ checkpointer=memory_checkpointer(),
+ )
+ runtime = GraphIncidentRuntime(graph)
+
+ result = await runtime.start(f"INC-FAILED-{stage}", _incident())
+
+ assert not result.interrupted
+ assert result.values["phase"] is IncidentPhase.FAILED
+ errors = result.values["errors"]
+ assert isinstance(errors, list)
+ assert errors
+ assert all(isinstance(error, WorkflowError) for error in errors)
+ assert {error.category for error in errors} == {f"{stage}_failed"}
+ assert await runtime.history(f"INC-FAILED-{stage}")
+
+ asyncio.run(scenario())