pinboard-sdk 0.18.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 (93) hide show
  1. pinboard_sdk-0.18.0/.gitignore +9 -0
  2. pinboard_sdk-0.18.0/PKG-INFO +57 -0
  3. pinboard_sdk-0.18.0/README.md +40 -0
  4. pinboard_sdk-0.18.0/examples/__init__.py +0 -0
  5. pinboard_sdk-0.18.0/examples/durable_tray_app.py +69 -0
  6. pinboard_sdk-0.18.0/pyproject.toml +59 -0
  7. pinboard_sdk-0.18.0/src/pinboard/__init__.py +74 -0
  8. pinboard_sdk-0.18.0/src/pinboard/api.py +178 -0
  9. pinboard_sdk-0.18.0/src/pinboard/domain.py +73 -0
  10. pinboard_sdk-0.18.0/src/pinboard/durability/__init__.py +11 -0
  11. pinboard_sdk-0.18.0/src/pinboard/durability/frontier.py +194 -0
  12. pinboard_sdk-0.18.0/src/pinboard/durability/postgres.py +309 -0
  13. pinboard_sdk-0.18.0/src/pinboard/durability/store.py +159 -0
  14. pinboard_sdk-0.18.0/src/pinboard/hosting/__init__.py +13 -0
  15. pinboard_sdk-0.18.0/src/pinboard/hosting/_fencing.py +56 -0
  16. pinboard_sdk-0.18.0/src/pinboard/hosting/_records.py +168 -0
  17. pinboard_sdk-0.18.0/src/pinboard/hosting/_winddown.py +292 -0
  18. pinboard_sdk-0.18.0/src/pinboard/hosting/directory.py +581 -0
  19. pinboard_sdk-0.18.0/src/pinboard/hosting/dispatch.py +323 -0
  20. pinboard_sdk-0.18.0/src/pinboard/hosting/host.py +264 -0
  21. pinboard_sdk-0.18.0/src/pinboard/hosting/hostable.py +86 -0
  22. pinboard_sdk-0.18.0/src/pinboard/hosting/routing.py +61 -0
  23. pinboard_sdk-0.18.0/src/pinboard/lifespan.py +19 -0
  24. pinboard_sdk-0.18.0/src/pinboard/metrics.py +71 -0
  25. pinboard_sdk-0.18.0/src/pinboard/sim/__init__.py +223 -0
  26. pinboard_sdk-0.18.0/src/pinboard/sim/cluster.py +343 -0
  27. pinboard_sdk-0.18.0/src/pinboard/sim/contract.py +418 -0
  28. pinboard_sdk-0.18.0/src/pinboard/sim/coprocess.py +124 -0
  29. pinboard_sdk-0.18.0/src/pinboard/sim/e2e.py +168 -0
  30. pinboard_sdk-0.18.0/src/pinboard/sim/fleet.py +133 -0
  31. pinboard_sdk-0.18.0/src/pinboard/sim/harness.py +346 -0
  32. pinboard_sdk-0.18.0/src/pinboard/sim/node.py +99 -0
  33. pinboard_sdk-0.18.0/src/pinboard/sim/store.py +192 -0
  34. pinboard_sdk-0.18.0/src/pinboard/sim/workload.py +209 -0
  35. pinboard_sdk-0.18.0/src/pinboard/testing.py +243 -0
  36. pinboard_sdk-0.18.0/src/pinboard/trays/__init__.py +50 -0
  37. pinboard_sdk-0.18.0/src/pinboard/trays/_attempts.py +62 -0
  38. pinboard_sdk-0.18.0/src/pinboard/trays/_declare.py +134 -0
  39. pinboard_sdk-0.18.0/src/pinboard/trays/_dispatch.py +115 -0
  40. pinboard_sdk-0.18.0/src/pinboard/trays/_lifecycle.py +283 -0
  41. pinboard_sdk-0.18.0/src/pinboard/trays/_machine.py +473 -0
  42. pinboard_sdk-0.18.0/src/pinboard/trays/_primitives.py +302 -0
  43. pinboard_sdk-0.18.0/src/pinboard/trays/_storage.py +153 -0
  44. pinboard_sdk-0.18.0/src/pinboard/worker/__init__.py +22 -0
  45. pinboard_sdk-0.18.0/src/pinboard/worker/_acks.py +91 -0
  46. pinboard_sdk-0.18.0/src/pinboard/worker/_attestation.py +149 -0
  47. pinboard_sdk-0.18.0/src/pinboard/worker/_backoff.py +48 -0
  48. pinboard_sdk-0.18.0/src/pinboard/worker/_cluster.py +142 -0
  49. pinboard_sdk-0.18.0/src/pinboard/worker/_drain_echo.py +56 -0
  50. pinboard_sdk-0.18.0/src/pinboard/worker/_headroom.py +35 -0
  51. pinboard_sdk-0.18.0/src/pinboard/worker/_registration.py +452 -0
  52. pinboard_sdk-0.18.0/src/pinboard/worker/_worker.py +481 -0
  53. pinboard_sdk-0.18.0/src/pinboard/worker/lease.py +237 -0
  54. pinboard_sdk-0.18.0/src/pinboard/worker/wire.py +59 -0
  55. pinboard_sdk-0.18.0/tests/conftest.py +78 -0
  56. pinboard_sdk-0.18.0/tests/test_activation_limit.py +200 -0
  57. pinboard_sdk-0.18.0/tests/test_authorize_gate.py +537 -0
  58. pinboard_sdk-0.18.0/tests/test_cluster.py +409 -0
  59. pinboard_sdk-0.18.0/tests/test_control_messages.py +271 -0
  60. pinboard_sdk-0.18.0/tests/test_control_plane.py +533 -0
  61. pinboard_sdk-0.18.0/tests/test_crash_sweep.py +466 -0
  62. pinboard_sdk-0.18.0/tests/test_create_task.py +60 -0
  63. pinboard_sdk-0.18.0/tests/test_detached_timeout.py +203 -0
  64. pinboard_sdk-0.18.0/tests/test_dispatch.py +460 -0
  65. pinboard_sdk-0.18.0/tests/test_dispatch_state.py +141 -0
  66. pinboard_sdk-0.18.0/tests/test_domain.py +85 -0
  67. pinboard_sdk-0.18.0/tests/test_drain.py +207 -0
  68. pinboard_sdk-0.18.0/tests/test_durability.py +537 -0
  69. pinboard_sdk-0.18.0/tests/test_durability_postgres.py +647 -0
  70. pinboard_sdk-0.18.0/tests/test_e2e_sim.py +49 -0
  71. pinboard_sdk-0.18.0/tests/test_e2e_tray_durability.py +234 -0
  72. pinboard_sdk-0.18.0/tests/test_eviction.py +360 -0
  73. pinboard_sdk-0.18.0/tests/test_hostable.py +50 -0
  74. pinboard_sdk-0.18.0/tests/test_id_validation.py +49 -0
  75. pinboard_sdk-0.18.0/tests/test_lease.py +160 -0
  76. pinboard_sdk-0.18.0/tests/test_lifecycle.py +319 -0
  77. pinboard_sdk-0.18.0/tests/test_pinboard_integration.py +221 -0
  78. pinboard_sdk-0.18.0/tests/test_placement_epoch.py +126 -0
  79. pinboard_sdk-0.18.0/tests/test_placement_health.py +72 -0
  80. pinboard_sdk-0.18.0/tests/test_registration.py +1171 -0
  81. pinboard_sdk-0.18.0/tests/test_sabotage.py +197 -0
  82. pinboard_sdk-0.18.0/tests/test_sim.py +244 -0
  83. pinboard_sdk-0.18.0/tests/test_simstore.py +202 -0
  84. pinboard_sdk-0.18.0/tests/test_state_store.py +185 -0
  85. pinboard_sdk-0.18.0/tests/test_streaming.py +140 -0
  86. pinboard_sdk-0.18.0/tests/test_take.py +153 -0
  87. pinboard_sdk-0.18.0/tests/test_testing.py +93 -0
  88. pinboard_sdk-0.18.0/tests/test_trays.py +1522 -0
  89. pinboard_sdk-0.18.0/tests/test_traystore.py +600 -0
  90. pinboard_sdk-0.18.0/tests/test_websocket.py +359 -0
  91. pinboard_sdk-0.18.0/tests/test_wind_down.py +374 -0
  92. pinboard_sdk-0.18.0/tests/test_worker.py +399 -0
  93. pinboard_sdk-0.18.0/tests/test_worker_hosts.py +129 -0
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ uv.lock
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: pinboard-sdk
3
+ Version: 0.18.0
4
+ Summary: One live instance of your class per id, with an HTTP surface
5
+ Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: fastapi>=0.115
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: starlette>=0.37
11
+ Requires-Dist: statepatch<0.4,>=0.3.0
12
+ Provides-Extra: postgres
13
+ Requires-Dist: psycopg>=3.1; extra == 'postgres'
14
+ Provides-Extra: psutil
15
+ Requires-Dist: psutil>=5.9; extra == 'psutil'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # pinboard-sdk
19
+
20
+ Durable objects for Python: one live instance of your class per id across a fleet, activated on the first request, with its own HTTP surface. Imports as `pinboard`. Part of [harness-sdk](https://github.com/assistant-ui/harness-sdk), with `statewire` and `harness-sdk`.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ uv add pinboard-sdk
26
+ ```
27
+
28
+ Extras: `uv add "pinboard-sdk[postgres]"`, `uv add "pinboard-sdk[psutil]"`.
29
+
30
+ ## Use
31
+
32
+ ```python
33
+ from fastapi import FastAPI
34
+ from pinboard import PinboardAPI, Worker, route
35
+ from pinboard.durability import MemoryStateStore
36
+
37
+ class Counter(PinboardAPI):
38
+ async def lifespan(self):
39
+ self.count = 0
40
+ yield
41
+
42
+ @route.post("/increment")
43
+ async def increment(self):
44
+ self.count += 1
45
+ return {"id": self.id, "count": self.count}
46
+
47
+ worker = Worker(hosts={"/counters": Counter}, state_store=MemoryStateStore())
48
+ app = FastAPI()
49
+ app.include_router(worker)
50
+ ```
51
+
52
+ ## Docs
53
+
54
+ - Get started: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/get-started/durable-host/page.mdx
55
+ - Guide: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/guides/python/authoring/page.mdx
56
+ - Reference: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/reference/pinboard-python/page.mdx
57
+ - Spec: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/spec/gateway-registry/page.mdx
@@ -0,0 +1,40 @@
1
+ # pinboard-sdk
2
+
3
+ Durable objects for Python: one live instance of your class per id across a fleet, activated on the first request, with its own HTTP surface. Imports as `pinboard`. Part of [harness-sdk](https://github.com/assistant-ui/harness-sdk), with `statewire` and `harness-sdk`.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ uv add pinboard-sdk
9
+ ```
10
+
11
+ Extras: `uv add "pinboard-sdk[postgres]"`, `uv add "pinboard-sdk[psutil]"`.
12
+
13
+ ## Use
14
+
15
+ ```python
16
+ from fastapi import FastAPI
17
+ from pinboard import PinboardAPI, Worker, route
18
+ from pinboard.durability import MemoryStateStore
19
+
20
+ class Counter(PinboardAPI):
21
+ async def lifespan(self):
22
+ self.count = 0
23
+ yield
24
+
25
+ @route.post("/increment")
26
+ async def increment(self):
27
+ self.count += 1
28
+ return {"id": self.id, "count": self.count}
29
+
30
+ worker = Worker(hosts={"/counters": Counter}, state_store=MemoryStateStore())
31
+ app = FastAPI()
32
+ app.include_router(worker)
33
+ ```
34
+
35
+ ## Docs
36
+
37
+ - Get started: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/get-started/durable-host/page.mdx
38
+ - Guide: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/guides/python/authoring/page.mdx
39
+ - Reference: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/reference/pinboard-python/page.mdx
40
+ - Spec: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/spec/gateway-registry/page.mdx
File without changes
@@ -0,0 +1,69 @@
1
+ """Durable tray worker: the minimal gateway + tray durability config.
2
+
3
+ Each ``/lanes/{id}`` instance owns one tray. ``POST /enqueue`` returns once the
4
+ item is durably admitted; the handler records the item in ``executed`` when it runs.
5
+ The gateway's durability sweep (``PINBOARD_DURABILITY_POSTGRES_URL`` on the
6
+ pinboard side, pointing at the same database) resurrects lanes with pending
7
+ work after any combination of worker crashes and pinboard/redis wipes.
8
+
9
+ Env: POSTGRES_DSN, PINBOARD_URL, PINBOARD_TOKEN, ADVERTISE_URL,
10
+ HEARTBEAT_INTERVAL (default 3).
11
+ Run: uv run uvicorn examples.durable_tray_app:app --port 8000
12
+ """
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import os
17
+
18
+ from fastapi import FastAPI
19
+
20
+ from pinboard import PinboardAPI, Worker, Tray, inbox, route, tray
21
+ from pinboard.durability.postgres import PostgresStateStore
22
+ from pinboard.trays import persistent_queue
23
+
24
+
25
+ class WorkTray(Tray):
26
+ queue = persistent_queue()
27
+
28
+ def items(self):
29
+ while self.queue.value:
30
+ yield self.queue.peek()
31
+ self.queue.popleft()
32
+
33
+ @inbox
34
+ def add(self, item: dict) -> None:
35
+ self.kick()
36
+ self.queue.append(item)
37
+
38
+
39
+ class Lane(PinboardAPI):
40
+ @tray(WorkTray)
41
+ async def work(self, item: dict, ctx) -> None:
42
+ if ctx.attempts == 1 and item.get("hold"):
43
+ await asyncio.Event().wait() # chaos: first delivery completes only by kill
44
+ executed = ctx.tray.storage.get("executed", {})
45
+ ctx.tray.storage.set("executed", {**executed, item["id"]: ctx.attempts})
46
+
47
+ @route.post("/enqueue")
48
+ async def enqueue(self, item: dict) -> dict:
49
+ lane = await self.work.get()
50
+ await lane.add(item) # the durable-admission receipt
51
+ return {"admitted": item["id"]}
52
+
53
+
54
+ store = PostgresStateStore(dsn=os.environ["POSTGRES_DSN"])
55
+ worker = Worker(
56
+ hosts={"/lanes": Lane},
57
+ heartbeat_interval=float(os.environ.get("HEARTBEAT_INTERVAL", "3")),
58
+ state_store=store,
59
+ )
60
+
61
+
62
+ @contextlib.asynccontextmanager
63
+ async def lifespan(app: FastAPI):
64
+ await store.setup()
65
+ yield
66
+
67
+
68
+ app = FastAPI(lifespan=lifespan)
69
+ app.include_router(worker)
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pinboard-sdk"
7
+ version = "0.18.0"
8
+ description = "One live instance of your class per id, with an HTTP surface"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ dependencies = ["fastapi>=0.115", "starlette>=0.37", "httpx>=0.27", "statepatch>=0.3.0,<0.4"]
13
+
14
+ [tool.uv.sources]
15
+ statepatch = { workspace = true }
16
+
17
+ [project.optional-dependencies]
18
+ psutil = ["psutil>=5.9"]
19
+ postgres = ["psycopg>=3.1"]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/assistant-ui/harness-sdk"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/pinboard"]
26
+
27
+ [tool.ruff]
28
+ extend = "../../pyproject.toml"
29
+
30
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
31
+ "time.time".msg = "read the clock through a Domain (pinboard.domain)"
32
+ "time.monotonic".msg = "read the clock through a Domain (pinboard.domain)"
33
+ "time.perf_counter".msg = "read the clock through a Domain (pinboard.domain)"
34
+ "time.time_ns".msg = "read the clock through a Domain (pinboard.domain)"
35
+ "time.monotonic_ns".msg = "read the clock through a Domain (pinboard.domain)"
36
+ "time.perf_counter_ns".msg = "read the clock through a Domain (pinboard.domain)"
37
+ "time.sleep".msg = "sleep through a Domain (pinboard.domain)"
38
+ "asyncio.sleep".msg = "sleep through a Domain (pinboard.domain)"
39
+ "asyncio.wait_for".msg = "wait through a Domain (pinboard.domain)"
40
+ "asyncio.timeout".msg = "wait through a Domain (pinboard.domain)"
41
+ "asyncio.create_task".msg = "spawn through a Domain (pinboard.domain)"
42
+ "asyncio.ensure_future".msg = "spawn through a Domain (pinboard.domain)"
43
+ "asyncio.gather".msg = "gather turns coroutines into ambient tasks; spawn through a Domain (pinboard.domain) and gather the tasks"
44
+ "datetime.datetime.now".msg = "read the clock through a Domain (pinboard.domain)"
45
+ "datetime.datetime.utcnow".msg = "read the clock through a Domain (pinboard.domain)"
46
+ "datetime.date.today".msg = "read the clock through a Domain (pinboard.domain)"
47
+ "random".msg = "draw from a Domain rng stream (pinboard.domain)"
48
+ "secrets".msg = "mint tokens through a Domain (pinboard.domain)"
49
+ "uuid.uuid4".msg = "mint tokens through a Domain (pinboard.domain)"
50
+ "uuid.uuid1".msg = "mint tokens through a Domain (pinboard.domain)"
51
+ "os.urandom".msg = "mint tokens through a Domain (pinboard.domain)"
52
+
53
+ [tool.ruff.lint.per-file-ignores]
54
+ "src/pinboard/domain.py" = ["TID251"]
55
+ "src/pinboard/sim/__init__.py" = ["TID251"]
56
+ "src/pinboard/sim/harness.py" = ["TID251"]
57
+ "src/pinboard/testing.py" = ["TID251"]
58
+ "tests/**" = ["TID251"]
59
+ "examples/**" = ["TID251"]
@@ -0,0 +1,74 @@
1
+ from .api import AuthRejected, PinboardAPI, PinboardContext, auth_result
2
+ from .domain import Domain, RealDomain
3
+ from .durability import (
4
+ FenceLost,
5
+ Frontier,
6
+ InstanceDurability,
7
+ Receipt,
8
+ StateStore,
9
+ )
10
+ from statepatch import Observable, observable
11
+
12
+ from .hosting.host import PinboardHost
13
+ from .trays import (
14
+ Tray,
15
+ TrayCtx,
16
+ TrayDescriptor,
17
+ TrayMapNamespace,
18
+ TrayNamespace,
19
+ TrayStorage,
20
+ inbox,
21
+ persistent,
22
+ persistent_outbox,
23
+ persistent_queue,
24
+ tray,
25
+ )
26
+ from .hosting.hostable import Hostable, HostableContext
27
+ from .hosting.routing import route
28
+ from .worker import (
29
+ Attestation,
30
+ EvictionRequested,
31
+ Host,
32
+ InstanceLease,
33
+ Lease,
34
+ Worker,
35
+ attestation,
36
+ )
37
+
38
+ __all__ = [
39
+ "Attestation",
40
+ "attestation",
41
+ "AuthRejected",
42
+ "Domain",
43
+ "RealDomain",
44
+ "auth_result",
45
+ "PinboardAPI",
46
+ "PinboardContext",
47
+ "Host",
48
+ "PinboardHost",
49
+ "Worker",
50
+ "Hostable",
51
+ "HostableContext",
52
+ "InstanceLease",
53
+ "EvictionRequested",
54
+ "Lease",
55
+ "route",
56
+ "StateStore",
57
+ "FenceLost",
58
+ "InstanceDurability",
59
+ "Frontier",
60
+ "Receipt",
61
+ "Observable",
62
+ "observable",
63
+ "Tray",
64
+ "TrayCtx",
65
+ "TrayDescriptor",
66
+ "TrayMapNamespace",
67
+ "TrayNamespace",
68
+ "tray",
69
+ "inbox",
70
+ "TrayStorage",
71
+ "persistent",
72
+ "persistent_queue",
73
+ "persistent_outbox",
74
+ ]
@@ -0,0 +1,178 @@
1
+ import asyncio
2
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Coroutine, Literal
3
+
4
+ from starlette.requests import HTTPConnection
5
+
6
+ from .domain import Domain
7
+ from .durability import Frontier, InstanceDurability
8
+ from .worker.lease import EvictionRequested, InstanceLease
9
+
10
+ if TYPE_CHECKING:
11
+ import httpx
12
+
13
+ from .hostable import Hostable, HostableContext
14
+
15
+
16
+ AUTH_RESULT_SCOPE_KEY = "pinned.auth_result"
17
+
18
+
19
+ class AuthRejected(Exception):
20
+ """Deliberate route denial with an exact optional transport payload."""
21
+
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ *,
26
+ status_code: Literal[401, 403] = 401,
27
+ payload: Any = None,
28
+ code: str | None = None,
29
+ ):
30
+ if type(status_code) is not int or status_code not in (401, 403):
31
+ raise ValueError("AuthRejected status_code must be 401 or 403")
32
+ if not isinstance(message, str):
33
+ raise TypeError("AuthRejected message must be a string")
34
+ if code is not None and not isinstance(code, str):
35
+ raise TypeError("AuthRejected code must be a string")
36
+ super().__init__(message)
37
+ self.message = message
38
+ self.status_code = status_code
39
+ self.payload = payload
40
+ self.code = code
41
+
42
+
43
+ async def auth_result(conn: HTTPConnection) -> Any:
44
+ """The current route resolver's return value, for ``Depends(auth_result)``."""
45
+ if AUTH_RESULT_SCOPE_KEY not in conn.scope:
46
+ raise RuntimeError("auth_result resolves only inside a pinned route")
47
+ return conn.scope[AUTH_RESULT_SCOPE_KEY]
48
+
49
+
50
+ class PinboardContext:
51
+ def __init__(
52
+ self,
53
+ id: str,
54
+ *,
55
+ lease: InstanceLease,
56
+ abort: Callable[[], None],
57
+ create_task: Callable[[Coroutine[Any, Any, Any]], "asyncio.Task[Any]"],
58
+ durability: InstanceDurability | None = None,
59
+ cluster: "httpx.AsyncClient | None" = None,
60
+ domain: Domain,
61
+ create_durable_task: Callable[[Coroutine[Any, Any, Any]], "asyncio.Task[Any]"]
62
+ | None = None,
63
+ ) -> None:
64
+ self.id = id
65
+ self.lease = lease
66
+ self.durability = durability
67
+ self.cluster = cluster
68
+ self.domain = domain
69
+ self._abort = abort
70
+ self._create_task = create_task
71
+ if durability is not None:
72
+ durability.frontier = Frontier(
73
+ durability, self.abort, create_durable_task or self.create_task
74
+ )
75
+
76
+ def abort(self) -> None:
77
+ self._abort()
78
+
79
+ def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
80
+ return self._create_task(coro)
81
+
82
+
83
+ class PinboardAPI:
84
+ # The instance base depends only on the context interface, not the concrete
85
+ # PinboardContext — a PinboardAPI would host unchanged under any HostableContext.
86
+ def __init__(self, ctx: "HostableContext") -> None:
87
+ self._ctx = ctx
88
+
89
+ async def authorize(self, conn: HTTPConnection) -> Any:
90
+ """Auth seam gating every ``@route`` handler: raise ``AuthRejected``
91
+ to reject. The return value is the caller's identity — any host-chosen
92
+ object, ``None`` for anonymous — consumed via ``Depends(auth_result)``.
93
+
94
+ Runs before ``lifespan`` on a cold instance, so a denied request never
95
+ activates: ``self.id`` is available, state built in ``lifespan`` is
96
+ not — cold and warm calls share that one contract. Runs per request; writes to ``self`` are latest-wins."""
97
+ return None
98
+
99
+ async def _route_failed(self, conn: HTTPConnection, message: str) -> None:
100
+ """Protocol notification before a failed WebSocket route closes."""
101
+
102
+ async def _route_refused(
103
+ self,
104
+ conn: HTTPConnection,
105
+ message: str,
106
+ *,
107
+ status: int,
108
+ retry_after: float,
109
+ ) -> None:
110
+ """Protocol notification before a refused (retryable) WebSocket route closes."""
111
+
112
+ def stats(self) -> "dict[str, Any] | None":
113
+ """Optional per-instance wire stats merged into ``/_pinned/stats`` rows."""
114
+ return None
115
+
116
+ @property
117
+ def id(self) -> str:
118
+ return self._ctx.id
119
+
120
+ @property
121
+ def lease(self) -> InstanceLease:
122
+ return self._ctx.lease
123
+
124
+ def abort(self) -> None:
125
+ self._ctx.abort()
126
+
127
+ @property
128
+ def domain(self) -> Domain:
129
+ """Ambient primitives — now/monotonic/sleep/wait/spawn/rng/token_hex — simulable under ``SimDomain``."""
130
+ return self._ctx.domain
131
+
132
+ @property
133
+ def cluster(self) -> "httpx.AsyncClient":
134
+ """HTTP client addressing other instances by data-plane path (``/<ns>/<id>/...``), with this instance attested as the caller."""
135
+ cluster = self._ctx.cluster
136
+ if cluster is None:
137
+ raise RuntimeError("self.cluster requires a PinboardHost")
138
+ return cluster
139
+
140
+ @property
141
+ def frontier(self) -> Frontier:
142
+ """Instance-wide ordered log of durable effects; ``await self.frontier.wait()`` is the durable receipt."""
143
+ durability = self._ctx.durability
144
+ if durability is None:
145
+ raise RuntimeError(
146
+ "durability requires a state store (Worker(state_store=...))"
147
+ )
148
+ assert durability.frontier is not None
149
+ return durability.frontier
150
+
151
+ @property
152
+ def placement_epoch(self) -> int:
153
+ """The placement epoch this activation writes at (the ``pinboard-epoch`` header, 0 without a pinboard); follows an in-place epoch advance."""
154
+ durability = self._ctx.durability
155
+ if durability is None:
156
+ raise RuntimeError(
157
+ "placement_epoch requires a state store (Worker(state_store=...))"
158
+ )
159
+ return durability.epoch
160
+
161
+ def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
162
+ return self._ctx.create_task(coro)
163
+
164
+ async def eviction_checkpoint(self) -> None:
165
+ """Yield unfinished execution for relocation when this instance is winding down."""
166
+ if self.lease.winding_down:
167
+ raise EvictionRequested()
168
+
169
+ async def lifespan(self) -> AsyncIterator[None]:
170
+ yield
171
+
172
+
173
+ if TYPE_CHECKING:
174
+ # The seam, proved structurally: the concrete types satisfy the Protocols a
175
+ # future zero-dependency host would depend on instead of these classes.
176
+ def _assert_conformance(ctx: PinboardContext, api: PinboardAPI) -> None:
177
+ _c: "HostableContext" = ctx
178
+ _h: "Hostable" = api
@@ -0,0 +1,73 @@
1
+ """The ambient-primitive seam: clocks, sleep, timed waits, task spawn and randomness.
2
+
3
+ Every reading of the world in pinned goes through a ``Domain``, threaded from
4
+ the worker down. ``RealDomain`` is the process; a simulation installs its own
5
+ to run the same code on virtual time.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import random
11
+ import secrets
12
+ import time
13
+ from typing import Any, Coroutine, Protocol, runtime_checkable
14
+
15
+
16
+ @runtime_checkable
17
+ class Domain(Protocol):
18
+ """Ambient primitives, injected: wall clock, monotonic clock, sleep, timed waits, task spawn, seeded RNG streams, nonces."""
19
+
20
+ def now(self) -> float: ...
21
+
22
+ def monotonic(self) -> float: ...
23
+
24
+ async def sleep(self, seconds: float) -> None: ...
25
+
26
+ async def wait(self, event: asyncio.Event, timeout: float) -> bool: ...
27
+
28
+ def spawn(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]": ...
29
+
30
+ def rng(self, label: str) -> random.Random: ...
31
+
32
+ def token_hex(self, nbytes: int) -> str: ...
33
+
34
+
35
+ class RealDomain:
36
+ """The process Domain: system clocks, the running event loop, per-label RNG streams off one seed."""
37
+
38
+ def __init__(self, *, seed: int = 0) -> None:
39
+ if not isinstance(seed, int) or isinstance(seed, bool):
40
+ raise TypeError(f"seed must be an int: {seed!r}")
41
+ self._seed = seed
42
+ self._rngs: dict[str, random.Random] = {}
43
+
44
+ def now(self) -> float:
45
+ return time.time()
46
+
47
+ def monotonic(self) -> float:
48
+ return time.monotonic()
49
+
50
+ async def sleep(self, seconds: float) -> None:
51
+ await asyncio.sleep(seconds)
52
+
53
+ async def wait(self, event: asyncio.Event, timeout: float) -> bool:
54
+ # A cancel landing exactly as the timeout fires surfaces as
55
+ # TimeoutError; callers read the event, not the outcome of the race.
56
+ with contextlib.suppress(asyncio.TimeoutError):
57
+ await asyncio.wait_for(event.wait(), timeout)
58
+ return event.is_set()
59
+
60
+ def spawn(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
61
+ return asyncio.create_task(coro)
62
+
63
+ def rng(self, label: str) -> random.Random:
64
+ # One substream per label: a new call site cannot shift another's draws.
65
+ if not label:
66
+ raise ValueError("an rng stream needs a non-empty label")
67
+ stream = self._rngs.get(label)
68
+ if stream is None:
69
+ stream = self._rngs[label] = random.Random(f"{self._seed}:{label}")
70
+ return stream
71
+
72
+ def token_hex(self, nbytes: int) -> str:
73
+ return secrets.token_hex(nbytes)
@@ -0,0 +1,11 @@
1
+ from .frontier import Frontier, InstanceDurability, Receipt
2
+ from .store import FenceLost, MemoryStateStore, StateStore
3
+
4
+ __all__ = [
5
+ "FenceLost",
6
+ "Frontier",
7
+ "InstanceDurability",
8
+ "MemoryStateStore",
9
+ "Receipt",
10
+ "StateStore",
11
+ ]