mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 12:18:39 +00:00
gateway: passive GET /api/widgets, discovery-free module endpoint, drop dead ui_tabs_pending
- New ouroboros/gateway/widgets.py: GET /api/widgets projects live UI tabs from the in-memory extension snapshot only (no discover_skills, no stale-review reconcile, no schedule sync, no hashing, no writes). Each card carries the owning skill's live payload content_hash as `revision` -- a revision fact for the page's change signature, not an ETag. Wired through router, endpoint_index, api_client (`apiClient.widgets`), api_types (WidgetTab, WidgetsResponse) and the parity/smoke suites. The Widgets page itself still reads GET /api/extensions; the frontend switch is a later phase. - api_extension_module: authorization is the live loader state alone. `extension_loader.live_bundle_facts` resolves the reviewed payload directory from the loaded bundle and the live tab registration must still declare exactly this entry, so both discover_skills walks leave this hot read path. Adds `Access-Control-Allow-Origin: *` (the opaque-origin module frame fetches anonymously cross-origin); `Cache-Control: no-store` stays. An unloaded skill now answers 409 "not live" before the 404 entry check. - Remove the dead two-phase fields `ui_host_pending` / `ui_tabs_pending` (always True / always []; no frontend, CLI, or colab reader) and their test pins; type the `live` block of GET /api/extensions as ExtensionLiveSnapshot (homed in gateway/widgets.py to keep contracts.py in its size band). - ARCHITECTURE.md: module tree entry, endpoint rows, Skills and Widgets rationale for the passive read and the CORS header. - tests/test_gateway_widgets.py pins zero discovery/reconcile/sync/hash calls on both read paths and the exact WidgetTab shape. No version bump: contribution branch, release carriers byte-identical to the base. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
parent
a76961de6a
commit
b2bcd659c1
16 changed files with 337 additions and 44 deletions
|
|
@ -296,6 +296,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── ui_preferences.py ← owner-local UI preferences (`state/ui_preferences.json`): widget order, nested subagent expansion, and UI defaults
|
||||
│ ├── models.py ← model catalog, request-local provider readiness probes, and local-model lifecycle endpoints
|
||||
│ ├── extensions.py ← extensions/skills HTTP surface (GET /api/extensions, GET /api/extensions/<skill>/manifest, ALL /api/extensions/<skill>/<rest:path>, POST /api/skills/<skill>/toggle, POST /api/skills/<skill>/delete, POST /api/skills/<skill>/review, POST /api/skills/<skill>/grants)
|
||||
│ ├── widgets.py ← GET /api/widgets: the Widgets page card list projected from the in-memory extension snapshot (live UI tabs + the owning skill's live payload `content_hash` as `revision`) — no skill discovery, stale-review reconcile, schedule sync, hashing, or writes on this read path; also homes the `ExtensionLiveSnapshot` TypedDict that types the `live` block of GET /api/extensions (kept out of contracts.py for its size band)
|
||||
│ ├── skill_publish.py ← Selected-skill read-only publication preflight: unique candidate capture, current review-staleness projection, safe scanner cache, and one backend-owned five-state response; creates no task and performs no GitHub effect
|
||||
│ ├── marketplace.py ← ClawHub + OuroborosHub HTTP surface
|
||||
│ ├── mcp.py ← MCP Settings API surface backed by the shared MCPManager
|
||||
|
|
@ -802,11 +803,11 @@ Skills has three views: installed skills, ClawHub, and OuroborosHub. Marketplace
|
|||
|
||||
Installation, deterministic preflight, LLM review, owner grants, dependency readiness, extension loading, enablement, and execution are separate lifecycle facts. A fresh executable review does not imply that requested keys were granted or dependencies installed, and `enabled=true` does not override a blocked review or load error. Owner attestation, where eligible, skips only the expensive LLM review; deterministic preflight and the normal post-pass dependency/extension reconciliation still run. Repair creates a real constrained managed task visible in Chat. Hub publication uses the selected-skill preflight and ordinary managed task described under Skills and extensions; the passive Installed projection neither runs Betterleaks nor claims publication readiness.
|
||||
|
||||
Widgets is a separate page because extension UI is an execution surface, not catalogue metadata. It renders only UI tabs registered by reviewed live extensions and supports three modes. An extension-route iframe uses an empty sandbox capability set. A declarative widget is rendered by host-owned code from a validated schema. A reviewed module widget runs in an opaque-origin `srcdoc` iframe with `allow-scripts` but without `allow-same-origin`. Framed declarations may set a bounded `height` from 320 to 8,192 pixels; a module without `height` starts at the 320-pixel floor and reports its existing `#root` content height through the nonce-bound bridge, capped by an optional module-only `max_height` (default 8,192). For module auto-height, the injected host bootstrap owns vertical viewport overflow: below the finite ceiling it suppresses only `overflow-y`, keeping the child's inline-size basis stable while block size is applied; at the ceiling it releases that rule so excess content is vertically reachable. Horizontal document overflow remains author-controlled and reachable. Fixed-height modules receive no host overflow rule; legacy route iframes retain their existing scroll behavior and remain explicit-height-only because their opaque document cannot be measured by the parent. A framed resize protocol is correct only when measurement and application converge to a fixed point. Geometry keys are rejected for declarative renders, which remain content-driven.
|
||||
Widgets is a separate page because extension UI is an execution surface, not catalogue metadata. It renders only UI tabs registered by reviewed live extensions and supports three modes. The card list has a dedicated passive read, `GET /api/widgets`: a projection of the in-memory extension snapshot in which each card carries the owning skill's live payload `content_hash` as `revision` (a change-signature fact, not an ETag); the Widgets page itself still reads `live.ui_tabs` from `GET /api/extensions`. An extension-route iframe uses an empty sandbox capability set. A declarative widget is rendered by host-owned code from a validated schema. A reviewed module widget runs in an opaque-origin `srcdoc` iframe with `allow-scripts` but without `allow-same-origin`. Framed declarations may set a bounded `height` from 320 to 8,192 pixels; a module without `height` starts at the 320-pixel floor and reports its existing `#root` content height through the nonce-bound bridge, capped by an optional module-only `max_height` (default 8,192). For module auto-height, the injected host bootstrap owns vertical viewport overflow: below the finite ceiling it suppresses only `overflow-y`, keeping the child's inline-size basis stable while block size is applied; at the ceiling it releases that rule so excess content is vertically reachable. Horizontal document overflow remains author-controlled and reachable. Fixed-height modules receive no host overflow rule; legacy route iframes retain their existing scroll behavior and remain explicit-height-only because their opaque document cannot be measured by the parent. A framed resize protocol is correct only when measurement and application converge to a fixed point. Geometry keys are rejected for declarative renders, which remain content-driven.
|
||||
|
||||
Declarative widgets support forms and actions, status/data/text/code/markdown, tables, tabs, charts, polls, jobs, streams, subscriptions, progress, media, files, maps, calendars, kanban, and composition through `group`, `metric`, and `callout`. One recursive validator limits the tree to depth 8 and 256 nodes and reports the exact failing path. Nested interactive components use an explicit id or stable tree path as identity; `subscription.render` remains transitively passive so an incoming event cannot smuggle a new active control tree past validation. Text, attributes, links, media routes, and field values are escaped or constrained for their actual sink.
|
||||
|
||||
Module widgets receive a narrow parent-mediated fetch bridge. The iframe's policy denies ambient network and origin authority; the parent accepts requests only to the exact owning extension prefix under `/api/extensions/<skill>/...` and returns the response through a nonce-bound message exchange. The host-generated module bootstrap observes the content-sized `#root` edge with `ResizeObserver` plus a load measurement, integer-deduplicates and clamps resize messages, and receives a nonce-bound dispose message that rejects pending child fetch promises and disconnects the observer. Module source loading is also bounded and aborted when a mount becomes stale. This preserves useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM, or broad API authority. Chart.js is bundled locally, and module/declarative rendering must not depend on a third-party CDN.
|
||||
Module widgets receive a narrow parent-mediated fetch bridge. The iframe's policy denies ambient network and origin authority; the parent accepts requests only to the exact owning extension prefix under `/api/extensions/<skill>/...` and returns the response through a nonce-bound message exchange. The host-generated module bootstrap observes the content-sized `#root` edge with `ResizeObserver` plus a load measurement, integer-deduplicates and clamps resize messages, and receives a nonce-bound dispose message that rejects pending child fetch promises and disconnects the observer. Module source loading is also bounded and aborted when a mount becomes stale. The module source endpoint authorizes purely against the live loader registration (the skill's loaded bundle declares that entry — no skill discovery on the read path) and answers with `Access-Control-Allow-Origin: *`, because the requesting `srcdoc` frame has an opaque origin and its script fetches are cross-origin and anonymous. This preserves useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM, or broad API authority. Chart.js is bundled locally, and module/declarative rendering must not depend on a third-party CDN.
|
||||
|
||||
A mounted widget owns its timers, abort controllers, chart objects, event streams, polls, jobs, and WebSocket message handlers through one disposer. Leaving Widgets or forcing a refresh disposes the mounted work, removes framed iframes, aborts host bridge requests, and ignores late resize/fetch messages; an async mount that finishes after the page generation changed disposes itself instead of registering a hidden frame. A later visit may repaint the last good extension payload and restore bounded widget session state without leaving the hidden copy running. Job polling keeps its `job_id` across bounded retryable transport/408/429/5xx failures and request timeouts, while explicit terminal job states remain terminal. A missing or malformed status envelope fails immediately; a non-empty producer-specific in-progress status remains pending but is still bounded by `max_ticks`. The existing interval and tick bounds remain the scheduler rather than a second polling service. Poll and WebSocket writers use monotonic progress for one job so an older response cannot rewind a newer event. Transient refresh failure preserves the last good widgets instead of blanking the page. Card order is keyboard- and drag-adjustable owner UI state stored through `/api/ui/preferences`; it never rewrites the extension manifest or changes the review boundary.
|
||||
|
||||
|
|
@ -872,9 +873,10 @@ Every `/api/files/*` operation resolves its requested path and refuses the opera
|
|||
| GET | `/api/health` | `gateway.state.api_health` |
|
||||
| GET | `/api/state` | `gateway.state.api_state` |
|
||||
| GET | `/api/extensions` | `gateway.extensions.api_extensions_index` (unique rows additionally carry `content_hash`, `published` (validated receipt object or null), `published_malformed`; identity-collision rows carry `identity_collision: true` and omit the receipt fields) |
|
||||
| GET | `/api/widgets` | `gateway.widgets.api_widgets` (passive projection of `extension_loader.snapshot()` UI tabs; each card carries the owning skill's live payload `content_hash` as `revision` — a change-signature fact for the page, not an ETag) |
|
||||
| POST | `/api/skills/{skill}/publish-preflight` | `gateway.skill_publish.api_skill_publish_preflight` |
|
||||
| GET | `/api/extensions/{skill}/manifest` | `gateway.extensions.api_extension_manifest` |
|
||||
| GET | `/api/extensions/{skill}/module/{entry}` | `gateway.extensions.api_extension_module` |
|
||||
| GET | `/api/extensions/{skill}/module/{entry}` | `gateway.extensions.api_extension_module` (authorizes against the live loader registration only — the loaded bundle's module tab must declare exactly this entry, no skill discovery; answers `Cache-Control: no-store` plus `Access-Control-Allow-Origin: *` because the requesting module frame has an opaque origin and fetches anonymously cross-origin) |
|
||||
| GET | `/api/extensions/{skill}/settings_section` | `gateway.extensions.api_extension_settings_section` |
|
||||
| ANY | `/api/extensions/{skill}/{rest:path}` | `gateway.extensions.api_extension_dispatch` |
|
||||
| GET | `/api/skills/daemons` | `gateway.extensions.api_skill_daemons` |
|
||||
|
|
|
|||
|
|
@ -813,7 +813,6 @@ class PluginAPIImpl:
|
|||
"span": span,
|
||||
"grid_span": span,
|
||||
**_widget_geometry_from_render(validated_render),
|
||||
"ui_host_pending": True,
|
||||
}, "ui_tabs", "ui tab")
|
||||
|
||||
def register_settings_section(
|
||||
|
|
@ -2191,7 +2190,6 @@ def snapshot() -> Dict[str, Any]:
|
|||
dict(copy.deepcopy(value), key=key)
|
||||
for key, value in sorted(_ui_tabs.items())
|
||||
],
|
||||
"ui_tabs_pending": [],
|
||||
# Settings sections follow the same host-surfaced shape as UI tabs.
|
||||
"settings_sections": [
|
||||
dict(copy.deepcopy(value), key=key)
|
||||
|
|
@ -2200,6 +2198,21 @@ def snapshot() -> Dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def live_bundle_facts(skill_name: str) -> Optional[tuple[str, str]]:
|
||||
"""``(content_hash, skill_dir)`` of one LIVE extension bundle, or ``None``.
|
||||
|
||||
Loader-side truth for read paths that must not re-discover skills: the
|
||||
Widgets projection reports the hash as each card's ``revision`` and the
|
||||
module endpoint resolves the reviewed payload directory from it. The
|
||||
directory never reaches the browser.
|
||||
"""
|
||||
with _lock:
|
||||
bundle = _extensions.get(skill_name)
|
||||
if bundle is None or not bundle.skill_dir:
|
||||
return None
|
||||
return str(bundle.content_hash or ""), str(bundle.skill_dir)
|
||||
|
||||
|
||||
def get_tool(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the registered extension tool, if any."""
|
||||
with _lock:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ouroboros.gateway.widgets import ExtensionLiveSnapshot
|
||||
|
||||
try: # Python 3.11+
|
||||
from typing import Literal, NotRequired, Required, TypedDict # type: ignore[attr-defined]
|
||||
except ImportError: # pragma: no cover - CI supports Python 3.10.
|
||||
|
|
@ -912,6 +914,7 @@ class UploadResponse(TypedDict):
|
|||
class ExtensionsIndexResponse(TypedDict, total=False):
|
||||
extensions: list[Dict[str, Any]]
|
||||
skills: list[Dict[str, Any]]
|
||||
live: ExtensionLiveSnapshot
|
||||
lifecycle: Dict[str, Any]
|
||||
error: str
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ HTTP_ENDPOINTS: tuple[str, ...] = (
|
|||
"POST /api/claudexor/login/{job_id}/reconcile",
|
||||
"DELETE /api/claudexor/credential-profiles/{harness}/{profile_id}",
|
||||
"PATCH /api/claudexor/credential-profiles/{harness}/{profile_id}",
|
||||
"GET /api/widgets",
|
||||
"GET /api/extensions",
|
||||
"GET /api/extensions/{skill}/manifest",
|
||||
"GET /api/extensions/{skill}/module/{entry}",
|
||||
|
|
|
|||
|
|
@ -317,14 +317,6 @@ def _build_extensions_index(drive_root, repo_path):
|
|||
prefix = extension_name_prefix(skill_name)
|
||||
return sum(1 for name in live_snapshot.get("ws_handlers", []) if str(name).startswith(prefix))
|
||||
|
||||
def _pending_ui_tabs(skill_name: str) -> list[str]:
|
||||
prefix = f"{skill_name}:"
|
||||
return [
|
||||
str(name)
|
||||
for name in live_snapshot.get("ui_tabs_pending", [])
|
||||
if str(name).startswith(prefix)
|
||||
]
|
||||
|
||||
# Inline ClawHub provenance so Installed UI avoids a second round-trip.
|
||||
try:
|
||||
from ouroboros.marketplace.provenance import read_provenance, read_publication_record
|
||||
|
|
@ -413,7 +405,6 @@ def _build_extensions_index(drive_root, repo_path):
|
|||
"health_regressed": False,
|
||||
"last_known_good": None,
|
||||
"dispatch_live": False,
|
||||
"ui_tabs_pending": [],
|
||||
"review_findings": [],
|
||||
"skill_review": {},
|
||||
"grants": {},
|
||||
|
|
@ -436,7 +427,6 @@ def _build_extensions_index(drive_root, repo_path):
|
|||
or _live_route_count(s.name)
|
||||
or _live_ws_count(s.name)
|
||||
),
|
||||
"ui_tabs_pending": _pending_ui_tabs(s.name),
|
||||
"review_findings": list(s.review.findings or []),
|
||||
"skill_review": skill_review_ui_projection(drive_root, s.name),
|
||||
"grants": grant_status_for_skill(drive_root, s),
|
||||
|
|
@ -531,9 +521,16 @@ async def api_extension_manifest(request: Request) -> JSONResponse:
|
|||
|
||||
|
||||
async def api_extension_module(request: Request) -> Response:
|
||||
"""Serve reviewed widget module JS only for live registered tab entries."""
|
||||
from ouroboros.config import get_skills_repo_path
|
||||
from ouroboros.extension_loader import runtime_state_for_skill_name
|
||||
"""Serve reviewed widget module JS only for live registered tab entries.
|
||||
|
||||
Authorization is the live loader state alone — the skill holds a loaded
|
||||
bundle whose module tab declares exactly this entry — so this read path
|
||||
never re-discovers skills or hashes payloads (DEVELOPMENT "Passive GET").
|
||||
The requesting ``srcdoc`` frame has an opaque origin, so its fetch is
|
||||
cross-origin and anonymous; the reply therefore carries
|
||||
``Access-Control-Allow-Origin: *`` (no credentials are involved).
|
||||
"""
|
||||
from ouroboros.extension_loader import live_bundle_facts
|
||||
|
||||
skill_name = str(request.path_params.get("skill") or "").strip()
|
||||
entry = str(request.path_params.get("entry") or "").strip()
|
||||
|
|
@ -541,33 +538,22 @@ async def api_extension_module(request: Request) -> Response:
|
|||
return json_error("missing skill/module entry", 400)
|
||||
if "/" in entry or "\\" in entry or ".." in entry or entry.startswith("."):
|
||||
return json_error("invalid module entry", 400)
|
||||
|
||||
drive_root = _request_drive_root(request)
|
||||
repo_path = get_skills_repo_path()
|
||||
state = await asyncio.to_thread(
|
||||
runtime_state_for_skill_name,
|
||||
skill_name,
|
||||
drive_root,
|
||||
repo_path=repo_path,
|
||||
)
|
||||
if not state.get("desired_live"):
|
||||
return json_error(f"extension {skill_name!r} not live: {state.get('reason')}", 409, state=state)
|
||||
loaded = await asyncio.to_thread(find_skill, drive_root, skill_name, repo_path=repo_path)
|
||||
if loaded is None:
|
||||
return json_error("skill not found", 404)
|
||||
facts = live_bundle_facts(skill_name)
|
||||
if facts is None:
|
||||
return json_error(f"extension {skill_name!r} not live", 409)
|
||||
# Authorize against live PluginAPI tab registrations, not only manifest ui_tab.
|
||||
live = snapshot()
|
||||
module_declared = any(
|
||||
str(tab.get("skill") or "") == skill_name
|
||||
and str((tab.get("render") or {}).get("kind") or "") == "module"
|
||||
and str((tab.get("render") or {}).get("entry") or "") == entry
|
||||
for tab in live.get("ui_tabs", [])
|
||||
for tab in snapshot().get("ui_tabs", [])
|
||||
)
|
||||
if not module_declared:
|
||||
return json_error("module entry is not declared by a live widget tab", 404)
|
||||
target = (loaded.skill_dir / entry).resolve()
|
||||
skill_root = pathlib.Path(facts[1])
|
||||
target = (skill_root / entry).resolve()
|
||||
try:
|
||||
target.relative_to(loaded.skill_dir.resolve())
|
||||
target.relative_to(skill_root.resolve())
|
||||
except ValueError:
|
||||
return json_error("module entry escapes skill directory", 400)
|
||||
if not target.is_file():
|
||||
|
|
@ -579,7 +565,7 @@ async def api_extension_module(request: Request) -> Response:
|
|||
return Response(
|
||||
text,
|
||||
media_type="application/javascript; charset=utf-8",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
headers={"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ def collect_routes(
|
|||
api_skill_review_history_detail,
|
||||
api_skill_toggle,
|
||||
)
|
||||
from ouroboros.gateway.widgets import api_widgets
|
||||
from ouroboros.gateway.files import (
|
||||
api_chat_upload,
|
||||
api_chat_upload_delete,
|
||||
|
|
@ -151,6 +152,7 @@ def collect_routes(
|
|||
routes: list[BaseRoute] = [
|
||||
Route("/api/health", endpoint=api_health),
|
||||
Route("/api/state", endpoint=api_state),
|
||||
Route("/api/widgets", endpoint=api_widgets, methods=["GET"]),
|
||||
Route("/api/extensions", endpoint=api_extensions_index, methods=["GET"]),
|
||||
Route("/api/extensions/{skill}/manifest", endpoint=api_extension_manifest, methods=["GET"]),
|
||||
Route("/api/extensions/{skill}/module/{entry}", endpoint=api_extension_module, methods=["GET"]),
|
||||
|
|
|
|||
85
ouroboros/gateway/widgets.py
Normal file
85
ouroboros/gateway/widgets.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""GET /api/widgets — the Widgets page card list, projected from the live loader.
|
||||
|
||||
Built purely from the in-memory extension snapshot: no skill discovery, no
|
||||
stale-review reconcile, no schedule sync, no disk hashing, no writes
|
||||
(DEVELOPMENT.md "Passive GET"). ``revision`` is the owning skill's live loader
|
||||
``content_hash`` — a revision FACT for the page's change signature, not an
|
||||
ETag and not a cache-busting token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, TypedDict
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class WidgetTab(TypedDict):
|
||||
"""One Widgets card as served by ``GET /api/widgets``."""
|
||||
|
||||
key: str
|
||||
skill: str
|
||||
tab_id: str
|
||||
title: str
|
||||
icon: str
|
||||
ws_prefix: str
|
||||
render: Dict[str, Any]
|
||||
span: int
|
||||
grid_span: int
|
||||
revision: str
|
||||
|
||||
|
||||
class WidgetsResponse(TypedDict):
|
||||
ui_tabs: List[WidgetTab]
|
||||
|
||||
|
||||
class ExtensionLiveSnapshot(TypedDict):
|
||||
"""``extension_loader.snapshot()`` — the ``live`` block of ``GET /api/extensions``.
|
||||
|
||||
Homed beside the Widgets projection that consumes it so ``gateway/contracts.py``
|
||||
stays within its module size band.
|
||||
"""
|
||||
|
||||
extensions: List[str]
|
||||
tools: List[str]
|
||||
routes: List[str]
|
||||
ws_handlers: List[str]
|
||||
ui_tabs: List[Dict[str, Any]]
|
||||
settings_sections: List[Dict[str, Any]]
|
||||
|
||||
|
||||
def widget_tabs() -> List[WidgetTab]:
|
||||
"""Project live UI tabs into Widgets cards stamped with the owning skill's revision."""
|
||||
from ouroboros.extension_loader import live_bundle_facts, snapshot
|
||||
|
||||
tabs: List[WidgetTab] = []
|
||||
revisions: Dict[str, str] = {}
|
||||
for tab in snapshot().get("ui_tabs", []):
|
||||
skill = str(tab.get("skill") or "")
|
||||
if skill not in revisions:
|
||||
facts = live_bundle_facts(skill)
|
||||
revisions[skill] = facts[0] if facts else ""
|
||||
# The TypedDict IS the projection: every declared key except the stamped
|
||||
# revision comes straight from the snapshot tab (frame geometry stays in
|
||||
# ``render``, which is where the page reads it).
|
||||
card: Dict[str, Any] = {
|
||||
name: tab.get(name) for name in WidgetTab.__annotations__ if name != "revision"
|
||||
}
|
||||
card["revision"] = revisions[skill]
|
||||
tabs.append(card) # type: ignore[arg-type]
|
||||
return tabs
|
||||
|
||||
|
||||
async def api_widgets(_request: Request) -> JSONResponse:
|
||||
"""GET /api/widgets — live widget cards from the loader snapshot only."""
|
||||
return JSONResponse({"ui_tabs": widget_tabs()})
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExtensionLiveSnapshot",
|
||||
"WidgetTab",
|
||||
"WidgetsResponse",
|
||||
"api_widgets",
|
||||
"widget_tabs",
|
||||
]
|
||||
|
|
@ -252,7 +252,6 @@ def test_register_ui_tab_surfaces_hostable_widget(tmp_path):
|
|||
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root)
|
||||
assert err is None, err
|
||||
snap = extension_loader.snapshot()
|
||||
assert snap["ui_tabs_pending"] == []
|
||||
assert snap["ui_tabs"][0]["key"] == "uiwait:weather"
|
||||
assert snap["ui_tabs"][0]["ws_prefix"] == extension_loader.extension_name_prefix("uiwait")
|
||||
assert snap["ui_tabs"][0]["render"]["kind"] == "declarative"
|
||||
|
|
|
|||
|
|
@ -663,7 +663,7 @@ def test_api_extension_manifest_prefers_runtime_load_error(tmp_path, monkeypatch
|
|||
_stop_patches(patches)
|
||||
|
||||
|
||||
def test_api_extensions_index_marks_widget_only_extensions_as_ui_pending(
|
||||
def test_api_extensions_index_lists_widget_only_extension_tabs(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from ouroboros import extension_loader
|
||||
|
|
@ -706,10 +706,8 @@ def test_api_extensions_index_marks_widget_only_extensions_as_ui_pending(
|
|||
entry = next(s for s in data["skills"] if s["name"] == "ext_widget")
|
||||
assert entry["live_loaded"] is True
|
||||
assert entry["dispatch_live"] is False
|
||||
assert entry["ui_tabs_pending"] == []
|
||||
assert data["live"]["ui_tabs"][0]["key"] == "ext_widget:weather"
|
||||
assert data["live"]["ui_tabs"][0]["render"]["kind"] == "declarative"
|
||||
assert data["live"]["ui_tabs_pending"] == []
|
||||
finally:
|
||||
_stop_patches(patches)
|
||||
|
||||
|
|
@ -1157,6 +1155,7 @@ def test_api_extension_module_serves_only_live_declared_entry(tmp_path, monkeypa
|
|||
assert ok.status_code == 200, ok.text
|
||||
assert "window.__ok" in ok.text
|
||||
assert ok.headers["cache-control"] == "no-store"
|
||||
assert ok.headers["access-control-allow-origin"] == "*"
|
||||
|
||||
assert client.get("/api/extensions/ext_module/module/other.js").status_code == 404
|
||||
assert client.get("/api/extensions/ext_module/module/../widget.js").status_code in {400, 404}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from ouroboros.gateway.contracts import (
|
|||
VideoOutbound,
|
||||
)
|
||||
from ouroboros.gateway.router import collect_routes
|
||||
from ouroboros.gateway.widgets import WidgetTab, WidgetsResponse
|
||||
|
||||
|
||||
def _js_typedef_fields(text: str, name: str) -> set[str]:
|
||||
|
|
@ -227,6 +228,8 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path):
|
|||
"ClaudexorLoginJobProblem",
|
||||
"ClaudexorCredentialProfileDeleteResponse",
|
||||
"ClaudexorVendorCredentialDisposition",
|
||||
"WidgetTab",
|
||||
"WidgetsResponse",
|
||||
):
|
||||
assert re.search(rf"@typedef \{{Object\}} {name}\b", text), f"api_types.js missing {name}"
|
||||
api_client = (pathlib.Path(__file__).resolve().parent.parent / "web" / "modules" / "api_client.js").read_text(
|
||||
|
|
@ -257,7 +260,8 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path):
|
|||
ClaudexorLoginJobResponse, ClaudexorLoginJobProblem,
|
||||
ClaudexorCredentialProfileDeleteResponse,
|
||||
ClaudexorVendorCredentialDisposition,
|
||||
ClaudexorStatusReads, ClaudexorStatusResponse):
|
||||
ClaudexorStatusReads, ClaudexorStatusResponse,
|
||||
WidgetTab, WidgetsResponse):
|
||||
expected = set(get_type_hints(cls, include_extras=True))
|
||||
actual = _js_typedef_fields(text, cls.__name__)
|
||||
assert actual == expected, f"{cls.__name__} JSDoc fields drifted: missing={sorted(expected - actual)}, extra={sorted(actual - expected)}"
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ def test_gateway_core_routes_and_health_shape(tmp_path):
|
|||
assert "/api/ui/preferences" in paths
|
||||
assert "/ws" in paths
|
||||
assert "/api/extensions" in paths
|
||||
assert "/api/widgets" in paths
|
||||
app = Starlette(routes=routes)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/health")
|
||||
widgets = client.get("/api/widgets")
|
||||
assert widgets.status_code == 200
|
||||
assert set(widgets.json()) == {"ui_tabs"}
|
||||
assert isinstance(widgets.json()["ui_tabs"], list)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
|
|
|
|||
165
tests/test_gateway_widgets.py
Normal file
165
tests/test_gateway_widgets.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""``GET /api/widgets`` and the module endpoint read the live loader only.
|
||||
|
||||
Both are hot Widgets-page paths (DEVELOPMENT.md "Passive GET"): they must not
|
||||
re-discover skills, reconcile review jobs, sync schedules, or hash payloads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from ouroboros import extension_loader
|
||||
from ouroboros.gateway.router import collect_routes
|
||||
from ouroboros.gateway.widgets import WidgetTab
|
||||
from tests._shared import clean_extension_runtime_state
|
||||
from tests.test_extension_loader import _prepare_extension
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_loader(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
clean_extension_runtime_state()
|
||||
yield
|
||||
clean_extension_runtime_state()
|
||||
|
||||
|
||||
# Every seam a discovery/reconcile/sync/hash could enter the read path through.
|
||||
_PASSIVE_SEAMS = (
|
||||
("ouroboros.skill_loader", "discover_skills"),
|
||||
("ouroboros.skill_loader", "find_skill"),
|
||||
("ouroboros.skill_loader", "compute_content_hash"),
|
||||
("ouroboros.extension_loader", "discover_skills"),
|
||||
("ouroboros.extension_loader", "find_skill"),
|
||||
("ouroboros.extension_loader", "compute_content_hash"),
|
||||
("ouroboros.gateway.extensions", "discover_skills"),
|
||||
("ouroboros.gateway.extensions", "find_skill"),
|
||||
("ouroboros.skill_review_runner", "reconcile_stale_review_jobs"),
|
||||
("supervisor.queue", "sync_skill_schedules"),
|
||||
)
|
||||
|
||||
|
||||
def _arm_counters(monkeypatch) -> dict[str, int]:
|
||||
"""Wrap every seam in a counting delegate (the real call still runs).
|
||||
|
||||
Arm AFTER the app is built: a module first-imported while a sibling seam
|
||||
is wrapped captures the wrapper as its own original, and monkeypatch then
|
||||
faithfully "restores" that capture. Delegating wrappers keep even such a
|
||||
capture behaviour-preserving; building the app first avoids it entirely.
|
||||
"""
|
||||
modules = {name: importlib.import_module(name) for name, _attr in _PASSIVE_SEAMS}
|
||||
calls: dict[str, int] = {}
|
||||
for module_name, attr in _PASSIVE_SEAMS:
|
||||
label = f"{module_name}.{attr}"
|
||||
calls[label] = 0
|
||||
original = getattr(modules[module_name], attr)
|
||||
|
||||
def _counted(*args, _label=label, _original=original, **kwargs):
|
||||
calls[_label] += 1
|
||||
return _original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(modules[module_name], attr, _counted)
|
||||
return calls
|
||||
|
||||
|
||||
def _client(tmp_path) -> TestClient:
|
||||
return TestClient(Starlette(routes=collect_routes(data_dir=tmp_path)))
|
||||
|
||||
|
||||
def test_api_widgets_projects_live_tabs_without_discovery(tmp_path, monkeypatch):
|
||||
loaded, _, drive_root = _prepare_extension(
|
||||
tmp_path,
|
||||
"ext_widget",
|
||||
"def register(api):\n"
|
||||
" api.register_ui_tab('weather', 'Weather', icon='cloud', render={'kind': 'declarative', "
|
||||
"'schema_version': 1, 'components': [{'type': 'markdown', 'text': 'ok'}]})\n",
|
||||
permissions=["widget"],
|
||||
)
|
||||
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root)
|
||||
assert err is None, err
|
||||
live_tab = extension_loader.snapshot()["ui_tabs"][0]
|
||||
client = _client(tmp_path) # imports every gateway module before the seams are wrapped
|
||||
calls = _arm_counters(monkeypatch)
|
||||
|
||||
with client:
|
||||
response = client.get("/api/widgets")
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert set(payload) == {"ui_tabs"}
|
||||
assert len(payload["ui_tabs"]) == 1
|
||||
tab = payload["ui_tabs"][0]
|
||||
# Exact contract shape: the TypedDict keys, nothing else (the dead
|
||||
# two-phase flags are gone; framed geometry is covered below).
|
||||
assert set(tab) == set(WidgetTab.__annotations__)
|
||||
assert tab == {
|
||||
"key": "ext_widget:weather",
|
||||
"skill": "ext_widget",
|
||||
"tab_id": "weather",
|
||||
"title": "Weather",
|
||||
"icon": "cloud",
|
||||
"ws_prefix": extension_loader.extension_name_prefix("ext_widget"),
|
||||
"render": live_tab["render"],
|
||||
"span": 1,
|
||||
"grid_span": 1,
|
||||
"revision": loaded.content_hash,
|
||||
}
|
||||
assert tab["revision"] and tab["revision"] == extension_loader.live_bundle_facts("ext_widget")[0]
|
||||
|
||||
extension_loader.unload_extension("ext_widget")
|
||||
assert client.get("/api/widgets").json() == {"ui_tabs": []}
|
||||
assert all(count == 0 for count in calls.values()), calls
|
||||
|
||||
|
||||
def test_api_extension_module_serves_live_entry_without_discovery(tmp_path, monkeypatch):
|
||||
skill_dir = tmp_path / "skills" / "ext_module"
|
||||
skill_dir.mkdir(parents=True)
|
||||
# Written BEFORE the payload hash is taken so the reviewed hash covers it.
|
||||
(skill_dir / "widget.js").write_text("window.__ok = true;\n", encoding="utf-8")
|
||||
loaded, _, drive_root = _prepare_extension(
|
||||
tmp_path,
|
||||
"ext_module",
|
||||
"def register(api):\n"
|
||||
" api.register_ui_tab('module', 'Module', render={'kind': 'module', 'entry': 'widget.js', 'height': 480})\n",
|
||||
permissions=["widget"],
|
||||
)
|
||||
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root)
|
||||
assert err is None, err
|
||||
client = _client(tmp_path) # imports every gateway module before the seams are wrapped
|
||||
calls = _arm_counters(monkeypatch)
|
||||
|
||||
with client:
|
||||
# Framed geometry rides inside ``render`` (where the page reads it), never
|
||||
# as a promoted top-level card key.
|
||||
card = client.get("/api/widgets").json()["ui_tabs"][0]
|
||||
assert card["render"]["height"] == 480 and "height" not in card
|
||||
ok = client.get("/api/extensions/ext_module/module/widget.js")
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert "window.__ok" in ok.text
|
||||
assert ok.headers["content-type"].startswith("application/javascript")
|
||||
assert ok.headers["cache-control"] == "no-store"
|
||||
assert ok.headers["access-control-allow-origin"] == "*"
|
||||
# Exact-entry authorization stays; an unloaded skill is "not live".
|
||||
assert client.get("/api/extensions/ext_module/module/other.js").status_code == 404
|
||||
assert client.get("/api/extensions/ext_module/module/plugin.py").status_code == 404
|
||||
assert client.get("/api/extensions/nope/module/widget.js").status_code == 409
|
||||
extension_loader.unload_extension("ext_module")
|
||||
assert client.get("/api/extensions/ext_module/module/widget.js").status_code == 409
|
||||
assert all(count == 0 for count in calls.values()), calls
|
||||
|
||||
|
||||
def test_live_bundle_facts_reports_loaded_bundle_only(tmp_path):
|
||||
assert extension_loader.live_bundle_facts("absent") is None
|
||||
loaded, _, drive_root = _prepare_extension(
|
||||
tmp_path,
|
||||
"ext_facts",
|
||||
"def register(api):\n pass\n",
|
||||
permissions=[],
|
||||
)
|
||||
assert extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root) is None
|
||||
content_hash, skill_dir = extension_loader.live_bundle_facts("ext_facts")
|
||||
assert content_hash == loaded.content_hash
|
||||
assert skill_dir == str(loaded.skill_dir.resolve())
|
||||
extension_loader.unload_extension("ext_facts")
|
||||
assert extension_loader.live_bundle_facts("ext_facts") is None
|
||||
|
|
@ -129,7 +129,6 @@ def test_required_projection_fields_match_between_endpoints(monkeypatch):
|
|||
"routes": [],
|
||||
"ws_handlers": [],
|
||||
"ui_tabs": [],
|
||||
"ui_tabs_pending": [],
|
||||
}
|
||||
|
||||
def _stub_read_provenance(*_a, **_kw):
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ def test_extensions_index_rows_expose_loader_content_hash(monkeypatch, tmp_path)
|
|||
monkeypatch.setattr(
|
||||
extensions_api,
|
||||
"snapshot",
|
||||
lambda: {"tools": [], "routes": [], "ws_handlers": [], "ui_tabs": [], "ui_tabs_pending": []},
|
||||
lambda: {"tools": [], "routes": [], "ws_handlers": [], "ui_tabs": []},
|
||||
)
|
||||
monkeypatch.setattr(supervisor_queue, "sync_skill_schedules", lambda *_a, **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
|
|
|
|||
|
|
@ -224,6 +224,13 @@ export const apiClient = {
|
|||
*/
|
||||
providerTest: (payload) => jsonPost('/api/providers/test', payload),
|
||||
extensions: () => fetchJson('/api/extensions', { cache: 'no-store' }),
|
||||
/**
|
||||
* Widgets page cards: live extension UI tabs projected from the loader
|
||||
* snapshot (no skill discovery), each stamped with the owning skill's
|
||||
* payload `revision`.
|
||||
* @returns {Promise<import('./api_types.js').WidgetsResponse>}
|
||||
*/
|
||||
widgets: () => fetchJson('/api/widgets', { cache: 'no-store' }),
|
||||
skillPublishPreflight,
|
||||
createTask,
|
||||
skillLifecycleQueue: () => fetchJson('/api/skills/lifecycle-queue', { cache: 'no-store' }),
|
||||
|
|
|
|||
|
|
@ -722,6 +722,29 @@
|
|||
* @property {boolean=} identity_collision
|
||||
*/
|
||||
|
||||
/**
|
||||
* One Widgets card from `GET /api/widgets` (`gateway/widgets.py::WidgetTab`).
|
||||
* `revision` is the owning skill's live payload content hash — a change
|
||||
* signature for the page, not an ETag or cache token. Frame geometry stays
|
||||
* inside `render`.
|
||||
* @typedef {Object} WidgetTab
|
||||
* @property {string} key
|
||||
* @property {string} skill
|
||||
* @property {string} tab_id
|
||||
* @property {string} title
|
||||
* @property {string} icon
|
||||
* @property {string} ws_prefix
|
||||
* @property {Object} render
|
||||
* @property {number} span
|
||||
* @property {number} grid_span
|
||||
* @property {string} revision
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WidgetsResponse
|
||||
* @property {WidgetTab[]} ui_tabs
|
||||
*/
|
||||
|
||||
/**
|
||||
* One `/api/marketplace/ouroboroshub/catalog` result row (additive hubflow fields).
|
||||
* `POST /api/marketplace/ouroboroshub/install` additionally accepts the adopt
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue