phlo-api 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {phlo_api-0.3.1 → phlo_api-0.4.0}/PKG-INFO +1 -1
  2. {phlo_api-0.3.1 → phlo_api-0.4.0}/pyproject.toml +1 -1
  3. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2.py +59 -212
  4. phlo_api-0.4.0/src/phlo_api/observatory_api/v2_cache.py +37 -0
  5. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_capabilities.py +6 -0
  6. phlo_api-0.4.0/src/phlo_api/observatory_api/v2_saved_queries.py +94 -0
  7. phlo_api-0.4.0/src/phlo_api/observatory_api/v2_search.py +124 -0
  8. phlo_api-0.4.0/src/phlo_api/observatory_api/v2_services.py +438 -0
  9. phlo_api-0.4.0/src/phlo_api/observatory_api/v2_workflow_wizard.py +1135 -0
  10. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/PKG-INFO +1 -1
  11. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/SOURCES.txt +7 -0
  12. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_api.py +2 -0
  13. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_capabilities.py +1 -0
  14. phlo_api-0.4.0/tests/test_observatory_v2_read_models.py +76 -0
  15. phlo_api-0.4.0/tests/test_observatory_v2_workflow_wizard.py +291 -0
  16. {phlo_api-0.3.1 → phlo_api-0.4.0}/README.md +0 -0
  17. {phlo_api-0.3.1 → phlo_api-0.4.0}/setup.cfg +0 -0
  18. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/Dockerfile +0 -0
  19. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/__init__.py +0 -0
  20. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/__init__.py +0 -0
  21. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/authentication.py +0 -0
  22. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/authorization.py +0 -0
  23. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/maintenance.py +0 -0
  24. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/api/observability.py +0 -0
  25. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/main.py +0 -0
  26. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/__init__.py +0 -0
  27. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/contributing.py +0 -0
  28. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/dagster.py +0 -0
  29. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/extension_settings.py +0 -0
  30. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/extensions.py +0 -0
  31. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/iceberg.py +0 -0
  32. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/lineage.py +0 -0
  33. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/loki.py +0 -0
  34. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/nessie.py +0 -0
  35. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/quality.py +0 -0
  36. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/search.py +0 -0
  37. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/settings.py +0 -0
  38. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/trino.py +0 -0
  39. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/trino_sql.py +0 -0
  40. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_actions.py +0 -0
  41. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_catalog.py +0 -0
  42. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_governance.py +0 -0
  43. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_metadata.py +0 -0
  44. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_models.py +0 -0
  45. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_observability.py +0 -0
  46. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_products.py +0 -0
  47. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_runs.py +0 -0
  48. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/observatory_api/v2_storage.py +0 -0
  49. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/plugin.py +0 -0
  50. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/regulated_surface_adapter.py +0 -0
  51. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api/service.yaml +0 -0
  52. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/dependency_links.txt +0 -0
  53. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/entry_points.txt +0 -0
  54. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/requires.txt +0 -0
  55. {phlo_api-0.3.1 → phlo_api-0.4.0}/src/phlo_api.egg-info/top_level.txt +0 -0
  56. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_api_backend_endpoints.py +0 -0
  57. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_api_import_boundaries.py +0 -0
  58. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_authentication_api.py +0 -0
  59. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_authorization_api.py +0 -0
  60. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_cors.py +0 -0
  61. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_integration_api.py +0 -0
  62. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_lineage_api.py +0 -0
  63. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_loki_api.py +0 -0
  64. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_maintenance_api.py +0 -0
  65. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observability_api.py +0 -0
  66. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_actions.py +0 -0
  67. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_catalog.py +0 -0
  68. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_observability.py +0 -0
  69. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_storage.py +0 -0
  70. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_observatory_v2_surfaces.py +0 -0
  71. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_rest_api.py +0 -0
  72. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_service_correlation_runtime.py +0 -0
  73. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_service_url_resolution.py +0 -0
  74. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_trino_defaults.py +0 -0
  75. {phlo_api-0.3.1 → phlo_api-0.4.0}/tests/test_trino_sql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phlo-api
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Phlo API - Backend service exposing Phlo internals to Observatory
5
5
  Author-email: Phlo Team <team@phlo.dev>
6
6
  License: MIT
@@ -19,7 +19,7 @@ dependencies = [
19
19
  description = "Phlo API - Backend service exposing Phlo internals to Observatory"
20
20
  name = "phlo-api"
21
21
  requires-python = ">=3.11"
22
- version = "0.3.1"
22
+ version = "0.4.0"
23
23
 
24
24
  [[project.authors]]
25
25
  email = "team@phlo.dev"
@@ -15,18 +15,14 @@ import re
15
15
  import socket
16
16
  import subprocess
17
17
  import sys
18
- import threading
19
- import time
20
- from datetime import UTC, datetime
21
18
  from typing import Any
22
- from urllib.parse import quote
23
- from uuid import uuid4
24
19
 
25
20
  from fastapi import APIRouter
26
21
  from fastapi import HTTPException
27
22
  from pydantic import BaseModel
28
23
 
29
24
  from phlo_api.observatory_api.v2_actions import execute_v2_action
25
+ from phlo_api.observatory_api.v2_cache import ReadModelCache
30
26
  from phlo_api.observatory_api.v2_capabilities import build_capability_inventory
31
27
  from phlo_api.observatory_api.v2_catalog import load_catalog_items
32
28
  from phlo_api.observatory_api.v2_governance import load_governance_items
@@ -78,7 +74,24 @@ from phlo_api.observatory_api.v2_metadata import safe_metadata as _safe_metadata
78
74
  from phlo_api.observatory_api.v2_observability import load_observability_items
79
75
  from phlo_api.observatory_api.v2_products import load_api_items, load_bi_items
80
76
  from phlo_api.observatory_api.v2_runs import load_runs
77
+ from phlo_api.observatory_api.v2_saved_queries import (
78
+ dedupe_saved_queries as _dedupe_saved_queries_impl,
79
+ load_saved_queries as _load_saved_queries_impl,
80
+ save_query as _save_query_impl,
81
+ validate_saved_query_sql as _validate_saved_query_sql_impl,
82
+ write_saved_queries as _write_saved_queries_impl,
83
+ )
84
+ from phlo_api.observatory_api.v2_search import search_results as _search_results_impl
85
+ from phlo_api.observatory_api.v2_services import load_services as _load_services_impl
81
86
  from phlo_api.observatory_api.v2_storage import load_storage_items
87
+ from phlo_api.observatory_api.v2_workflow_wizard import (
88
+ V2WorkflowActionRequest,
89
+ V2WorkflowActionResult,
90
+ V2WorkflowProposalRequest,
91
+ apply_workflow_action,
92
+ build_workflow_proposal,
93
+ build_workflow_wizard_payload,
94
+ )
82
95
 
83
96
  router = APIRouter(tags=["observatory-v2"])
84
97
 
@@ -98,34 +111,16 @@ _ENV_DEFAULT_RE = re.compile(r"^\$\{[^}:]+:-(?P<default>[^}]+)\}$")
98
111
  _TABLE_LIST_METADATA_PREFIX_DENYLIST = ("phlo/compiled_sql",)
99
112
  _FAST_READ_MODEL_TTL_SECONDS = 30
100
113
  _EXPENSIVE_READ_MODEL_TTL_SECONDS = 120
101
- _READ_MODEL_CACHE: dict[tuple[str, str], tuple[float, Any]] = {}
102
- _READ_MODEL_CACHE_LOCK = threading.RLock()
114
+ _READ_MODEL_CACHE = ReadModelCache(project_key=lambda: str(_project_root()))
103
115
  _DOCKER_SOCKET = "/var/run/docker.sock"
104
116
 
105
117
 
106
- def _read_model_cache_key(name: str) -> tuple[str, str]:
107
- return (str(_project_root()), name)
108
-
109
-
110
118
  def _cached_read_model(name: str, ttl_seconds: float, loader: Any) -> Any:
111
- key = _read_model_cache_key(name)
112
- now = time.monotonic()
113
-
114
- with _READ_MODEL_CACHE_LOCK:
115
- cached = _READ_MODEL_CACHE.get(key)
116
- if cached is not None:
117
- expires_at, value = cached
118
- if expires_at > now:
119
- return value
120
-
121
- value = loader()
122
- _READ_MODEL_CACHE[key] = (time.monotonic() + ttl_seconds, value)
123
- return value
119
+ return _READ_MODEL_CACHE.cached(name, ttl_seconds, loader)
124
120
 
125
121
 
126
122
  def _clear_read_model_cache() -> None:
127
- with _READ_MODEL_CACHE_LOCK:
128
- _READ_MODEL_CACHE.clear()
123
+ _READ_MODEL_CACHE.clear()
129
124
 
130
125
 
131
126
  class V2ServiceList(BaseModel):
@@ -625,56 +620,7 @@ def _service_config_from_definition(service: Any) -> list[V2ServiceConfigEntry]:
625
620
 
626
621
 
627
622
  def _load_services() -> list[V2Service]:
628
- """Load services through core discovery, falling back deterministically."""
629
- try:
630
- from phlo.plugins.discovery import ServiceDiscovery
631
-
632
- discovered = ServiceDiscovery().discover().values()
633
- except Exception:
634
- discovered = []
635
-
636
- services: list[V2Service] = []
637
- containers = _load_docker_containers()
638
- discovered = list(discovered)
639
- runtime_statuses = _load_docker_service_statuses({service.name for service in discovered})
640
- for service in discovered:
641
- in_stack = service.name in runtime_statuses
642
- status, health = runtime_statuses.get(
643
- service.name,
644
- ("unknown", V2Health(state="unknown", message="Runtime status unavailable")),
645
- )
646
- services.append(
647
- V2Service(
648
- id=service.name,
649
- name=service.name,
650
- kind=service.category or "service",
651
- status=status,
652
- health=health,
653
- definition_state="configured" if in_stack else "available",
654
- runtime_state=status,
655
- in_stack=in_stack,
656
- disabled=bool(getattr(service, "disabled", False)),
657
- profile=_coerce_str(service.profile, "") or None,
658
- backend="docker" if in_stack else "unknown",
659
- depends_on=list(service.depends_on or []),
660
- impacts=[],
661
- links=_service_links_from_definition(service),
662
- metadata=_safe_metadata(
663
- {
664
- "default": bool(service.default),
665
- "profile": service.profile,
666
- "core": bool(getattr(service, "core", False)),
667
- "description": getattr(service, "description", None),
668
- }
669
- ),
670
- )
671
- )
672
-
673
- services.extend(
674
- _runtime_services_from_containers(containers, {service.id for service in services})
675
- )
676
-
677
- return sorted(services, key=lambda item: item.id) if services else _fallback_services()
623
+ return _load_services_impl(_project_root(), containers=_load_docker_containers())
678
624
 
679
625
 
680
626
  def _overview_health_from_services(services: Sequence[V2Service]) -> V2Health:
@@ -1619,73 +1565,23 @@ def _row_offset(row_id: str) -> int:
1619
1565
 
1620
1566
 
1621
1567
  def _load_saved_queries() -> list[V2SavedQuery]:
1622
- path = _saved_queries_path()
1623
- if not path.exists():
1624
- return []
1625
- try:
1626
- payload = json.loads(path.read_text(encoding="utf-8"))
1627
- except (OSError, json.JSONDecodeError):
1628
- return []
1629
- items = payload.get("items") if isinstance(payload, Mapping) else None
1630
- if not isinstance(items, list):
1631
- return []
1632
- queries: list[V2SavedQuery] = []
1633
- for item in items:
1634
- if isinstance(item, Mapping):
1635
- try:
1636
- queries.append(V2SavedQuery.model_validate(item))
1637
- except Exception:
1638
- continue
1639
- return _dedupe_saved_queries(queries)
1568
+ return _load_saved_queries_impl(_project_root())
1640
1569
 
1641
1570
 
1642
1571
  def _dedupe_saved_queries(queries: list[V2SavedQuery]) -> list[V2SavedQuery]:
1643
- unique: dict[tuple[str, str, str], V2SavedQuery] = {}
1644
- for query in sorted(queries, key=lambda item: item.updated_at, reverse=True):
1645
- key = (
1646
- query.name.strip().casefold(),
1647
- " ".join(query.sql.split()).casefold(),
1648
- (query.branch or "main").strip().casefold(),
1649
- )
1650
- unique.setdefault(key, query)
1651
- return list(unique.values())
1572
+ return _dedupe_saved_queries_impl(queries)
1652
1573
 
1653
1574
 
1654
1575
  def _write_saved_queries(queries: list[V2SavedQuery]) -> None:
1655
- _saved_queries_path().write_text(
1656
- json.dumps({"items": [query.model_dump() for query in queries]}, indent=2),
1657
- encoding="utf-8",
1658
- )
1576
+ _write_saved_queries_impl(_project_root(), queries)
1659
1577
 
1660
1578
 
1661
1579
  def _save_query(request: V2SavedQueryRequest) -> V2SavedQuery:
1662
- if not request.name.strip():
1663
- raise HTTPException(status_code=400, detail="Saved query name is required.")
1664
- validate_error = _validate_saved_query_sql(request.sql)
1665
- if validate_error:
1666
- raise HTTPException(status_code=400, detail=validate_error)
1667
-
1668
- now = datetime.now(UTC).isoformat()
1669
- query = V2SavedQuery(
1670
- id=f"query-{uuid4().hex[:12]}",
1671
- name=request.name.strip(),
1672
- sql=request.sql.strip(),
1673
- branch=request.branch,
1674
- created_at=now,
1675
- updated_at=now,
1676
- metadata=_safe_metadata(request.metadata),
1677
- )
1678
- queries = _dedupe_saved_queries([query, *_load_saved_queries()])
1679
- _write_saved_queries(queries[:100])
1680
- return query
1580
+ return _save_query_impl(_project_root(), request)
1681
1581
 
1682
1582
 
1683
1583
  def _validate_saved_query_sql(sql: str) -> str | None:
1684
- if not sql.strip():
1685
- return "SQL is required."
1686
- if _READ_QUERY_RE.match(sql) is None:
1687
- return "Only read-only SELECT * FROM <known_table> [LIMIT n] queries can be saved."
1688
- return None
1584
+ return _validate_saved_query_sql_impl(sql)
1689
1585
 
1690
1586
 
1691
1587
  def _load_stage_diff(source_table_id: str, target_table_id: str) -> V2StageDiff:
@@ -1797,87 +1693,15 @@ def _load_branch_detail(branch_name: str) -> V2BranchDetail:
1797
1693
 
1798
1694
 
1799
1695
  def _search_results(query: str) -> list[V2SearchResult]:
1800
- needle = query.strip().lower()
1801
- if not needle:
1802
- return []
1803
-
1804
- results: list[V2SearchResult] = []
1805
- for service in _load_services():
1806
- haystack = " ".join([service.id, service.name, service.kind, service.status]).lower()
1807
- if needle in haystack:
1808
- results.append(
1809
- V2SearchResult(
1810
- id=f"service:{service.id}",
1811
- label=service.name,
1812
- kind="service",
1813
- summary=f"{service.kind} · {service.status}",
1814
- href="/services",
1815
- )
1816
- )
1817
-
1818
- for asset in _load_assets():
1819
- haystack = " ".join(
1820
- [asset.id, asset.name, asset.group or "", asset.description or "", *asset.kinds]
1821
- ).lower()
1822
- if needle in haystack:
1823
- results.append(
1824
- V2SearchResult(
1825
- id=f"asset:{asset.id}",
1826
- label=asset.name,
1827
- kind="asset",
1828
- summary=asset.description or asset.group,
1829
- href=f"/asset/{_route_path_segment(asset.id)}",
1830
- )
1831
- )
1832
-
1833
- for table in _load_tables_without_catalog():
1834
- haystack = " ".join(
1835
- [table.id, table.name, table.namespace or "", table.format or "", table.branch or ""]
1836
- ).lower()
1837
- if needle in haystack:
1838
- results.append(
1839
- V2SearchResult(
1840
- id=f"table:{table.id}",
1841
- label=table.namespace + "." + table.name if table.namespace else table.name,
1842
- kind="table",
1843
- summary=f"{table.format or 'table'} · {table.branch or 'main'}",
1844
- href=f"/table/{_route_path_segment(table.id)}",
1845
- )
1846
- )
1847
-
1848
- for check in _load_quality():
1849
- haystack = " ".join(
1850
- [check.id, check.name, check.asset_id, check.status, check.severity or ""]
1851
- ).lower()
1852
- if needle in haystack:
1853
- results.append(
1854
- V2SearchResult(
1855
- id=f"quality:{check.id}",
1856
- label=check.name,
1857
- kind="quality",
1858
- summary=f"{check.asset_id} · {check.status}",
1859
- href="/quality",
1860
- )
1861
- )
1862
-
1863
- for extension in _load_extensions():
1864
- haystack = " ".join([extension.id, extension.name, extension.version or ""]).lower()
1865
- if needle in haystack:
1866
- results.append(
1867
- V2SearchResult(
1868
- id=f"extension:{extension.id}",
1869
- label=extension.name,
1870
- kind="extension",
1871
- summary=extension.settings_scope or extension.version,
1872
- href=f"/extension/{_route_path_segment(extension.id)}",
1873
- )
1874
- )
1875
-
1876
- return results[:25]
1877
-
1878
-
1879
- def _route_path_segment(resource_id: str) -> str:
1880
- return quote(resource_id, safe="")
1696
+ return _search_results_impl(
1697
+ query=query,
1698
+ services=_load_services(),
1699
+ assets=_load_assets(),
1700
+ tables=_load_tables_without_catalog(),
1701
+ operations=_load_operations(),
1702
+ quality=_load_quality(),
1703
+ extensions=_load_extensions(),
1704
+ )
1881
1705
 
1882
1706
 
1883
1707
  def _load_extension_detail(extension_id: str) -> V2ExtensionDetail:
@@ -2555,6 +2379,29 @@ def get_v2_settings() -> V2Settings:
2555
2379
  return _cached_read_model("settings", _EXPENSIVE_READ_MODEL_TTL_SECONDS, _load_settings)
2556
2380
 
2557
2381
 
2382
+ @router.get("/workflow-wizard")
2383
+ def get_v2_workflow_wizard() -> dict[str, Any]:
2384
+ """Return provider-neutral workflow wizard contributions."""
2385
+
2386
+ return build_workflow_wizard_payload()
2387
+
2388
+
2389
+ @router.post("/workflow-wizard/proposals")
2390
+ def post_v2_workflow_wizard_proposal(request: V2WorkflowProposalRequest) -> dict[str, Any]:
2391
+ """Build a side-effect-free workflow proposal."""
2392
+
2393
+ return build_workflow_proposal(_project_root(), request)
2394
+
2395
+
2396
+ @router.post("/workflow-wizard/actions", response_model=V2WorkflowActionResult)
2397
+ def post_v2_workflow_wizard_action(request: V2WorkflowActionRequest) -> V2WorkflowActionResult:
2398
+ """Run a guarded workflow wizard apply action."""
2399
+
2400
+ result = apply_workflow_action(_project_root(), request)
2401
+ _clear_read_model_cache()
2402
+ return result
2403
+
2404
+
2558
2405
  @router.get("/search", response_model=V2SearchList)
2559
2406
  def get_v2_search(q: str) -> V2SearchList:
2560
2407
  """Search provider-neutral Observatory v2 resources."""
@@ -0,0 +1,37 @@
1
+ """Shared Observatory v2 read model cache helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class ReadModelCache:
14
+ """Small TTL cache scoped by project and read model name."""
15
+
16
+ project_key: Callable[[], str]
17
+ _values: dict[tuple[str, str], tuple[float, Any]] = field(default_factory=dict)
18
+ _lock: threading.RLock = field(default_factory=threading.RLock)
19
+
20
+ def cached(self, name: str, ttl_seconds: float, loader: Callable[[], Any]) -> Any:
21
+ key = (self.project_key(), name)
22
+ now = time.monotonic()
23
+
24
+ with self._lock:
25
+ cached = self._values.get(key)
26
+ if cached is not None:
27
+ expires_at, value = cached
28
+ if expires_at > now:
29
+ return value
30
+
31
+ value = loader()
32
+ self._values[key] = (time.monotonic() + ttl_seconds, value)
33
+ return value
34
+
35
+ def clear(self) -> None:
36
+ with self._lock:
37
+ self._values.clear()
@@ -50,6 +50,12 @@ ROUTE_REQUIREMENTS = [
50
50
  required_any=["query_engine", "table_store"],
51
51
  reason="Install a query engine or table store to browse data.",
52
52
  ),
53
+ V2RouteRequirement(
54
+ route_id="workflows",
55
+ label="Workflows",
56
+ path="/workflows/new",
57
+ reason="Core guided workflow creation surface.",
58
+ ),
53
59
  V2RouteRequirement(
54
60
  route_id="assets",
55
61
  label="Assets",
@@ -0,0 +1,94 @@
1
+ """Saved-query read model persistence for Observatory v2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import UTC, datetime
7
+ import json
8
+ from pathlib import Path
9
+ import re
10
+ from uuid import uuid4
11
+
12
+ from fastapi import HTTPException
13
+
14
+ from phlo_api.observatory_api.v2_metadata import safe_metadata
15
+ from phlo_api.observatory_api.v2_models import V2SavedQuery, V2SavedQueryRequest
16
+
17
+ READ_QUERY_RE = re.compile(
18
+ r"^\s*select\s+\*\s+from\s+(?P<table>[A-Za-z0-9_.:-]+)(?:\s+limit\s+(?P<limit>\d+))?\s*;?\s*$",
19
+ re.IGNORECASE,
20
+ )
21
+
22
+
23
+ def saved_queries_path(project_root: Path) -> Path:
24
+ state_dir = project_root / ".phlo" / "observatory-v2"
25
+ state_dir.mkdir(parents=True, exist_ok=True)
26
+ return state_dir / "saved_queries.json"
27
+
28
+
29
+ def load_saved_queries(project_root: Path) -> list[V2SavedQuery]:
30
+ path = saved_queries_path(project_root)
31
+ if not path.exists():
32
+ return []
33
+ try:
34
+ payload = json.loads(path.read_text(encoding="utf-8"))
35
+ except (OSError, json.JSONDecodeError):
36
+ return []
37
+ items = payload.get("items") if isinstance(payload, Mapping) else None
38
+ if not isinstance(items, list):
39
+ return []
40
+ queries: list[V2SavedQuery] = []
41
+ for item in items:
42
+ if isinstance(item, Mapping):
43
+ try:
44
+ queries.append(V2SavedQuery.model_validate(item))
45
+ except Exception:
46
+ continue
47
+ return dedupe_saved_queries(queries)
48
+
49
+
50
+ def dedupe_saved_queries(queries: list[V2SavedQuery]) -> list[V2SavedQuery]:
51
+ unique: dict[tuple[str, str, str], V2SavedQuery] = {}
52
+ for query in sorted(queries, key=lambda item: item.updated_at, reverse=True):
53
+ key = (
54
+ query.name.strip().casefold(),
55
+ " ".join(query.sql.split()).casefold(),
56
+ (query.branch or "main").strip().casefold(),
57
+ )
58
+ unique.setdefault(key, query)
59
+ return list(unique.values())
60
+
61
+
62
+ def write_saved_queries(project_root: Path, queries: list[V2SavedQuery]) -> None:
63
+ saved_queries_path(project_root).write_text(
64
+ json.dumps({"items": [query.model_dump() for query in queries]}, indent=2),
65
+ encoding="utf-8",
66
+ )
67
+
68
+
69
+ def save_query(project_root: Path, request: V2SavedQueryRequest) -> V2SavedQuery:
70
+ if not request.name.strip():
71
+ raise HTTPException(status_code=400, detail="Saved query name is required.")
72
+ validate_error = validate_saved_query_sql(request.sql)
73
+ if validate_error:
74
+ raise HTTPException(status_code=400, detail=validate_error)
75
+
76
+ now = datetime.now(UTC).isoformat()
77
+ query = V2SavedQuery(
78
+ id=f"query-{uuid4().hex[:12]}",
79
+ name=request.name.strip(),
80
+ sql=request.sql.strip(),
81
+ branch=request.branch,
82
+ created_at=now,
83
+ updated_at=now,
84
+ metadata=safe_metadata(request.metadata),
85
+ )
86
+ queries = dedupe_saved_queries([query, *load_saved_queries(project_root)])
87
+ write_saved_queries(project_root, queries[:100])
88
+ return query
89
+
90
+
91
+ def validate_saved_query_sql(sql: str) -> str | None:
92
+ if READ_QUERY_RE.match(sql):
93
+ return None
94
+ return "Only simple SELECT preview queries can be saved."
@@ -0,0 +1,124 @@
1
+ """Search result composition for Observatory v2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from urllib.parse import quote
7
+
8
+ from phlo_api.observatory_api.v2_models import (
9
+ V2Asset,
10
+ V2Extension,
11
+ V2Operation,
12
+ V2QualityCheck,
13
+ V2SearchResult,
14
+ V2Service,
15
+ V2Table,
16
+ )
17
+
18
+
19
+ def search_results(
20
+ *,
21
+ query: str,
22
+ services: Sequence[V2Service],
23
+ assets: Sequence[V2Asset],
24
+ tables: Sequence[V2Table],
25
+ operations: Sequence[V2Operation],
26
+ quality: Sequence[V2QualityCheck] = (),
27
+ extensions: Sequence[V2Extension] = (),
28
+ ) -> list[V2SearchResult]:
29
+ needle = query.strip().lower()
30
+ if not needle:
31
+ return []
32
+
33
+ results: list[V2SearchResult] = []
34
+ for service in services:
35
+ haystack = " ".join([service.id, service.name, service.kind, service.status]).lower()
36
+ if needle in haystack:
37
+ results.append(
38
+ V2SearchResult(
39
+ id=f"service:{service.id}",
40
+ label=service.name,
41
+ kind="service",
42
+ summary=f"{service.kind} · {service.status}",
43
+ href="/services",
44
+ )
45
+ )
46
+
47
+ for asset in assets:
48
+ haystack = " ".join(
49
+ [asset.id, asset.name, asset.group or "", asset.description or "", *asset.kinds]
50
+ ).lower()
51
+ if needle in haystack:
52
+ results.append(
53
+ V2SearchResult(
54
+ id=f"asset:{asset.id}",
55
+ label=asset.name,
56
+ kind="asset",
57
+ summary=asset.description or asset.group,
58
+ href=f"/asset/{route_path_segment(asset.id)}",
59
+ )
60
+ )
61
+
62
+ for table in tables:
63
+ haystack = " ".join(
64
+ [table.id, table.name, table.namespace or "", table.format or "", table.branch or ""]
65
+ ).lower()
66
+ if needle in haystack:
67
+ results.append(
68
+ V2SearchResult(
69
+ id=f"table:{table.id}",
70
+ label=table.namespace + "." + table.name if table.namespace else table.name,
71
+ kind="table",
72
+ summary=f"{table.format or 'table'} · {table.branch or 'main'}",
73
+ href=f"/table/{route_path_segment(table.id)}",
74
+ )
75
+ )
76
+
77
+ for operation in operations:
78
+ haystack = " ".join(
79
+ [operation.id, operation.name, operation.kind, operation.status]
80
+ ).lower()
81
+ if needle in haystack:
82
+ results.append(
83
+ V2SearchResult(
84
+ id=f"operation:{operation.id}",
85
+ label=operation.name,
86
+ kind="operation",
87
+ summary=f"{operation.kind} · {operation.status}",
88
+ href=f"/operations/{route_path_segment(operation.id)}",
89
+ )
90
+ )
91
+
92
+ for check in quality:
93
+ haystack = " ".join(
94
+ [check.id, check.name, check.asset_id, check.status, check.severity or ""]
95
+ ).lower()
96
+ if needle in haystack:
97
+ results.append(
98
+ V2SearchResult(
99
+ id=f"quality:{check.id}",
100
+ label=check.name,
101
+ kind="quality",
102
+ summary=f"{check.asset_id} · {check.status}",
103
+ href="/quality",
104
+ )
105
+ )
106
+
107
+ for extension in extensions:
108
+ haystack = " ".join([extension.id, extension.name, extension.version or ""]).lower()
109
+ if needle in haystack:
110
+ results.append(
111
+ V2SearchResult(
112
+ id=f"extension:{extension.id}",
113
+ label=extension.name,
114
+ kind="extension",
115
+ summary=extension.settings_scope or extension.version,
116
+ href=f"/extension/{route_path_segment(extension.id)}",
117
+ )
118
+ )
119
+
120
+ return results[:25]
121
+
122
+
123
+ def route_path_segment(resource_id: str) -> str:
124
+ return quote(resource_id, safe="")