Repository navigation
feat(website): WebsiteEnvironment serves a site on the website/v1 contract, and a conformance check tests any server (FD-4683) #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
williamloo-scale
wants to merge
20
commits into
wloo/website-harness-gaps
Choose a base branch
from
wloo/website-environment-sdk
base: wloo/website-harness-gaps
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,659
−0
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
7c9dfc4
feat(website): WebsiteEnvironment serves a site on the website/v1 con…
williamloo-scale e19a8d3
fix(website): a site believes AgentEnv-Role only with its WEBSITE_ROL…
williamloo-scale 28452d1
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale 425ade0
fix(website): drop the conformance test's unused json import (FD-4683)
williamloo-scale 01ab058
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale 5d5303e
feat(website): a site's sync_time takes the universe's time zone (FD-…
williamloo-scale ae2b015
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale c51f4e3
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale 5ccdf83
feat(website): a site says it's ready by its card's website/v1 ready …
williamloo-scale 2318642
fix(website): a body is validated and described by its whole annotati…
williamloo-scale eb2da70
fix(website): /go and /api/session answer before a broad @api route t…
williamloo-scale 151f1a9
fix(website): a refresh of the old clock never overwrites a sync to a…
williamloo-scale 08a5161
fix(website): the clock script's Date answers called as well as const…
williamloo-scale 28d9853
fix(website): conformance leaves a server's state as it found it, che…
williamloo-scale 225c688
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale a6414c2
fix(website): conformance counts a data request as answered only on a…
williamloo-scale 26e5305
fix(website): the OpenAPI document describes answers as serialized an…
williamloo-scale 605071c
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale f940a20
fix(website): a bodyless status publishes no body, and no @api route …
williamloo-scale 6e4e5a0
Merge wloo/website-harness-gaps into wloo/website-environment-sdk
williamloo-scale File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
30 changes: 30 additions & 0 deletions
30
packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| """The website SDK: a site's server, meeting ``urn:agentenv:website/v1``. Subclass WebsiteEnvironment, declare the | ||
| site with ``@website(domains=[...], static="dist/")``, mark its API with ``@api.get/post/put/patch/delete(path)``, and | ||
| ``serve()`` it; it needs only the plain package, and uvicorn to serve. The class is optional: a server in any language | ||
| that meets the contract works, which ``python -m agentenv_protocol.website.conformance <url>`` checks.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from .clock import CLOCK_EXTENSION_URI, UNIVERSE_NOW_HEADER, UniverseClock | ||
| from .environment import ( | ||
| DEFAULT_HEALTH_PATH, | ||
| SESSION_PATH, | ||
| STATE_PATH, | ||
| WEBSITE_EXTENSION_URI, | ||
| WebsiteDeclaration, | ||
| WebsiteEnvironment, | ||
| ready_check, | ||
| website, | ||
| ) | ||
| from .errors import ApiError, BadRequest, Conflict, Forbidden, NotFound, Unauthorized | ||
| from .routes import ROLE_KEY_ENV, ROLE_KEY_HEADER, ApiContext, ApiRoute, api, reserved_path | ||
| from .session import SESSION_COOKIE, SessionSigner | ||
| from .store import Collections | ||
|
|
||
| __all__ = [ | ||
| "WebsiteEnvironment", "WebsiteDeclaration", "website", "api", "ApiContext", "ApiRoute", "ready_check", | ||
| "ApiError", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict", | ||
| "Collections", "UniverseClock", "SessionSigner", "reserved_path", | ||
| "WEBSITE_EXTENSION_URI", "CLOCK_EXTENSION_URI", "UNIVERSE_NOW_HEADER", "DEFAULT_HEALTH_PATH", "SESSION_PATH", | ||
| "STATE_PATH", "SESSION_COOKIE", "ROLE_KEY_ENV", "ROLE_KEY_HEADER", | ||
| ] |
113 changes: 113 additions & 0 deletions
113
packages/agentenv-protocol/src/agentenv_protocol/website/clock.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| """The universe's clock as a site reads it: ``clock/v1``'s ``sync_time`` hands the server the gateway's ``/clock/time`` | ||
| URL, as it hands it to MCP servers, and the site's time then follows that clock's ``{"virtual_time": <RFC3339>}``, | ||
| read at most once a second and extrapolated between reads. The rate it extrapolates at is one two successive intervals | ||
| between reads agreed on, so a clock set or advanced between two reads is taken for a jump, not a speed; until two agree | ||
| it is real time's. An unsynced site reads wall time. Every answer of a site carries the time it answered at as ``X-Universe-Now``, | ||
| RFC3339 in UTC. ``sync_time`` may also name the universe's time zone, an IANA name, which the clock keeps for the site | ||
| to show local times in; the time itself stays UTC.""" | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import datetime | ||
| import json | ||
| import logging | ||
| import time | ||
|
|
||
| import httpx | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| CLOCK_EXTENSION_URI = "urn:agentenv:clock/v1" | ||
| UNIVERSE_NOW_HEADER = "X-Universe-Now" | ||
| REFRESH_S = 1.0 | ||
| READ_TIMEOUT_S = 5.0 | ||
|
|
||
|
|
||
| def iso(moment: datetime.datetime) -> str: | ||
| return moment.astimezone(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") | ||
|
|
||
|
|
||
| def parse_time(value: str) -> datetime.datetime: | ||
| moment = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) | ||
| return moment if moment.tzinfo else moment.replace(tzinfo=datetime.timezone.utc) | ||
|
|
||
|
|
||
| class UniverseClock: | ||
| """The time a site answers with: wall time until ``sync`` names a gateway clock, that clock's after.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self.url: str | None = None | ||
| self.timezone: str | None = None | ||
| self._virtual: datetime.datetime | None = None | ||
| self._read_at = 0.0 | ||
| self._tried_at = 0.0 | ||
| self._rate = 1.0 | ||
| self._measured: float | None = None | ||
| self._lock: asyncio.Lock | None = None | ||
|
|
||
| @property | ||
| def synced(self) -> bool: | ||
| return self._virtual is not None | ||
|
|
||
| def now(self) -> datetime.datetime: | ||
| if self._virtual is None: | ||
| return datetime.datetime.now(datetime.timezone.utc) | ||
| return self._virtual + datetime.timedelta(seconds=(time.monotonic() - self._read_at) * self._rate) | ||
|
|
||
| async def sync(self, url: str, timezone: str | None = None) -> datetime.datetime: | ||
| """Follow the clock at ``url`` from now on, in ``timezone`` (none when the sync names none); raises when it | ||
| can't be read, keeping the clock and zone it followed. It waits for a refresh reading the clock it followed, | ||
| so that read never lands on the new clock.""" | ||
| async with self._reading(): | ||
| virtual = await _read(url) | ||
| self.url, self._virtual, self._rate, self._measured = url, virtual, 1.0, None | ||
| self.timezone = timezone | ||
| self._read_at = self._tried_at = time.monotonic() | ||
| return virtual | ||
|
|
||
| async def refresh(self) -> None: | ||
| """Read the synced clock again when the last read is a second old; a failed read keeps extrapolating.""" | ||
| if self.url is None or time.monotonic() - self._tried_at < REFRESH_S: | ||
| return | ||
| async with self._reading(): | ||
| started = time.monotonic() | ||
| if started - self._tried_at < REFRESH_S: | ||
| return | ||
| self._tried_at = started | ||
| try: | ||
| virtual = await _read(self.url) | ||
| except Exception as e: | ||
| logger.debug("reading the universe's clock at %s failed: %r", self.url, e) | ||
| return | ||
| read_at = time.monotonic() | ||
| if self._virtual is not None and read_at > self._read_at: | ||
| measured = max(0.0, (virtual - self._virtual).total_seconds() / (read_at - self._read_at)) | ||
| if self._measured is not None and abs(measured - self._measured) <= 0.1 * max(measured, self._measured, 1.0): | ||
| self._rate = measured | ||
| self._measured = measured | ||
| self._virtual, self._read_at = virtual, read_at | ||
|
|
||
| def _reading(self) -> asyncio.Lock: | ||
| """The lock a sync and a refresh each read the clock under.""" | ||
| self._lock = self._lock or asyncio.Lock() | ||
| return self._lock | ||
|
|
||
|
|
||
| async def _read(url: str) -> datetime.datetime: | ||
| async with httpx.AsyncClient() as client: | ||
| response = await client.get(url, timeout=READ_TIMEOUT_S) | ||
| response.raise_for_status() | ||
| return parse_time(response.json()["virtual_time"]) | ||
|
|
||
|
|
||
| def clock_patch_script(now: datetime.datetime) -> bytes: | ||
| """An inline script that shifts the page's ``Date`` to the universe's time ``now``, and follows the | ||
| ``X-Universe-Now`` header of every ``fetch`` the page makes after. The ``Date`` it puts in place answers called, | ||
| with the time's string, as constructed, and keeps ``Date.parse``, ``Date.UTC`` and ``instanceof Date``.""" | ||
| return ("<script>(function(){var D=Date,o=%s-D.now();function n(){return D.now()+o}" | ||
| "function U(){if(!new.target)return new D(n()).toString();" | ||
| "return Reflect.construct(D,arguments.length?[].slice.call(arguments):[n()],new.target)}" | ||
| "Object.setPrototypeOf(U,D);U.prototype=D.prototype;D.prototype.constructor=U;U.now=n;U.parse=D.parse;" | ||
| "U.UTC=D.UTC;window.Date=U;var f=window.fetch;if(f)window.fetch=function(){return f.apply(this,arguments)" | ||
| ".then(function(r){var t=D.parse(r.headers.get(%s)||'');if(!isNaN(t))o=t-D.now();return r})}})();" | ||
| "</script>" % (int(now.timestamp() * 1000), json.dumps(UNIVERSE_NOW_HEADER))).encode() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new clock comparison uses an unnamed
0.1tolerance. The repository requires magic numbers to be stored as class or instance variables with descriptive names. Name this tolerance so readers can see what it controls. The32passed tosecrets.token_bytes()insession.pyhas the same problem.This repository requirement must be satisfied before merging.
Rule Used: Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. (source)
Learned From
scaleapi/scaleapi#126388
Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!