From 10e191fcd7cbf08d50e5134e197602e7421dc98f Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:01:35 +0000 Subject: [PATCH 01/30] feat: Substrate workspace-runtime adapter contract (fixture-backed) Per-session WorkspaceBinding adapter over Substrate's actor control plane (kubectl ate: create/get/resume/suspend/revert), replacing the fixed workspace_namespace/workspace_pod pair for Substrate-backed sessions. Mainloop persists the session<->actor mapping with ownership-generation fencing (workspace_bindings table), reusing the SENDING-before-transport and retry-after-inspect rules from contracts.py/native_sessions.py rather than a new scheme. CRASHED is surfaced as a typed CapabilityResult, never auto-reverted; revert_workspace requires explicit acknowledge_loss=True. Fixture-only so far (no live cluster): transport parsing and the retry-safe create/attach/surface_gap policy are unit tested against a fake kubectl-ate subprocess, following test_herdr.py's pattern. The DB-backed orchestration functions are not yet exercised against a live cluster, matching native_sessions.py's own disclaimer in docs/specs. --- backend/src/mainloop/config.py | 12 + backend/src/mainloop/db/postgres.py | 27 ++ backend/src/mainloop/runtime/substrate.py | 236 ++++++++++++++ .../src/mainloop/runtime/workspace_adapter.py | 302 ++++++++++++++++++ backend/tests/runtime/test_substrate.py | 150 +++++++++ .../tests/runtime/test_workspace_adapter.py | 110 +++++++ 6 files changed, 837 insertions(+) create mode 100644 backend/src/mainloop/runtime/substrate.py create mode 100644 backend/src/mainloop/runtime/workspace_adapter.py create mode 100644 backend/tests/runtime/test_substrate.py create mode 100644 backend/tests/runtime/test_workspace_adapter.py diff --git a/backend/src/mainloop/config.py b/backend/src/mainloop/config.py index 297e847..94814a7 100644 --- a/backend/src/mainloop/config.py +++ b/backend/src/mainloop/config.py @@ -37,6 +37,18 @@ def database_url(self) -> str: "main-0" # pod that runs the native main thread (scratch cwd, no repo) ) + # Substrate workspace-runtime adapter (bounded integration spike; see + # docs/architecture/native-agent-inventory.md and .tasknotes/plan.md). Empty + # kubeconfig/context falls back to the ambient kubeconfig. One actor per session + # replaces the fixed workspace_namespace/workspace_pod pair above for Substrate-backed + # sessions; Herdr pod-exec keeps working unchanged for sessions that are not. + substrate_kubeconfig: str = "" + substrate_context: str = "" + substrate_atespace: str = "mainloop-workspaces" + substrate_actor_template: str = "mainloop-workspace" + substrate_cli: str = "kubectl-ate" + substrate_preview_base_url: str = "" + # Native main thread (context model). MAIN_THREAD_MODE=native replaces the SDK chat path. main_thread_mode: str = "sdk" # sdk | native main_thread_model: str = "sonnet" diff --git a/backend/src/mainloop/db/postgres.py b/backend/src/mainloop/db/postgres.py index 47a6405..810f1b7 100644 --- a/backend/src/mainloop/db/postgres.py +++ b/backend/src/mainloop/db/postgres.py @@ -223,6 +223,33 @@ def _parse_json_field(value: Any) -> list | dict | None: CREATE UNIQUE INDEX IF NOT EXISTS idx_native_bindings_token ON native_bindings(token_hash) WHERE token_hash IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_native_bindings_parent ON native_bindings(parent_session_id); +-- Substrate workspace-runtime adapter: durable mapping from a Mainloop session to a Substrate +-- actor. Separate from native_bindings (the Herdr agent/native-session identity) because a +-- session's workspace runtime is a distinct concept -- see ROADMAP.md "Workspace platform". +-- One actor per session (workspace_id = session_id) replaces the fixed workspace pod for +-- Substrate-backed sessions. ownership_generation fences resume/suspend/revert the same way +-- native_bindings.generation fences Herdr sends: a stale caller's mutation is rejected, and a +-- retry re-inspects the actor and this row rather than creating a second one. +CREATE TABLE IF NOT EXISTS workspace_bindings ( + workspace_id TEXT PRIMARY KEY REFERENCES sessions(id), + provider TEXT NOT NULL DEFAULT 'substrate', + atespace TEXT NOT NULL, + actor_name TEXT NOT NULL, + actor_template TEXT NOT NULL, + native_session_id TEXT, + preview_route TEXT, + runtime_endpoint TEXT, + observed_state TEXT NOT NULL DEFAULT 'unknown', + observed_at TIMESTAMPTZ, + external_snapshot_uri TEXT, + ownership_generation INTEGER NOT NULL DEFAULT 1, + desired_state TEXT NOT NULL DEFAULT 'active', + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (atespace, actor_name) +); + -- Topics are durable records (not sessions). Supervisors (next slice) attach to a topic. CREATE TABLE IF NOT EXISTS topics ( id TEXT PRIMARY KEY, diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py new file mode 100644 index 0000000..076a054 --- /dev/null +++ b/backend/src/mainloop/runtime/substrate.py @@ -0,0 +1,236 @@ +"""Thin Substrate adapter: drives ``kubectl ate`` (control-plane CLI over gRPC to +``ate-api-server``) to manage per-session actors as Mainloop workspaces. + +Unlike ``herdr.py`` (pod-exec into an already-running workspace), this adapter talks to +Substrate's cluster-level control plane: actors are created, suspended, resumed, reverted and +deleted through ``ateapipb.Control`` (see the pinned checkout's ``pkg/proto/ateapipb/ateapi.proto`` +and ``cmd/kubectl-ate/internal/cmd/actor.go``). ``TransportError`` means the outcome of the call +is unknown; callers must inspect the actor before retrying rather than blindly re-creating it. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import shlex +from dataclasses import dataclass +from enum import StrEnum + +from mainloop.config import settings + +logger = logging.getLogger(__name__) + + +class TransportError(RuntimeError): + """The ``kubectl ate`` call failed or timed out; whether it took effect is unknown.""" + + +class ActorState(StrEnum): + """Mirrors ``ateapipb.ActorState``; unrecognized strings map to UNSPECIFIED.""" + + UNSPECIFIED = "ACTOR_STATE_UNSPECIFIED" + RESUMING = "ACTOR_STATE_RESUMING" + RUNNING = "ACTOR_STATE_RUNNING" + SUSPENDING = "ACTOR_STATE_SUSPENDING" + SUSPENDED = "ACTOR_STATE_SUSPENDED" + PAUSING = "ACTOR_STATE_PAUSING" + PAUSED = "ACTOR_STATE_PAUSED" + CRASHED = "ACTOR_STATE_CRASHED" + DELETING = "ACTOR_STATE_DELETING" + REVERTING = "ACTOR_STATE_REVERTING" + + @classmethod + def parse(cls, raw: str | None) -> "ActorState": + try: + return cls(raw) + except ValueError: + return cls.UNSPECIFIED + + +# ActorState -> WorkspaceBinding.observed_state (models.native_agent). Only RUNNING is ready; +# terminal/absent states are unavailable; states mid-transition or unrecognized are unknown. +OBSERVED_STATE = { + ActorState.RUNNING: "ready", + ActorState.SUSPENDED: "unavailable", + ActorState.PAUSED: "unavailable", + ActorState.CRASHED: "unavailable", + ActorState.DELETING: "unavailable", + ActorState.RESUMING: "unknown", + ActorState.SUSPENDING: "unknown", + ActorState.PAUSING: "unknown", + ActorState.REVERTING: "unknown", + ActorState.UNSPECIFIED: "unknown", +} + + +@dataclass(frozen=True, slots=True) +class ExecResult: + exit_code: int + stdout: str + stderr: str + + +@dataclass(frozen=True, slots=True) +class ActorRecord: + """Parsed subset of an ``ateapipb.Actor`` (protojson via ``kubectl ate ... -o json``).""" + + atespace: str + name: str + uid: str | None + state: ActorState + external_snapshot_uri: str | None + current_actor_template_uid: str | None + raw: dict + + +def _actor_from_json(doc: dict) -> ActorRecord: + metadata = doc.get("metadata") or {} + status = doc.get("status") or {} + snapshot = status.get("externalSnapshot") or {} + return ActorRecord( + atespace=metadata.get("atespace", ""), + name=metadata.get("name", ""), + uid=metadata.get("uid"), + state=ActorState.parse(status.get("state")), + external_snapshot_uri=snapshot.get("snapshotUri"), + current_actor_template_uid=status.get("currentActorTemplateUid"), + raw=doc, + ) + + +class SubstrateControl: + """Wraps ``kubectl ate`` for one (kubeconfig, context) pair. No retries, no caching.""" + + def __init__( + self, + *, + kubeconfig: str | None = None, + context: str | None = None, + cli: str | None = None, + ): + self.kubeconfig = ( + kubeconfig if kubeconfig is not None else settings.substrate_kubeconfig + ) + self.context = context if context is not None else settings.substrate_context + self.cli = cli or settings.substrate_cli + + def _base_args(self) -> list[str]: + args = [self.cli] + if self.kubeconfig: + args += ["--kubeconfig", self.kubeconfig] + if self.context: + args += ["--context", self.context] + return args + + async def _exec(self, args: list[str], timeout: float = 45) -> ExecResult: + command = self._base_args() + args + try: + proc = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except TimeoutError as exc: + proc.kill() + await proc.wait() + raise TransportError( + f"{cli_quote(*command)} timed out after {timeout}s" + ) from exc + except OSError as exc: + raise TransportError(f"exec failed: {type(exc).__name__}: {exc}") from exc + if proc.returncode is None: + raise TransportError("kubectl ate did not report an exit status") + return ExecResult( + proc.returncode, out.decode(errors="replace"), err.decode(errors="replace") + ) + + async def get_actor(self, atespace: str, name: str) -> ActorRecord | None: + res = await self._exec( + ["get", "actor", name, "--atespace", atespace, "-o", "json"] + ) + if res.exit_code != 0: + if "not found" in res.stderr.lower() or "NotFound" in res.stderr: + return None + raise TransportError( + f"get actor failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + text = res.stdout.strip() + if not text: + return None + return _actor_from_json(json.loads(text)) + + async def create_actor( + self, atespace: str, name: str, *, template: str, tag: str | None = None + ) -> ActorRecord: + args = ["create", "actor", name, "--atespace", atespace, "--template", template] + if tag: + args += ["--tag", tag] + res = await self._exec(args, timeout=90) + if res.exit_code != 0: + raise RuntimeError( + f"create actor failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + return await self._require(atespace, name, res) + + async def resume_actor(self, atespace: str, name: str) -> ActorRecord: + res = await self._exec( + ["resume", "actor", name, "--atespace", atespace, "-o", "json"], timeout=90 + ) + if res.exit_code != 0: + raise RuntimeError( + f"resume actor failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + return await self._require(atespace, name, res) + + async def suspend_actor(self, atespace: str, name: str) -> ActorRecord: + res = await self._exec( + ["suspend", "actor", name, "--atespace", atespace, "-o", "json"], timeout=90 + ) + if res.exit_code != 0: + raise RuntimeError( + f"suspend actor failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + return await self._require(atespace, name, res) + + async def revert_actor(self, atespace: str, name: str) -> ActorRecord: + """Discard current execution, roll back to the last completed snapshot. Explicit only: + callers must have surfaced the possible loss of unsnapshotted work before calling this. + """ + res = await self._exec( + ["revert", "actor", name, "--atespace", atespace, "-o", "json"], timeout=90 + ) + if res.exit_code != 0: + raise RuntimeError( + f"revert actor failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + return await self._require(atespace, name, res) + + async def delete_actor( + self, atespace: str, name: str, *, any_state: bool = False + ) -> None: + args = ["delete", "actor", name, "--atespace", atespace] + if any_state: + args.append("--any-state") + res = await self._exec(args, timeout=60) + if res.exit_code != 0 and "not found" not in res.stderr.lower(): + raise RuntimeError( + f"delete actor failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + + async def _require(self, atespace: str, name: str, res: ExecResult) -> ActorRecord: + text = res.stdout.strip() + if text: + return _actor_from_json(json.loads(text)) + # Some verbs may not echo the actor; read it back rather than guessing its state. + actor = await self.get_actor(atespace, name) + if actor is None: + raise TransportError(f"actor {atespace}/{name} not found after operation") + return actor + + +def cli_quote(*parts: str) -> str: + """Only for logging/evidence; commands are passed as argv, never through a shell.""" + return " ".join(shlex.quote(p) for p in parts) diff --git a/backend/src/mainloop/runtime/workspace_adapter.py b/backend/src/mainloop/runtime/workspace_adapter.py new file mode 100644 index 0000000..aa3eb64 --- /dev/null +++ b/backend/src/mainloop/runtime/workspace_adapter.py @@ -0,0 +1,302 @@ +"""Per-session Substrate workspace bindings: the durable mapping between a Mainloop session, +a Substrate actor, its snapshot, cross-referenced native session id, and Mainloop's ownership +generation for that actor. This is the adapter boundary described in ``.tasknotes/plan.md``: +Mainloop owns creation intent, desired state, retry policy and audit; Substrate owns isolated +actor compute and snapshots. Produces ``models.native_agent.WorkspaceBinding`` -- the existing +contract type -- rather than a parallel workspace model. + +Rules carried over from ``native_sessions.py`` / ``contracts.py`` rather than reinvented: +- Identity is persisted before an uncertain external call, and a retry re-inspects the actor and + this row before creating or mutating anything (no blind replay, no second writer). +- Every mutation is fenced by ``ownership_generation``: a stale caller's write is rejected, not + silently applied. +- ``CRASHED`` and revert are never automatic. Reverting is only reachable through + ``revert_workspace`` with an explicit acknowledgement that unsnapshotted work may be lost. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime + +from mainloop.config import settings +from mainloop.db import db +from mainloop.runtime.contracts import ContractError, StaleOwnership +from mainloop.runtime.substrate import ( + OBSERVED_STATE, + ActorRecord, + ActorState, + SubstrateControl, + TransportError, +) + +from models import CapabilityResult, CapabilityState, WorkspaceBinding + +logger = logging.getLogger(__name__) + +_locks: dict[str, asyncio.Lock] = {} + + +def _lock(session_id: str) -> asyncio.Lock: + return _locks.setdefault(session_id, asyncio.Lock()) + + +def actor_name(session_id: str) -> str: + return f"ml-{session_id[:16]}" + + +def _control() -> SubstrateControl: + return SubstrateControl() + + +async def get_workspace(session_id: str) -> dict | None: + async with db.connection() as conn: + row = await conn.fetchrow( + "SELECT * FROM workspace_bindings WHERE workspace_id=$1", session_id + ) + return dict(row) if row else None + + +def _binding_from_row(row: dict) -> WorkspaceBinding: + capabilities: tuple[CapabilityResult, ...] = () + if row["observed_state"] == "unavailable" and row["last_error"]: + # A row-level note (e.g. "actor missing", "actor CRASHED") becomes a typed capability + # result rather than free text on the contract model, per the proved/partial evidence rule. + note = row["last_error"] + capabilities = ( + CapabilityResult( + capability=( + "actor_crashed" if "CRASHED" in note.upper() else "actor_health" + ), + state=CapabilityState.PROVED, + # SubstrateControl always talks to a real (possibly Kind) cluster, so this + # in-binding health signal is "live" by construction. The four plan-mandated + # integration-gate CapabilityResults are separate records in the proof note, + # scored "fixture" or "live" by whatever trial produced their evidence_ref. + scope="live", + evidence_ref=f"substrate://{row['atespace']}/{row['actor_name']}", + detail=note, + ), + ) + return WorkspaceBinding( + workspace_id=row["workspace_id"], + runtime_endpoint=row["runtime_endpoint"] or row["preview_route"] or "unrouted", + observed_at=row["observed_at"] or row["updated_at"], + observed_state=row["observed_state"], + capabilities=capabilities, + ) + + +async def _insert_row( + session_id: str, + atespace: str, + name: str, + template: str, + actor: ActorRecord, + now: datetime, +) -> None: + route = f"{atespace}/{name}" + async with db.connection() as conn: + await conn.execute( + """INSERT INTO workspace_bindings + (workspace_id, atespace, actor_name, actor_template, preview_route, + runtime_endpoint, observed_state, observed_at, external_snapshot_uri) + VALUES ($1,$2,$3,$4,$5,$5,$6,$7,$8)""", + session_id, + atespace, + name, + template, + route, + OBSERVED_STATE[actor.state], + now, + actor.external_snapshot_uri, + ) + + +CRASHED_NOTE = ( + "actor CRASHED: it stopped running and lost anything since its last completed snapshot. " + "Substrate's actor record has no snapshot timestamp, so snapshot age cannot be reported here. " + "Resume is rejected in this state; only an explicit revert_workspace(acknowledge_loss=True) " + "restores it, discarding any unsnapshotted work." +) + + +async def _update_observed( + session_id: str, actor: ActorRecord, now: datetime, *, last_error: str | None = None +) -> None: + if last_error is None and actor.state == ActorState.CRASHED: + last_error = CRASHED_NOTE + async with db.connection() as conn: + await conn.execute( + """UPDATE workspace_bindings + SET observed_state=$2, observed_at=$3, external_snapshot_uri=$4, + last_error=$5, updated_at=NOW() + WHERE workspace_id=$1""", + session_id, + OBSERVED_STATE[actor.state], + now, + actor.external_snapshot_uri, + last_error, + ) + + +async def _mark_missing(session_id: str, now: datetime) -> None: + async with db.connection() as conn: + await conn.execute( + """UPDATE workspace_bindings + SET observed_state='unavailable', observed_at=$2, last_error=$3, updated_at=NOW() + WHERE workspace_id=$1""", + session_id, + now, + "actor not found where this row expected one; not recreated automatically " + "(inspect and reconcile explicitly, or delete this row to allow a fresh actor)", + ) + + +async def _bump_generation(session_id: str, expected: int) -> None: + async with db.connection() as conn: + tag = await conn.execute( + """UPDATE workspace_bindings SET ownership_generation=ownership_generation+1, + updated_at=NOW() WHERE workspace_id=$1 AND ownership_generation=$2""", + session_id, + expected, + ) + if tag.endswith(" 0"): + raise StaleOwnership( + f"workspace_bindings.{session_id} is no longer at generation {expected}" + ) + + +def plan_ensure(row_exists: bool, actor_found: bool) -> str: + """Retry-safe provision-or-attach policy, factored out of ``ensure_workspace`` so it is + directly testable without a database or cluster. + + ``create``: no row and no actor -- first provision. ``attach``: an actor already exists + (whether or not this process created it), so observe it rather than creating another. + ``surface_gap``: this row believes it owns an actor that Substrate no longer has -- never + silently recreate under the same name; a human or a later explicit call must reconcile it. + """ + if actor_found: + return "attach" + return "create" if not row_exists else "surface_gap" + + +async def ensure_workspace( + session_id: str, *, control: SubstrateControl | None = None +) -> WorkspaceBinding: + """Idempotent provision-or-attach. Never creates a second actor for a row that already + believes it owns one; a gap between this row and Substrate's view is surfaced, not papered + over.""" + control = control or _control() + async with _lock(session_id): + row = await get_workspace(session_id) + atespace = settings.substrate_atespace + template = settings.substrate_actor_template + name = row["actor_name"] if row else actor_name(session_id) + try: + actor = await control.get_actor(atespace, name) + except TransportError: + if row is not None: + return _binding_from_row( + row + ) # unreachable now; last known state stands + raise + now = datetime.now(UTC) + action = plan_ensure(row is not None, actor is not None) + if action == "surface_gap": + await _mark_missing(session_id, now) + return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] + if action == "create": + actor = await control.create_actor(atespace, name, template=template) + await _insert_row(session_id, atespace, name, template, actor, now) + else: + await _update_observed(session_id, actor, now) + return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] + + +async def observe_workspace( + session_id: str, *, control: SubstrateControl | None = None +) -> WorkspaceBinding | None: + """Read-only reconcile: refresh observed_state/observed_at without changing desired state.""" + control = control or _control() + row = await get_workspace(session_id) + if row is None: + return None + now = datetime.now(UTC) + try: + actor = await control.get_actor(row["atespace"], row["actor_name"]) + except TransportError as exc: + logger.info("workspace observe skipped for %s: %s", session_id, exc) + return _binding_from_row(row) + if actor is None: + await _mark_missing(session_id, now) + else: + await _update_observed(session_id, actor, now) + return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] + + +async def resume_workspace( + session_id: str, *, control: SubstrateControl | None = None +) -> WorkspaceBinding: + control = control or _control() + async with _lock(session_id): + row = await get_workspace(session_id) + if row is None: + raise ContractError(f"no workspace binding for session {session_id}") + actor = await control.resume_actor(row["atespace"], row["actor_name"]) + now = datetime.now(UTC) + await _update_observed(session_id, actor, now) + await _bump_generation(session_id, row["ownership_generation"]) + return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] + + +async def suspend_workspace( + session_id: str, *, control: SubstrateControl | None = None +) -> WorkspaceBinding: + control = control or _control() + async with _lock(session_id): + row = await get_workspace(session_id) + if row is None: + raise ContractError(f"no workspace binding for session {session_id}") + actor = await control.suspend_actor(row["atespace"], row["actor_name"]) + now = datetime.now(UTC) + await _update_observed(session_id, actor, now) + await _bump_generation(session_id, row["ownership_generation"]) + return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] + + +async def revert_workspace( + session_id: str, *, acknowledge_loss: bool, control: SubstrateControl | None = None +) -> WorkspaceBinding: + """Roll back to the actor's last completed snapshot, discarding any unsnapshotted work. + Never called implicitly by this module -- the caller (API layer) must have shown the user + the actor is CRASHED (or otherwise irrecoverable) and gotten explicit confirmation first. + """ + if not acknowledge_loss: + raise ContractError( + "revert_workspace requires acknowledge_loss=True: it discards unsnapshotted work" + ) + control = control or _control() + async with _lock(session_id): + row = await get_workspace(session_id) + if row is None: + raise ContractError(f"no workspace binding for session {session_id}") + actor = await control.revert_actor(row["atespace"], row["actor_name"]) + now = datetime.now(UTC) + await _update_observed( + session_id, + actor, + now, + last_error="reverted to last completed snapshot; any unsnapshotted work was lost", + ) + await _bump_generation(session_id, row["ownership_generation"]) + return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] + + +def is_crashed(binding_row: dict) -> bool: + return ( + binding_row["observed_state"] == "unavailable" + and binding_row.get("last_error") is not None + and "CRASHED" in (binding_row.get("last_error") or "").upper() + ) diff --git a/backend/tests/runtime/test_substrate.py b/backend/tests/runtime/test_substrate.py new file mode 100644 index 0000000..66fb1a3 --- /dev/null +++ b/backend/tests/runtime/test_substrate.py @@ -0,0 +1,150 @@ +"""Substrate transport adapter over a fake ``kubectl ate`` subprocess: no cluster, no actors, +no credentials. Mirrors test_herdr.py's fake-transport pattern for the Herdr adapter.""" + +import asyncio +import unittest + +from mainloop.runtime.substrate import ( + ActorState, + ExecResult, + SubstrateControl, + TransportError, + _actor_from_json, +) + + +class FakeControl(SubstrateControl): + def __init__(self, results): + super().__init__( + kubeconfig="fixture-kubeconfig", context="kind-substrate-preview" + ) + self.results = list(results) + self.calls: list[list[str]] = [] + + async def _exec(self, args, timeout=45): + self.calls.append(args) + result = self.results.pop(0) + if isinstance(result, Exception): + raise result + return result + + +def run(coro): + return asyncio.run(coro) + + +def actor_json(state: str, *, snapshot_uri: str | None = None) -> str: + doc = { + "metadata": {"atespace": "mainloop-workspaces", "name": "ml-abc", "uid": "u-1"}, + "status": {"state": state}, + } + if snapshot_uri: + doc["status"]["externalSnapshot"] = {"snapshotUri": snapshot_uri} + import json + + return json.dumps(doc) + + +class ActorJsonParsingTests(unittest.TestCase): + def test_parses_running_actor_with_snapshot(self): + import json + + doc = json.loads(actor_json("ACTOR_STATE_RUNNING", snapshot_uri="gs://b/p")) + record = _actor_from_json(doc) + self.assertEqual(record.atespace, "mainloop-workspaces") + self.assertEqual(record.name, "ml-abc") + self.assertEqual(record.state, ActorState.RUNNING) + self.assertEqual(record.external_snapshot_uri, "gs://b/p") + + def test_unrecognized_state_string_is_unspecified_not_a_crash(self): + record = _actor_from_json( + {"metadata": {}, "status": {"state": "SOME_FUTURE_STATE"}} + ) + self.assertEqual(record.state, ActorState.UNSPECIFIED) + + def test_missing_status_defaults_to_unspecified(self): + record = _actor_from_json({"metadata": {"atespace": "a", "name": "n"}}) + self.assertEqual(record.state, ActorState.UNSPECIFIED) + self.assertIsNone(record.external_snapshot_uri) + + +class SubstrateControlTests(unittest.TestCase): + def test_get_actor_parses_json_and_uses_argv_not_shell(self): + ctl = FakeControl([ExecResult(0, actor_json("ACTOR_STATE_RUNNING"), "")]) + actor = run(ctl.get_actor("mainloop-workspaces", "ml-abc")) + self.assertEqual(actor.state, ActorState.RUNNING) + self.assertEqual( + ctl.calls[0], + [ + "get", + "actor", + "ml-abc", + "--atespace", + "mainloop-workspaces", + "-o", + "json", + ], + ) + + def test_get_actor_not_found_returns_none_not_an_exception(self): + ctl = FakeControl( + [ExecResult(1, "", 'Error: actors.ate.dev "ml-abc" not found')] + ) + self.assertIsNone(run(ctl.get_actor("mainloop-workspaces", "ml-abc"))) + + def test_get_actor_other_failure_raises_transport_error(self): + ctl = FakeControl([ExecResult(1, "", "connection refused")]) + with self.assertRaises(TransportError): + run(ctl.get_actor("mainloop-workspaces", "ml-abc")) + + def test_create_actor_uses_template_flag(self): + ctl = FakeControl([ExecResult(0, actor_json("ACTOR_STATE_RESUMING"), "")]) + run( + ctl.create_actor( + "mainloop-workspaces", "ml-abc", template="mainloop-workspace" + ) + ) + self.assertEqual( + ctl.calls[0], + [ + "create", + "actor", + "ml-abc", + "--atespace", + "mainloop-workspaces", + "--template", + "mainloop-workspace", + ], + ) + + def test_create_actor_falls_back_to_a_read_when_output_is_empty(self): + ctl = FakeControl( + [ + ExecResult(0, "", ""), + ExecResult(0, actor_json("ACTOR_STATE_RESUMING"), ""), + ] + ) + actor = run(ctl.create_actor("mainloop-workspaces", "ml-abc", template="t")) + self.assertEqual(actor.state, ActorState.RESUMING) + self.assertEqual(ctl.calls[1][:2], ["get", "actor"]) + + def test_revert_is_a_single_explicit_call_never_a_retry_loop(self): + ctl = FakeControl([ExecResult(0, actor_json("ACTOR_STATE_SUSPENDED"), "")]) + actor = run(ctl.revert_actor("mainloop-workspaces", "ml-abc")) + self.assertEqual(actor.state, ActorState.SUSPENDED) + self.assertEqual(len(ctl.calls), 1) + self.assertEqual(ctl.calls[0][:2], ["revert", "actor"]) + + def test_delete_actor_tolerates_already_gone(self): + ctl = FakeControl([ExecResult(1, "", "not found")]) + run(ctl.delete_actor("mainloop-workspaces", "ml-abc")) # does not raise + + def test_transport_error_on_transient_failure_is_not_retried(self): + ctl = FakeControl([TransportError("boom")]) + with self.assertRaises(TransportError): + run(ctl.suspend_actor("mainloop-workspaces", "ml-abc")) + self.assertEqual(len(ctl.calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_workspace_adapter.py b/backend/tests/runtime/test_workspace_adapter.py new file mode 100644 index 0000000..ffcb281 --- /dev/null +++ b/backend/tests/runtime/test_workspace_adapter.py @@ -0,0 +1,110 @@ +"""Pure policy and projection functions from workspace_adapter.py, tested without a database or +cluster -- the same split as test_session_status.py covers for native_sessions.py's status rules. +The DB-backed orchestration functions (ensure_workspace, resume_workspace, ...) are not yet +exercised against a live cluster; see docs/spikes and the task proof note.""" + +import unittest +from datetime import UTC, datetime + +from mainloop.runtime.workspace_adapter import ( + CRASHED_NOTE, + _binding_from_row, + actor_name, + is_crashed, + plan_ensure, +) + +from models import CapabilityState + +NOW = datetime(2026, 9, 22, tzinfo=UTC) + + +def row(**overrides): + base = { + "workspace_id": "sess-1", + "atespace": "mainloop-workspaces", + "actor_name": "ml-sess1", + "actor_template": "mainloop-workspace", + "preview_route": "mainloop-workspaces/ml-sess1", + "runtime_endpoint": "mainloop-workspaces/ml-sess1", + "observed_state": "ready", + "observed_at": NOW, + "updated_at": NOW, + "last_error": None, + } + base.update(overrides) + return base + + +class ActorNameTests(unittest.TestCase): + def test_stable_and_namespace_safe(self): + name = actor_name("0123456789abcdefextra") + self.assertEqual(name, "ml-0123456789abcdef") + self.assertEqual(name, actor_name("0123456789abcdefextra")) # deterministic + + +class PlanEnsureTests(unittest.TestCase): + def test_first_provision_creates(self): + self.assertEqual(plan_ensure(row_exists=False, actor_found=False), "create") + + def test_existing_actor_is_attached_whether_or_not_we_have_a_row(self): + self.assertEqual(plan_ensure(row_exists=False, actor_found=True), "attach") + self.assertEqual(plan_ensure(row_exists=True, actor_found=True), "attach") + + def test_a_row_with_no_matching_actor_is_surfaced_not_recreated(self): + self.assertEqual(plan_ensure(row_exists=True, actor_found=False), "surface_gap") + + +class BindingProjectionTests(unittest.TestCase): + def test_ready_row_has_no_capability_noise(self): + binding = _binding_from_row(row()) + self.assertEqual(binding.observed_state, "ready") + self.assertEqual(binding.capabilities, ()) + + def test_crashed_row_surfaces_a_proved_actor_crashed_capability(self): + binding = _binding_from_row( + row(observed_state="unavailable", last_error=CRASHED_NOTE) + ) + self.assertEqual(len(binding.capabilities), 1) + cap = binding.capabilities[0] + self.assertEqual(cap.capability, "actor_crashed") + self.assertEqual(cap.state, CapabilityState.PROVED) + self.assertEqual(cap.scope, "live") + self.assertIn("CRASHED", cap.detail) + self.assertIn("Substrate's actor record has no snapshot timestamp", cap.detail) + + def test_missing_actor_row_surfaces_a_distinct_capability_from_crashed(self): + binding = _binding_from_row( + row( + observed_state="unavailable", + last_error="actor not found where this row expected one; not recreated " + "automatically (inspect and reconcile explicitly, or delete this row to allow " + "a fresh actor)", + ) + ) + self.assertEqual(binding.capabilities[0].capability, "actor_health") + + def test_runtime_endpoint_falls_back_to_preview_route(self): + binding = _binding_from_row(row(runtime_endpoint=None)) + self.assertEqual(binding.runtime_endpoint, "mainloop-workspaces/ml-sess1") + + def test_runtime_endpoint_falls_back_to_unrouted_before_first_provision_observation( + self, + ): + binding = _binding_from_row(row(runtime_endpoint=None, preview_route=None)) + self.assertEqual(binding.runtime_endpoint, "unrouted") + + +class IsCrashedTests(unittest.TestCase): + def test_true_only_for_the_crashed_note(self): + self.assertTrue( + is_crashed(row(observed_state="unavailable", last_error=CRASHED_NOTE)) + ) + self.assertFalse( + is_crashed(row(observed_state="unavailable", last_error="actor missing")) + ) + self.assertFalse(is_crashed(row())) + + +if __name__ == "__main__": + unittest.main() From e4bf2dda5ec9daf37e22d4bf1b950fc62079480a Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:14:06 +0000 Subject: [PATCH 02/30] fix: create_actor was missing -o json; add Substrate ActorTemplate manifest Live testing against a real kind-substrate-preview cluster (pinned commit cdac9baef81dd319b46086d695266e6161e9e592) found create_actor parsing kubectl-ate's default table output as JSON and crashing. Fixed by passing -o json like every other verb; test fixture updated to match the real post-create state (SUSPENDED, not an assumed RESUMING). Adds spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl: a WorkerPool + protojson ActorTemplate for a per-session Herdr + agentctl actor (SNAPSHOT_CONTENT_SCOPE_FULL on pause/commit), reusing spikes/k8s-herdr-agents' image contents. Verified live: actor create/get/resume/suspend/revert/delete all work through this adapter's real code path; force-killing a worker pod drives the actor to CRASHED (mapped to WorkspaceBinding.observed_state=unavailable), and explicit revert recovers it to SUSPENDED from the golden snapshot, after which resume brings it back to RUNNING. Full results and commands are in the task's proof note (.tasknotes, not tracked here). --- backend/src/mainloop/runtime/substrate.py | 12 ++- backend/tests/runtime/test_substrate.py | 6 +- .../k8s/actor-template.yaml.tmpl | 76 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py index 076a054..5ba0dad 100644 --- a/backend/src/mainloop/runtime/substrate.py +++ b/backend/src/mainloop/runtime/substrate.py @@ -165,7 +165,17 @@ async def get_actor(self, atespace: str, name: str) -> ActorRecord | None: async def create_actor( self, atespace: str, name: str, *, template: str, tag: str | None = None ) -> ActorRecord: - args = ["create", "actor", name, "--atespace", atespace, "--template", template] + args = [ + "create", + "actor", + name, + "--atespace", + atespace, + "--template", + template, + "-o", + "json", + ] if tag: args += ["--tag", tag] res = await self._exec(args, timeout=90) diff --git a/backend/tests/runtime/test_substrate.py b/backend/tests/runtime/test_substrate.py index 66fb1a3..51e3997 100644 --- a/backend/tests/runtime/test_substrate.py +++ b/backend/tests/runtime/test_substrate.py @@ -98,7 +98,9 @@ def test_get_actor_other_failure_raises_transport_error(self): run(ctl.get_actor("mainloop-workspaces", "ml-abc")) def test_create_actor_uses_template_flag(self): - ctl = FakeControl([ExecResult(0, actor_json("ACTOR_STATE_RESUMING"), "")]) + # A freshly created actor starts SUSPENDED (never auto-started); measured against a + # live kind-substrate-preview cluster while building this adapter. + ctl = FakeControl([ExecResult(0, actor_json("ACTOR_STATE_SUSPENDED"), "")]) run( ctl.create_actor( "mainloop-workspaces", "ml-abc", template="mainloop-workspace" @@ -114,6 +116,8 @@ def test_create_actor_uses_template_flag(self): "mainloop-workspaces", "--template", "mainloop-workspace", + "-o", + "json", ], ) diff --git a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl new file mode 100644 index 0000000..f4542e2 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl @@ -0,0 +1,76 @@ +# WorkerPool (a real K8s CRD, `kubectl apply`) plus the Mainloop workspace ActorTemplate +# (protojson-shaped ateapipb.ActorTemplate, applied with `kubectl ate create actor-template -f -` +# after the __IMAGE__ placeholder is substituted with a digest-pinned reference -- Substrate +# requires containers.image to be pinned by digest). Mirrors the shape of the pinned checkout's +# demos/counter/{counter,counter-template}.yaml.tmpl. +# +# The atespace ("mainloop-workspaces" by default; see backend/src/mainloop/config.py +# substrate_atespace) is both this WorkerPool's k8s namespace and a control-plane atespace +# resource created separately with `kubectl ate create atespace`. +apiVersion: v1 +kind: Namespace +metadata: + name: ${ATESPACE} +--- +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: ${TEMPLATE_NAME} + namespace: ${ATESPACE} + labels: + workload: ${TEMPLATE_NAME} +spec: + replicas: 2 + workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + template: + resources: + limits: + cpu: '2' + memory: 2Gi + requests: + cpu: 250m + memory: 2Gi +--- +# ActorTemplate: one Herdr + agentctl container per actor, same image and entrypoint as +# spikes/k8s-herdr-agents (see that spike's Dockerfile/bin/entrypoint.sh), running under +# Substrate instead of a StatefulSet. snapshotsConfig captures full process memory on +# suspend/commit so a resumed actor's native agent session (Claude/Codex under Herdr) continues +# rather than cold-booting -- this is the behavior gate 5 (native-session continuity) measures. +metadata: + atespace: ${ATESPACE} + name: ${TEMPLATE_NAME} +workerSelector: + matchLabels: + workload: ${TEMPLATE_NAME} +containers: +- name: workspace + image: __IMAGE__ + command: + - /usr/local/bin/entrypoint.sh + env: + - { name: HOME, value: /workspace/.home } + - { name: WORKSPACE_PATH, value: /workspace/repo } + - { name: STANDIN_STATE_DIR, value: /workspace/.standin } + - { name: HERDR_SESSION, value: mainloop-substrate } + - { name: AGENT_CONFIG_DIR, value: /etc/agent-config } + - { name: CODEX_HOME, value: /workspace/.codex } + volumeMounts: + - { name: workspace, mountPath: /workspace } + # ateapipb.SecurityContext only models Linux capability adjustments (no + # allowPrivilegeEscalation/readOnlyRootFilesystem -- the gVisor sandbox is the isolation + # boundary here, not those pod-level knobs), so there is nothing to set beyond the container's + # own non-root USER (see the image's Dockerfile). + resources: + limits: + - { name: cpu, quantity: "2" } + - { name: memory, quantity: 2Gi } +snapshotsConfig: + onPause: SNAPSHOT_CONTENT_SCOPE_FULL + onCommit: SNAPSHOT_CONTENT_SCOPE_FULL + storageLocation: gs://${BUCKET_NAME}/mainloop-workspaces/ +sandboxConfig: + sandboxClass: SANDBOX_CLASS_GVISOR + configName: gvisor-default +volumes: +- name: workspace + durableDir: {} From 032277fbf4e7349cfe43fdb3911ccc5dad78b509 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:15:54 +0000 Subject: [PATCH 03/30] docs: record the Substrate workspace-adapter spike Durable record of the cluster-lane findings (docs/spikes/, mirroring k8s-herdr-agents.md's format): agentgateway dataplane fix, ActorTemplate corrections (digest-pinned images, SecurityContext field set, ko resolve before apply), live actor lifecycle and CRASHED/revert behavior, and what gates 3-5 still need. The full proof note with exact commands and CapabilityResult table lives in the task's .tasknotes (not tracked). --- docs/spikes/substrate-workspace-adapter.md | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/spikes/substrate-workspace-adapter.md diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md new file mode 100644 index 0000000..9d00f1d --- /dev/null +++ b/docs/spikes/substrate-workspace-adapter.md @@ -0,0 +1,91 @@ +# Spike: Substrate as Mainloop's Kubernetes workspace runtime + +Status: local spike, not a product feature. Adapter code lives in +`backend/src/mainloop/runtime/substrate.py` and `workspace_adapter.py`; the actor manifest +lives in `spikes/substrate-workspace-adapter/`. See `docs/spikes/k8s-herdr-agents.md` for the +native-session/Herdr spike this one builds on and does not replace. + +## What it shows + +[Substrate](https://github.com/agent-substrate/substrate) can provide the per-session isolated +compute Mainloop's roadmap calls for ("Workspace platform"), while Mainloop stays the durable +owner of the session<->actor mapping, delivery, and audit state. A Mainloop-authored +`ActorTemplate` (Herdr + `agentctl`, the same image contents as the Herdr spike) runs as a +Substrate actor instead of a fixed StatefulSet pod, and `backend/src/mainloop/runtime/substrate.py` +drives its lifecycle through the real `kubectl ate` control-plane CLI. + +## Real versus stand-in + +| Layer | Status | +| --- | --- | +| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | +| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | +| Herdr + `agentctl` inside the actor image | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | +| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | +| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | + +## Run it + +There is no single demo script yet (unlike `spikes/k8s-herdr-agents/demo.sh`); the commands used +are recorded in the task's proof note. In outline: + +```bash +KIND_CLUSTER_NAME=substrate-preview KUBECONFIG=/tmp/substrate-preview-kubeconfig \ + /tmp/substrate-preview-src/hack/create-kind-cluster.sh +KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ + KUBECONFIG=/tmp/substrate-preview-kubeconfig \ + /tmp/substrate-preview-src/hack/install-ate-kind.sh --deploy-ate-system +KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ + KUBECONFIG=/tmp/substrate-preview-kubeconfig \ + /tmp/substrate-preview-src/hack/install-ate-kind.sh --deploy-atenet --atenet-dataplane=agentgateway +# build kubectl-ate, build+push the actor image, apply spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl +# (WorkerPool via `ko resolve | kubectl apply`, ActorTemplate via `kubectl ate create actor-template -f -`) +``` + +## Observed behaviour + +- The default Envoy-based `atenet-router` crash-looped on this cluster too (matching the prior + `docs/spikes/../substrate-kind-preview-proof` finding); the `agentgateway` dataplane fixed it. +- A freshly created actor starts `SUSPENDED`, not running -- `create_actor` never implicitly + starts an actor. An explicit `resume_actor` is required, and it returned `RUNNING` directly + (no further polling needed) in every observed case. +- Force-deleting an actor's worker pod (`kubectl delete pod ... --grace-period=0 --force`, the + same technique as the prior proof's "abrupt worker loss" trial) drove the actor to `CRASHED` + within a few seconds, correctly observed as `WorkspaceBinding.observed_state="unavailable"` + through `substrate.py`'s real `ActorState` -> `observed_state` mapping. +- `revert_actor` on a `CRASHED` actor returned it to `SUSPENDED` from its last completed (here: + golden) snapshot; a subsequent `resume_actor` brought it back to `RUNNING`. Nothing in the + adapter reverts automatically -- `workspace_adapter.revert_workspace` requires + `acknowledge_loss=True`. +- Actor logs for a resume showed gVisor's `runsc ... restore -image-path ... restore-state` + path (`"Actor restoring"` / `"Actor restored"`), not a fresh container boot -- consistent with + the golden snapshot's process state (including the running `herdr` server) being restored + rather than the entrypoint re-running. This is supporting evidence for gate 5 (native-session + continuity) but not a full proof: no real agent session was resumed and asked to recall a + pre-suspend nonce in this run. +- Building this adapter against the real CLI found one bug fixed in the same commit: + `create_actor` was missing `-o json` and crashed parsing `kubectl ate`'s default table output. + +## Limits / not attempted in this run + +- **Preview/HMR gate**: no Vite actor, no authenticated fixed-header proxy, no `agent-browser` + trial. The prior `substrate-kind-preview-proof` note already showed this works and does not + always work reliably (10-30s stalls with an HMR socket open); this run did not repeat or + extend that measurement against Mainloop's own actor template. +- **Dev-service gate**: no Postgres actor, no egress policy, no reconnect-after-wake trial against + our template. +- **Native-session gate, live**: no real Claude/Codex session was started inside a Substrate + actor; the credential wiring authorized by `.tasknotes/plan.md` was not used. Only the + fixture-level contract logic (`test_workspace_adapter.py`) and the generic + restore-vs-reboot log evidence above are available. +- **`workspace_bindings` orchestration functions** (`ensure_workspace`, `resume_workspace`, ...) + were not exercised against a live Postgres + running backend; only their extracted pure logic + (`plan_ensure`, `_binding_from_row`, `is_crashed`) is unit tested, and the transport layer + they call (`SubstrateControl`) is proved live as described above. + +## Cleanup + +All test actors deleted, then the `substrate-preview` cluster and its `kind-registry` deleted +(`hack/delete-kind-cluster.sh`). Final `kind get clusters` / `docker ps` showed only +`mainloop-test` / `mainloop-test-control-plane`. Root disk free was unchanged (~26G) before and +after. No Mainloop repository files outside this branch's own commits were changed. From 784a5b4c6440736eb4187b3316a20f2073055981 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:16:11 +0000 Subject: [PATCH 04/30] style: format substrate-workspace-adapter spike doc --- docs/spikes/substrate-workspace-adapter.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 9d00f1d..72f377e 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -16,13 +16,13 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. ## Real versus stand-in -| Layer | Status | -| --- | --- | -| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | -| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | -| Herdr + `agentctl` inside the actor image | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | -| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | -| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | +| Layer | Status | +| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | +| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | +| Herdr + `agentctl` inside the actor image | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | +| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | +| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | ## Run it From cfed93205b7e2d9740e8a75c64fe7ef3b1e560d2 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:49:17 +0000 Subject: [PATCH 05/30] feat: prove the preview/HMR gate live; find the credential-injection gap Gate 3 (preview/HMR), proved live: a second ActorTemplate (spikes/substrate-workspace-adapter/image/ -- real Herdr, real Vite 7.3.1, a generic exec shim standing in for a credentialed agent's Bash tool) served through a real NGINX ate-target-actor header-proxy to a real browser (agent-browser) with a real HMR WebSocket held open the whole time. A real shell write produced a genuine in-place hot update (window marker survived; console logged "hot updated"), including across an explicit suspend/resume cycle. Getting there found and fixed three independent, real bugs: - NGINX proxy_pass defaults to HTTP/1.0, silently breaking the WebSocket upgrade (the pinned checkout's own Jupyter demo nginx.conf has the same gap); fixed with proxy_http_version 1.1. - A hardcoded hmr.clientPort pointed the browser's WebSocket at the actor's internal port instead of the proxy's; removed the override. - A shell-redirect truncate-in-place write was never observed by Vite's watcher on the gVisor-sandboxed filesystem, with or without polling; an atomic rename-replace write (sed -i, how most real editors write) was picked up every time. The initial "inotify doesn't work under gVisor" hypothesis was tested and found wrong, and corrected in the docs rather than left standing. Gate 5 (native-session, live): investigated Substrate's credential primitives before attempting a live Claude/Codex proof and found a real, structural gap -- ActorTemplate env values are literal-only on an immutable resource, and SystemInfo volumes only project actor identity and one CA trust bundle, not arbitrary secrets. There is no safe way yet to deliver CLAUDE_CODE_OAUTH_TOKEN or ~/.codex/auth.json into an actor. Documented rather than worked around unsafely; the live proof stays unattempted pending new plumbing. Full findings in docs/spikes/substrate-workspace-adapter.md and the task's proof note (.tasknotes, not tracked here). --- docs/spikes/substrate-workspace-adapter.md | 99 ++++++++++++++++--- .../image/.gitignore | 1 + .../image/Dockerfile | 21 ++++ .../image/entrypoint.sh | 32 ++++++ .../image/exec-shim.js | 67 +++++++++++++ .../image/vite-fixture/index.html | 11 +++ .../image/vite-fixture/main.js | 4 + .../image/vite-fixture/package.json | 11 +++ .../image/vite-fixture/vite.config.js | 24 +++++ .../k8s/preview-gate-template.yaml.tmpl | 52 ++++++++++ .../k8s/preview-proxy.yaml.tmpl | 76 ++++++++++++++ 11 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 spikes/substrate-workspace-adapter/image/.gitignore create mode 100644 spikes/substrate-workspace-adapter/image/Dockerfile create mode 100644 spikes/substrate-workspace-adapter/image/entrypoint.sh create mode 100644 spikes/substrate-workspace-adapter/image/exec-shim.js create mode 100644 spikes/substrate-workspace-adapter/image/vite-fixture/index.html create mode 100644 spikes/substrate-workspace-adapter/image/vite-fixture/main.js create mode 100644 spikes/substrate-workspace-adapter/image/vite-fixture/package.json create mode 100644 spikes/substrate-workspace-adapter/image/vite-fixture/vite.config.js create mode 100644 spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl create mode 100644 spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 72f377e..4859ad3 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -16,13 +16,34 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. ## Real versus stand-in -| Layer | Status | -| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | -| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | -| Herdr + `agentctl` inside the actor image | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | -| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | -| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | +| Layer | Status | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | +| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | +| `preview-gate` WorkerPool + ActorTemplate: real Herdr server, real Vite dev server, real NGINX header-proxy, real browser (`agent-browser`), real WebSocket HMR | Real | +| Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | +| The preview-gate's file edits (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | +| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | +| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | + +## Why not a real agent for the preview-gate edit (credential-injection gap) + +Substrate's pinned commit has no generic secret-injection mechanism equivalent to a Kubernetes +Secret volume/env mount. `ActorTemplate` container env values are literal only (no +`envFrom`/`valueFrom`, and the template is immutable, so baking a token in would also mean +storing it permanently in a control-plane object -- unacceptable under this task's "credentials +by path, never by value" rule). The only credential-shaped primitives are `SystemInfo` volumes +(`actorMetadata`: the actor's own name/atespace/uid; `trustBundle`: a named, allowlisted CA +bundle -- today only `egress-mitm.ate.dev`) and `pkg/proto/credproviderpb` (`CredentialProvider`, +a plugin the _egress gateway_ calls to inject a credential into an actor's _outbound_ request, +keyed by the actor's SPIFFE identity -- not a way to hand the actor's own process a local file or +env var it can read directly, which is what the Claude Code / Codex CLIs need). A real +native-agent proof (gate 5) therefore needs either an unsafe workaround or new plumbing (e.g. an +authenticated credential-relay using the `MintActorJWT`/`MintActorCertificate` RPCs already in +`ateapipb.Control`), out of scope for this spike. The preview-gate measurement below instead uses +a generic exec shim (`spikes/substrate-workspace-adapter/image/exec-shim.js`) that pastes text +into a real Herdr shell pane via `herdr pane run` -- a real shell executing a real command, just +not a credentialed agent's own tool call. ## Run it @@ -38,7 +59,11 @@ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ KUBECONFIG=/tmp/substrate-preview-kubeconfig \ /tmp/substrate-preview-src/hack/install-ate-kind.sh --deploy-atenet --atenet-dataplane=agentgateway -# build kubectl-ate, build+push the actor image, apply spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl +# build kubectl-ate, build+push an actor image, apply either: +# k8s/actor-template.yaml.tmpl -- the mainloop-workspace template (Herdr + agentctl) +# k8s/preview-gate-template.yaml.tmpl -- the preview-gate template (+ image/, a real Vite dev +# server and exec shim, for the preview/HMR gate) +# k8s/preview-proxy.yaml.tmpl -- the NGINX ate-target-actor header-proxy in front of it # (WorkerPool via `ko resolve | kubectl apply`, ActorTemplate via `kubectl ate create actor-template -f -`) ``` @@ -66,12 +91,51 @@ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ - Building this adapter against the real CLI found one bug fixed in the same commit: `create_actor` was missing `-o json` and crashed parsing `kubectl ate`'s default table output. +### Preview/HMR gate (gate 3): proved live, three real bugs isolated and fixed + +Through the actual intended route (real browser -> NGINX header-proxy -> `atenet-router` +(agentgateway) -> a real Vite dev server in a real actor), with a real WebSocket HMR socket open +the whole time: a real shell write (via the exec-shim's `herdr pane run`, not a purpose-built +`/__edit` endpoint) to `main.js` produced a genuine in-place HMR update -- confirmed by a +`window.__hmrMarker` value set before the edit surviving after it (a full reload would have reset +it) and by the console logging `[vite] hot updated: /main.js`. This held across an explicit +suspend/resume cycle too: content and the marker's own page state survived, and Vite's client +logged `server connection lost. Polling for restart...` during the suspend and reconnected +cleanly on resume, with a further post-resume edit still hot-updating correctly. + +Getting there required isolating and fixing three independent, real bugs -- exactly what the +prior Kind preview proof asked for ("isolate ... rather than re-measuring as one blob"): + +1. **NGINX's `proxy_pass` defaults to HTTP/1.0 upstream**, which silently breaks `Connection: +Upgrade`. Symptom: `503 upstream call failed: SendRequest: connection closed before message +completed` from `atenet-router`, which looked like a router bug until isolated by testing the + same header-routed request directly against the router (works) versus through NGINX (fails). + The Jupyter demo's own `nginx.conf` (the pattern this proxy was copied from) has the same gap. + Fix: add `proxy_http_version 1.1;`. +2. **A hardcoded `hmr.clientPort` pointed the browser's WebSocket at the actor's internal port + (80), not the port the browser actually reached the proxy on.** Symptom: `[vite] failed to +connect to websocket (Error: WebSocket closed without opened.)` in the real browser, while a + raw `curl` WebSocket upgrade against the same actor succeeded (isolating it to the _browser's_ + target URL, not the routing path). Fix: do not set `hmr.clientPort`; let Vite infer it from + `window.location`, which is correct for same-origin proxying. +3. **A plain shell-redirect truncate-in-place write (`cmd > file`) was never observed by Vite's + file watcher on this gVisor-sandboxed filesystem, with or without `usePolling`; an atomic + rename-replace write (`sed -i`, or any editor/tool that writes-then-renames, which is how most + real editors and Node's own atomic-write helpers behave) was picked up every time.** This was + isolated by holding the watcher config fixed and varying only the write method. The initial + hypothesis (inotify does not work under gVisor) was wrong and is corrected here rather than + left standing: the default inotify-based watch picked up `sed -i` edits fine, with or without + polling enabled. `usePolling` is kept in the fixture's `vite.config.js` as defense in depth, + but it was not the actual fix. + +None of these three are Substrate bugs in the sense of "broken by Substrate" -- (1) is a gap in +the demo NGINX pattern this repo's own docs show, (2) is a Vite config default that does not +suit a proxied deployment, and (3) is a filesystem-semantics fact worth knowing about (most real +editors already write this way, so it may not affect a real native-agent's edits, which is +exactly why gate 5's live proof matters and was not reached in this run). + ## Limits / not attempted in this run -- **Preview/HMR gate**: no Vite actor, no authenticated fixed-header proxy, no `agent-browser` - trial. The prior `substrate-kind-preview-proof` note already showed this works and does not - always work reliably (10-30s stalls with an HMR socket open); this run did not repeat or - extend that measurement against Mainloop's own actor template. - **Dev-service gate**: no Postgres actor, no egress policy, no reconnect-after-wake trial against our template. - **Native-session gate, live**: no real Claude/Codex session was started inside a Substrate @@ -85,7 +149,10 @@ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ ## Cleanup -All test actors deleted, then the `substrate-preview` cluster and its `kind-registry` deleted -(`hack/delete-kind-cluster.sh`). Final `kind get clusters` / `docker ps` showed only -`mainloop-test` / `mainloop-test-control-plane`. Root disk free was unchanged (~26G) before and -after. No Mainloop repository files outside this branch's own commits were changed. +Each cluster lane in this spike (the initial adapter/CRASHED trial, and the later preview-gate +trial) deleted its own test actors, then the `substrate-preview` cluster and its `kind-registry` +(`hack/delete-kind-cluster.sh`), and pruned the locally built, unpushed-elsewhere Docker images. +Final `kind get clusters` / `docker ps` showed only `mainloop-test` / +`mainloop-test-control-plane` after every lane. Root disk stayed in the 19-27G-free range +throughout (above the plan's 8G in-flight-trial abort threshold at all times); available RAM +stayed above 8G. No Mainloop repository files outside this branch's own commits were changed. diff --git a/spikes/substrate-workspace-adapter/image/.gitignore b/spikes/substrate-workspace-adapter/image/.gitignore new file mode 100644 index 0000000..a78a325 --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/.gitignore @@ -0,0 +1 @@ +herdr diff --git a/spikes/substrate-workspace-adapter/image/Dockerfile b/spikes/substrate-workspace-adapter/image/Dockerfile new file mode 100644 index 0000000..1a81238 --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/Dockerfile @@ -0,0 +1,21 @@ +# Preview-gate spike image: real Herdr server + a real Vite dev server, driven by a generic +# exec shim (see exec-shim.js) standing in for a credentialed native agent's own Bash tool. +# herdr is copied from the host into the build context by the build script (never committed). +# No credentials are baked in. +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates git curl procps \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -m -u 10001 agent +COPY herdr /usr/local/bin/herdr +COPY vite-fixture /work/vite-fixture +COPY exec-shim.js /usr/local/bin/exec-shim.js +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh \ + && cd /work/vite-fixture && npm install --no-audit --no-fund \ + && chown -R agent:agent /work +ENV VITE_DIR=/work/vite-fixture +ENV EXEC_SHIM=/usr/local/bin/exec-shim.js +ENV HOME=/home/agent +ENV HERDR_SESSION=mainloop-preview +USER 10001:10001 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/spikes/substrate-workspace-adapter/image/entrypoint.sh b/spikes/substrate-workspace-adapter/image/entrypoint.sh new file mode 100644 index 0000000..cf6385e --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts +# Preview-gate actor entrypoint. Starts a real Herdr server, a real Vite dev server in one pane, +# and a minimal generic exec shim (Node http server -> `herdr pane run`) in a second pane. The +# shim is a stand-in for a credentialed native agent's own Bash tool -- Substrate's pinned commit +# has no generic secret-injection mechanism (only SystemInfo actor-identity/trust-bundle volumes +# and an egress CredentialProvider), so a real Claude/Codex session cannot be started here yet. +# See docs/spikes/substrate-workspace-adapter.md. +set -eu +STATE_DIR=/work/.mainloop +mkdir -p "${HOME}" "${STATE_DIR}" + +echo "herdr $(herdr --version) server starting (HOME=${HOME} session=${HERDR_SESSION})" +herdr --session "${HERDR_SESSION}" server & +HERDR_PID=$! + +for _ in $(seq 1 60); do + herdr --session "${HERDR_SESSION}" status server >/dev/null 2>&1 && break + sleep 0.5 +done + +dev_ws=$(herdr --session "${HERDR_SESSION}" workspace create --label dev --cwd "${VITE_DIR}") +dev_pane=$(echo "${dev_ws}" | jq -r '.result.root_pane.pane_id') +shell_ws=$(herdr --session "${HERDR_SESSION}" workspace create --label shell --cwd "${VITE_DIR}") +shell_pane=$(echo "${shell_ws}" | jq -r '.result.root_pane.pane_id') +echo "${shell_pane}" >"${STATE_DIR}/shell-pane-id" + +herdr --session "${HERDR_SESSION}" pane run "${dev_pane}" "npm run dev" + +EXEC_SHIM_PANE_ID="${shell_pane}" HERDR_SESSION="${HERDR_SESSION}" node "${EXEC_SHIM}" & + +wait "${HERDR_PID}" diff --git a/spikes/substrate-workspace-adapter/image/exec-shim.js b/spikes/substrate-workspace-adapter/image/exec-shim.js new file mode 100644 index 0000000..01250e7 --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/exec-shim.js @@ -0,0 +1,67 @@ +// Minimal generic command executor for the preview-gate spike: POST /run { command } pastes +// `command` as literal text into a real Herdr shell pane via `herdr pane run` (fire-and-forget; +// the pane is a real bash shell, so this is a real shell write, not a purpose-built edit +// endpoint). GET /read returns the pane's current terminal buffer (`herdr pane read`), since +// `pane run` itself never captures output. Stands in for a credentialed native agent's own Bash +// tool -- see entrypoint.sh and docs/spikes/substrate-workspace-adapter.md for why a real agent +// could not be used here. +// Listens on port 8090, separate from the Vite dev server's port 80. Reached only through +// atenet-router's arbitrary-port CONNECT tunnel with the ate-target-actor header (see +// docs/api-guide.md "Workload Connectivity"), from test orchestration on the host -- never +// through the previewed route a browser uses (port 80 via the NGINX header-proxy). +'use strict'; +const http = require('node:http'); +const { execFile } = require('node:child_process'); + +const PANE_ID = process.env.EXEC_SHIM_PANE_ID; +const SESSION = process.env.HERDR_SESSION; +if (!PANE_ID || !SESSION) { + console.error('exec-shim: EXEC_SHIM_PANE_ID and HERDR_SESSION are required'); + process.exit(1); +} + +function herdr(args, res) { + execFile('herdr', ['--session', SESSION, ...args], (err, stdout, stderr) => { + if (err) { + res.writeHead(502).end(String(err)); + return; + } + res + .writeHead(200, { 'content-type': 'application/json' }) + .end(JSON.stringify({ ok: true, stdout, stderr })); + }); +} + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/read') { + herdr(['pane', 'read', PANE_ID], res); + return; + } + if (req.method !== 'POST' || req.url !== '/run') { + res.writeHead(404).end(); + return; + } + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 65536) req.destroy(); + }); + req.on('end', () => { + let command; + try { + command = JSON.parse(body).command; + } catch { + res.writeHead(400).end('invalid json'); + return; + } + if (typeof command !== 'string' || !command) { + res.writeHead(400).end('missing command'); + return; + } + herdr(['pane', 'run', PANE_ID, command], res); + }); +}); + +server.listen(8090, '0.0.0.0', () => { + console.log('exec-shim listening on :8090, pane', PANE_ID); +}); diff --git a/spikes/substrate-workspace-adapter/image/vite-fixture/index.html b/spikes/substrate-workspace-adapter/image/vite-fixture/index.html new file mode 100644 index 0000000..b1bb92e --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/vite-fixture/index.html @@ -0,0 +1,11 @@ + + + + + Mainloop preview fixture + + +

loading...

+ + + diff --git a/spikes/substrate-workspace-adapter/image/vite-fixture/main.js b/spikes/substrate-workspace-adapter/image/vite-fixture/main.js new file mode 100644 index 0000000..7183e38 --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/vite-fixture/main.js @@ -0,0 +1,4 @@ +document.getElementById('label').textContent = 'Preview one'; +if (import.meta.hot) { + import.meta.hot.accept(); +} diff --git a/spikes/substrate-workspace-adapter/image/vite-fixture/package.json b/spikes/substrate-workspace-adapter/image/vite-fixture/package.json new file mode 100644 index 0000000..4dc9408 --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/vite-fixture/package.json @@ -0,0 +1,11 @@ +{ + "name": "mainloop-preview-fixture", + "private": true, + "type": "module", + "scripts": { + "dev": "vite" + }, + "devDependencies": { + "vite": "7.3.1" + } +} diff --git a/spikes/substrate-workspace-adapter/image/vite-fixture/vite.config.js b/spikes/substrate-workspace-adapter/image/vite-fixture/vite.config.js new file mode 100644 index 0000000..5ae1260 --- /dev/null +++ b/spikes/substrate-workspace-adapter/image/vite-fixture/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + host: '0.0.0.0', + port: 80, + strictPort: true, + allowedHosts: true, + // No hmr.clientPort override: the browser reaches this actor through a proxy whose port + // varies by deployment (port-forward, ingress, ...). Vite infers the HMR client's port from + // window.location by default, which is correct for same-origin proxying (our NGINX + // header-proxy) and was the actual bug the first time this was set to the actor's internal + // port 80 -- the browser tried to open a WebSocket to its own port 80, not the proxy's port. + // Measured live: the actual variable was NOT inotify-vs-polling (the default inotify watch + // picks up an atomic rename-replace write, e.g. `sed -i`, correctly, with polling enabled or + // not). It was the write method -- a plain shell-redirect truncate-in-place write (`cmd > + // file`) was never observed by Vite's watcher on this gVisor-sandboxed filesystem, with or + // without polling, while an atomic rename-replace write (`sed -i`, or any editor/tool that + // writes-then-renames, which is how most real editors and Node's own atomic-write helpers + // behave) was picked up every time and produced a true HMR update, not a reload. usePolling + // is left enabled here only as defense in depth; it was not the fix. + watch: { usePolling: true, interval: 300 } + } +}); diff --git a/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl new file mode 100644 index 0000000..0da7943 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl @@ -0,0 +1,52 @@ +# WorkerPool + ActorTemplate for the preview/HMR gate measurement (gate 3 in .tasknotes/plan.md): +# a real Vite dev server plus a generic exec shim standing in for a credentialed native agent's +# Bash tool (see spikes/substrate-workspace-adapter/image/). No durable volume: this template +# relies on SNAPSHOT_CONTENT_SCOPE_FULL to preserve /work (including node_modules and any edits) +# across suspend/resume, which is what the gate is actually measuring. +apiVersion: v1 +kind: Namespace +metadata: + name: ${ATESPACE} +--- +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: preview-gate + namespace: ${ATESPACE} + labels: + workload: preview-gate +spec: + replicas: 1 + workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + template: + resources: + limits: + cpu: '1' + memory: 1Gi + requests: + cpu: 250m + memory: 1Gi +--- +metadata: + atespace: ${ATESPACE} + name: preview-gate +workerSelector: + matchLabels: + workload: preview-gate +containers: +- name: preview + image: __IMAGE__ + env: + - { name: HOME, value: /home/agent } + - { name: HERDR_SESSION, value: mainloop-preview } + resources: + limits: + - { name: cpu, quantity: "1" } + - { name: memory, quantity: 1Gi } +snapshotsConfig: + onPause: SNAPSHOT_CONTENT_SCOPE_FULL + onCommit: SNAPSHOT_CONTENT_SCOPE_FULL + storageLocation: gs://${BUCKET_NAME}/preview-gate/ +sandboxConfig: + sandboxClass: SANDBOX_CLASS_GVISOR + configName: gvisor-default diff --git a/spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl new file mode 100644 index 0000000..24cc6d0 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl @@ -0,0 +1,76 @@ +# Fixed authenticated preview route: an NGINX Deployment/Service that injects the +# ate-target-actor header atenet-router needs (browsers cannot set custom headers), following +# the same pattern as the pinned checkout's demos/jupyter/jupyter.yaml.tmpl proxy. This is the +# route intended for Mainloop's own preview UI (not a bespoke fixture endpoint): the browser +# never talks to atenet-router directly. +# +# proxy_http_version 1.1 is required for the WebSocket upgrade to survive proxy_pass -- NGINX +# defaults to HTTP/1.0 upstream, which silently breaks `Connection: Upgrade`. The Jupyter demo's +# own nginx.conf (copied as the starting point here) does not set it either; measured live +# against this cluster as a 503 "connection closed before message completed" from atenet-router, +# not from NGINX itself, which made it look like a router bug until isolated. See +# docs/spikes/substrate-workspace-adapter.md. +apiVersion: v1 +kind: ConfigMap +metadata: + name: preview-proxy-config + namespace: ${ATESPACE} +data: + nginx.conf: | + events {} + http { + server { + listen 80; + location / { + proxy_pass http://atenet-router.ate-system.svc.cluster.local; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header ate-target-actor ${ATESPACE}/${ACTOR_NAME}; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: preview-proxy + namespace: ${ATESPACE} +spec: + replicas: 1 + selector: + matchLabels: + app: preview-proxy + template: + metadata: + labels: + app: preview-proxy + spec: + containers: + - name: nginx + image: nginx:alpine + ports: + - containerPort: 80 + volumeMounts: + - name: config + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + volumes: + - name: config + configMap: + name: preview-proxy-config +--- +apiVersion: v1 +kind: Service +metadata: + name: preview-proxy + namespace: ${ATESPACE} +spec: + type: ClusterIP + selector: + app: preview-proxy + ports: + - port: 80 From b2a4d2390d20beef0dcac489b31e21b7165dc7dd Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:02:00 +0000 Subject: [PATCH 06/30] feat: prove the dev-service gate live, including real policy enforcement Gate 4 (dev-service/Postgres connectivity), proved live: a third ActorTemplate (spikes/substrate-workspace-adapter/dev-service-image/ -- real psql, the same exec-shim pattern) reached a real postgres:16-alpine StatefulSet (matching k8s/apps/mainloop/overlays/test/postgres-statefulset.yaml's image/auth shape) under a narrow EgressPolicy (a single CIDR rule for the Postgres Service's /32 ClusterIP). kubectl-ate has no CLI verb for EgressPolicy at all -- confirmed by the pinned checkout's own demos/egress/README.md ("no CLI verb yet"). Added spikes/substrate-workspace-adapter/egress-tool/main.go, a small standalone Go program mirroring that checkout's own e2e test helper, calling CreateActorEgressPolicy directly over gRPC. Result: DNS + a real SELECT query over the actual Postgres wire protocol + reconnection after an explicit suspend/resume cycle all worked cleanly. Authorization is real, not passive: a request to a different Service's ClusterIP (outside the /32 rule) was cleanly rejected with HTTP 403 "actor egress policy denied destination" from the egress gateway itself. This improves on the prior Kind preview proof's external-backend trial (403 -> 503, unproven reconnect); the isolated difference is a CIDR rule (works for any TCP protocol) versus a hostname rule (HTTP/TLS-SNI-specific, never the right tool for a non-HTTP protocol like Postgres). Full findings in docs/spikes/substrate-workspace-adapter.md and the task's proof note (.tasknotes, not tracked here). --- docs/spikes/substrate-workspace-adapter.md | 70 +++++++++++----- .../dev-service-image/.gitignore | 1 + .../dev-service-image/Dockerfile | 19 +++++ .../dev-service-image/entrypoint.sh | 23 +++++ .../dev-service-image/exec-shim.js | 67 +++++++++++++++ .../egress-tool/main.go | 84 +++++++++++++++++++ .../k8s/dev-service-gate-template.yaml.tmpl | 47 +++++++++++ .../k8s/postgres-target.yaml | 68 +++++++++++++++ 8 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 spikes/substrate-workspace-adapter/dev-service-image/.gitignore create mode 100644 spikes/substrate-workspace-adapter/dev-service-image/Dockerfile create mode 100644 spikes/substrate-workspace-adapter/dev-service-image/entrypoint.sh create mode 100644 spikes/substrate-workspace-adapter/dev-service-image/exec-shim.js create mode 100644 spikes/substrate-workspace-adapter/egress-tool/main.go create mode 100644 spikes/substrate-workspace-adapter/k8s/dev-service-gate-template.yaml.tmpl create mode 100644 spikes/substrate-workspace-adapter/k8s/postgres-target.yaml diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 4859ad3..4b413be 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -16,15 +16,16 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. ## Real versus stand-in -| Layer | Status | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | -| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | -| `preview-gate` WorkerPool + ActorTemplate: real Herdr server, real Vite dev server, real NGINX header-proxy, real browser (`agent-browser`), real WebSocket HMR | Real | -| Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | -| The preview-gate's file edits (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | -| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | -| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | +| Layer | Status | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | +| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | +| `preview-gate` WorkerPool + ActorTemplate: real Herdr server, real Vite dev server, real NGINX header-proxy, real browser (`agent-browser`), real WebSocket HMR | Real | +| `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | +| Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | +| File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | +| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | +| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | ## Why not a real agent for the preview-gate edit (credential-injection gap) @@ -59,11 +60,13 @@ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ KUBECONFIG=/tmp/substrate-preview-kubeconfig \ /tmp/substrate-preview-src/hack/install-ate-kind.sh --deploy-atenet --atenet-dataplane=agentgateway -# build kubectl-ate, build+push an actor image, apply either: -# k8s/actor-template.yaml.tmpl -- the mainloop-workspace template (Herdr + agentctl) -# k8s/preview-gate-template.yaml.tmpl -- the preview-gate template (+ image/, a real Vite dev -# server and exec shim, for the preview/HMR gate) -# k8s/preview-proxy.yaml.tmpl -- the NGINX ate-target-actor header-proxy in front of it +# build kubectl-ate, build+push an actor image, apply one of: +# k8s/actor-template.yaml.tmpl -- mainloop-workspace: Herdr + agentctl +# k8s/preview-gate-template.yaml.tmpl -- preview-gate: real Vite dev server + exec shim +# + k8s/preview-proxy.yaml.tmpl -- the NGINX ate-target-actor header-proxy in front +# k8s/dev-service-gate-template.yaml.tmpl -- dev-service-gate: real psql + exec shim +# + k8s/postgres-target.yaml -- the external postgres:16-alpine StatefulSet +# + egress-tool/main.go -- creates the actor's EgressPolicy (no CLI verb) # (WorkerPool via `ko resolve | kubectl apply`, ActorTemplate via `kubectl ate create actor-template -f -`) ``` @@ -134,10 +137,37 @@ suit a proxied deployment, and (3) is a filesystem-semantics fact worth knowing editors already write this way, so it may not affect a real native-agent's edits, which is exactly why gate 5's live proof matters and was not reached in this run). +### Dev-service gate (gate 4): proved live, including real policy enforcement + +A real `postgres:16-alpine` StatefulSet (same image/auth shape as +`k8s/apps/mainloop/overlays/test/postgres-statefulset.yaml`) in its own namespace, reached from a +`dev-service-gate` actor (real `psql`, driven through the same generic exec shim) under an +`EgressPolicy` scoped to exactly the Postgres Service's `/32` ClusterIP. + +`kubectl-ate` has **no CLI verb for egress policies** -- confirmed by the pinned checkout's own +`demos/egress/README.md`: `"test-egress.sh creates and resumes the Actor but cannot create its +EgressPolicy (no CLI verb yet)"`. Its own e2e suite calls the gRPC API directly +(`internal/e2e/egresspolicy.go`). This spike does the same: +`spikes/substrate-workspace-adapter/egress-tool/main.go`, a small standalone `main` mirroring +that helper without the `testing.T` dependency (build instructions are in the file's header +comment; it must be built inside a Substrate checkout since it imports `internal/` packages). + +**Result**: DNS resolution (bypasses the policy enforcement point entirely -- port 53 is always +allowed), a real `SELECT` query over the actual Postgres wire protocol, and reconnection after an +explicit suspend/resume cycle (a second query, `SELECT 43`, succeeded cleanly post-resume, no +policy re-creation needed -- the policy is attached to the actor, not the connection) all worked +on the first try. Authorization is real, not merely passive: a request to a _different_ Service's +ClusterIP (not covered by the `/32` rule) was cleanly rejected -- +`HTTP 403 actor egress policy denied destination` from the egress gateway itself, not a silent +timeout or a security-group-shaped ambiguity. This is a materially better outcome than the prior +Kind preview proof's own external-backend trial (`403 -> 503`, "reconnection after wake was +therefore not proved") -- the difference was using a **CIDR rule** (works for any TCP protocol +per `docs/network-egress.md`'s "CIDR/all policy: dial now" passthrough path) instead of a +**hostname rule** (HTTP/TLS-SNI-specific, and Postgres is neither), which the prior proof's HTTP +`fetch`-based trial did not have reason to distinguish. + ## Limits / not attempted in this run -- **Dev-service gate**: no Postgres actor, no egress policy, no reconnect-after-wake trial against - our template. - **Native-session gate, live**: no real Claude/Codex session was started inside a Substrate actor; the credential wiring authorized by `.tasknotes/plan.md` was not used. Only the fixture-level contract logic (`test_workspace_adapter.py`) and the generic @@ -149,10 +179,10 @@ exactly why gate 5's live proof matters and was not reached in this run). ## Cleanup -Each cluster lane in this spike (the initial adapter/CRASHED trial, and the later preview-gate -trial) deleted its own test actors, then the `substrate-preview` cluster and its `kind-registry` -(`hack/delete-kind-cluster.sh`), and pruned the locally built, unpushed-elsewhere Docker images. -Final `kind get clusters` / `docker ps` showed only `mainloop-test` / +Each of the three cluster lanes in this spike (adapter/CRASHED, preview-gate, dev-service-gate) +deleted its own test actors and target resources, then the `substrate-preview` cluster and its +`kind-registry` (`hack/delete-kind-cluster.sh`), and pruned the locally built, unpushed-elsewhere +Docker images. Final `kind get clusters` / `docker ps` showed only `mainloop-test` / `mainloop-test-control-plane` after every lane. Root disk stayed in the 19-27G-free range throughout (above the plan's 8G in-flight-trial abort threshold at all times); available RAM stayed above 8G. No Mainloop repository files outside this branch's own commits were changed. diff --git a/spikes/substrate-workspace-adapter/dev-service-image/.gitignore b/spikes/substrate-workspace-adapter/dev-service-image/.gitignore new file mode 100644 index 0000000..a78a325 --- /dev/null +++ b/spikes/substrate-workspace-adapter/dev-service-image/.gitignore @@ -0,0 +1 @@ +herdr diff --git a/spikes/substrate-workspace-adapter/dev-service-image/Dockerfile b/spikes/substrate-workspace-adapter/dev-service-image/Dockerfile new file mode 100644 index 0000000..7759bbf --- /dev/null +++ b/spikes/substrate-workspace-adapter/dev-service-image/Dockerfile @@ -0,0 +1,19 @@ +# Dev-service-gate spike image (gate 4): real Herdr server + a real psql client, driven by the +# same generic exec shim as the preview-gate image, to test a Substrate actor's egress +# connectivity to an external PostgreSQL Service under a narrow CIDR egress policy. +# herdr is copied from the host into the build context by the build script (never committed). +# No credentials are baked in. +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates curl procps postgresql-client \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -m -u 10001 agent +COPY herdr /usr/local/bin/herdr +COPY exec-shim.js /usr/local/bin/exec-shim.js +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh \ + && mkdir -p /work && chown -R agent:agent /work +ENV EXEC_SHIM=/usr/local/bin/exec-shim.js +ENV HOME=/home/agent +ENV HERDR_SESSION=mainloop-dev-service +USER 10001:10001 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/spikes/substrate-workspace-adapter/dev-service-image/entrypoint.sh b/spikes/substrate-workspace-adapter/dev-service-image/entrypoint.sh new file mode 100644 index 0000000..4ae7773 --- /dev/null +++ b/spikes/substrate-workspace-adapter/dev-service-image/entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts +# Dev-service-gate actor entrypoint (gate 4 in .tasknotes/plan.md): a real Herdr server plus a +# generic exec shim, no dev server -- just enough to run a real psql client against an external +# Postgres Service from inside the actor. See docs/spikes/substrate-workspace-adapter.md. +set -eu +mkdir -p "${HOME}" + +echo "herdr $(herdr --version) server starting (HOME=${HOME} session=${HERDR_SESSION})" +herdr --session "${HERDR_SESSION}" server & +HERDR_PID=$! + +for _ in $(seq 1 60); do + herdr --session "${HERDR_SESSION}" status server >/dev/null 2>&1 && break + sleep 0.5 +done + +shell_ws=$(herdr --session "${HERDR_SESSION}" workspace create --label shell --cwd /work) +shell_pane=$(echo "${shell_ws}" | jq -r '.result.root_pane.pane_id') + +EXEC_SHIM_PANE_ID="${shell_pane}" HERDR_SESSION="${HERDR_SESSION}" node "${EXEC_SHIM}" & + +wait "${HERDR_PID}" diff --git a/spikes/substrate-workspace-adapter/dev-service-image/exec-shim.js b/spikes/substrate-workspace-adapter/dev-service-image/exec-shim.js new file mode 100644 index 0000000..01250e7 --- /dev/null +++ b/spikes/substrate-workspace-adapter/dev-service-image/exec-shim.js @@ -0,0 +1,67 @@ +// Minimal generic command executor for the preview-gate spike: POST /run { command } pastes +// `command` as literal text into a real Herdr shell pane via `herdr pane run` (fire-and-forget; +// the pane is a real bash shell, so this is a real shell write, not a purpose-built edit +// endpoint). GET /read returns the pane's current terminal buffer (`herdr pane read`), since +// `pane run` itself never captures output. Stands in for a credentialed native agent's own Bash +// tool -- see entrypoint.sh and docs/spikes/substrate-workspace-adapter.md for why a real agent +// could not be used here. +// Listens on port 8090, separate from the Vite dev server's port 80. Reached only through +// atenet-router's arbitrary-port CONNECT tunnel with the ate-target-actor header (see +// docs/api-guide.md "Workload Connectivity"), from test orchestration on the host -- never +// through the previewed route a browser uses (port 80 via the NGINX header-proxy). +'use strict'; +const http = require('node:http'); +const { execFile } = require('node:child_process'); + +const PANE_ID = process.env.EXEC_SHIM_PANE_ID; +const SESSION = process.env.HERDR_SESSION; +if (!PANE_ID || !SESSION) { + console.error('exec-shim: EXEC_SHIM_PANE_ID and HERDR_SESSION are required'); + process.exit(1); +} + +function herdr(args, res) { + execFile('herdr', ['--session', SESSION, ...args], (err, stdout, stderr) => { + if (err) { + res.writeHead(502).end(String(err)); + return; + } + res + .writeHead(200, { 'content-type': 'application/json' }) + .end(JSON.stringify({ ok: true, stdout, stderr })); + }); +} + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/read') { + herdr(['pane', 'read', PANE_ID], res); + return; + } + if (req.method !== 'POST' || req.url !== '/run') { + res.writeHead(404).end(); + return; + } + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 65536) req.destroy(); + }); + req.on('end', () => { + let command; + try { + command = JSON.parse(body).command; + } catch { + res.writeHead(400).end('invalid json'); + return; + } + if (typeof command !== 'string' || !command) { + res.writeHead(400).end('missing command'); + return; + } + herdr(['pane', 'run', PANE_ID, command], res); + }); +}); + +server.listen(8090, '0.0.0.0', () => { + console.log('exec-shim listening on :8090, pane', PANE_ID); +}); diff --git a/spikes/substrate-workspace-adapter/egress-tool/main.go b/spikes/substrate-workspace-adapter/egress-tool/main.go new file mode 100644 index 0000000..1607afc --- /dev/null +++ b/spikes/substrate-workspace-adapter/egress-tool/main.go @@ -0,0 +1,84 @@ +// Creates/updates an actor's EgressPolicy directly via gRPC, since kubectl-ate has no CLI verb +// for it as of the pinned commit (confirmed by the substrate checkout's own +// demos/egress/README.md: "test-egress.sh creates and resumes the Actor but cannot create its +// EgressPolicy (no CLI verb yet)"). Mirrors that checkout's internal/e2e/egresspolicy.go +// (EnsureEgressPolicy), without the testing.T dependency. +// +// This file imports Substrate's internal packages (internal/ateclient, internal/resources), so +// it cannot be built as a standalone Go module outside a Substrate checkout. To use it: drop +// this file into /cmd/mainloop-egress-tool/main.go and build with +// `GOFLAGS=-mod=vendor go build -o mainloop-egress-tool ./cmd/mainloop-egress-tool` from the +// checkout root (module github.com/agent-substrate/substrate, pinned commit +// cdac9baef81dd319b46086d695266e6161e9e592 when this was written). +// +// Usage: mainloop-egress-tool --kubeconfig --context --atespace --actor +// [--cidr ] (omit --cidr to allow all destinations) +package main + +import ( + "context" + "flag" + "fmt" + "log" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +func main() { + kubeconfig := flag.String("kubeconfig", "", "") + context_ := flag.String("context", "", "") + atespace := flag.String("atespace", "", "") + actorName := flag.String("actor", "", "") + cidr := flag.String("cidr", "", "CIDR to allow; empty means allow-all") + flag.Parse() + + ctx := context.Background() + cli, err := ateclient.NewClient(ctx, *kubeconfig, *context_, "", "", false) + if err != nil { + log.Fatalf("connect: %v", err) + } + defer cli.Close() + + actorRef := resources.ActorRef{Atespace: *atespace, Name: *actorName}.ToObjectRef() + + var rule *ateapipb.EgressRule + if *cidr == "" { + rule = &ateapipb.EgressRule{All: &emptypb.Empty{}} + } else { + rule = &ateapipb.EgressRule{Cidrs: &ateapipb.CIDRRule{Cidrs: []string{*cidr}}} + } + policy := &ateapipb.EgressPolicy{ + Metadata: &ateapipb.ResourceMetadata{Atespace: *atespace, Name: "default"}, + Rules: []*ateapipb.EgressRule{rule}, + } + + _, err = cli.CreateActorEgressPolicy(ctx, &ateapipb.CreateActorEgressPolicyRequest{ + Actor: actorRef, + EgressPolicy: policy, + }) + if status.Code(err) == codes.AlreadyExists { + existing, gerr := cli.GetActorEgressPolicy(ctx, &ateapipb.GetActorEgressPolicyRequest{Actor: actorRef}) + if gerr != nil { + log.Fatalf("get existing: %v", gerr) + } + policy.Metadata = existing.GetMetadata() + if _, uerr := cli.UpdateActorEgressPolicy(ctx, &ateapipb.UpdateActorEgressPolicyRequest{ + Actor: actorRef, + EgressPolicy: policy, + }); uerr != nil { + log.Fatalf("update: %v", uerr) + } + fmt.Println("updated existing egress policy") + return + } + if err != nil { + log.Fatalf("create: %v", err) + } + fmt.Println("created egress policy") +} diff --git a/spikes/substrate-workspace-adapter/k8s/dev-service-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/dev-service-gate-template.yaml.tmpl new file mode 100644 index 0000000..6b47103 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/dev-service-gate-template.yaml.tmpl @@ -0,0 +1,47 @@ +# WorkerPool + ActorTemplate for the dev-service gate measurement (gate 4 in +# .tasknotes/plan.md): a real psql client, driven by the same generic exec shim as the +# preview-gate template, to test egress connectivity to an external PostgreSQL Service. See +# spikes/substrate-workspace-adapter/dev-service-image/. +apiVersion: v1 +kind: Namespace +metadata: + name: ${ATESPACE} +--- +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: dev-service-gate + namespace: ${ATESPACE} + labels: + workload: dev-service-gate +spec: + replicas: 1 + workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + template: + resources: + limits: { cpu: '1', memory: 1Gi } + requests: { cpu: 250m, memory: 1Gi } +--- +metadata: + atespace: ${ATESPACE} + name: dev-service-gate +workerSelector: + matchLabels: + workload: dev-service-gate +containers: +- name: dev-service + image: __IMAGE__ + env: + - { name: HOME, value: /home/agent } + - { name: HERDR_SESSION, value: mainloop-dev-service } + resources: + limits: + - { name: cpu, quantity: "1" } + - { name: memory, quantity: 1Gi } +snapshotsConfig: + onPause: SNAPSHOT_CONTENT_SCOPE_FULL + onCommit: SNAPSHOT_CONTENT_SCOPE_FULL + storageLocation: gs://${BUCKET_NAME}/dev-service-gate/ +sandboxConfig: + sandboxClass: SANDBOX_CLASS_GVISOR + configName: gvisor-default diff --git a/spikes/substrate-workspace-adapter/k8s/postgres-target.yaml b/spikes/substrate-workspace-adapter/k8s/postgres-target.yaml new file mode 100644 index 0000000..f9f1557 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/postgres-target.yaml @@ -0,0 +1,68 @@ +# The "actual external PostgreSQL development service" gate 4 measures connectivity to, matching +# k8s/apps/mainloop/overlays/test/postgres-statefulset.yaml's image/auth shape (postgres:16-alpine, +# mainloop/mainloop/mainloop). Deployed as a plain K8s StatefulSet in its own namespace -- outside +# any atespace -- since it represents a real external service, not a Substrate actor. +apiVersion: v1 +kind: Namespace +metadata: + name: postgres-target +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: postgres-target +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: postgres-target +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_USER + value: mainloop + - name: POSTGRES_PASSWORD + value: mainloop + - name: POSTGRES_DB + value: mainloop + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + resources: + requests: { memory: 256Mi, cpu: 100m } + limits: { memory: 512Mi, cpu: 500m } + readinessProbe: + exec: + command: [pg_isready, -U, mainloop] + initialDelaySeconds: 5 + periodSeconds: 5 + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 1Gi From b7ffcbe331a0bd449430120ca0a489d2885427c8 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:09:59 +0000 Subject: [PATCH 07/30] feat: build gate-5 live-agent infrastructure; block on safety classifier Adds spikes/substrate-workspace-adapter/live-agent-image/ (real Herdr + real Claude/Codex CLIs), k8s/cred-server.yaml.tmpl (an in-cluster nginx server exposing ~/.claude-token and ~/.codex/auth.json from Kubernetes Secrets created by path, never by value), and k8s/live-agent-gate-template.yaml.tmpl. Credentials are fetched at actor-runtime rather than baked into the immutable ActorTemplate, reachable only because the actor's own narrow EgressPolicy allows exactly the cred-server's ClusterIP -- reusing the same CIDR-scoped enforcement gate 4 (dev-service) proved actually denies everything else with a clean 403, as the credential-delivery boundary. Did not run the trial: the cluster-creation step, which provisions a live actor that pulls in real Claude Code / Codex OAuth credentials, was declined by Claude Code's own auto-mode safety classifier ("Create Unsafe Agents"). Per .tasknotes/plan.md's own stopping criterion for a missing permission, and because this touches the operator's real subscription credentials, the run stopped there rather than seeking a workaround, and asked the operator directly rather than proceeding. Gate 5 (native-session continuity, live) is documented as built-but- not-run in docs/spikes/substrate-workspace-adapter.md and the task's proof note (.tasknotes, not tracked here), pending explicit operator authorization to run it. --- docs/spikes/substrate-workspace-adapter.md | 18 +- .../k8s/cred-server.yaml.tmpl | 71 ++++++ .../k8s/live-agent-gate-template.yaml.tmpl | 45 ++++ .../live-agent-image/.gitignore | 4 + .../live-agent-image/Dockerfile | 28 +++ .../live-agent-image/agent-config/claude.env | 4 + .../live-agent-image/agent-config/codex.env | 4 + .../agent-config/mainloop-system.txt | 3 + .../live-agent-image/bin/agentctl | 235 ++++++++++++++++++ .../live-agent-image/bin/mainloop | 159 ++++++++++++ .../live-agent-image/entrypoint.sh | 57 +++++ .../live-agent-image/exec-shim.js | 67 +++++ 12 files changed, 690 insertions(+), 5 deletions(-) create mode 100644 spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl create mode 100644 spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/.gitignore create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/Dockerfile create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/agent-config/claude.env create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/agent-config/codex.env create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/agent-config/mainloop-system.txt create mode 100755 spikes/substrate-workspace-adapter/live-agent-image/bin/agentctl create mode 100755 spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 4b413be..56a9ee9 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -24,7 +24,8 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. | `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | | Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | | File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | -| Claude/Codex agent processes, credentials | Not run in this spike (see "Not attempted") | +| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, real cred-server | Built, not run -- blocked at cluster creation by Claude Code's own safety classifier ("Create Unsafe Agents"); see "Limits" | +| Claude/Codex agent processes, credentials | Not run in this spike (see "Limits") | | `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | ## Why not a real agent for the preview-gate edit (credential-injection gap) @@ -168,10 +169,17 @@ per `docs/network-egress.md`'s "CIDR/all policy: dial now" passthrough path) ins ## Limits / not attempted in this run -- **Native-session gate, live**: no real Claude/Codex session was started inside a Substrate - actor; the credential wiring authorized by `.tasknotes/plan.md` was not used. Only the - fixture-level contract logic (`test_workspace_adapter.py`) and the generic - restore-vs-reboot log evidence above are available. +- **Native-session gate, live**: infrastructure built and ready + (`spikes/substrate-workspace-adapter/live-agent-image/`, `k8s/cred-server.yaml.tmpl`, + `k8s/live-agent-gate-template.yaml.tmpl`) -- real Claude/Codex CLIs, credentials fetched at + actor-runtime through the same egress-CIDR mechanism gate 4 proved enforces (never baked into + a template), reusing `.tasknotes/plan.md`'s by-path Secret pattern. The trial was not run: the + cluster-creation step was declined by Claude Code's own auto-mode safety classifier ("Create + Unsafe Agents"), and the run stopped there rather than seeking a workaround, per the plan's own + "missing permission" stopping criterion -- this involves the operator's real subscription + credentials, so proceeding past a safety control without explicit human authorization was not + appropriate. Only the fixture-level contract logic (`test_workspace_adapter.py`) and the + generic restore-vs-reboot log evidence above are available for this gate. - **`workspace_bindings` orchestration functions** (`ensure_workspace`, `resume_workspace`, ...) were not exercised against a live Postgres + running backend; only their extracted pure logic (`plan_ensure`, `_binding_from_row`, `is_crashed`) is unit tested, and the transport layer diff --git a/spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl new file mode 100644 index 0000000..f2b2459 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl @@ -0,0 +1,71 @@ +# Serves the two credential files a real Claude/Codex CLI needs, from Kubernetes Secrets created +# by path (never by value -- see build script). Reachable only by an actor whose EgressPolicy +# explicitly allows this Service's ClusterIP: the same CIDR-scoped enforcement gate 4 +# (dev-service) proved denies everything else with a clean 403, reused here as the credential +# boundary. Plain NGINX static-file serving; no application code. Deployed outside any atespace, +# like the gate 4 Postgres target -- this represents a Mainloop-operated credential-relay +# service, not part of the actor's own image or an atespace resource. +apiVersion: v1 +kind: Namespace +metadata: + name: cred-server +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cred-server-nginx-config + namespace: cred-server +data: + nginx.conf: | + events {} + http { + server { + listen 80; + location = /claude-token { alias /secrets/claude/token; } + location = /codex-auth.json { alias /secrets/codex/auth.json; } + location / { return 404; } + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cred-server + namespace: cred-server +spec: + replicas: 1 + selector: + matchLabels: + app: cred-server + template: + metadata: + labels: + app: cred-server + spec: + containers: + - name: nginx + image: nginx:alpine + ports: + - containerPort: 80 + volumeMounts: + - { name: config, mountPath: /etc/nginx/nginx.conf, subPath: nginx.conf } + - { name: claude-token, mountPath: /secrets/claude, readOnly: true } + - { name: codex-auth, mountPath: /secrets/codex, readOnly: true } + volumes: + - name: config + configMap: { name: cred-server-nginx-config } + - name: claude-token + secret: { secretName: mainloop-claude-token } + - name: codex-auth + secret: { secretName: mainloop-codex-auth } +--- +apiVersion: v1 +kind: Service +metadata: + name: cred-server + namespace: cred-server +spec: + selector: + app: cred-server + ports: + - port: 80 diff --git a/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl new file mode 100644 index 0000000..a6e5350 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl @@ -0,0 +1,45 @@ +# WorkerPool + ActorTemplate for the bounded live native-agent proof (gate 5 in +# .tasknotes/plan.md): real Claude Code / Codex CLIs under Herdr, driven by the exec shim. See +# spikes/substrate-workspace-adapter/live-agent-image/. +apiVersion: v1 +kind: Namespace +metadata: + name: ${ATESPACE} +--- +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: live-agent-gate + namespace: ${ATESPACE} + labels: + workload: live-agent-gate +spec: + replicas: 1 + workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + template: + resources: + limits: { cpu: '2', memory: 2Gi } + requests: { cpu: 250m, memory: 2Gi } +--- +metadata: + atespace: ${ATESPACE} + name: live-agent-gate +workerSelector: + matchLabels: + workload: live-agent-gate +containers: +- name: live-agent + image: __IMAGE__ + env: + - { name: CRED_SERVER, value: "cred-server.cred-server.svc.cluster.local" } + resources: + limits: + - { name: cpu, quantity: "2" } + - { name: memory, quantity: 2Gi } +snapshotsConfig: + onPause: SNAPSHOT_CONTENT_SCOPE_FULL + onCommit: SNAPSHOT_CONTENT_SCOPE_FULL + storageLocation: gs://${BUCKET_NAME}/live-agent-gate/ +sandboxConfig: + sandboxClass: SANDBOX_CLASS_GVISOR + configName: gvisor-default diff --git a/spikes/substrate-workspace-adapter/live-agent-image/.gitignore b/spikes/substrate-workspace-adapter/live-agent-image/.gitignore new file mode 100644 index 0000000..8ab7127 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/.gitignore @@ -0,0 +1,4 @@ +herdr +claude +codex +codex-code-mode-host diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile new file mode 100644 index 0000000..824d030 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -0,0 +1,28 @@ +# Live-agent-gate spike image (gate 5, bounded live proof): real Herdr + real Claude Code / +# Codex CLIs + agentctl, driven by the same generic exec shim as the other gate images. +# herdr/claude/codex/codex-code-mode-host are copied from the host into the build context by the +# build script (never committed). No credentials are baked into this image; they are fetched at +# actor runtime from an in-cluster server reachable only via a narrow EgressPolicy (see +# entrypoint.sh and docs/spikes/substrate-workspace-adapter.md). +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates git curl procps ripgrep \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -m -u 10001 agent +COPY herdr /usr/local/bin/herdr +COPY claude /usr/local/bin/claude +COPY codex /usr/local/bin/codex +COPY codex-code-mode-host /usr/local/bin/codex-code-mode-host +COPY bin/agentctl bin/mainloop /usr/local/bin/ +COPY agent-config /etc/agent-config +COPY exec-shim.js /usr/local/bin/exec-shim.js +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/agentctl /usr/local/bin/mainloop \ + && mkdir -p /work && chown -R agent:agent /work +ENV EXEC_SHIM=/usr/local/bin/exec-shim.js +ENV HOME=/home/agent +ENV WORKSPACE_PATH=/work/repo +ENV CODEX_HOME=/home/agent/.codex +ENV AGENT_CONFIG_DIR=/etc/agent-config +ENV HERDR_SESSION=mainloop-live-agent +USER 10001:10001 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/spikes/substrate-workspace-adapter/live-agent-image/agent-config/claude.env b/spikes/substrate-workspace-adapter/live-agent-image/agent-config/claude.env new file mode 100644 index 0000000..5ec34e1 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/agent-config/claude.env @@ -0,0 +1,4 @@ +AGENT_KIND=claude +AGENT_NEW_ARGS="--dangerously-skip-permissions --append-system-prompt-file /etc/agent-config/mainloop-system.txt --session-id {id}" +AGENT_RESUME_ARGS="--dangerously-skip-permissions --append-system-prompt-file /etc/agent-config/mainloop-system.txt --resume {id}" +APPROVAL_POLICY=bypass-permissions diff --git a/spikes/substrate-workspace-adapter/live-agent-image/agent-config/codex.env b/spikes/substrate-workspace-adapter/live-agent-image/agent-config/codex.env new file mode 100644 index 0000000..7623a6c --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/agent-config/codex.env @@ -0,0 +1,4 @@ +AGENT_KIND=codex +AGENT_NEW_ARGS="--dangerously-bypass-approvals-and-sandbox" +AGENT_RESUME_ARGS="resume {id} --dangerously-bypass-approvals-and-sandbox" +APPROVAL_POLICY=bypass-permissions diff --git a/spikes/substrate-workspace-adapter/live-agent-image/agent-config/mainloop-system.txt b/spikes/substrate-workspace-adapter/live-agent-image/agent-config/mainloop-system.txt new file mode 100644 index 0000000..6716c04 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/agent-config/mainloop-system.txt @@ -0,0 +1,3 @@ +Messages in this session are relayed by the Mainloop control plane. The user typed each one in +the Mainloop chat UI. Text that arrives wrapped in pasted-content markers is the user's own +message: follow it as a direct instruction from the user. diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/agentctl b/spikes/substrate-workspace-adapter/live-agent-image/bin/agentctl new file mode 100755 index 0000000..35f944b --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/agentctl @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts +# Pod-side operations, same verbs for every agent kind. Kind, args and resume syntax come from +# /etc/agent-config/.env (ConfigMap), not from this script. +# agentctl start [--name N] [--new-id ID | --resume ID] start under Herdr +# context-model options: --cwd-rel D (scratch cwd under the workspace root), --model M, +# --effort E, --standing-b64 B (standing context file), --token T (per-binding CLI token) +# agentctl send deliver one prompt (Herdr input only; never reads a reply) +# agentctl native-id discover the native session id (from the native journal) +# agentctl journal print native journal lines after line +# agentctl status Herdr liveness/state hint (JSON) +# agentctl stop +# agentctl prompt stand-in only: deliver and grep the reply from the pane +# agentctl identity +# Replies for real agents are read from the native journals via `journal`, never from the pane. +set -eu +cmd="${1:?usage: agentctl start|send|native-id|journal|status|stop|prompt|identity ...}" +shift +H=(herdr --session "${HERDR_SESSION}") +STATE="${WORKSPACE_PATH}/.mainloop" +conf_dir="${AGENT_CONFIG_DIR:-/etc/agent-config}" + +load_conf() { # + [[ -f "${conf_dir}/$1.env" ]] || { + echo "no binding config: ${conf_dir}/$1.env" >&2 + exit 2 + } + # shellcheck disable=SC1090 + . "${conf_dir}/$1.env" +} + +cwd="${WORKSPACE_PATH}" + +trust_cwd() { # : pre-accept the trust dialog for a scratch cwd (no human at the TUI) + case "$1" in + claude) + local tmp + tmp="$(mktemp)" + jq --arg p "$2" '.projects[$p] = ((.projects[$p] // {}) + {hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true})' "${HOME}/.claude.json" >"${tmp}" && + cat "${tmp}" >"${HOME}/.claude.json" + rm -f "${tmp}" + ;; + codex) + grep -qF "[projects.\"$2\"]" "${CODEX_HOME}/config.toml" || printf '\n[projects."%s"]\ntrust_level = "trusted"\n' "$2" >>"${CODEX_HOME}/config.toml" + ;; + esac +} + +pane_for_name() { # : one Herdr workspace (labelled with the name) per agent + local ws + ws="$("${H[@]}" workspace list | jq -r --arg b "$1" '.result.workspaces[] | select(.label==$b) | .workspace_id' | head -n1)" + if [[ -z ${ws} ]]; then + ws="$("${H[@]}" workspace create --label "$1" --cwd "${cwd}" | jq -r .result.workspace.workspace_id)" + fi + "${H[@]}" pane list --workspace "${ws}" | jq -r '.result.panes[0].pane_id' +} + +journal_file() { # + case "$1" in + claude) find "${CLAUDE_CONFIG_DIR:-${HOME}/.claude}/projects" -name "$2.jsonl" 2>/dev/null | head -n1 ;; + codex) find "${CODEX_HOME}/sessions" -name "rollout-*-$2.jsonl" 2>/dev/null | head -n1 ;; + *) + echo "no journal for kind $1" >&2 + return 1 + ;; + esac +} + +case "${cmd}" in +start) + binding="${1:?binding required}" + shift + name="${binding}" + mode=new + nid="" + cwd_rel="" + model="" + effort="" + standing_b64="" + token="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) + name="$2" + shift 2 + ;; + --new-id) + mode=new + nid="$2" + shift 2 + ;; + --resume) + mode=resume + nid="$2" + shift 2 + ;; + --cwd-rel) + cwd_rel="$2" + shift 2 + ;; + --model) + model="$2" + shift 2 + ;; + --effort) + effort="$2" + shift 2 + ;; + --standing-b64) + standing_b64="$2" + shift 2 + ;; + --token) + token="$2" + shift 2 + ;; + *) + echo "unknown option $1" >&2 + exit 2 + ;; + esac + done + load_conf "${binding}" + ident="${STATE}/${name}.identity.json" + if "${H[@]}" agent get "${name}" >/dev/null 2>&1; then + echo "agent ${name} already live" + exit 0 + fi + if [[ ${mode} == resume ]]; then args="${AGENT_RESUME_ARGS:-${AGENT_ARGS}}"; else args="${AGENT_NEW_ARGS:-${AGENT_ARGS}}"; fi + if [[ -n ${cwd_rel} ]]; then + cwd="$(dirname "${WORKSPACE_PATH}")/${cwd_rel}" + mkdir -p "${cwd}/.mainloop" + chmod 700 "${cwd}/.mainloop" + # Secrets and generated context go to files on the PVC (0600), never into the pane command. + [[ -z ${token} ]] || ( + umask 077 + printf '%s' "${token}" >"${cwd}/.mainloop/token" + ) + [[ -z ${standing_b64} ]] || printf '%s' "${standing_b64}" | base64 -d >"${cwd}/.mainloop/standing.md" + [[ ! -f "${conf_dir}/${binding}.settings.json" ]] || cp "${conf_dir}/${binding}.settings.json" "${cwd}/.mainloop/settings.json" + trust_cwd "${AGENT_KIND}" "${cwd}" + fi + args="${args//\{id\}/${nid}}" + args="${args//\{model\}/${model}}" + args="${args//\{effort\}/${effort}}" + args="${args//\{standing\}/${cwd}/.mainloop/standing.md}" + args="${args//\{settings\}/${cwd}/.mainloop/settings.json}" + mkdir -p "${STATE}" + touch "${STATE}/${name}.started" + pane="$(pane_for_name "${name}")" + # $args is intentionally word-split: it is the native executable's argument list. + # shellcheck disable=SC2086 + "${H[@]}" agent start "${name}" --kind "${AGENT_KIND}" --pane "${pane}" --timeout 60000 -- ${args} >/dev/null + "${H[@]}" agent get "${name}" | jq -c --arg b "${binding}" --arg k "${AGENT_KIND}" --arg args "${args}" --arg mode "${mode}" --arg nid "${nid}" \ + '.result.agent | {binding:$b, kind:$k, args:$args, mode:$mode, native_session_id:(if $nid=="" then null else $nid end), herdr_agent:.agent, herdr_name:.name, pane_id, terminal_id, workspace_id, status:.agent_status}' >"${ident}" + cat "${ident}" + ;; +send) + name="${1:?name required}" + text="${2:?text required}" + # One delivery, no --wait retries: Herdr status is a hint, the journal is the receipt. + "${H[@]}" agent prompt "${name}" "${text}" >/dev/null + echo sent + ;; +native-id) + name="${1:?name required}" + ident="${STATE}/${name}.identity.json" + kind="$(jq -r .kind "${ident}")" + known="$(jq -r '.native_session_id // empty' "${ident}")" + if [[ -n ${known} ]]; then + echo "${known}" + exit 0 + fi + case "${kind}" in + codex) + f="$(find "${CODEX_HOME}/sessions" -name 'rollout-*.jsonl' -newer "${STATE}/${name}.started" 2>/dev/null | sort | head -n1)" + [[ -n ${f} ]] || exit 1 + id="$(basename "${f}" .jsonl | sed -E 's/^rollout-[0-9T:-]+-//')" + ;; + *) exit 1 ;; + esac + tmp="$(mktemp)" + jq --arg id "${id}" '.native_session_id=$id' "${ident}" >"${tmp}" && cat "${tmp}" >"${ident}" && rm -f "${tmp}" + echo "${id}" + ;; +journal) + kind="$(jq -r .kind "${STATE}/${1:?name required}.identity.json")" + id="${2:?native id required}" + from="${3:-0}" + f="$(journal_file "${kind}" "${id}")" + [[ -n ${f} ]] || { + echo "#nofile" + exit 0 + } + n="$(wc -l <"${f}")" # complete (newline-terminated) lines only + printf '#file\t%s\t%s\n' "${f}" "${n}" + if [[ ${n} -gt ${from} ]]; then sed -n "$((from + 1)),${n}p" "${f}" | awk -v s="${from}" '{print (NR + s) "\t" $0}'; fi + ;; +status) + # A failed `agent get` must fail the verb (a pipeline would report jq's exit status). + out="$("${H[@]}" agent get "${1:?name required}")" || exit 1 + printf '%s' "${out}" | jq -c '.result.agent | {name, agent, pane_id, terminal_id, status: .agent_status}' + ;; +prompt) # stand-in agents only + binding="${1:?binding required}" + text="${2:?prompt text required}" + "${H[@]}" agent prompt "${binding}" "${text}" --wait --timeout 60000 >/dev/null + "${H[@]}" agent read "${binding}" | grep 'STANDIN-REPLY' | grep -F "echo=${text}" | tail -n1 + ;; +stop) + name="${1:?name required}" + kind="$(jq -r '.kind // empty' "${STATE}/${name}.identity.json" 2>/dev/null || true)" + if [[ ${kind} == claude ]]; then + # A pasted "/exit" is text, and the restricted main thread has slash commands disabled: + # two quick Ctrl-C key presses exit Claude Code (measured). + "${H[@]}" agent send-keys "${name}" ctrl+c ctrl+c >/dev/null + else + "${H[@]}" agent prompt "${name}" "/exit" >/dev/null + fi + for _ in $(seq 1 20); do + "${H[@]}" agent get "${name}" >/dev/null 2>&1 || { + echo "agent ${name} stopped" + exit 0 + } + sleep 0.5 + done + echo "agent ${name} still live after stop" >&2 + exit 1 + ;; +identity) cat "${STATE}/${1:?name required}.identity.json" ;; +*) + echo "unknown command ${cmd}" >&2 + exit 2 + ;; +esac diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop b/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop new file mode 100755 index 0000000..03b0ca0 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts +# `mainloop`: the agents' thin client for the Mainloop control plane. It holds no policy. +# Identity is the per-binding token in .mainloop/token (found by walking up from $PWD, written +# by `agentctl start`); the server decides what this token may do and answers in plain text. +set -u +API="${MAINLOOP_API:-http://mainloop-backend.mainloop.svc.cluster.local:8000}" + +find_token() { + local d="${PWD}" + while :; do + [[ -f "${d}/.mainloop/token" ]] && { + cat "${d}/.mainloop/token" + return 0 + } + [[ ${d} == / ]] && return 1 + d="$(dirname "${d}")" + done +} +TOKEN="${MAINLOOP_TOKEN:-$(find_token)}" || { + echo "mainloop: no agent token found from ${PWD}" >&2 + exit 2 +} + +call() { # [json body] ; query params via Q=(--data-urlencode k=v ...) + local out code body + out="$(curl -sS -m 30 -w '\n%{http_code}' -X "$1" -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' ${Q[@]+"${Q[@]}"} ${3:+-d "$3"} -G "${API}$2" 2>&1)" || + { + echo "mainloop: control plane unreachable" >&2 + exit 3 + } + code="${out##*$'\n'}" + body="${out%$'\n'*}" + if [[ ${code} -ge 200 ]] 2>/dev/null && [[ ${code} -lt 300 ]]; then + printf '%s' "${body}" | jq -r '.text // .' + else + echo "mainloop: refused (${code}): $(printf '%s' "${body}" | jq -r '.detail // .' 2>/dev/null || printf '%s' "${body}")" >&2 + exit 1 + fi +} +Q=() +# POST/GET with a body use -d, which makes curl POST; -G turns -d into a query string, so bodies +# are sent with --json-style separately: +post() { # + local out code body + out="$(curl -sS -m 30 -w '\n%{http_code}' -X POST -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' --data-binary "$2" "${API}$1" 2>&1)" || + { + echo "mainloop: control plane unreachable" >&2 + exit 3 + } + code="${out##*$'\n'}" + body="${out%$'\n'*}" + if [[ ${code} -ge 200 ]] 2>/dev/null && [[ ${code} -lt 300 ]]; then + printf '%s' "${body}" | jq -r '.text // .' + else + echo "mainloop: refused (${code}): $(printf '%s' "${body}" | jq -r '.detail // .' 2>/dev/null || printf '%s' "${body}")" >&2 + exit 1 + fi +} +usage() { + sed -n '2,3p' "$0" + echo "verbs: whoami topics topic note decide pending delegate status read cancel clear report standing" +} + +verb="${1:-help}" +[[ $# -gt 0 ]] && shift +case "${verb}" in +help | -h | --help) usage ;; +whoami) call GET /agent-api/whoami ;; +topics) call GET /agent-api/topics ;; +standing) call GET /agent-api/standing ;; +topic) + [[ ${1-} == open ]] || { + echo "usage: mainloop topic open [--status ]" >&2 + exit 2 + } + shift + name="${1:?topic name required}" + shift + status="" + [[ ${1-} == --status ]] && status="${2-}" + post /agent-api/topics "$(jq -n --arg n "${name}" --arg s "${status}" 'if $s=="" then {name:$n} else {name:$n,status:$s} end')" + ;; +note | decide | pending) + kind="${verb}" + [[ ${verb} == decide ]] && kind=decision + if [[ ${verb} == pending ]] && [[ ${1-} == --done ]]; then + post "/agent-api/records/${2:?id required}/done" '{}' + exit + fi + text="${1:?text required}" + shift + topic="" + [[ ${1-} == --topic ]] && topic="${2-}" + post /agent-api/records "$(jq -n --arg k "${kind}" --arg t "${text}" --arg p "${topic}" 'if $p=="" then {kind:$k,text:$t} else {kind:$k,text:$t,topic:$p} end')" + ;; +delegate) + topic=inbox + kind="" + title="" + brief="" + while [[ $# -gt 0 ]]; do + case "$1" in + --topic) + topic="$2" + shift 2 + ;; + --kind) + kind="$2" + shift 2 + ;; + --title) + title="$2" + shift 2 + ;; + *) + brief="$1" + shift + ;; + esac + done + [[ -n ${kind} ]] && [[ -n ${brief} ]] || { + echo 'usage: mainloop delegate --topic --kind claude|codex --title "" ""' >&2 + exit 2 + } + post /agent-api/delegate "$(jq -n --arg t "${topic}" --arg k "${kind}" --arg ti "${title}" --arg b "${brief}" '{topic:$t,kind:$k,title:$ti,brief:$b}')" + ;; +status) + [[ -n ${1-} ]] && Q=(--data-urlencode "session=$1") + call GET /agent-api/status + ;; +read) + id="${1:?session id required}" + shift + since=0 + [[ ${1-} == --since ]] && since="${2:-0}" + Q=(--data-urlencode "session=${id}" --data-urlencode "since=${since}") + call GET /agent-api/read + ;; +cancel) + post /agent-api/cancel "$(jq -n --arg s "${1:?session id required}" '{session:$s}')" + ;; +clear) + post /agent-api/clear "$(jq -n --arg s "${1-}" 'if $s=="" then {} else {session:$s} end')" + ;; +report) + [[ ${1-} == --summary ]] || { + echo 'usage: mainloop report --summary ""' >&2 + exit 2 + } + post /agent-api/report "$(jq -n --arg s "${2:?summary required}" '{summary:$s}')" + ;; +*) + usage >&2 + exit 2 + ;; +esac diff --git a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh new file mode 100644 index 0000000..5f1547e --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts +# Live-agent-gate actor entrypoint (gate 5 in .tasknotes/plan.md, bounded live proof): a real +# Herdr server plus real Claude Code / Codex CLIs. Substrate has no Kubernetes-Secret-equivalent +# volume/env mechanism for an actor (see docs/spikes/substrate-workspace-adapter.md, +# "Credential-injection gap"), so credentials are fetched over the network from a small in-cluster +# server, reachable only because this actor's narrow EgressPolicy allows exactly that server's +# ClusterIP -- the same CIDR-scoped access-control mechanism gate 4 (dev-service) proved actually +# enforces (a non-allowed destination gets a clean 403), reused here as the auth boundary rather +# than inventing a new one. Never echoed, never written to a template, never logged. +set -eu +mkdir -p "${HOME}" "${HOME}/.claude" "${CODEX_HOME}" + +if [[ -n ${CRED_SERVER-} ]]; then + curl -fsS "http://${CRED_SERVER}/claude-token" -o "${HOME}/.claude-oauth-token" + chmod 600 "${HOME}/.claude-oauth-token" + CLAUDE_CODE_OAUTH_TOKEN="$(tr -d ' \r\n' <"${HOME}/.claude-oauth-token")" + export CLAUDE_CODE_OAUTH_TOKEN + curl -fsS "http://${CRED_SERVER}/codex-auth.json" -o "${CODEX_HOME}/auth.json" + chmod 600 "${CODEX_HOME}/auth.json" +fi + +# Claude Code: onboarding done, workspace trusted, bypass-permissions warning accepted. +if [[ ! -s "${HOME}/.claude.json" ]]; then + jq -n --arg p "${WORKSPACE_PATH}" '{ + hasCompletedOnboarding: true, + numStartups: 1, + theme: "dark", + projects: {($p): {hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true, allowedTools: []}} + }' >"${HOME}/.claude.json" +fi +[[ -s "${HOME}/.claude/settings.json" ]] || echo '{"skipDangerousModePermissionPrompt": true}' >"${HOME}/.claude/settings.json" + +# Codex: trust the workspace. +if [[ ! -s "${CODEX_HOME}/config.toml" ]]; then + printf '[projects."%s"]\ntrust_level = "trusted"\n' "${WORKSPACE_PATH}" >"${CODEX_HOME}/config.toml" +fi +grep -q '^\[notice\]' "${CODEX_HOME}/config.toml" || printf '\n[notice]\nhide_rate_limit_model_nudge = true\n' >>"${CODEX_HOME}/config.toml" + +mkdir -p "${WORKSPACE_PATH}" +[[ -d "${WORKSPACE_PATH}/.git" ]] || git -C "${WORKSPACE_PATH}" init -q + +echo "herdr $(herdr --version) server starting (HOME=${HOME} session=${HERDR_SESSION})" +herdr --session "${HERDR_SESSION}" server & +HERDR_PID=$! + +for _ in $(seq 1 60); do + herdr --session "${HERDR_SESSION}" status server >/dev/null 2>&1 && break + sleep 0.5 +done + +shell_ws=$(herdr --session "${HERDR_SESSION}" workspace create --label shell --cwd "${WORKSPACE_PATH}") +shell_pane=$(echo "${shell_ws}" | jq -r '.result.root_pane.pane_id') + +EXEC_SHIM_PANE_ID="${shell_pane}" HERDR_SESSION="${HERDR_SESSION}" node "${EXEC_SHIM}" & + +wait "${HERDR_PID}" diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js new file mode 100644 index 0000000..01250e7 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -0,0 +1,67 @@ +// Minimal generic command executor for the preview-gate spike: POST /run { command } pastes +// `command` as literal text into a real Herdr shell pane via `herdr pane run` (fire-and-forget; +// the pane is a real bash shell, so this is a real shell write, not a purpose-built edit +// endpoint). GET /read returns the pane's current terminal buffer (`herdr pane read`), since +// `pane run` itself never captures output. Stands in for a credentialed native agent's own Bash +// tool -- see entrypoint.sh and docs/spikes/substrate-workspace-adapter.md for why a real agent +// could not be used here. +// Listens on port 8090, separate from the Vite dev server's port 80. Reached only through +// atenet-router's arbitrary-port CONNECT tunnel with the ate-target-actor header (see +// docs/api-guide.md "Workload Connectivity"), from test orchestration on the host -- never +// through the previewed route a browser uses (port 80 via the NGINX header-proxy). +'use strict'; +const http = require('node:http'); +const { execFile } = require('node:child_process'); + +const PANE_ID = process.env.EXEC_SHIM_PANE_ID; +const SESSION = process.env.HERDR_SESSION; +if (!PANE_ID || !SESSION) { + console.error('exec-shim: EXEC_SHIM_PANE_ID and HERDR_SESSION are required'); + process.exit(1); +} + +function herdr(args, res) { + execFile('herdr', ['--session', SESSION, ...args], (err, stdout, stderr) => { + if (err) { + res.writeHead(502).end(String(err)); + return; + } + res + .writeHead(200, { 'content-type': 'application/json' }) + .end(JSON.stringify({ ok: true, stdout, stderr })); + }); +} + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/read') { + herdr(['pane', 'read', PANE_ID], res); + return; + } + if (req.method !== 'POST' || req.url !== '/run') { + res.writeHead(404).end(); + return; + } + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 65536) req.destroy(); + }); + req.on('end', () => { + let command; + try { + command = JSON.parse(body).command; + } catch { + res.writeHead(400).end('invalid json'); + return; + } + if (typeof command !== 'string' || !command) { + res.writeHead(400).end('missing command'); + return; + } + herdr(['pane', 'run', PANE_ID, command], res); + }); +}); + +server.listen(8090, '0.0.0.0', () => { + console.log('exec-shim listening on :8090, pane', PANE_ID); +}); From 4514cda7b5a79024411094020a7b58f9c0661599 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:25:46 +0000 Subject: [PATCH 08/30] fix: repair Gate 5 workspace adapter harness --- backend/scripts/gate5_setup.py | 615 ++++++++++++++++++ backend/src/mainloop/runtime/substrate.py | 421 +++++++++++- .../kubectl-ate-workers-pinned-cdac9ba.json | 25 + backend/tests/runtime/test_contracts.py | 25 + backend/tests/runtime/test_gate5_setup.py | 389 +++++++++++ backend/tests/runtime/test_substrate.py | 534 ++++++++++++++- docs/spikes/substrate-workspace-adapter.md | 61 +- .../egress-tool/main.go | 46 +- .../egress-tool/main_test.go | 28 + .../k8s/cred-server.yaml.tmpl | 71 -- .../k8s/live-agent-gate-template.yaml.tmpl | 13 +- .../live-agent-image/Dockerfile | 7 +- .../live-agent-image/entrypoint.sh | 44 +- .../live-agent-image/exec-shim.js | 22 + 14 files changed, 2181 insertions(+), 120 deletions(-) create mode 100644 backend/scripts/gate5_setup.py create mode 100644 backend/tests/runtime/fixtures/substrate/kubectl-ate-workers-pinned-cdac9ba.json create mode 100644 backend/tests/runtime/test_gate5_setup.py create mode 100644 spikes/substrate-workspace-adapter/egress-tool/main_test.go delete mode 100644 spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py new file mode 100644 index 0000000..2271154 --- /dev/null +++ b/backend/scripts/gate5_setup.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +r""" +Gate 5 (native-session continuity, .tasknotes/plan.md) credential-free harness: registers +the atespace, resolves and applies the WorkerPool, creates a *versioned* ActorTemplate and +waits for its golden snapshot, then creates/resumes one actor and waits for it to become +RUNNING with its persistent control service (Herdr) confirmed ready -- all without a +provider credential, a credential server, or a native Claude/Codex session. + +Implements recovery step 2 of .tasknotes/gate5-review-and-recovery-plan-2026-09-22.md. That +review found the prior harness applied an unresolved `ko://` worker image, never registered +the atespace at the API level (a Kubernetes Namespace of the same name is not an atespace), +checked for an existing ActorTemplate with the atespace embedded in the name instead of the +CLI's required `-a` flag, and printed success once a log line appeared even when that +happened before the read that mattered -- while the underlying golden-snapshot failure was a +credential fetch built into the shared, immutable template, which this script's manifest no +longer has (see spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh). + +Deliberately out of scope here (recovery plan steps 3-4, a separate task): fetching a real +credential, attaching it to a running actor, and starting a native Claude/Codex session. + +Prerequisites: + kubectl, kubectl-ate, and ko built from the pinned Substrate checkout. + A running kind-substrate-preview cluster with the ate-system + agentgateway dataplane + installed (this script accepts only the exact kind-substrate-preview context). + The live-agent-gate image already built and pushed (see live-agent-image/), its digest + passed with --image. + +Usage: + cd backend + uv run python scripts/gate5_setup.py \\ + --context kind-substrate-preview --kubeconfig /tmp/substrate-preview-kubeconfig \\ + --ate-cli /tmp/substrate-preview-src/bin/kubectl-ate \\ + --ko /tmp/substrate-preview-src/bin/ko \\ + --substrate-src /tmp/substrate-preview-src \\ + --atespace live-agent-gate --template-version v1 \\ + --image localhost:5001/live-agent-gate@sha256:... \\ + --manifest ../spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl \\ + --state-file /tmp/gate5-run-state.json \\ + --egress-tool /tmp/substrate-preview-src/bin/mainloop-egress-tool \\ + --egress-deny-all + +Re-running with the same --state-file reconciles the persisted actor uid against the +cluster's current state rather than blindly creating or resuming; a name collision with a +*different* uid is refused, not silently overwritten. +""" + +import argparse +import asyncio +import contextlib +import http.client +import json +import os +import re +import socket +import string +import subprocess # nosec B404 - drives trusted local kubectl/ko/egress-tool binaries, argv only +import sys +import tempfile +import time +import uuid +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from mainloop.runtime.substrate import ( # noqa: E402 + ActorFailedToStart, + ActorState, + GoldenState, + IdentityConflict, + IdentityOutcome, + SubstrateControl, + TransportError, + reconcile_actor_identity, + wait_for_actor_health, + wait_for_actor_running, + wait_for_eligible_worker, + wait_for_golden_snapshot, +) + +PINNED_SUBSTRATE_COMMIT = "cdac9baef81dd319b46086d695266e6161e9e592" +WORKER_NAMESPACE = "live-agent-gate" +WORKER_SELECTOR = "workload=live-agent-gate" +WORKER_SANDBOX_CLASS = "gvisor" +ACTOR_SHIM_PORT = 8090 + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--context", required=True) + p.add_argument("--kubeconfig", required=True) + p.add_argument("--ate-cli", default="kubectl-ate") + p.add_argument("--ko", default="ko") + p.add_argument("--substrate-src", required=True) + p.add_argument("--router-port", type=int, default=18091) + p.add_argument("--atespace", required=True) + p.add_argument( + "--template-version", + required=True, + help='e.g. "v2" -- never reuse one whose golden snapshot failed', + ) + p.add_argument( + "--image", + required=True, + help="already-built and pushed image digest, e.g. localhost:5001/live-agent-gate@sha256:...", + ) + p.add_argument( + "--manifest", required=True, help="path to live-agent-gate-template.yaml.tmpl" + ) + p.add_argument("--bucket-name", default="ate-snapshots") + p.add_argument("--actor-name", default="claude-gate5") + p.add_argument("--state-file", required=True) + p.add_argument("--golden-timeout", type=float, default=300) + p.add_argument("--worker-timeout", type=float, default=120) + p.add_argument("--actor-timeout", type=float, default=120) + p.add_argument("--readiness-timeout", type=float, default=60) + p.add_argument("--egress-tool", required=True) + egress = p.add_mutually_exclusive_group(required=True) + egress.add_argument("--egress-cidr", help="CIDR to allow") + egress.add_argument("--egress-allow-all", action="store_true") + egress.add_argument("--egress-deny-all", action="store_true") + return p.parse_args() + + +def load_state(path: str) -> dict: + if not os.path.exists(path): + return {} + with open(path) as f: + return json.load(f) + + +def save_state(path: str, state: dict) -> None: + tmp = f"{path}.tmp" + with open(tmp, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, path) + + +def render_manifest( + path: str, *, atespace: str, template_name: str, bucket_name: str, image: str +) -> list[str]: + """Substitutes the template's ${ATESPACE}/${TEMPLATE_NAME}/${BUCKET_NAME} placeholders + and the __IMAGE__ marker, then splits the multi-document YAML on its own '---' + separators. Returns [namespace_and_workerpool_doc, actor_template_doc].""" + with open(path) as f: + raw = f.read() + rendered = ( + string.Template(raw) + .safe_substitute( + ATESPACE=atespace, TEMPLATE_NAME=template_name, BUCKET_NAME=bucket_name + ) + .replace("__IMAGE__", image) + ) + docs = [d for d in rendered.split("\n---\n") if d.strip()] + if len(docs) != 3: + raise RuntimeError( + f"expected 3 YAML documents (Namespace, WorkerPool, ActorTemplate) in {path}, got {len(docs)}" + ) + namespace_and_workerpool = f"{docs[0]}\n---\n{docs[1]}\n" + return [namespace_and_workerpool, docs[2] + "\n"] + + +def verify_substrate_source(source: str, *, runner=subprocess.run) -> str: + """Require the exact source checkout that supplies the pinned ``ko`` module.""" + root = Path(source).expanduser().resolve() + if not (root / ".git").exists(): + raise RuntimeError(f"--substrate-src is not a git checkout: {root}") + for required in ("go.mod", ".ko.yaml"): + if not (root / required).is_file(): + raise RuntimeError(f"--substrate-src is missing {required}: {root}") + result = runner( + ["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + timeout=15, + ) + commit = result.stdout.strip() + if commit != PINNED_SUBSTRATE_COMMIT: + raise RuntimeError( + f"--substrate-src must be pinned at {PINNED_SUBSTRATE_COMMIT}; found {commit!r}" + ) + return str(root) + + +def get_cluster_identity( + *, context: str, kubeconfig: str, runner=subprocess.run +) -> dict[str, str]: + """Identify the selected cluster by its API URL and kube-system namespace UID.""" + base = ["kubectl", "--context", context, "--kubeconfig", kubeconfig] + config = runner( + [*base, "config", "view", "--minify", "-o", "json"], + capture_output=True, + text=True, + check=True, + timeout=20, + ) + config_doc = json.loads(config.stdout) + context_name = (config_doc.get("contexts") or [{}])[0].get("name") + api_server = ((config_doc.get("clusters") or [{}])[0].get("cluster") or {}).get( + "server" + ) + if context_name != context or not api_server: + raise RuntimeError( + f"kubeconfig did not resolve the requested context {context!r} to an API server" + ) + namespace = runner( + [*base, "get", "namespace", "kube-system", "-o", "json"], + capture_output=True, + text=True, + check=True, + timeout=20, + ) + namespace_uid = (json.loads(namespace.stdout).get("metadata") or {}).get("uid") + if not namespace_uid: + raise RuntimeError("kube-system namespace response is missing metadata.uid") + return {"api_server_url": api_server, "kube_system_namespace_uid": namespace_uid} + + +def prepare_run_state(args: argparse.Namespace, cluster: dict[str, str]) -> dict: + """Validate or persist the run intent before any cluster create/apply call.""" + if not re.fullmatch(r".+@sha256:[0-9a-fA-F]{64}", args.image): + raise RuntimeError("--image must be pinned by a full @sha256 digest") + template_name = f"live-agent-gate-{args.template_version}" + identity = { + "context": args.context, + "cluster_identity": cluster, + "atespace": args.atespace, + "template_name": template_name, + "image_digest": args.image, + "actor_name": args.actor_name, + } + state = load_state(args.state_file) + if state: + missing = {"run_id", "template_uid", "actor_uid"} - state.keys() + mismatch = { + key: (state.get(key), value) + for key, value in identity.items() + if state.get(key) != value + } + if missing or mismatch: + details = [] + if missing: + details.append(f"missing identity fields {sorted(missing)}") + if mismatch: + details.append(f"requested identity differs: {mismatch}") + raise RuntimeError( + "state file does not match this run (" + "; ".join(details) + ")" + ) + return state + + state = { + **identity, + "run_id": str(uuid.uuid4()), + "template_uid": None, + "actor_uid": None, + } + save_state(args.state_file, state) + return state + + +def apply_worker_pool( + doc: str, + *, + kubeconfig: str, + context: str, + ko: str, + substrate_src: str, + runner=subprocess.run, +) -> None: + """Resolve the WorkerPool's `ko://...` workerImage and apply it (and the Namespace doc + it's paired with) via `ko resolve | kubectl apply`. An unresolved ko:// reference + reaches the pod as an InvalidImageName, not a manifest-time error, so this step must not + be skipped even though `kubectl apply` alone would exit 0.""" + substrate_src = verify_substrate_source(substrate_src, runner=runner) + ko_docker_repo = os.environ.get("KO_DOCKER_REPO") + if not ko_docker_repo: + raise RuntimeError( + "KO_DOCKER_REPO must be set (e.g. localhost:5001 for kind) to resolve ko:// images" + ) + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f: + f.write(doc) + doc_path = f.name + try: + resolved = ( + runner( # nosec B603 - argv list, ko path is an operator-supplied flag + [ko, "resolve", "-f", doc_path], + env={**os.environ, "KO_DOCKER_REPO": ko_docker_repo}, + capture_output=True, + text=True, + check=True, + timeout=180, + cwd=substrate_src, + ) + ) + runner( # nosec - argv list; kubectl is expected on PATH like git/uv + [ + "kubectl", + "--context", + context, + "--kubeconfig", + kubeconfig, + "apply", + "-f", + "-", + ], + input=resolved.stdout, + capture_output=True, + text=True, + check=True, + timeout=60, + ) + finally: + os.unlink(doc_path) + + +def run_egress_tool(args: argparse.Namespace) -> None: + cmd = [ + args.egress_tool, + "--kubeconfig", + args.kubeconfig, + "--context", + args.context, + "--atespace", + args.atespace, + "--actor", + args.actor_name, + ] + if args.egress_deny_all: + cmd.append("--deny-all") + elif args.egress_allow_all: + cmd.append("--allow-all") + else: + cmd += ["--cidr", args.egress_cidr] + subprocess.run( + cmd, check=True, timeout=60 + ) # nosec B603 - argv list, path is an operator-supplied flag + + +@contextlib.contextmanager +def actor_router_tunnel(args: argparse.Namespace): + """Forward the Substrate router through the explicitly selected kube context.""" + command = [ + "kubectl", + "--context", + args.context, + "--kubeconfig", + args.kubeconfig, + "port-forward", + "--address", + "127.0.0.1", + "--namespace", + "ate-system", + "service/atenet-router", + f"{args.router_port}:80", + ] + process = ( + subprocess.Popen( # nosec B603 - fixed kubectl argv, explicit kube context + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + ) + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + if process.poll() is not None: + output = process.communicate()[0] + raise RuntimeError(f"router port-forward exited early: {output[-500:]}") + try: + with socket.create_connection( + ("127.0.0.1", args.router_port), timeout=0.2 + ): + break + except OSError: + time.sleep(0.2) + else: + raise RuntimeError("router port-forward did not become ready within 20s") + yield args.router_port + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + if process.stdout: + process.stdout.close() + + +def actor_health_check( + *, port: int, atespace: str, actor_name: str, timeout_s: float = 2 +) -> bool: + """Call the actor's non-default shim port through Substrate's HTTP CONNECT route.""" + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout_s) + connection.set_tunnel( + f"actor-upstream:{ACTOR_SHIM_PORT}", + headers={"ate-target-actor": f"{atespace}/{actor_name}"}, + ) + try: + connection.request("GET", "/healthz") + response = connection.getresponse() + response.read() + return response.status == 200 + except (OSError, http.client.HTTPException): + return False + finally: + connection.close() + + +async def ensure_golden_template( + control: SubstrateControl, + args: argparse.Namespace, + template_name: str, + actor_template_doc: str, + state: dict, +) -> str: + existing = await control.get_actor_template(args.atespace, template_name) + if existing is None: + print(f"-- creating actor-template {args.atespace}/{template_name}") + existing = await control.create_actor_template( + args.atespace, template_name, actor_template_doc + ) + if existing.atespace != args.atespace or existing.name != template_name: + raise IdentityConflict( + "actor-template create/read returned a different identity" + ) + if not existing.uid: + raise TransportError("actor-template response is missing metadata.uid") + if state.get("template_uid") and state["template_uid"] != existing.uid: + raise IdentityConflict( + f"actor-template {args.atespace}/{template_name} uid changed from " + f"{state['template_uid']} to {existing.uid}" + ) + containers = existing.raw.get("containers") or [] + template_images = [ + item.get("image") for item in containers if isinstance(item, dict) + ] + if args.image not in template_images: + raise IdentityConflict( + f"actor-template {args.atespace}/{template_name} does not use requested " + "image digest" + ) + state["template_uid"] = existing.uid + save_state(args.state_file, state) + + if existing.golden_state is GoldenState.FAILED: + raise SystemExit( + f"actor-template {args.atespace}/{template_name} already failed its golden " + f"snapshot ({existing.error_message}); ActorTemplates are immutable -- pass a " + "new --template-version rather than reusing this one" + ) + if existing.golden_state is not GoldenState.PENDING: + print( + f"-- actor-template {args.atespace}/{template_name} already exists (state={existing.golden_state.value}), awaiting its golden snapshot" + ) + + record = await wait_for_golden_snapshot( + control, args.atespace, template_name, timeout_s=args.golden_timeout + ) + print(f"-- golden snapshot ready: {record.golden_tag}") + return record.uid or existing.uid + + +async def ensure_actor( + control: SubstrateControl, + args: argparse.Namespace, + template_name: str, + template_uid: str, + state: dict, +) -> str: + """Reconciles any persisted identity against the cluster's current state before + deciding whether to create or resume, then waits for RUNNING. Returns the actor uid. + """ + persisted_uid = state.get("actor_uid") + live = await control.get_actor(args.atespace, args.actor_name) + outcome = reconcile_actor_identity(persisted_uid, live) + + if outcome is IdentityOutcome.UNOWNED: + raise IdentityConflict( + f"actor {args.atespace}/{args.actor_name} already exists with uid {live.uid}, " + f"but {args.state_file} has no actor uid; refusing to adopt it. Use a new " + "--actor-name or reconcile the state file explicitly" + ) + if outcome is IdentityOutcome.DIVERGED: + raise IdentityConflict( + f"actor {args.atespace}/{args.actor_name} exists with uid {live.uid}, but " + f"{args.state_file} recorded {persisted_uid} from a prior run -- refusing to " + "resume or recreate it; reconcile manually or use a different --actor-name" + ) + + if outcome is IdentityOutcome.ABSENT: + print(f"-- creating actor {args.atespace}/{args.actor_name}") + actor = await control.create_actor( + args.atespace, args.actor_name, template=template_name + ) + if actor.current_actor_template_uid != template_uid: + raise IdentityConflict( + f"created actor {args.atespace}/{args.actor_name} references template uid " + f"{actor.current_actor_template_uid!r}, expected {template_uid!r}" + ) + state["actor_uid"] = actor.uid + save_state(args.state_file, state) + else: + actor = live + if actor.current_actor_template_uid != template_uid: + raise IdentityConflict( + f"actor {args.atespace}/{args.actor_name} references template uid " + f"{actor.current_actor_template_uid!r}, requested {template_uid!r}; " + "refusing to resume it" + ) + if actor.state in {ActorState.CRASHED, ActorState.DELETING}: + raise ActorFailedToStart( + f"actor {args.atespace}/{args.actor_name} is {actor.state.value}; " + "choose an explicit revert or a new --actor-name before retrying" + ) + print( + f"-- actor {args.atespace}/{args.actor_name} already exists (uid matches persisted identity), state={actor.state.value}" + ) + + if actor.state in {ActorState.SUSPENDED, ActorState.PAUSED}: + print(f"-- resuming actor {args.atespace}/{args.actor_name}") + await control.resume_actor(args.atespace, args.actor_name) + + actor = await wait_for_actor_running( + control, args.atespace, args.actor_name, timeout_s=args.actor_timeout + ) + print(f"-- actor RUNNING: uid={actor.uid}") + return actor.uid + + +async def async_main(args: argparse.Namespace) -> None: + substrate_src = verify_substrate_source(args.substrate_src) + cluster = get_cluster_identity(context=args.context, kubeconfig=args.kubeconfig) + state = prepare_run_state(args, cluster) + control = SubstrateControl( + kubeconfig=args.kubeconfig, context=args.context, cli=args.ate_cli + ) + template_name = f"live-agent-gate-{args.template_version}" + + print(f"-- registering atespace {args.atespace}") + await control.ensure_atespace(args.atespace) + + namespace_and_workerpool_doc, actor_template_doc = render_manifest( + args.manifest, + atespace=args.atespace, + template_name=template_name, + bucket_name=args.bucket_name, + image=args.image, + ) + print("-- resolving and applying the Namespace + WorkerPool") + apply_worker_pool( + namespace_and_workerpool_doc, + kubeconfig=args.kubeconfig, + context=args.context, + ko=args.ko, + substrate_src=substrate_src, + ) + + print( + f"-- waiting for an eligible worker in namespace={WORKER_NAMESPACE}, " + f"selector={WORKER_SELECTOR}, sandbox={WORKER_SANDBOX_CLASS}" + ) + await wait_for_eligible_worker( + control, + WORKER_NAMESPACE, + WORKER_SELECTOR, + WORKER_SANDBOX_CLASS, + timeout_s=args.worker_timeout, + ) + + template_uid = await ensure_golden_template( + control, args, template_name, actor_template_doc, state + ) + await ensure_actor(control, args, template_name, template_uid, state) + + print("-- confirming current control-service health through the actor route") + with actor_router_tunnel(args) as route_port: + await wait_for_actor_health( + lambda: actor_health_check( + port=route_port, + atespace=args.atespace, + actor_name=args.actor_name, + ), + timeout_s=args.readiness_timeout, + ) + + print("-- applying the actor's EgressPolicy") + run_egress_tool(args) + + print() + print(f"== actor {args.atespace}/{args.actor_name} is RUNNING, credential-free ==") + print( + "Credential injection and native-agent session launch are separate, still-gated steps" + ) + print("(recovery plan steps 3-4) -- not performed by this script.") + + +def main() -> None: + args = parse_args() + if args.context != "kind-substrate-preview": + raise SystemExit( + f"refusing to target context {args.context!r}: expected the dedicated " + "kind-substrate-preview context" + ) + try: + asyncio.run(async_main(args)) + except (subprocess.CalledProcessError, RuntimeError) as exc: + print(f"gate5_setup failed: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py index 5ba0dad..70158b2 100644 --- a/backend/src/mainloop/runtime/substrate.py +++ b/backend/src/mainloop/runtime/substrate.py @@ -14,8 +14,10 @@ import json import logging import shlex +import time from dataclasses import dataclass from enum import StrEnum +from typing import Callable from mainloop.config import settings @@ -99,6 +101,96 @@ def _actor_from_json(doc: dict) -> ActorRecord: ) +class GoldenState(StrEnum): + """Mirrors the ``ateapipb.GoldenSnapshotStatus`` lifecycle for an ActorTemplate.""" + + PENDING = "pending" + READY = "ready" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class ActorTemplateRecord: + """Parsed subset of an ``ateapipb.ActorTemplate``, including golden-snapshot status.""" + + atespace: str + name: str + uid: str | None + golden_state: GoldenState + golden_tag: str | None + error_message: str + raw: dict + + +def _actor_template_from_json(doc: dict) -> ActorTemplateRecord: + metadata = doc.get("metadata") or {} + status = doc.get("status") or {} + golden = status.get("goldenSnapshotStatus") or {} + error_message = golden.get("errorMessage", "") + golden_tag_ref = golden.get("goldenTag") + golden_tag = golden_tag_ref.get("name") if golden_tag_ref else None + if error_message: + golden_state = GoldenState.FAILED + elif golden_tag: + golden_state = GoldenState.READY + else: + golden_state = GoldenState.PENDING + return ActorTemplateRecord( + atespace=metadata.get("atespace", ""), + name=metadata.get("name", ""), + uid=metadata.get("uid"), + golden_state=golden_state, + golden_tag=golden_tag, + error_message=error_message, + raw=doc, + ) + + +class WaitTimeout(RuntimeError): + """A bounded poll reached its deadline without observing a terminal outcome. Callers + must treat this as failure, never as an implied success.""" + + +class GoldenSnapshotFailed(RuntimeError): + """The template controller reported an error while building the golden snapshot.""" + + +class GoldenSnapshotTimeout(WaitTimeout): + """The golden snapshot did not reach a terminal state within the bound. Not success.""" + + +class NoEligibleWorker(WaitTimeout): + """No worker registered for the atespace within the bound. Not success.""" + + +class IdentityOutcome(StrEnum): + """Result of reconciling a persisted actor UID against the cluster's current state, + before a retry decides whether to create, resume, or refuse to touch an actor.""" + + ABSENT = "absent" # no live actor with this name; safe to create fresh + UNOWNED = "unowned" # live actor exists but no actor uid was persisted + MATCHES = "matches" # live actor's uid matches the persisted identity + DIVERGED = "diverged" # live actor exists under this name with a different uid + + +class IdentityConflict(RuntimeError): + """A live actor exists under the expected name but with a different uid than the + identity persisted from a prior run. Recreating or resuming it blindly could operate + on someone else's actor; this must be surfaced, not silently resolved.""" + + +def reconcile_actor_identity( + persisted_uid: str | None, live: ActorRecord | None +) -> IdentityOutcome: + if live is None: + return IdentityOutcome.ABSENT + if persisted_uid is None: + return IdentityOutcome.UNOWNED + if persisted_uid == live.uid: + return IdentityOutcome.MATCHES + return IdentityOutcome.DIVERGED + + class SubstrateControl: """Wraps ``kubectl ate`` for one (kubeconfig, context) pair. No retries, no caching.""" @@ -123,16 +215,22 @@ def _base_args(self) -> list[str]: args += ["--context", self.context] return args - async def _exec(self, args: list[str], timeout: float = 45) -> ExecResult: + async def _exec( + self, args: list[str], timeout: float = 45, stdin: str | None = None + ) -> ExecResult: command = self._base_args() + args try: proc = await asyncio.create_subprocess_exec( *command, + stdin=asyncio.subprocess.PIPE if stdin is not None else None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: - out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + out, err = await asyncio.wait_for( + proc.communicate(stdin.encode() if stdin is not None else None), + timeout=timeout, + ) except TimeoutError as exc: proc.kill() await proc.wait() @@ -240,7 +338,326 @@ async def _require(self, atespace: str, name: str, res: ExecResult) -> ActorReco raise TransportError(f"actor {atespace}/{name} not found after operation") return actor + async def atespace_exists(self, name: str) -> bool: + res = await self._exec(["get", "atespaces", name, "-o", "json"]) + if res.exit_code == 0: + return True + if "not found" in res.stderr.lower(): + return False + raise TransportError( + f"get atespace failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + + async def ensure_atespace(self, name: str) -> None: + """Register the atespace via the control-plane API. Idempotent: a Kubernetes + Namespace of the same name is a separate, unrelated object and does not register + an atespace, so this call is required before an ActorTemplate or actor can be + created in it (``create actor-template``/``create actor`` require it to exist). + """ + res = await self._exec(["create", "atespace", name, "-o", "json"]) + if res.exit_code == 0: + return + if "already exists" in res.stderr.lower() or "AlreadyExists" in res.stderr: + return + raise TransportError( + f"create atespace failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + + async def get_actor_template( + self, atespace: str, name: str + ) -> ActorTemplateRecord | None: + res = await self._exec( + ["get", "actor-template", name, "-a", atespace, "-o", "json"] + ) + if res.exit_code != 0: + if "not found" in res.stderr.lower() or "NotFound" in res.stderr: + return None + raise TransportError( + f"get actor-template failed (exit {res.exit_code}): " + f"{res.stderr.strip()[-300:]}" + ) + text = res.stdout.strip() + if not text: + return None + return _actor_template_from_json(json.loads(text)) + + async def create_actor_template( + self, atespace: str, name: str, manifest: str + ) -> ActorTemplateRecord: + """``manifest`` is the protojson-shaped ActorTemplate document; its own + ``metadata.atespace``/``metadata.name`` select where it is created. The atespace + must already exist (``ensure_atespace``); templates are immutable, so a changed + manifest needs a new name, never a reused one. The pinned CLI prints a table by + default, so JSON is requested explicitly. If the create result is uncertain, read + the named template back before reporting failure; a later invocation also starts + with that read and cannot blindly repeat the create.""" + try: + res = await self._exec( + ["create", "actor-template", "-f", "-", "-o", "json"], + stdin=manifest, + ) + except TransportError as create_error: + return await self._read_uncertain_actor_template_create( + atespace, name, create_error + ) + + text = res.stdout.strip() + if res.exit_code != 0: + reason = ( + f"create actor-template failed (exit {res.exit_code}): " + f"{res.stderr.strip()[-300:]}" + ) + return await self._read_uncertain_actor_template_create( + atespace, name, TransportError(reason) + ) + if not text: + return await self._read_uncertain_actor_template_create( + atespace, + name, + TransportError("create actor-template returned no output"), + ) + try: + return _actor_template_from_json(json.loads(text)) + except (json.JSONDecodeError, TypeError) as parse_error: + return await self._read_uncertain_actor_template_create( + atespace, + name, + TransportError( + f"create actor-template returned invalid JSON: {parse_error}" + ), + ) + + async def _read_uncertain_actor_template_create( + self, atespace: str, name: str, create_error: TransportError + ) -> ActorTemplateRecord: + try: + existing = await self.get_actor_template(atespace, name) + except TransportError as read_error: + raise TransportError( + f"actor-template create outcome is uncertain and read-back failed: " + f"{read_error}" + ) from create_error + if existing is not None: + return existing + raise create_error + + async def get_eligible_workers( + self, namespace: str, selector: str, sandbox_class: str + ) -> int: + """Count active workers with free actor capacity matching this WorkerPool. + + The pinned CLI's ``-a`` filter means "already hosting an actor in an atespace", + so it excludes the idle worker needed before the first actor exists. Namespace, + pool labels, and sandbox class identify the intended pool instead. + """ + res = await self._exec( + [ + "get", + "workers", + "-n", + namespace, + "-l", + selector, + "--sandbox-class", + sandbox_class, + "-o", + "json", + ] + ) + if res.exit_code != 0: + raise TransportError( + f"get workers failed (exit {res.exit_code}): {res.stderr.strip()[-300:]}" + ) + text = res.stdout.strip() + if not text: + return 0 + try: + doc = json.loads(text) + except json.JSONDecodeError as exc: + raise TransportError(f"get workers returned invalid JSON: {exc}") from exc + if not isinstance(doc, dict) or "workers" not in doc: + raise TransportError("get workers JSON is missing its 'workers' list") + workers = doc["workers"] + if not isinstance(workers, list): + raise TransportError("get workers JSON field 'workers' is not a list") + return sum( + _worker_is_eligible( + worker, + namespace=namespace, + selector=selector, + sandbox_class=sandbox_class, + ) + for worker in workers + ) + def cli_quote(*parts: str) -> str: """Only for logging/evidence; commands are passed as argv, never through a shell.""" return " ".join(shlex.quote(p) for p in parts) + + +def _worker_is_eligible( + worker: object, *, namespace: str, selector: str, sandbox_class: str +) -> bool: + if not isinstance(worker, dict): + return False + if worker.get("workerNamespace") != namespace: + return False + if worker.get("sandboxClass") != sandbox_class: + return False + key, separator, value = selector.partition("=") + if not separator or not key or not value: + raise ValueError(f"unsupported worker label selector: {selector!r}") + labels = worker.get("labels") or {} + if not isinstance(labels, dict) or labels.get(key) != value: + return False + + status = worker.get("status") or {} + if not isinstance(status, dict) or status.get("state") != "WORKER_STATE_ACTIVE": + return False + capacity = status.get("capacity") or {} + allocated = status.get("allocated") or {} + if isinstance(capacity, dict) and capacity.get("actors") is not None: + allocated_actors = ( + allocated.get("actors", 0) if isinstance(allocated, dict) else 0 + ) + try: + if int(capacity["actors"]) - int(allocated_actors or 0) <= 0: + return False + except (TypeError, ValueError) as exc: + raise TransportError("worker actor capacity is not an integer") from exc + return True + + +async def wait_for_golden_snapshot( + control: SubstrateControl, + atespace: str, + name: str, + *, + timeout_s: float, + poll_interval_s: float = 3, + sleep=asyncio.sleep, + clock=time.monotonic, +) -> ActorTemplateRecord: + """Poll an ActorTemplate's golden-snapshot status to a terminal outcome. Raises + ``GoldenSnapshotFailed``/``GoldenSnapshotTimeout`` rather than returning normally on + anything but a completed golden tag -- a caller must never infer success from a log + line or from reaching this function without an exception being possible.""" + deadline = clock() + timeout_s + while True: + record = await control.get_actor_template(atespace, name) + if record is None: + raise TransportError( + f"actor-template {atespace}/{name} disappeared while awaiting its " + "golden snapshot" + ) + if record.golden_state is GoldenState.FAILED: + raise GoldenSnapshotFailed( + f"golden snapshot for {atespace}/{name} failed: {record.error_message}" + ) + if record.golden_state is GoldenState.READY: + return record + if clock() >= deadline: + raise GoldenSnapshotTimeout( + f"golden snapshot for {atespace}/{name} did not complete within " + f"{timeout_s}s (last state: {record.golden_state.value})" + ) + await sleep(poll_interval_s) + + +async def wait_for_eligible_worker( + control: SubstrateControl, + namespace: str, + selector: str, + sandbox_class: str, + *, + timeout_s: float, + poll_interval_s: float = 3, + sleep=asyncio.sleep, + clock=time.monotonic, +) -> int: + """Poll for at least one worker matching the intended WorkerPool. Raises on timeout rather + than proceeding to create an actor-template/actor against a pool with no capacity. + """ + deadline = clock() + timeout_s + while True: + count = await control.get_eligible_workers(namespace, selector, sandbox_class) + if count > 0: + return count + if clock() >= deadline: + raise NoEligibleWorker( + f"no eligible worker for namespace={namespace}, selector={selector}, " + f"sandbox_class={sandbox_class} within {timeout_s}s" + ) + await sleep(poll_interval_s) + + +class ActorHealthTimeout(WaitTimeout): + """The actor route did not return a healthy response within the readiness bound.""" + + +async def wait_for_actor_health( + health_check: Callable[[], bool], + *, + timeout_s: float, + poll_interval_s: float = 2, + sleep=asyncio.sleep, + clock=time.monotonic, +) -> None: + """Wait for a live actor-route ``/healthz`` check to succeed. + + A restored actor need not replay startup logs, so readiness is based on current service + health rather than a boot marker. + """ + deadline = clock() + timeout_s + while True: + if health_check(): + return + if clock() >= deadline: + raise ActorHealthTimeout( + f"actor /healthz did not return 200 within {timeout_s}s" + ) + await sleep(poll_interval_s) + + +class ActorFailedToStart(RuntimeError): + """An actor reached a terminal, non-running state (e.g. CRASHED) while awaiting + readiness. Distinct from ``WaitTimeout``: this is a reported failure, not a bound + running out.""" + + +_ACTOR_TERMINAL_FAILURE_STATES = frozenset({ActorState.CRASHED, ActorState.DELETING}) + + +async def wait_for_actor_running( + control: SubstrateControl, + atespace: str, + name: str, + *, + timeout_s: float, + poll_interval_s: float = 2, + sleep=asyncio.sleep, + clock=time.monotonic, +) -> ActorRecord: + """Poll an actor to RUNNING. Raises ``ActorFailedToStart`` on a terminal failure state + and ``WaitTimeout`` on exceeding the bound -- never returns normally except on RUNNING, + so a caller can never mistake "still starting" for success.""" + deadline = clock() + timeout_s + while True: + actor = await control.get_actor(atespace, name) + if actor is None: + raise TransportError( + f"actor {atespace}/{name} disappeared while awaiting readiness" + ) + if actor.state is ActorState.RUNNING: + return actor + if actor.state in _ACTOR_TERMINAL_FAILURE_STATES: + raise ActorFailedToStart( + f"actor {atespace}/{name} reached {actor.state.value} while awaiting readiness" + ) + if clock() >= deadline: + raise WaitTimeout( + f"actor {atespace}/{name} did not reach RUNNING within {timeout_s}s " + f"(last state: {actor.state.value})" + ) + await sleep(poll_interval_s) diff --git a/backend/tests/runtime/fixtures/substrate/kubectl-ate-workers-pinned-cdac9ba.json b/backend/tests/runtime/fixtures/substrate/kubectl-ate-workers-pinned-cdac9ba.json new file mode 100644 index 0000000..bce43a0 --- /dev/null +++ b/backend/tests/runtime/fixtures/substrate/kubectl-ate-workers-pinned-cdac9ba.json @@ -0,0 +1,25 @@ +{ + "workers": [ + { + "labels": { + "workload": "live-agent-gate" + }, + "metadata": { + "name": "worker-1" + }, + "sandboxClass": "gvisor", + "status": { + "allocated": { + "actors": 3 + }, + "capacity": { + "actors": 4 + }, + "state": "WORKER_STATE_ACTIVE" + }, + "workerNamespace": "live-agent-gate", + "workerPod": "worker-1-pod", + "workerPool": "live-agent-gate-workers" + } + ] +} diff --git a/backend/tests/runtime/test_contracts.py b/backend/tests/runtime/test_contracts.py index 9789d83..aaa0233 100644 --- a/backend/tests/runtime/test_contracts.py +++ b/backend/tests/runtime/test_contracts.py @@ -221,6 +221,31 @@ def test_takeover_can_retire_unsent_attempt_with_evidence(self): self.store.create_attempt(attempt("retry", generation=2), 2) self.assertEqual(len(self.store.attempts), 2) + def test_backend_restart_reconciles_persisted_recorded_attempt_before_retry(self): + # A restart leaves the durable attempt row intact. Ownership takeover is + # the in-memory contract's fake-backed model of loading that row under a + # new generation; no transport or database is involved. + recorded = self.store.create_attempt(attempt(), 1) + self.assertEqual(recorded.state, DeliveryState.RECORDED) + self.store.take_ownership(1) + + historical = self.store.attempts[0] + self.assertEqual(historical.state, DeliveryState.RECORDED) + self.assertEqual(historical.ownership_generation, 1) + with self.assertRaises(ContractError): + self.store.create_attempt(attempt("retry", generation=2), 2) + with self.assertRaises(ContractError): + self.store.reconcile(self.evidence("delivered"), 2) + + retired = self.store.reconcile(self.evidence("not_delivered"), 2) + self.assertEqual(retired.state, DeliveryState.FAILED) + self.assertEqual(retired.result, "not_delivered") + self.store.create_attempt(attempt("retry", generation=2), 2) + self.assertEqual( + [item.state for item in self.store.attempts], + [DeliveryState.FAILED, DeliveryState.RECORDED], + ) + def test_takeover_reconnect_deduplicates_source_event(self): original = self.store.ingest(attention(), 1) self.store.take_ownership(1) diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py new file mode 100644 index 0000000..fcfc2ac --- /dev/null +++ b/backend/tests/runtime/test_gate5_setup.py @@ -0,0 +1,389 @@ +"""Credential-free regressions for the gate-5 setup script's build and rerun identity.""" + +import json +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from mainloop.runtime.substrate import ( + ActorFailedToStart, + ActorRecord, + ActorState, + IdentityConflict, +) + +from scripts import gate5_setup + +FIXTURE_KUBECONFIG = "/fixture/kubeconfig" +FIXTURE_KO = "/fixture/substrate/bin/ko" + + +def completed(argv, returncode=0, stdout="", stderr=""): + return SimpleNamespace( + args=argv, returncode=returncode, stdout=stdout, stderr=stderr + ) + + +class Gate5SourceAndBuildTests(unittest.TestCase): + def make_source(self, root: Path) -> Path: + (root / ".git").mkdir(parents=True) + (root / "go.mod").write_text("module fixture\n") + (root / ".ko.yaml").write_text("defaultBaseImage: scratch\n") + return root + + def test_source_requires_pinned_checkout_and_required_files(self): + with tempfile.TemporaryDirectory() as temp_dir: + source = self.make_source(Path(temp_dir) / "substrate") + calls = [] + + def runner(argv, **kwargs): + calls.append(argv) + return completed( + argv, stdout=gate5_setup.PINNED_SUBSTRATE_COMMIT + "\n" + ) + + self.assertEqual( + gate5_setup.verify_substrate_source(str(source), runner=runner), + str(source), + ) + self.assertEqual(calls[0][:4], ["git", "-C", str(source), "rev-parse"]) + + def test_source_rejects_wrong_commit(self): + with tempfile.TemporaryDirectory() as temp_dir: + source = self.make_source(Path(temp_dir) / "substrate") + + def runner(argv, **kwargs): + return completed(argv, stdout="a" * 40) + + with self.assertRaisesRegex(RuntimeError, "must be pinned"): + gate5_setup.verify_substrate_source(str(source), runner=runner) + + def test_source_rejects_missing_go_module_files(self): + with tempfile.TemporaryDirectory() as temp_dir: + source = Path(temp_dir) / "substrate" + source.mkdir() + (source / ".git").mkdir() + with self.assertRaisesRegex(RuntimeError, "missing go.mod"): + gate5_setup.verify_substrate_source(str(source)) + + def test_ko_resolve_uses_pinned_source_cwd_and_explicit_kube_target(self): + with tempfile.TemporaryDirectory() as temp_dir: + source = self.make_source(Path(temp_dir) / "substrate") + calls = [] + + def runner(argv, **kwargs): + calls.append((argv, kwargs)) + if argv[0] == "git": + return completed( + argv, stdout=gate5_setup.PINNED_SUBSTRATE_COMMIT + "\n" + ) + if argv[1:3] == ["resolve", "-f"]: + return completed(argv, stdout="resolved-yaml") + return completed(argv) + + with patch.dict("os.environ", {"KO_DOCKER_REPO": "localhost:5001"}): + gate5_setup.apply_worker_pool( + "apiVersion: v1\n", + kubeconfig=FIXTURE_KUBECONFIG, + context="kind-substrate-preview", + ko=FIXTURE_KO, + substrate_src=str(source), + runner=runner, + ) + + ko_argv, ko_kwargs = calls[1] + self.assertEqual(ko_argv[:3], [FIXTURE_KO, "resolve", "-f"]) + self.assertEqual(ko_kwargs["cwd"], str(source)) + apply_argv, apply_kwargs = calls[2] + self.assertEqual( + apply_argv[:5], + [ + "kubectl", + "--context", + "kind-substrate-preview", + "--kubeconfig", + FIXTURE_KUBECONFIG, + ], + ) + self.assertEqual(apply_kwargs["input"], "resolved-yaml") + + def test_cluster_identity_uses_explicit_context_and_namespace_uid(self): + calls = [] + results = [ + json.dumps( + { + "contexts": [{"name": "kind-substrate-preview"}], + "clusters": [{"cluster": {"server": "https://127.0.0.1:45147"}}], + } + ), + json.dumps({"metadata": {"uid": "kube-system-uid"}}), + ] + + def runner(argv, **kwargs): + calls.append(argv) + return completed(argv, stdout=results.pop(0)) + + identity = gate5_setup.get_cluster_identity( + context="kind-substrate-preview", + kubeconfig=FIXTURE_KUBECONFIG, + runner=runner, + ) + self.assertEqual( + identity, + { + "api_server_url": "https://127.0.0.1:45147", + "kube_system_namespace_uid": "kube-system-uid", + }, + ) + self.assertTrue( + all("--context" in call and "--kubeconfig" in call for call in calls) + ) + + def test_actor_health_uses_actor_route_and_shim_port(self): + class FakeConnection: + def __init__(self, _host, _port, timeout): + self.timeout = timeout + self.tunnel = None + self.requested = None + + def set_tunnel(self, target, *, headers): + self.tunnel = (target, headers) + + def request(self, method, path): + self.requested = (method, path) + + def getresponse(self): + return SimpleNamespace(status=200, read=lambda: b"ok") + + def close(self): + pass + + connections = [] + + def make_connection(*args, **kwargs): + connection = FakeConnection(*args, **kwargs) + connections.append(connection) + return connection + + with patch.object(gate5_setup.http.client, "HTTPConnection", make_connection): + self.assertTrue( + gate5_setup.actor_health_check( + port=18091, + atespace="live-agent-gate", + actor_name="claude-gate5", + ) + ) + self.assertEqual( + connections[0].tunnel, + ( + "actor-upstream:8090", + {"ate-target-actor": "live-agent-gate/claude-gate5"}, + ), + ) + self.assertEqual(connections[0].requested, ("GET", "/healthz")) + + +class Gate5StateTests(unittest.TestCase): + def args(self, state_file: str, **overrides): + values = { + "context": "kind-substrate-preview", + "kubeconfig": FIXTURE_KUBECONFIG, + "atespace": "live-agent-gate", + "template_version": "v1", + "image": "localhost:5001/live-agent-gate@sha256:" + "a" * 64, + "actor_name": "claude-gate5", + "state_file": state_file, + } + values.update(overrides) + return SimpleNamespace(**values) + + def cluster(self, *, uid="cluster-uid"): + return { + "api_server_url": "https://127.0.0.1:45147", + "kube_system_namespace_uid": uid, + } + + def test_persists_run_intent_before_actor_or_template_uids_exist(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + args = self.args(path) + state = gate5_setup.prepare_run_state(args, self.cluster()) + stored = json.loads(Path(path).read_text()) + self.assertEqual(stored["run_id"], state["run_id"]) + self.assertEqual(stored["template_name"], "live-agent-gate-v1") + self.assertEqual(stored["actor_name"], "claude-gate5") + self.assertIsNone(stored["template_uid"]) + self.assertIsNone(stored["actor_uid"]) + self.assertEqual(stored["cluster_identity"], self.cluster()) + + def test_rerun_refuses_changed_cluster_identity(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + args = self.args(path) + gate5_setup.prepare_run_state(args, self.cluster()) + with self.assertRaisesRegex(RuntimeError, "requested identity differs"): + gate5_setup.prepare_run_state(args, self.cluster(uid="other-cluster")) + + def test_rerun_refuses_changed_template_request(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + gate5_setup.prepare_run_state(self.args(path), self.cluster()) + with self.assertRaisesRegex(RuntimeError, "template_name"): + gate5_setup.prepare_run_state( + self.args(path, template_version="v2"), self.cluster() + ) + + +class SetupControl: + def __init__(self, actor: ActorRecord | None): + self.actor = actor + self.created = [] + self.resumed = 0 + + async def get_actor(self, _atespace, _name): + return self.actor + + async def create_actor(self, atespace, name, *, template): + self.created.append((atespace, name, template)) + self.actor = ActorRecord( + atespace=atespace, + name=name, + uid="actor-new", + state=ActorState.SUSPENDED, + external_snapshot_uri=None, + current_actor_template_uid="template-new", + raw={}, + ) + return self.actor + + async def resume_actor(self, _atespace, _name): + self.resumed += 1 + self.actor = replace(self.actor, state=ActorState.RUNNING) + return self.actor + + +class Gate5ActorIdentityTests(unittest.TestCase): + def state(self, path, *, actor_uid="actor-1"): + state = { + "run_id": "run-1", + "actor_uid": actor_uid, + "actor_name": "claude-gate5", + "template_name": "live-agent-gate-v2", + "template_uid": "template-new", + } + gate5_setup.save_state(path, state) + return state + + def actor( + self, *, uid="actor-1", template_uid="template-new", state=ActorState.RUNNING + ): + return ActorRecord( + atespace="live-agent-gate", + name="claude-gate5", + uid=uid, + state=state, + external_snapshot_uri=None, + current_actor_template_uid=template_uid, + raw={}, + ) + + def args(self, path): + return SimpleNamespace( + state_file=path, + atespace="live-agent-gate", + actor_name="claude-gate5", + actor_timeout=5, + ) + + def test_exact_old_template_collision_with_new_template_is_refused(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + state = { + "run_id": "run-2", + "actor_uid": None, + "template_uid": "template-new", + } + control = SetupControl(self.actor(template_uid="template-old")) + with self.assertRaisesRegex(IdentityConflict, "no actor uid"): + asyncio_run( + gate5_setup.ensure_actor( + control, + self.args(path), + "live-agent-gate-v2", + "template-new", + state, + ) + ) + + def test_owned_actor_with_template_mismatch_is_refused(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + state = self.state(path) + control = SetupControl(self.actor(template_uid="template-old")) + with self.assertRaisesRegex(IdentityConflict, "references template uid"): + asyncio_run( + gate5_setup.ensure_actor( + control, + self.args(path), + "live-agent-gate-v2", + "template-new", + state, + ) + ) + + def test_crashed_and_deleting_actors_are_refused_before_resume(self): + for actor_state in (ActorState.CRASHED, ActorState.DELETING): + with self.subTest( + actor_state=actor_state + ), tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + state = self.state(path) + control = SetupControl(self.actor(state=actor_state)) + with self.assertRaises(ActorFailedToStart): + asyncio_run( + gate5_setup.ensure_actor( + control, + self.args(path), + "live-agent-gate-v2", + "template-new", + state, + ) + ) + self.assertEqual(control.resumed, 0) + + def test_new_actor_is_resumed_and_uid_is_saved(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + state = { + "run_id": "run-3", + "actor_uid": None, + "template_uid": "template-new", + } + control = SetupControl(None) + uid = asyncio_run( + gate5_setup.ensure_actor( + control, + self.args(path), + "live-agent-gate-v2", + "template-new", + state, + ) + ) + self.assertEqual(uid, "actor-new") + self.assertEqual(control.resumed, 1) + self.assertEqual( + json.loads(Path(path).read_text())["actor_uid"], "actor-new" + ) + + +def asyncio_run(coro): + import asyncio + + return asyncio.run(coro) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_substrate.py b/backend/tests/runtime/test_substrate.py index 51e3997..e4ff2d5 100644 --- a/backend/tests/runtime/test_substrate.py +++ b/backend/tests/runtime/test_substrate.py @@ -2,16 +2,43 @@ no credentials. Mirrors test_herdr.py's fake-transport pattern for the Herdr adapter.""" import asyncio +import json import unittest +from pathlib import Path from mainloop.runtime.substrate import ( + ActorFailedToStart, + ActorHealthTimeout, ActorState, ExecResult, + GoldenSnapshotFailed, + GoldenSnapshotTimeout, + GoldenState, + IdentityOutcome, + NoEligibleWorker, SubstrateControl, TransportError, + WaitTimeout, _actor_from_json, + _actor_template_from_json, + reconcile_actor_identity, + wait_for_actor_health, + wait_for_actor_running, + wait_for_eligible_worker, + wait_for_golden_snapshot, ) +# Generated with kubectl-ate's PrintWorkersTo(..., "json") at pinned Substrate commit +# cdac9baef81dd319b46086d695266e6161e9e592. It is CLI printer output with sanitized fixture +# records, not a claim that a live worker was observed. +PINNED_WORKERS_OUTPUT = ( + Path(__file__).parent + / "fixtures" + / "substrate" + / "kubectl-ate-workers-pinned-cdac9ba.json" +).read_text() +PINNED_WORKERS_DOCUMENT = json.loads(PINNED_WORKERS_OUTPUT) + class FakeControl(SubstrateControl): def __init__(self, results): @@ -20,31 +47,92 @@ def __init__(self, results): ) self.results = list(results) self.calls: list[list[str]] = [] + self.stdins: list[str | None] = [] - async def _exec(self, args, timeout=45): + async def _exec(self, args, timeout=45, stdin=None): self.calls.append(args) + self.stdins.append(stdin) + if args and args[0] in {"get", "create", "resume", "suspend", "revert"}: + if "-o" not in args or args[args.index("-o") + 1] != "json": + return ExecResult(0, "NAME STATUS\nresource Pending\n", "") result = self.results.pop(0) if isinstance(result, Exception): raise result return result +async def fake_sleep(_seconds: float) -> None: + return None + + +class FakeClock: + """A monotonic clock that advances by a fixed step every time it's read, so a bounded + poll loop can be driven to its deadline deterministically without a real delay.""" + + def __init__(self, step: float = 1.0): + self.value = 0.0 + self.step = step + + def __call__(self) -> float: + current = self.value + self.value += self.step + return current + + def run(coro): return asyncio.run(coro) -def actor_json(state: str, *, snapshot_uri: str | None = None) -> str: +def actor_json(state: str, *, snapshot_uri: str | None = None, uid: str = "u-1") -> str: doc = { - "metadata": {"atespace": "mainloop-workspaces", "name": "ml-abc", "uid": "u-1"}, + "metadata": {"atespace": "mainloop-workspaces", "name": "ml-abc", "uid": uid}, "status": {"state": state}, } if snapshot_uri: doc["status"]["externalSnapshot"] = {"snapshotUri": snapshot_uri} - import json + return json.dumps(doc) + +def actor_template_json( + *, error_message: str = "", golden_tag: str | None = None, uid: str = "t-1" +) -> str: + doc = { + "metadata": { + "atespace": "live-agent-gate", + "name": "live-agent-gate-v1", + "uid": uid, + }, + "status": {"goldenSnapshotStatus": {}}, + } + if error_message: + doc["status"]["goldenSnapshotStatus"]["errorMessage"] = error_message + if golden_tag: + doc["status"]["goldenSnapshotStatus"]["goldenTag"] = { + "atespace": "ate-golden", + "name": golden_tag, + } return json.dumps(doc) +def worker_record( + *, + state="WORKER_STATE_ACTIVE", + namespace="live-agent-gate", + pool_label="live-agent-gate", + sandbox_class="gvisor", + capacity=1, + allocated=0, +): + record = json.loads(json.dumps(PINNED_WORKERS_DOCUMENT["workers"][0])) + record["workerNamespace"] = namespace + record["sandboxClass"] = sandbox_class + record["labels"]["workload"] = pool_label + record["status"]["state"] = state + record["status"]["capacity"]["actors"] = capacity + record["status"]["allocated"]["actors"] = allocated + return record + + class ActorJsonParsingTests(unittest.TestCase): def test_parses_running_actor_with_snapshot(self): import json @@ -150,5 +238,443 @@ def test_transport_error_on_transient_failure_is_not_retried(self): self.assertEqual(len(ctl.calls), 1) +class AtespaceTests(unittest.TestCase): + def test_ensure_atespace_creates(self): + ctl = FakeControl([ExecResult(0, "{}", "")]) + run(ctl.ensure_atespace("live-agent-gate")) + self.assertEqual( + ctl.calls[0], ["create", "atespace", "live-agent-gate", "-o", "json"] + ) + + def test_ensure_atespace_tolerates_already_exists(self): + ctl = FakeControl( + [ + ExecResult( + 1, "", 'Error: atespaces.ate.dev "live-agent-gate" already exists' + ) + ] + ) + run(ctl.ensure_atespace("live-agent-gate")) # does not raise + + def test_ensure_atespace_other_failure_raises(self): + ctl = FakeControl([ExecResult(1, "", "connection refused")]) + with self.assertRaises(TransportError): + run(ctl.ensure_atespace("live-agent-gate")) + + def test_atespace_exists_false_on_not_found(self): + ctl = FakeControl([ExecResult(1, "", 'Error: atespaces.ate.dev "x" not found')]) + self.assertFalse(run(ctl.atespace_exists("x"))) + + +class ActorTemplateJsonParsingTests(unittest.TestCase): + def test_pending_when_no_tag_and_no_error(self): + record = _actor_template_from_json(json.loads(actor_template_json())) + self.assertEqual(record.golden_state, GoldenState.PENDING) + + def test_ready_when_golden_tag_present(self): + record = _actor_template_from_json( + json.loads(actor_template_json(golden_tag="golden-1")) + ) + self.assertEqual(record.golden_state, GoldenState.READY) + self.assertEqual(record.golden_tag, "golden-1") + + def test_failed_when_error_message_present(self): + record = _actor_template_from_json( + json.loads(actor_template_json(error_message="golden actor crashed")) + ) + self.assertEqual(record.golden_state, GoldenState.FAILED) + self.assertEqual(record.error_message, "golden actor crashed") + + +class ActorTemplateControlTests(unittest.TestCase): + def test_get_actor_template_uses_atespace_flag_not_a_composite_name(self): + # The prior harness bug: `get actor-template "/"` as a single + # positional argument, which the CLI requires as separate `-a `. + ctl = FakeControl([ExecResult(0, actor_template_json(golden_tag="g1"), "")]) + run(ctl.get_actor_template("live-agent-gate", "live-agent-gate-v1")) + self.assertEqual( + ctl.calls[0], + [ + "get", + "actor-template", + "live-agent-gate-v1", + "-a", + "live-agent-gate", + "-o", + "json", + ], + ) + + def test_get_actor_template_not_found_returns_none(self): + ctl = FakeControl( + [ExecResult(1, "", 'Error: actortemplates.ate.dev "x" not found')] + ) + self.assertIsNone(run(ctl.get_actor_template("live-agent-gate", "x"))) + + def test_get_actor_template_other_failure_raises_transport_error(self): + ctl = FakeControl([ExecResult(1, "", "connection refused")]) + with self.assertRaises(TransportError): + run(ctl.get_actor_template("live-agent-gate", "x")) + + def test_create_actor_template_requests_json_and_passes_manifest_over_stdin(self): + ctl = FakeControl([ExecResult(0, actor_template_json(), "")]) + run( + ctl.create_actor_template( + "live-agent-gate", "live-agent-gate-v1", "metadata:\n name: x\n" + ) + ) + self.assertEqual( + ctl.calls[0], + ["create", "actor-template", "-f", "-", "-o", "json"], + ) + self.assertEqual(ctl.stdins[0], "metadata:\n name: x\n") + + def test_uncertain_template_create_reads_before_returning_success(self): + ctl = FakeControl( + [ + TransportError("request timed out after send"), + ExecResult(0, actor_template_json(golden_tag="g1"), ""), + ] + ) + record = run( + ctl.create_actor_template( + "live-agent-gate", "live-agent-gate-v1", "manifest" + ) + ) + self.assertEqual(record.uid, "t-1") + self.assertEqual(len(ctl.calls), 2) + self.assertEqual( + ctl.calls[1][:3], ["get", "actor-template", "live-agent-gate-v1"] + ) + + def test_uncertain_template_create_reads_before_reporting_absent(self): + ctl = FakeControl( + [ + TransportError("request timed out after send"), + ExecResult(1, "", 'Error: actortemplates.ate.dev "x" not found'), + ] + ) + with self.assertRaises(TransportError): + run(ctl.create_actor_template("live-agent-gate", "x", "manifest")) + self.assertEqual( + len(ctl.calls), 2 + ) # read-back happened before any caller retry + + +class WaitForGoldenSnapshotTests(unittest.TestCase): + def test_returns_once_ready(self): + ctl = FakeControl( + [ + ExecResult(0, actor_template_json(), ""), # pending + ExecResult(0, actor_template_json(golden_tag="g1"), ""), # ready + ] + ) + record = run( + wait_for_golden_snapshot( + ctl, + "live-agent-gate", + "live-agent-gate-v1", + timeout_s=30, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + self.assertEqual(record.golden_state, GoldenState.READY) + + def test_raises_on_reported_failure_not_just_returns(self): + ctl = FakeControl( + [ExecResult(0, actor_template_json(error_message="boom"), "")] + ) + with self.assertRaises(GoldenSnapshotFailed): + run( + wait_for_golden_snapshot( + ctl, + "live-agent-gate", + "live-agent-gate-v1", + timeout_s=30, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + def test_raises_timeout_rather_than_claiming_success(self): + # Every poll is still pending; the fake clock advances past the deadline. This is + # the "polling loop prints success after timeout" bug from the recovery review -- + # here, timing out must raise, never return. + ctl = FakeControl([ExecResult(0, actor_template_json(), "") for _ in range(50)]) + with self.assertRaises(GoldenSnapshotTimeout): + run( + wait_for_golden_snapshot( + ctl, + "live-agent-gate", + "live-agent-gate-v1", + timeout_s=5, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + def test_disappearing_template_is_a_transport_error_not_pending(self): + ctl = FakeControl( + [ExecResult(1, "", 'Error: actortemplates.ate.dev "x" not found')] + ) + with self.assertRaises(TransportError): + run( + wait_for_golden_snapshot( + ctl, + "live-agent-gate", + "live-agent-gate-v1", + timeout_s=30, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + +class GetEligibleWorkersTests(unittest.TestCase): + def get_workers(self, workers): + return FakeControl([ExecResult(0, json.dumps({"workers": workers}), "")]) + + def test_parses_pinned_cli_json_and_uses_pool_filters(self): + ctl = FakeControl([ExecResult(0, PINNED_WORKERS_OUTPUT, "")]) + self.assertEqual( + run( + ctl.get_eligible_workers( + "live-agent-gate", "workload=live-agent-gate", "gvisor" + ) + ), + 1, + ) + self.assertEqual( + ctl.calls[0], + [ + "get", + "workers", + "-n", + "live-agent-gate", + "-l", + "workload=live-agent-gate", + "--sandbox-class", + "gvisor", + "-o", + "json", + ], + ) + + def test_excludes_occupied_worker_using_capacity_minus_allocated(self): + ctl = self.get_workers([worker_record(capacity=1, allocated=1)]) + self.assertEqual( + run( + ctl.get_eligible_workers( + "live-agent-gate", "workload=live-agent-gate", "gvisor" + ) + ), + 0, + ) + + def test_excludes_draining_worker(self): + ctl = self.get_workers([worker_record(state="WORKER_STATE_DRAINING")]) + self.assertEqual( + run( + ctl.get_eligible_workers( + "live-agent-gate", "workload=live-agent-gate", "gvisor" + ) + ), + 0, + ) + + def test_excludes_wrong_pool_worker(self): + ctl = self.get_workers([worker_record(pool_label="other")]) + self.assertEqual( + run( + ctl.get_eligible_workers( + "live-agent-gate", "workload=live-agent-gate", "gvisor" + ) + ), + 0, + ) + + def test_empty_workers_list_returns_zero(self): + ctl = self.get_workers([]) + self.assertEqual( + run( + ctl.get_eligible_workers( + "live-agent-gate", "workload=live-agent-gate", "gvisor" + ) + ), + 0, + ) + + def test_missing_workers_field_is_a_contract_error(self): + ctl = FakeControl([ExecResult(0, json.dumps({"items": []}), "")]) + with self.assertRaisesRegex(TransportError, "missing its 'workers' list"): + run( + ctl.get_eligible_workers( + "live-agent-gate", "workload=live-agent-gate", "gvisor" + ) + ) + + +class WaitForEligibleWorkerTests(unittest.TestCase): + def test_returns_once_a_worker_is_eligible(self): + ctl = FakeControl( + [ + ExecResult(0, json.dumps({"workers": []}), ""), + ExecResult(0, json.dumps({"workers": [worker_record()]}), ""), + ] + ) + count = run( + wait_for_eligible_worker( + ctl, + "live-agent-gate", + "workload=live-agent-gate", + "gvisor", + timeout_s=30, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + self.assertEqual(count, 1) + + def test_raises_no_eligible_worker_on_timeout(self): + ctl = FakeControl( + [ExecResult(0, json.dumps({"workers": []}), "") for _ in range(50)] + ) + with self.assertRaises(NoEligibleWorker): + run( + wait_for_eligible_worker( + ctl, + "live-agent-gate", + "workload=live-agent-gate", + "gvisor", + timeout_s=5, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + +class WaitForActorRunningTests(unittest.TestCase): + def test_returns_once_running(self): + ctl = FakeControl( + [ + ExecResult(0, actor_json("ACTOR_STATE_RESUMING"), ""), + ExecResult(0, actor_json("ACTOR_STATE_RUNNING"), ""), + ] + ) + actor = run( + wait_for_actor_running( + ctl, + "mainloop-workspaces", + "ml-abc", + timeout_s=30, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + self.assertEqual(actor.state, ActorState.RUNNING) + + def test_raises_actor_failed_to_start_on_crash_not_timeout(self): + ctl = FakeControl([ExecResult(0, actor_json("ACTOR_STATE_CRASHED"), "")]) + with self.assertRaises(ActorFailedToStart): + run( + wait_for_actor_running( + ctl, + "mainloop-workspaces", + "ml-abc", + timeout_s=30, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + def test_raises_wait_timeout_when_stuck_resuming(self): + ctl = FakeControl( + [ExecResult(0, actor_json("ACTOR_STATE_RESUMING"), "") for _ in range(50)] + ) + with self.assertRaises(WaitTimeout): + run( + wait_for_actor_running( + ctl, + "mainloop-workspaces", + "ml-abc", + timeout_s=5, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + +class WaitForActorHealthTests(unittest.TestCase): + def test_slow_start_waits_until_health_route_returns_200(self): + checks = iter([False, False, True]) + run( + wait_for_actor_health( + lambda: next(checks), + timeout_s=10, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + def test_failed_pane_or_shim_times_out_while_health_stays_non_200(self): + with self.assertRaises(ActorHealthTimeout): + run( + wait_for_actor_health( + lambda: False, + timeout_s=3, + poll_interval_s=0, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + def test_restored_actor_passes_without_replaying_boot_marker(self): + # Restore resumes processes; current health, not a repeated startup log, is the + # readiness contract. + run( + wait_for_actor_health( + lambda: True, + timeout_s=3, + sleep=fake_sleep, + clock=FakeClock(step=1), + ) + ) + + +class ReconcileActorIdentityTests(unittest.TestCase): + def test_absent_when_no_live_actor(self): + self.assertEqual(reconcile_actor_identity("u-1", None), IdentityOutcome.ABSENT) + + def test_matches_when_uids_equal(self): + live = _actor_from_json( + json.loads(actor_json("ACTOR_STATE_RUNNING", uid="u-1")) + ) + self.assertEqual(reconcile_actor_identity("u-1", live), IdentityOutcome.MATCHES) + + def test_unowned_when_no_actor_uid_was_persisted(self): + live = _actor_from_json( + json.loads(actor_json("ACTOR_STATE_RUNNING", uid="u-1")) + ) + self.assertEqual(reconcile_actor_identity(None, live), IdentityOutcome.UNOWNED) + + def test_diverged_when_uids_differ(self): + # A rerun must not silently resume or recreate an actor that turned out to belong + # to a different run under the same name. + live = _actor_from_json( + json.loads(actor_json("ACTOR_STATE_RUNNING", uid="u-2")) + ) + self.assertEqual( + reconcile_actor_identity("u-1", live), IdentityOutcome.DIVERGED + ) + + if __name__ == "__main__": unittest.main() diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 56a9ee9..42206e9 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -24,7 +24,7 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. | `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | | Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | | File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | -| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, real cred-server | Built, not run -- blocked at cluster creation by Claude Code's own safety classifier ("Create Unsafe Agents"); see "Limits" | +| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, credential-free boot | Phase 1 harness repair complete; credential-free lifecycle rerun is pending. No native-agent session has been run. | | Claude/Codex agent processes, credentials | Not run in this spike (see "Limits") | | `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | @@ -169,22 +169,59 @@ per `docs/network-egress.md`'s "CIDR/all policy: dial now" passthrough path) ins ## Limits / not attempted in this run -- **Native-session gate, live**: infrastructure built and ready - (`spikes/substrate-workspace-adapter/live-agent-image/`, `k8s/cred-server.yaml.tmpl`, - `k8s/live-agent-gate-template.yaml.tmpl`) -- real Claude/Codex CLIs, credentials fetched at - actor-runtime through the same egress-CIDR mechanism gate 4 proved enforces (never baked into - a template), reusing `.tasknotes/plan.md`'s by-path Secret pattern. The trial was not run: the - cluster-creation step was declined by Claude Code's own auto-mode safety classifier ("Create - Unsafe Agents"), and the run stopped there rather than seeking a workaround, per the plan's own - "missing permission" stopping criterion -- this involves the operator's real subscription - credentials, so proceeding past a safety control without explicit human authorization was not - appropriate. Only the fixture-level contract logic (`test_workspace_adapter.py`) and the - generic restore-vs-reboot log evidence above are available for this gate. +- **Native-session gate, live**: a prior owner-authorized attempt reached golden-snapshot + creation but failed when the boot-time credential fetch received HTTP 403. The earlier + description that the run was declined by a safety classifier was inaccurate: the run was + authorized, while tool policy rejected particular actions. Phase 1 removes the boot-time + fetch and repairs the harness; the credential-free lifecycle proof is pending. No Claude or + Codex session has been run. The old relay manifest and fetch helper have been removed. See + the finish plan for the bounded lifecycle proof and the separately gated Claude-only + credential-boundary attempt. - **`workspace_bindings` orchestration functions** (`ensure_workspace`, `resume_workspace`, ...) were not exercised against a live Postgres + running backend; only their extracted pure logic (`plan_ensure`, `_binding_from_row`, `is_crashed`) is unit tested, and the transport layer they call (`SubstrateControl`) is proved live as described above. +### Native-session gate addendum: a later live attempt failed at golden creation, now repaired + +After the run above, a separate live attempt (outside this doc's own commits) did create the +live-agent-gate infrastructure and hit a real failure during golden-snapshot creation, reviewed +in `.tasknotes/gate5-review-and-recovery-plan-2026-09-22.md`: the golden actor's entrypoint +fetched a credential from `cred-server` unconditionally at boot, that fetch was denied (`403`), +and the golden actor exited before its snapshot was captured -- `runsc exit 128` on a later +restore attempt is consistent with capturing a process that had already exited. The harness +script driving that attempt (never committed; reviewed from a scratch copy) also applied an +unresolved `ko://` WorkerPool image, never registered the atespace at the control-plane API +(a Kubernetes Namespace of the same name is not an atespace), checked for an existing +ActorTemplate with the atespace embedded in the name rather than the CLI's required `-a` flag, +and printed success from a log line reached before the state it implied was actually confirmed. + +This Phase 1 change (recovery plan step 2) repairs those bugs and removes the root cause: + +- `entrypoint.sh` no longer fetches a credential or needs network access to reach a running + state. There is no credential-fetch helper or relay path in the image. Credential delivery + remains a separate, gated step and is never performed during golden-actor warmup. +- `k8s/live-agent-gate-template.yaml.tmpl` no longer sets `CRED_SERVER` in the (shared, + immutable) container env, and its ActorTemplate name is now versioned + (`live-agent-gate-${TEMPLATE_VERSION}`) so a failed golden snapshot is never reused. +- `backend/src/mainloop/runtime/substrate.py` gained `ensure_atespace`/`get_actor_template`/ + `create_actor_template`/`get_eligible_workers`, plus bounded, exception-raising waits for + golden snapshots, eligible workers, actor state, and the live actor health route. Rerun + identity reconciliation distinguishes absent, unowned, matching, and diverged actors before + the harness creates or resumes one. +- `backend/scripts/gate5_setup.py` registers the atespace, resolves the WorkerPool image with + `ko resolve` from the verified pinned checkout, waits for an eligible worker and a golden + snapshot, binds reruns to persisted cluster/template/actor identity, then confirms health + through the actor route before applying egress policy. +- `egress-tool/main.go` fails closed: exactly one of `--deny-all`, `--cidr`, or `--allow-all` + must be explicit. + +**Scope of this repair**: code and fixture tests only. The focused Substrate, setup, and contract +tests pass; the owner reports the full runtime suite passes 189/189 outside the restricted +sandbox. No fresh lifecycle measurement is claimed here: `gate5_setup.py` has not yet been run +against a fresh `kind-substrate-preview` cluster. Phase 2 must confirm the golden snapshot and +restored readiness before this addendum can report a live result. + ## Cleanup Each of the three cluster lanes in this spike (adapter/CRASHED, preview-gate, dev-service-gate) diff --git a/spikes/substrate-workspace-adapter/egress-tool/main.go b/spikes/substrate-workspace-adapter/egress-tool/main.go index 1607afc..57d51ac 100644 --- a/spikes/substrate-workspace-adapter/egress-tool/main.go +++ b/spikes/substrate-workspace-adapter/egress-tool/main.go @@ -12,7 +12,12 @@ // cdac9baef81dd319b46086d695266e6161e9e592 when this was written). // // Usage: mainloop-egress-tool --kubeconfig --context --atespace --actor -// [--cidr ] (omit --cidr to allow all destinations) +// --deny-all | --cidr | --allow-all +// +// Fails closed: exactly one of --deny-all, --cidr, or --allow-all is required. An earlier version of this +// tool silently allowed all destinations whenever --cidr was omitted (see +// .tasknotes/gate5-review-and-recovery-plan-2026-09-22.md, "Make missing egress configuration +// fail closed"); --allow-all must now be passed explicitly to get that behavior. package main import ( @@ -30,14 +35,39 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" ) +// validateEgressInput is the fail-closed check, isolated as a pure function so it can be +// exercised without a cluster or a Substrate checkout. +func validateEgressInput(cidr string, denyAll, allowAll bool) error { + selected := 0 + if cidr != "" { + selected++ + } + if denyAll { + selected++ + } + if allowAll { + selected++ + } + if selected != 1 { + return fmt.Errorf("exactly one of --deny-all, --cidr , or --allow-all is required") + } + return nil +} + func main() { kubeconfig := flag.String("kubeconfig", "", "") context_ := flag.String("context", "", "") atespace := flag.String("atespace", "", "") actorName := flag.String("actor", "", "") - cidr := flag.String("cidr", "", "CIDR to allow; empty means allow-all") + cidr := flag.String("cidr", "", "CIDR to allow") + denyAll := flag.Bool("deny-all", false, "explicitly deny all actor egress") + allowAll := flag.Bool("allow-all", false, "explicitly allow all egress destinations") flag.Parse() + if err := validateEgressInput(*cidr, *denyAll, *allowAll); err != nil { + log.Fatalf("%v", err) + } + ctx := context.Background() cli, err := ateclient.NewClient(ctx, *kubeconfig, *context_, "", "", false) if err != nil { @@ -47,15 +77,15 @@ func main() { actorRef := resources.ActorRef{Atespace: *atespace, Name: *actorName}.ToObjectRef() - var rule *ateapipb.EgressRule - if *cidr == "" { - rule = &ateapipb.EgressRule{All: &emptypb.Empty{}} - } else { - rule = &ateapipb.EgressRule{Cidrs: &ateapipb.CIDRRule{Cidrs: []string{*cidr}}} + var rules []*ateapipb.EgressRule + if *allowAll { + rules = []*ateapipb.EgressRule{{All: &emptypb.Empty{}}} + } else if *cidr != "" { + rules = []*ateapipb.EgressRule{{Cidrs: &ateapipb.CIDRRule{Cidrs: []string{*cidr}}}} } policy := &ateapipb.EgressPolicy{ Metadata: &ateapipb.ResourceMetadata{Atespace: *atespace, Name: "default"}, - Rules: []*ateapipb.EgressRule{rule}, + Rules: rules, } _, err = cli.CreateActorEgressPolicy(ctx, &ateapipb.CreateActorEgressPolicyRequest{ diff --git a/spikes/substrate-workspace-adapter/egress-tool/main_test.go b/spikes/substrate-workspace-adapter/egress-tool/main_test.go new file mode 100644 index 0000000..dcf8e0c --- /dev/null +++ b/spikes/substrate-workspace-adapter/egress-tool/main_test.go @@ -0,0 +1,28 @@ +package main + +import "testing" + +func TestValidateEgressInputRequiresExactlyOneMode(t *testing.T) { + tests := []struct { + name string + cidr string + denyAll bool + allowAll bool + wantErr bool + }{ + {name: "deny all", denyAll: true}, + {name: "cidr", cidr: "192.0.2.1/32"}, + {name: "allow all", allowAll: true}, + {name: "missing mode", wantErr: true}, + {name: "conflicting modes", denyAll: true, allowAll: true, wantErr: true}, + {name: "cidr and deny all", cidr: "192.0.2.1/32", denyAll: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateEgressInput(tt.cidr, tt.denyAll, tt.allowAll) + if (err != nil) != tt.wantErr { + t.Fatalf("validateEgressInput() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl deleted file mode 100644 index f2b2459..0000000 --- a/spikes/substrate-workspace-adapter/k8s/cred-server.yaml.tmpl +++ /dev/null @@ -1,71 +0,0 @@ -# Serves the two credential files a real Claude/Codex CLI needs, from Kubernetes Secrets created -# by path (never by value -- see build script). Reachable only by an actor whose EgressPolicy -# explicitly allows this Service's ClusterIP: the same CIDR-scoped enforcement gate 4 -# (dev-service) proved denies everything else with a clean 403, reused here as the credential -# boundary. Plain NGINX static-file serving; no application code. Deployed outside any atespace, -# like the gate 4 Postgres target -- this represents a Mainloop-operated credential-relay -# service, not part of the actor's own image or an atespace resource. -apiVersion: v1 -kind: Namespace -metadata: - name: cred-server ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: cred-server-nginx-config - namespace: cred-server -data: - nginx.conf: | - events {} - http { - server { - listen 80; - location = /claude-token { alias /secrets/claude/token; } - location = /codex-auth.json { alias /secrets/codex/auth.json; } - location / { return 404; } - } - } ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: cred-server - namespace: cred-server -spec: - replicas: 1 - selector: - matchLabels: - app: cred-server - template: - metadata: - labels: - app: cred-server - spec: - containers: - - name: nginx - image: nginx:alpine - ports: - - containerPort: 80 - volumeMounts: - - { name: config, mountPath: /etc/nginx/nginx.conf, subPath: nginx.conf } - - { name: claude-token, mountPath: /secrets/claude, readOnly: true } - - { name: codex-auth, mountPath: /secrets/codex, readOnly: true } - volumes: - - name: config - configMap: { name: cred-server-nginx-config } - - name: claude-token - secret: { secretName: mainloop-claude-token } - - name: codex-auth - secret: { secretName: mainloop-codex-auth } ---- -apiVersion: v1 -kind: Service -metadata: - name: cred-server - namespace: cred-server -spec: - selector: - app: cred-server - ports: - - port: 80 diff --git a/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl index a6e5350..dfb08a5 100644 --- a/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl @@ -1,6 +1,10 @@ # WorkerPool + ActorTemplate for the bounded live native-agent proof (gate 5 in # .tasknotes/plan.md): real Claude Code / Codex CLIs under Herdr, driven by the exec shim. See # spikes/substrate-workspace-adapter/live-agent-image/. +# +# Deliberately no credential-relay env: the golden actor boots without credentials or external +# network access. The pinned ActorTemplate API supports readyz; the probe waits for the control +# server, shell pane, and exec shim before the golden snapshot or final actor is considered ready. apiVersion: v1 kind: Namespace metadata: @@ -23,19 +27,22 @@ spec: --- metadata: atespace: ${ATESPACE} - name: live-agent-gate + # Versioned, not "live-agent-gate": ActorTemplates are immutable, and a template whose + # golden snapshot failed must never be reused under the same name (recovery plan step 2). + name: ${TEMPLATE_NAME} workerSelector: matchLabels: workload: live-agent-gate containers: - name: live-agent image: __IMAGE__ - env: - - { name: CRED_SERVER, value: "cred-server.cred-server.svc.cluster.local" } resources: limits: - { name: cpu, quantity: "2" } - { name: memory, quantity: 2Gi } + readyz: + httpGet: { path: /healthz, port: 8090 } + timeoutSeconds: 60 snapshotsConfig: onPause: SNAPSHOT_CONTENT_SCOPE_FULL onCommit: SNAPSHOT_CONTENT_SCOPE_FULL diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile index 824d030..bbd0b26 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -1,9 +1,10 @@ # Live-agent-gate spike image (gate 5, bounded live proof): real Herdr + real Claude Code / # Codex CLIs + agentctl, driven by the same generic exec shim as the other gate images. # herdr/claude/codex/codex-code-mode-host are copied from the host into the build context by the -# build script (never committed). No credentials are baked into this image; they are fetched at -# actor runtime from an in-cluster server reachable only via a narrow EgressPolicy (see -# entrypoint.sh and docs/spikes/substrate-workspace-adapter.md). +# build script (never committed). No credentials are baked into this image, and entrypoint.sh +# never fetches one: the golden actor built from this image boots to a ready control service +# with no credential and no external network access. Credential delivery remains deferred until +# a reviewed boundary exists (see docs/spikes/substrate-workspace-adapter.md). FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates git curl procps ripgrep \ && rm -rf /var/lib/apt/lists/* \ diff --git a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh index 5f1547e..907aba7 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh +++ b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh @@ -3,23 +3,17 @@ # Live-agent-gate actor entrypoint (gate 5 in .tasknotes/plan.md, bounded live proof): a real # Herdr server plus real Claude Code / Codex CLIs. Substrate has no Kubernetes-Secret-equivalent # volume/env mechanism for an actor (see docs/spikes/substrate-workspace-adapter.md, -# "Credential-injection gap"), so credentials are fetched over the network from a small in-cluster -# server, reachable only because this actor's narrow EgressPolicy allows exactly that server's -# ClusterIP -- the same CIDR-scoped access-control mechanism gate 4 (dev-service) proved actually -# enforces (a non-allowed destination gets a clean 403), reused here as the auth boundary rather -# than inventing a new one. Never echoed, never written to a template, never logged. +# "Credential-injection gap"). +# +# Credential-free by construction: this entrypoint never fetches a credential and never +# requires network access to reach a running state. The template controller uses the +# ActorTemplate's `/healthz` readiness check before accepting the golden actor. A boot path +# that depends on a credential fetch succeeding can fail golden creation when its relay is +# denied or unreachable, which is what happened. Credential delivery is deferred to a +# reviewed boundary and is never performed during golden-actor warmup or from this entrypoint. set -eu mkdir -p "${HOME}" "${HOME}/.claude" "${CODEX_HOME}" -if [[ -n ${CRED_SERVER-} ]]; then - curl -fsS "http://${CRED_SERVER}/claude-token" -o "${HOME}/.claude-oauth-token" - chmod 600 "${HOME}/.claude-oauth-token" - CLAUDE_CODE_OAUTH_TOKEN="$(tr -d ' \r\n' <"${HOME}/.claude-oauth-token")" - export CLAUDE_CODE_OAUTH_TOKEN - curl -fsS "http://${CRED_SERVER}/codex-auth.json" -o "${CODEX_HOME}/auth.json" - chmod 600 "${CODEX_HOME}/auth.json" -fi - # Claude Code: onboarding done, workspace trusted, bypass-permissions warning accepted. if [[ ! -s "${HOME}/.claude.json" ]]; then jq -n --arg p "${WORKSPACE_PATH}" '{ @@ -39,19 +33,35 @@ grep -q '^\[notice\]' "${CODEX_HOME}/config.toml" || printf '\n[notice]\nhide_ra mkdir -p "${WORKSPACE_PATH}" [[ -d "${WORKSPACE_PATH}/.git" ]] || git -C "${WORKSPACE_PATH}" init -q +# Credential-free identity/counter marker for the fake-payload proof (recovery plan step 2): +# a plain file the exec shim can read/increment to verify golden restore and suspend/resume +# without any real agent session or credential. +[[ -f "${WORKSPACE_PATH}/gate5-counter" ]] || echo 0 >"${WORKSPACE_PATH}/gate5-counter" -echo "herdr $(herdr --version) server starting (HOME=${HOME} session=${HERDR_SESSION})" +echo "herdr $(herdr --version) starting (HOME=${HOME} session=${HERDR_SESSION})" herdr --session "${HERDR_SESSION}" server & HERDR_PID=$! +# The persistent control service's readiness check: an explicit, confirmed status call, +# not a fixed sleep or a pre-confirmation log line. The ActorTemplate's `/healthz` probe +# checks the Herdr server and this shell pane before the controller captures its snapshot. +ready=0 for _ in $(seq 1 60); do - herdr --session "${HERDR_SESSION}" status server >/dev/null 2>&1 && break + if herdr --session "${HERDR_SESSION}" status server >/dev/null 2>&1; then + ready=1 + break + fi sleep 0.5 done - +if [[ ${ready} -ne 1 ]]; then + echo "CONTROL_SERVICE_READINESS_TIMEOUT: herdr server did not report ready within 30s" >&2 + kill "${HERDR_PID}" 2>/dev/null || true + exit 1 +fi shell_ws=$(herdr --session "${HERDR_SESSION}" workspace create --label shell --cwd "${WORKSPACE_PATH}") shell_pane=$(echo "${shell_ws}" | jq -r '.result.root_pane.pane_id') EXEC_SHIM_PANE_ID="${shell_pane}" HERDR_SESSION="${HERDR_SESSION}" node "${EXEC_SHIM}" & +echo "CONTROL_SERVICE_READY session=${HERDR_SESSION} pane=${shell_pane}" wait "${HERDR_PID}" diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index 01250e7..8755b95 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -32,7 +32,29 @@ function herdr(args, res) { }); } +function healthz(res) { + execFile('herdr', ['--session', SESSION, 'status', 'server'], (statusErr, stdout) => { + if (statusErr || !/^status:\s+running\s*$/m.test(stdout)) { + res.writeHead(503, { 'content-type': 'text/plain' }).end('not ready'); + return; + } + execFile('herdr', ['--session', SESSION, 'pane', 'read', PANE_ID], (paneErr) => { + if (paneErr) { + res.writeHead(503, { 'content-type': 'text/plain' }).end('not ready'); + return; + } + // The request is served by this shim, Herdr reports a running server, and pane read + // confirms the shell pane still exists. Do not expose status output or pane contents. + res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); + }); + }); +} + const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/healthz') { + healthz(res); + return; + } if (req.method === 'GET' && req.url === '/read') { herdr(['pane', 'read', PANE_ID], res); return; From 190b222f365892185e531c23c81e5cd98c391d75 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:13:39 +0000 Subject: [PATCH 09/30] fix: correct Gate 5 rerun and router handling Treat {} as an empty worker list, route the setup tunnel to CONNECT port 8081, and reconcile actor ownership before waiting for worker capacity. Add regressions and update the spike evidence. --- backend/scripts/gate5_setup.py | 64 +++++++-- backend/src/mainloop/runtime/substrate.py | 13 +- backend/tests/runtime/test_gate5_setup.py | 147 +++++++++++++++++++-- backend/tests/runtime/test_substrate.py | 11 ++ docs/spikes/substrate-workspace-adapter.md | 146 ++++++++++++++------ 5 files changed, 311 insertions(+), 70 deletions(-) diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index 2271154..be894ea 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -351,7 +351,7 @@ def actor_router_tunnel(args: argparse.Namespace): "--namespace", "ate-system", "service/atenet-router", - f"{args.router_port}:80", + f"{args.router_port}:8081", ] process = ( subprocess.Popen( # nosec B603 - fixed kubectl argv, explicit kube context @@ -463,6 +463,55 @@ async def ensure_golden_template( return record.uid or existing.uid +async def wait_for_worker_if_actor_is_absent( + control: SubstrateControl, args: argparse.Namespace, state: dict +) -> None: + """Reconcile actor ownership before waiting for capacity needed by a new actor.""" + live = await control.get_actor(args.atespace, args.actor_name) + outcome = reconcile_actor_identity(state.get("actor_uid"), live) + if outcome is IdentityOutcome.UNOWNED: + raise IdentityConflict( + f"actor {args.atespace}/{args.actor_name} already exists with uid {live.uid}, " + f"but {args.state_file} has no actor uid; refusing to adopt it. Use a new " + "--actor-name or reconcile the state file explicitly" + ) + if outcome is IdentityOutcome.DIVERGED: + raise IdentityConflict( + f"actor {args.atespace}/{args.actor_name} exists with uid {live.uid}, but " + f"{args.state_file} recorded {state.get('actor_uid')} from a prior run -- " + "refusing to resume or recreate it; reconcile manually or use a different " + "--actor-name" + ) + if outcome is IdentityOutcome.MATCHES: + if ( + state.get("template_uid") + and live.current_actor_template_uid != state["template_uid"] + ): + raise IdentityConflict( + f"actor {args.atespace}/{args.actor_name} references template uid " + f"{live.current_actor_template_uid!r}, but {args.state_file} recorded " + f"{state['template_uid']!r}" + ) + if live.state in {ActorState.CRASHED, ActorState.DELETING}: + raise ActorFailedToStart( + f"actor {args.atespace}/{args.actor_name} is {live.state.value}; " + "choose an explicit revert or a new --actor-name before retrying" + ) + return + + print( + f"-- waiting for an eligible worker in namespace={WORKER_NAMESPACE}, " + f"selector={WORKER_SELECTOR}, sandbox={WORKER_SANDBOX_CLASS}" + ) + await wait_for_eligible_worker( + control, + WORKER_NAMESPACE, + WORKER_SELECTOR, + WORKER_SANDBOX_CLASS, + timeout_s=args.worker_timeout, + ) + + async def ensure_actor( control: SubstrateControl, args: argparse.Namespace, @@ -558,18 +607,7 @@ async def async_main(args: argparse.Namespace) -> None: substrate_src=substrate_src, ) - print( - f"-- waiting for an eligible worker in namespace={WORKER_NAMESPACE}, " - f"selector={WORKER_SELECTOR}, sandbox={WORKER_SANDBOX_CLASS}" - ) - await wait_for_eligible_worker( - control, - WORKER_NAMESPACE, - WORKER_SELECTOR, - WORKER_SANDBOX_CLASS, - timeout_s=args.worker_timeout, - ) - + await wait_for_worker_if_actor_is_absent(control, args, state) template_uid = await ensure_golden_template( control, args, template_name, actor_template_doc, state ) diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py index 70158b2..f50d063 100644 --- a/backend/src/mainloop/runtime/substrate.py +++ b/backend/src/mainloop/runtime/substrate.py @@ -475,9 +475,18 @@ async def get_eligible_workers( doc = json.loads(text) except json.JSONDecodeError as exc: raise TransportError(f"get workers returned invalid JSON: {exc}") from exc - if not isinstance(doc, dict) or "workers" not in doc: + if not isinstance(doc, dict): raise TransportError("get workers JSON is missing its 'workers' list") - workers = doc["workers"] + # The pinned CLI uses protojson's default omission behavior: a valid + # ListWorkers response with zero matches is `{}`, not `{"workers": []}`. + # Treat only that empty object as the empty list; a non-empty object + # without the field is still a contract error. + if "workers" not in doc: + if doc: + raise TransportError("get workers JSON is missing its 'workers' list") + workers = [] + else: + workers = doc["workers"] if not isinstance(workers, list): raise TransportError("get workers JSON field 'workers' is not a list") return sum( diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index fcfc2ac..0623bc8 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -185,6 +185,49 @@ def make_connection(*args, **kwargs): ) self.assertEqual(connections[0].requested, ("GET", "/healthz")) + def test_actor_router_tunnel_forwards_to_connect_listener(self): + class FakeSocket: + def __enter__(self): + return self + + def __exit__(self, *_exc): + return False + + class FakeProcess: + def __init__(self): + self.terminated = False + self.stdout = None + + def poll(self): + return None + + def terminate(self): + self.terminated = True + + def wait(self, timeout): + self.wait_timeout = timeout + + args = SimpleNamespace( + context="kind-substrate-preview", + kubeconfig=FIXTURE_KUBECONFIG, + router_port=18081, + ) + process = FakeProcess() + with ( + patch.object( + gate5_setup.subprocess, "Popen", return_value=process + ) as popen, + patch.object( + gate5_setup.socket, "create_connection", return_value=FakeSocket() + ), + gate5_setup.actor_router_tunnel(args) as route_port, + ): + self.assertEqual(route_port, 18081) + + command = popen.call_args.args[0] + self.assertIn("18081:8081", command) + self.assertTrue(process.terminated) + class Gate5StateTests(unittest.TestCase): def args(self, state_file: str, **overrides): @@ -242,11 +285,14 @@ def __init__(self, actor: ActorRecord | None): self.actor = actor self.created = [] self.resumed = 0 + self.events = [] async def get_actor(self, _atespace, _name): + self.events.append("get_actor") return self.actor async def create_actor(self, atespace, name, *, template): + self.events.append("create_actor") self.created.append((atespace, name, template)) self.actor = ActorRecord( atespace=atespace, @@ -260,6 +306,7 @@ async def create_actor(self, atespace, name, *, template): return self.actor async def resume_actor(self, _atespace, _name): + self.events.append("resume_actor") self.resumed += 1 self.actor = replace(self.actor, state=ActorState.RUNNING) return self.actor @@ -296,6 +343,7 @@ def args(self, path): atespace="live-agent-gate", actor_name="claude-gate5", actor_timeout=5, + worker_timeout=5, ) def test_exact_old_template_collision_with_new_template_is_refused(self): @@ -336,9 +384,10 @@ def test_owned_actor_with_template_mismatch_is_refused(self): def test_crashed_and_deleting_actors_are_refused_before_resume(self): for actor_state in (ActorState.CRASHED, ActorState.DELETING): - with self.subTest( - actor_state=actor_state - ), tempfile.TemporaryDirectory() as temp_dir: + with ( + self.subTest(actor_state=actor_state), + tempfile.TemporaryDirectory() as temp_dir, + ): path = str(Path(temp_dir) / "state.json") state = self.state(path) control = SetupControl(self.actor(state=actor_state)) @@ -363,21 +412,97 @@ def test_new_actor_is_resumed_and_uid_is_saved(self): "template_uid": "template-new", } control = SetupControl(None) - uid = asyncio_run( - gate5_setup.ensure_actor( - control, - self.args(path), - "live-agent-gate-v2", - "template-new", - state, + + async def wait_for_worker(*_args, **_kwargs): + control.events.append("wait_for_eligible_worker") + + with patch.object(gate5_setup, "wait_for_eligible_worker", wait_for_worker): + asyncio_run( + gate5_setup.wait_for_worker_if_actor_is_absent( + control, self.args(path), state + ) + ) + uid = asyncio_run( + gate5_setup.ensure_actor( + control, + self.args(path), + "live-agent-gate-v2", + "template-new", + state, + ) ) - ) self.assertEqual(uid, "actor-new") self.assertEqual(control.resumed, 1) + self.assertLess( + control.events.index("get_actor"), + control.events.index("wait_for_eligible_worker"), + ) + self.assertLess( + control.events.index("wait_for_eligible_worker"), + control.events.index("create_actor"), + ) self.assertEqual( json.loads(Path(path).read_text())["actor_uid"], "actor-new" ) + def test_owned_rerun_skips_worker_wait_when_its_actor_occupies_only_worker(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + state = self.state(path) + control = SetupControl(self.actor()) + + async def unexpected_worker_wait(*_args, **_kwargs): + self.fail("owned rerun must not wait for a spare worker") + + with patch.object( + gate5_setup, "wait_for_eligible_worker", unexpected_worker_wait + ): + asyncio_run( + gate5_setup.wait_for_worker_if_actor_is_absent( + control, self.args(path), state + ) + ) + uid = asyncio_run( + gate5_setup.ensure_actor( + control, + self.args(path), + "live-agent-gate-v2", + "template-new", + state, + ) + ) + + self.assertEqual(uid, "actor-1") + self.assertEqual(control.created, []) + self.assertEqual(control.resumed, 0) + + def test_unowned_actor_is_refused_before_worker_wait(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + state = { + "run_id": "run-4", + "actor_uid": None, + "actor_name": "claude-gate5", + "template_name": "live-agent-gate-v2", + "template_uid": "template-new", + } + control = SetupControl(self.actor(template_uid="template-old")) + + async def unexpected_worker_wait(*_args, **_kwargs): + self.fail("unowned actor must be refused before worker discovery") + + with patch.object( + gate5_setup, "wait_for_eligible_worker", unexpected_worker_wait + ): + with self.assertRaisesRegex(IdentityConflict, "no actor uid"): + asyncio_run( + gate5_setup.wait_for_worker_if_actor_is_absent( + control, self.args(path), state + ) + ) + + self.assertEqual(control.events, ["get_actor"]) + def asyncio_run(coro): import asyncio diff --git a/backend/tests/runtime/test_substrate.py b/backend/tests/runtime/test_substrate.py index e4ff2d5..e903daf 100644 --- a/backend/tests/runtime/test_substrate.py +++ b/backend/tests/runtime/test_substrate.py @@ -507,6 +507,17 @@ def test_empty_workers_list_returns_zero(self): 0, ) + def test_empty_object_matches_pinned_cli_empty_result(self): + ctl = FakeControl([ExecResult(0, "{}", "")]) + self.assertEqual( + run( + ctl.get_eligible_workers( + "live-agent-gate", "workload=live-agent-gate", "gvisor" + ) + ), + 0, + ) + def test_missing_workers_field_is_a_contract_error(self): ctl = FakeControl([ExecResult(0, json.dumps({"items": []}), "")]) with self.assertRaisesRegex(TransportError, "missing its 'workers' list"): diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 42206e9..427b3d3 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -16,17 +16,17 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. ## Real versus stand-in -| Layer | Status | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | -| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | -| `preview-gate` WorkerPool + ActorTemplate: real Herdr server, real Vite dev server, real NGINX header-proxy, real browser (`agent-browser`), real WebSocket HMR | Real | -| `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | -| Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | -| File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | -| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, credential-free boot | Phase 1 harness repair complete; credential-free lifecycle rerun is pending. No native-agent session has been run. | -| Claude/Codex agent processes, credentials | Not run in this spike (see "Limits") | -| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | +| Layer | Status | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | +| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | +| `preview-gate` WorkerPool + ActorTemplate: real Herdr server, real Vite dev server, real NGINX header-proxy, real browser (`agent-browser`), real WebSocket HMR | Real | +| `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | +| Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | +| File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | +| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, credential-free boot | Phase 2: golden snapshot READY; actor RUNNING; `/healthz` and one suspend/resume proved live. Counter/marker restore and worker-loss revert remain unproved. No native-agent session has been run. | +| Claude/Codex agent processes, credentials | Not run in this spike (see "Limits") | +| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | ## Why not a real agent for the preview-gate edit (credential-injection gap) @@ -34,15 +34,19 @@ Substrate's pinned commit has no generic secret-injection mechanism equivalent t Secret volume/env mount. `ActorTemplate` container env values are literal only (no `envFrom`/`valueFrom`, and the template is immutable, so baking a token in would also mean storing it permanently in a control-plane object -- unacceptable under this task's "credentials -by path, never by value" rule). The only credential-shaped primitives are `SystemInfo` volumes -(`actorMetadata`: the actor's own name/atespace/uid; `trustBundle`: a named, allowlisted CA -bundle -- today only `egress-mitm.ate.dev`) and `pkg/proto/credproviderpb` (`CredentialProvider`, -a plugin the _egress gateway_ calls to inject a credential into an actor's _outbound_ request, -keyed by the actor's SPIFFE identity -- not a way to hand the actor's own process a local file or -env var it can read directly, which is what the Claude Code / Codex CLIs need). A real -native-agent proof (gate 5) therefore needs either an unsafe workaround or new plumbing (e.g. an -authenticated credential-relay using the `MintActorJWT`/`MintActorCertificate` RPCs already in -`ateapipb.Control`), out of scope for this spike. The preview-gate measurement below instead uses +by path, never by value" rule). `SystemInfo` volumes are limited to actor metadata and an +allowlisted CA bundle. `CredentialProvider` is an egress-gateway plugin, keyed by actor SPIFFE +identity, that injects a credential into an outbound request; it does not hand a token to the +CLI's local environment or filesystem. + +The pinned commit also has experimental static-header injection from a Kubernetes Secret URI +into decrypted outbound requests. It requires Envoy with SDSMint and the experimental +credential-injection flag. Envoy 1.39.1 crashed on this host, while agentgateway does not support +the injection path. The revised Phase 3 uses a Mainloop-owned router NetworkPolicy and a per-actor +shim token instead; credentials are delivered through that closed channel. This keeps the +credential path separate from the unsupported Envoy feature, though actor snapshots will contain +credentials after delivery. The earlier unauthenticated relay is not used. The preview-gate +measurement below instead uses a generic exec shim (`spikes/substrate-workspace-adapter/image/exec-shim.js`) that pastes text into a real Herdr shell pane via `herdr pane run` -- a real shell executing a real command, just not a credentialed agent's own tool call. @@ -169,23 +173,25 @@ per `docs/network-egress.md`'s "CIDR/all policy: dial now" passthrough path) ins ## Limits / not attempted in this run -- **Native-session gate, live**: a prior owner-authorized attempt reached golden-snapshot - creation but failed when the boot-time credential fetch received HTTP 403. The earlier - description that the run was declined by a safety classifier was inaccurate: the run was - authorized, while tool policy rejected particular actions. Phase 1 removes the boot-time - fetch and repairs the harness; the credential-free lifecycle proof is pending. No Claude or - Codex session has been run. The old relay manifest and fetch helper have been removed. See - the finish plan for the bounded lifecycle proof and the separately gated Claude-only - credential-boundary attempt. +- **Native-session gate, live**: no Claude or Codex session was run. The earlier attempt was + authorized; tool policy rejected particular actions after the boot-time credential fetch + received HTTP 403. Phase 1 removed the unauthenticated relay and repaired the harness. Phase 2 + produced a READY golden snapshot, a RUNNING actor, a healthy `/healthz` through the documented + CONNECT port, and one successful suspend/resume. Counter/marker persistence and worker-loss + revert were not proved. Before any credential work, a tokenless caller reached `POST /run` + through the router and executed a harmless command. No existing ingress authorization primitive + closed this path under the earlier plan, so that credential attempt stopped and Gate 5 remains + partial/fixture pending the rewritten Phase 3 and Phase 4. The owner approved that plan on + 2026-09-23; no provider Secret was created or read in the earlier run. - **`workspace_bindings` orchestration functions** (`ensure_workspace`, `resume_workspace`, ...) were not exercised against a live Postgres + running backend; only their extracted pure logic (`plan_ensure`, `_binding_from_row`, `is_crashed`) is unit tested, and the transport layer they call (`SubstrateControl`) is proved live as described above. -### Native-session gate addendum: a later live attempt failed at golden creation, now repaired +### Native-session gate addendum: harness repair and Phase 2 partial live proof -After the run above, a separate live attempt (outside this doc's own commits) did create the -live-agent-gate infrastructure and hit a real failure during golden-snapshot creation, reviewed +The original live attempt (outside this doc's own commits) created the live-agent-gate +infrastructure and hit a real failure during golden-snapshot creation, reviewed in `.tasknotes/gate5-review-and-recovery-plan-2026-09-22.md`: the golden actor's entrypoint fetched a credential from `cred-server` unconditionally at boot, that fetch was denied (`403`), and the golden actor exited before its snapshot was captured -- `runsc exit 128` on a later @@ -196,7 +202,7 @@ unresolved `ko://` WorkerPool image, never registered the atespace at the contro ActorTemplate with the atespace embedded in the name rather than the CLI's required `-a` flag, and printed success from a log line reached before the state it implied was actually confirmed. -This Phase 1 change (recovery plan step 2) repairs those bugs and removes the root cause: +Phase 1 (recovery plan step 2) repairs the harness findings and removes the boot-time fetch: - `entrypoint.sh` no longer fetches a credential or needs network access to reach a running state. There is no credential-fetch helper or relay path in the image. Credential delivery @@ -216,18 +222,70 @@ This Phase 1 change (recovery plan step 2) repairs those bugs and removes the ro - `egress-tool/main.go` fails closed: exactly one of `--deny-all`, `--cidr`, or `--allow-all` must be explicit. -**Scope of this repair**: code and fixture tests only. The focused Substrate, setup, and contract -tests pass; the owner reports the full runtime suite passes 189/189 outside the restricted -sandbox. No fresh lifecycle measurement is claimed here: `gate5_setup.py` has not yet been run -against a fresh `kind-substrate-preview` cluster. Phase 2 must confirm the golden snapshot and -restored readiness before this addendum can report a live result. +Phase 2 used pinned Substrate `cdac9baef81dd319b46086d695266e6161e9e592`, a fresh +`kind-substrate-preview` cluster, agentgateway, and image +`localhost:5001/live-agent-gate@sha256:a5ffadbede22382732067873c0239fa67a757f3bbde38b838942a5bfa20fbeda`. +ActorTemplate `live-agent-gate-v1` (UID `b16cf365-0856-4623-87a9-479112767d46`) reached a READY +golden snapshot; actor `claude-gate5` (UID `cbb4d70c-4ba9-4ce2-af08-20f866a11866`) reached +RUNNING. The harness health probe timed out because it forwarded the router's HTTP port 80 while +the documented non-default-port CONNECT listener is 8081. A direct `/healthz` through 8081 +returned 200. Suspend produced snapshot +`gs://ate-snapshots/live-agent-gate/atespaces/live-agent-gate/actors/cbb4d70c-4ba9-4ce2-af08-20f866a11866/snapshots/fadab73f-5f8a-4346-8cfb-af67c85c893d`; resume returned the same actor UID to RUNNING, the health route returned 200, and logs showed gVisor's `restore -image-path` path. The marker/counter and process PID were not measured. + +The first setup pass exposed one additional pinned-CLI result shape: a valid zero-match worker +query serializes as `{}`. The adapter now treats only an empty object as zero workers and still +rejects non-empty objects missing `workers`. The 9 worker-discovery regression tests pass. The +unchanged rerun did not create a duplicate actor, but waited for spare worker capacity before +checking the persisted actor UID and failed because the single worker was already occupied by +`claude-gate5`. The harness's own health check also used router Service port 80 instead of its +CONNECT listener on 8081; direct `/healthz` through 8081 worked. + +Before any provider credential work, a separate tokenless Pod in `default` POSTed a harmless +command through `atenet-router:8081` to `actor-upstream:8090/run` with the actor-routing header; +the shim returned `200 OK`. The pinned router documentation says ingress treats request headers +as unauthenticated input, while ingress authorization is future roadmap work. This is not +closable with an existing Substrate actor-ingress primitive in this configuration. Under the +earlier plan this meant fallback C; the 2026-09-23 owner decision supersedes that fallback with a +Mainloop-owned NetworkPolicy, per-actor shim token, and closed-channel credential delivery. No +provider credential Secret was created or read. Phase 4 was not attempted in that run. + +After deny-all egress was applied, operator `/run` calls did not complete; a pane read confirmed +the baseline counter/marker command had not run. Therefore the suspend/resume result proves the +actor and Herdr health path restored, but does not establish counter/marker persistence. The +force-delete-worker/revert portion of the lane was not attempted without those markers. + +The reviewing session reports the full runtime suite passed 189/189 outside its restricted +sandbox before the Phase 2 empty-result correction. After that correction, the focused +Substrate, workspace-adapter, contract, and setup suites passed 91/91. The credential-free proof +is partial live evidence; the native-session capability remains partial/fixture, and the full +counter/marker and worker-loss checks are unproved. + +## CapabilityResult + +| Capability | State | Scope | Evidence and limit | +| ---------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workspace_adapter_contract` | partial | fixture | `SubstrateControl` is exercised live; `workspace_bindings` orchestration is fixture-tested, not run against a live backend and Postgres. | +| `substrate_actor_lifecycle` | partial | live | READY golden creation, actor RUNNING, health, suspend, and resume ran live; marker persistence was not proved, and the exact rerun stopped at the occupied single worker before identity reconciliation. | +| `preview_hmr` | proved | live | Real Vite HMR socket and edits through the actor route; survives suspend/resume. | +| `dev_service_postgres` | proved | live | Real Postgres query, narrow allow rule, denied destination, and reconnect after wake. | +| `native_session_continuity` | partial | fixture | No provider session ran. A tokenless unrelated Pod reached `POST /run` through the router; no existing Substrate ingress authorization primitive closed this path. | +| `failure_recovery` | partial | live | Actor CRASHED/revert mechanics were proved in the earlier live lane; backend restart with a durable `recorded` attempt is covered by a fake-backed contract test, not live Postgres delivery. | + +## Recommendation status at the Phase 2b checkpoint + +The earlier recommendation to defer native sessions is superseded by the owner's 2026-09-23 +decision. Current live evidence still does not prove native-session support. Phase 3 must close +the router ingress boundary and deliver credentials through that channel; Phase 4 must then prove +Claude and Codex continuity. The final adopt/defer recommendation remains open until those phases +finish or stop on a named condition. ## Cleanup -Each of the three cluster lanes in this spike (adapter/CRASHED, preview-gate, dev-service-gate) -deleted its own test actors and target resources, then the `substrate-preview` cluster and its -`kind-registry` (`hack/delete-kind-cluster.sh`), and pruned the locally built, unpushed-elsewhere -Docker images. Final `kind get clusters` / `docker ps` showed only `mainloop-test` / -`mainloop-test-control-plane` after every lane. Root disk stayed in the 19-27G-free range -throughout (above the plan's 8G in-flight-trial abort threshold at all times); available RAM -stayed above 8G. No Mainloop repository files outside this branch's own commits were changed. +Phase 0 removed the approved `cred-server` relay resources and both named Secrets, then deleted +the owner-confirmed failed-trial `kind` cluster and its `kind-registry`. The fresh Phase 2 +`kind-substrate-preview` cluster, its `kind-registry`, and the exact local image tag built by +this run were deleted after evidence capture. Final inventory showed only `mainloop-test` and +`mainloop-test-control-plane`; the run-specific image tag was absent and the unrelated `latest` +tag was preserved. Root disk had 21 GiB available and RAM had 9.5 GiB available. `mainloop-test` +was outside the cleanup scope. The owner handles rotation of the Claude and Codex credentials +previously served by the relay. From a1d26b5095f7285b277b606abf4b5b3603961e8c Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:44:04 +0000 Subject: [PATCH 10/30] feat: add Phase 3 actor ingress boundary Restrict router ingress to Mainloop control traffic and add bearer-token authentication to the exec shim. Add repeatable setup flags, router test tools, and sanitized shim tests. Correct the preview image readiness and proxy configuration, and update the spike documentation with the evidence and current stop state. The preview and cross-actor checks passed. The hostname egress check still allowed an unlisted example.com request, so the actor policy was reset to deny-all and egress work is handed off. No provider credentials or live agent sessions were used. --- backend/scripts/gate5_setup.py | 128 ++++++++++++- backend/tests/runtime/test_gate5_setup.py | 104 ++++++++++ docs/spikes/substrate-workspace-adapter.md | 179 +++++++++++++----- .../egress-tool/main.go | 20 +- .../image/entrypoint.sh | 2 +- .../k8s/preview-gate-template.yaml.tmpl | 12 +- .../k8s/preview-proxy.yaml.tmpl | 6 +- .../k8s/router-ingress-policy.yaml | 45 +++++ .../live-agent-image/entrypoint.sh | 8 +- .../live-agent-image/exec-shim.js | 97 +++++++++- .../tests/exec-shim.test.js | 130 +++++++++++++ .../tools/router-client.js | 110 +++++++++++ 12 files changed, 767 insertions(+), 74 deletions(-) create mode 100644 spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml create mode 100644 spikes/substrate-workspace-adapter/tests/exec-shim.test.js create mode 100644 spikes/substrate-workspace-adapter/tools/router-client.js diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index be894ea..d1ac0d2 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -51,6 +51,7 @@ import json import os import re +import secrets import socket import string import subprocess # nosec B404 - drives trusted local kubectl/ko/egress-tool binaries, argv only @@ -116,6 +117,13 @@ def parse_args() -> argparse.Namespace: p.add_argument("--egress-tool", required=True) egress = p.add_mutually_exclusive_group(required=True) egress.add_argument("--egress-cidr", help="CIDR to allow") + egress.add_argument( + "--egress-hostname", + action="append", + dest="egress_hostnames", + metavar="HOSTNAME", + help="provider hostname to allow (repeatable)", + ) egress.add_argument("--egress-allow-all", action="store_true") egress.add_argument("--egress-deny-all", action="store_true") return p.parse_args() @@ -130,9 +138,12 @@ def load_state(path: str) -> dict: def save_state(path: str, state: dict) -> None: tmp = f"{path}.tmp" - with open(tmp, "w") as f: + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: json.dump(state, f, indent=2) + os.chmod(tmp, 0o600) os.replace(tmp, path) + os.chmod(path, 0o600) def render_manifest( @@ -329,6 +340,9 @@ def run_egress_tool(args: argparse.Namespace) -> None: cmd.append("--deny-all") elif args.egress_allow_all: cmd.append("--allow-all") + elif args.egress_hostnames: + for hostname in args.egress_hostnames: + cmd.extend(["--hostname", hostname]) else: cmd += ["--cidr", args.egress_cidr] subprocess.run( @@ -409,6 +423,114 @@ def actor_health_check( connection.close() +def actor_shim_request( + *, + port: int, + atespace: str, + actor_name: str, + method: str, + path: str, + token: str | None = None, + body: dict | None = None, + timeout_s: float = 5, +) -> int | None: + """Send a request through the actor's CONNECT route without logging its body.""" + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout_s) + connection.set_tunnel( + f"actor-upstream:{ACTOR_SHIM_PORT}", + headers={"ate-target-actor": f"{atespace}/{actor_name}"}, + ) + headers = {} + request_body = None + if body is not None: + request_body = json.dumps(body) + headers["Content-Type"] = "application/json" + if token is not None: + headers["Authorization"] = f"Bearer {token}" + try: + connection.request(method, path, body=request_body, headers=headers) + response = connection.getresponse() + response.read() + return response.status + except (OSError, http.client.HTTPException): + return None + finally: + connection.close() + + +def ensure_shim_token( + *, + args: argparse.Namespace, + state: dict, + port: int, + requester=actor_shim_request, +) -> None: + """Persist a per-actor token before installing it, then verify the auth boundary.""" + token = state.get("shim_token") + if not token: + token = secrets.token_urlsafe(32) + state["shim_token"] = token + save_state(args.state_file, state) + + status = requester( + port=port, + atespace=args.atespace, + actor_name=args.actor_name, + method="POST", + path="/token", + body={"token": token}, + ) + if status == 409: + already_installed = requester( + port=port, + atespace=args.atespace, + actor_name=args.actor_name, + method="GET", + path="/read", + token=token, + ) + if already_installed != 200: + raise RuntimeError( + "the actor already has a shim token that does not match the private state; " + "manual reconciliation is required" + ) + elif status != 201: + raise RuntimeError( + f"shim token installation failed (HTTP {status or 'no response'})" + ) + + checks = ( + ("missing-token /read", "GET", "/read", None, None, 401), + ("wrong-token /read", "GET", "/read", f"{token}x", None, 401), + ("authenticated /read", "GET", "/read", token, None, 200), + ( + "second /token", + "POST", + "/token", + None, + {"token": "one-time-install-probe"}, + 409, + ), + ("open /healthz", "GET", "/healthz", None, None, 200), + ) + for label, method, path, bearer, request_body, expected in checks: + observed = requester( + port=port, + atespace=args.atespace, + actor_name=args.actor_name, + method=method, + path=path, + token=bearer, + body=request_body, + ) + if observed != expected: + raise RuntimeError( + f"shim token acceptance failed at {label} " + f"(HTTP {observed or 'no response'}, expected {expected})" + ) + print("-- per-actor shim token installed; missing/wrong/correct and one-time checks passed") + + async def ensure_golden_template( control: SubstrateControl, args: argparse.Namespace, @@ -613,8 +735,10 @@ async def async_main(args: argparse.Namespace) -> None: ) await ensure_actor(control, args, template_name, template_uid, state) - print("-- confirming current control-service health through the actor route") + print("-- installing and checking the per-actor shim token through the actor route") with actor_router_tunnel(args) as route_port: + ensure_shim_token(args=args, state=state, port=route_port) + print("-- confirming current control-service health through the actor route") await wait_for_actor_health( lambda: actor_health_check( port=route_port, diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index 0623bc8..f02a663 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -1,6 +1,8 @@ """Credential-free regressions for the gate-5 setup script's build and rerun identity.""" import json +from contextlib import redirect_stdout +from io import StringIO import tempfile import unittest from dataclasses import replace @@ -228,6 +230,38 @@ def wait(self, timeout): self.assertIn("18081:8081", command) self.assertTrue(process.terminated) + def test_egress_hostname_rules_are_passed_as_repeatable_flags(self): + args = SimpleNamespace( + egress_tool="/fixture/mainloop-egress-tool", + kubeconfig=FIXTURE_KUBECONFIG, + context="kind-substrate-preview", + atespace="live-agent-gate", + actor_name="claude-gate5", + egress_deny_all=False, + egress_allow_all=False, + egress_hostnames=["api.anthropic.com", "api.openai.com"], + ) + with patch.object(gate5_setup.subprocess, "run") as run: + gate5_setup.run_egress_tool(args) + self.assertEqual( + run.call_args.args[0], + [ + "/fixture/mainloop-egress-tool", + "--kubeconfig", + FIXTURE_KUBECONFIG, + "--context", + "kind-substrate-preview", + "--atespace", + "live-agent-gate", + "--actor", + "claude-gate5", + "--hostname", + "api.anthropic.com", + "--hostname", + "api.openai.com", + ], + ) + class Gate5StateTests(unittest.TestCase): def args(self, state_file: str, **overrides): @@ -324,6 +358,76 @@ def state(self, path, *, actor_uid="actor-1"): gate5_setup.save_state(path, state) return state + def test_state_file_is_private_for_future_shim_token(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + gate5_setup.save_state(path, {"shim_token": "fixture-secret"}) + self.assertEqual(Path(path).stat().st_mode & 0o777, 0o600) + + def test_shim_token_is_persisted_before_body_only_install_and_verified(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + state = {"run_id": "run-token"} + args = SimpleNamespace( + state_file=path, + atespace="live-agent-gate", + actor_name="claude-gate5", + ) + token = "fixture-shim-token-with-at-least-32-characters" + statuses = [201, 401, 401, 200, 409, 200] + calls = [] + + def requester(**kwargs): + calls.append(kwargs) + return statuses.pop(0) + + output = StringIO() + with ( + patch.object(gate5_setup.secrets, "token_urlsafe", return_value=token), + redirect_stdout(output), + ): + gate5_setup.ensure_shim_token( + args=args, state=state, port=18091, requester=requester + ) + + self.assertEqual(json.loads(Path(path).read_text())["shim_token"], token) + self.assertEqual(Path(path).stat().st_mode & 0o777, 0o600) + self.assertEqual(calls[0]["method"], "POST") + self.assertEqual(calls[0]["path"], "/token") + self.assertEqual(calls[0]["body"], {"token": token}) + self.assertNotIn("token", calls[0]) + self.assertEqual(calls[1]["token"], None) + self.assertEqual(calls[2]["token"], f"{token}x") + self.assertEqual(calls[3]["token"], token) + self.assertNotIn(token, output.getvalue()) + + def test_shim_token_rerun_verifies_existing_token_without_rotating_it(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + token = "existing-shim-token-with-at-least-32-characters" + state = {"run_id": "run-token", "shim_token": token} + gate5_setup.save_state(path, state) + args = SimpleNamespace( + state_file=path, + atespace="live-agent-gate", + actor_name="claude-gate5", + ) + statuses = [409, 200, 401, 401, 200, 409, 200] + calls = [] + + def requester(**kwargs): + calls.append(kwargs) + return statuses.pop(0) + + with patch.object(gate5_setup.secrets, "token_urlsafe") as generate: + gate5_setup.ensure_shim_token( + args=args, state=state, port=18091, requester=requester + ) + + generate.assert_not_called() + self.assertEqual(calls[1]["token"], token) + self.assertEqual(json.loads(Path(path).read_text())["shim_token"], token) + def actor( self, *, uid="actor-1", template_uid="template-new", state=ActorState.RUNNING ): diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 427b3d3..7c2d255 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -24,8 +24,8 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. | `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | | Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | | File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | -| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, credential-free boot | Phase 2: golden snapshot READY; actor RUNNING; `/healthz` and one suspend/resume proved live. Counter/marker restore and worker-loss revert remain unproved. No native-agent session has been run. | -| Claude/Codex agent processes, credentials | Not run in this spike (see "Limits") | +| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, credential-free boot | Golden snapshot READY and actor RUNNING proved live. A later credential-free preview actor hit the pinned gVisor restore error described below. No provider session ran. | +| Claude/Codex agent processes, credentials | Not run; the 2026-09-23 live lane stopped before credential work (see "Limits") | | `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | ## Why not a real agent for the preview-gate edit (credential-injection gap) @@ -171,18 +171,23 @@ per `docs/network-egress.md`'s "CIDR/all policy: dial now" passthrough path) ins **hostname rule** (HTTP/TLS-SNI-specific, and Postgres is neither), which the prior proof's HTTP `fetch`-based trial did not have reason to distinguish. -## Limits / not attempted in this run - -- **Native-session gate, live**: no Claude or Codex session was run. The earlier attempt was - authorized; tool policy rejected particular actions after the boot-time credential fetch - received HTTP 403. Phase 1 removed the unauthenticated relay and repaired the harness. Phase 2 - produced a READY golden snapshot, a RUNNING actor, a healthy `/healthz` through the documented - CONNECT port, and one successful suspend/resume. Counter/marker persistence and worker-loss - revert were not proved. Before any credential work, a tokenless caller reached `POST /run` - through the router and executed a harmless command. No existing ingress authorization primitive - closed this path under the earlier plan, so that credential attempt stopped and Gate 5 remains - partial/fixture pending the rewritten Phase 3 and Phase 4. The owner approved that plan on - 2026-09-23; no provider Secret was created or read in the earlier run. +## Limits from the earlier Phase 2 checkpoint + +- **Native-session gate, live**: no Claude or Codex session ran in the earlier checkpoint. The + owner authorized the work; earlier tool-policy decisions rejected particular actions after + the old relay returned HTTP 403. The relay was removed. The first Phase 3 preview restore + error was later diagnosed as a dead app from the old image and missing readiness probe, then + corrected with a new image and template. The current Phase 3 run passed 3a–3c and stopped at + the hostname-egress bypass described below; no provider credential was delivered. +- Preview/HMR under the router policy and actor-to-actor access were unverified at the earlier + checkpoint. In the current finish run, preview HMR passed, and an unrelated actor's CONNECT + to the shim was rejected at the actor egress gateway before reaching the router. The + per-actor shim token passed live checks, including suspend/resume persistence. Provider Secret + delivery and Phase 4 session continuity remain unproved; no provider Secret was created or + read. +- Phase 2's counter/marker persistence and worker-loss revert were not proved in their original + run. The current token suspend/resume check did not cover worker loss or those markers; those + checks remain open. - **`workspace_bindings` orchestration functions** (`ensure_workspace`, `resume_workspace`, ...) were not exercised against a live Postgres + running backend; only their extracted pure logic (`plan_ensure`, `_binding_from_row`, `is_crashed`) is unit tested, and the transport layer @@ -194,9 +199,9 @@ The original live attempt (outside this doc's own commits) created the live-agen infrastructure and hit a real failure during golden-snapshot creation, reviewed in `.tasknotes/gate5-review-and-recovery-plan-2026-09-22.md`: the golden actor's entrypoint fetched a credential from `cred-server` unconditionally at boot, that fetch was denied (`403`), -and the golden actor exited before its snapshot was captured -- `runsc exit 128` on a later -restore attempt is consistent with capturing a process that had already exited. The harness -script driving that attempt (never committed; reviewed from a scratch copy) also applied an +and the golden actor exited before its snapshot was captured. A later restore returned +`runsc exit 128`; the original evidence did not establish that the denied fetch caused that +restore error. The harness script driving that attempt (never committed; reviewed from a scratch copy) also applied an unresolved `ko://` WorkerPool image, never registered the atespace at the control-plane API (a Kubernetes Namespace of the same name is not an atespace), checked for an existing ActorTemplate with the atespace embedded in the name rather than the CLI's required `-a` flag, @@ -255,37 +260,115 @@ actor and Herdr health path restored, but does not establish counter/marker pers force-delete-worker/revert portion of the lane was not attempted without those markers. The reviewing session reports the full runtime suite passed 189/189 outside its restricted -sandbox before the Phase 2 empty-result correction. After that correction, the focused -Substrate, workspace-adapter, contract, and setup suites passed 91/91. The credential-free proof -is partial live evidence; the native-session capability remains partial/fixture, and the full -counter/marker and worker-loss checks are unproved. +sandbox before the Phase 2 empty-result correction. The previous Phase 2 run's operator calls +after deny-all egress did not complete. In the Phase 3 run, an in-cluster control namespace POST +to `/run` returned 200 after deny-all egress was attached, so the ingress response path worked +for that trusted request. The focused setup, Substrate, and contract suites now pass 85/85. +The credential-free proof is partial live evidence; native-session continuity remains partial, +and full counter/marker and worker-loss checks are unproved. + +### Phase 2b — rerun and router corrections + +The harness now forwards to the router CONNECT listener on Service port 8081, checks live actor +identity before waiting for worker capacity, and handles the pinned CLI's `{}` zero-worker result. +The focused 63-test setup/Substrate suite passed before the Phase 3 code; the latest setup, +Substrate, and contract run passed 85/85. Phase 2b was committed locally as `3ca780a`. +The in-sandbox `unittest discover` process IDs 2293648 and 2293673 were still alive but invisible +to the sandbox check; the reviewing session killed them on 2026-09-23. The full suite result +189/189 is attributed to that reviewing session. + +### Phase 3–4 finish run — router boundary, preview correction, and egress stop + +One fresh `kind-substrate-preview` cluster ran Kind v0.33.0, node image v1.37.0, enforcing +kindnet `v20260820`, and pinned Substrate +`cdac9baef81dd319b46086d695266e6161e9e592` with the agentgateway dataplane. Phase 3a passed: +the client reached an HTTP probe before policy, was blocked by deny-all ingress, then reached it +after a namespace-scoped allow. + +The tracked `k8s/router-ingress-policy.yaml` selects router Pods by `app=atenet-router`. It +allows `mainloop-control` on router control ports, the preview proxy on HTTP only, and +`otel-system` on stats port 15020. NetworkPolicy ports target Pod ports; Service port 80 maps +to router container port 8080. A POST to `/run` through port 8081 returned 200 from +`mainloop-control` and timed out from an unrelated Pod in `default`. + +The corrected `preview-gate-v2` template used the rebuilt image from +`spikes/substrate-workspace-adapter/image/`, an explicit working directory, and `readyz`. The +live Vite page passed HMR through the NGINX preview proxy: editing `main.js` changed the page +heading in place, preserved the browser marker and time origin, and produced a successful HMR +WebSocket upgrade. An unrelated preview actor's CONNECT attempt to +`live-agent-gate/claude-gate5:8090` returned 405 at the actor egress gateway before reaching the +router. The request was rejected, though that result is an egress-gateway denial rather than a +router NetworkPolicy denial. + +The first immutable preview template remains abandoned. Its worker logged `npm error ENOENT` +for `/package.json` before golden checkpoint. `savedMFOwners=[_pause:/]` records the surviving +pause container after the application exited; the template lacked `readyz`, so the dead app was +snapshotted. This is a harness/image/readiness error, not a gVisor restore blocker. Record it as +a candidate upstream issue because Substrate checkpointed an exited container without a clear +error. The corrected template name is `preview-gate-v2`. + +On `live-agent-gate/claude-gate5` (actor UID +`6125c129-7312-453b-aea5-a2fae6745c62`), the live shim-token test passed: missing and wrong +tokens returned 401, the correct token authorized `/run`, a second token install returned 409, +and the token continued to authorize requests after suspend/resume with the same actor UID. A +deny-all control check initially returned `/healthz` 200 and authenticated `/run` 200 with the +command marker observed. After a later restore, `/healthz` intermittently timed out at the shim's +serial Herdr status/pane-read check, while direct `herdr status server` and authenticated +`/read` succeeded. The WorkerPool remained Ready; the failure was located at the shim health +handler, not the router or NetworkPolicy. + +Provider discovery without credentials exited early (Claude rc 1, Codex rc 2) and emitted no +hostnames. Since those were not CLI-measured destinations, the initial bounded check used +provider/API/auth host candidates, informed by [Claude Code's network requirements](https://code.claude.com/docs/en/corporate-proxy), +the [Codex ChatGPT sign-in flow](https://developers.openai.com/codex/auth), and the Codex file's +`chatgpt` auth mode: +`api.anthropic.com`, `platform.claude.com`, `api.openai.com`, `auth.openai.com`, and +`chatgpt.com`. The live Substrate API confirmed exactly one hostname rule with those entries; +its policy cache is 10 seconds. HTTPS to those hosts reached their public services, but an +unlisted HTTPS request to `example.com` also returned 200. This fails Phase 3d's required +unlisted-host 403 and shows hostname-only egress is not enforced on this agentgateway path. +The actor was restored to a zero-rule deny-all EgressPolicy immediately afterward. No provider +Secret was created or read, no credential was delivered, and no Claude or Codex session was +started. Phase 4 and all snapshot-revert tests were not attempted. + +The restore diagnosis is clarified in the Phase 3 stop-condition section of the finish plan. +The Codex sandbox could not see the stalled `unittest discover` processes 2293648 and 2293673; +the reviewing session killed both on 2026-09-23. The full runtime result 189/189 is attributed +to that reviewing session, not to a sandbox process killed by this agent. ## CapabilityResult -| Capability | State | Scope | Evidence and limit | -| ---------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `workspace_adapter_contract` | partial | fixture | `SubstrateControl` is exercised live; `workspace_bindings` orchestration is fixture-tested, not run against a live backend and Postgres. | -| `substrate_actor_lifecycle` | partial | live | READY golden creation, actor RUNNING, health, suspend, and resume ran live; marker persistence was not proved, and the exact rerun stopped at the occupied single worker before identity reconciliation. | -| `preview_hmr` | proved | live | Real Vite HMR socket and edits through the actor route; survives suspend/resume. | -| `dev_service_postgres` | proved | live | Real Postgres query, narrow allow rule, denied destination, and reconnect after wake. | -| `native_session_continuity` | partial | fixture | No provider session ran. A tokenless unrelated Pod reached `POST /run` through the router; no existing Substrate ingress authorization primitive closed this path. | -| `failure_recovery` | partial | live | Actor CRASHED/revert mechanics were proved in the earlier live lane; backend restart with a durable `recorded` attempt is covered by a fake-backed contract test, not live Postgres delivery. | - -## Recommendation status at the Phase 2b checkpoint - -The earlier recommendation to defer native sessions is superseded by the owner's 2026-09-23 -decision. Current live evidence still does not prove native-session support. Phase 3 must close -the router ingress boundary and deliver credentials through that channel; Phase 4 must then prove -Claude and Codex continuity. The final adopt/defer recommendation remains open until those phases -finish or stop on a named condition. - -## Cleanup - -Phase 0 removed the approved `cred-server` relay resources and both named Secrets, then deleted -the owner-confirmed failed-trial `kind` cluster and its `kind-registry`. The fresh Phase 2 -`kind-substrate-preview` cluster, its `kind-registry`, and the exact local image tag built by -this run were deleted after evidence capture. Final inventory showed only `mainloop-test` and -`mainloop-test-control-plane`; the run-specific image tag was absent and the unrelated `latest` -tag was preserved. Root disk had 21 GiB available and RAM had 9.5 GiB available. `mainloop-test` -was outside the cleanup scope. The owner handles rotation of the Claude and Codex credentials -previously served by the relay. +| Capability | State | Scope | Evidence and limit | +| ----------------------------- | ------- | ---------- | ------------------ | +| `workspace_adapter_contract` | partial | fixture | `SubstrateControl` is exercised live; `workspace_bindings` orchestration is fixture-tested, not run against a live backend and Postgres. | +| `substrate_actor_lifecycle` | partial | live | READY golden creation and actor RUNNING proved live; full marker and worker-loss recovery remain unproved. | +| `preview_hmr` | proved | live | Real Vite HMR edit and WebSocket upgrade succeeded through the preview proxy. | +| `dev_service_postgres` | proved | live | Real Postgres query, narrow allow rule, denied destination, and reconnect after wake. | +| `phase3a_networkpolicy` | proved | live | Kindnet blocked and then allowed the in-cluster HTTP probe according to NetworkPolicy. | +| `router_ingress_boundary` | proved | live | `default` was blocked; `mainloop-control` was admitted; unrelated actor CONNECT to the shim was rejected at actor egress; preview traffic passed. | +| `shim_token_auth` | proved | live | 401/409 behavior, successful bearer use, and token persistence across suspend/resume passed on the final actor. | +| `provider_egress` | failed | live | Exact hostname policy was confirmed, but HTTPS to unlisted `example.com` returned 200 through agentgateway. Actor returned to deny-all. | +| `credential_delivery` | unknown | unverified | No provider Secret was created or read; credentials were not delivered. | +| `native_session_continuity` | partial | unverified | Phase 3d failed before credential delivery; no native session was run. | +| `failure_recovery` | partial | live | Earlier CRASHED/revert mechanics were proved; worker-loss recovery and the backend-restart `recorded` case remain unproved live. | + +## Recommendation + +The corrected preview image resolves the earlier `npm ENOENT`/dead-checkpoint harness error; +that result does not justify deferring native-session adoption. The current agentgateway run +found a separate security blocker: a hostname-only EgressPolicy allowed HTTPS to an unlisted +host. Do not deliver provider credentials or promote native sessions to production until +hostname enforcement is verified on a supported dataplane or another host-aware egress boundary +is added. Production also needs a GitOps-managed router NetworkPolicy on an enforcing CNI, +shim-token issuance by the real Mainloop backend, and snapshot-bucket access controls. The +credential-bearing snapshot trade-off remains open because no credential entered an actor. + +## Cleanup and current cluster state + +Phase 0 removed the approved relay resources and both named Secrets, then deleted the +owner-confirmed failed-trial cluster. Per the owner's 2026-09-23 instruction, cleanup of the +fresh `kind-substrate-preview` cluster and its registry is stopped; both remain up for review. +The actor's EgressPolicy is zero-rule deny-all. `mainloop-test` remains outside scope. No +provider Secret or credential-bearing snapshot was created. Current Kind/Docker inventory and +disk headroom are recorded in the task proof note. The owner handles rotation of credentials +previously served by the removed relay. diff --git a/spikes/substrate-workspace-adapter/egress-tool/main.go b/spikes/substrate-workspace-adapter/egress-tool/main.go index 57d51ac..c3884b3 100644 --- a/spikes/substrate-workspace-adapter/egress-tool/main.go +++ b/spikes/substrate-workspace-adapter/egress-tool/main.go @@ -12,9 +12,9 @@ // cdac9baef81dd319b46086d695266e6161e9e592 when this was written). // // Usage: mainloop-egress-tool --kubeconfig --context --atespace --actor -// --deny-all | --cidr | --allow-all +// --deny-all | --cidr | --hostname ... | --allow-all // -// Fails closed: exactly one of --deny-all, --cidr, or --allow-all is required. An earlier version of this +// Fails closed: exactly one of --deny-all, --cidr, --hostname, or --allow-all is required. An earlier version of this // tool silently allowed all destinations whenever --cidr was omitted (see // .tasknotes/gate5-review-and-recovery-plan-2026-09-22.md, "Make missing egress configuration // fail closed"); --allow-all must now be passed explicitly to get that behavior. @@ -37,11 +37,14 @@ import ( // validateEgressInput is the fail-closed check, isolated as a pure function so it can be // exercised without a cluster or a Substrate checkout. -func validateEgressInput(cidr string, denyAll, allowAll bool) error { +func validateEgressInput(cidr string, hostnames []string, denyAll, allowAll bool) error { selected := 0 if cidr != "" { selected++ } + if len(hostnames) > 0 { + selected++ + } if denyAll { selected++ } @@ -49,7 +52,7 @@ func validateEgressInput(cidr string, denyAll, allowAll bool) error { selected++ } if selected != 1 { - return fmt.Errorf("exactly one of --deny-all, --cidr , or --allow-all is required") + return fmt.Errorf("exactly one of --deny-all, --cidr , --hostname , or --allow-all is required") } return nil } @@ -60,11 +63,16 @@ func main() { atespace := flag.String("atespace", "", "") actorName := flag.String("actor", "", "") cidr := flag.String("cidr", "", "CIDR to allow") + var hostnames []string + flag.Func("hostname", "exact hostname to allow (repeatable)", func(value string) error { + hostnames = append(hostnames, value) + return nil + }) denyAll := flag.Bool("deny-all", false, "explicitly deny all actor egress") allowAll := flag.Bool("allow-all", false, "explicitly allow all egress destinations") flag.Parse() - if err := validateEgressInput(*cidr, *denyAll, *allowAll); err != nil { + if err := validateEgressInput(*cidr, hostnames, *denyAll, *allowAll); err != nil { log.Fatalf("%v", err) } @@ -82,6 +90,8 @@ func main() { rules = []*ateapipb.EgressRule{{All: &emptypb.Empty{}}} } else if *cidr != "" { rules = []*ateapipb.EgressRule{{Cidrs: &ateapipb.CIDRRule{Cidrs: []string{*cidr}}}} + } else if len(hostnames) > 0 { + rules = []*ateapipb.EgressRule{{Hostnames: &ateapipb.HostnameRule{Patterns: hostnames}}} } policy := &ateapipb.EgressPolicy{ Metadata: &ateapipb.ResourceMetadata{Atespace: *atespace, Name: "default"}, diff --git a/spikes/substrate-workspace-adapter/image/entrypoint.sh b/spikes/substrate-workspace-adapter/image/entrypoint.sh index cf6385e..d3d6be9 100644 --- a/spikes/substrate-workspace-adapter/image/entrypoint.sh +++ b/spikes/substrate-workspace-adapter/image/entrypoint.sh @@ -25,7 +25,7 @@ shell_ws=$(herdr --session "${HERDR_SESSION}" workspace create --label shell --c shell_pane=$(echo "${shell_ws}" | jq -r '.result.root_pane.pane_id') echo "${shell_pane}" >"${STATE_DIR}/shell-pane-id" -herdr --session "${HERDR_SESSION}" pane run "${dev_pane}" "npm run dev" +herdr --session "${HERDR_SESSION}" pane run "${dev_pane}" "cd '${VITE_DIR}' && npm run dev -- --host 0.0.0.0" EXEC_SHIM_PANE_ID="${shell_pane}" HERDR_SESSION="${HERDR_SESSION}" node "${EXEC_SHIM}" & diff --git a/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl index 0da7943..68c9ae2 100644 --- a/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl @@ -11,10 +11,10 @@ metadata: apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: - name: preview-gate + name: ${TEMPLATE_NAME} namespace: ${ATESPACE} labels: - workload: preview-gate + workload: ${TEMPLATE_NAME} spec: replicas: 1 workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor @@ -29,10 +29,11 @@ spec: --- metadata: atespace: ${ATESPACE} - name: preview-gate + # ActorTemplates are immutable; use a new name after the old image failed warmup. + name: ${TEMPLATE_NAME} workerSelector: matchLabels: - workload: preview-gate + workload: ${TEMPLATE_NAME} containers: - name: preview image: __IMAGE__ @@ -43,6 +44,9 @@ containers: limits: - { name: cpu, quantity: "1" } - { name: memory, quantity: 1Gi } + readyz: + httpGet: { path: /, port: 80 } + timeoutSeconds: 60 snapshotsConfig: onPause: SNAPSHOT_CONTENT_SCOPE_FULL onCommit: SNAPSHOT_CONTENT_SCOPE_FULL diff --git a/spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl index 24cc6d0..9fca364 100644 --- a/spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/preview-proxy.yaml.tmpl @@ -19,6 +19,10 @@ data: nginx.conf: | events {} http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } server { listen 80; location / { @@ -29,7 +33,7 @@ data: proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; } } } diff --git a/spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml b/spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml new file mode 100644 index 0000000..01901a3 --- /dev/null +++ b/spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml @@ -0,0 +1,45 @@ +# Mainloop-owned ingress boundary for the Substrate router. NetworkPolicy ports +# are pod ports: Service port 80 maps to the router's HTTP listener on 8080. +# The agentgateway router forwards requests to api.ate-system.svc:443 itself; +# actor/provider egress goes through atenet-egress, so neither ate-system worker +# pods nor the egress gateway are router ingress clients in this install. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: atenet-router-mainloop-ingress + namespace: ate-system +spec: + podSelector: + matchLabels: + app: atenet-router + policyTypes: + - Ingress + ingress: + # Trusted Mainloop backend. It uses HTTP plus the Substrate CONNECT listener + # for actor control and arbitrary-port workspace traffic. + - from: + - namespaceSelector: + matchLabels: + mainloop.dev/role: control + ports: + - { protocol: TCP, port: 8080 } + - { protocol: TCP, port: 8081 } + - { protocol: TCP, port: 8443 } + - { protocol: TCP, port: 8444 } + # The fixed NGINX preview proxy injects ate-target-actor and only needs HTTP. + - from: + - namespaceSelector: + matchLabels: + mainloop.dev/role: workspace + podSelector: + matchLabels: + app: preview-proxy + ports: + - { protocol: TCP, port: 8080 } + # The installer annotates the router for Prometheus scraping on 15020. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: otel-system + ports: + - { protocol: TCP, port: 15020 } diff --git a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh index 907aba7..80ee2f0 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh +++ b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh @@ -6,11 +6,9 @@ # "Credential-injection gap"). # # Credential-free by construction: this entrypoint never fetches a credential and never -# requires network access to reach a running state. The template controller uses the -# ActorTemplate's `/healthz` readiness check before accepting the golden actor. A boot path -# that depends on a credential fetch succeeding can fail golden creation when its relay is -# denied or unreachable, which is what happened. Credential delivery is deferred to a -# reviewed boundary and is never performed during golden-actor warmup or from this entrypoint. +# requires network access to reach a running state. Mainloop installs a per-actor shim token +# and provider credentials only after the final actor is RUNNING. The golden actor stays clean. +# The template controller checks `/healthz` before accepting the golden actor. set -eu mkdir -p "${HOME}" "${HOME}/.claude" "${CODEX_HOME}" diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index 8755b95..7a308da 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -12,6 +12,9 @@ 'use strict'; const http = require('node:http'); const { execFile } = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); const PANE_ID = process.env.EXEC_SHIM_PANE_ID; const SESSION = process.env.HERDR_SESSION; @@ -20,6 +23,56 @@ if (!PANE_ID || !SESSION) { process.exit(1); } +const tokenPath = path.join(process.env.HOME || '/home/agent', '.mainloop', 'exec-shim-token'); +let bearerToken = null; +try { + bearerToken = fs.readFileSync(tokenPath, 'utf8'); +} catch (err) { + if (err.code !== 'ENOENT') throw err; +} + +function authorized(req) { + if (bearerToken === null) return true; + const header = req.headers.authorization; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false; + const provided = Buffer.from(header.slice('Bearer '.length)); + const expected = Buffer.from(bearerToken); + return provided.length === expected.length && crypto.timingSafeEqual(provided, expected); +} + +function unauthorized(res) { + res.writeHead(401, { 'content-type': 'text/plain' }).end('unauthorized'); +} + +function requestBody(req, onBody) { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 65536) req.destroy(); + }); + req.on('end', () => onBody(body)); +} + +function installToken(token) { + if (bearerToken !== null) return false; + if (typeof token !== 'string' || token.length < 32 || token.length > 4096) return null; + const directory = path.dirname(tokenPath); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); + const fd = fs.openSync(tokenPath, 'wx', 0o600); + try { + fs.writeFileSync(fd, token, 'utf8'); + fs.fsyncSync(fd); + } catch (err) { + fs.closeSync(fd); + fs.rmSync(tokenPath, { force: true }); + throw err; + } + fs.closeSync(fd); + bearerToken = token; + return true; +} + function herdr(args, res) { execFile('herdr', ['--session', SESSION, ...args], (err, stdout, stderr) => { if (err) { @@ -55,7 +108,38 @@ const server = http.createServer((req, res) => { healthz(res); return; } + if (req.method === 'POST' && req.url === '/token') { + if (bearerToken !== null) { + res.writeHead(409).end('token already set'); + return; + } + requestBody(req, (body) => { + let token; + try { + token = JSON.parse(body).token; + } catch { + res.writeHead(400).end('invalid json'); + return; + } + try { + const installed = installToken(token); + if (installed === null) { + res.writeHead(400).end('invalid token'); + return; + } + if (!installed) { + res.writeHead(409).end('token already set'); + return; + } + res.writeHead(201).end('token set'); + } catch { + res.writeHead(500).end('token could not be stored'); + } + }); + return; + } if (req.method === 'GET' && req.url === '/read') { + if (!authorized(req)) return unauthorized(res); herdr(['pane', 'read', PANE_ID], res); return; } @@ -63,12 +147,8 @@ const server = http.createServer((req, res) => { res.writeHead(404).end(); return; } - let body = ''; - req.on('data', (chunk) => { - body += chunk; - if (body.length > 65536) req.destroy(); - }); - req.on('end', () => { + if (!authorized(req)) return unauthorized(res); + requestBody(req, (body) => { let command; try { command = JSON.parse(body).command; @@ -84,6 +164,7 @@ const server = http.createServer((req, res) => { }); }); -server.listen(8090, '0.0.0.0', () => { - console.log('exec-shim listening on :8090, pane', PANE_ID); +server.listen(Number(process.env.EXEC_SHIM_PORT || 8090), '0.0.0.0', () => { + const address = server.address(); + console.log(`exec-shim listening on :${address.port}`); }); diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js new file mode 100644 index 0000000..2428509 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -0,0 +1,130 @@ +'use strict'; + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const shim = path.resolve(__dirname, '../live-agent-image/exec-shim.js'); + +async function startShim(home, fakeBin, shimPath) { + const child = spawn(process.execPath, [shimPath], { + env: { + ...process.env, + HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + HERDR_SESSION: 'shim-test', + EXEC_SHIM_PANE_ID: 'pane-test', + EXEC_SHIM_PORT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`shim did not start: ${output}`)), 5000); + child.once('error', reject); + child.once('exit', (code) => reject(new Error(`shim exited ${code}: ${output}`))); + child.stdout.on('data', (chunk) => { + output += chunk; + const match = output.match(/exec-shim listening on :(\d+)/); + if (match) { + clearTimeout(timer); + resolve(Number(match[1])); + } + }); + child.stderr.on('data', (chunk) => { + output += chunk; + }); + }); + return { child, port, output: () => output }; +} + +function request(port, method, route, { body, token } = {}) { + return new Promise((resolve, reject) => { + const headers = {}; + if (body !== undefined) headers['content-type'] = 'application/json'; + if (token !== undefined) headers.authorization = `Bearer ${token}`; + const req = http.request( + { host: '127.0.0.1', port, method, path: route, headers }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => + resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }), + ); + }, + ); + req.once('error', reject); + if (body !== undefined) req.end(JSON.stringify(body)); + else req.end(); + }); +} + +async function stop(child) { + if (child.exitCode !== null) return; + child.kill('SIGTERM'); + await once(child, 'exit'); +} + +test('shim token gates run/read, is one-time, private, and survives process restart', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-test-')); + const home = path.join(root, 'home'); + const fakeBin = path.join(root, 'bin'); + const shimPath = path.join(root, 'exec-shim.js'); + fs.mkdirSync(home); + fs.mkdirSync(fakeBin); + fs.copyFileSync(shim, shimPath); + const herdr = path.join(fakeBin, 'herdr'); + fs.writeFileSync( + herdr, + '#!/bin/sh\nif [ "$3" = "status" ] && [ "$4" = "server" ]; then echo "status: running"; exit 0; fi\nif [ "$3" = "pane" ] && [ "$4" = "read" ]; then echo "fixture pane"; exit 0; fi\nexit 0\n', + { mode: 0o700 }, + ); + + let running = await startShim(home, fakeBin, shimPath); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + + assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); + assert.equal((await request(running.port, 'GET', '/read')).status, 200); + assert.equal( + (await request(running.port, 'POST', '/run', { body: { command: 'echo fixture' } })).status, + 200, + ); + + const token = 'fixture-only-token-with-at-least-thirty-two-characters'; + assert.equal( + (await request(running.port, 'POST', '/token', { body: { token } })).status, + 201, + ); + const tokenPath = path.join(home, '.mainloop', 'exec-shim-token'); + assert.equal(fs.statSync(tokenPath).mode & 0o777, 0o600); + assert.equal(fs.statSync(path.dirname(tokenPath)).mode & 0o777, 0o700); + assert.equal(fs.readFileSync(tokenPath, 'utf8'), token); + assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); + assert.equal((await request(running.port, 'GET', '/read')).status, 401); + assert.equal((await request(running.port, 'GET', '/read', { token: `${token}-wrong` })).status, 401); + assert.equal((await request(running.port, 'GET', '/read', { token })).status, 200); + assert.equal((await request(running.port, 'POST', '/run', { body: { command: 'echo fixture' } })).status, 401); + assert.equal( + (await request(running.port, 'POST', '/run', { body: { command: 'echo fixture' }, token })).status, + 200, + ); + assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 409); + assert.equal(running.output().includes(token), false); + + await stop(running.child); + running = await startShim(home, fakeBin, shimPath); + assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); + assert.equal((await request(running.port, 'GET', '/read')).status, 401); + assert.equal((await request(running.port, 'GET', '/read', { token })).status, 200); + assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 409); +}); diff --git a/spikes/substrate-workspace-adapter/tools/router-client.js b/spikes/substrate-workspace-adapter/tools/router-client.js new file mode 100644 index 0000000..4555f11 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tools/router-client.js @@ -0,0 +1,110 @@ +// Small control-plane probe for the Substrate CONNECT router. Request data, including any +// bearer token or credential body, is read from stdin so it never appears in argv or logs. +'use strict'; + +const http = require('node:http'); + +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + if (input.length > 1_200_000) process.stdin.destroy(); +}); +process.stdin.on('end', () => { + let request; + try { + request = JSON.parse(input); + } catch { + process.stdout.write('{"error":"invalid request"}\n'); + process.exitCode = 2; + return; + } + + const targetPort = Number(request.targetPort || 8090); + if ( + !/^[a-z0-9-]+$/.test(request.atespace || '') || + !/^[a-z0-9-]+$/.test(request.actor || '') || + !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535 || + !['GET', 'POST'].includes(request.method) || + typeof request.path !== 'string' || !request.path.startsWith('/') + ) { + process.stdout.write('{"error":"invalid request"}\n'); + process.exitCode = 2; + return; + } + + let completed = false; + const finish = (result) => { + if (completed) return; + completed = true; + process.stdout.write(`${JSON.stringify(result)}\n`); + }; + + const tunnel = http.request({ + hostname: 'atenet-router.ate-system.svc.cluster.local', + port: 8081, + method: 'CONNECT', + path: `actor-upstream:${targetPort}`, + headers: { + host: `actor-upstream:${targetPort}`, + 'ate-target-actor': `${request.atespace}/${request.actor}`, + }, + timeout: 8000, + }); + tunnel.on('connect', (response, socket) => { + if (response.statusCode !== 200) { + socket.destroy(); + finish({ connectStatus: response.statusCode }); + return; + } + + const headers = { connection: 'close', host: `actor-upstream:${targetPort}` }; + let body = Buffer.alloc(0); + if (request.body !== undefined) { + body = Buffer.from(JSON.stringify(request.body)); + headers['content-type'] = 'application/json'; + headers['content-length'] = String(body.length); + } else if (typeof request.rawBody === 'string') { + body = Buffer.from(request.rawBody); + headers['content-type'] = 'application/octet-stream'; + headers['content-length'] = String(body.length); + } + if (typeof request.bearerToken === 'string') { + headers.authorization = `Bearer ${request.bearerToken}`; + } + const headerLines = Object.entries(headers).map(([name, value]) => `${name}: ${value}`); + const requestHeader = Buffer.from( + `${request.method} ${request.path} HTTP/1.1\r\n${headerLines.join('\r\n')}\r\n\r\n`, + ); + const responseChunks = []; + if (response.head && response.head.length) responseChunks.push(response.head); + socket.setTimeout(15000, () => socket.destroy(new Error('response timeout'))); + socket.on('data', (chunk) => responseChunks.push(chunk)); + socket.on('end', () => { + const responseBytes = Buffer.concat(responseChunks); + const headerEnd = responseBytes.indexOf('\r\n\r\n'); + if (headerEnd < 0) { + finish({ transportError: 'invalid-http-response', receivedBytes: responseBytes.length }); + return; + } + const statusLine = responseBytes.subarray(0, headerEnd).toString('latin1').split('\r\n')[0]; + const status = Number(statusLine.split(' ')[1]); + const responseBody = responseBytes.subarray(headerEnd + 4).toString('utf8'); + finish(request.includeResponseBody + ? { status, body: responseBody } + : { status }); + }); + socket.on('error', (err) => finish({ transportError: err.code || 'request-failed' })); + socket.write(Buffer.concat([requestHeader, body])); + }); + tunnel.on('response', (response) => { + response.resume(); + finish({ connectStatus: response.statusCode }); + }); + tunnel.on('timeout', () => { + tunnel.destroy(); + finish({ transportError: 'connect-timeout' }); + }); + tunnel.on('error', (err) => finish({ transportError: err.code || 'connect-failed' })); + tunnel.end(); +}); From 5536f6603bf3f5508c5995a3d1c6162fd16fe0da Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:15:33 +0000 Subject: [PATCH 11/30] fix: preflight actor images and bound health probes Verify the digest-addressed actor image through the active run registry before creating an immutable ActorTemplate. Add a fake-backed regression for the registry HEAD request and refusal cases. Bound exec-shim health commands, share concurrent probes, and cache a recent healthy result so restored actors can satisfy readyz cheaply. --- backend/scripts/gate5_setup.py | 51 ++++++++++++++ backend/tests/runtime/test_gate5_setup.py | 56 +++++++++++++++ .../live-agent-image/exec-shim.js | 69 +++++++++++++++---- .../tests/exec-shim.test.js | 46 ++++++++++++- 4 files changed, 207 insertions(+), 15 deletions(-) diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index d1ac0d2..0687384 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -58,6 +58,8 @@ import sys import tempfile import time +import urllib.error +import urllib.request import uuid from pathlib import Path @@ -193,6 +195,54 @@ def verify_substrate_source(source: str, *, runner=subprocess.run) -> str: return str(root) +IMAGE_MANIFEST_ACCEPT = ", ".join( + ( + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.oci.image.manifest.v1+json", + ) +) + + +def verify_image_manifest(image: str, *, opener=urllib.request.urlopen) -> None: + """Verify the digest-addressed image is present in the registry workers will use. + + Local Docker RepoDigests can refer to a registry that has since been deleted. A HEAD + against the registry endpoint catches that setup error before creating an immutable + ActorTemplate and its golden actor. + """ + reference, separator, digest = image.partition("@") + registry, slash, repository = reference.partition("/") + if ( + not separator + or not slash + or not registry + or not repository + or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest) + ): + raise RuntimeError("--image must be a registry/repository pinned by a full sha256 digest") + + request = urllib.request.Request( + f"http://{registry}/v2/{repository}/manifests/{digest}", + headers={"Accept": IMAGE_MANIFEST_ACCEPT}, + method="HEAD", + ) + try: + with opener(request, timeout=10) as response: + status = response.status + except urllib.error.HTTPError as exc: + raise RuntimeError( + f"image manifest preflight failed: registry returned HTTP {exc.code}" + ) from exc + except (urllib.error.URLError, OSError) as exc: + raise RuntimeError(f"image manifest preflight failed: {exc}") from exc + if not 200 <= status < 300: + raise RuntimeError( + f"image manifest preflight failed: registry returned HTTP {status}" + ) + print(f"-- registry manifest confirmed for {repository}@{digest}") + + def get_cluster_identity( *, context: str, kubeconfig: str, runner=subprocess.run ) -> dict[str, str]: @@ -702,6 +752,7 @@ async def ensure_actor( async def async_main(args: argparse.Namespace) -> None: + verify_image_manifest(args.image) substrate_src = verify_substrate_source(args.substrate_src) cluster = get_cluster_identity(context=args.context, kubeconfig=args.kubeconfig) state = prepare_run_state(args, cluster) diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index f02a663..6da8493 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -9,6 +9,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch +from urllib.error import HTTPError from mainloop.runtime.substrate import ( ActorFailedToStart, @@ -30,6 +31,61 @@ def completed(argv, returncode=0, stdout="", stderr=""): class Gate5SourceAndBuildTests(unittest.TestCase): + def test_image_manifest_preflight_checks_registry_endpoint_and_accept_types(self): + calls = [] + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def opener(request, *, timeout): + calls.append((request, timeout)) + return Response() + + image = "localhost:5001/live-agent-gate@sha256:" + "a" * 64 + gate5_setup.verify_image_manifest(image, opener=opener) + + request, timeout = calls[0] + self.assertEqual( + request.full_url, + f"http://localhost:5001/v2/live-agent-gate/manifests/sha256:{'a' * 64}", + ) + self.assertEqual(request.get_method(), "HEAD") + self.assertEqual(request.get_header("Accept"), gate5_setup.IMAGE_MANIFEST_ACCEPT) + self.assertEqual(timeout, 10) + + def test_image_manifest_preflight_fails_before_template_on_missing_manifest(self): + image = "localhost:5001/live-agent-gate@sha256:" + "b" * 64 + + def missing(request, *, timeout): + error = HTTPError(request.full_url, 404, "MANIFEST_UNKNOWN", {}, None) + error.close() + raise error + + with self.assertRaisesRegex(RuntimeError, "registry returned HTTP 404"): + gate5_setup.verify_image_manifest(image, opener=missing) + + def test_image_manifest_preflight_rejects_tag_or_malformed_digest(self): + calls = [] + + def opener(*_args, **_kwargs): + calls.append(True) + + for image in ( + "localhost:5001/live-agent-gate:latest", + "localhost:5001/live-agent-gate@sha256:bad", + ): + with self.subTest(image=image), self.assertRaisesRegex( + RuntimeError, "full sha256 digest" + ): + gate5_setup.verify_image_manifest(image, opener=opener) + self.assertEqual(calls, []) + def make_source(self, root: Path) -> Path: (root / ".git").mkdir(parents=True) (root / "go.mod").write_text("module fixture\n") diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index 7a308da..34d6896 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -18,6 +18,8 @@ const path = require('node:path'); const PANE_ID = process.env.EXEC_SHIM_PANE_ID; const SESSION = process.env.HERDR_SESSION; +const HEALTH_COMMAND_TIMEOUT_MS = 1500; +const HEALTH_CACHE_MS = 3000; if (!PANE_ID || !SESSION) { console.error('exec-shim: EXEC_SHIM_PANE_ID and HERDR_SESSION are required'); process.exit(1); @@ -85,22 +87,63 @@ function herdr(args, res) { }); } +function runHealthCheck() { + return new Promise((resolve, reject) => { + execFile( + 'herdr', + ['--session', SESSION, 'status', 'server'], + { timeout: HEALTH_COMMAND_TIMEOUT_MS }, + (statusErr, stdout) => { + if (statusErr || !/^status:\s+running\s*$/m.test(stdout)) { + reject(statusErr || new Error('Herdr server is not running')); + return; + } + execFile( + 'herdr', + ['--session', SESSION, 'pane', 'read', PANE_ID], + { timeout: HEALTH_COMMAND_TIMEOUT_MS }, + (paneErr) => { + if (paneErr) { + reject(paneErr); + return; + } + resolve(); + }, + ); + }, + ); + }); +} + +let lastGoodHealthAt = 0; +let healthCheckInFlight = null; + function healthz(res) { - execFile('herdr', ['--session', SESSION, 'status', 'server'], (statusErr, stdout) => { - if (statusErr || !/^status:\s+running\s*$/m.test(stdout)) { + const respond = (ready) => { + if (res.destroyed) return; + if (ready) { + // The shim is responsive, Herdr reports a running server, and the shell pane exists. + // Never return status output or pane contents. + res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); + } else { res.writeHead(503, { 'content-type': 'text/plain' }).end('not ready'); - return; } - execFile('herdr', ['--session', SESSION, 'pane', 'read', PANE_ID], (paneErr) => { - if (paneErr) { - res.writeHead(503, { 'content-type': 'text/plain' }).end('not ready'); - return; - } - // The request is served by this shim, Herdr reports a running server, and pane read - // confirms the shell pane still exists. Do not expose status output or pane contents. - res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); - }); - }); + }; + + if (Date.now() - lastGoodHealthAt < HEALTH_CACHE_MS) { + respond(true); + return; + } + if (!healthCheckInFlight) { + healthCheckInFlight = runHealthCheck() + .then(() => { + lastGoodHealthAt = Date.now(); + }) + .finally(() => { + healthCheckInFlight = null; + }); + } + healthCheckInFlight.then(() => respond(true), () => respond(false)); } const server = http.createServer((req, res) => { diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js index 2428509..f3b991a 100644 --- a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -13,10 +13,11 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const shim = path.resolve(__dirname, '../live-agent-image/exec-shim.js'); -async function startShim(home, fakeBin, shimPath) { +async function startShim(home, fakeBin, shimPath, extraEnv = {}) { const child = spawn(process.execPath, [shimPath], { env: { ...process.env, + ...extraEnv, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, HERDR_SESSION: 'shim-test', @@ -67,7 +68,7 @@ function request(port, method, route, { body, token } = {}) { } async function stop(child) { - if (child.exitCode !== null) return; + if (child.exitCode !== null || child.signalCode !== null) return; child.kill('SIGTERM'); await once(child, 'exit'); } @@ -128,3 +129,44 @@ test('shim token gates run/read, is one-time, private, and survives process rest assert.equal((await request(running.port, 'GET', '/read', { token })).status, 200); assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 409); }); + +test('healthz bounds Herdr calls and reuses a recent successful check', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-health-')); + const home = path.join(root, 'home'); + const fakeBin = path.join(root, 'bin'); + const shimPath = path.join(root, 'exec-shim.js'); + const logPath = path.join(root, 'herdr-calls.log'); + fs.mkdirSync(home); + fs.mkdirSync(fakeBin); + fs.copyFileSync(shim, shimPath); + const herdr = path.join(fakeBin, 'herdr'); + fs.writeFileSync( + herdr, + '#!/bin/sh\nif [ -n "${EXEC_SHIM_TEST_LOG:-}" ]; then printf "%s %s\\n" "$3" "$4" >> "$EXEC_SHIM_TEST_LOG"; fi\nif [ "$3" = "status" ] && [ "$4" = "server" ]; then if [ "${HERDR_TEST_SLOW_STATUS:-}" = "1" ]; then exec sleep 5; fi; echo "status: running"; exit 0; fi\nif [ "$3" = "pane" ] && [ "$4" = "read" ]; then echo "fixture pane"; exit 0; fi\nexit 0\n', + { mode: 0o700 }, + ); + + const running = await startShim(home, fakeBin, shimPath, { + EXEC_SHIM_TEST_LOG: logPath, + }); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + + assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); + assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); + assert.deepEqual(fs.readFileSync(logPath, 'utf8').trim().split('\n'), [ + 'status server', + 'pane read', + ]); + + await stop(running.child); + const slow = await startShim(home, fakeBin, shimPath, { + HERDR_TEST_SLOW_STATUS: '1', + }); + t.after(async () => stop(slow.child)); + const startedAt = Date.now(); + assert.equal((await request(slow.port, 'GET', '/healthz')).status, 503); + assert.ok(Date.now() - startedAt < 4000, 'hung Herdr status must be bounded by execFile timeout'); +}); From e362e95c82f4eb563a68036f59d863fc79e36b53 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:50:10 +0000 Subject: [PATCH 12/30] feat: add Substrate egress tooling and native credential delivery Trust the run-specific Substrate MITM CA in the actor image and point Claude and Codex at the system CA bundle. Require an installed shim token before writing Codex auth, validate the payload as a JSON object, and cover the one-time private write with fixtures. Use the selected atespace when waiting for an eligible worker. Include the Round 3 credential-provider and egress-injection sources and document the measured hostname, injection, KPR, Claude, and Codex results. Track A passed; Track B was skipped. KPR=true passed the Substrate core but actor DNS to the kube-dns Service IP timed out; the exact component remains unverified, so the preview uses KPR=false. Claude recalled its nonce after suspend/resume. The Codex turn stopped at Envoy upstream SAN verification: the IPv4 override fixed api.openai.com (upstream HTTP 401, expected without auth), but chatgpt.com failed verification on a shared Cloudflare IP because Envoy received api.openai.com SANs while expecting chatgpt.com. The cause remains unverified. The live V4_PREFERRED override is an uncommitted cluster-only change; production adoption remains deferred. Checks: 24 fake-backed gate5 setup tests (uv run), 3 exec-shim tests, the pinned Substrate source check, and git diff --check passed. --- backend/scripts/gate5_setup.py | 5 +- backend/tests/runtime/test_gate5_setup.py | 22 ++++ docs/spikes/substrate-workspace-adapter.md | 101 +++++++++++++++--- .../live-agent-image/Dockerfile | 13 +++ .../live-agent-image/exec-shim.js | 66 ++++++++++++ .../tests/exec-shim.test.js | 50 +++++++++ .../tools/README-round3-egress.md | 30 ++++++ .../tools/round3-claude-provider/main.go | 76 +++++++++++++ .../tools/round3-credprovider/main.go | 95 ++++++++++++++++ .../tools/round3-egress-injection/main.go | 44 ++++++++ 10 files changed, 484 insertions(+), 18 deletions(-) create mode 100644 spikes/substrate-workspace-adapter/tools/README-round3-egress.md create mode 100644 spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go create mode 100644 spikes/substrate-workspace-adapter/tools/round3-credprovider/main.go create mode 100644 spikes/substrate-workspace-adapter/tools/round3-egress-injection/main.go diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index 0687384..715398a 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -81,7 +81,6 @@ ) PINNED_SUBSTRATE_COMMIT = "cdac9baef81dd319b46086d695266e6161e9e592" -WORKER_NAMESPACE = "live-agent-gate" WORKER_SELECTOR = "workload=live-agent-gate" WORKER_SANDBOX_CLASS = "gvisor" ACTOR_SHIM_PORT = 8090 @@ -672,12 +671,12 @@ async def wait_for_worker_if_actor_is_absent( return print( - f"-- waiting for an eligible worker in namespace={WORKER_NAMESPACE}, " + f"-- waiting for an eligible worker in namespace={args.atespace}, " f"selector={WORKER_SELECTOR}, sandbox={WORKER_SANDBOX_CLASS}" ) await wait_for_eligible_worker( control, - WORKER_NAMESPACE, + args.atespace, WORKER_SELECTOR, WORKER_SANDBOX_CLASS, timeout_s=args.worker_timeout, diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index 6da8493..724aa0d 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -605,6 +605,28 @@ async def wait_for_worker(*_args, **_kwargs): json.loads(Path(path).read_text())["actor_uid"], "actor-new" ) + def test_worker_wait_uses_the_selected_atespace_namespace(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + args = self.args(path) + args.atespace = "native-codex" + control = SetupControl(None) + observed = {} + + async def wait_for_worker(_control, namespace, *_args, **_kwargs): + observed["namespace"] = namespace + + with patch.object(gate5_setup, "wait_for_eligible_worker", wait_for_worker): + asyncio_run( + gate5_setup.wait_for_worker_if_actor_is_absent( + control, + args, + {"run_id": "run-codex", "actor_uid": None, "template_uid": "template-codex"}, + ) + ) + + self.assertEqual(observed["namespace"], "native-codex") + def test_owned_rerun_skips_worker_wait_when_its_actor_occupies_only_worker(self): with tempfile.TemporaryDirectory() as temp_dir: path = str(Path(temp_dir) / "state.json") diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 7c2d255..e6ba0da 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -5,6 +5,15 @@ Status: local spike, not a product feature. Adapter code lives in lives in `spikes/substrate-workspace-adapter/`. See `docs/spikes/k8s-herdr-agents.md` for the native-session/Herdr spike this one builds on and does not replace. +## Current status — Round 3 and Phase 4 (2026-09-23) + +Substrate actors, the Cilium-backed preview cluster, router ingress controls, per-actor shim +tokens, and Envoy hostname enforcement were measured live. Claude completed a native turn and +recalled a nonce after suspend/resume. Codex's auth file was installed safely, but its native +turn failed at Envoy's upstream connection to the OpenAI API (HTTP 503, reset before response +headers). Gate 5 is partial/live, so defer production adoption of the native-session path until +Codex egress and session continuity are proved. + ## What it shows [Substrate](https://github.com/agent-substrate/substrate) can provide the per-session isolated @@ -14,7 +23,7 @@ owner of the session<->actor mapping, delivery, and audit state. A Mainloop-auth Substrate actor instead of a fixed StatefulSet pod, and `backend/src/mainloop/runtime/substrate.py` drives its lifecycle through the real `kubectl ate` control-plane CLI. -## Real versus stand-in +## Earlier real-versus-stand-in inventory (before Round 3) | Layer | Status | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -28,7 +37,7 @@ drives its lifecycle through the real `kubectl ate` control-plane CLI. | Claude/Codex agent processes, credentials | Not run; the 2026-09-23 live lane stopped before credential work (see "Limits") | | `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | -## Why not a real agent for the preview-gate edit (credential-injection gap) +## Preview-gate edit path and Round 3 credential boundary Substrate's pinned commit has no generic secret-injection mechanism equivalent to a Kubernetes Secret volume/env mount. `ActorTemplate` container env values are literal only (no @@ -40,16 +49,19 @@ identity, that injects a credential into an outbound request; it does not hand a CLI's local environment or filesystem. The pinned commit also has experimental static-header injection from a Kubernetes Secret URI -into decrypted outbound requests. It requires Envoy with SDSMint and the experimental -credential-injection flag. Envoy 1.39.1 crashed on this host, while agentgateway does not support -the injection path. The revised Phase 3 uses a Mainloop-owned router NetworkPolicy and a per-actor -shim token instead; credentials are delivered through that closed channel. This keeps the -credential path separate from the unsupported Envoy feature, though actor snapshots will contain -credentials after delivery. The earlier unauthenticated relay is not used. The preview-gate -measurement below instead uses -a generic exec shim (`spikes/substrate-workspace-adapter/image/exec-shim.js`) that pastes text -into a real Herdr shell pane via `herdr pane run` -- a real shell executing a real command, just -not a credentialed agent's own tool call. +into decrypted outbound requests. HTTPS hostname rules require the Envoy sdsmint overlay and +`--experimental-egress-credential-injection`; the plain Envoy overlay has no MITM egress, and +agentgateway does not implement this injection path. Envoy 1.39.1 crashed during an earlier +install attempt, but the rebuilt Round 3 cluster ran Envoy with sdsmint and the credential +provider. Claude's credential was injected on the upstream leg and stayed out of actor snapshots. +Codex instead received `auth.json` through the authenticated shim because Codex refreshes that +file locally; its actor snapshots therefore contain that credential. Neither credential value +was logged or copied into a golden snapshot. The old unauthenticated relay was not used. + +The preview/HMR edit itself still uses a generic exec shim +(`spikes/substrate-workspace-adapter/image/exec-shim.js`) that pastes text into a real Herdr shell +pane via `herdr pane run` -- a real shell executing a real command, but not a native agent's own +Bash tool. ## Run it @@ -336,7 +348,7 @@ The Codex sandbox could not see the stalled `unittest discover` processes 229364 the reviewing session killed both on 2026-09-23. The full runtime result 189/189 is attributed to that reviewing session, not to a sandbox process killed by this agent. -## CapabilityResult +## Historical CapabilityResult (before Round 3) | Capability | State | Scope | Evidence and limit | | ----------------------------- | ------- | ---------- | ------------------ | @@ -352,7 +364,7 @@ to that reviewing session, not to a sandbox process killed by this agent. | `native_session_continuity` | partial | unverified | Phase 3d failed before credential delivery; no native session was run. | | `failure_recovery` | partial | live | Earlier CRASHED/revert mechanics were proved; worker-loss recovery and the backend-restart `recorded` case remain unproved live. | -## Recommendation +## Historical recommendation (superseded by the Round 3/Phase 4 result) The corrected preview image resolves the earlier `npm ENOENT`/dead-checkpoint harness error; that result does not justify deferring native-session adoption. The current agentgateway run @@ -363,7 +375,7 @@ is added. Production also needs a GitOps-managed router NetworkPolicy on an enfo shim-token issuance by the real Mainloop backend, and snapshot-bucket access controls. The credential-bearing snapshot trade-off remains open because no credential entered an actor. -## Cleanup and current cluster state +## Historical cleanup note (superseded by the Phase 4 owner-review hold) Phase 0 removed the approved relay resources and both named Secrets, then deleted the owner-confirmed failed-trial cluster. Per the owner's 2026-09-23 instruction, cleanup of the @@ -372,3 +384,62 @@ The actor's EgressPolicy is zero-rule deny-all. `mainloop-test` remains outside provider Secret or credential-bearing snapshot was created. Current Kind/Docker inventory and disk headroom are recorded in the task proof note. The owner handles rotation of credentials previously served by the removed relay. + +## Current CapabilityResult — Round 3 and Phase 4 + +| Capability | State | Scope | Evidence and limit | +| --- | --- | --- | --- | +| `workspace_adapter_contract` | partial | fixture | Contract and identity reconciliation are covered by fakes; the `workspace_bindings` orchestration was not run against live Postgres and a running backend. | +| `substrate_actor_lifecycle` | proved | live | Golden, actor readiness, router ingress, and suspend/resume passed on the Cilium preview cluster. | +| `preview_hmr` | proved | live | The earlier real Vite/WebSocket HMR route remains measured and passed. | +| `dev_service_postgres` | proved | live | The earlier real PostgreSQL query, narrow CIDR rule, denied destination, and wake reconnection remain measured and passed. | +| `networkpolicy_enforcement` | proved | live | Cilium blocked the denied probe and allowed the control-namespace probe. | +| `router_ingress_boundary` | proved | live | `default` was denied; `mainloop-control` was admitted; the preview route remained functional. | +| `shim_token_auth` | proved | live | Missing/wrong/correct token, one-time install, and suspend/resume persistence passed. | +| `image_manifest_preflight` | proved | live | The exact pushed digest returned registry HTTP 200 before template creation; fake-backed tests cover the manifest check and rejection cases. | +| `shim_healthz` | proved | live | Readiness checks pass through the actor route; bounded Herdr calls and cached health have fixture coverage. | +| `cilium_kube_proxy_replacement` | partial | live | KPR=true core checks passed. DNS to the CoreDNS Pod IP worked, while DNS to the kube-dns Service IP timed out. The exact failing component was not isolated; kube-proxy replacement ClusterIP translation from the nested actor network is only a hypothesis. The cluster fell back to KPR=false as directed. | +| `provider_hostname_egress` | proved | live | Actor A's listed Claude host returned 404 while unlisted `example.com` and raw IP returned 403. Actor B's differing rule allowed `example.com` to reach an upstream 503, denied `api.anthropic.com`, and denied raw IP. Track B was skipped because Track A passed. | +| `dummy_header_injection` | proved | live | The compare-only provider returned a fixed match boolean; the final actor had no injected value in its env/files and received no echoed value. Earlier dummy-only diagnostic history is in the task proof note. | +| `credential_delivery` | proved | live | Claude's credential was retrieved by the actor-bound provider and injected on the Envoy upstream leg. Codex `auth.json` was installed through the authenticated shim, once, mode 0600. These delivery proofs do not imply successful authentication for both CLIs. | +| `claude_native_session` | proved | live | Claude Code 2.1.280 completed a turn, preserved its session ID through suspend/resume, and recalled a prior nonce. | +| `codex_native_session` | partial | live | Codex CLI 0.156.1 created a thread, but Envoy returned an upstream-connect 503 before the turn completed; the model was not reported, and there was no marker or recall. No Codex revert was attempted. | +| `native_session_continuity` | partial | live | Claude suspend/resume recall passed. Claude post-worker-loss recall and transcript rollback after snapshot revert remain unverified; Codex continuity did not pass. | +| `snapshot_revert` | partial | live | Claude state-A restore and actor lifecycle passed, but transcript/history rollback was not conclusively measured. Codex revert was not attempted. | +| `worker_loss_recovery` | partial | live | The Claude actor recovered on a replacement worker from its completed snapshot. Post-loss native recall did not complete. | +| `backend_restart_delivery_reconciliation` | proved | fixture | The `ContractStore` fake test models a persisted `recorded` row across ownership restart, requires `not_delivered` evidence before retry, and rejects a duplicate attempt. | +| `snapshot_bucket_access_control` | unknown | unverified | Read access to snapshot storage was not established in this install. | + +## Current recommendation + +**Defer production adoption of the Substrate native-session path.** The live run proves that +Substrate can host the isolated workspace lifecycle, enforce router ingress and actor hostname +egress, inject Claude credentials outside the actor snapshot, and preserve a Claude session +through suspend/resume. It does not prove the required two-agent path: Codex could not complete a +turn because the egress Envoy reset its OpenAI upstream connection before response headers +(HTTP 503). The same credential-free API request returned `server: envoy` and an +`upstream connect error`, while TLS verification succeeded and the unlisted-host rule still +returned 403. The bounded review found no image, CA, install-flag, actor identity, or hostname +policy mismatch. + +Before production, resolve and retest that Envoy-to-OpenAI upstream hop, then prove Codex +authentication and nonce recall across suspend/resume. Production also needs a GitOps-managed +router NetworkPolicy, a CNI that enforces it, shim-token issuance from the real Mainloop backend, +and snapshot-bucket access control. The snapshot containing Codex `auth.json` must be removed +when the actor is deleted; the owner handles credential rotation. + +## Current cleanup and review hold + +The Phase 4 owner-review brief requires both actors to remain SUSPENDED for review and their +credential Secrets to be deleted. The dedicated preview cluster and run-built images remain +available for that review; `mainloop-test` remains untouched. Temporary delivery Jobs, +ConfigMaps, shim-token Secrets, the Claude provider, and the Claude/Codex credential Secrets are +removed. The actor snapshots are intentionally retained for owner inspection, so the Codex +snapshot still contains `auth.json`. The proof note records the final inventory and identifies +this review hold as the reason the older finish-plan teardown was not applied. + +The Round 3 helper sources are under `spikes/substrate-workspace-adapter/tools/`; the captured +sources match the pinned Substrate checkout. The Codex file-write route now requires an installed +shim token even on a tokenless golden and validates a JSON object. That source hardening is +fixture-tested, but the retained actor image digest predates the change; rebuild before using +that route in another run. diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile index bbd0b26..fae870c 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1.7 # Live-agent-gate spike image (gate 5, bounded live proof): real Herdr + real Claude Code / # Codex CLIs + agentctl, driven by the same generic exec shim as the other gate images. # herdr/claude/codex/codex-code-mode-host are copied from the host into the build context by the @@ -9,6 +10,16 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates git curl procps ripgrep \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -u 10001 agent +# The run-specific Substrate MITM CA is a public trust anchor. BuildKit mounts +# it without retaining the input file or putting the PEM in the build command. +ARG EGRESS_MITM_CA_SHA256="" +RUN --mount=type=secret,id=egress-mitm-ca,target=/run/secrets/egress-mitm-ca,required=false \ + if [ -s /run/secrets/egress-mitm-ca ]; then \ + test -n "${EGRESS_MITM_CA_SHA256}" && \ + test "$(sha256sum /run/secrets/egress-mitm-ca | cut -d' ' -f1)" = "${EGRESS_MITM_CA_SHA256}" && \ + install -m 0644 /run/secrets/egress-mitm-ca /usr/local/share/ca-certificates/substrate-egress-mitm.crt && \ + update-ca-certificates; \ + fi COPY herdr /usr/local/bin/herdr COPY claude /usr/local/bin/claude COPY codex /usr/local/bin/codex @@ -25,5 +36,7 @@ ENV WORKSPACE_PATH=/work/repo ENV CODEX_HOME=/home/agent/.codex ENV AGENT_CONFIG_DIR=/etc/agent-config ENV HERDR_SESSION=mainloop-live-agent +ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt USER 10001:10001 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index 34d6896..11c19aa 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -75,6 +75,45 @@ function installToken(token) { return true; } +function installCodexAuth(contentBase64) { + if (typeof contentBase64 !== 'string') return false; + const contents = Buffer.from(contentBase64, 'base64'); + if ( + contents.length === 0 || + contents.length > 65536 || + contents.toString('base64') !== contentBase64 + ) return false; + let auth; + try { + auth = JSON.parse(contents.toString('utf8')); + } catch { + return false; + } + if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return false; + const codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || '/home/agent', '.codex'); + fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); + fs.chmodSync(codexHome, 0o700); + const destination = path.join(codexHome, 'auth.json'); + let fd; + try { + fd = fs.openSync(destination, 'wx', 0o600); + } catch (err) { + if (err.code === 'EEXIST') return null; + throw err; + } + try { + fs.writeFileSync(fd, contents); + fs.fsyncSync(fd); + fs.closeSync(fd); + fs.chmodSync(destination, 0o600); + } catch (err) { + try { fs.closeSync(fd); } catch {} + fs.rmSync(destination, { force: true }); + throw err; + } + return true; +} + function herdr(args, res) { execFile('herdr', ['--session', SESSION, ...args], (err, stdout, stderr) => { if (err) { @@ -186,6 +225,33 @@ const server = http.createServer((req, res) => { herdr(['pane', 'read', PANE_ID], res); return; } + if (req.method === 'POST' && req.url === '/write-codex-auth') { + if (bearerToken === null || !authorized(req)) return unauthorized(res); + requestBody(req, (body) => { + let contentBase64; + try { + contentBase64 = JSON.parse(body).contentBase64; + } catch { + res.writeHead(400).end('invalid json'); + return; + } + try { + const installed = installCodexAuth(contentBase64); + if (installed === null) { + res.writeHead(409).end('auth file already exists'); + return; + } + if (!installed) { + res.writeHead(400).end('invalid auth payload'); + return; + } + res.writeHead(201).end('Codex auth installed'); + } catch { + res.writeHead(500).end('Codex auth could not be stored'); + } + }); + return; + } if (req.method !== 'POST' || req.url !== '/run') { res.writeHead(404).end(); return; diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js index f3b991a..783e6c8 100644 --- a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -130,6 +130,56 @@ test('shim token gates run/read, is one-time, private, and survives process rest assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 409); }); +test('Codex auth write requires an installed shim token and creates a private one-time file', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-codex-auth-')); + const home = path.join(root, 'home'); + const fakeBin = path.join(root, 'bin'); + const shimPath = path.join(root, 'exec-shim.js'); + const codexHome = path.join(home, '.codex'); + fs.mkdirSync(home); + fs.mkdirSync(fakeBin); + fs.copyFileSync(shim, shimPath); + const herdr = path.join(fakeBin, 'herdr'); + fs.writeFileSync( + herdr, + '#!/bin/sh\nif [ "$3" = "status" ] && [ "$4" = "server" ]; then echo "status: running"; exit 0; fi\nif [ "$3" = "pane" ] && [ "$4" = "read" ]; then echo "fixture pane"; exit 0; fi\nexit 0\n', + { mode: 0o700 }, + ); + + const running = await startShim(home, fakeBin, shimPath, { CODEX_HOME: codexHome }); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + + const authContents = JSON.stringify({ fixture: 'codex-auth-never-logged' }); + const contentBase64 = Buffer.from(authContents).toString('base64'); + const write = (encoded, token) => + request(running.port, 'POST', '/write-codex-auth', { + body: { contentBase64: encoded }, + ...(token === undefined ? {} : { token }), + }); + + assert.equal((await write(contentBase64)).status, 401, 'golden actor must reject writes before token installation'); + assert.equal(fs.existsSync(codexHome), false); + + const token = 'fixture-only-codex-shim-token-at-least-thirty-two-chars'; + assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 201); + assert.equal((await write(contentBase64)).status, 401); + assert.equal((await write(contentBase64, `${token}-wrong`)).status, 401); + assert.equal((await write(Buffer.from('[]').toString('base64'), token)).status, 400); + assert.equal((await write('!!!!', token)).status, 400); + + assert.equal((await write(contentBase64, token)).status, 201); + const authPath = path.join(codexHome, 'auth.json'); + assert.equal(fs.readFileSync(authPath, 'utf8'), authContents); + assert.equal(fs.statSync(codexHome).mode & 0o777, 0o700); + assert.equal(fs.statSync(authPath).mode & 0o777, 0o600); + assert.equal((await write(contentBase64, token)).status, 409, 'auth file must not be overwritten'); + assert.equal(running.output().includes(authContents), false); + assert.equal(running.output().includes(contentBase64), false); +}); + test('healthz bounds Herdr calls and reuses a recent successful check', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-health-')); const home = path.join(root, 'home'); diff --git a/spikes/substrate-workspace-adapter/tools/README-round3-egress.md b/spikes/substrate-workspace-adapter/tools/README-round3-egress.md new file mode 100644 index 0000000..1cdb39b --- /dev/null +++ b/spikes/substrate-workspace-adapter/tools/README-round3-egress.md @@ -0,0 +1,30 @@ +# Round 3 egress helpers + +These source files are a live-only, dummy-credential harness for the 2026-09-23 +Substrate preview run. They are not production credential-provider code. + +- `round3-credprovider/main.go` implements a temporary mTLS gRPC credential + provider and HTTPS echo endpoint. It reads only a Kubernetes Secret created + specifically for this test. The echo endpoint compares the injected header + to that dummy value and returns a boolean result; it never returns the value. +- `round3-egress-injection/main.go` updates one actor's egress policy to inject + a credential for one hostname. `--prefix` supports bearer-token headers; + omit it for the dummy echo check. +- `round3-claude-provider/main.go` is the separate Phase 3e preview provider. + It is pinned to the Claude Secret URI and one actor SPIFFE ID, reads the + credential from a read-only Secret mount, and logs only a success marker. + It is test-run scaffolding, not a general-purpose or production provider. + +Build from the pinned Substrate checkout (`cdac9baef81dd319b46086d695266e6161e9e592`), +where the imported internal packages and protobuf modules are available: + +```sh +CGO_ENABLED=0 GOFLAGS=-mod=vendor go build -o /tmp/round3-credprovider ./cmd/round3-credprovider +CGO_ENABLED=0 GOFLAGS=-mod=vendor go build -o /tmp/round3-egress-injection ./cmd/round3-egress-injection +CGO_ENABLED=0 GOFLAGS=-mod=vendor go build -o /tmp/round3-claude-provider ./cmd/round3-claude-provider +``` + +Use only a throwaway dummy Secret with the first provider in a disposable +preview cluster. The Phase 3e provider is separately actor-bound; pass its +credential only as a read-only Secret mount. Never put a credential in source, +logs, actor commands, or echo responses. diff --git a/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go b/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go new file mode 100644 index 0000000..cc7371c --- /dev/null +++ b/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "log" + "net" + "os" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +const ( + expectedURI = "ate-secret://kubernetes.io/mainloop-control/claude-oauth/oauth-token" + expectedActorID = "spiffe://substrate-actor.local/atespace/live-agent-gate/actor/egress-actor-a" + credentialPath = "/run/claude/oauth-token" + servingBundlePath = "/run/servicedns/credential-bundle.pem" + clientCAPath = "/run/podidentity-ca/trust-bundle.pem" +) + +type provider struct { + credproviderpb.UnimplementedCredentialProviderServer +} + +func (provider) FetchSecret(_ context.Context, req *credproviderpb.FetchSecretRequest) (*credproviderpb.FetchSecretResponse, error) { + if req.GetUri() != expectedURI || req.GetActorSpiffeId() != expectedActorID { + return nil, status.Error(codes.PermissionDenied, "credential request rejected") + } + value, err := os.ReadFile(credentialPath) + if err != nil { + log.Printf("credential_fetch=unavailable actor_identity_match=true") + return nil, status.Error(codes.Unavailable, "credential unavailable") + } + if len(value) == 0 { + return nil, status.Error(codes.NotFound, "credential unavailable") + } + log.Printf("credential_fetch=ok actor_identity_match=true credential_kind=claude-oauth") + return &credproviderpb.FetchSecretResponse{OpaqueBytes: value}, nil +} + +func main() { + servingCert, err := tls.LoadX509KeyPair(servingBundlePath, servingBundlePath) + if err != nil { + log.Fatal("serving certificate unavailable") + } + caBytes, err := os.ReadFile(clientCAPath) + if err != nil { + log.Fatal("client CA unavailable") + } + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(caBytes) { + log.Fatal("client CA bundle invalid") + } + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{servingCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + } + grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) + credproviderpb.RegisterCredentialProviderServer(grpcServer, provider{}) + listener, err := net.Listen("tcp", ":50051") + if err != nil { + log.Fatal("gRPC listener unavailable") + } + log.Printf("credential_provider_ready=true") + if err := grpcServer.Serve(listener); err != nil { + log.Fatal("gRPC server failed") + } +} diff --git a/spikes/substrate-workspace-adapter/tools/round3-credprovider/main.go b/spikes/substrate-workspace-adapter/tools/round3-credprovider/main.go new file mode 100644 index 0000000..a08dbb7 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tools/round3-credprovider/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "io" + "log" + "net" + "net/http" + "os" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" + + "github.com/agent-substrate/substrate/pkg/proto/credproviderpb" +) + +const expectedURI = "ate-secret://kubernetes.io/ate-system/round3-dummy/token" + +type provider struct { + credproviderpb.UnimplementedCredentialProviderServer + secretPath string +} + +func (p provider) FetchSecret(_ context.Context, req *credproviderpb.FetchSecretRequest) (*credproviderpb.FetchSecretResponse, error) { + if req.GetUri() != expectedURI || !strings.HasPrefix(req.GetActorSpiffeId(), "spiffe://") { + return nil, status.Error(codes.PermissionDenied, "dummy provider request rejected") + } + value, err := os.ReadFile(p.secretPath) + if err != nil { + return nil, status.Error(codes.Unavailable, "dummy value unavailable") + } + log.Printf("credential_fetch=ok actor_identity_present=true") + return &credproviderpb.FetchSecretResponse{OpaqueBytes: bytes.TrimSpace(value)}, nil +} + +func main() { + bundle := "/run/servicedns/credential-bundle.pem" + servingCert, err := tls.LoadX509KeyPair(bundle, bundle) + if err != nil { + log.Fatal("serving certificate unavailable") + } + caBytes, err := os.ReadFile("/run/podidentity-ca/trust-bundle.pem") + if err != nil { + log.Fatal("client CA unavailable") + } + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(caBytes) { + log.Fatal("client CA bundle invalid") + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{servingCert}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs} + grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) + credproviderpb.RegisterCredentialProviderServer(grpcServer, provider{secretPath: "/run/dummy/token"}) + listener, err := net.Listen("tcp", ":50051") + if err != nil { + log.Fatal("gRPC listener unavailable") + } + go func() { + log.Printf("credential_provider_ready=true") + if err := grpcServer.Serve(listener); err != nil { + log.Fatal("gRPC server failed") + } + }() + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "text/plain") + if r.URL.Path == "/readyz" { + w.WriteHeader(http.StatusNoContent) + return + } + want, err := os.ReadFile("/run/dummy/token") + if err != nil { + http.Error(w, "dummy value unavailable", http.StatusServiceUnavailable) + return + } + if bytes.Equal(bytes.TrimSpace(want), []byte(r.Header.Get("X-Mainloop-Dummy"))) { + _, _ = io.WriteString(w, "injected-header-matched") + return + } + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, "injected-header-mismatch") + }) + server := &http.Server{ + Addr: ":8443", + Handler: nil, + TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{servingCert}}, + } + if err := server.ListenAndServeTLS("", ""); err != nil { + log.Fatal("TLS echo listener unavailable") + } +} diff --git a/spikes/substrate-workspace-adapter/tools/round3-egress-injection/main.go b/spikes/substrate-workspace-adapter/tools/round3-egress-injection/main.go new file mode 100644 index 0000000..9b4b6a6 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tools/round3-egress-injection/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + + "github.com/agent-substrate/substrate/internal/ateclient" + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" +) + +func main() { + kubeconfig := flag.String("kubeconfig", "", "") + contextName := flag.String("context", "", "") + atespace := flag.String("atespace", "", "") + actor := flag.String("actor", "", "") + hostname := flag.String("hostname", "", "") + header := flag.String("header", "", "") + prefix := flag.String("prefix", "", "") + uri := flag.String("credential-uri", "", "") + flag.Parse() + ctx := context.Background() + cli, err := ateclient.NewClient(ctx, *kubeconfig, *contextName, "", "", false) + if err != nil { + log.Fatal("ateapi client unavailable") + } + defer cli.Close() + ref := resources.ActorRef{Atespace: *atespace, Name: *actor}.ToObjectRef() + existing, err := cli.GetActorEgressPolicy(ctx, &ateapipb.GetActorEgressPolicyRequest{Actor: ref}) + if err != nil { + log.Fatal("actor egress policy unavailable") + } + policy := &ateapipb.EgressPolicy{Metadata: existing.GetMetadata(), Rules: []*ateapipb.EgressRule{{ + Hostnames: &ateapipb.HostnameRule{Patterns: []string{*hostname}, Effects: &ateapipb.EgressRuleEffects{ + InjectStaticHeaders: []*ateapipb.CredentialHeaderInjection{{Header: *header, Prefix: *prefix, CredentialUri: *uri}}, + }}, + }}} + if _, err := cli.UpdateActorEgressPolicy(ctx, &ateapipb.UpdateActorEgressPolicyRequest{Actor: ref, EgressPolicy: policy}); err != nil { + log.Fatal("injection policy update failed") + } + fmt.Println("egress_header_injection_policy=updated") +} From 7db742b077c4c91761e4b7372e0b7fbeb5c66fec Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:22:43 +0000 Subject: [PATCH 13/30] feat: add native-agent credential delivery and startup Add one authenticated /credential endpoint for allowlisted Claude and Codex credentials, preserving Codex JSON validation and exclusive one-time writes. Add control-side path-based delivery through temporary control Secrets and an actor-scoped router tunnel. Seed native CLI first-run configuration and add explicit Claude and Codex launchers; Claude loads its token into the CLI environment with telemetry and update traffic disabled. Checks: 9 fake-backed Node tests, 4 fake-backed Python tests, shell and JavaScript syntax checks, and git diff --check passed. The live Phase 4 proof did not use this code; this draft contains local code only. --- backend/scripts/gate5_deliver_credentials.py | 479 ++++++++++++++++++ .../tests/runtime/test_gate5_credentials.py | 138 +++++ .../live-agent-image/Dockerfile | 4 +- .../bin/prepare-native-agent-config.cjs | 51 ++ .../live-agent-image/bin/start-native-agent | 47 ++ .../live-agent-image/entrypoint.sh | 21 +- .../live-agent-image/exec-shim.js | 113 +++-- .../tests/deliver-credentials.test.js | 214 ++++++++ .../tests/exec-shim.test.js | 40 +- .../tools/phase4/deliver-credentials.cjs | 155 ++++++ 10 files changed, 1185 insertions(+), 77 deletions(-) create mode 100644 backend/scripts/gate5_deliver_credentials.py create mode 100644 backend/tests/runtime/test_gate5_credentials.py create mode 100644 spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs create mode 100755 spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent create mode 100644 spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js create mode 100644 spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs diff --git a/backend/scripts/gate5_deliver_credentials.py b/backend/scripts/gate5_deliver_credentials.py new file mode 100644 index 0000000..a420cb9 --- /dev/null +++ b/backend/scripts/gate5_deliver_credentials.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +"""Deliver one provider credential from a control-namespace Secret to its final actor. + +The source file is passed only as a path. kubectl reads it through an inherited file +descriptor into a Secret in mainloop-control; a short-lived control Job mounts that Secret +and sends its contents in the authenticated exec-shim request body. Nothing reads a Secret +back through the Kubernetes API, and neither secret value is placed in argv, env, or logs. + +This is a separate command from gate5_setup.py, so golden creation and credential-free actor +setup never invoke delivery. +""" + +import argparse +import json +import os +import re +import secrets +import stat +import subprocess # nosec B404 - fixed kubectl commands, secret data via file descriptors +import sys +import tempfile +from pathlib import Path +from typing import Callable + +CONTROL_NAMESPACE = "mainloop-control" +DEFAULT_CONTEXT = "kind-substrate-preview" +DEFAULT_KUBECONFIG = "/tmp/substrate-preview-kubeconfig" +DEFAULT_IMAGE = ( + "localhost:5001/live-agent-gate@sha256:" + "8ec007c56b070a2357f20203807197e42ebdb8d0c0e155d57a3bd48fb8d10f57" +) +MAX_SECRET_BYTES = 1024 * 1024 +ACTOR_NAMESPACES = {"claude": "native-claude", "codex": "native-codex"} +SHARED_CLUSTER_NOTE = ( + Path(__file__).resolve().parents[2] + / ".tasknotes" + / "shared-cluster-2026-09-23.md" +) +DELIVERY_SCRIPT = ( + Path(__file__).resolve().parents[2] + / "spikes" + / "substrate-workspace-adapter" + / "tools" + / "phase4" + / "deliver-credentials.cjs" +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--context", default=DEFAULT_CONTEXT) + parser.add_argument("--kubeconfig", default=DEFAULT_KUBECONFIG) + parser.add_argument("--actor-namespace", required=True) + parser.add_argument("--actor-name", required=True) + parser.add_argument("--state-file", required=True) + parser.add_argument("--credential", choices=("claude", "codex"), required=True) + parser.add_argument( + "--claude-token-file", default=str(Path.home() / ".claude-token") + ) + parser.add_argument( + "--codex-auth-file", default=str(Path.home() / ".codex" / "auth.json") + ) + parser.add_argument("--image", default=DEFAULT_IMAGE) + return parser.parse_args() + + +def read_private_state(path: str, *, actor_namespace: str, actor_name: str) -> dict: + try: + with open(path, encoding="utf-8") as stream: + state = json.load(stream) + except (OSError, ValueError): + raise RuntimeError("private actor state is unavailable") from None + + if not isinstance(state, dict) or ( + state.get("context") != DEFAULT_CONTEXT + or state.get("atespace") != actor_namespace + or state.get("actor_name") != actor_name + or not state.get("actor_uid") + ): + raise RuntimeError("private actor state does not own the requested final actor") + token = state.get("shim_token") + if ( + not isinstance(token, str) + or not 32 <= len(token) <= 4096 + or re.search(r"\s", token) + ): + raise RuntimeError("private actor state has no valid shim token") + return state + + +def require_handover(path: Path = SHARED_CLUSTER_NOTE) -> None: + try: + note = path.read_text(encoding="utf-8") + except OSError: + raise RuntimeError("shared-cluster handover note is unavailable") from None + if not re.search(r"(?m)^\*\*Handover:\*\*\s+done(?:\s|$)", note): + raise RuntimeError("shared-cluster handover is not done; no resources were created") + + +def kubectl_prefix(context: str, kubeconfig: str) -> list[str]: + if context != DEFAULT_CONTEXT: + raise RuntimeError(f"refusing context {context!r}; expected {DEFAULT_CONTEXT!r}") + return ["kubectl", "--context", context, "--kubeconfig", kubeconfig] + + +def run_kubectl( + argv: list[str], + *, + runner: Callable = subprocess.run, + input_bytes: bytes | None = None, + pass_fds: tuple[int, ...] = (), + timeout: int = 60, + action: str, +): + try: + return runner( + argv, + input=input_bytes, + pass_fds=pass_fds, + capture_output=True, + check=True, + timeout=timeout, + ) + except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired): + raise RuntimeError(f"{action} failed") from None + + +def create_secret_from_path( + *, + context: str, + kubeconfig: str, + namespace: str, + name: str, + key: str, + source_path: str, + runner: Callable = subprocess.run, +) -> None: + """Create a Secret without placing the source path or bytes in child argv/env/logs.""" + try: + source = open(source_path, "rb") + except OSError: + raise RuntimeError("credential source file is unavailable") from None + with source: + details = os.fstat(source.fileno()) + if ( + not stat.S_ISREG(details.st_mode) + or details.st_size <= 0 + or details.st_size > MAX_SECRET_BYTES + ): + raise RuntimeError("credential source file has an invalid size or type") + source_fd = source.fileno() + descriptor_path = f"/proc/self/fd/{source_fd}" + create = [ + *kubectl_prefix(context, kubeconfig), + "create", + "secret", + "generic", + name, + "--namespace", + namespace, + f"--from-file={key}={descriptor_path}", + "--dry-run=client", + "-o", + "json", + ] + result = run_kubectl( + create, + runner=runner, + pass_fds=(source_fd,), + action="credential Secret rendering", + ) + apply = [*kubectl_prefix(context, kubeconfig), "apply", "-f", "-"] + run_kubectl( + apply, + runner=runner, + input_bytes=result.stdout, + action="credential Secret creation", + ) + + +def apply_json( + *, + context: str, + kubeconfig: str, + manifest: dict, + runner: Callable = subprocess.run, + action: str, +) -> None: + encoded = json.dumps(manifest, separators=(",", ":")).encode("utf-8") + run_kubectl( + [*kubectl_prefix(context, kubeconfig), "apply", "-f", "-"], + runner=runner, + input_bytes=encoded, + action=action, + ) + + +def delete_delivery_resources( + *, context: str, kubeconfig: str, names: list[str], runner: Callable +) -> None: + run_kubectl( + [ + *kubectl_prefix(context, kubeconfig), + "delete", + "--namespace", + CONTROL_NAMESPACE, + "--ignore-not-found=true", + "--wait=true", + "--timeout=15s", + *names, + ], + runner=runner, + timeout=20, + action="delivery resource cleanup", + ) + + +def build_job( + *, + name: str, + image: str, + credential: str, + credential_secret: str, + shim_secret: str, + actor_namespace: str, + actor_name: str, +) -> dict: + return { + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": {"name": name, "namespace": CONTROL_NAMESPACE}, + "spec": { + "activeDeadlineSeconds": 60, + "backoffLimit": 0, + "ttlSecondsAfterFinished": 120, + "template": { + "metadata": {"labels": {"mainloop.dev/role": "control"}}, + "spec": { + "automountServiceAccountToken": False, + "restartPolicy": "Never", + "securityContext": { + "runAsNonRoot": True, + "runAsUser": 10001, + "runAsGroup": 10001, + "fsGroup": 10001, + }, + "containers": [ + { + "name": "deliver-credential", + "image": image, + "imagePullPolicy": "IfNotPresent", + "command": [ + "node", + "/var/run/phase4-delivery/deliver-credentials.cjs", + ], + "env": [ + {"name": "CREDENTIAL_KIND", "value": credential}, + {"name": "ACTOR_NAMESPACE", "value": actor_namespace}, + {"name": "ACTOR_NAME", "value": actor_name}, + { + "name": "ROUTER_HOST", + "value": "atenet-router.ate-system.svc.cluster.local", + }, + {"name": "ROUTER_PORT", "value": "8081"}, + ], + "volumeMounts": [ + { + "name": "delivery-script", + "mountPath": "/var/run/phase4-delivery", + "readOnly": True, + }, + { + "name": "credential", + "mountPath": "/var/run/phase4-credentials", + "readOnly": True, + }, + { + "name": "shim-auth", + "mountPath": "/var/run/phase4-shim-auth", + "readOnly": True, + }, + ], + "securityContext": { + "allowPrivilegeEscalation": False, + "readOnlyRootFilesystem": True, + "capabilities": {"drop": ["ALL"]}, + }, + } + ], + "volumes": [ + { + "name": "delivery-script", + "configMap": { + "name": name, + "items": [ + { + "key": "deliver-credentials.cjs", + "path": "deliver-credentials.cjs", + } + ], + }, + }, + { + "name": "credential", + "secret": { + "secretName": credential_secret, + "defaultMode": 0o440, + "items": [ + {"key": "credential", "path": "credential"} + ], + }, + }, + { + "name": "shim-auth", + "secret": { + "secretName": shim_secret, + "defaultMode": 0o440, + "items": [{"key": "token", "path": "token"}], + }, + }, + ], + }, + }, + }, + } + + +def deliver_credentials( + args: argparse.Namespace, + *, + runner: Callable = subprocess.run, + handover_note: Path = SHARED_CLUSTER_NOTE, +) -> None: + require_handover(handover_note) + expected_namespace = ACTOR_NAMESPACES[args.credential] + if args.actor_namespace != expected_namespace: + raise RuntimeError("credential kind and actor namespace do not match") + if not re.fullmatch( + r"localhost:5001/live-agent-gate@sha256:[0-9a-f]{64}", args.image + ): + raise RuntimeError("delivery image must be the digest-pinned live-agent-gate image") + + state = read_private_state( + args.state_file, + actor_namespace=args.actor_namespace, + actor_name=args.actor_name, + ) + credential_path = ( + args.claude_token_file if args.credential == "claude" else args.codex_auth_file + ) + credential_secret = f"phase4-{args.credential}-{secrets.token_hex(4)}" + shim_secret = f"phase4-shim-{secrets.token_hex(4)}" + job_name = f"phase4-delivery-{args.credential}-{secrets.token_hex(4)}" + resource_names = [ + f"job/{job_name}", + f"configmap/{job_name}", + f"secret/{credential_secret}", + f"secret/{shim_secret}", + ] + failed = False + try: + create_secret_from_path( + context=args.context, + kubeconfig=args.kubeconfig, + namespace=CONTROL_NAMESPACE, + name=credential_secret, + key="credential", + source_path=credential_path, + runner=runner, + ) + with tempfile.TemporaryFile(mode="w+b") as shim_token_file: + shim_token_file.write(state["shim_token"].encode("utf-8")) + shim_token_file.flush() + shim_token_file.seek(0) + shim_fd = shim_token_file.fileno() + shim_key_path = f"/proc/self/fd/{shim_fd}" + create = [ + *kubectl_prefix(args.context, args.kubeconfig), + "create", + "secret", + "generic", + shim_secret, + "--namespace", + CONTROL_NAMESPACE, + f"--from-file=token={shim_key_path}", + "--dry-run=client", + "-o", + "json", + ] + created = run_kubectl( + create, + runner=runner, + pass_fds=(shim_fd,), + action="shim Secret rendering", + ) + run_kubectl( + [*kubectl_prefix(args.context, args.kubeconfig), "apply", "-f", "-"], + runner=runner, + input_bytes=created.stdout, + action="shim Secret creation", + ) + + configmap = { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": job_name, "namespace": CONTROL_NAMESPACE}, + "data": { + "deliver-credentials.cjs": DELIVERY_SCRIPT.read_text(encoding="utf-8") + }, + } + apply_json( + context=args.context, + kubeconfig=args.kubeconfig, + manifest=configmap, + runner=runner, + action="delivery ConfigMap creation", + ) + job = build_job( + name=job_name, + image=args.image, + credential=args.credential, + credential_secret=credential_secret, + shim_secret=shim_secret, + actor_namespace=args.actor_namespace, + actor_name=args.actor_name, + ) + apply_json( + context=args.context, + kubeconfig=args.kubeconfig, + manifest=job, + runner=runner, + action="delivery Job creation", + ) + run_kubectl( + [ + *kubectl_prefix(args.context, args.kubeconfig), + "wait", + "--namespace", + CONTROL_NAMESPACE, + f"job/{job_name}", + "--for=condition=complete", + "--timeout=75s", + ], + runner=runner, + timeout=80, + action="credential delivery Job", + ) + print( + f"credential delivered for {args.credential} to " + f"{args.actor_namespace}/{args.actor_name}" + ) + except Exception: + failed = True + raise + finally: + try: + delete_delivery_resources( + context=args.context, + kubeconfig=args.kubeconfig, + names=resource_names, + runner=runner, + ) + except RuntimeError: + if not failed: + raise + print("credential delivery resource cleanup failed", file=sys.stderr) + + +def main() -> None: + args = parse_args() + try: + deliver_credentials(args) + except RuntimeError as exc: + print(f"gate5_deliver_credentials failed: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/runtime/test_gate5_credentials.py b/backend/tests/runtime/test_gate5_credentials.py new file mode 100644 index 0000000..ca0ff6c --- /dev/null +++ b/backend/tests/runtime/test_gate5_credentials.py @@ -0,0 +1,138 @@ +"""Fake-backed checks for the private, actor-targeted credential delivery path.""" + +import json +import os +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +import tempfile +import unittest +from types import SimpleNamespace + +from scripts import gate5_deliver_credentials + + +class Gate5CredentialDeliveryTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempdir.cleanup) + self.root = Path(self.tempdir.name) + self.credential_path = self.root / "private-provider-file" + self.credential_value = b"fixture-provider-credential-never-for-argv-or-logs" + self.credential_path.write_bytes(self.credential_value) + self.state_path = self.root / "private-state.json" + self.state_path.write_text( + json.dumps( + { + "context": "kind-substrate-preview", + "atespace": "native-claude", + "actor_name": "claude-final", + "actor_uid": "actor-uid-fixture", + "shim_token": "fixture-shim-token-with-at-least-32-characters", + } + ), + encoding="utf-8", + ) + self.handover_path = self.root / "shared-cluster.md" + self.handover_path.write_text("**Handover:** done — 2026-09-23 17:00 UTC\n", encoding="utf-8") + self.args = SimpleNamespace( + context="kind-substrate-preview", + kubeconfig="/fixture/kubeconfig", + actor_namespace="native-claude", + actor_name="claude-final", + state_file=str(self.state_path), + credential="claude", + claude_token_file=str(self.credential_path), + codex_auth_file="/fixture/not-used", + image=gate5_deliver_credentials.DEFAULT_IMAGE, + ) + + def fake_runner(self, calls, data_seen): + def runner(argv, **kwargs): + calls.append((argv, kwargs)) + if "create" in argv and "secret" in argv: + fds = kwargs.get("pass_fds", ()) + self.assertEqual(len(fds), 1) + data_seen.append(os.pread(fds[0], 1024 * 1024, 0)) + return SimpleNamespace(stdout=b'{"apiVersion":"v1","kind":"Secret"}') + return SimpleNamespace(stdout=b"completed") + + return runner + + def test_credentials_flow_through_control_secret_and_authenticated_job_without_path_or_value_in_argv(self): + calls = [] + data_seen = [] + output = StringIO() + with redirect_stdout(output): + gate5_deliver_credentials.deliver_credentials( + self.args, + runner=self.fake_runner(calls, data_seen), + handover_note=self.handover_path, + ) + + self.assertEqual(data_seen[0], self.credential_value) + self.assertNotIn(self.args.state_file.encode(), b"".join(data_seen)) + self.assertEqual(len(calls), 8) + for argv, _kwargs in calls: + self.assertEqual(argv[0], "kubectl") + self.assertIn("--context", argv) + self.assertIn("kind-substrate-preview", argv) + self.assertIn("--kubeconfig", argv) + self.assertIn("/fixture/kubeconfig", argv) + joined = " ".join(map(str, argv)) + self.assertNotIn(str(self.credential_path), joined) + self.assertNotIn(self.credential_value.decode(), joined) + self.assertNotIn(self.args.state_file, joined) + + secret_argv = calls[0][0] + from_file = next(part for part in secret_argv if part.startswith("--from-file=")) + self.assertRegex(from_file, r"^--from-file=credential=/proc/self/fd/\d+$") + job_manifest = json.loads(calls[5][1]["input"]) + self.assertEqual(job_manifest["kind"], "Job") + self.assertEqual(job_manifest["metadata"]["namespace"], "mainloop-control") + pod_spec = job_manifest["spec"]["template"]["spec"] + self.assertEqual(pod_spec["securityContext"]["fsGroup"], 10001) + self.assertEqual(pod_spec["volumes"][1]["secret"]["defaultMode"], 0o440) + container = pod_spec["containers"][0] + self.assertEqual(container["env"][0], {"name": "CREDENTIAL_KIND", "value": "claude"}) + self.assertEqual(container["env"][1], {"name": "ACTOR_NAMESPACE", "value": "native-claude"}) + self.assertNotIn(self.credential_value.decode(), str(job_manifest)) + self.assertNotIn(str(self.credential_path), str(job_manifest)) + self.assertEqual( + output.getvalue().strip(), + "credential delivered for claude to native-claude/claude-final", + ) + + def test_pending_handover_refuses_before_any_kubectl_call(self): + calls = [] + self.handover_path.write_text("**Handover:** pending\n", encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "handover is not done"): + gate5_deliver_credentials.deliver_credentials( + self.args, + runner=self.fake_runner(calls, []), + handover_note=self.handover_path, + ) + self.assertEqual(calls, []) + + def test_actor_ownership_mismatch_refuses_before_any_kubectl_call(self): + calls = [] + state = json.loads(self.state_path.read_text(encoding="utf-8")) + state["atespace"] = "native-codex" + self.state_path.write_text(json.dumps(state), encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "does not own"): + gate5_deliver_credentials.deliver_credentials( + self.args, + runner=self.fake_runner(calls, []), + handover_note=self.handover_path, + ) + self.assertEqual(calls, []) + + def test_credentials_are_not_wired_into_the_golden_setup_flow(self): + setup_source = Path(gate5_deliver_credentials.__file__).with_name("gate5_setup.py") + source = setup_source.read_text(encoding="utf-8") + self.assertNotIn("gate5_deliver_credentials", source) + self.assertNotIn("deliver_credentials(", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile index fae870c..f7724a4 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -25,10 +25,12 @@ COPY claude /usr/local/bin/claude COPY codex /usr/local/bin/codex COPY codex-code-mode-host /usr/local/bin/codex-code-mode-host COPY bin/agentctl bin/mainloop /usr/local/bin/ +COPY bin/start-native-agent /usr/local/bin/start-native-agent +COPY bin/prepare-native-agent-config.cjs /usr/local/bin/prepare-native-agent-config.cjs COPY agent-config /etc/agent-config COPY exec-shim.js /usr/local/bin/exec-shim.js COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/agentctl /usr/local/bin/mainloop \ +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/agentctl /usr/local/bin/mainloop /usr/local/bin/start-native-agent \ && mkdir -p /work && chown -R agent:agent /work ENV EXEC_SHIM=/usr/local/bin/exec-shim.js ENV HOME=/home/agent diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs b/spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs new file mode 100644 index 0000000..8217864 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs @@ -0,0 +1,51 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const home = process.env.HOME || '/home/agent'; +const workspace = process.env.WORKSPACE_PATH || '/work/repo'; +const codexHome = process.env.CODEX_HOME || path.join(home, '.codex'); +const claudeConfig = path.join(home, '.claude.json'); +const claudeSettingsDir = path.join(home, '.claude'); +const claudeSettings = path.join(claudeSettingsDir, 'settings.json'); +const codexConfig = path.join(codexHome, 'config.toml'); + +fs.mkdirSync(claudeSettingsDir, { recursive: true, mode: 0o700 }); +fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); + +if (!fs.existsSync(claudeConfig) || fs.statSync(claudeConfig).size === 0) { + fs.writeFileSync(claudeConfig, `${JSON.stringify({ + hasCompletedOnboarding: true, + numStartups: 1, + theme: 'dark', + projects: { + [workspace]: { + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + allowedTools: [], + }, + }, + }, null, 2)}\n`, { mode: 0o600 }); +} +if (!fs.existsSync(claudeSettings) || fs.statSync(claudeSettings).size === 0) { + fs.writeFileSync(claudeSettings, '{"skipDangerousModePermissionPrompt":true}\n', { mode: 0o600 }); +} + +if (!fs.existsSync(codexConfig) || fs.statSync(codexConfig).size === 0) { + const quotedWorkspace = JSON.stringify(workspace); + const defaults = [ + 'check_for_update_on_startup = false', + '', + '[tui]', + 'theme = "dark"', + '', + `[projects.${quotedWorkspace}]`, + 'trust_level = "trusted"', + '', + '[notice]', + 'hide_rate_limit_model_nudge = true', + '', + ].join('\n'); + fs.writeFileSync(codexConfig, defaults, { mode: 0o600 }); +} diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent b/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent new file mode 100755 index 0000000..0a61910 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Start one native CLI in the actor's persistent Herdr shell pane after credential delivery. +set -euo pipefail + +kind="${1:?usage: start-native-agent claude|codex}" +if [[ $# -ne 1 ]]; then + echo 'usage: start-native-agent claude|codex' >&2 + exit 2 +fi + +workspace="${WORKSPACE_PATH:-/work/repo}" +cd "${workspace}" + +case "${kind}" in + claude) + token_file="${HOME}/.mainloop/claude-token" + if [[ ! -s "${token_file}" ]]; then + echo 'Claude credential is not installed' >&2 + exit 1 + fi + CLAUDE_CODE_OAUTH_TOKEN="$(tr -d ' \r\n' <"${token_file}")" + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN}" ]]; then + echo 'Claude credential is empty' >&2 + exit 1 + fi + export CLAUDE_CODE_OAUTH_TOKEN + export DISABLE_TELEMETRY=1 + export DISABLE_ERROR_REPORTING=1 + export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 + export DISABLE_AUTOUPDATER=1 + exec claude \ + --dangerously-skip-permissions \ + --append-system-prompt-file "${AGENT_SYSTEM_PROMPT_FILE:-/etc/agent-config/mainloop-system.txt}" + ;; + codex) + auth_file="${CODEX_HOME:-${HOME}/.codex}/auth.json" + if [[ ! -s "${auth_file}" ]]; then + echo 'Codex credential is not installed' >&2 + exit 1 + fi + exec codex --dangerously-bypass-approvals-and-sandbox + ;; + *) + echo 'unsupported native CLI' >&2 + exit 2 + ;; +esac diff --git a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh index 80ee2f0..82dc5e1 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh +++ b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh @@ -10,24 +10,9 @@ # and provider credentials only after the final actor is RUNNING. The golden actor stays clean. # The template controller checks `/healthz` before accepting the golden actor. set -eu -mkdir -p "${HOME}" "${HOME}/.claude" "${CODEX_HOME}" - -# Claude Code: onboarding done, workspace trusted, bypass-permissions warning accepted. -if [[ ! -s "${HOME}/.claude.json" ]]; then - jq -n --arg p "${WORKSPACE_PATH}" '{ - hasCompletedOnboarding: true, - numStartups: 1, - theme: "dark", - projects: {($p): {hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true, allowedTools: []}} - }' >"${HOME}/.claude.json" -fi -[[ -s "${HOME}/.claude/settings.json" ]] || echo '{"skipDangerousModePermissionPrompt": true}' >"${HOME}/.claude/settings.json" - -# Codex: trust the workspace. -if [[ ! -s "${CODEX_HOME}/config.toml" ]]; then - printf '[projects."%s"]\ntrust_level = "trusted"\n' "${WORKSPACE_PATH}" >"${CODEX_HOME}/config.toml" -fi -grep -q '^\[notice\]' "${CODEX_HOME}/config.toml" || printf '\n[notice]\nhide_rate_limit_model_nudge = true\n' >>"${CODEX_HOME}/config.toml" +# Seed supported first-run defaults before either CLI is started. Native sessions start only +# through start-native-agent, after the final actor receives its credential. +node /usr/local/bin/prepare-native-agent-config.cjs mkdir -p "${WORKSPACE_PATH}" [[ -d "${WORKSPACE_PATH}/.git" ]] || git -C "${WORKSPACE_PATH}" init -q diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index 11c19aa..be75d1c 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -1,10 +1,7 @@ -// Minimal generic command executor for the preview-gate spike: POST /run { command } pastes -// `command` as literal text into a real Herdr shell pane via `herdr pane run` (fire-and-forget; -// the pane is a real bash shell, so this is a real shell write, not a purpose-built edit -// endpoint). GET /read returns the pane's current terminal buffer (`herdr pane read`), since -// `pane run` itself never captures output. Stands in for a credentialed native agent's own Bash -// tool -- see entrypoint.sh and docs/spikes/substrate-workspace-adapter.md for why a real agent -// could not be used here. +// Minimal preview-gate command executor: POST /run { command } pastes `command` as literal +// text into a real Herdr shell pane, and GET /read returns its current terminal buffer. +// Authenticated POST /credential { name, contents } writes only one of the fixed credential +// files used by the native-agent launchers. Request bodies are never logged. // Listens on port 8090, separate from the Vite dev server's port 80. Reached only through // atenet-router's arbitrary-port CONNECT tunnel with the ate-target-actor header (see // docs/api-guide.md "Workload Connectivity"), from test orchestration on the host -- never @@ -20,12 +17,21 @@ const PANE_ID = process.env.EXEC_SHIM_PANE_ID; const SESSION = process.env.HERDR_SESSION; const HEALTH_COMMAND_TIMEOUT_MS = 1500; const HEALTH_CACHE_MS = 3000; +const MAX_REQUEST_BODY_BYTES = 64 * 1024; +const MAX_CREDENTIAL_REQUEST_BODY_BYTES = 512 * 1024; +const MAX_CREDENTIAL_BYTES = 64 * 1024; if (!PANE_ID || !SESSION) { console.error('exec-shim: EXEC_SHIM_PANE_ID and HERDR_SESSION are required'); process.exit(1); } const tokenPath = path.join(process.env.HOME || '/home/agent', '.mainloop', 'exec-shim-token'); +const homePath = path.resolve(process.env.HOME || '/home/agent'); +const codexHomePath = path.resolve(process.env.CODEX_HOME || path.join(homePath, '.codex')); +const credentialPaths = new Map([ + ['claude-token', path.join(homePath, '.mainloop', 'claude-token')], + ['codex-auth', path.join(codexHomePath, 'auth.json')], +]); let bearerToken = null; try { bearerToken = fs.readFileSync(tokenPath, 'utf8'); @@ -46,13 +52,22 @@ function unauthorized(res) { res.writeHead(401, { 'content-type': 'text/plain' }).end('unauthorized'); } -function requestBody(req, onBody) { - let body = ''; +function requestBody(req, res, onBody, maxBytes = MAX_REQUEST_BODY_BYTES) { + const chunks = []; + let size = 0; + let tooLarge = false; req.on('data', (chunk) => { - body += chunk; - if (body.length > 65536) req.destroy(); + size += chunk.length; + if (size > maxBytes) { + if (!tooLarge) res.writeHead(413).end('request too large'); + tooLarge = true; + return; + } + if (!tooLarge) chunks.push(chunk); + }); + req.on('end', () => { + if (!tooLarge) onBody(Buffer.concat(chunks).toString('utf8')); }); - req.on('end', () => onBody(body)); } function installToken(token) { @@ -75,34 +90,38 @@ function installToken(token) { return true; } -function installCodexAuth(contentBase64) { - if (typeof contentBase64 !== 'string') return false; - const contents = Buffer.from(contentBase64, 'base64'); +function installCredential(name, contents) { + const destination = credentialPaths.get(name); + if (!destination) return false; if ( - contents.length === 0 || - contents.length > 65536 || - contents.toString('base64') !== contentBase64 - ) return false; - let auth; - try { - auth = JSON.parse(contents.toString('utf8')); - } catch { - return false; + typeof contents !== 'string' || + !contents || + Buffer.byteLength(contents, 'utf8') > MAX_CREDENTIAL_BYTES + ) return null; + if (name === 'codex-auth') { + let auth; + try { + auth = JSON.parse(contents); + } catch { + return null; + } + if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return null; } - if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return false; - const codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || '/home/agent', '.codex'); - fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); - fs.chmodSync(codexHome, 0o700); - const destination = path.join(codexHome, 'auth.json'); + + const directory = path.dirname(destination); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const directoryStat = fs.lstatSync(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) return null; + fs.chmodSync(directory, 0o700); let fd; try { fd = fs.openSync(destination, 'wx', 0o600); } catch (err) { - if (err.code === 'EEXIST') return null; + if (err.code === 'EEXIST') return 'exists'; throw err; } try { - fs.writeFileSync(fd, contents); + fs.writeFileSync(fd, contents, 'utf8'); fs.fsyncSync(fd); fs.closeSync(fd); fs.chmodSync(destination, 0o600); @@ -195,7 +214,7 @@ const server = http.createServer((req, res) => { res.writeHead(409).end('token already set'); return; } - requestBody(req, (body) => { + requestBody(req, res, (body) => { let token; try { token = JSON.parse(body).token; @@ -225,31 +244,39 @@ const server = http.createServer((req, res) => { herdr(['pane', 'read', PANE_ID], res); return; } - if (req.method === 'POST' && req.url === '/write-codex-auth') { + if (req.method === 'POST' && req.url === '/credential') { if (bearerToken === null || !authorized(req)) return unauthorized(res); - requestBody(req, (body) => { - let contentBase64; + requestBody(req, res, (body) => { + let document; try { - contentBase64 = JSON.parse(body).contentBase64; + document = JSON.parse(body); } catch { res.writeHead(400).end('invalid json'); return; } + if (!document || typeof document !== 'object' || Array.isArray(document)) { + res.writeHead(400).end('invalid credential'); + return; + } try { - const installed = installCodexAuth(contentBase64); + const installed = installCredential(document.name, document.contents); if (installed === null) { - res.writeHead(409).end('auth file already exists'); + res.writeHead(400).end('invalid credential'); return; } if (!installed) { - res.writeHead(400).end('invalid auth payload'); + res.writeHead(403).end('credential name is not allowlisted'); return; } - res.writeHead(201).end('Codex auth installed'); + if (installed === 'exists') { + res.writeHead(409).end('credential file already exists'); + return; + } + res.writeHead(201).end('credential stored'); } catch { - res.writeHead(500).end('Codex auth could not be stored'); + res.writeHead(500).end('credential could not be stored'); } - }); + }, MAX_CREDENTIAL_REQUEST_BODY_BYTES); return; } if (req.method !== 'POST' || req.url !== '/run') { @@ -257,7 +284,7 @@ const server = http.createServer((req, res) => { return; } if (!authorized(req)) return unauthorized(res); - requestBody(req, (body) => { + requestBody(req, res, (body) => { let command; try { command = JSON.parse(body).command; diff --git a/spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js b/spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js new file mode 100644 index 0000000..47c2a01 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js @@ -0,0 +1,214 @@ +'use strict'; + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { buildCredentialPayload, deliverFromMountedFiles, postViaRouter } = require( + path.resolve(__dirname, '../tools/phase4/deliver-credentials.cjs'), +); +const liveAgentImage = path.resolve(__dirname, '../live-agent-image'); + +test('credential payload uses the fixed shim allowlist and validates Codex auth JSON', () => { + assert.deepEqual(buildCredentialPayload('claude', 'fixture token\r\n'), { + name: 'claude-token', + contents: 'fixturetoken', + }); + assert.deepEqual(buildCredentialPayload('codex', '{"access_token":"fixture"}'), { + name: 'codex-auth', + contents: '{"access_token":"fixture"}', + }); + assert.throws(() => buildCredentialPayload('../../etc/passwd', 'fixture'), /unsupported/); + assert.throws(() => buildCredentialPayload('codex', 'not-json'), SyntaxError); +}); + +test('control delivery reads mounted paths and sends credential contents only in the request payload', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'phase4-delivery-')); + const credentialFile = path.join(root, 'credential'); + const shimTokenFile = path.join(root, 'shim-token'); + fs.writeFileSync(credentialFile, 'fixture-claude-token\n', { mode: 0o600 }); + fs.writeFileSync(shimTokenFile, 'fixture-shim-token-with-at-least-32-characters', { mode: 0o600 }); + const calls = []; + try { + const result = await deliverFromMountedFiles({ + kind: 'claude', + credentialFile, + shimTokenFile, + namespace: 'native-claude', + actor: 'claude-final', + request: async (request) => { + calls.push(request); + return 201; + }, + }); + assert.deepEqual(result, { + kind: 'claude', + namespace: 'native-claude', + actor: 'claude-final', + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].payload.name, 'claude-token'); + assert.equal(calls[0].payload.contents, 'fixture-claude-token'); + assert.equal(calls[0].token, 'fixture-shim-token-with-at-least-32-characters'); + assert.equal(calls[0].namespace, 'native-claude'); + assert.equal(calls[0].actor, 'claude-final'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('router delivery authenticates the CONNECT target and posts the body without logging it', async (t) => { + const received = {}; + const proxy = http.createServer(); + proxy.on('connect', (request, socket, head) => { + received.target = request.url; + received.actorHeader = request.headers['ate-target-actor']; + if (head.length) socket.unshift(head); + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + let bytes = Buffer.alloc(0); + socket.on('data', (chunk) => { + bytes = Buffer.concat([bytes, chunk]); + const boundary = bytes.indexOf('\r\n\r\n'); + if (boundary === -1) return; + const headers = bytes.subarray(0, boundary).toString('latin1'); + const length = Number(/^content-length:\s*(\d+)\s*$/im.exec(headers)?.[1]); + if (!Number.isFinite(length) || bytes.length < boundary + 4 + length) return; + received.requestHeaders = headers; + received.payload = JSON.parse(bytes.subarray(boundary + 4, boundary + 4 + length).toString('utf8')); + socket.end('HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'); + }); + }); + proxy.listen(0, '127.0.0.1'); + await once(proxy, 'listening'); + t.after(() => proxy.close()); + + const { port } = proxy.address(); + const status = await postViaRouter({ + host: '127.0.0.1', + port, + namespace: 'native-codex', + actor: 'codex-final', + token: 'fixture-shim-token-with-at-least-32-characters', + payload: { name: 'codex-auth', contents: '{"fixture":"provider"}' }, + }); + assert.equal(status, 201); + assert.equal(received.target, 'actor-upstream:8090'); + assert.equal(received.actorHeader, 'native-codex/codex-final'); + assert.match(received.requestHeaders, /Authorization: Bearer fixture-shim-token-with-at-least-32-characters/i); + assert.deepEqual(received.payload, { name: 'codex-auth', contents: '{"fixture":"provider"}' }); +}); + +test('native-agent launcher reads Claude auth from its file into the process environment only', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'phase4-launch-claude-')); + const home = path.join(root, 'home'); + const bin = path.join(root, 'bin'); + const workspace = path.join(root, 'repo'); + const tokenFile = path.join(home, '.mainloop', 'claude-token'); + const tokenCapture = path.join(root, 'token.capture'); + const argsCapture = path.join(root, 'args.capture'); + fs.mkdirSync(path.dirname(tokenFile), { recursive: true }); + fs.mkdirSync(bin); + fs.mkdirSync(workspace); + fs.writeFileSync(tokenFile, 'fixture-claude-oauth-value\r\n', { mode: 0o600 }); + fs.writeFileSync( + path.join(bin, 'claude'), + '#!/bin/sh\nprintf "%s" "$CLAUDE_CODE_OAUTH_TOKEN" >"$TOKEN_CAPTURE"\nprintf "%s\\n" "$DISABLE_TELEMETRY" "$DISABLE_ERROR_REPORTING" "$CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" "$DISABLE_AUTOUPDATER" >"$FLAGS_CAPTURE"\nprintf "%s\\n" "$@" >"$ARGS_CAPTURE"\n', + { mode: 0o700 }, + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const result = spawnSync('/bin/bash', [path.join(liveAgentImage, 'bin/start-native-agent'), 'claude'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + WORKSPACE_PATH: workspace, + TOKEN_CAPTURE: tokenCapture, + FLAGS_CAPTURE: path.join(root, 'flags.capture'), + ARGS_CAPTURE: argsCapture, + AGENT_SYSTEM_PROMPT_FILE: path.join(root, 'system-prompt.fixture'), + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(tokenCapture, 'utf8'), 'fixture-claude-oauth-value'); + assert.deepEqual(fs.readFileSync(path.join(root, 'flags.capture'), 'utf8').trim().split('\n'), [ + '1', '1', '1', '1', + ]); + const args = fs.readFileSync(argsCapture, 'utf8'); + assert.match(args, /--dangerously-skip-permissions/); + assert.match(args, /--append-system-prompt-file/); + assert.equal(args.includes('fixture-claude-oauth-value'), false); + assert.equal(result.stdout.includes('fixture-claude-oauth-value'), false); +}); + +test('native-agent launcher starts Codex only when its installed auth file exists', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'phase4-launch-codex-')); + const home = path.join(root, 'home'); + const codexHome = path.join(home, '.codex'); + const bin = path.join(root, 'bin'); + const workspace = path.join(root, 'repo'); + const argsCapture = path.join(root, 'args.capture'); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(bin); + fs.mkdirSync(workspace); + fs.writeFileSync(path.join(codexHome, 'auth.json'), '{"fixture":"codex-auth"}', { mode: 0o600 }); + fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\nprintf "%s\\n" "$@" >"$ARGS_CAPTURE"\n', { mode: 0o700 }); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const env = { + ...process.env, + HOME: home, + CODEX_HOME: codexHome, + PATH: `${bin}:${process.env.PATH}`, + WORKSPACE_PATH: workspace, + ARGS_CAPTURE: argsCapture, + }; + const result = spawnSync('/bin/bash', [path.join(liveAgentImage, 'bin/start-native-agent'), 'codex'], { + encoding: 'utf8', + env, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(argsCapture, 'utf8').trim(), '--dangerously-bypass-approvals-and-sandbox'); + assert.equal(result.stdout.includes('fixture'), false); + + fs.rmSync(path.join(codexHome, 'auth.json')); + const missing = spawnSync('/bin/bash', [path.join(liveAgentImage, 'bin/start-native-agent'), 'codex'], { + encoding: 'utf8', + env, + }); + assert.equal(missing.status, 1); + assert.equal(missing.stderr.includes('auth.json'), false); +}); + +test('golden boot seeds trusted workspaces, themes, and disabled update checks without starting a CLI', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'phase4-config-')); + const home = path.join(root, 'home'); + const codexHome = path.join(home, '.codex'); + const workspace = path.join(root, 'repo'); + const result = spawnSync(process.execPath, [path.join(liveAgentImage, 'bin/prepare-native-agent-config.cjs')], { + encoding: 'utf8', + env: { ...process.env, HOME: home, CODEX_HOME: codexHome, WORKSPACE_PATH: workspace }, + }); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + assert.equal(result.status, 0, result.stderr); + + const claudeConfig = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8')); + assert.equal(claudeConfig.hasCompletedOnboarding, true); + assert.equal(claudeConfig.theme, 'dark'); + assert.equal(claudeConfig.projects[workspace].hasTrustDialogAccepted, true); + const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + assert.match(codexConfig, /^check_for_update_on_startup = false$/m); + assert.match(codexConfig, /^theme = "dark"$/m); + assert.ok(codexConfig.includes(`[projects.${JSON.stringify(workspace)}]`)); + assert.match(codexConfig, /^trust_level = "trusted"$/m); +}); diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js index 783e6c8..5006a60 100644 --- a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -130,8 +130,8 @@ test('shim token gates run/read, is one-time, private, and survives process rest assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 409); }); -test('Codex auth write requires an installed shim token and creates a private one-time file', async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-codex-auth-')); +test('credential delivery requires a shim token and writes only allowlisted private files once', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-credentials-')); const home = path.join(root, 'home'); const fakeBin = path.join(root, 'bin'); const shimPath = path.join(root, 'exec-shim.js'); @@ -152,32 +152,42 @@ test('Codex auth write requires an installed shim token and creates a private on fs.rmSync(root, { recursive: true, force: true }); }); + const claudeContents = 'fixture-claude-token-never-logged'; const authContents = JSON.stringify({ fixture: 'codex-auth-never-logged' }); - const contentBase64 = Buffer.from(authContents).toString('base64'); - const write = (encoded, token) => - request(running.port, 'POST', '/write-codex-auth', { - body: { contentBase64: encoded }, + const write = (name, contents, token) => + request(running.port, 'POST', '/credential', { + body: { name, contents }, ...(token === undefined ? {} : { token }), }); - assert.equal((await write(contentBase64)).status, 401, 'golden actor must reject writes before token installation'); + assert.equal((await write('codex-auth', authContents)).status, 401, 'golden actor must reject writes before token installation'); assert.equal(fs.existsSync(codexHome), false); const token = 'fixture-only-codex-shim-token-at-least-thirty-two-chars'; assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 201); - assert.equal((await write(contentBase64)).status, 401); - assert.equal((await write(contentBase64, `${token}-wrong`)).status, 401); - assert.equal((await write(Buffer.from('[]').toString('base64'), token)).status, 400); - assert.equal((await write('!!!!', token)).status, 400); - - assert.equal((await write(contentBase64, token)).status, 201); + assert.equal((await write('claude-token', claudeContents)).status, 401); + assert.equal((await write('claude-token', claudeContents, `${token}-wrong`)).status, 401); + assert.equal((await write('../outside', claudeContents, token)).status, 403); + assert.equal(fs.existsSync(path.join(root, 'outside')), false); + assert.equal((await write('codex-auth', '[]', token)).status, 400); + assert.equal((await write('codex-auth', 'not-json', token)).status, 400); + assert.equal((await request(running.port, 'POST', '/write-codex-auth', { token, body: {} })).status, 404); + + assert.equal((await write('claude-token', claudeContents, token)).status, 201); + const claudePath = path.join(home, '.mainloop', 'claude-token'); + assert.equal(fs.readFileSync(claudePath, 'utf8'), claudeContents); + assert.equal(fs.statSync(claudePath).mode & 0o777, 0o600); + assert.equal(fs.statSync(path.dirname(claudePath)).mode & 0o777, 0o700); + assert.equal((await write('claude-token', 'replacement', token)).status, 409); + + assert.equal((await write('codex-auth', authContents, token)).status, 201); const authPath = path.join(codexHome, 'auth.json'); assert.equal(fs.readFileSync(authPath, 'utf8'), authContents); assert.equal(fs.statSync(codexHome).mode & 0o777, 0o700); assert.equal(fs.statSync(authPath).mode & 0o777, 0o600); - assert.equal((await write(contentBase64, token)).status, 409, 'auth file must not be overwritten'); + assert.equal((await write('codex-auth', authContents, token)).status, 409, 'auth file must not be overwritten'); + assert.equal(running.output().includes(claudeContents), false); assert.equal(running.output().includes(authContents), false); - assert.equal(running.output().includes(contentBase64), false); }); test('healthz bounds Herdr calls and reuses a recent successful check', async (t) => { diff --git a/spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs b/spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs new file mode 100644 index 0000000..5ebb55e --- /dev/null +++ b/spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs @@ -0,0 +1,155 @@ +'use strict'; + +const fs = require('node:fs'); +const http = require('node:http'); + +const CREDENTIALS = Object.freeze({ + claude: Object.freeze({ name: 'claude-token' }), + codex: Object.freeze({ name: 'codex-auth' }), +}); + +function validateActorIdentity(namespace, actor) { + const dnsLabel = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/; + if (!dnsLabel.test(namespace || '') || !dnsLabel.test(actor || '')) { + throw new Error('invalid actor identity'); + } +} + +function buildCredentialPayload(kind, rawContents) { + const credential = CREDENTIALS[kind]; + if (!credential) throw new Error('unsupported credential kind'); + if (typeof rawContents !== 'string' || !rawContents) { + throw new Error('empty credential'); + } + const contents = kind === 'claude' ? rawContents.replace(/[ \r\n]/g, '') : rawContents; + if (!contents || Buffer.byteLength(contents, 'utf8') > 1024 * 1024) { + throw new Error('invalid credential size'); + } + if (kind === 'codex') { + const parsed = JSON.parse(contents); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('invalid Codex auth document'); + } + } + return { name: credential.name, contents }; +} + +function postViaRouter({ host, port, namespace, actor, token, payload, timeoutMs = 10000 }) { + validateActorIdentity(namespace, actor); + if (typeof token !== 'string' || !token || /[\r\n]/.test(token)) { + return Promise.reject(new Error('invalid shim token')); + } + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error, status) => { + if (settled) return; + settled = true; + if (error) reject(error); + else resolve(status); + }; + + const tunnel = http.request({ + host, + port, + method: 'CONNECT', + path: 'actor-upstream:8090', + headers: { 'ate-target-actor': `${namespace}/${actor}` }, + }); + tunnel.setTimeout(timeoutMs, () => tunnel.destroy(new Error('router timed out'))); + tunnel.once('error', (error) => finish(error)); + tunnel.once('connect', (response, socket, head) => { + if (response.statusCode !== 200) { + socket.destroy(); + finish(new Error('router rejected actor tunnel')); + return; + } + if (head.length) socket.unshift(head); + + let responseHeaders = Buffer.alloc(0); + socket.setTimeout(timeoutMs, () => socket.destroy(new Error('shim timed out'))); + socket.once('error', (error) => finish(error)); + socket.on('data', (chunk) => { + responseHeaders = Buffer.concat([responseHeaders, chunk]); + const boundary = responseHeaders.indexOf('\r\n\r\n'); + if (boundary === -1) return; + const statusLine = responseHeaders + .subarray(0, boundary) + .toString('latin1') + .split('\r\n', 1)[0]; + const match = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(statusLine); + if (!match) { + finish(new Error('invalid shim response')); + socket.destroy(); + return; + } + finish(null, Number(match[1])); + socket.end(); + }); + + const headers = Buffer.from([ + 'POST /credential HTTP/1.1', + 'Host: actor-upstream:8090', + `Authorization: Bearer ${token}`, + 'Content-Type: application/json', + `Content-Length: ${body.length}`, + 'Connection: close', + '', + '', + ].join('\r\n'), 'ascii'); + socket.write(Buffer.concat([headers, body])); + }); + tunnel.end(); + }); +} + +async function deliverFromMountedFiles({ + kind, + credentialFile, + shimTokenFile, + namespace, + actor, + host = 'atenet-router.ate-system.svc.cluster.local', + port = 8081, + request = postViaRouter, +}) { + validateActorIdentity(namespace, actor); + const payload = buildCredentialPayload(kind, fs.readFileSync(credentialFile, 'utf8')); + const token = fs.readFileSync(shimTokenFile, 'utf8').trim(); + if (!token || token.length > 4096 || /\s/.test(token)) { + throw new Error('invalid shim token'); + } + const status = await request({ + host, + port, + namespace, + actor, + token, + payload, + }); + if (status !== 201) throw new Error('shim rejected credential delivery'); + return { kind, namespace, actor }; +} + +async function main() { + await deliverFromMountedFiles({ + kind: process.env.CREDENTIAL_KIND, + credentialFile: '/var/run/phase4-credentials/credential', + shimTokenFile: '/var/run/phase4-shim-auth/token', + namespace: process.env.ACTOR_NAMESPACE, + actor: process.env.ACTOR_NAME, + host: process.env.ROUTER_HOST || 'atenet-router.ate-system.svc.cluster.local', + port: Number(process.env.ROUTER_PORT || '8081'), + }); + process.stdout.write(`credential delivered for ${process.env.CREDENTIAL_KIND}\n`); +} + +if (require.main === module) { + main().catch(() => { + process.stderr.write('credential delivery failed\n'); + process.exitCode = 1; + }); +} + +module.exports = { buildCredentialPayload, deliverFromMountedFiles, postViaRouter }; From 147e2a009414f178dd18d467d78772bed4523e88 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:40:41 +0000 Subject: [PATCH 14/30] spike: run native turns headlessly and close out Phase 5 evidence Replace the live-agent actor's Herdr pane shim with authenticated, turn-shaped /turn and /run endpoints. Prompts go to the native CLIs on stdin; turns return parsed events, native ids and final messages, and a second concurrent turn for one agent returns 409. Start only the actor-local shim at boot and make readiness depend on the shim and workspace. Remove Herdr, agentctl and HERDR_SESSION from the actor image and templates. Update setup checks and the spike doc with the headless per-turn design, the no-attachable-TUI trade-off, gate states and a deferred production recommendation. The rebuilt headless image is not yet live-proved. Add sanitized Claude stream-json and Codex JSONL fixtures, fake-backed shim coverage, and a ContractStore restart case for a persisted recorded delivery that blocks retry until reconciliation. Record the worker-pool isolation finding: worker selection is not scoped by atespace or namespace, so per-tenant pool names and selectors must be unique. Require an explicit kubeconfig in the credential-delivery CLI. --- backend/scripts/gate5_deliver_credentials.py | 23 +- backend/scripts/gate5_setup.py | 48 +- backend/src/mainloop/runtime/substrate.py | 11 +- backend/tests/runtime/test_contracts.py | 55 ++ .../tests/runtime/test_gate5_credentials.py | 57 +- backend/tests/runtime/test_gate5_setup.py | 30 +- docs/spikes/substrate-workspace-adapter.md | 248 ++++--- .../k8s/actor-template.yaml.tmpl | 11 +- .../k8s/live-agent-gate-template.yaml.tmpl | 9 +- .../k8s/router-ingress-policy.yaml | 58 +- .../live-agent-image/.gitignore | 4 +- .../live-agent-image/Dockerfile | 22 +- .../bin/prepare-native-agent-config.cjs | 33 +- .../live-agent-image/bin/start-native-agent | 88 +-- .../live-agent-image/entrypoint.sh | 49 +- .../live-agent-image/exec-shim.js | 629 +++++++++++++----- .../tests/deliver-credentials.test.js | 111 ++-- .../tests/exec-shim.test.js | 434 ++++++++---- .../fixtures/native/claude-stream-json.jsonl | 3 + .../tests/fixtures/native/codex-jsonl.jsonl | 4 + .../tools/phase4/deliver-credentials.cjs | 33 +- .../tools/router-client.js | 17 +- 22 files changed, 1354 insertions(+), 623 deletions(-) create mode 100644 spikes/substrate-workspace-adapter/tests/fixtures/native/claude-stream-json.jsonl create mode 100644 spikes/substrate-workspace-adapter/tests/fixtures/native/codex-jsonl.jsonl diff --git a/backend/scripts/gate5_deliver_credentials.py b/backend/scripts/gate5_deliver_credentials.py index a420cb9..f088340 100644 --- a/backend/scripts/gate5_deliver_credentials.py +++ b/backend/scripts/gate5_deliver_credentials.py @@ -24,7 +24,6 @@ CONTROL_NAMESPACE = "mainloop-control" DEFAULT_CONTEXT = "kind-substrate-preview" -DEFAULT_KUBECONFIG = "/tmp/substrate-preview-kubeconfig" DEFAULT_IMAGE = ( "localhost:5001/live-agent-gate@sha256:" "8ec007c56b070a2357f20203807197e42ebdb8d0c0e155d57a3bd48fb8d10f57" @@ -32,9 +31,7 @@ MAX_SECRET_BYTES = 1024 * 1024 ACTOR_NAMESPACES = {"claude": "native-claude", "codex": "native-codex"} SHARED_CLUSTER_NOTE = ( - Path(__file__).resolve().parents[2] - / ".tasknotes" - / "shared-cluster-2026-09-23.md" + Path(__file__).resolve().parents[2] / ".tasknotes" / "shared-cluster-2026-09-23.md" ) DELIVERY_SCRIPT = ( Path(__file__).resolve().parents[2] @@ -49,7 +46,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--context", default=DEFAULT_CONTEXT) - parser.add_argument("--kubeconfig", default=DEFAULT_KUBECONFIG) + parser.add_argument("--kubeconfig", required=True) parser.add_argument("--actor-namespace", required=True) parser.add_argument("--actor-name", required=True) parser.add_argument("--state-file", required=True) @@ -94,12 +91,16 @@ def require_handover(path: Path = SHARED_CLUSTER_NOTE) -> None: except OSError: raise RuntimeError("shared-cluster handover note is unavailable") from None if not re.search(r"(?m)^\*\*Handover:\*\*\s+done(?:\s|$)", note): - raise RuntimeError("shared-cluster handover is not done; no resources were created") + raise RuntimeError( + "shared-cluster handover is not done; no resources were created" + ) def kubectl_prefix(context: str, kubeconfig: str) -> list[str]: if context != DEFAULT_CONTEXT: - raise RuntimeError(f"refusing context {context!r}; expected {DEFAULT_CONTEXT!r}") + raise RuntimeError( + f"refusing context {context!r}; expected {DEFAULT_CONTEXT!r}" + ) return ["kubectl", "--context", context, "--kubeconfig", kubeconfig] @@ -305,9 +306,7 @@ def build_job( "secret": { "secretName": credential_secret, "defaultMode": 0o440, - "items": [ - {"key": "credential", "path": "credential"} - ], + "items": [{"key": "credential", "path": "credential"}], }, }, { @@ -338,7 +337,9 @@ def deliver_credentials( if not re.fullmatch( r"localhost:5001/live-agent-gate@sha256:[0-9a-f]{64}", args.image ): - raise RuntimeError("delivery image must be the digest-pinned live-agent-gate image") + raise RuntimeError( + "delivery image must be the digest-pinned live-agent-gate image" + ) state = read_private_state( args.state_file, diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index 715398a..fc965dc 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -3,7 +3,7 @@ Gate 5 (native-session continuity, .tasknotes/plan.md) credential-free harness: registers the atespace, resolves and applies the WorkerPool, creates a *versioned* ActorTemplate and waits for its golden snapshot, then creates/resumes one actor and waits for it to become -RUNNING with its persistent control service (Herdr) confirmed ready -- all without a +RUNNING with its actor-local headless shim confirmed ready -- all without a provider credential, a credential server, or a native Claude/Codex session. Implements recovery step 2 of .tasknotes/gate5-review-and-recovery-plan-2026-09-22.md. That @@ -15,8 +15,9 @@ credential fetch built into the shared, immutable template, which this script's manifest no longer has (see spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh). -Deliberately out of scope here (recovery plan steps 3-4, a separate task): fetching a real -credential, attaching it to a running actor, and starting a native Claude/Codex session. +This setup harness remains credential-free: it installs and checks the shim token but does not +fetch provider credentials or submit a native turn. The image's authenticated /turn endpoint +runs one headless native CLI process per request; Mainloop owns delivery and retry decisions. Prerequisites: kubectl, kubectl-ate, and ko built from the pinned Substrate checkout. @@ -39,7 +40,7 @@ --egress-tool /tmp/substrate-preview-src/bin/mainloop-egress-tool \\ --egress-deny-all -Re-running with the same --state-file reconciles the persisted actor uid against the + Re-running with the same --state-file reconciles the persisted actor uid against the cluster's current state rather than blindly creating or resuming; a name collision with a *different* uid is refused, not silently overwritten. """ @@ -219,7 +220,9 @@ def verify_image_manifest(image: str, *, opener=urllib.request.urlopen) -> None: or not repository or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest) ): - raise RuntimeError("--image must be a registry/repository pinned by a full sha256 digest") + raise RuntimeError( + "--image must be a registry/repository pinned by a full sha256 digest" + ) request = urllib.request.Request( f"http://{registry}/v2/{repository}/manifests/{digest}", @@ -535,10 +538,10 @@ def ensure_shim_token( atespace=args.atespace, actor_name=args.actor_name, method="GET", - path="/read", + path="/turn/00000000-0000-4000-8000-000000000000", token=token, ) - if already_installed != 200: + if already_installed != 404: raise RuntimeError( "the actor already has a shim token that does not match the private state; " "manual reconciliation is required" @@ -549,9 +552,30 @@ def ensure_shim_token( ) checks = ( - ("missing-token /read", "GET", "/read", None, None, 401), - ("wrong-token /read", "GET", "/read", f"{token}x", None, 401), - ("authenticated /read", "GET", "/read", token, None, 200), + ( + "missing-token /turn/", + "GET", + "/turn/00000000-0000-4000-8000-000000000000", + None, + None, + 401, + ), + ( + "wrong-token /turn/", + "GET", + "/turn/00000000-0000-4000-8000-000000000000", + f"{token}x", + None, + 401, + ), + ( + "authenticated unknown /turn/", + "GET", + "/turn/00000000-0000-4000-8000-000000000000", + token, + None, + 404, + ), ( "second /token", "POST", @@ -577,7 +601,9 @@ def ensure_shim_token( f"shim token acceptance failed at {label} " f"(HTTP {observed or 'no response'}, expected {expected})" ) - print("-- per-actor shim token installed; missing/wrong/correct and one-time checks passed") + print( + "-- per-actor shim token installed; missing/wrong/correct and one-time checks passed" + ) async def ensure_golden_template( diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py index f50d063..b063a2c 100644 --- a/backend/src/mainloop/runtime/substrate.py +++ b/backend/src/mainloop/runtime/substrate.py @@ -1,11 +1,12 @@ """Thin Substrate adapter: drives ``kubectl ate`` (control-plane CLI over gRPC to ``ate-api-server``) to manage per-session actors as Mainloop workspaces. -Unlike ``herdr.py`` (pod-exec into an already-running workspace), this adapter talks to -Substrate's cluster-level control plane: actors are created, suspended, resumed, reverted and -deleted through ``ateapipb.Control`` (see the pinned checkout's ``pkg/proto/ateapipb/ateapi.proto`` -and ``cmd/kubectl-ate/internal/cmd/actor.go``). ``TransportError`` means the outcome of the call -is unknown; callers must inspect the actor before retrying rather than blindly re-creating it. +The actor image is designed for headless, per-turn native CLI invocations; no Herdr server or +terminal manager runs inside it. This adapter talks to Substrate's cluster-level control plane: +actors are created, suspended, resumed, reverted and deleted through ``ateapipb.Control`` (see +the pinned checkout's ``pkg/proto/ateapipb/ateapi.proto`` and +``cmd/kubectl-ate/internal/cmd/actor.go``). ``TransportError`` means the outcome of the call is +unknown; callers must inspect the actor before retrying rather than blindly re-creating it. """ from __future__ import annotations diff --git a/backend/tests/runtime/test_contracts.py b/backend/tests/runtime/test_contracts.py index aaa0233..78f5f17 100644 --- a/backend/tests/runtime/test_contracts.py +++ b/backend/tests/runtime/test_contracts.py @@ -246,6 +246,61 @@ def test_backend_restart_reconciles_persisted_recorded_attempt_before_retry(self [DeliveryState.FAILED, DeliveryState.RECORDED], ) + def test_restart_rehydrates_recorded_prompt_without_replay_or_loss(self): + # Model a backend restart with fake durable rows, then load those rows into + # a fresh ContractStore before taking ownership. ContractStore has no I/O; + # this proves the contract semantics, not a database or transport restart. + before_restart = ContractStore(binding()) + prompt = {**message(), "payload_ref": "fixture://prompts/restart-window"} + before_restart.record_message(prompt, 1) + before_restart.create_attempt(attempt(), 1) + fake_database = { + "binding": before_restart.binding.model_dump(mode="json"), + "messages": [ + item.model_dump(mode="json") for item in before_restart.messages + ], + "attempts": [ + item.model_dump(mode="json") for item in before_restart.attempts + ], + } + + after_restart = ContractStore(fake_database["binding"]) + for persisted_message in fake_database["messages"]: + after_restart.record_message(persisted_message, 1) + for persisted_attempt in fake_database["attempts"]: + after_restart.create_attempt(persisted_attempt, 1) + + binding_after_takeover = after_restart.take_ownership(1) + self.assertEqual(binding_after_takeover.ownership_generation, 2) + self.assertEqual(after_restart.messages[0].payload_ref, prompt["payload_ref"]) + pending = after_restart.checkpoint(2).pending_delivery + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0].state, DeliveryState.RECORDED) + with self.assertRaises(ContractError): + after_restart.create_attempt(attempt("replay", generation=2), 2) + + retired = after_restart.reconcile( + { + "attempt_id": "attempt", + "binding_id": "binding", + "evidence_ref": "fixture://journal/no-prompt-receipt", + "observed_at": NOW, + "outcome": "not_delivered", + }, + 2, + ) + self.assertEqual(retired.state, DeliveryState.FAILED) + self.assertEqual(after_restart.messages[0].payload_ref, prompt["payload_ref"]) + retry = after_restart.create_attempt(attempt("retry", generation=2), 2) + self.assertEqual(retry.logical_message_id, "message") + self.assertEqual( + [ + (item.attempt_id, item.state) + for item in after_restart.checkpoint(2).pending_delivery + ], + [("retry", DeliveryState.RECORDED)], + ) + def test_takeover_reconnect_deduplicates_source_event(self): original = self.store.ingest(attention(), 1) self.store.take_ownership(1) diff --git a/backend/tests/runtime/test_gate5_credentials.py b/backend/tests/runtime/test_gate5_credentials.py index ca0ff6c..7884298 100644 --- a/backend/tests/runtime/test_gate5_credentials.py +++ b/backend/tests/runtime/test_gate5_credentials.py @@ -2,12 +2,13 @@ import json import os +import tempfile +import unittest from contextlib import redirect_stdout from io import StringIO from pathlib import Path -import tempfile -import unittest from types import SimpleNamespace +from unittest.mock import patch from scripts import gate5_deliver_credentials @@ -34,7 +35,9 @@ def setUp(self): encoding="utf-8", ) self.handover_path = self.root / "shared-cluster.md" - self.handover_path.write_text("**Handover:** done — 2026-09-23 17:00 UTC\n", encoding="utf-8") + self.handover_path.write_text( + "**Handover:** done — 2026-09-23 17:00 UTC\n", encoding="utf-8" + ) self.args = SimpleNamespace( context="kind-substrate-preview", kubeconfig="/fixture/kubeconfig", @@ -47,6 +50,34 @@ def setUp(self): image=gate5_deliver_credentials.DEFAULT_IMAGE, ) + def test_kubeconfig_is_required_by_cli(self): + required = [ + "--actor-namespace", + "native-claude", + "--actor-name", + "claude-final", + "--state-file", + "/fixture/state.json", + "--credential", + "claude", + ] + with patch("sys.argv", ["gate5_deliver_credentials.py", *required]): + with self.assertRaises(SystemExit) as raised: + gate5_deliver_credentials.parse_args() + self.assertEqual(raised.exception.code, 2) + + with patch( + "sys.argv", + [ + "gate5_deliver_credentials.py", + *required, + "--kubeconfig", + "/fixture/kubeconfig", + ], + ): + parsed = gate5_deliver_credentials.parse_args() + self.assertEqual(parsed.kubeconfig, "/fixture/kubeconfig") + def fake_runner(self, calls, data_seen): def runner(argv, **kwargs): calls.append((argv, kwargs)) @@ -59,7 +90,9 @@ def runner(argv, **kwargs): return runner - def test_credentials_flow_through_control_secret_and_authenticated_job_without_path_or_value_in_argv(self): + def test_credentials_flow_through_control_secret_and_authenticated_job_without_path_or_value_in_argv( + self, + ): calls = [] data_seen = [] output = StringIO() @@ -85,7 +118,9 @@ def test_credentials_flow_through_control_secret_and_authenticated_job_without_p self.assertNotIn(self.args.state_file, joined) secret_argv = calls[0][0] - from_file = next(part for part in secret_argv if part.startswith("--from-file=")) + from_file = next( + part for part in secret_argv if part.startswith("--from-file=") + ) self.assertRegex(from_file, r"^--from-file=credential=/proc/self/fd/\d+$") job_manifest = json.loads(calls[5][1]["input"]) self.assertEqual(job_manifest["kind"], "Job") @@ -94,8 +129,12 @@ def test_credentials_flow_through_control_secret_and_authenticated_job_without_p self.assertEqual(pod_spec["securityContext"]["fsGroup"], 10001) self.assertEqual(pod_spec["volumes"][1]["secret"]["defaultMode"], 0o440) container = pod_spec["containers"][0] - self.assertEqual(container["env"][0], {"name": "CREDENTIAL_KIND", "value": "claude"}) - self.assertEqual(container["env"][1], {"name": "ACTOR_NAMESPACE", "value": "native-claude"}) + self.assertEqual( + container["env"][0], {"name": "CREDENTIAL_KIND", "value": "claude"} + ) + self.assertEqual( + container["env"][1], {"name": "ACTOR_NAMESPACE", "value": "native-claude"} + ) self.assertNotIn(self.credential_value.decode(), str(job_manifest)) self.assertNotIn(str(self.credential_path), str(job_manifest)) self.assertEqual( @@ -128,7 +167,9 @@ def test_actor_ownership_mismatch_refuses_before_any_kubectl_call(self): self.assertEqual(calls, []) def test_credentials_are_not_wired_into_the_golden_setup_flow(self): - setup_source = Path(gate5_deliver_credentials.__file__).with_name("gate5_setup.py") + setup_source = Path(gate5_deliver_credentials.__file__).with_name( + "gate5_setup.py" + ) source = setup_source.read_text(encoding="utf-8") self.assertNotIn("gate5_deliver_credentials", source) self.assertNotIn("deliver_credentials(", source) diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index 724aa0d..5cbfcd3 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -1,11 +1,11 @@ """Credential-free regressions for the gate-5 setup script's build and rerun identity.""" import json -from contextlib import redirect_stdout -from io import StringIO import tempfile import unittest +from contextlib import redirect_stdout from dataclasses import replace +from io import StringIO from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -56,7 +56,9 @@ def opener(request, *, timeout): f"http://localhost:5001/v2/live-agent-gate/manifests/sha256:{'a' * 64}", ) self.assertEqual(request.get_method(), "HEAD") - self.assertEqual(request.get_header("Accept"), gate5_setup.IMAGE_MANIFEST_ACCEPT) + self.assertEqual( + request.get_header("Accept"), gate5_setup.IMAGE_MANIFEST_ACCEPT + ) self.assertEqual(timeout, 10) def test_image_manifest_preflight_fails_before_template_on_missing_manifest(self): @@ -80,8 +82,9 @@ def opener(*_args, **_kwargs): "localhost:5001/live-agent-gate:latest", "localhost:5001/live-agent-gate@sha256:bad", ): - with self.subTest(image=image), self.assertRaisesRegex( - RuntimeError, "full sha256 digest" + with ( + self.subTest(image=image), + self.assertRaisesRegex(RuntimeError, "full sha256 digest"), ): gate5_setup.verify_image_manifest(image, opener=opener) self.assertEqual(calls, []) @@ -429,8 +432,8 @@ def test_shim_token_is_persisted_before_body_only_install_and_verified(self): atespace="live-agent-gate", actor_name="claude-gate5", ) - token = "fixture-shim-token-with-at-least-32-characters" - statuses = [201, 401, 401, 200, 409, 200] + token = "fixture-shim-" + "v" * 40 + statuses = [201, 401, 401, 404, 409, 200] calls = [] def requester(**kwargs): @@ -452,6 +455,9 @@ def requester(**kwargs): self.assertEqual(calls[0]["path"], "/token") self.assertEqual(calls[0]["body"], {"token": token}) self.assertNotIn("token", calls[0]) + self.assertEqual( + calls[1]["path"], "/turn/00000000-0000-4000-8000-000000000000" + ) self.assertEqual(calls[1]["token"], None) self.assertEqual(calls[2]["token"], f"{token}x") self.assertEqual(calls[3]["token"], token) @@ -460,7 +466,7 @@ def requester(**kwargs): def test_shim_token_rerun_verifies_existing_token_without_rotating_it(self): with tempfile.TemporaryDirectory() as temp_dir: path = str(Path(temp_dir) / "state.json") - token = "existing-shim-token-with-at-least-32-characters" + token = "existing-shim-" + "v" * 40 state = {"run_id": "run-token", "shim_token": token} gate5_setup.save_state(path, state) args = SimpleNamespace( @@ -468,7 +474,7 @@ def test_shim_token_rerun_verifies_existing_token_without_rotating_it(self): atespace="live-agent-gate", actor_name="claude-gate5", ) - statuses = [409, 200, 401, 401, 200, 409, 200] + statuses = [409, 404, 401, 401, 404, 409, 200] calls = [] def requester(**kwargs): @@ -621,7 +627,11 @@ async def wait_for_worker(_control, namespace, *_args, **_kwargs): gate5_setup.wait_for_worker_if_actor_is_absent( control, args, - {"run_id": "run-codex", "actor_uid": None, "template_uid": "template-codex"}, + { + "run_id": "run-codex", + "actor_uid": None, + "template_uid": "template-codex", + }, ) ) diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index e6ba0da..5293a1f 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -2,40 +2,63 @@ Status: local spike, not a product feature. Adapter code lives in `backend/src/mainloop/runtime/substrate.py` and `workspace_adapter.py`; the actor manifest -lives in `spikes/substrate-workspace-adapter/`. See `docs/spikes/k8s-herdr-agents.md` for the -native-session/Herdr spike this one builds on and does not replace. - -## Current status — Round 3 and Phase 4 (2026-09-23) - -Substrate actors, the Cilium-backed preview cluster, router ingress controls, per-actor shim -tokens, and Envoy hostname enforcement were measured live. Claude completed a native turn and -recalled a nonce after suspend/resume. Codex's auth file was installed safely, but its native -turn failed at Envoy's upstream connection to the OpenAI API (HTTP 503, reset before response -headers). Gate 5 is partial/live, so defer production adoption of the native-session path until -Codex egress and session continuity are proved. +lives in `spikes/substrate-workspace-adapter/`. The earlier live proof used a Herdr-backed +actor; the target described by this closeout has no Herdr server or terminal manager in the +actor and invokes native CLIs headlessly once per turn. See +`docs/spikes/k8s-herdr-agents.md` for the historical native-session/Herdr spike. + +## Current status — Phase 5 closeout (2026-09-23) + +The Round 3 and Phase 4 runs measured Substrate actors, Cilium-enforced router ingress, +per-actor shim tokens, Envoy hostname egress, and native sessions for both Claude Code and +Codex in the earlier Herdr-backed actor. Both agents completed a native turn and recalled a +nonce in the same session after suspend/resume. Measured suspend/resume times were 393/408 ms +for Claude and 573/789 ms for Codex. Those results do not prove the headless per-turn actor +model. Its rebuilt image is **not yet live-proved**. The Codex file marker was not confirmed in +the recorded run; its follow-up result, Claude recall after worker loss, and Claude history +after revert remain pending in the cluster lane. + +The earlier Codex API HTTP 503 came from Envoy selecting unreachable IPv6 upstream addresses +when its DNS caches used `ALL`; `V4_PREFERRED` corrected that path. A separate ChatGPT SAN +failure was traced by reviewer-provided evidence to cross-SNI upstream TLS session reuse in +Envoy 1.39.1. After applying both settings, a credential-free probe returned the expected API +401 and ChatGPT 200 with no TLS verification failures, then the native Codex turn and recall +completed. Native-session support behind the Phase 3 boundary is proved for both agents in the +earlier actor design. The requested headless actor design remains unproved, the broader +continuity gate is partial, and production adoption is deferred pending live proof of its +rebuilt image and the requirements below. ## What it shows [Substrate](https://github.com/agent-substrate/substrate) can provide the per-session isolated compute Mainloop's roadmap calls for ("Workspace platform"), while Mainloop stays the durable -owner of the session<->actor mapping, delivery, and audit state. A Mainloop-authored -`ActorTemplate` (Herdr + `agentctl`, the same image contents as the Herdr spike) runs as a -Substrate actor instead of a fixed StatefulSet pod, and `backend/src/mainloop/runtime/substrate.py` -drives its lifecycle through the real `kubectl ate` control-plane CLI. +owner of session-to-actor mapping, delivery, and audit state. The target actor has no Herdr +server or terminal manager; Mainloop delivers each turn to a headless native CLI process. The +actor holds files between turns, with no attachable TUI; callers watch progress through streamed +events. The rebuilt image for that design is not yet live-proved. Existing live lifecycle and +native-session results below came from the earlier Herdr-backed actor and are historical evidence +for Substrate and the Phase 3 boundary, not proof of the target image. + +The headless shim exposes authenticated `POST /turn` and `GET /turn/:id` endpoints. Prompts are +sent to the native CLI on stdin; the shim stores bounded JSONL events per turn and reports the +native session/thread id and final message. It permits one in-flight turn per agent and returns +409 instead of queueing. `POST /run` starts a shell command with a bounded timeout and actor-local +output file; `GET /run/:id` reports its status and bounded output. `/healthz` and `/readyz` check +only the shim and workspace. The actor image and these routes still need live proof. ## Earlier real-versus-stand-in inventory (before Round 3) -| Layer | Status | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | -| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | -| `preview-gate` WorkerPool + ActorTemplate: real Herdr server, real Vite dev server, real NGINX header-proxy, real browser (`agent-browser`), real WebSocket HMR | Real | -| `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | -| Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | -| File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | -| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, credential-free boot | Golden snapshot READY and actor RUNNING proved live. A later credential-free preview actor hit the pinned gVisor restore error described below. No provider session ran. | -| Claude/Codex agent processes, credentials | Not run; the 2026-09-23 live lane stopped before credential work (see "Limits") | -| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | +| Layer | Status | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| kind cluster `substrate-preview`, pinned Substrate `cdac9baef8...` (ate-system + agentgateway dataplane) | Real | +| `mainloop-workspace` WorkerPool + ActorTemplate, actor create/get/resume/suspend/revert/delete | Real, driven through `backend/src/mainloop/runtime/substrate.py`'s actual code (not a separate probe script's own CLI calls) | +| `preview-gate` WorkerPool + ActorTemplate: real Herdr server, real Vite dev server, real NGINX header-proxy, real browser (`agent-browser`), real WebSocket HMR | Real | +| `dev-service-gate` WorkerPool + ActorTemplate: real `psql`, real external `postgres:16-alpine` StatefulSet, real `EgressPolicy` (CIDR rule, created via a small gRPC tool since `kubectl-ate` has no CLI verb for it) | Real | +| Herdr + `agentctl` inside the actor images | Real (same image contents as `spikes/k8s-herdr-agents`), without the real Claude/Codex CLIs | +| File edits and shell commands run inside actors (a generic `herdr pane run` shim, not a native agent's own Bash tool) | Stand-in -- see "Why not a real agent" below | +| `live-agent-gate` WorkerPool + ActorTemplate: real Claude/Codex CLIs, credential-free boot | Golden snapshot READY and actor RUNNING proved live. A later credential-free preview actor hit the pinned gVisor restore error described below. No provider session ran. | +| Claude/Codex agent processes, credentials | Not run; the 2026-09-23 live lane stopped before credential work (see "Limits") | +| `workspace_bindings` durable mapping (Postgres) | Fixture/unit-tested only; not exercised against a live backend + database in this run | ## Preview-gate edit path and Round 3 credential boundary @@ -51,12 +74,21 @@ CLI's local environment or filesystem. The pinned commit also has experimental static-header injection from a Kubernetes Secret URI into decrypted outbound requests. HTTPS hostname rules require the Envoy sdsmint overlay and `--experimental-egress-credential-injection`; the plain Envoy overlay has no MITM egress, and -agentgateway does not implement this injection path. Envoy 1.39.1 crashed during an earlier -install attempt, but the rebuilt Round 3 cluster ran Envoy with sdsmint and the credential -provider. Claude's credential was injected on the upstream leg and stayed out of actor snapshots. -Codex instead received `auth.json` through the authenticated shim because Codex refreshes that -file locally; its actor snapshots therefore contain that credential. Neither credential value -was logged or copied into a golden snapshot. The old unauthenticated relay was not used. +agentgateway does not implement this injection path. An earlier Envoy 1.39.1 router install +crashed, while the later Round 3 Cilium setup ran Envoy egress with sdsmint and the +actor-bound credential provider. Claude's credential was injected on the upstream leg and stayed +out of actor snapshots. Codex instead received `auth.json` through the authenticated shim because +Codex refreshes that file locally; its actor snapshots therefore contain that credential. Moving +Codex credentials out of snapshots remains a production requirement. Neither credential value +was logged or copied into a golden snapshot, and the old unauthenticated relay was not used. + +The Round 3 egress run also found that `dns_lookup_family: ALL` selected unreachable IPv6 +addresses on the IPv4-only Kind node. Setting the five egress DNS caches to `V4_PREFERRED` +allowed the credential-free API and ChatGPT probes to reach upstreams. A separate +reviewer-provided diagnosis tied the earlier ChatGPT SAN failures to cross-SNI TLS session +resumption; setting `max_session_keys: 0` on the four approved upstream TLS clusters preserved +SAN verification and the later probe passed. These are Envoy-specific settings; they do not make +agentgateway hostname enforcement safe. The preview/HMR edit itself still uses a generic exec shim (`spikes/substrate-workspace-adapter/image/exec-shim.js`) that pastes text into a real Herdr shell @@ -183,23 +215,21 @@ per `docs/network-egress.md`'s "CIDR/all policy: dial now" passthrough path) ins **hostname rule** (HTTP/TLS-SNI-specific, and Postgres is neither), which the prior proof's HTTP `fetch`-based trial did not have reason to distinguish. -## Limits from the earlier Phase 2 checkpoint +## Historical limits from the earlier Phase 2 checkpoint -- **Native-session gate, live**: no Claude or Codex session ran in the earlier checkpoint. The - owner authorized the work; earlier tool-policy decisions rejected particular actions after - the old relay returned HTTP 403. The relay was removed. The first Phase 3 preview restore - error was later diagnosed as a dead app from the old image and missing readiness probe, then - corrected with a new image and template. The current Phase 3 run passed 3a–3c and stopped at - the hostname-egress bypass described below; no provider credential was delivered. +- **Native-session gate, live at that checkpoint**: no Claude or Codex session ran then. The + owner authorized the work; a tool policy rejected particular actions after the old relay + returned HTTP 403. The relay was removed. The first Phase 3 preview restore error was later + diagnosed as a dead app from the old image and missing readiness probe, then corrected with a + new image and template. Those earlier limits were superseded by the Round 3 and Phase 4 results + above. - Preview/HMR under the router policy and actor-to-actor access were unverified at the earlier checkpoint. In the current finish run, preview HMR passed, and an unrelated actor's CONNECT to the shim was rejected at the actor egress gateway before reaching the router. The - per-actor shim token passed live checks, including suspend/resume persistence. Provider Secret - delivery and Phase 4 session continuity remain unproved; no provider Secret was created or - read. -- Phase 2's counter/marker persistence and worker-loss revert were not proved in their original - run. The current token suspend/resume check did not cover worker loss or those markers; those - checks remain open. + per-actor shim token passed live checks, including suspend/resume persistence. +- Phase 2's counter/marker persistence and worker-loss revert were not proved in that original + run. Later Phase 4 checks proved Claude's file marker across suspend/resume; the three pending + Phase 4 checks are listed in the current results below. - **`workspace_bindings` orchestration functions** (`ensure_workspace`, `resume_workspace`, ...) were not exercised against a live Postgres + running backend; only their extracted pure logic (`plan_ensure`, `_binding_from_row`, `is_crashed`) is unit tested, and the transport layer @@ -251,11 +281,13 @@ returned 200. Suspend produced snapshot The first setup pass exposed one additional pinned-CLI result shape: a valid zero-match worker query serializes as `{}`. The adapter now treats only an empty object as zero workers and still -rejects non-empty objects missing `workers`. The 9 worker-discovery regression tests pass. The -unchanged rerun did not create a duplicate actor, but waited for spare worker capacity before -checking the persisted actor UID and failed because the single worker was already occupied by -`claude-gate5`. The harness's own health check also used router Service port 80 instead of its -CONNECT listener on 8081; direct `/healthz` through 8081 worked. +rejects non-empty objects missing `workers`. The affected correction run passed 30 tests: 9 +worker-discovery tests and 21 contract tests. The live Phase 2 run reached a READY golden and a +RUNNING actor, returned `/healthz` 200 through the CONNECT listener on port 8081, and suspended +then resumed the same actor to RUNNING with `/healthz` 200. Its unchanged rerun did not create a +duplicate actor, but waited for spare worker capacity before checking the persisted actor UID and +failed because the single worker was already occupied. The marker/counter and process PID were +not measured in that run. Before any provider credential work, a separate tokenless Pod in `default` POSTed a harmless command through `atenet-router:8081` to `actor-upstream:8090/run` with the actor-routing header; @@ -350,19 +382,19 @@ to that reviewing session, not to a sandbox process killed by this agent. ## Historical CapabilityResult (before Round 3) -| Capability | State | Scope | Evidence and limit | -| ----------------------------- | ------- | ---------- | ------------------ | -| `workspace_adapter_contract` | partial | fixture | `SubstrateControl` is exercised live; `workspace_bindings` orchestration is fixture-tested, not run against a live backend and Postgres. | -| `substrate_actor_lifecycle` | partial | live | READY golden creation and actor RUNNING proved live; full marker and worker-loss recovery remain unproved. | -| `preview_hmr` | proved | live | Real Vite HMR edit and WebSocket upgrade succeeded through the preview proxy. | -| `dev_service_postgres` | proved | live | Real Postgres query, narrow allow rule, denied destination, and reconnect after wake. | -| `phase3a_networkpolicy` | proved | live | Kindnet blocked and then allowed the in-cluster HTTP probe according to NetworkPolicy. | -| `router_ingress_boundary` | proved | live | `default` was blocked; `mainloop-control` was admitted; unrelated actor CONNECT to the shim was rejected at actor egress; preview traffic passed. | -| `shim_token_auth` | proved | live | 401/409 behavior, successful bearer use, and token persistence across suspend/resume passed on the final actor. | -| `provider_egress` | failed | live | Exact hostname policy was confirmed, but HTTPS to unlisted `example.com` returned 200 through agentgateway. Actor returned to deny-all. | -| `credential_delivery` | unknown | unverified | No provider Secret was created or read; credentials were not delivered. | -| `native_session_continuity` | partial | unverified | Phase 3d failed before credential delivery; no native session was run. | -| `failure_recovery` | partial | live | Earlier CRASHED/revert mechanics were proved; worker-loss recovery and the backend-restart `recorded` case remain unproved live. | +| Capability | State | Scope | Evidence and limit | +| ---------------------------- | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workspace_adapter_contract` | partial | fixture | `SubstrateControl` is exercised live; `workspace_bindings` orchestration is fixture-tested, not run against a live backend and Postgres. | +| `substrate_actor_lifecycle` | partial | live | READY golden creation and actor RUNNING proved live; full marker and worker-loss recovery remain unproved. | +| `preview_hmr` | proved | live | Real Vite HMR edit and WebSocket upgrade succeeded through the preview proxy. | +| `dev_service_postgres` | proved | live | Real Postgres query, narrow allow rule, denied destination, and reconnect after wake. | +| `phase3a_networkpolicy` | proved | live | Kindnet blocked and then allowed the in-cluster HTTP probe according to NetworkPolicy. | +| `router_ingress_boundary` | proved | live | `default` was blocked; `mainloop-control` was admitted; unrelated actor CONNECT to the shim was rejected at actor egress; preview traffic passed. | +| `shim_token_auth` | proved | live | 401/409 behavior, successful bearer use, and token persistence across suspend/resume passed on the final actor. | +| `provider_egress` | failed | live | Exact hostname policy was confirmed, but HTTPS to unlisted `example.com` returned 200 through agentgateway. Actor returned to deny-all. | +| `credential_delivery` | unknown | unverified | No provider Secret was created or read; credentials were not delivered. | +| `native_session_continuity` | partial | unverified | Phase 3d failed before credential delivery; no native session was run. | +| `failure_recovery` | partial | live | Earlier CRASHED/revert mechanics were proved; worker-loss recovery and the backend-restart `recorded` case remain unproved live. | ## Historical recommendation (superseded by the Round 3/Phase 4 result) @@ -385,48 +417,54 @@ provider Secret or credential-bearing snapshot was created. Current Kind/Docker disk headroom are recorded in the task proof note. The owner handles rotation of credentials previously served by the removed relay. -## Current CapabilityResult — Round 3 and Phase 4 - -| Capability | State | Scope | Evidence and limit | -| --- | --- | --- | --- | -| `workspace_adapter_contract` | partial | fixture | Contract and identity reconciliation are covered by fakes; the `workspace_bindings` orchestration was not run against live Postgres and a running backend. | -| `substrate_actor_lifecycle` | proved | live | Golden, actor readiness, router ingress, and suspend/resume passed on the Cilium preview cluster. | -| `preview_hmr` | proved | live | The earlier real Vite/WebSocket HMR route remains measured and passed. | -| `dev_service_postgres` | proved | live | The earlier real PostgreSQL query, narrow CIDR rule, denied destination, and wake reconnection remain measured and passed. | -| `networkpolicy_enforcement` | proved | live | Cilium blocked the denied probe and allowed the control-namespace probe. | -| `router_ingress_boundary` | proved | live | `default` was denied; `mainloop-control` was admitted; the preview route remained functional. | -| `shim_token_auth` | proved | live | Missing/wrong/correct token, one-time install, and suspend/resume persistence passed. | -| `image_manifest_preflight` | proved | live | The exact pushed digest returned registry HTTP 200 before template creation; fake-backed tests cover the manifest check and rejection cases. | -| `shim_healthz` | proved | live | Readiness checks pass through the actor route; bounded Herdr calls and cached health have fixture coverage. | -| `cilium_kube_proxy_replacement` | partial | live | KPR=true core checks passed. DNS to the CoreDNS Pod IP worked, while DNS to the kube-dns Service IP timed out. The exact failing component was not isolated; kube-proxy replacement ClusterIP translation from the nested actor network is only a hypothesis. The cluster fell back to KPR=false as directed. | -| `provider_hostname_egress` | proved | live | Actor A's listed Claude host returned 404 while unlisted `example.com` and raw IP returned 403. Actor B's differing rule allowed `example.com` to reach an upstream 503, denied `api.anthropic.com`, and denied raw IP. Track B was skipped because Track A passed. | -| `dummy_header_injection` | proved | live | The compare-only provider returned a fixed match boolean; the final actor had no injected value in its env/files and received no echoed value. Earlier dummy-only diagnostic history is in the task proof note. | -| `credential_delivery` | proved | live | Claude's credential was retrieved by the actor-bound provider and injected on the Envoy upstream leg. Codex `auth.json` was installed through the authenticated shim, once, mode 0600. These delivery proofs do not imply successful authentication for both CLIs. | -| `claude_native_session` | proved | live | Claude Code 2.1.280 completed a turn, preserved its session ID through suspend/resume, and recalled a prior nonce. | -| `codex_native_session` | partial | live | Codex CLI 0.156.1 created a thread, but Envoy returned an upstream-connect 503 before the turn completed; the model was not reported, and there was no marker or recall. No Codex revert was attempted. | -| `native_session_continuity` | partial | live | Claude suspend/resume recall passed. Claude post-worker-loss recall and transcript rollback after snapshot revert remain unverified; Codex continuity did not pass. | -| `snapshot_revert` | partial | live | Claude state-A restore and actor lifecycle passed, but transcript/history rollback was not conclusively measured. Codex revert was not attempted. | -| `worker_loss_recovery` | partial | live | The Claude actor recovered on a replacement worker from its completed snapshot. Post-loss native recall did not complete. | -| `backend_restart_delivery_reconciliation` | proved | fixture | The `ContractStore` fake test models a persisted `recorded` row across ownership restart, requires `not_delivered` evidence before retry, and rejects a duplicate attempt. | -| `snapshot_bucket_access_control` | unknown | unverified | Read access to snapshot storage was not established in this install. | - -## Current recommendation - -**Defer production adoption of the Substrate native-session path.** The live run proves that -Substrate can host the isolated workspace lifecycle, enforce router ingress and actor hostname -egress, inject Claude credentials outside the actor snapshot, and preserve a Claude session -through suspend/resume. It does not prove the required two-agent path: Codex could not complete a -turn because the egress Envoy reset its OpenAI upstream connection before response headers -(HTTP 503). The same credential-free API request returned `server: envoy` and an -`upstream connect error`, while TLS verification succeeded and the unlisted-host rule still -returned 403. The bounded review found no image, CA, install-flag, actor identity, or hostname -policy mismatch. - -Before production, resolve and retest that Envoy-to-OpenAI upstream hop, then prove Codex -authentication and nonce recall across suspend/resume. Production also needs a GitOps-managed -router NetworkPolicy, a CNI that enforces it, shim-token issuance from the real Mainloop backend, -and snapshot-bucket access control. The snapshot containing Codex `auth.json` must be removed -when the actor is deleted; the owner handles credential rotation. +## Current CapabilityResult — gates 1–6 and Phase 4 + +| Gate / capability | State | Scope | Evidence and limit | +| ----------------------------------------------- | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Gate 1 — native contract | proved | fixture | Record transitions, ownership fencing, and reconciliation are exercised with `ContractStore` fakes; this is not a database-backed restart proof. | +| Gate 2 — workspace binding adapter | partial | fixture | Planning and row-mapping logic use fakes; `workspace_bindings` orchestration was not run against live Postgres and a running backend. | +| Gate 2 — Substrate control adapter | proved | live | Actor lifecycle operations used the real `kubectl ate` adapter and the Cilium preview cluster. | +| Gate 3 — preview/HMR | proved | live | Real Vite edits updated the open browser session over WebSocket, including across suspend/resume. | +| Gate 4 — dev-service access | proved | live | Real PostgreSQL query, narrow CIDR policy, denied destination, and reconnect after wake passed. | +| Gate 5 — native sessions in prior actor design | proved | live | Claude and Codex each completed a native turn and recalled a nonce after suspend/resume in the same Herdr-backed actor. Suspend/resume measured 393/408 ms for Claude and 573/789 ms for Codex. | +| Gate 5 — headless per-turn CLI actor | unknown | unverified | Target design has no Herdr server or terminal manager in the actor. Its rebuilt image is not yet live-proved; the earlier Phase 4 measurements do not establish this gate. | +| Gate 5 — complete continuity | partial | live | In the earlier actor design, the Codex file marker was not confirmed. Claude recall after worker loss, Claude history after revert, and the Codex marker follow-up remain pending. | +| Gate 6 — actor failure recovery | partial | live | CRASHED-to-revert-to-resume and replacement-worker restore were measured; native recall after worker loss and history after revert are not established. | +| Phase 4 — Claude native session (prior actor) | proved | live | In the earlier Herdr-backed actor, Claude Code completed a turn and same-session nonce recall after suspend/resume; the post-worker-loss and post-revert history checks remain pending. | +| Phase 4 — Codex native session (prior actor) | partial | live | In the earlier Herdr-backed actor, Codex CLI completed a turn and same-session nonce recall after suspend/resume; file-marker continuity remains pending. | +| `backend_restart_delivery_reconciliation` | proved | fixture | A new fake-backed test reloads a persisted message and `recorded` attempt into a fresh `ContractStore`; retry is blocked until `not_delivered` evidence, and the same payload reference remains pending. It does not exercise Postgres, a transport, or the production delivery loop. | +| `router_ingress_boundary` and `shim_token_auth` | proved | live | The unrelated namespace was denied, control-namespace access succeeded, and missing/wrong/correct token plus one-time install and suspend/resume checks passed. | +| `provider_hostname_egress` | proved | live | Envoy/Cilium actor policies denied unlisted hosts; the earlier agentgateway HTTPS path allowed an unlisted host and is not a supported hostname boundary. | +| `credential_delivery` | proved | live | Claude was injected on the Envoy upstream leg. Codex `auth.json` was installed through the shim and remains in the actor snapshot; production must move Codex credentials to egress injection. | +| `cilium_kube_proxy_replacement` | partial | live | KPR=true CoreDNS Pod-IP queries worked, but kube-dns Service-IP queries timed out. The cause was not isolated; the run continued with KPR=false. | +| `snapshot_bucket_access_control` | unknown | unverified | Snapshot-bucket read access was not established. | +| `stuck_state_timeouts` | unknown | unverified | Production timeouts and surfaced recovery for stuck states remain to be implemented and measured. | + +## Current recommendation and production requirements + +**Defer production adoption of the headless per-turn actor design.** The earlier Herdr-backed +actor proved that Substrate and the Phase 3 boundary can host native Claude and Codex sessions; +both completed a turn and same-session nonce recall across suspend/resume. This does not prove +the target actor, which has no Herdr server or terminal manager and invokes native CLIs headlessly +per turn. Its rebuilt image is not yet live-proved. Production readiness also remains partial +because the Codex marker and two Claude post-worker-loss/revert-history checks are pending, and +durable backend integration and snapshot access controls are not proved. + +Production requirements: + +- Isolate worker selection by tenant. Substrate does not scope selection by atespace or + namespace; require unique per-tenant WorkerPool names/selectors and enforce uniqueness with + admission policy. +- Manage the router `NetworkPolicy` through GitOps and use a CNI that enforces it. +- Issue and rotate per-actor shim tokens from the real Mainloop backend. +- Enforce snapshot-bucket access controls. The Codex snapshot currently contains `auth.json`. +- Move Codex credentials from the actor snapshot to a supported egress-injection flow. +- Use Envoy sdsmint for experimental credential injection; agentgateway does not implement it. +- Set `dns_lookup_family: V4_PREFERRED` on the egress DNS caches and `max_session_keys: 0` on + the relevant upstream TLS clusters; retain hostname and SAN verification. +- Resolve KPR=true actor DNS: CoreDNS Pod-IP lookup worked, but kube-dns Service-IP lookup + timed out and the exact cause remains unknown. +- Add bounded timeouts and visible recovery for actors and deliveries that remain stuck. ## Current cleanup and review hold diff --git a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl index f4542e2..fb24de2 100644 --- a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl @@ -31,11 +31,9 @@ spec: cpu: 250m memory: 2Gi --- -# ActorTemplate: one Herdr + agentctl container per actor, same image and entrypoint as -# spikes/k8s-herdr-agents (see that spike's Dockerfile/bin/entrypoint.sh), running under -# Substrate instead of a StatefulSet. snapshotsConfig captures full process memory on -# suspend/commit so a resumed actor's native agent session (Claude/Codex under Herdr) continues -# rather than cold-booting -- this is the behavior gate 5 (native-session continuity) measures. +# ActorTemplate: one headless per-turn native-agent container per actor, running under Substrate. +# Substrate owns lifecycle and snapshots; the actor retains workspace files between turns and +# has no Herdr server or terminal manager. Gate 5 must live-prove the rebuilt image. metadata: atespace: ${ATESPACE} name: ${TEMPLATE_NAME} @@ -50,10 +48,9 @@ containers: env: - { name: HOME, value: /workspace/.home } - { name: WORKSPACE_PATH, value: /workspace/repo } - - { name: STANDIN_STATE_DIR, value: /workspace/.standin } - - { name: HERDR_SESSION, value: mainloop-substrate } - { name: AGENT_CONFIG_DIR, value: /etc/agent-config } - { name: CODEX_HOME, value: /workspace/.codex } + - { name: EXEC_SHIM_STATE_DIR, value: /workspace/.mainloop/exec-shim } volumeMounts: - { name: workspace, mountPath: /workspace } # ateapipb.SecurityContext only models Linux capability adjustments (no diff --git a/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl index dfb08a5..f231ecc 100644 --- a/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl @@ -1,10 +1,11 @@ # WorkerPool + ActorTemplate for the bounded live native-agent proof (gate 5 in -# .tasknotes/plan.md): real Claude Code / Codex CLIs under Herdr, driven by the exec shim. See -# spikes/substrate-workspace-adapter/live-agent-image/. +# .tasknotes/plan.md): headless per-turn Claude Code / Codex CLIs, driven by the actor-local +# shim. Substrate owns suspend/resume/revert; no terminal manager runs in the actor. +# See spikes/substrate-workspace-adapter/live-agent-image/. # # Deliberately no credential-relay env: the golden actor boots without credentials or external -# network access. The pinned ActorTemplate API supports readyz; the probe waits for the control -# server, shell pane, and exec shim before the golden snapshot or final actor is considered ready. +# network access. The pinned ActorTemplate API supports readyz; the probe waits for the shim and +# workspace to become healthy before the golden snapshot or final actor is considered ready. apiVersion: v1 kind: Namespace metadata: diff --git a/spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml b/spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml index 01901a3..0a18299 100644 --- a/spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml +++ b/spikes/substrate-workspace-adapter/k8s/router-ingress-policy.yaml @@ -13,33 +13,33 @@ spec: matchLabels: app: atenet-router policyTypes: - - Ingress + - Ingress ingress: - # Trusted Mainloop backend. It uses HTTP plus the Substrate CONNECT listener - # for actor control and arbitrary-port workspace traffic. - - from: - - namespaceSelector: - matchLabels: - mainloop.dev/role: control - ports: - - { protocol: TCP, port: 8080 } - - { protocol: TCP, port: 8081 } - - { protocol: TCP, port: 8443 } - - { protocol: TCP, port: 8444 } - # The fixed NGINX preview proxy injects ate-target-actor and only needs HTTP. - - from: - - namespaceSelector: - matchLabels: - mainloop.dev/role: workspace - podSelector: - matchLabels: - app: preview-proxy - ports: - - { protocol: TCP, port: 8080 } - # The installer annotates the router for Prometheus scraping on 15020. - - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: otel-system - ports: - - { protocol: TCP, port: 15020 } + # Trusted Mainloop backend. It uses HTTP plus the Substrate CONNECT listener + # for actor control and arbitrary-port workspace traffic. + - from: + - namespaceSelector: + matchLabels: + mainloop.dev/role: control + ports: + - { protocol: TCP, port: 8080 } + - { protocol: TCP, port: 8081 } + - { protocol: TCP, port: 8443 } + - { protocol: TCP, port: 8444 } + # The fixed NGINX preview proxy injects ate-target-actor and only needs HTTP. + - from: + - namespaceSelector: + matchLabels: + mainloop.dev/role: workspace + podSelector: + matchLabels: + app: preview-proxy + ports: + - { protocol: TCP, port: 8080 } + # The installer annotates the router for Prometheus scraping on 15020. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: otel-system + ports: + - { protocol: TCP, port: 15020 } diff --git a/spikes/substrate-workspace-adapter/live-agent-image/.gitignore b/spikes/substrate-workspace-adapter/live-agent-image/.gitignore index 8ab7127..64443b6 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/.gitignore +++ b/spikes/substrate-workspace-adapter/live-agent-image/.gitignore @@ -1,4 +1,6 @@ -herdr +/claude +/codex +/codex-code-mode-host claude codex codex-code-mode-host diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile index f7724a4..d71d199 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -1,11 +1,8 @@ # syntax=docker/dockerfile:1.7 -# Live-agent-gate spike image (gate 5, bounded live proof): real Herdr + real Claude Code / -# Codex CLIs + agentctl, driven by the same generic exec shim as the other gate images. -# herdr/claude/codex/codex-code-mode-host are copied from the host into the build context by the -# build script (never committed). No credentials are baked into this image, and entrypoint.sh -# never fetches one: the golden actor built from this image boots to a ready control service -# with no credential and no external network access. Credential delivery remains deferred until -# a reviewed boundary exists (see docs/spikes/substrate-workspace-adapter.md). +# Headless Substrate actor image: Substrate owns lifecycle; the shim starts one native CLI +# process per delivered turn. Claude/Codex binaries are supplied in the local build context. +# No credentials are baked in or fetched during startup; the golden actor reaches readiness +# without credentials or external network access. FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates git curl procps ripgrep \ && rm -rf /var/lib/apt/lists/* \ @@ -20,24 +17,23 @@ RUN --mount=type=secret,id=egress-mitm-ca,target=/run/secrets/egress-mitm-ca,req install -m 0644 /run/secrets/egress-mitm-ca /usr/local/share/ca-certificates/substrate-egress-mitm.crt && \ update-ca-certificates; \ fi -COPY herdr /usr/local/bin/herdr COPY claude /usr/local/bin/claude COPY codex /usr/local/bin/codex COPY codex-code-mode-host /usr/local/bin/codex-code-mode-host -COPY bin/agentctl bin/mainloop /usr/local/bin/ COPY bin/start-native-agent /usr/local/bin/start-native-agent COPY bin/prepare-native-agent-config.cjs /usr/local/bin/prepare-native-agent-config.cjs -COPY agent-config /etc/agent-config +COPY agent-config/mainloop-system.txt /etc/agent-config/mainloop-system.txt COPY exec-shim.js /usr/local/bin/exec-shim.js COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/agentctl /usr/local/bin/mainloop /usr/local/bin/start-native-agent \ +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/start-native-agent \ && mkdir -p /work && chown -R agent:agent /work ENV EXEC_SHIM=/usr/local/bin/exec-shim.js +ENV NATIVE_AGENT_LAUNCHER=/usr/local/bin/start-native-agent ENV HOME=/home/agent ENV WORKSPACE_PATH=/work/repo ENV CODEX_HOME=/home/agent/.codex -ENV AGENT_CONFIG_DIR=/etc/agent-config -ENV HERDR_SESSION=mainloop-live-agent +ENV EXEC_SHIM_STATE_DIR=/work/repo/.mainloop/exec-shim +ENV AGENT_SYSTEM_PROMPT_FILE=/etc/agent-config/mainloop-system.txt ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt USER 10001:10001 diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs b/spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs index 8217864..e2de1bc 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/prepare-native-agent-config.cjs @@ -1,3 +1,4 @@ +// Seed stable first-run defaults for the headless per-turn Claude and Codex CLIs. 'use strict'; const fs = require('node:fs'); @@ -15,18 +16,26 @@ fs.mkdirSync(claudeSettingsDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); if (!fs.existsSync(claudeConfig) || fs.statSync(claudeConfig).size === 0) { - fs.writeFileSync(claudeConfig, `${JSON.stringify({ - hasCompletedOnboarding: true, - numStartups: 1, - theme: 'dark', - projects: { - [workspace]: { - hasTrustDialogAccepted: true, - hasCompletedProjectOnboarding: true, - allowedTools: [], + fs.writeFileSync( + claudeConfig, + `${JSON.stringify( + { + hasCompletedOnboarding: true, + numStartups: 1, + theme: 'dark', + projects: { + [workspace]: { + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + allowedTools: [] + } + } }, - }, - }, null, 2)}\n`, { mode: 0o600 }); + null, + 2 + )}\n`, + { mode: 0o600 } + ); } if (!fs.existsSync(claudeSettings) || fs.statSync(claudeSettings).size === 0) { fs.writeFileSync(claudeSettings, '{"skipDangerousModePermissionPrompt":true}\n', { mode: 0o600 }); @@ -45,7 +54,7 @@ if (!fs.existsSync(codexConfig) || fs.statSync(codexConfig).size === 0) { '', '[notice]', 'hide_rate_limit_model_nudge = true', - '', + '' ].join('\n'); fs.writeFileSync(codexConfig, defaults, { mode: 0o600 }); } diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent b/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent index 0a61910..726816d 100755 --- a/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent @@ -1,47 +1,61 @@ #!/usr/bin/env bash -# Start one native CLI in the actor's persistent Herdr shell pane after credential delivery. +# Run one headless native CLI turn. The prompt is inherited on stdin, never passed in argv. set -euo pipefail -kind="${1:?usage: start-native-agent claude|codex}" -if [[ $# -ne 1 ]]; then - echo 'usage: start-native-agent claude|codex' >&2 - exit 2 +kind="${1:?usage: start-native-agent claude|codex [native-session-id]}" +if [[ $# -gt 2 ]]; then + echo 'usage: start-native-agent claude|codex [native-session-id]' >&2 + exit 2 +fi +session_id="${2-}" +if [[ -n ${session_id} && ! ${session_id} =~ ^[A-Za-z0-9._:-]{1,256}$ ]]; then + echo 'invalid native session id' >&2 + exit 2 fi workspace="${WORKSPACE_PATH:-/work/repo}" cd "${workspace}" +# Keep telemetry and automatic network chatter disabled for every native invocation. +export DISABLE_TELEMETRY=1 +export DISABLE_ERROR_REPORTING=1 +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export DISABLE_AUTOUPDATER=1 + case "${kind}" in - claude) - token_file="${HOME}/.mainloop/claude-token" - if [[ ! -s "${token_file}" ]]; then - echo 'Claude credential is not installed' >&2 - exit 1 - fi - CLAUDE_CODE_OAUTH_TOKEN="$(tr -d ' \r\n' <"${token_file}")" - if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN}" ]]; then - echo 'Claude credential is empty' >&2 - exit 1 - fi - export CLAUDE_CODE_OAUTH_TOKEN - export DISABLE_TELEMETRY=1 - export DISABLE_ERROR_REPORTING=1 - export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 - export DISABLE_AUTOUPDATER=1 - exec claude \ - --dangerously-skip-permissions \ - --append-system-prompt-file "${AGENT_SYSTEM_PROMPT_FILE:-/etc/agent-config/mainloop-system.txt}" - ;; - codex) - auth_file="${CODEX_HOME:-${HOME}/.codex}/auth.json" - if [[ ! -s "${auth_file}" ]]; then - echo 'Codex credential is not installed' >&2 - exit 1 - fi - exec codex --dangerously-bypass-approvals-and-sandbox - ;; - *) - echo 'unsupported native CLI' >&2 - exit 2 - ;; +claude) + token_file="${HOME}/.mainloop/claude-token" + if [[ ! -s ${token_file} ]]; then + echo 'Claude credential is not installed' >&2 + exit 1 + fi + CLAUDE_CODE_OAUTH_TOKEN="$(tr -d ' \r\n' <"${token_file}")" + if [[ -z ${CLAUDE_CODE_OAUTH_TOKEN} ]]; then + echo 'Claude credential is empty' >&2 + exit 1 + fi + export CLAUDE_CODE_OAUTH_TOKEN + args=(-p) + if [[ -n ${session_id} ]]; then + args+=(--resume "${session_id}") + fi + args+=(--output-format stream-json --dangerously-skip-permissions + --append-system-prompt-file "${AGENT_SYSTEM_PROMPT_FILE:-/etc/agent-config/mainloop-system.txt}") + exec claude "${args[@]}" + ;; +codex) + auth_file="${CODEX_HOME:-${HOME}/.codex}/auth.json" + if [[ ! -s ${auth_file} ]]; then + echo 'Codex credential is not installed' >&2 + exit 1 + fi + if [[ -n ${session_id} ]]; then + exec codex exec resume --json "${session_id}" --dangerously-bypass-approvals-and-sandbox + fi + exec codex exec --json --dangerously-bypass-approvals-and-sandbox + ;; +*) + echo 'unsupported native CLI' >&2 + exit 2 + ;; esac diff --git a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh index 82dc5e1..3ecbb62 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh +++ b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh @@ -1,50 +1,37 @@ #!/usr/bin/env bash -# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts -# Live-agent-gate actor entrypoint (gate 5 in .tasknotes/plan.md, bounded live proof): a real -# Herdr server plus real Claude Code / Codex CLIs. Substrate has no Kubernetes-Secret-equivalent -# volume/env mechanism for an actor (see docs/spikes/substrate-workspace-adapter.md, -# "Credential-injection gap"). -# -# Credential-free by construction: this entrypoint never fetches a credential and never -# requires network access to reach a running state. Mainloop installs a per-actor shim token -# and provider credentials only after the final actor is RUNNING. The golden actor stays clean. -# The template controller checks `/healthz` before accepting the golden actor. +# Start only the actor-local shim. Native CLIs run headlessly once per delivered turn. set -eu -# Seed supported first-run defaults before either CLI is started. Native sessions start only -# through start-native-agent, after the final actor receives its credential. -node /usr/local/bin/prepare-native-agent-config.cjs +node /usr/local/bin/prepare-native-agent-config.cjs mkdir -p "${WORKSPACE_PATH}" [[ -d "${WORKSPACE_PATH}/.git" ]] || git -C "${WORKSPACE_PATH}" init -q -# Credential-free identity/counter marker for the fake-payload proof (recovery plan step 2): -# a plain file the exec shim can read/increment to verify golden restore and suspend/resume -# without any real agent session or credential. + +# Credential-free marker retained for the bounded Gate 5 restore/suspend-resume probe. [[ -f "${WORKSPACE_PATH}/gate5-counter" ]] || echo 0 >"${WORKSPACE_PATH}/gate5-counter" -echo "herdr $(herdr --version) starting (HOME=${HOME} session=${HERDR_SESSION})" -herdr --session "${HERDR_SESSION}" server & -HERDR_PID=$! +node "${EXEC_SHIM}" & +SHIM_PID=$! +trap 'kill "${SHIM_PID}" 2>/dev/null || true' EXIT INT TERM -# The persistent control service's readiness check: an explicit, confirmed status call, -# not a fixed sleep or a pre-confirmation log line. The ActorTemplate's `/healthz` probe -# checks the Herdr server and this shell pane before the controller captures its snapshot. ready=0 for _ in $(seq 1 60); do - if herdr --session "${HERDR_SESSION}" status server >/dev/null 2>&1; then + if curl --fail --silent http://127.0.0.1:"${EXEC_SHIM_PORT:-8090}"/healthz >/dev/null; then ready=1 break fi + if ! kill -0 "${SHIM_PID}" 2>/dev/null; then + wait "${SHIM_PID}" || true + echo 'CONTROL_SERVICE_READINESS_FAILED: shim exited before healthz passed' >&2 + exit 1 + fi sleep 0.5 done if [[ ${ready} -ne 1 ]]; then - echo "CONTROL_SERVICE_READINESS_TIMEOUT: herdr server did not report ready within 30s" >&2 - kill "${HERDR_PID}" 2>/dev/null || true + echo 'CONTROL_SERVICE_READINESS_TIMEOUT: shim/workspace not ready within 30s' >&2 + kill "${SHIM_PID}" 2>/dev/null || true + wait "${SHIM_PID}" || true exit 1 fi -shell_ws=$(herdr --session "${HERDR_SESSION}" workspace create --label shell --cwd "${WORKSPACE_PATH}") -shell_pane=$(echo "${shell_ws}" | jq -r '.result.root_pane.pane_id') - -EXEC_SHIM_PANE_ID="${shell_pane}" HERDR_SESSION="${HERDR_SESSION}" node "${EXEC_SHIM}" & -echo "CONTROL_SERVICE_READY session=${HERDR_SESSION} pane=${shell_pane}" -wait "${HERDR_PID}" +echo "CONTROL_SERVICE_READY workspace=${WORKSPACE_PATH} port=${EXEC_SHIM_PORT:-8090}" +wait "${SHIM_PID}" diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index be75d1c..c92b73b 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -1,36 +1,40 @@ -// Minimal preview-gate command executor: POST /run { command } pastes `command` as literal -// text into a real Herdr shell pane, and GET /read returns its current terminal buffer. -// Authenticated POST /credential { name, contents } writes only one of the fixed credential -// files used by the native-agent launchers. Request bodies are never logged. -// Listens on port 8090, separate from the Vite dev server's port 80. Reached only through -// atenet-router's arbitrary-port CONNECT tunnel with the ate-target-actor header (see -// docs/api-guide.md "Workload Connectivity"), from test orchestration on the host -- never -// through the previewed route a browser uses (port 80 via the NGINX header-proxy). +// Authenticated, headless turn and command service for the Substrate actor. +// Prompts are piped to the native CLI on stdin and are never logged or persisted. 'use strict'; const http = require('node:http'); -const { execFile } = require('node:child_process'); +const { spawn } = require('node:child_process'); const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const PANE_ID = process.env.EXEC_SHIM_PANE_ID; -const SESSION = process.env.HERDR_SESSION; -const HEALTH_COMMAND_TIMEOUT_MS = 1500; -const HEALTH_CACHE_MS = 3000; const MAX_REQUEST_BODY_BYTES = 64 * 1024; const MAX_CREDENTIAL_REQUEST_BODY_BYTES = 512 * 1024; const MAX_CREDENTIAL_BYTES = 64 * 1024; -if (!PANE_ID || !SESSION) { - console.error('exec-shim: EXEC_SHIM_PANE_ID and HERDR_SESSION are required'); - process.exit(1); +const MAX_JOB_OUTPUT_BYTES = 2 * 1024 * 1024; +const MAX_RESPONSE_OUTPUT_BYTES = 32 * 1024; +const MAX_RETURN_EVENTS = 256; +const DEFAULT_RUN_TIMEOUT_MS = 60_000; +const DEFAULT_TURN_TIMEOUT_MS = 10 * 60_000; +const MAX_TIMEOUT_MS = 10 * 60_000; +const WORKSPACE_PATH = path.resolve(process.env.WORKSPACE_PATH || '/work/repo'); +const HOME_PATH = path.resolve(process.env.HOME || '/home/agent'); +const CODEX_HOME_PATH = path.resolve(process.env.CODEX_HOME || path.join(HOME_PATH, '.codex')); +const STATE_DIR = path.resolve( + process.env.EXEC_SHIM_STATE_DIR || path.join(WORKSPACE_PATH, '.mainloop') +); +const RUN_DIR = path.join(STATE_DIR, 'runs'); +const TURN_DIR = path.join(STATE_DIR, 'turns'); +const LAUNCHER = process.env.NATIVE_AGENT_LAUNCHER || '/usr/local/bin/start-native-agent'; + +for (const directory of [STATE_DIR, RUN_DIR, TURN_DIR]) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); } -const tokenPath = path.join(process.env.HOME || '/home/agent', '.mainloop', 'exec-shim-token'); -const homePath = path.resolve(process.env.HOME || '/home/agent'); -const codexHomePath = path.resolve(process.env.CODEX_HOME || path.join(homePath, '.codex')); +const tokenPath = path.join(HOME_PATH, '.mainloop', 'exec-shim-token'); const credentialPaths = new Map([ - ['claude-token', path.join(homePath, '.mainloop', 'claude-token')], - ['codex-auth', path.join(codexHomePath, 'auth.json')], + ['claude-token', path.join(HOME_PATH, '.mainloop', 'claude-token')], + ['codex-auth', path.join(CODEX_HOME_PATH, 'auth.json')] ]); let bearerToken = null; try { @@ -39,8 +43,15 @@ try { if (err.code !== 'ENOENT') throw err; } +const jobs = new Map(); +const activeTurns = new Map(); + +function json(res, status, document) { + res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(document)); +} + function authorized(req) { - if (bearerToken === null) return true; + if (bearerToken === null) return false; const header = req.headers.authorization; if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false; const provided = Buffer.from(header.slice('Bearer '.length)); @@ -66,7 +77,7 @@ function requestBody(req, res, onBody, maxBytes = MAX_REQUEST_BODY_BYTES) { if (!tooLarge) chunks.push(chunk); }); req.on('end', () => { - if (!tooLarge) onBody(Buffer.concat(chunks).toString('utf8')); + if (!tooLarge && !res.destroyed) onBody(Buffer.concat(chunks).toString('utf8')); }); } @@ -97,7 +108,8 @@ function installCredential(name, contents) { typeof contents !== 'string' || !contents || Buffer.byteLength(contents, 'utf8') > MAX_CREDENTIAL_BYTES - ) return null; + ) + return null; if (name === 'codex-auth') { let auth; try { @@ -126,87 +138,422 @@ function installCredential(name, contents) { fs.closeSync(fd); fs.chmodSync(destination, 0o600); } catch (err) { - try { fs.closeSync(fd); } catch {} + try { + fs.closeSync(fd); + } catch {} fs.rmSync(destination, { force: true }); throw err; } return true; } -function herdr(args, res) { - execFile('herdr', ['--session', SESSION, ...args], (err, stdout, stderr) => { - if (err) { - res.writeHead(502).end(String(err)); - return; +function atomicJsonWrite(file, document) { + const temporary = file + '.' + process.pid + '.tmp'; + fs.writeFileSync(temporary, JSON.stringify(document), { mode: 0o600 }); + fs.renameSync(temporary, file); +} + +function metadataPath(directory, id) { + return path.join(directory, id + '.json'); +} + +function loadJobs(directory, kind) { + for (const name of fs.readdirSync(directory)) { + if (!name.endsWith('.json')) continue; + try { + const record = JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8')); + if (!record || typeof record.id !== 'string') continue; + if (record.status === 'running') { + record.status = 'interrupted'; + record.exit_code = null; + record.blocking = kind === 'turn'; + record.updated_at = new Date().toISOString(); + atomicJsonWrite(path.join(directory, name), record); + } + jobs.set(record.id, { ...record, kind }); + if (kind === 'turn' && record.blocking && record.agent) { + activeTurns.set(record.agent, record.id); + } + } catch { + // Ignore an incomplete or corrupt record; it cannot safely be resumed. } - res - .writeHead(200, { 'content-type': 'application/json' }) - .end(JSON.stringify({ ok: true, stdout, stderr })); - }); + } } -function runHealthCheck() { - return new Promise((resolve, reject) => { - execFile( - 'herdr', - ['--session', SESSION, 'status', 'server'], - { timeout: HEALTH_COMMAND_TIMEOUT_MS }, - (statusErr, stdout) => { - if (statusErr || !/^status:\s+running\s*$/m.test(stdout)) { - reject(statusErr || new Error('Herdr server is not running')); - return; - } - execFile( - 'herdr', - ['--session', SESSION, 'pane', 'read', PANE_ID], - { timeout: HEALTH_COMMAND_TIMEOUT_MS }, - (paneErr) => { - if (paneErr) { - reject(paneErr); - return; - } - resolve(); - }, - ); - }, - ); +loadJobs(RUN_DIR, 'run'); +loadJobs(TURN_DIR, 'turn'); + +function createJob(kind, fields) { + const id = crypto.randomUUID(); + const directory = kind === 'run' ? RUN_DIR : TURN_DIR; + const job = { + id, + kind, + status: 'running', + exit_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + truncated: false, + stored_bytes: 0, + ...fields + }; + jobs.set(id, job); + atomicJsonWrite(metadataPath(directory, id), publicMetadata(job)); + return job; +} + +function publicMetadata(job) { + const { + id, + kind, + status, + exit_code, + created_at, + updated_at, + truncated, + agent, + native_session_id, + blocking + } = job; + return { + id, + kind, + status, + exit_code, + created_at, + updated_at, + truncated, + agent, + native_session_id, + blocking + }; +} + +function saveJob(job) { + job.updated_at = new Date().toISOString(); + const directory = job.kind === 'run' ? RUN_DIR : TURN_DIR; + atomicJsonWrite(metadataPath(directory, job.id), publicMetadata(job)); +} + +function appendBounded(job, filename, chunk) { + const remaining = MAX_JOB_OUTPUT_BYTES - job.stored_bytes; + if (remaining <= 0) { + job.truncated = true; + return; + } + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const stored = bytes.length > remaining ? bytes.subarray(0, remaining) : bytes; + fs.appendFileSync(filename, stored, { mode: 0o600 }); + job.stored_bytes += stored.length; + if (stored.length < bytes.length) job.truncated = true; +} + +function readBounded(filename, limit = MAX_RESPONSE_OUTPUT_BYTES) { + try { + const fd = fs.openSync(filename, 'r'); + try { + const size = fs.fstatSync(fd).size; + const length = Math.min(size, limit); + const buffer = Buffer.alloc(length); + fs.readSync(fd, buffer, 0, length, Math.max(0, size - length)); + return { text: buffer.toString('utf8'), truncated: size > limit }; + } finally { + fs.closeSync(fd); + } + } catch (err) { + if (err.code === 'ENOENT') return { text: '', truncated: false }; + throw err; + } +} + +function runOutputPath(job) { + return path.join(RUN_DIR, job.id + '.output'); +} + +function turnEventsPath(job) { + return path.join(TURN_DIR, job.id + '.events.jsonl'); +} + +function turnStderrPath(job) { + return path.join(TURN_DIR, job.id + '.stderr'); +} + +function killProcessGroup(child, signal) { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch (err) { + if (err.code !== 'ESRCH') child.kill(signal); + } +} + +function runChild(job, child, timeoutMs, onStart) { + let timedOut = false; + let spawnError = null; + let finalized = false; + let exitCode = null; + const finish = (code) => { + if (finalized) return; + finalized = true; + if (timedOut) job.status = 'timed_out'; + else if (spawnError) job.status = 'failed'; + else job.status = code === 0 ? 'completed' : 'failed'; + job.exit_code = code; + job.blocking = false; + if (spawnError) job.error = 'process could not start'; + saveJob(job); + if (job.kind === 'turn' && activeTurns.get(job.agent) === job.id) { + activeTurns.delete(job.agent); + } + }; + const timer = setTimeout(() => { + timedOut = true; + job.timed_out = true; + killProcessGroup(child, 'SIGTERM'); + const killTimer = setTimeout(() => { + killProcessGroup(child, 'SIGKILL'); + finish(exitCode); + }, 500); + killTimer.unref(); + }, timeoutMs); + timer.unref(); + child.once('error', (err) => { + spawnError = err; + }); + onStart(child); + child.once('close', (code) => { + clearTimeout(timer); + exitCode = code; + if (!timedOut) finish(code); }); } -let lastGoodHealthAt = 0; -let healthCheckInFlight = null; - -function healthz(res) { - const respond = (ready) => { - if (res.destroyed) return; - if (ready) { - // The shim is responsive, Herdr reports a running server, and the shell pane exists. - // Never return status output or pane contents. - res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); - } else { - res.writeHead(503, { 'content-type': 'text/plain' }).end('not ready'); +function parseTurn(job) { + const file = turnEventsPath(job); + const content = readBounded(file, MAX_JOB_OUTPUT_BYTES).text; + const lines = content.split('\n'); + const parsed = []; + let nativeSessionId = job.native_session_id || null; + let finalMessage = null; + for (const line of lines) { + if (!line.trim()) continue; + let event; + try { + event = JSON.parse(line); + } catch { + continue; } + if (!event || typeof event !== 'object' || Array.isArray(event)) continue; + if (event.session_id && job.agent === 'claude') nativeSessionId = event.session_id; + if (event.thread_id && job.agent === 'codex') nativeSessionId = event.thread_id; + if (event.type === 'thread.started' && event.thread_id) nativeSessionId = event.thread_id; + if (event.type === 'result' && typeof event.result === 'string') finalMessage = event.result; + const item = event.item || (event.params && event.params.item); + if ( + job.agent === 'codex' && + item && + (item.type === 'agent_message' || item.type === 'agentMessage') && + typeof item.text === 'string' + ) + finalMessage = item.text; + parsed.push(event); + } + return { + native_session_id: nativeSessionId, + final_message: finalMessage, + events: parsed.slice(-MAX_RETURN_EVENTS) + }; +} + +function turnResponse(job) { + const parsed = parseTurn(job); + if (parsed.native_session_id !== job.native_session_id) { + job.native_session_id = parsed.native_session_id; + saveJob(job); + } + const stderr = readBounded(turnStderrPath(job)); + return { + id: job.id, + agent: job.agent, + status: job.status, + exit_code: job.exit_code, + native_session_id: parsed.native_session_id, + final_message: parsed.final_message, + events: parsed.events, + stderr: stderr.text, + truncated: job.truncated || stderr.truncated }; +} + +function workspaceReady() { + try { + const stat = fs.statSync(WORKSPACE_PATH); + if (!stat.isDirectory()) return false; + fs.accessSync(WORKSPACE_PATH, fs.constants.R_OK | fs.constants.W_OK); + return true; + } catch { + return false; + } +} - if (Date.now() - lastGoodHealthAt < HEALTH_CACHE_MS) { - respond(true); +function validateTimeout(value, fallback) { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) return null; + return value; +} + +function handleBody(req, res, callback, maxBytes = MAX_REQUEST_BODY_BYTES) { + requestBody( + req, + res, + (body) => { + let document; + try { + document = JSON.parse(body); + } catch { + res.writeHead(400).end('invalid json'); + return; + } + if (!document || typeof document !== 'object' || Array.isArray(document)) { + res.writeHead(400).end('invalid request'); + return; + } + callback(document); + }, + maxBytes + ); +} + +function startRun(document, res) { + const command = document.command; + const timeoutMs = validateTimeout(document.timeout_ms, DEFAULT_RUN_TIMEOUT_MS); + if (typeof command !== 'string' || command.length === 0) { + res.writeHead(400).end('missing command'); return; } - if (!healthCheckInFlight) { - healthCheckInFlight = runHealthCheck() - .then(() => { - lastGoodHealthAt = Date.now(); - }) - .finally(() => { - healthCheckInFlight = null; - }); + if (timeoutMs === null) { + res.writeHead(400).end('invalid timeout_ms'); + return; + } + if (!workspaceReady()) { + res.writeHead(503).end('workspace is not ready'); + return; + } + const job = createJob('run', { blocking: false }); + try { + const child = spawn('/bin/sh', ['-lc', command], { + cwd: WORKSPACE_PATH, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }); + runChild(job, child, timeoutMs, (processChild) => { + processChild.stdout.on('data', (chunk) => appendBounded(job, runOutputPath(job), chunk)); + processChild.stderr.on('data', (chunk) => appendBounded(job, runOutputPath(job), chunk)); + }); + json(res, 202, { id: job.id, status: job.status }); + } catch { + job.status = 'failed'; + job.error = 'process could not start'; + saveJob(job); + json(res, 500, { id: job.id, status: job.status }); + } +} + +function startTurn(document, res) { + const agent = document.agent; + const prompt = document.prompt; + const sessionId = document.session_id; + const timeoutMs = validateTimeout(document.timeout_ms, DEFAULT_TURN_TIMEOUT_MS); + if (agent !== 'claude' && agent !== 'codex') { + res.writeHead(400).end('unsupported agent'); + return; + } + if (typeof prompt !== 'string' || prompt.length === 0) { + res.writeHead(400).end('missing prompt'); + return; + } + if ( + sessionId !== undefined && + (typeof sessionId !== 'string' || !/^[A-Za-z0-9._:-]{1,256}$/.test(sessionId)) + ) { + res.writeHead(400).end('invalid session_id'); + return; + } + if (timeoutMs === null) { + res.writeHead(400).end('invalid timeout_ms'); + return; + } + if (!workspaceReady()) { + res.writeHead(503).end('workspace is not ready'); + return; + } + if (activeTurns.has(agent)) { + res.writeHead(409).end('turn already in flight for agent'); + return; + } + + const job = createJob('turn', { + agent, + native_session_id: sessionId || null, + blocking: true + }); + activeTurns.set(agent, job.id); + try { + const child = spawn(LAUNCHER, [agent, ...(sessionId ? [sessionId] : [])], { + cwd: WORKSPACE_PATH, + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: true + }); + runChild(job, child, timeoutMs, (processChild) => { + processChild.stdout.on('data', (chunk) => appendBounded(job, turnEventsPath(job), chunk)); + processChild.stderr.on('data', (chunk) => appendBounded(job, turnStderrPath(job), chunk)); + processChild.stdin.on('error', () => {}); + processChild.stdin.end(prompt, 'utf8'); + }); + json(res, 202, { id: job.id, status: job.status }); + } catch { + activeTurns.delete(agent); + job.status = 'failed'; + job.blocking = false; + job.error = 'process could not start'; + saveJob(job); + json(res, 500, { id: job.id, status: job.status }); } - healthCheckInFlight.then(() => respond(true), () => respond(false)); +} + +function getJob(kind, id, res) { + if (!/^[0-9a-f-]{36}$/i.test(id)) { + res.writeHead(404).end('not found'); + return; + } + const job = jobs.get(id); + if (!job || job.kind !== kind) { + res.writeHead(404).end('not found'); + return; + } + if (kind === 'turn') { + json(res, 200, turnResponse(job)); + return; + } + const output = readBounded(runOutputPath(job)); + json(res, 200, { + id: job.id, + status: job.status, + exit_code: job.exit_code, + output: output.text, + truncated: job.truncated || output.truncated + }); } const server = http.createServer((req, res) => { - if (req.method === 'GET' && req.url === '/healthz') { - healthz(res); + if (req.method === 'GET' && (req.url === '/healthz' || req.url === '/readyz')) { + if (!workspaceReady()) { + res.writeHead(503, { 'content-type': 'text/plain' }).end('workspace not ready'); + return; + } + res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); return; } if (req.method === 'POST' && req.url === '/token') { @@ -214,16 +561,9 @@ const server = http.createServer((req, res) => { res.writeHead(409).end('token already set'); return; } - requestBody(req, res, (body) => { - let token; + handleBody(req, res, (document) => { try { - token = JSON.parse(body).token; - } catch { - res.writeHead(400).end('invalid json'); - return; - } - try { - const installed = installToken(token); + const installed = installToken(document.token); if (installed === null) { res.writeHead(400).end('invalid token'); return; @@ -239,68 +579,59 @@ const server = http.createServer((req, res) => { }); return; } - if (req.method === 'GET' && req.url === '/read') { + if (req.method === 'POST' && req.url === '/credential') { if (!authorized(req)) return unauthorized(res); - herdr(['pane', 'read', PANE_ID], res); + handleBody( + req, + res, + (document) => { + try { + const installed = installCredential(document.name, document.contents); + if (installed === null) { + res.writeHead(400).end('invalid credential'); + return; + } + if (!installed) { + res.writeHead(403).end('credential name is not allowlisted'); + return; + } + if (installed === 'exists') { + res.writeHead(409).end('credential file already exists'); + return; + } + res.writeHead(201).end('credential stored'); + } catch { + res.writeHead(500).end('credential could not be stored'); + } + }, + MAX_CREDENTIAL_REQUEST_BODY_BYTES + ); return; } - if (req.method === 'POST' && req.url === '/credential') { - if (bearerToken === null || !authorized(req)) return unauthorized(res); - requestBody(req, res, (body) => { - let document; - try { - document = JSON.parse(body); - } catch { - res.writeHead(400).end('invalid json'); - return; - } - if (!document || typeof document !== 'object' || Array.isArray(document)) { - res.writeHead(400).end('invalid credential'); - return; - } - try { - const installed = installCredential(document.name, document.contents); - if (installed === null) { - res.writeHead(400).end('invalid credential'); - return; - } - if (!installed) { - res.writeHead(403).end('credential name is not allowlisted'); - return; - } - if (installed === 'exists') { - res.writeHead(409).end('credential file already exists'); - return; - } - res.writeHead(201).end('credential stored'); - } catch { - res.writeHead(500).end('credential could not be stored'); - } - }, MAX_CREDENTIAL_REQUEST_BODY_BYTES); + if (req.method === 'POST' && req.url === '/run') { + if (!authorized(req)) return unauthorized(res); + handleBody(req, res, (document) => startRun(document, res)); return; } - if (req.method !== 'POST' || req.url !== '/run') { - res.writeHead(404).end(); + if (req.method === 'POST' && req.url === '/turn') { + if (!authorized(req)) return unauthorized(res); + handleBody(req, res, (document) => startTurn(document, res)); return; } - if (!authorized(req)) return unauthorized(res); - requestBody(req, res, (body) => { - let command; - try { - command = JSON.parse(body).command; - } catch { - res.writeHead(400).end('invalid json'); - return; - } - if (typeof command !== 'string' || !command) { - res.writeHead(400).end('missing command'); - return; - } - herdr(['pane', 'run', PANE_ID, command], res); - }); + const match = req.method === 'GET' && req.url.match(/^\/(run|turn)\/([^/?]+)$/); + if (match) { + if (!authorized(req)) return unauthorized(res); + getJob(match[1] === 'run' ? 'run' : 'turn', match[2], res); + return; + } + res.writeHead(404).end(); }); -server.listen(Number(process.env.EXEC_SHIM_PORT || 8090), '0.0.0.0', () => { - const address = server.address(); - console.log(`exec-shim listening on :${address.port}`); -}); +server.listen( + Number(process.env.EXEC_SHIM_PORT || 8090), + process.env.EXEC_SHIM_HOST || '0.0.0.0', + () => { + const address = server.address(); + console.log('exec-shim listening on :' + address.port); + } +); diff --git a/spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js b/spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js index 47c2a01..0d7c7c7 100644 --- a/spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js +++ b/spikes/substrate-workspace-adapter/tests/deliver-credentials.test.js @@ -14,18 +14,18 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const { buildCredentialPayload, deliverFromMountedFiles, postViaRouter } = require( - path.resolve(__dirname, '../tools/phase4/deliver-credentials.cjs'), + path.resolve(__dirname, '../tools/phase4/deliver-credentials.cjs') ); const liveAgentImage = path.resolve(__dirname, '../live-agent-image'); test('credential payload uses the fixed shim allowlist and validates Codex auth JSON', () => { assert.deepEqual(buildCredentialPayload('claude', 'fixture token\r\n'), { name: 'claude-token', - contents: 'fixturetoken', + contents: 'fixturetoken' }); assert.deepEqual(buildCredentialPayload('codex', '{"access_token":"fixture"}'), { name: 'codex-auth', - contents: '{"access_token":"fixture"}', + contents: '{"access_token":"fixture"}' }); assert.throws(() => buildCredentialPayload('../../etc/passwd', 'fixture'), /unsupported/); assert.throws(() => buildCredentialPayload('codex', 'not-json'), SyntaxError); @@ -36,7 +36,9 @@ test('control delivery reads mounted paths and sends credential contents only in const credentialFile = path.join(root, 'credential'); const shimTokenFile = path.join(root, 'shim-token'); fs.writeFileSync(credentialFile, 'fixture-claude-token\n', { mode: 0o600 }); - fs.writeFileSync(shimTokenFile, 'fixture-shim-token-with-at-least-32-characters', { mode: 0o600 }); + fs.writeFileSync(shimTokenFile, 'fixture-shim-token-with-at-least-32-characters', { + mode: 0o600 + }); const calls = []; try { const result = await deliverFromMountedFiles({ @@ -48,12 +50,12 @@ test('control delivery reads mounted paths and sends credential contents only in request: async (request) => { calls.push(request); return 201; - }, + } }); assert.deepEqual(result, { kind: 'claude', namespace: 'native-claude', - actor: 'claude-final', + actor: 'claude-final' }); assert.equal(calls.length, 1); assert.equal(calls[0].payload.name, 'claude-token'); @@ -83,7 +85,9 @@ test('router delivery authenticates the CONNECT target and posts the body withou const length = Number(/^content-length:\s*(\d+)\s*$/im.exec(headers)?.[1]); if (!Number.isFinite(length) || bytes.length < boundary + 4 + length) return; received.requestHeaders = headers; - received.payload = JSON.parse(bytes.subarray(boundary + 4, boundary + 4 + length).toString('utf8')); + received.payload = JSON.parse( + bytes.subarray(boundary + 4, boundary + 4 + length).toString('utf8') + ); socket.end('HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'); }); }); @@ -98,12 +102,15 @@ test('router delivery authenticates the CONNECT target and posts the body withou namespace: 'native-codex', actor: 'codex-final', token: 'fixture-shim-token-with-at-least-32-characters', - payload: { name: 'codex-auth', contents: '{"fixture":"provider"}' }, + payload: { name: 'codex-auth', contents: '{"fixture":"provider"}' } }); assert.equal(status, 201); assert.equal(received.target, 'actor-upstream:8090'); assert.equal(received.actorHeader, 'native-codex/codex-final'); - assert.match(received.requestHeaders, /Authorization: Bearer fixture-shim-token-with-at-least-32-characters/i); + assert.match( + received.requestHeaders, + /Authorization: Bearer fixture-shim-token-with-at-least-32-characters/i + ); assert.deepEqual(received.payload, { name: 'codex-auth', contents: '{"fixture":"provider"}' }); }); @@ -122,29 +129,38 @@ test('native-agent launcher reads Claude auth from its file into the process env fs.writeFileSync( path.join(bin, 'claude'), '#!/bin/sh\nprintf "%s" "$CLAUDE_CODE_OAUTH_TOKEN" >"$TOKEN_CAPTURE"\nprintf "%s\\n" "$DISABLE_TELEMETRY" "$DISABLE_ERROR_REPORTING" "$CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" "$DISABLE_AUTOUPDATER" >"$FLAGS_CAPTURE"\nprintf "%s\\n" "$@" >"$ARGS_CAPTURE"\n', - { mode: 0o700 }, + { mode: 0o700 } ); t.after(() => fs.rmSync(root, { recursive: true, force: true })); - const result = spawnSync('/bin/bash', [path.join(liveAgentImage, 'bin/start-native-agent'), 'claude'], { - encoding: 'utf8', - env: { - ...process.env, - HOME: home, - PATH: `${bin}:${process.env.PATH}`, - WORKSPACE_PATH: workspace, - TOKEN_CAPTURE: tokenCapture, - FLAGS_CAPTURE: path.join(root, 'flags.capture'), - ARGS_CAPTURE: argsCapture, - AGENT_SYSTEM_PROMPT_FILE: path.join(root, 'system-prompt.fixture'), - }, - }); + const result = spawnSync( + '/bin/bash', + [path.join(liveAgentImage, 'bin/start-native-agent'), 'claude'], + { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + WORKSPACE_PATH: workspace, + TOKEN_CAPTURE: tokenCapture, + FLAGS_CAPTURE: path.join(root, 'flags.capture'), + ARGS_CAPTURE: argsCapture, + AGENT_SYSTEM_PROMPT_FILE: path.join(root, 'system-prompt.fixture') + } + } + ); assert.equal(result.status, 0, result.stderr); assert.equal(fs.readFileSync(tokenCapture, 'utf8'), 'fixture-claude-oauth-value'); assert.deepEqual(fs.readFileSync(path.join(root, 'flags.capture'), 'utf8').trim().split('\n'), [ - '1', '1', '1', '1', + '1', + '1', + '1', + '1' ]); const args = fs.readFileSync(argsCapture, 'utf8'); + assert.match(args, /-p/); + assert.match(args, /--output-format/); assert.match(args, /--dangerously-skip-permissions/); assert.match(args, /--append-system-prompt-file/); assert.equal(args.includes('fixture-claude-oauth-value'), false); @@ -162,7 +178,9 @@ test('native-agent launcher starts Codex only when its installed auth file exist fs.mkdirSync(bin); fs.mkdirSync(workspace); fs.writeFileSync(path.join(codexHome, 'auth.json'), '{"fixture":"codex-auth"}', { mode: 0o600 }); - fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\nprintf "%s\\n" "$@" >"$ARGS_CAPTURE"\n', { mode: 0o700 }); + fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\nprintf "%s\\n" "$@" >"$ARGS_CAPTURE"\n', { + mode: 0o700 + }); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const env = { @@ -171,21 +189,32 @@ test('native-agent launcher starts Codex only when its installed auth file exist CODEX_HOME: codexHome, PATH: `${bin}:${process.env.PATH}`, WORKSPACE_PATH: workspace, - ARGS_CAPTURE: argsCapture, + ARGS_CAPTURE: argsCapture }; - const result = spawnSync('/bin/bash', [path.join(liveAgentImage, 'bin/start-native-agent'), 'codex'], { - encoding: 'utf8', - env, - }); + const result = spawnSync( + '/bin/bash', + [path.join(liveAgentImage, 'bin/start-native-agent'), 'codex'], + { + encoding: 'utf8', + env + } + ); assert.equal(result.status, 0, result.stderr); - assert.equal(fs.readFileSync(argsCapture, 'utf8').trim(), '--dangerously-bypass-approvals-and-sandbox'); + assert.equal( + fs.readFileSync(argsCapture, 'utf8').trim(), + 'exec\n--json\n--dangerously-bypass-approvals-and-sandbox' + ); assert.equal(result.stdout.includes('fixture'), false); fs.rmSync(path.join(codexHome, 'auth.json')); - const missing = spawnSync('/bin/bash', [path.join(liveAgentImage, 'bin/start-native-agent'), 'codex'], { - encoding: 'utf8', - env, - }); + const missing = spawnSync( + '/bin/bash', + [path.join(liveAgentImage, 'bin/start-native-agent'), 'codex'], + { + encoding: 'utf8', + env + } + ); assert.equal(missing.status, 1); assert.equal(missing.stderr.includes('auth.json'), false); }); @@ -195,10 +224,14 @@ test('golden boot seeds trusted workspaces, themes, and disabled update checks w const home = path.join(root, 'home'); const codexHome = path.join(home, '.codex'); const workspace = path.join(root, 'repo'); - const result = spawnSync(process.execPath, [path.join(liveAgentImage, 'bin/prepare-native-agent-config.cjs')], { - encoding: 'utf8', - env: { ...process.env, HOME: home, CODEX_HOME: codexHome, WORKSPACE_PATH: workspace }, - }); + const result = spawnSync( + process.execPath, + [path.join(liveAgentImage, 'bin/prepare-native-agent-config.cjs')], + { + encoding: 'utf8', + env: { ...process.env, HOME: home, CODEX_HOME: codexHome, WORKSPACE_PATH: workspace } + } + ); t.after(() => fs.rmSync(root, { recursive: true, force: true })); assert.equal(result.status, 0, result.stderr); diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js index 5006a60..63c0f46 100644 --- a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -11,20 +11,36 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const shim = path.resolve(__dirname, '../live-agent-image/exec-shim.js'); +const image = path.resolve(__dirname, '../live-agent-image'); +const shim = path.join(image, 'exec-shim.js'); +const launcher = path.join(image, 'bin/start-native-agent'); +const fixtures = path.join(__dirname, 'fixtures/native'); +const token = 'fixture-only-shim-token-long-enough-for-testing'; -async function startShim(home, fakeBin, shimPath, extraEnv = {}) { - const child = spawn(process.execPath, [shimPath], { +async function startShim(root, extraEnv = {}) { + const home = path.join(root, 'home'); + const workspace = path.join(root, 'repo'); + const state = path.join(root, 'state'); + const fakeBin = path.join(root, 'bin'); + const shimCopy = path.join(root, 'exec-shim.js'); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(workspace, { recursive: true }); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.copyFileSync(shim, shimCopy); + const child = spawn(process.execPath, [shimCopy], { env: { ...process.env, ...extraEnv, HOME: home, - PATH: `${fakeBin}:${process.env.PATH}`, - HERDR_SESSION: 'shim-test', - EXEC_SHIM_PANE_ID: 'pane-test', + CODEX_HOME: path.join(home, '.codex'), + WORKSPACE_PATH: workspace, + EXEC_SHIM_STATE_DIR: state, + EXEC_SHIM_HOST: '127.0.0.1', EXEC_SHIM_PORT: '0', + NATIVE_AGENT_LAUNCHER: launcher, + PATH: `${fakeBin}:${process.env.PATH}` }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; const port = await new Promise((resolve, reject) => { @@ -43,110 +59,132 @@ async function startShim(home, fakeBin, shimPath, extraEnv = {}) { output += chunk; }); }); - return { child, port, output: () => output }; + return { child, port, output: () => output, home, workspace, fakeBin, state }; } -function request(port, method, route, { body, token } = {}) { +function request(port, method, route, { body, bearer } = {}) { return new Promise((resolve, reject) => { const headers = {}; if (body !== undefined) headers['content-type'] = 'application/json'; - if (token !== undefined) headers.authorization = `Bearer ${token}`; - const req = http.request( - { host: '127.0.0.1', port, method, path: route, headers }, - (res) => { - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => - resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }), - ); - }, - ); + if (bearer !== undefined) headers.authorization = `Bearer ${bearer}`; + const req = http.request({ host: '127.0.0.1', port, method, path: route, headers }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode, + body: Buffer.concat(chunks).toString('utf8') + }) + ); + }); req.once('error', reject); if (body !== undefined) req.end(JSON.stringify(body)); else req.end(); }); } +async function installToken(running) { + const result = await request(running.port, 'POST', '/token', { body: { token } }); + assert.equal(result.status, 201, result.body); +} + +async function installCredential(running, name, contents) { + const result = await request(running.port, 'POST', '/credential', { + bearer: token, + body: { name, contents } + }); + assert.equal(result.status, 201, result.body); +} + +async function waitForJob(running, kind, id, bearer = token) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const result = await request(running.port, 'GET', `/${kind}/${id}`, { bearer }); + assert.equal(result.status, 200, result.body); + const job = JSON.parse(result.body); + if (job.status !== 'running') return job; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`${kind} job ${id} did not finish`); +} + async function stop(child) { if (child.exitCode !== null || child.signalCode !== null) return; child.kill('SIGTERM'); await once(child, 'exit'); } -test('shim token gates run/read, is one-time, private, and survives process restart', async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-test-')); - const home = path.join(root, 'home'); - const fakeBin = path.join(root, 'bin'); - const shimPath = path.join(root, 'exec-shim.js'); - fs.mkdirSync(home); - fs.mkdirSync(fakeBin); - fs.copyFileSync(shim, shimPath); - const herdr = path.join(fakeBin, 'herdr'); - fs.writeFileSync( - herdr, - '#!/bin/sh\nif [ "$3" = "status" ] && [ "$4" = "server" ]; then echo "status: running"; exit 0; fi\nif [ "$3" = "pane" ] && [ "$4" = "read" ]; then echo "fixture pane"; exit 0; fi\nexit 0\n', - { mode: 0o700 }, - ); - - let running = await startShim(home, fakeBin, shimPath); +function fakeCli(file, script) { + fs.writeFileSync(file, script, { mode: 0o700 }); +} + +test('token is private and one-time; readiness is workspace-only and work routes require auth', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-token-')); + let running = await startShim(root); t.after(async () => { await stop(running.child); fs.rmSync(root, { recursive: true, force: true }); }); assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); - assert.equal((await request(running.port, 'GET', '/read')).status, 200); + assert.equal((await request(running.port, 'GET', '/readyz')).status, 200); assert.equal( - (await request(running.port, 'POST', '/run', { body: { command: 'echo fixture' } })).status, - 200, + (await request(running.port, 'POST', '/run', { body: { command: 'true' } })).status, + 401 ); - - const token = 'fixture-only-token-with-at-least-thirty-two-characters'; assert.equal( - (await request(running.port, 'POST', '/token', { body: { token } })).status, - 201, + (await request(running.port, 'POST', '/turn', { body: { agent: 'claude', prompt: 'fixture' } })) + .status, + 401 ); - const tokenPath = path.join(home, '.mainloop', 'exec-shim-token'); + await installToken(running); + + const tokenPath = path.join(running.home, '.mainloop', 'exec-shim-token'); assert.equal(fs.statSync(tokenPath).mode & 0o777, 0o600); assert.equal(fs.statSync(path.dirname(tokenPath)).mode & 0o777, 0o700); assert.equal(fs.readFileSync(tokenPath, 'utf8'), token); - assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); - assert.equal((await request(running.port, 'GET', '/read')).status, 401); - assert.equal((await request(running.port, 'GET', '/read', { token: `${token}-wrong` })).status, 401); - assert.equal((await request(running.port, 'GET', '/read', { token })).status, 200); - assert.equal((await request(running.port, 'POST', '/run', { body: { command: 'echo fixture' } })).status, 401); assert.equal( - (await request(running.port, 'POST', '/run', { body: { command: 'echo fixture' }, token })).status, - 200, + (await request(running.port, 'GET', '/turn/00000000-0000-4000-8000-000000000000')).status, + 401 + ); + assert.equal( + ( + await request(running.port, 'GET', '/turn/00000000-0000-4000-8000-000000000000', { + bearer: `${token}-wrong` + }) + ).status, + 401 + ); + assert.equal( + ( + await request(running.port, 'GET', '/turn/00000000-0000-4000-8000-000000000000', { + bearer: token + }) + ).status, + 404 ); assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 409); assert.equal(running.output().includes(token), false); await stop(running.child); - running = await startShim(home, fakeBin, shimPath); + running = await startShim(root); assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); - assert.equal((await request(running.port, 'GET', '/read')).status, 401); - assert.equal((await request(running.port, 'GET', '/read', { token })).status, 200); - assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 409); + assert.equal( + (await request(running.port, 'GET', '/turn/00000000-0000-4000-8000-000000000000')).status, + 401 + ); + assert.equal( + ( + await request(running.port, 'GET', '/turn/00000000-0000-4000-8000-000000000000', { + bearer: token + }) + ).status, + 404 + ); }); -test('credential delivery requires a shim token and writes only allowlisted private files once', async (t) => { +test('credential delivery remains token-gated, allowlisted, private, and one-time', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-credentials-')); - const home = path.join(root, 'home'); - const fakeBin = path.join(root, 'bin'); - const shimPath = path.join(root, 'exec-shim.js'); - const codexHome = path.join(home, '.codex'); - fs.mkdirSync(home); - fs.mkdirSync(fakeBin); - fs.copyFileSync(shim, shimPath); - const herdr = path.join(fakeBin, 'herdr'); - fs.writeFileSync( - herdr, - '#!/bin/sh\nif [ "$3" = "status" ] && [ "$4" = "server" ]; then echo "status: running"; exit 0; fi\nif [ "$3" = "pane" ] && [ "$4" = "read" ]; then echo "fixture pane"; exit 0; fi\nexit 0\n', - { mode: 0o700 }, - ); - - const running = await startShim(home, fakeBin, shimPath, { CODEX_HOME: codexHome }); + const running = await startShim(root); t.after(async () => { await stop(running.child); fs.rmSync(root, { recursive: true, force: true }); @@ -154,79 +192,219 @@ test('credential delivery requires a shim token and writes only allowlisted priv const claudeContents = 'fixture-claude-token-never-logged'; const authContents = JSON.stringify({ fixture: 'codex-auth-never-logged' }); - const write = (name, contents, token) => - request(running.port, 'POST', '/credential', { - body: { name, contents }, - ...(token === undefined ? {} : { token }), - }); - - assert.equal((await write('codex-auth', authContents)).status, 401, 'golden actor must reject writes before token installation'); - assert.equal(fs.existsSync(codexHome), false); - - const token = 'fixture-only-codex-shim-token-at-least-thirty-two-chars'; - assert.equal((await request(running.port, 'POST', '/token', { body: { token } })).status, 201); - assert.equal((await write('claude-token', claudeContents)).status, 401); - assert.equal((await write('claude-token', claudeContents, `${token}-wrong`)).status, 401); - assert.equal((await write('../outside', claudeContents, token)).status, 403); - assert.equal(fs.existsSync(path.join(root, 'outside')), false); - assert.equal((await write('codex-auth', '[]', token)).status, 400); - assert.equal((await write('codex-auth', 'not-json', token)).status, 400); - assert.equal((await request(running.port, 'POST', '/write-codex-auth', { token, body: {} })).status, 404); - - assert.equal((await write('claude-token', claudeContents, token)).status, 201); - const claudePath = path.join(home, '.mainloop', 'claude-token'); + assert.equal( + ( + await request(running.port, 'POST', '/credential', { + body: { name: 'codex-auth', contents: authContents } + }) + ).status, + 401 + ); + await installToken(running); + assert.equal( + ( + await request(running.port, 'POST', '/credential', { + bearer: `${token}-wrong`, + body: { name: 'claude-token', contents: claudeContents } + }) + ).status, + 401 + ); + assert.equal( + ( + await request(running.port, 'POST', '/credential', { + bearer: token, + body: { name: '../outside', contents: claudeContents } + }) + ).status, + 403 + ); + assert.equal( + ( + await request(running.port, 'POST', '/credential', { + bearer: token, + body: { name: 'codex-auth', contents: '[]' } + }) + ).status, + 400 + ); + assert.equal( + ( + await request(running.port, 'POST', '/credential', { + bearer: token, + body: { name: 'claude-token', contents: claudeContents } + }) + ).status, + 201 + ); + const claudePath = path.join(running.home, '.mainloop', 'claude-token'); assert.equal(fs.readFileSync(claudePath, 'utf8'), claudeContents); assert.equal(fs.statSync(claudePath).mode & 0o777, 0o600); - assert.equal(fs.statSync(path.dirname(claudePath)).mode & 0o777, 0o700); - assert.equal((await write('claude-token', 'replacement', token)).status, 409); - - assert.equal((await write('codex-auth', authContents, token)).status, 201); - const authPath = path.join(codexHome, 'auth.json'); + assert.equal( + ( + await request(running.port, 'POST', '/credential', { + bearer: token, + body: { name: 'claude-token', contents: 'replacement' } + }) + ).status, + 409 + ); + assert.equal( + ( + await request(running.port, 'POST', '/credential', { + bearer: token, + body: { name: 'codex-auth', contents: authContents } + }) + ).status, + 201 + ); + const authPath = path.join(running.home, '.codex', 'auth.json'); assert.equal(fs.readFileSync(authPath, 'utf8'), authContents); - assert.equal(fs.statSync(codexHome).mode & 0o777, 0o700); assert.equal(fs.statSync(authPath).mode & 0o777, 0o600); - assert.equal((await write('codex-auth', authContents, token)).status, 409, 'auth file must not be overwritten'); assert.equal(running.output().includes(claudeContents), false); assert.equal(running.output().includes(authContents), false); }); -test('healthz bounds Herdr calls and reuses a recent successful check', async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-health-')); - const home = path.join(root, 'home'); - const fakeBin = path.join(root, 'bin'); - const shimPath = path.join(root, 'exec-shim.js'); - const logPath = path.join(root, 'herdr-calls.log'); - fs.mkdirSync(home); - fs.mkdirSync(fakeBin); - fs.copyFileSync(shim, shimPath); - const herdr = path.join(fakeBin, 'herdr'); - fs.writeFileSync( - herdr, - '#!/bin/sh\nif [ -n "${EXEC_SHIM_TEST_LOG:-}" ]; then printf "%s %s\\n" "$3" "$4" >> "$EXEC_SHIM_TEST_LOG"; fi\nif [ "$3" = "status" ] && [ "$4" = "server" ]; then if [ "${HERDR_TEST_SLOW_STATUS:-}" = "1" ]; then exec sleep 5; fi; echo "status: running"; exit 0; fi\nif [ "$3" = "pane" ] && [ "$4" = "read" ]; then echo "fixture pane"; exit 0; fi\nexit 0\n', - { mode: 0o700 }, +test('turn prompt is piped on stdin and never appears in argv or shim logs', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-stdin-')); + const running = await startShim(root, { + ARGS_CAPTURE: path.join(root, 'claude-args'), + PROMPT_CAPTURE: path.join(root, 'claude-prompt') + }); + fakeCli( + path.join(running.fakeBin, 'claude'), + '#!/bin/sh\nprintf "%s\\n" "$@" >"$ARGS_CAPTURE"\ncat >"$PROMPT_CAPTURE"\ncat <<\'EVENTS\'\n{"type":"system","subtype":"init","session_id":"fixture-session"}\n{"type":"result","result":"fixture answer","session_id":"fixture-session"}\nEVENTS\n' + ); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + await installCredential(running, 'claude-token', 'fixture-claude-token'); + const prompt = 'private fixture prompt must travel only on stdin'; + const started = await request(running.port, 'POST', '/turn', { + bearer: token, + body: { agent: 'claude', prompt } + }); + assert.equal(started.status, 202, started.body); + const { id } = JSON.parse(started.body); + const result = await waitForJob(running, 'turn', id); + assert.equal(result.status, 'completed'); + assert.equal(result.final_message, 'fixture answer'); + assert.equal(result.native_session_id, 'fixture-session'); + assert.equal(fs.readFileSync(path.join(root, 'claude-prompt'), 'utf8'), prompt); + const argv = fs.readFileSync(path.join(root, 'claude-args'), 'utf8'); + assert.match(argv, /-p/); + assert.match(argv, /--output-format/); + assert.equal(argv.includes(prompt), false); + assert.equal(running.output().includes(prompt), false); + assert.equal( + fs + .readFileSync(path.join(running.state, 'turns', id + '.events.jsonl'), 'utf8') + .includes(prompt), + false + ); +}); + +test('a second concurrent turn for the same agent receives 409 and is not queued', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-concurrency-')); + const running = await startShim(root, { TURN_DELAY: '0.4' }); + fakeCli( + path.join(running.fakeBin, 'claude'), + '#!/bin/sh\ncat >/dev/null\nsleep "$TURN_DELAY"\nprintf \'%s\\n\' \'{"type":"result","session_id":"fixture-session","result":"done"}\'\n' ); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + await installCredential(running, 'claude-token', 'fixture-claude-token'); + const body = { agent: 'claude', prompt: 'turn prompt' }; + const first = await request(running.port, 'POST', '/turn', { bearer: token, body }); + assert.equal(first.status, 202, first.body); + const second = await request(running.port, 'POST', '/turn', { bearer: token, body }); + assert.equal(second.status, 409); + const result = await waitForJob(running, 'turn', JSON.parse(first.body).id); + assert.equal(result.status, 'completed'); +}); + +test('/run executes a command, stores bounded output, and reports timeout', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-run-')); + const running = await startShim(root); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + const quick = await request(running.port, 'POST', '/run', { + bearer: token, + body: { command: 'printf fixture-output', timeout_ms: 1000 } + }); + assert.equal(quick.status, 202, quick.body); + const completed = await waitForJob(running, 'run', JSON.parse(quick.body).id); + assert.equal(completed.status, 'completed'); + assert.equal(completed.exit_code, 0); + assert.equal(completed.output, 'fixture-output'); + + const slow = await request(running.port, 'POST', '/run', { + bearer: token, + body: { command: 'sleep 5; printf should-not-finish', timeout_ms: 50 } + }); + assert.equal(slow.status, 202, slow.body); + const timeout = await waitForJob(running, 'run', JSON.parse(slow.body).id); + assert.equal(timeout.status, 'timed_out'); + assert.equal(timeout.output.includes('should-not-finish'), false); +}); - const running = await startShim(home, fakeBin, shimPath, { - EXEC_SHIM_TEST_LOG: logPath, +test('Claude stream-json and Codex JSONL produce native ids and final messages', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-events-')); + const running = await startShim(root, { + CLAUDE_EVENTS: path.join(fixtures, 'claude-stream-json.jsonl'), + CODEX_EVENTS: path.join(fixtures, 'codex-jsonl.jsonl'), + CLAUDE_ARGS_CAPTURE: path.join(root, 'claude-args'), + CODEX_ARGS_CAPTURE: path.join(root, 'codex-args') }); + fakeCli( + path.join(running.fakeBin, 'claude'), + '#!/bin/sh\nprintf "%s\\n" "$@" >"$CLAUDE_ARGS_CAPTURE"\ncat >/dev/null\ncat "$CLAUDE_EVENTS"\n' + ); + fakeCli( + path.join(running.fakeBin, 'codex'), + '#!/bin/sh\nprintf "%s\\n" "$@" >"$CODEX_ARGS_CAPTURE"\ncat >/dev/null\ncat "$CODEX_EVENTS"\n' + ); t.after(async () => { await stop(running.child); fs.rmSync(root, { recursive: true, force: true }); }); + await installToken(running); + await installCredential(running, 'claude-token', 'fixture-claude-token'); + await installCredential(running, 'codex-auth', '{"fixture":"auth"}'); - assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); - assert.equal((await request(running.port, 'GET', '/healthz')).status, 200); - assert.deepEqual(fs.readFileSync(logPath, 'utf8').trim().split('\n'), [ - 'status server', - 'pane read', - ]); + const claudeStart = await request(running.port, 'POST', '/turn', { + bearer: token, + body: { agent: 'claude', session_id: 'resume-session-001', prompt: 'claude fixture prompt' } + }); + assert.equal(claudeStart.status, 202, claudeStart.body); + const claude = await waitForJob(running, 'turn', JSON.parse(claudeStart.body).id); + assert.equal(claude.native_session_id, 'fixture-claude-session'); + assert.equal(claude.final_message, 'fixture Claude final'); + assert.equal(claude.events.length, 3); + assert.match( + fs.readFileSync(path.join(root, 'claude-args'), 'utf8'), + /--resume\s+resume-session-001/ + ); - await stop(running.child); - const slow = await startShim(home, fakeBin, shimPath, { - HERDR_TEST_SLOW_STATUS: '1', + const codexStart = await request(running.port, 'POST', '/turn', { + bearer: token, + body: { agent: 'codex', session_id: 'resume-thread-001', prompt: 'codex fixture prompt' } }); - t.after(async () => stop(slow.child)); - const startedAt = Date.now(); - assert.equal((await request(slow.port, 'GET', '/healthz')).status, 503); - assert.ok(Date.now() - startedAt < 4000, 'hung Herdr status must be bounded by execFile timeout'); + assert.equal(codexStart.status, 202, codexStart.body); + const codex = await waitForJob(running, 'turn', JSON.parse(codexStart.body).id); + assert.equal(codex.native_session_id, 'fixture-codex-thread'); + assert.equal(codex.final_message, 'fixture Codex final'); + assert.equal(codex.events.length, 4); + assert.equal( + fs.readFileSync(path.join(root, 'codex-args'), 'utf8').trim(), + 'exec\nresume\n--json\nresume-thread-001\n--dangerously-bypass-approvals-and-sandbox' + ); }); diff --git a/spikes/substrate-workspace-adapter/tests/fixtures/native/claude-stream-json.jsonl b/spikes/substrate-workspace-adapter/tests/fixtures/native/claude-stream-json.jsonl new file mode 100644 index 0000000..5d05fa4 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tests/fixtures/native/claude-stream-json.jsonl @@ -0,0 +1,3 @@ +{"type":"system","subtype":"init","session_id":"fixture-claude-session","model":"claude-fixture"} +{"type":"assistant","message":{"content":[{"type":"text","text":"fixture Claude response"}]}} +{"type":"result","subtype":"success","session_id":"fixture-claude-session","result":"fixture Claude final"} diff --git a/spikes/substrate-workspace-adapter/tests/fixtures/native/codex-jsonl.jsonl b/spikes/substrate-workspace-adapter/tests/fixtures/native/codex-jsonl.jsonl new file mode 100644 index 0000000..c2da5e8 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tests/fixtures/native/codex-jsonl.jsonl @@ -0,0 +1,4 @@ +{"type":"thread.started","thread_id":"fixture-codex-thread"} +{"type":"turn.started","thread_id":"fixture-codex-thread"} +{"type":"item.completed","item":{"id":"fixture-message-id","type":"agent_message","text":"fixture Codex final"}} +{"type":"turn.completed","thread_id":"fixture-codex-thread"} diff --git a/spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs b/spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs index 5ebb55e..7cf3167 100644 --- a/spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs +++ b/spikes/substrate-workspace-adapter/tools/phase4/deliver-credentials.cjs @@ -5,7 +5,7 @@ const http = require('node:http'); const CREDENTIALS = Object.freeze({ claude: Object.freeze({ name: 'claude-token' }), - codex: Object.freeze({ name: 'codex-auth' }), + codex: Object.freeze({ name: 'codex-auth' }) }); function validateActorIdentity(namespace, actor) { @@ -55,7 +55,7 @@ function postViaRouter({ host, port, namespace, actor, token, payload, timeoutMs port, method: 'CONNECT', path: 'actor-upstream:8090', - headers: { 'ate-target-actor': `${namespace}/${actor}` }, + headers: { 'ate-target-actor': `${namespace}/${actor}` } }); tunnel.setTimeout(timeoutMs, () => tunnel.destroy(new Error('router timed out'))); tunnel.once('error', (error) => finish(error)); @@ -88,16 +88,19 @@ function postViaRouter({ host, port, namespace, actor, token, payload, timeoutMs socket.end(); }); - const headers = Buffer.from([ - 'POST /credential HTTP/1.1', - 'Host: actor-upstream:8090', - `Authorization: Bearer ${token}`, - 'Content-Type: application/json', - `Content-Length: ${body.length}`, - 'Connection: close', - '', - '', - ].join('\r\n'), 'ascii'); + const headers = Buffer.from( + [ + 'POST /credential HTTP/1.1', + 'Host: actor-upstream:8090', + `Authorization: Bearer ${token}`, + 'Content-Type: application/json', + `Content-Length: ${body.length}`, + 'Connection: close', + '', + '' + ].join('\r\n'), + 'ascii' + ); socket.write(Buffer.concat([headers, body])); }); tunnel.end(); @@ -112,7 +115,7 @@ async function deliverFromMountedFiles({ actor, host = 'atenet-router.ate-system.svc.cluster.local', port = 8081, - request = postViaRouter, + request = postViaRouter }) { validateActorIdentity(namespace, actor); const payload = buildCredentialPayload(kind, fs.readFileSync(credentialFile, 'utf8')); @@ -126,7 +129,7 @@ async function deliverFromMountedFiles({ namespace, actor, token, - payload, + payload }); if (status !== 201) throw new Error('shim rejected credential delivery'); return { kind, namespace, actor }; @@ -140,7 +143,7 @@ async function main() { namespace: process.env.ACTOR_NAMESPACE, actor: process.env.ACTOR_NAME, host: process.env.ROUTER_HOST || 'atenet-router.ate-system.svc.cluster.local', - port: Number(process.env.ROUTER_PORT || '8081'), + port: Number(process.env.ROUTER_PORT || '8081') }); process.stdout.write(`credential delivered for ${process.env.CREDENTIAL_KIND}\n`); } diff --git a/spikes/substrate-workspace-adapter/tools/router-client.js b/spikes/substrate-workspace-adapter/tools/router-client.js index 4555f11..bb29c97 100644 --- a/spikes/substrate-workspace-adapter/tools/router-client.js +++ b/spikes/substrate-workspace-adapter/tools/router-client.js @@ -24,9 +24,12 @@ process.stdin.on('end', () => { if ( !/^[a-z0-9-]+$/.test(request.atespace || '') || !/^[a-z0-9-]+$/.test(request.actor || '') || - !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535 || + !Number.isInteger(targetPort) || + targetPort < 1 || + targetPort > 65535 || !['GET', 'POST'].includes(request.method) || - typeof request.path !== 'string' || !request.path.startsWith('/') + typeof request.path !== 'string' || + !request.path.startsWith('/') ) { process.stdout.write('{"error":"invalid request"}\n'); process.exitCode = 2; @@ -47,9 +50,9 @@ process.stdin.on('end', () => { path: `actor-upstream:${targetPort}`, headers: { host: `actor-upstream:${targetPort}`, - 'ate-target-actor': `${request.atespace}/${request.actor}`, + 'ate-target-actor': `${request.atespace}/${request.actor}` }, - timeout: 8000, + timeout: 8000 }); tunnel.on('connect', (response, socket) => { if (response.statusCode !== 200) { @@ -74,7 +77,7 @@ process.stdin.on('end', () => { } const headerLines = Object.entries(headers).map(([name, value]) => `${name}: ${value}`); const requestHeader = Buffer.from( - `${request.method} ${request.path} HTTP/1.1\r\n${headerLines.join('\r\n')}\r\n\r\n`, + `${request.method} ${request.path} HTTP/1.1\r\n${headerLines.join('\r\n')}\r\n\r\n` ); const responseChunks = []; if (response.head && response.head.length) responseChunks.push(response.head); @@ -90,9 +93,7 @@ process.stdin.on('end', () => { const statusLine = responseBytes.subarray(0, headerEnd).toString('latin1').split('\r\n')[0]; const status = Number(statusLine.split(' ')[1]); const responseBody = responseBytes.subarray(headerEnd + 4).toString('utf8'); - finish(request.includeResponseBody - ? { status, body: responseBody } - : { status }); + finish(request.includeResponseBody ? { status, body: responseBody } : { status }); }); socket.on('error', (err) => finish({ transportError: err.code || 'request-failed' })); socket.write(Buffer.concat([requestHeader, body])); From d9fc9e2dc4e635dcab628fe63dbaaa9c45204e4f Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:58:38 +0000 Subject: [PATCH 15/30] feat: add Substrate transport for native sessions Add a config-selected Substrate workspace transport that attaches to pre-created actors through the CONNECT router and reads per-actor shim tokens from Kubernetes Secrets. Keep Herdr as the default runtime. Extend the headless shim with authenticated credential readiness, turn status, bounded native journal pages, and stop handling; cover delivery through native_sessions with fake router and shim tests. Document the runtime configuration and its fixture-backed evidence. Start the actor entrypoint as root only for ownership preparation, then exec it as UID/GID 10001 with initialized groups, an empty capability bounding set, and no-new-privileges. Install setpriv in the image and refuse root in the shim outside its explicit test-only override. Cover the root guard and image privilege-drop wiring with local tests. --- backend/src/mainloop/config.py | 25 +- .../src/mainloop/runtime/native_sessions.py | 53 ++- .../mainloop/runtime/substrate_workspace.py | 418 ++++++++++++++++++ .../tests/runtime/test_substrate_workspace.py | 375 ++++++++++++++++ docs/spikes/substrate-workspace-adapter.md | 17 + .../live-agent-image/Dockerfile | 9 +- .../live-agent-image/entrypoint.sh | 36 ++ .../live-agent-image/exec-shim.js | 229 +++++++++- .../tests/exec-shim.test.js | 184 +++++++- 9 files changed, 1332 insertions(+), 14 deletions(-) create mode 100644 backend/src/mainloop/runtime/substrate_workspace.py create mode 100644 backend/tests/runtime/test_substrate_workspace.py diff --git a/backend/src/mainloop/config.py b/backend/src/mainloop/config.py index 94814a7..efa7244 100644 --- a/backend/src/mainloop/config.py +++ b/backend/src/mainloop/config.py @@ -1,11 +1,22 @@ """Configuration management.""" +from typing import Literal from urllib.parse import quote_plus -from pydantic import computed_field +from pydantic import BaseModel, ConfigDict, Field, computed_field from pydantic_settings import BaseSettings, SettingsConfigDict +class SubstrateActorBinding(BaseModel): + """Deployment-provided route and token Secret for one pre-created actor.""" + + atespace: str + actor: str + shim_token_secret_name: str + + model_config = ConfigDict(extra="forbid", frozen=True) + + class Settings(BaseSettings): """Application settings.""" @@ -37,6 +48,18 @@ def database_url(self) -> str: "main-0" # pod that runs the native main thread (scratch cwd, no repo) ) + # Native-session workspace transport. Herdr remains the default; Substrate attaches to + # pre-created actors through the CONNECT router and never creates or resumes actors itself. + workspace_runtime: Literal["herdr", "substrate"] = "herdr" + substrate_router_address: str = ( + "http://atenet-router.ate-system.svc.cluster.local:8081" + ) + substrate_shim_secret_namespace: str = "mainloop-control" + substrate_actor_bindings: dict[ + Literal["claude", "codex"], SubstrateActorBinding + ] = Field(default_factory=dict) + substrate_resume_timeout_seconds: float = 120.0 + # Substrate workspace-runtime adapter (bounded integration spike; see # docs/architecture/native-agent-inventory.md and .tasknotes/plan.md). Empty # kubeconfig/context falls back to the ambient kubeconfig. One actor per session diff --git a/backend/src/mainloop/runtime/native_sessions.py b/backend/src/mainloop/runtime/native_sessions.py index 21667ea..3c2cfcb 100644 --- a/backend/src/mainloop/runtime/native_sessions.py +++ b/backend/src/mainloop/runtime/native_sessions.py @@ -31,6 +31,7 @@ from mainloop.runtime.herdr import HerdrWorkspace, TransportError, WorkspaceUnavailable from mainloop.runtime.journal import completed_turns, parse_journal from mainloop.runtime.standing import content_hash +from mainloop.runtime.substrate_workspace import SubstrateWorkspace from models import NativeDeliveryInfo, NativeSessionInfo, SessionStatus @@ -44,7 +45,7 @@ DELIVERED_MAX_AGE = timedelta(minutes=30) _NS = uuid.UUID("6f0f7f0e-3f1e-4a3c-9d3b-0e4b6f5c2a11") _locks: dict[str, asyncio.Lock] = {} -_workspaces: dict[str, HerdrWorkspace] = {} +_workspaces: dict[tuple[str, ...], HerdrWorkspace | SubstrateWorkspace] = {} _rotating: set[str] = set() OPEN_STATES = ("recorded", "sending", "delivered") # Ended by the user or by failure. Agent activity never moves a session out of these. @@ -80,12 +81,50 @@ def is_rotating(session_id: str) -> bool: return session_id in _rotating -def workspace_for(binding: dict) -> HerdrWorkspace: - """One Herdr workspace pod per binding: ``main-0`` for the main thread, else ``workspace-0``.""" +def workspace_for(binding: dict) -> HerdrWorkspace | SubstrateWorkspace: + """Select the configured transport and map a native binding to its workspace. + + In Substrate mode, the native kind selects its atespace, actor, and shim Secret from + ``SUBSTRATE_ACTOR_BINDINGS``. No actor identity is inferred from a session or binding. + Herdr's existing pod mapping remains the default and is unchanged. + """ + if settings.workspace_runtime == "substrate": + agent = binding["kind"] + actor_binding = settings.substrate_actor_bindings.get(agent) + if actor_binding is None: + raise RuntimeError( + f"no Substrate actor binding is configured for native agent {agent}" + ) + atespace = actor_binding.atespace + actor = actor_binding.actor + key = ( + "substrate", + atespace, + actor, + actor_binding.shim_token_secret_name, + binding["kind"], + ) + if key not in _workspaces: + _workspaces[key] = SubstrateWorkspace( + atespace=atespace, + actor=actor, + agent=agent, + shim_token_secret_name=actor_binding.shim_token_secret_name, + native_session_id=binding.get("native_session_id"), + ) + workspace = _workspaces[key] + if not isinstance(workspace, SubstrateWorkspace): + raise RuntimeError("workspace cache has an incompatible Substrate entry") + workspace.set_native_session_id(binding.get("native_session_id")) + return workspace pod = binding.get("pod") or settings.workspace_pod - if pod not in _workspaces: - _workspaces[pod] = HerdrWorkspace(pod=pod) - return _workspaces[pod] + key = ("herdr", pod) + if key not in _workspaces: + _workspaces[key] = HerdrWorkspace(pod=pod) + workspace = _workspaces[key] + if not isinstance(workspace, HerdrWorkspace): + raise RuntimeError("workspace cache has an incompatible Herdr entry") + return workspace def rotation_due( @@ -768,7 +807,7 @@ async def identity(session_id: str) -> NativeSessionInfo | None: ready, uid = pod.ready, pod.uid if ready: live = (await ws.agent_status(binding["agent_name"])) is not None - except TransportError as exc: + except (TransportError, WorkspaceUnavailable) as exc: note = f"workspace unreachable: {exc}" if any(d.state == "uncertain" for d in deliveries): note = "delivery unknown: the last prompt was not replayed; check the reply, then send again if needed" diff --git a/backend/src/mainloop/runtime/substrate_workspace.py b/backend/src/mainloop/runtime/substrate_workspace.py new file mode 100644 index 0000000..3690ad2 --- /dev/null +++ b/backend/src/mainloop/runtime/substrate_workspace.py @@ -0,0 +1,418 @@ +"""Native-session transport to a pre-created Substrate actor through its CONNECT router. + +The actor shim owns the native CLI turn and journal files. This adapter preserves the +``HerdrWorkspace`` method contract used by ``native_sessions`` while treating delivery errors +after ``POST /turn`` as unknown; callers must reconcile the journal and never replay blindly. +""" + +from __future__ import annotations + +import asyncio +import base64 +import http.client +import json +import logging +import re +import socket +from dataclasses import dataclass +from urllib.parse import urlencode, urlsplit + +from kubernetes import client, config +from kubernetes.client.rest import ApiException +from mainloop.config import settings +from mainloop.runtime.herdr import ( + JournalSlice, + PodState, + TransportError, + WorkspaceUnavailable, +) + +logger = logging.getLogger(__name__) + +_AGENT = re.compile(r"^(claude|codex)$") +_DNS_LABEL = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$") +_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +_JOURNAL_PAGE_SIZE = 200 +_ACTOR_PORT = 8090 +_SECRET_API: client.CoreV1Api | None = None + + +@dataclass(frozen=True, slots=True) +class _Response: + status: int + body: str + + +def _secret_api() -> client.CoreV1Api: + global _SECRET_API + if _SECRET_API is None: + try: + config.load_incluster_config() + except config.ConfigException: + config.load_kube_config() + _SECRET_API = client.CoreV1Api() + return _SECRET_API + + +def _read_secret_token(secret_name: str, namespace: str) -> str: + try: + secret = _secret_api().read_namespaced_secret(secret_name, namespace) + except ApiException as exc: + if exc.status == 404: + raise WorkspaceUnavailable( + "Substrate shim token Secret is unavailable" + ) from exc + raise TransportError( + f"Substrate shim token Secret read failed (status {exc.status})" + ) from exc + encoded = (secret.data or {}).get("token") + if not isinstance(encoded, str): + raise WorkspaceUnavailable("Substrate shim token Secret has no token key") + try: + token = base64.b64decode(encoded, validate=True).decode("utf-8").strip() + except (ValueError, UnicodeDecodeError) as exc: + raise WorkspaceUnavailable("Substrate shim token Secret is invalid") from exc + if not token: + raise WorkspaceUnavailable("Substrate shim token Secret is empty") + return token + + +def _read_headers(reader) -> tuple[int, http.client.HTTPMessage]: + status_line = reader.readline(8192) + if not status_line.endswith(b"\r\n"): + raise ValueError("invalid HTTP status line") + parts = status_line.decode("latin1").strip().split(" ", 2) + if len(parts) < 2 or not parts[0].startswith("HTTP/"): + raise ValueError("invalid HTTP status line") + status = int(parts[1]) + headers = http.client.parse_headers(reader) + return status, headers + + +def _read_body(reader, headers: http.client.HTTPMessage) -> bytes: + transfer_encoding = headers.get("transfer-encoding", "").lower() + if "chunked" in transfer_encoding: + chunks: list[bytes] = [] + size = 0 + while True: + line = reader.readline(8192) + if not line: + raise ValueError("truncated chunked HTTP response") + chunk_size = int(line.split(b";", 1)[0].strip(), 16) + if chunk_size == 0: + while reader.readline(8192) not in (b"\r\n", b"\n", b""): + pass + return b"".join(chunks) + size += chunk_size + if size > _MAX_RESPONSE_BYTES: + raise ValueError("HTTP response exceeded the bounded size") + chunk = reader.read(chunk_size) + if len(chunk) != chunk_size or reader.read(2) != b"\r\n": + raise ValueError("truncated chunked HTTP response") + chunks.append(chunk) + length = headers.get("content-length") + if length is not None: + size = int(length) + if size < 0 or size > _MAX_RESPONSE_BYTES: + raise ValueError("HTTP response exceeded the bounded size") + body = reader.read(size) + if len(body) != size: + raise ValueError("truncated HTTP response") + return body + body = reader.read(_MAX_RESPONSE_BYTES + 1) + if len(body) > _MAX_RESPONSE_BYTES: + raise ValueError("HTTP response exceeded the bounded size") + return body + + +def _router_request( + *, + host: str, + port: int, + actor: str, + atespace: str, + actor_port: int, + timeout: float, + method: str, + path: str, + token: str | None, + body: dict | None, +) -> _Response: + target = f"actor-upstream:{actor_port}" + sock = socket.create_connection((host, port), timeout=timeout) + sock.settimeout(timeout) + reader = sock.makefile("rb") + try: + connect = ( + f"CONNECT {target} HTTP/1.1\r\n" + f"Host: {target}\r\n" + f"ate-target-actor: {atespace}/{actor}\r\n" + "Connection: keep-alive\r\n\r\n" + ).encode("ascii") + sock.sendall(connect) + connect_status, _ = _read_headers(reader) + if connect_status != 200: + return _Response(connect_status, "") + + encoded_body = ( + json.dumps(body, separators=(",", ":")).encode() + if body is not None + else b"" + ) + headers = [f"Host: {target}", "Connection: close"] + if token is not None: + headers.append(f"Authorization: Bearer {token}") + if body is not None: + headers.extend( + [ + "Content-Type: application/json", + f"Content-Length: {len(encoded_body)}", + ] + ) + request = ( + f"{method} {path} HTTP/1.1\r\n" + "\r\n".join(headers) + "\r\n\r\n" + ).encode("ascii") + sock.sendall(request + encoded_body) + status, response_headers = _read_headers(reader) + response_body = _read_body(reader, response_headers) + return _Response(status, response_body.decode("utf-8", errors="replace")) + finally: + reader.close() + sock.close() + + +class SubstrateWorkspace: + """Drive one native agent in a pre-created Substrate actor.""" + + def __init__( + self, + *, + atespace: str, + actor: str, + agent: str, + shim_token_secret_name: str, + native_session_id: str | None = None, + router_address: str | None = None, + timeout: float | None = None, + ): + if not _DNS_LABEL.fullmatch(atespace) or not _DNS_LABEL.fullmatch(actor): + raise ValueError("Substrate atespace and actor must be DNS labels") + if not _AGENT.fullmatch(agent): + raise ValueError("Substrate native agent must be claude or codex") + self.atespace = atespace + self.actor = actor + self.pod = actor # The existing native-session view exposes this display field. + self.agent = agent + self.native_session_id = native_session_id + address = urlsplit(router_address or settings.substrate_router_address) + if ( + address.scheme != "http" + or not address.hostname + or address.username is not None + or address.password is not None + or address.path not in ("", "/") + or address.query + or address.fragment + ): + raise ValueError("Substrate router address must be an HTTP origin") + self.router_host = address.hostname + self.router_port = address.port or 80 + self.actor_port = _ACTOR_PORT + self.timeout = timeout or settings.substrate_resume_timeout_seconds + self.secret_namespace = settings.substrate_shim_secret_namespace + self.secret_name = shim_token_secret_name + if not _DNS_LABEL.fullmatch(self.secret_name): + raise ValueError("Substrate shim Secret name must be a DNS label") + self._token_value: str | None = None + + def set_native_session_id(self, native_session_id: str | None) -> None: + self.native_session_id = native_session_id + + async def _token(self) -> str: + if self._token_value is None: + self._token_value = await asyncio.to_thread( + _read_secret_token, self.secret_name, self.secret_namespace + ) + return self._token_value + + async def _request( + self, + method: str, + path: str, + *, + body: dict | None = None, + authenticated: bool = True, + ) -> _Response: + token = await self._token() if authenticated else None + response = await self._exchange(method, path, token=token, body=body) + if response.status == 401 and authenticated: + self._token_value = None + if method == "POST" and path == "/turn": + raise RuntimeError( + "Substrate shim rejected its bearer token (HTTP 401); prompt was not retried" + ) + token = await self._token() + response = await self._exchange(method, path, token=token, body=body) + if response.status == 401: + self._token_value = None + if response.status == 503: + raise WorkspaceUnavailable( + "Substrate router or actor capacity is unavailable" + ) + return response + + async def _exchange( + self, method: str, path: str, *, token: str | None, body: dict | None + ) -> _Response: + try: + response = await asyncio.to_thread( + _router_request, + host=self.router_host, + port=self.router_port, + actor=self.actor, + atespace=self.atespace, + actor_port=self.actor_port, + timeout=self.timeout, + method=method, + path=path, + token=token, + body=body, + ) + except (OSError, TimeoutError, ValueError, http.client.HTTPException) as exc: + raise TransportError( + f"Substrate router request failed: {type(exc).__name__}" + ) from exc + return response + + def _json(self, response: _Response, *, method: str) -> dict: + if response.status == 401: + raise RuntimeError("Substrate shim rejected its bearer token (HTTP 401)") + if response.status == 409: + raise RuntimeError("Substrate shim rejected the concurrent turn (HTTP 409)") + if not 200 <= response.status < 300: + raise RuntimeError( + f"Substrate shim {method} failed (HTTP {response.status})" + ) + try: + document = json.loads(response.body) + except json.JSONDecodeError as exc: + raise TransportError("Substrate shim returned invalid JSON") from exc + if not isinstance(document, dict): + raise TransportError("Substrate shim returned an invalid response") + return document + + async def pod_state(self) -> PodState: + try: + response = await self._request("GET", "/healthz", authenticated=False) + except WorkspaceUnavailable: + return PodState(self.actor, None, False) + if response.status == 503: + return PodState(self.actor, None, False) + if response.status != 200: + raise TransportError( + f"Substrate health check failed (HTTP {response.status})" + ) + return PodState(self.actor, None, True) + + async def require_ready(self) -> PodState: + state = await self.pod_state() + if not state.ready: + raise WorkspaceUnavailable( + f"Substrate actor {self.atespace}/{self.actor} is not ready" + ) + return state + + async def agent_status(self, name: str) -> dict | None: + if not name: + raise ValueError("native agent name is required") + query = urlencode({"agent": self.agent}) + response = await self._request("GET", f"/turn/status?{query}") + if response.status == 404: + return None + return self._json(response, method="GET /turn/status") + + async def start( + self, + binding: str, + name: str, + *, + native_id: str | None, + resume: bool, + extra: dict[str, str] | None = None, + ) -> dict: + del binding, name, resume, extra + self.native_session_id = native_id + await self.require_ready() + query = urlencode({"agent": self.agent}) + response = await self._request("GET", f"/agent/ready?{query}") + self._json(response, method="GET /agent/ready") + # The actor image and its provider credentials are provisioned before binding. A start + # verifies them without spawning another CLI process or creating/resuming an actor. + return {"actor": self.actor} + + async def send(self, name: str, text: str) -> None: + if not name: + raise ValueError("native agent name is required") + payload = {"agent": self.agent, "prompt": text} + if self.native_session_id: + payload["session_id"] = self.native_session_id + response = await self._request("POST", "/turn", body=payload) + self._json(response, method="POST /turn") + + async def stop(self, name: str) -> None: + if not name: + raise ValueError("native agent name is required") + response = await self._request("POST", "/turn/stop", body={"agent": self.agent}) + self._json(response, method="POST /turn/stop") + + async def _latest_turn(self) -> dict | None: + query = urlencode({"agent": self.agent}) + response = await self._request("GET", f"/turn/status?{query}") + if response.status == 404: + return None + return self._json(response, method="GET /turn/status") + + async def native_id(self, name: str) -> str | None: + if not name: + raise ValueError("native agent name is required") + turn = await self._latest_turn() + native_id = turn.get("native_session_id") if turn else None + if isinstance(native_id, str) and native_id: + self.native_session_id = native_id + return native_id + return None + + async def journal(self, name: str, native_id: str, from_line: int) -> JournalSlice: + if not name: + raise ValueError("native agent name is required") + query = urlencode( + { + "agent": self.agent, + "id": native_id, + "from": max(0, from_line), + "limit": _JOURNAL_PAGE_SIZE, + } + ) + response = await self._request("GET", f"/journal?{query}") + if response.status == 404: + return JournalSlice(None, 0, []) + document = self._json(response, method="GET /journal") + file = document.get("file") + total = document.get("total_lines") + raw_lines = document.get("lines") + if (file is not None and not isinstance(file, str)) or not isinstance( + total, int + ): + raise TransportError("Substrate journal response has invalid metadata") + if not isinstance(raw_lines, list): + raise TransportError("Substrate journal response has invalid lines") + lines: list[tuple[int, str]] = [] + for item in raw_lines: + if ( + not isinstance(item, dict) + or not isinstance(item.get("line"), int) + or not isinstance(item.get("text"), str) + ): + raise TransportError("Substrate journal response has an invalid line") + lines.append((item["line"], item["text"])) + return JournalSlice(file, total, lines) diff --git a/backend/tests/runtime/test_substrate_workspace.py b/backend/tests/runtime/test_substrate_workspace.py new file mode 100644 index 0000000..43a1bd2 --- /dev/null +++ b/backend/tests/runtime/test_substrate_workspace.py @@ -0,0 +1,375 @@ +"""In-process fake-router coverage; tests never open sockets or access Kubernetes.""" + +from __future__ import annotations + +import asyncio +import json +import unittest +from unittest.mock import AsyncMock, patch +from urllib.parse import parse_qs, urlsplit +from uuid import uuid4 + +from mainloop.config import SubstrateActorBinding, settings +from mainloop.runtime import native_sessions +from mainloop.runtime.herdr import WorkspaceUnavailable +from mainloop.runtime.substrate_workspace import SubstrateWorkspace, _Response + +FIXTURE_VALUE = "fixture-shim-value-one" +ROTATED_FIXTURE_VALUE = "fixture-shim-value-two" +FAKE_SHIM_NAME = "shim-fixture" + + +class FakeRouterAndShim: + """In-process CONNECT/router and authenticated shim model for transport contracts.""" + + def __init__(self): + self.suspended = False + self.capacity = False + self.expected_token = FIXTURE_VALUE + self.inflight: set[str] = set() + self.turns: dict[str, dict] = {} + self.journal_lines = [ + '{"type":"user"}', + '{"type":"assistant"}', + '{"type":"system","subtype":"turn_duration"}', + ] + self.connects: list[tuple[str, str]] = [] + self.requests: list[tuple[str, str, dict]] = [] + + def request( + self, + *, + actor: str, + atespace: str, + method: str, + path: str, + token: str | None, + body: dict | None, + **_unused, + ) -> _Response: + self.connects.append(("CONNECT", f"{atespace}/{actor}")) + if self.capacity: + return _Response(503, "capacity unavailable") + body = body or {} + self.requests.append((method, path, body)) + if (method, path) != ("GET", "/healthz") and token != self.expected_token: + return _Response(401, "unauthorized") + if method == "GET" and path == "/healthz": + if self.suspended: + self.suspended = False + return _Response(200, "ok") + parsed = urlsplit(path) + query = parse_qs(parsed.query) + if method == "GET" and parsed.path == "/agent/ready": + return _Response( + 200, json.dumps({"agent": query["agent"][0], "configured": True}) + ) + if method == "GET" and parsed.path == "/turn/status": + turn = self.turns.get(query.get("agent", [""])[0]) + return ( + _Response(200, json.dumps(turn)) if turn else _Response(404, "no turn") + ) + if method == "POST" and parsed.path == "/turn": + agent = body.get("agent") + if agent in self.inflight: + return _Response(409, "turn already in flight") + turn = { + "id": str(uuid4()), + "agent": agent, + "status": "running", + "native_session_id": body.get("session_id") or "native-fixture-id", + "events": [], + } + self.turns[agent] = turn + self.inflight.add(agent) + return _Response(202, json.dumps({"id": turn["id"], "status": "running"})) + if method == "POST" and parsed.path == "/turn/stop": + agent = body.get("agent") + self.inflight.discard(agent) + turn = self.turns.get(agent) + if turn: + turn["status"] = "interrupted" + return _Response(200, json.dumps({"status": "interrupted"})) + if method == "GET" and parsed.path == "/journal": + start = int(query.get("from", ["0"])[0]) + limit = int(query.get("limit", ["200"])[0]) + document = { + "file": "/fake/fixture-session.jsonl", + "total_lines": len(self.journal_lines), + "lines": [ + {"line": n, "text": line} + for n, line in enumerate(self.journal_lines, 1) + if n > start + ][:limit], + } + return _Response(200, json.dumps(document)) + return _Response(404, "not found") + + +class FakeSubstrateWorkspace(SubstrateWorkspace): + def __init__(self, router: FakeRouterAndShim, **kwargs): + super().__init__(**kwargs) + self.router = router + self.fixture_value = FIXTURE_VALUE + self.fixture_reads = 0 + + async def _token(self) -> str: + if self._token_value is None: + self.fixture_reads += 1 + self._token_value = self.fixture_value + return self._token_value + + async def _exchange( + self, method: str, path: str, *, token: str | None, body: dict | None + ): + return self.router.request( + actor=self.actor, + atespace=self.atespace, + method=method, + path=path, + token=token, + body=body, + ) + + +def fake_workspace(router: FakeRouterAndShim, *, shim_name=FAKE_SHIM_NAME): + return FakeSubstrateWorkspace( + router, + atespace="atespace-fixture", + actor="actor-fixture", + agent="claude", + shim_token_secret_name=shim_name, + router_address="http://router-fixture:8081", + timeout=2, + ) + + +class SubstrateWorkspaceTests(unittest.TestCase): + def test_start_send_status_native_id_and_journal_pages(self): + async def exercise(): + router = FakeRouterAndShim() + router.suspended = True + workspace = fake_workspace(router) + ident = await workspace.start( + "claude", "agent-fixture", native_id=None, resume=False + ) + self.assertEqual(ident["actor"], "actor-fixture") + self.assertFalse(router.suspended) + + await workspace.send("agent-fixture", "fixture prompt") + status = await workspace.agent_status("agent-fixture") + self.assertEqual(status["status"], "running") + self.assertEqual( + await workspace.native_id("agent-fixture"), "native-fixture-id" + ) + first = await workspace.journal("agent-fixture", "native-fixture-id", 0) + next_page = await workspace.journal("agent-fixture", "native-fixture-id", 1) + self.assertEqual(first.file, "/fake/fixture-session.jsonl") + self.assertEqual(first.total_lines, 3) + self.assertEqual(first.lines[0], (1, '{"type":"user"}')) + self.assertEqual(next_page.lines[0], (2, '{"type":"assistant"}')) + self.assertIn( + ("CONNECT", "atespace-fixture/actor-fixture"), router.connects + ) + sent = [ + body + for method, path, body in router.requests + if method == "POST" and path == "/turn" + ] + self.assertEqual(sent, [{"agent": "claude", "prompt": "fixture prompt"}]) + + asyncio.run(exercise()) + + def test_capacity_503_maps_to_workspace_unavailable(self): + async def exercise(): + router = FakeRouterAndShim() + router.capacity = True + with self.assertRaises(WorkspaceUnavailable): + await fake_workspace(router).require_ready() + + asyncio.run(exercise()) + + def test_concurrent_turn_and_bad_token_are_rejected(self): + async def exercise(): + router = FakeRouterAndShim() + workspace = fake_workspace(router) + await workspace.send("agent-fixture", "first fixture prompt") + with self.assertRaisesRegex(RuntimeError, "409"): + await workspace.send("agent-fixture", "second fixture prompt") + + router.expected_token = ROTATED_FIXTURE_VALUE + with self.assertRaisesRegex(RuntimeError, "401"): + await workspace.agent_status("agent-fixture") + + asyncio.run(exercise()) + + def test_401_refreshes_cached_secret_for_safe_request(self): + async def exercise(): + router = FakeRouterAndShim() + workspace = fake_workspace(router) + await workspace.send("agent-fixture", "initial fixture prompt") + router.expected_token = ROTATED_FIXTURE_VALUE + workspace.fixture_value = ROTATED_FIXTURE_VALUE + + status = await workspace.agent_status("agent-fixture") + + self.assertEqual(status["status"], "running") + self.assertEqual(workspace.fixture_reads, 2) + status_requests = [ + request + for request in router.requests + if request[0] == "GET" and request[1].startswith("/turn/status?") + ] + self.assertEqual(len(status_requests), 2) + + asyncio.run(exercise()) + + def test_401_clears_secret_without_replaying_turn(self): + async def exercise(): + router = FakeRouterAndShim() + workspace = fake_workspace(router) + await workspace.send("agent-fixture", "initial fixture prompt") + router.expected_token = ROTATED_FIXTURE_VALUE + workspace.fixture_value = ROTATED_FIXTURE_VALUE + previous_turn_count = sum( + 1 + for request in router.requests + if request[0] == "POST" and request[1] == "/turn" + ) + + with self.assertRaisesRegex(RuntimeError, "401"): + await workspace.send("agent-fixture", "one-shot fixture prompt") + + turn_requests = [ + request + for request in router.requests + if request[0] == "POST" and request[1] == "/turn" + ] + self.assertEqual(len(turn_requests), previous_turn_count + 1) + self.assertEqual(workspace.fixture_reads, 1) + self.assertIsNone(workspace._token_value) + + asyncio.run(exercise()) + + def test_native_sessions_delivery_uses_configured_actor(self): + async def exercise(): + router = FakeRouterAndShim() + binding = { + "session_id": "session-fixture", + "kind": "claude", + "role": "agent", + "agent_name": "agent-fixture", + "native_session_id": "native-fixture-id", + "journal_cursor": 0, + "generation": 1, + "journal_ref": None, + "model": None, + } + workspace_binding = SubstrateActorBinding( + atespace="atespace-configured", + actor="actor-configured", + shim_token_secret_name=FAKE_SHIM_NAME, + ) + key = ( + "substrate", + workspace_binding.atespace, + workspace_binding.actor, + workspace_binding.shim_token_secret_name, + "claude", + ) + + async def fake_exchange(workspace, method, path, *, token, body): + return router.request( + actor=workspace.actor, + atespace=workspace.atespace, + method=method, + path=path, + token=token, + body=body, + ) + + async def fake_token(_workspace): + return FIXTURE_VALUE + + with ( + patch.object(settings, "workspace_runtime", "substrate"), + patch.object( + settings, + "substrate_router_address", + "http://router-fixture:8081", + ), + patch.object( + settings, + "substrate_shim_secret_namespace", + "namespace-fixture", + ), + patch.object( + settings, + "substrate_actor_bindings", + {"claude": workspace_binding}, + ), + patch.object(SubstrateWorkspace, "_exchange", fake_exchange), + patch.object(SubstrateWorkspace, "_token", fake_token), + patch.object( + native_sessions, + "get_binding", + new=AsyncMock(return_value=binding), + ), + patch.object(native_sessions, "_update_binding", new=AsyncMock()), + patch.object( + native_sessions, "_set_delivery", new=AsyncMock() + ) as set_delivery, + patch.object(native_sessions, "sync", new=AsyncMock()) as sync, + ): + native_sessions._workspaces.pop(key, None) + self.assertEqual(settings.workspace_runtime, "substrate") + self.assertIsInstance( + native_sessions.workspace_for(binding), SubstrateWorkspace + ) + await asyncio.wait_for( + native_sessions._deliver( + "session-fixture", + "message-fixture", + "configured fixture prompt", + ), + timeout=2, + ) + workspace = native_sessions.workspace_for(binding) + self.assertIsInstance(workspace, SubstrateWorkspace) + self.assertEqual( + (workspace.atespace, workspace.actor, workspace.secret_name), + ("atespace-configured", "actor-configured", FAKE_SHIM_NAME), + ) + self.assertEqual(workspace.secret_namespace, "namespace-fixture") + self.assertIn( + ("CONNECT", "atespace-configured/actor-configured"), router.connects + ) + self.assertEqual( + [ + request + for request in router.requests + if request[0] == "POST" and request[1] == "/turn" + ], + [ + ( + "POST", + "/turn", + { + "agent": "claude", + "prompt": "configured fixture prompt", + "session_id": "native-fixture-id", + }, + ) + ], + ) + set_delivery.assert_awaited_once_with( + "message-fixture", "sending", cursor_before=3 + ) + sync.assert_awaited_once_with("session-fixture") + native_sessions._workspaces.pop(key, None) + + asyncio.run(exercise()) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 5293a1f..69bd1e3 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -46,6 +46,23 @@ native session/thread id and final message. It permits one in-flight turn per ag output file; `GET /run/:id` reports its status and bounded output. `/healthz` and `/readyz` check only the shim and workspace. The actor image and these routes still need live proof. +## Substrate runtime + +`WORKSPACE_RUNTIME=substrate` selects the native-session transport; `herdr` remains the default. +`SUBSTRATE_ROUTER_ADDRESS` configures the HTTP CONNECT listener. `SUBSTRATE_ACTOR_BINDINGS` is a +JSON object keyed by `claude` and `codex`; each entry supplies `atespace`, `actor`, and +`shim_token_secret_name`. `SUBSTRATE_SHIM_SECRET_NAMESPACE` selects the Secret namespace and +defaults to `mainloop-control`. The backend reads the Secret's `token` key there and keeps the +value out of logs and config. Actor names and Secret names stay in deployment config; Mainloop +attaches only to the named, pre-created actor. A 401 refreshes the cached token and retries +read/status operations once; a rejected `POST /turn` clears the cache and is never replayed. + +The adapter routes turns and status through the authenticated shim, verifies the selected CLI +credential is installed before starting a session, then mirrors replies and completion from native +session journals. The shim's bounded `GET /journal` endpoint pages Claude transcripts and Codex +rollout files. This is fixture-backed transport behavior; it does not add live-proof claims for the +headless image or its current actors. + ## Earlier real-versus-stand-in inventory (before Round 3) | Layer | Status | diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile index d71d199..4b575cf 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -4,7 +4,9 @@ # No credentials are baked in or fetched during startup; the golden actor reaches readiness # without credentials or external network access. FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates git curl procps ripgrep \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + jq ca-certificates git curl procps ripgrep util-linux \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -u 10001 agent # The run-specific Substrate MITM CA is a public trust anchor. BuildKit mounts @@ -26,7 +28,7 @@ COPY agent-config/mainloop-system.txt /etc/agent-config/mainloop-system.txt COPY exec-shim.js /usr/local/bin/exec-shim.js COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/start-native-agent \ - && mkdir -p /work && chown -R agent:agent /work + && mkdir -p /work && chown 10001:10001 /work ENV EXEC_SHIM=/usr/local/bin/exec-shim.js ENV NATIVE_AGENT_LAUNCHER=/usr/local/bin/start-native-agent ENV HOME=/home/agent @@ -36,5 +38,6 @@ ENV EXEC_SHIM_STATE_DIR=/work/repo/.mainloop/exec-shim ENV AGENT_SYSTEM_PROMPT_FILE=/etc/agent-config/mainloop-system.txt ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -USER 10001:10001 +# Substrate currently starts actors as root regardless of this image's USER setting. +USER 0:0 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh index 3ecbb62..309ec8a 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh +++ b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh @@ -2,6 +2,42 @@ # Start only the actor-local shim. Native CLIs run headlessly once per delivered turn. set -eu +AGENT_UID=10001 +AGENT_GID=10001 +CURRENT_UID="$(id -u)" +export HOME=/home/agent +WORKSPACE_PATH="${WORKSPACE_PATH:-/work/repo}" +EXEC_SHIM_STATE_DIR="${EXEC_SHIM_STATE_DIR:-${WORKSPACE_PATH}/.mainloop/exec-shim}" +export WORKSPACE_PATH EXEC_SHIM_STATE_DIR + +if [[ ${CURRENT_UID} -eq 0 && ${1-} != "--runtime-user" ]]; then + if ! command -v setpriv >/dev/null 2>&1; then + echo 'setpriv is required to run the actor shim without root' >&2 + exit 1 + fi + EXEC_SHIM_STATE_DIR="$(realpath -m "${EXEC_SHIM_STATE_DIR}")" + case "${EXEC_SHIM_STATE_DIR}" in + /work/*) ;; + *) + echo 'EXEC_SHIM_STATE_DIR must be under /work' >&2 + exit 1 + ;; + esac + export EXEC_SHIM_STATE_DIR + mkdir -p "${HOME}" /work "${EXEC_SHIM_STATE_DIR}" + chown -R "${AGENT_UID}:${AGENT_GID}" "${HOME}" /work + exec setpriv --reuid "${AGENT_UID}" --regid "${AGENT_GID}" --init-groups \ + --bounding-set=-all --no-new-privs -- "$0" --runtime-user +fi + +if [[ ${1-} == "--runtime-user" ]]; then + shift +fi +if [[ ${CURRENT_UID} -eq 0 ]]; then + echo 'entrypoint refused to continue as UID 0' >&2 + exit 1 +fi + node /usr/local/bin/prepare-native-agent-config.cjs mkdir -p "${WORKSPACE_PATH}" [[ -d "${WORKSPACE_PATH}/.git" ]] || git -C "${WORKSPACE_PATH}" init -q diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index c92b73b..9119a35 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -13,6 +13,10 @@ const MAX_CREDENTIAL_BYTES = 64 * 1024; const MAX_JOB_OUTPUT_BYTES = 2 * 1024 * 1024; const MAX_RESPONSE_OUTPUT_BYTES = 32 * 1024; const MAX_RETURN_EVENTS = 256; +const MAX_JOURNAL_LINES = 200; +const MAX_JOURNAL_LINE_BYTES = 1024 * 1024; +const MAX_JOURNAL_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_JOURNAL_SEARCH_ENTRIES = 100_000; const DEFAULT_RUN_TIMEOUT_MS = 60_000; const DEFAULT_TURN_TIMEOUT_MS = 10 * 60_000; const MAX_TIMEOUT_MS = 10 * 60_000; @@ -26,6 +30,15 @@ const RUN_DIR = path.join(STATE_DIR, 'runs'); const TURN_DIR = path.join(STATE_DIR, 'turns'); const LAUNCHER = process.env.NATIVE_AGENT_LAUNCHER || '/usr/local/bin/start-native-agent'; +if ( + typeof process.getuid === 'function' && + process.getuid() === 0 && + process.env.EXEC_SHIM_TEST_ALLOW_ROOT !== '1' +) { + process.stderr.write('exec-shim refuses to start as UID 0\n'); + process.exit(1); +} + for (const directory of [STATE_DIR, RUN_DIR, TURN_DIR]) { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); fs.chmodSync(directory, 0o700); @@ -45,6 +58,8 @@ try { const jobs = new Map(); const activeTurns = new Map(); +const latestTurns = new Map(); +const turnProcesses = new Map(); function json(res, status, document) { res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(document)); @@ -171,8 +186,12 @@ function loadJobs(directory, kind) { atomicJsonWrite(path.join(directory, name), record); } jobs.set(record.id, { ...record, kind }); - if (kind === 'turn' && record.blocking && record.agent) { - activeTurns.set(record.agent, record.id); + if (kind === 'turn' && record.agent) { + const previous = latestTurns.get(record.agent); + if (!previous || String(record.created_at) > String(previous.created_at)) { + latestTurns.set(record.agent, record); + } + if (record.blocking) activeTurns.set(record.agent, record.id); } } catch { // Ignore an incomplete or corrupt record; it cannot safely be resumed. @@ -296,6 +315,7 @@ function runChild(job, child, timeoutMs, onStart) { if (finalized) return; finalized = true; if (timedOut) job.status = 'timed_out'; + else if (job.stop_requested) job.status = 'interrupted'; else if (spawnError) job.status = 'failed'; else job.status = code === 0 ? 'completed' : 'failed'; job.exit_code = code; @@ -305,6 +325,8 @@ function runChild(job, child, timeoutMs, onStart) { if (job.kind === 'turn' && activeTurns.get(job.agent) === job.id) { activeTurns.delete(job.agent); } + if (job.kind === 'turn') turnProcesses.delete(job.id); + if (job.finishTurn) job.finishTurn(); }; const timer = setTimeout(() => { timedOut = true; @@ -385,6 +407,118 @@ function turnResponse(job) { }; } +function findJournal(agent, id) { + const root = + agent === 'claude' + ? path.join(process.env.CLAUDE_CONFIG_DIR || path.join(HOME_PATH, '.claude'), 'projects') + : path.join(CODEX_HOME_PATH, 'sessions'); + const expected = agent === 'claude' ? `${id}.jsonl` : null; + const stack = [root]; + let visited = 0; + while (stack.length > 0 && visited < MAX_JOURNAL_SEARCH_ENTRIES) { + const directory = stack.pop(); + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR' || err.code === 'EACCES') continue; + throw err; + } + for (const entry of entries) { + visited += 1; + if (visited > MAX_JOURNAL_SEARCH_ENTRIES) break; + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) stack.push(candidate); + else if ( + entry.isFile() && + (agent === 'claude' + ? entry.name === expected + : entry.name.startsWith('rollout-') && entry.name.endsWith(`-${id}.jsonl`)) + ) + return candidate; + } + } + return null; +} + +async function journalPage(file, from, limit) { + const lines = []; + let totalLines = 0; + let responseBytes = 0; + let pending = Buffer.alloc(0); + const stream = fs.createReadStream(file); + for await (const chunk of stream) { + let offset = 0; + let boundary; + while ((boundary = chunk.indexOf(0x0a, offset)) !== -1) { + const part = chunk.subarray(offset, boundary); + const line = pending.length ? Buffer.concat([pending, part]) : part; + pending = Buffer.alloc(0); + if (line.length > MAX_JOURNAL_LINE_BYTES) throw new RangeError('journal line too large'); + totalLines += 1; + const cost = line.length + 24; + if ( + totalLines > from && + totalLines <= from + limit && + responseBytes + cost <= MAX_JOURNAL_RESPONSE_BYTES + ) { + lines.push({ line: totalLines, text: line.toString('utf8') }); + responseBytes += cost; + } + offset = boundary + 1; + } + if (offset < chunk.length) { + const rest = chunk.subarray(offset); + pending = pending.length ? Buffer.concat([pending, rest]) : rest; + if (pending.length > MAX_JOURNAL_LINE_BYTES) throw new RangeError('journal line too large'); + } + } + return { file, total_lines: totalLines, lines }; +} + +async function getJournal(req, res) { + const query = new URL(req.url, 'http://exec-shim.invalid').searchParams; + const agent = query.get('agent'); + const id = query.get('id'); + const fromText = query.get('from') || '0'; + const limitText = query.get('limit') || String(MAX_JOURNAL_LINES); + if ( + (agent !== 'claude' && agent !== 'codex') || + typeof id !== 'string' || + !/^[A-Za-z0-9._:-]{1,256}$/.test(id) || + !/^\d{1,12}$/.test(fromText) || + !/^\d{1,4}$/.test(limitText) + ) { + res.writeHead(400).end('invalid journal query'); + return; + } + const from = Number(fromText); + const limit = Number(limitText); + if ( + !Number.isSafeInteger(from) || + !Number.isInteger(limit) || + limit < 1 || + limit > MAX_JOURNAL_LINES + ) { + res.writeHead(400).end('invalid journal page'); + return; + } + const file = findJournal(agent, id); + if (!file) { + json(res, 200, { file: null, total_lines: 0, lines: [] }); + return; + } + try { + json(res, 200, await journalPage(file, from, limit)); + } catch (err) { + if (err instanceof RangeError) { + res.writeHead(413).end('journal line too large'); + return; + } + res.writeHead(500).end('journal could not be read'); + } +} + function workspaceReady() { try { const stat = fs.statSync(WORKSPACE_PATH); @@ -498,7 +632,11 @@ function startTurn(document, res) { native_session_id: sessionId || null, blocking: true }); + job.completion = new Promise((resolve) => { + job.finishTurn = resolve; + }); activeTurns.set(agent, job.id); + latestTurns.set(agent, job); try { const child = spawn(LAUNCHER, [agent, ...(sessionId ? [sessionId] : [])], { cwd: WORKSPACE_PATH, @@ -507,6 +645,7 @@ function startTurn(document, res) { detached: true }); runChild(job, child, timeoutMs, (processChild) => { + turnProcesses.set(job.id, processChild); processChild.stdout.on('data', (chunk) => appendBounded(job, turnEventsPath(job), chunk)); processChild.stderr.on('data', (chunk) => appendBounded(job, turnStderrPath(job), chunk)); processChild.stdin.on('error', () => {}); @@ -523,6 +662,70 @@ function startTurn(document, res) { } } +function currentTurn(agent, res) { + if (agent !== 'claude' && agent !== 'codex') { + res.writeHead(400).end('unsupported agent'); + return; + } + const job = latestTurns.get(agent); + if (!job) { + res.writeHead(404).end('no turn'); + return; + } + json(res, 200, turnResponse(job)); +} + +function agentReady(agent, res) { + if (agent !== 'claude' && agent !== 'codex') { + res.writeHead(400).end('unsupported agent'); + return; + } + const credential = credentialPaths.get(agent === 'claude' ? 'claude-token' : 'codex-auth'); + try { + const stat = fs.statSync(credential); + if (stat.isFile() && stat.size > 0) { + json(res, 200, { agent, configured: true }); + return; + } + } catch { + // A missing credential is a readiness failure; never include file contents or paths. + } + res.writeHead(503).end('agent credential unavailable'); +} + +async function stopTurn(document, res) { + const agent = document.agent; + if (agent !== 'claude' && agent !== 'codex') { + res.writeHead(400).end('unsupported agent'); + return; + } + const id = activeTurns.get(agent); + const child = id && turnProcesses.get(id); + const job = id && jobs.get(id); + if (!job) { + json(res, 200, { status: 'not_running' }); + return; + } + if (!child) { + // A restored actor can retain an interrupted job record without a live process. The caller + // explicitly requested stop, so release the per-agent turn lock without replaying anything. + job.status = 'interrupted'; + job.blocking = false; + saveJob(job); + activeTurns.delete(agent); + json(res, 200, { id, status: job.status }); + return; + } + job.stop_requested = true; + saveJob(job); + killProcessGroup(child, 'SIGTERM'); + const killTimer = setTimeout(() => killProcessGroup(child, 'SIGKILL'), 500); + killTimer.unref(); + await job.completion; + clearTimeout(killTimer); + json(res, 200, { id, status: job.status }); +} + function getJob(kind, id, res) { if (!/^[0-9a-f-]{36}$/i.test(id)) { res.writeHead(404).end('not found'); @@ -618,6 +821,28 @@ const server = http.createServer((req, res) => { handleBody(req, res, (document) => startTurn(document, res)); return; } + if (req.method === 'POST' && req.url === '/turn/stop') { + if (!authorized(req)) return unauthorized(res); + handleBody(req, res, (document) => void stopTurn(document, res)); + return; + } + if (req.method === 'GET' && req.url.startsWith('/turn/status?')) { + if (!authorized(req)) return unauthorized(res); + const query = new URL(req.url, 'http://exec-shim.invalid').searchParams; + currentTurn(query.get('agent'), res); + return; + } + if (req.method === 'GET' && req.url.startsWith('/agent/ready?')) { + if (!authorized(req)) return unauthorized(res); + const query = new URL(req.url, 'http://exec-shim.invalid').searchParams; + agentReady(query.get('agent'), res); + return; + } + if (req.method === 'GET' && req.url.startsWith('/journal?')) { + if (!authorized(req)) return unauthorized(res); + void getJournal(req, res); + return; + } const match = req.method === 'GET' && req.url.match(/^\/(run|turn)\/([^/?]+)$/); if (match) { if (!authorized(req)) return unauthorized(res); diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js index 63c0f46..8740ee0 100644 --- a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -5,7 +5,7 @@ import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -14,6 +14,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const image = path.resolve(__dirname, '../live-agent-image'); const shim = path.join(image, 'exec-shim.js'); const launcher = path.join(image, 'bin/start-native-agent'); +const dockerfile = path.join(image, 'Dockerfile'); +const entrypoint = path.join(image, 'entrypoint.sh'); const fixtures = path.join(__dirname, 'fixtures/native'); const token = 'fixture-only-shim-token-long-enough-for-testing'; @@ -30,6 +32,7 @@ async function startShim(root, extraEnv = {}) { const child = spawn(process.execPath, [shimCopy], { env: { ...process.env, + ...(process.getuid() === 0 ? { EXEC_SHIM_TEST_ALLOW_ROOT: '1' } : {}), ...extraEnv, HOME: home, CODEX_HOME: path.join(home, '.codex'), @@ -62,6 +65,43 @@ async function startShim(root, extraEnv = {}) { return { child, port, output: () => output, home, workspace, fakeBin, state }; } +test('exec shim refuses UID 0 unless its explicit test-only override is set', () => { + const source = `Object.defineProperty(process, 'getuid', { value: () => 0 }); require(${JSON.stringify(shim)});`; + const result = spawnSync(process.execPath, ['-e', source], { + encoding: 'utf8', + env: { ...process.env, EXEC_SHIM_TEST_ALLOW_ROOT: '0' } + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /exec-shim refuses to start as UID 0/); +}); + +test('actor image prepares its writable paths before dropping root privileges', () => { + const imageDockerfile = fs.readFileSync(dockerfile, 'utf8'); + const imageEntrypoint = fs.readFileSync(entrypoint, 'utf8'); + assert.ok(imageDockerfile.includes('util-linux')); + assert.match(imageDockerfile, /^USER 0:0$/m); + const statePreparation = imageEntrypoint.indexOf( + 'mkdir -p "${HOME}" /work "${EXEC_SHIM_STATE_DIR}"' + ); + const ownership = imageEntrypoint.indexOf('chown -R "${AGENT_UID}:${AGENT_GID}" "${HOME}" /work'); + const privilegeDrop = imageEntrypoint.indexOf('exec setpriv'); + const shimStart = imageEntrypoint.indexOf('node "${EXEC_SHIM}"'); + assert.ok(statePreparation >= 0 && statePreparation < ownership); + assert.ok(ownership >= 0 && ownership < privilegeDrop); + assert.ok(privilegeDrop >= 0 && privilegeDrop < shimStart); + for (const option of [ + '--reuid "${AGENT_UID}"', + '--regid "${AGENT_GID}"', + '--init-groups', + '--bounding-set=-all', + '--no-new-privs' + ]) { + assert.ok(imageEntrypoint.includes(option), `entrypoint is missing ${option}`); + } + assert.equal(imageEntrypoint.includes('IS_SANDBOX'), false); + assert.equal(imageDockerfile.includes('EXEC_SHIM_TEST_ALLOW_ROOT'), false); +}); + function request(port, method, route, { body, bearer } = {}) { return new Promise((resolve, reject) => { const headers = {}; @@ -328,6 +368,148 @@ test('a second concurrent turn for the same agent receives 409 and is not queued assert.equal(result.status, 'completed'); }); +test('turn status locates the latest turn after it completes', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-status-')); + const running = await startShim(root, { + CLAUDE_EVENTS: path.join(fixtures, 'claude-stream-json.jsonl') + }); + fakeCli( + path.join(running.fakeBin, 'claude'), + '#!/bin/sh\ncat >/dev/null\ncat "$CLAUDE_EVENTS"\n' + ); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + await installCredential(running, 'claude-token', 'fixture-claude-token'); + + const submitted = await request(running.port, 'POST', '/turn', { + bearer: token, + body: { agent: 'claude', prompt: 'fixture status prompt' } + }); + assert.equal(submitted.status, 202, submitted.body); + const { id } = JSON.parse(submitted.body); + const current = await waitForJob(running, 'turn', id); + const status = await request(running.port, 'GET', '/turn/status?agent=claude', { + bearer: token + }); + assert.equal(status.status, 200, status.body); + assert.equal(JSON.parse(status.body).id, id); + assert.equal(JSON.parse(status.body).status, 'completed'); + assert.equal(JSON.parse(status.body).native_session_id, current.native_session_id); +}); + +test('agent readiness requires its credential and rejects unauthenticated checks', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-agent-ready-')); + const running = await startShim(root); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + + const missing = await request(running.port, 'GET', '/agent/ready?agent=codex', { + bearer: token + }); + assert.equal(missing.status, 503); + const unauthorized = await request(running.port, 'GET', '/agent/ready?agent=codex'); + assert.equal(unauthorized.status, 401); + + await installCredential(running, 'codex-auth', '{"auth_mode":"fixture"}'); + const configured = await request(running.port, 'GET', '/agent/ready?agent=codex', { + bearer: token + }); + assert.equal(configured.status, 200, configured.body); + assert.deepEqual(JSON.parse(configured.body), { agent: 'codex', configured: true }); +}); + +test('journal returns bounded numbered pages for Claude and Codex and rejects a bad token', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-journal-')); + const running = await startShim(root); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + + const claudeId = 'fixture-claude-session'; + const claudeFile = path.join(running.home, '.claude', 'projects', 'p1', `${claudeId}.jsonl`); + fs.mkdirSync(path.dirname(claudeFile), { recursive: true }); + fs.writeFileSync(claudeFile, '{"type":"system"}\n{"type":"user"}\n{"type":"assistant"}\npartial'); + const firstPage = await request( + running.port, + 'GET', + `/journal?agent=claude&id=${claudeId}&from=1&limit=1`, + { bearer: token } + ); + assert.equal(firstPage.status, 200, firstPage.body); + assert.deepEqual(JSON.parse(firstPage.body), { + file: claudeFile, + total_lines: 3, + lines: [{ line: 2, text: '{"type":"user"}' }] + }); + const wrongToken = await request( + running.port, + 'GET', + `/journal?agent=claude&id=${claudeId}&from=0`, + { bearer: `${token}-wrong` } + ); + assert.equal(wrongToken.status, 401); + + const codexId = 'fixture-codex-thread'; + const codexFile = path.join( + running.home, + '.codex', + 'sessions', + '2026', + '09', + 'rollout-2026-09-24T00-00-00-fixture-codex-thread.jsonl' + ); + fs.mkdirSync(path.dirname(codexFile), { recursive: true }); + fs.writeFileSync(codexFile, '{"type":"thread.started"}\n{"type":"turn.completed"}\n'); + const codexPage = await request( + running.port, + 'GET', + `/journal?agent=codex&id=${codexId}&from=0&limit=1`, + { bearer: token } + ); + assert.equal(codexPage.status, 200, codexPage.body); + assert.deepEqual(JSON.parse(codexPage.body), { + file: codexFile, + total_lines: 2, + lines: [{ line: 1, text: '{"type":"thread.started"}' }] + }); + assert.equal(running.output().includes(token), false); +}); + +test('stop interrupts an in-flight turn and releases its slot', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-stop-')); + const running = await startShim(root); + fakeCli(path.join(running.fakeBin, 'claude'), '#!/bin/sh\ncat >/dev/null\nsleep 5\n'); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + await installCredential(running, 'claude-token', 'fixture-claude-token'); + + const submitted = await request(running.port, 'POST', '/turn', { + bearer: token, + body: { agent: 'claude', prompt: 'stop fixture turn' } + }); + assert.equal(submitted.status, 202, submitted.body); + const { id } = JSON.parse(submitted.body); + const stopped = await request(running.port, 'POST', '/turn/stop', { + bearer: token, + body: { agent: 'claude' } + }); + assert.equal(stopped.status, 200, stopped.body); + assert.equal(JSON.parse(stopped.body).status, 'interrupted'); + const result = await waitForJob(running, 'turn', id); + assert.equal(result.status, 'interrupted'); +}); + test('/run executes a command, stores bounded output, and reports timeout', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-run-')); const running = await startShim(root); From 5d276462181c957c94483d8274d2dd622ba97608 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:42:01 +0000 Subject: [PATCH 16/30] feat: add local Substrate preview deployment Add a portable Kustomize overlay for Mainloop's control namespace, backend, frontend, PostgreSQL, and config-driven headless actor bindings. Add a bounded helper to build and preflight local-registry images, record their digests, and deploy a temporary digest-pinned render only to the pinned preview context. The deploy helper creates a random PostgreSQL Secret when absent, inspects status and logs, and opens local port-forwards. Document the preview workflow and teardown, including cluster-lane ownership of shim Secrets. --- docs/spikes/substrate-preview-quickstart.md | 61 ++++ .../substrate-preview/backend-rbac.yaml | 33 ++ .../overlays/substrate-preview/backend.yaml | 69 ++++ .../overlays/substrate-preview/configmap.yaml | 15 + .../overlays/substrate-preview/database.yaml | 73 ++++ .../overlays/substrate-preview/frontend.yaml | 64 ++++ .../substrate-preview/kustomization.yaml | 13 + .../overlays/substrate-preview/namespace.yaml | 6 + .../overlays/substrate-preview/services.yaml | 38 +++ scripts/substrate-preview.sh | 320 ++++++++++++++++++ 10 files changed, 692 insertions(+) create mode 100644 docs/spikes/substrate-preview-quickstart.md create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/backend.yaml create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/database.yaml create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/frontend.yaml create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/services.yaml create mode 100755 scripts/substrate-preview.sh diff --git a/docs/spikes/substrate-preview-quickstart.md b/docs/spikes/substrate-preview-quickstart.md new file mode 100644 index 0000000..73bd0ef --- /dev/null +++ b/docs/spikes/substrate-preview-quickstart.md @@ -0,0 +1,61 @@ +# Mainloop Substrate preview + +This local Kind preview runs Mainloop's backend, frontend, and PostgreSQL in `mainloop-control`. The backend uses the headless Claude and Codex actors already provisioned in the preview cluster. This overlay does not create actors or include shim-token values. + +## Prerequisites + +- The `kind-substrate-preview` cluster, `localhost:5001` registry, Substrate router ingress policy, and headless actors are ready. The cluster lane creates the two referenced `mainloop-shim-*` Secrets in `mainloop-control`, each with a `token` key; the deploy script does not create them. +- Docker, `kubectl`, `kubectl-ate`, `curl`, and `openssl` are installed. Docker can reach the local registry. +- The preview kubeconfig is available at `/tmp/substrate-preview-kubeconfig`. + +Set the kubeconfig path once in the shell: + +```bash +export SUBSTRATE_PREVIEW_KUBECONFIG=/tmp/substrate-preview-kubeconfig +``` + +## Build and deploy + +```bash +scripts/substrate-preview.sh build +scripts/substrate-preview.sh deploy +scripts/substrate-preview.sh status +``` + +`build` pushes the backend, frontend, and mirrored PostgreSQL images to `localhost:5001`, checks each pushed manifest by digest, and records those digests in `$XDG_STATE_HOME/mainloop/substrate-preview/image-digests` (or `$HOME/.local/state/mainloop/substrate-preview/image-digests` when `XDG_STATE_HOME` is unset). Run `build` before `deploy`; deploy fails if that state file is missing or invalid. `deploy` renders a temporary copy of the overlay with the recorded digests, then applies it to context `kind-substrate-preview`; the tracked overlay remains tag-based. On first deploy, it creates `mainloop-db-app` with a random password if that Secret is absent. The password is not printed, and subsequent deploys keep the existing Secret. + +## Open Mainloop + +Run this in a terminal and leave it running; Ctrl+C stops both port-forwards: + +```bash +scripts/substrate-preview.sh open +``` + +Open the printed frontend URL. The backend API docs are at `http://127.0.0.1:8000/docs`. + +## Watch actors and logs + +The installed `kubectl-ate get actor` command does not provide a watch flag. Poll both atespaces every two seconds to see actors suspend and resume: + +```bash +watch -n 2 'kubectl-ate --kubeconfig "$SUBSTRATE_PREVIEW_KUBECONFIG" --context kind-substrate-preview get actor --all-atespaces' +``` + +In another terminal, follow backend logs (or choose `frontend` or `postgres`): + +```bash +scripts/substrate-preview.sh logs +scripts/substrate-preview.sh logs frontend +``` + +`scripts/substrate-preview.sh status` prints Mainloop pods, Substrate actors, and WorkerPools. + +## Tear down + +This deletes the `mainloop-control` namespace, its local PostgreSQL data, and shim-token Secrets there. It leaves Substrate actors and cluster-level services alone. + +```bash +kubectl --kubeconfig "$SUBSTRATE_PREVIEW_KUBECONFIG" --context kind-substrate-preview \ + delete -k k8s/apps/mainloop/overlays/substrate-preview +``` diff --git a/k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml b/k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml new file mode 100644 index 0000000..f9f5e1d --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mainloop-backend + namespace: mainloop-control +automountServiceAccountToken: true +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: mainloop-read-shim-tokens + namespace: mainloop-control +rules: + - apiGroups: [''] + resources: [secrets] + resourceNames: + - mainloop-shim-live-agent-gate-headless-claude-reproof + - mainloop-shim-native-codex-headless-codex-reproof + verbs: [get] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: mainloop-read-shim-tokens + namespace: mainloop-control +subjects: + - kind: ServiceAccount + name: mainloop-backend + namespace: mainloop-control +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: mainloop-read-shim-tokens diff --git a/k8s/apps/mainloop/overlays/substrate-preview/backend.yaml b/k8s/apps/mainloop/overlays/substrate-preview/backend.yaml new file mode 100644 index 0000000..acc6a8b --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/backend.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mainloop-backend + namespace: mainloop-control +spec: + replicas: 1 + selector: + matchLabels: + app: mainloop-backend + template: + metadata: + labels: + app: mainloop-backend + spec: + serviceAccountName: mainloop-backend + containers: + - name: backend + image: localhost:5001/mainloop-backend:substrate-preview + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + envFrom: + - configMapRef: + name: mainloop-config + env: + - name: DB_USER + valueFrom: + secretKeyRef: + name: mainloop-db-app + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: mainloop-db-app + key: password + resources: + requests: + memory: 1Gi + cpu: 250m + limits: + memory: 2Gi + cpu: 1000m + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 90 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml new file mode 100644 index 0000000..b923b9d --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: mainloop-config + namespace: mainloop-control +data: + HOST: 0.0.0.0 + PORT: '8000' + DB_HOST: mainloop-db-pooler + DB_PORT: '5432' + DB_NAME: mainloop + FRONTEND_DOMAIN: localhost:3000 + WORKSPACE_RUNTIME: substrate + SUBSTRATE_ROUTER_ADDRESS: http://atenet-router.ate-system.svc.cluster.local:8081 + SUBSTRATE_ACTOR_BINDINGS: '{"claude":{"atespace":"live-agent-gate","actor":"headless-claude-reproof","shim_token_secret_name":"mainloop-shim-live-agent-gate-headless-claude-reproof"},"codex":{"atespace":"native-codex","actor":"headless-codex-reproof","shim_token_secret_name":"mainloop-shim-native-codex-headless-codex-reproof"}}' diff --git a/k8s/apps/mainloop/overlays/substrate-preview/database.yaml b/k8s/apps/mainloop/overlays/substrate-preview/database.yaml new file mode 100644 index 0000000..fda2932 --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/database.yaml @@ -0,0 +1,73 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: mainloop-control +spec: + clusterIP: None + selector: + app: postgres + ports: + - name: postgres + port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: mainloop-control +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: localhost:5001/postgres:16-alpine + imagePullPolicy: Always + ports: + - name: postgres + containerPort: 5432 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: mainloop-db-app + key: username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: mainloop-db-app + key: password + - name: POSTGRES_DB + value: mainloop + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 512Mi + cpu: 500m + readinessProbe: + exec: + command: [pg_isready, -U, mainloop] + initialDelaySeconds: 5 + periodSeconds: 5 + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 1Gi diff --git a/k8s/apps/mainloop/overlays/substrate-preview/frontend.yaml b/k8s/apps/mainloop/overlays/substrate-preview/frontend.yaml new file mode 100644 index 0000000..5031c06 --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/frontend.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mainloop-frontend + namespace: mainloop-control +spec: + replicas: 1 + selector: + matchLabels: + app: mainloop-frontend + template: + metadata: + labels: + app: mainloop-frontend + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + containers: + - name: frontend + image: localhost:5001/mainloop-frontend:substrate-preview + imagePullPolicy: Always + ports: + - name: http + containerPort: 3000 + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + env: + - name: ORIGIN + value: http://localhost:3000 + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 256Mi + cpu: 250m + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml b/k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml new file mode 100644 index 0000000..b5ad33e --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mainloop-control + +resources: + - namespace.yaml + - configmap.yaml + - database.yaml + - backend-rbac.yaml + - backend.yaml + - frontend.yaml + - services.yaml diff --git a/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml b/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml new file mode 100644 index 0000000..2ddd664 --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mainloop-control + labels: + mainloop.dev/role: control diff --git a/k8s/apps/mainloop/overlays/substrate-preview/services.yaml b/k8s/apps/mainloop/overlays/substrate-preview/services.yaml new file mode 100644 index 0000000..81d4843 --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/services.yaml @@ -0,0 +1,38 @@ +apiVersion: v1 +kind: Service +metadata: + name: mainloop-db-pooler + namespace: mainloop-control +spec: + selector: + app: postgres + ports: + - name: postgres + port: 5432 + targetPort: 5432 +--- +apiVersion: v1 +kind: Service +metadata: + name: mainloop-backend + namespace: mainloop-control +spec: + selector: + app: mainloop-backend + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: v1 +kind: Service +metadata: + name: mainloop-frontend + namespace: mainloop-control +spec: + selector: + app: mainloop-frontend + ports: + - name: http + port: 3000 + targetPort: 3000 diff --git a/scripts/substrate-preview.sh b/scripts/substrate-preview.sh new file mode 100755 index 0000000..39497e9 --- /dev/null +++ b/scripts/substrate-preview.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +CONTEXT=kind-substrate-preview +NAMESPACE=mainloop-control +REGISTRY=localhost:5001 +IMAGE_TAG=substrate-preview +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +PREVIEW_KUBECONFIG= +PREFLIGHT_DIGEST= + +usage() { + cat <<'EOF' +Usage: scripts/substrate-preview.sh [component] + +Commands: + build Build app images, mirror PostgreSQL, push and preflight manifests + deploy Apply the Kustomize overlay to kind-substrate-preview + open Port-forward frontend and backend; Ctrl+C stops both forwards + status Show Mainloop pods, Substrate actors, and WorkerPools + logs [component] Follow backend, frontend, or postgres logs (default: backend) + +Cluster commands require SUBSTRATE_PREVIEW_KUBECONFIG to name the preview kubeconfig. +EOF +} + +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +require_preview_kubeconfig() { + [[ -n ${SUBSTRATE_PREVIEW_KUBECONFIG-} ]] || + fail 'SUBSTRATE_PREVIEW_KUBECONFIG is unset; set it to the preview kubeconfig path.' + [[ -f ${SUBSTRATE_PREVIEW_KUBECONFIG} && -r ${SUBSTRATE_PREVIEW_KUBECONFIG} ]] || + fail "preview kubeconfig is not a readable file: ${SUBSTRATE_PREVIEW_KUBECONFIG}" + PREVIEW_KUBECONFIG="${SUBSTRATE_PREVIEW_KUBECONFIG}" +} + +kube() { + kubectl --kubeconfig "${PREVIEW_KUBECONFIG}" --context "${CONTEXT}" "$@" +} + +image_digest_file() { + local state_root="${XDG_STATE_HOME:-${HOME-}}" + [[ -n ${state_root} ]] || fail 'HOME is unset and XDG_STATE_HOME is unset; cannot locate the image digest state file.' + [[ ${state_root} == /* ]] || fail 'XDG_STATE_HOME must be an absolute path.' + printf '%s/mainloop/substrate-preview/image-digests\n' "${state_root%/}" +} + +validate_image_digest() { + local image_name="$1" + local digest="$2" + [[ ${digest} =~ ^sha256:[a-f0-9]{64}$ ]] || + fail "recorded ${image_name} digest is missing or invalid; run scripts/substrate-preview.sh build first." +} + +load_image_digests() { + local digest_file + digest_file="$(image_digest_file)" + [[ -f ${digest_file} && -r ${digest_file} ]] || + fail "no recorded image digests at ${digest_file}; run scripts/substrate-preview.sh build first." + + BACKEND_DIGEST="$(sed -n 's/^backend=//p' "${digest_file}")" + FRONTEND_DIGEST="$(sed -n 's/^frontend=//p' "${digest_file}")" + POSTGRES_DIGEST="$(sed -n 's/^postgres=//p' "${digest_file}")" + validate_image_digest backend "${BACKEND_DIGEST}" + validate_image_digest frontend "${FRONTEND_DIGEST}" + validate_image_digest postgres "${POSTGRES_DIGEST}" +} + +record_image_digests() { + local backend_digest="$1" + local frontend_digest="$2" + local postgres_digest="$3" + local digest_file + local digest_dir + local temporary_file + + digest_file="$(image_digest_file)" + digest_dir="${digest_file%/*}" + mkdir -p -- "${digest_dir}" + chmod 700 "${digest_dir}" + temporary_file="$(mktemp "${digest_dir}/.image-digests.XXXXXX")" + chmod 600 "${temporary_file}" + { + printf 'backend=%s\n' "${backend_digest}" + printf 'frontend=%s\n' "${frontend_digest}" + printf 'postgres=%s\n' "${postgres_digest}" + } >"${temporary_file}" + mv -f -- "${temporary_file}" "${digest_file}" + printf 'Recorded preflighted image digests in %s\n' "${digest_file}" +} + +ate() { + kubectl-ate --kubeconfig "${PREVIEW_KUBECONFIG}" --context "${CONTEXT}" "$@" +} + +preflight_image() { + local image_ref="$1" + local repository="$2" + local push_log="${BUILD_TMPDIR}/${repository//\//_}.push.log" + local digest + + docker push "${image_ref}" 2>&1 | tee "${push_log}" + digest="$(sed -nE 's/.*digest: (sha256:[a-f0-9]{64}).*/\1/p' "${push_log}" | tail -n 1)" + [[ ${digest} =~ ^sha256:[a-f0-9]{64}$ ]] || + fail "could not read a sha256 digest from docker push output for ${image_ref}" + + printf 'Preflighting %s at %s\n' "${image_ref}" "${digest}" + curl -fsI \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "http://${REGISTRY}/v2/${repository}/manifests/${digest}" >/dev/null + PREFLIGHT_DIGEST="${digest}" +} + +build_images() { + require_command docker + require_command curl + BUILD_TMPDIR="$(mktemp -d)" + trap 'rm -rf -- "$BUILD_TMPDIR"' EXIT + + docker build -f backend/Dockerfile -t "${REGISTRY}/mainloop-backend:${IMAGE_TAG}" . + docker build -f frontend/Dockerfile \ + --build-arg VITE_API_URL=http://localhost:8000 \ + -t "${REGISTRY}/mainloop-frontend:${IMAGE_TAG}" . + docker pull postgres:16-alpine + docker tag postgres:16-alpine "${REGISTRY}/postgres:16-alpine" + + preflight_image "${REGISTRY}/mainloop-backend:${IMAGE_TAG}" mainloop-backend + local backend_digest="${PREFLIGHT_DIGEST}" + preflight_image "${REGISTRY}/mainloop-frontend:${IMAGE_TAG}" mainloop-frontend + local frontend_digest="${PREFLIGHT_DIGEST}" + preflight_image "${REGISTRY}/postgres:16-alpine" postgres + local postgres_digest="${PREFLIGHT_DIGEST}" + record_image_digests "${backend_digest}" "${frontend_digest}" "${postgres_digest}" +} + +apply_digest_pinned_overlay() ( + set -Eeuo pipefail + local overlay_dir="${REPO_ROOT}/k8s/apps/mainloop/overlays/substrate-preview" + local render_dir + render_dir="$(mktemp -d)" + trap 'rm -rf -- "${render_dir}"' EXIT + cp -R -- "${overlay_dir}/." "${render_dir}/" + cat >>"${render_dir}/kustomization.yaml" </dev/null + unset database_password + printf 'Created mainloop-db-app with a random password.\n' +} + +deploy_overlay() { + load_image_digests + require_command kubectl + require_preview_kubeconfig + kube apply -f "${REPO_ROOT}/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml" + ensure_database_secret + apply_digest_pinned_overlay +} + +show_status() { + require_command kubectl + require_command kubectl-ate + require_preview_kubeconfig + + printf '%s\n' '== Mainloop pods ==' + kube get pods -n "${NAMESPACE}" -o wide + printf '\n%s\n' '== Substrate actors ==' + ate get actor --all-atespaces + printf '\n%s\n' '== Substrate WorkerPools ==' + kube get workerpools --all-namespaces -o wide +} + +follow_logs() { + local component="${1:-backend}" + local target + + case "${component}" in + backend) target=deployment/mainloop-backend ;; + frontend) target=deployment/mainloop-frontend ;; + postgres) target=statefulset/postgres ;; + *) fail "unknown log component '${component}' (choose backend, frontend, or postgres)" ;; + esac + + require_command kubectl + require_preview_kubeconfig + kube logs --follow --tail=200 -n "${NAMESPACE}" "${target}" +} + +open_preview() { + require_command kubectl + require_preview_kubeconfig + + local forward_dir + local frontend_pid + local backend_pid + local frontend_ready=0 + local backend_ready=0 + local attempt + + forward_dir="$(mktemp -d)" + cleanup_forwards() { + local result=$? + trap - EXIT + for child_pid in "${frontend_pid-}" "${backend_pid-}"; do + if [[ -n ${child_pid} ]]; then + kill "${child_pid}" 2>/dev/null || true + wait "${child_pid}" 2>/dev/null || true + fi + done + if [[ ${result} -ne 0 ]]; then + for forward_log in "${forward_dir}"/*.log; do + [[ -s ${forward_log} ]] && cat "${forward_log}" >&2 + done + fi + rm -rf -- "${forward_dir}" + return "${result}" + } + trap cleanup_forwards EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + kube port-forward --address 127.0.0.1 -n "${NAMESPACE}" service/mainloop-frontend 3000:3000 \ + >"${forward_dir}/frontend.log" 2>&1 & + frontend_pid=$! + kube port-forward --address 127.0.0.1 -n "${NAMESPACE}" service/mainloop-backend 8000:8000 \ + >"${forward_dir}/backend.log" 2>&1 & + backend_pid=$! + + attempt=0 + while ((attempt < 60)); do + attempt=$((attempt + 1)) + if ! kill -0 "${frontend_pid}" 2>/dev/null || ! kill -0 "${backend_pid}" 2>/dev/null; then + fail 'a port-forward exited before both local ports became ready' + fi + if (echo >/dev/tcp/127.0.0.1/3000) >/dev/null 2>&1; then + frontend_ready=1 + fi + if (echo >/dev/tcp/127.0.0.1/8000) >/dev/null 2>&1; then + backend_ready=1 + fi + [[ ${frontend_ready} -eq 1 && ${backend_ready} -eq 1 ]] && break + sleep 0.5 + done + [[ ${frontend_ready} -eq 1 && ${backend_ready} -eq 1 ]] || + fail 'timed out waiting for frontend and backend port-forwards' + + printf 'Mainloop: http://127.0.0.1:3000\n' + printf 'Backend API: http://127.0.0.1:8000/docs\n' + printf 'Press Ctrl+C to stop both port-forwards.\n' + wait -n "${frontend_pid}" "${backend_pid}" +} + +main() { + local command="${1-}" + case "${command}" in + build) + [[ $# -eq 1 ]] || fail 'build takes no additional arguments' + cd "${REPO_ROOT}" + build_images + ;; + deploy) + [[ $# -eq 1 ]] || fail 'deploy takes no additional arguments' + deploy_overlay + ;; + open) + [[ $# -eq 1 ]] || fail 'open takes no additional arguments' + open_preview + ;; + status) + [[ $# -eq 1 ]] || fail 'status takes no additional arguments' + show_status + ;; + logs) + [[ $# -le 2 ]] || fail 'logs accepts at most one component' + follow_logs "${2:-backend}" + ;; + -h | --help | help) + usage + ;; + *) + usage >&2 + fail "unknown command '${command-}'" + ;; + esac +} + +main "$@" From 63c5f2b11995920dc0489e3ee2e0f1b2a0246fa0 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:42:46 +0000 Subject: [PATCH 17/30] feat(workspaces): add lifecycle state and controls Persist a strict declarative workspace manifest and separate desired and observed lifecycle state, conditions, transitions, operation IDs, snapshots, and ownership generations from session and delivery state. Add owner-scoped lifecycle and refresh APIs, row-locked suspend reservations with a second pre-call delivery fence, and workspace SSE updates. Show lifecycle badges across session views and add a workspace detail page with conditions, manifest, snapshot status, and pending/error-aware controls. Document the user-visible behavior and cover lifecycle policy, fence ordering, and the API with fake-backed tests. --- backend/src/mainloop/api.py | 2 + backend/src/mainloop/db/postgres.py | 17 +- .../src/mainloop/runtime/workspace_adapter.py | 948 +++++++++++++++++- backend/src/mainloop/runtime/workspace_api.py | 101 ++ backend/src/mainloop/sse.py | 12 + backend/tests/runtime/test_workspace_api.py | 150 +++ .../tests/runtime/test_workspace_lifecycle.py | 364 +++++++ docs/specs/sessions.md | 5 + docs/specs/workspaces.md | 55 + docs/spikes/substrate-workspace-adapter.md | 20 + frontend/src/lib/api.ts | 84 ++ .../src/lib/components/SessionBlock.svelte | 8 + .../src/lib/components/SessionListItem.svelte | 8 + .../src/lib/components/SessionPicker.svelte | 8 +- .../components/WorkspaceLifecycleBadge.svelte | 38 + frontend/src/lib/sse.ts | 2 + frontend/src/lib/stores/workspaces.ts | 61 ++ frontend/src/routes/+layout.svelte | 9 + .../src/routes/sessions/[id]/+page.svelte | 18 + .../src/routes/workspaces/[id]/+page.svelte | 284 ++++++ models/src/models/__init__.py | 18 + models/src/models/workspace.py | 183 ++++ 22 files changed, 2370 insertions(+), 25 deletions(-) create mode 100644 backend/src/mainloop/runtime/workspace_api.py create mode 100644 backend/tests/runtime/test_workspace_api.py create mode 100644 backend/tests/runtime/test_workspace_lifecycle.py create mode 100644 docs/specs/workspaces.md create mode 100644 frontend/src/lib/components/WorkspaceLifecycleBadge.svelte create mode 100644 frontend/src/lib/stores/workspaces.ts create mode 100644 frontend/src/routes/workspaces/[id]/+page.svelte create mode 100644 models/src/models/workspace.py diff --git a/backend/src/mainloop/api.py b/backend/src/mainloop/api.py index d1b6e6c..8a6380f 100644 --- a/backend/src/mainloop/api.py +++ b/backend/src/mainloop/api.py @@ -17,6 +17,7 @@ ConversationResponse, ) from mainloop.runtime.agent_api import router as agent_api_router +from mainloop.runtime.workspace_api import router as workspace_api_router from mainloop.services.chat_handler import process_message from mainloop.services.github_pr import ( CommitSummary, @@ -395,6 +396,7 @@ async def list_topics(user_id: str = Header(alias="X-User-ID", default=None)): app.include_router(agent_api_router) +app.include_router(workspace_api_router) # ============= Conversation Endpoints ============= diff --git a/backend/src/mainloop/db/postgres.py b/backend/src/mainloop/db/postgres.py index 810f1b7..8683c64 100644 --- a/backend/src/mainloop/db/postgres.py +++ b/backend/src/mainloop/db/postgres.py @@ -250,6 +250,21 @@ def _parse_json_field(value: Any) -> list | dict | None: UNIQUE (atespace, actor_name) ); +-- Workspace lifecycle belongs to the workspace, separate from task/session, agent and delivery +-- state. The manifest is declarative intent; only actor observations establish runtime state. +CREATE TABLE IF NOT EXISTS workspace_lifecycles ( + workspace_id TEXT PRIMARY KEY REFERENCES workspace_bindings(workspace_id) ON DELETE CASCADE, + desired_state TEXT NOT NULL DEFAULT 'running', + observed_state TEXT NOT NULL DEFAULT 'unknown', + manifest JSONB NOT NULL, + conditions JSONB NOT NULL DEFAULT '[]'::jsonb, + last_transition JSONB, + operation_id TEXT, + snapshot_ref TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- Topics are durable records (not sessions). Supervisors (next slice) attach to a topic. CREATE TABLE IF NOT EXISTS topics ( id TEXT PRIMARY KEY, @@ -933,7 +948,7 @@ async def list_queue_items( query = f""" SELECT * FROM queue_items - WHERE {' AND '.join(conditions)} + WHERE {" AND ".join(conditions)} ORDER BY CASE priority WHEN 'urgent' THEN 1 diff --git a/backend/src/mainloop/runtime/workspace_adapter.py b/backend/src/mainloop/runtime/workspace_adapter.py index aa3eb64..091190d 100644 --- a/backend/src/mainloop/runtime/workspace_adapter.py +++ b/backend/src/mainloop/runtime/workspace_adapter.py @@ -17,7 +17,9 @@ from __future__ import annotations import asyncio +import json import logging +import uuid from datetime import UTC, datetime from mainloop.config import settings @@ -31,12 +33,39 @@ TransportError, ) -from models import CapabilityResult, CapabilityState, WorkspaceBinding +from models import ( + CapabilityResult, + CapabilityState, + WorkspaceAgentKind, + WorkspaceBinding, + WorkspaceCondition, + WorkspaceConditionStatus, + WorkspaceDesiredState, + WorkspaceLifecycle, + WorkspaceManifest, + WorkspaceObservedState, + WorkspaceTransition, +) logger = logging.getLogger(__name__) _locks: dict[str, asyncio.Lock] = {} +LIFECYCLE_STATE = { + ActorState.RUNNING: WorkspaceObservedState.RUNNING, + ActorState.SUSPENDING: WorkspaceObservedState.SUSPENDING, + ActorState.SUSPENDED: WorkspaceObservedState.SUSPENDED, + ActorState.RESUMING: WorkspaceObservedState.RESUMING, + ActorState.CRASHED: WorkspaceObservedState.FAILED, + ActorState.DELETING: WorkspaceObservedState.FAILED, + ActorState.PAUSED: WorkspaceObservedState.UNKNOWN, + ActorState.PAUSING: WorkspaceObservedState.UNKNOWN, + ActorState.REVERTING: WorkspaceObservedState.UNKNOWN, + ActorState.UNSPECIFIED: WorkspaceObservedState.UNKNOWN, +} + +BLOCKING_DELIVERY_STATES = {"recorded", "queued", "sending", "delivered", "uncertain"} + def _lock(session_id: str) -> asyncio.Lock: return _locks.setdefault(session_id, asyncio.Lock()) @@ -139,6 +168,8 @@ async def _update_observed( actor.external_snapshot_uri, last_error, ) + await ensure_workspace_lifecycle(session_id) + await _record_observation(session_id, actor=actor, binding_error=last_error) async def _mark_missing(session_id: str, now: datetime) -> None: @@ -210,6 +241,8 @@ async def ensure_workspace( if action == "create": actor = await control.create_actor(atespace, name, template=template) await _insert_row(session_id, atespace, name, template, actor, now) + await ensure_workspace_lifecycle(session_id) + await _record_observation(session_id, actor=actor) else: await _update_observed(session_id, actor, now) return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] @@ -238,32 +271,18 @@ async def observe_workspace( async def resume_workspace( session_id: str, *, control: SubstrateControl | None = None -) -> WorkspaceBinding: - control = control or _control() - async with _lock(session_id): - row = await get_workspace(session_id) - if row is None: - raise ContractError(f"no workspace binding for session {session_id}") - actor = await control.resume_actor(row["atespace"], row["actor_name"]) - now = datetime.now(UTC) - await _update_observed(session_id, actor, now) - await _bump_generation(session_id, row["ownership_generation"]) - return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] +) -> WorkspaceLifecycle: + return await _request_workspace_state( + session_id, WorkspaceDesiredState.RUNNING, control=control + ) async def suspend_workspace( session_id: str, *, control: SubstrateControl | None = None -) -> WorkspaceBinding: - control = control or _control() - async with _lock(session_id): - row = await get_workspace(session_id) - if row is None: - raise ContractError(f"no workspace binding for session {session_id}") - actor = await control.suspend_actor(row["atespace"], row["actor_name"]) - now = datetime.now(UTC) - await _update_observed(session_id, actor, now) - await _bump_generation(session_id, row["ownership_generation"]) - return _binding_from_row(await get_workspace(session_id)) # type: ignore[arg-type] +) -> WorkspaceLifecycle: + return await _request_workspace_state( + session_id, WorkspaceDesiredState.SUSPENDED, control=control + ) async def revert_workspace( @@ -300,3 +319,886 @@ def is_crashed(binding_row: dict) -> bool: and binding_row.get("last_error") is not None and "CRASHED" in (binding_row.get("last_error") or "").upper() ) + + +def lifecycle_state(actor_state: ActorState) -> WorkspaceObservedState: + """Project Substrate actor status without treating unknown states as healthy.""" + return LIFECYCLE_STATE[actor_state] + + +def suspend_fence_reason(delivery_states: set[str]) -> str | None: + """Return why parking is unsafe while the durable delivery ledger is open.""" + if "recorded" in delivery_states: + return "DeliveryRecorded" + if delivery_states & {"sending", "delivered"}: + return "TurnInFlight" + if "uncertain" in delivery_states: + return "DeliveryUncertain" + if "queued" in delivery_states: + return "DeliveryQueued" + return None + + +def _suspend_fence_detail(reason: str) -> str: + return { + "DeliveryRecorded": "A recorded delivery must be reconciled before the workspace can be suspended.", + "TurnInFlight": "A native turn is still in flight; wait for it to finish before suspending.", + "DeliveryUncertain": "A delivery outcome is uncertain; reconcile it before suspending.", + "DeliveryQueued": "A queued delivery must be handled before the workspace can be suspended.", + }[reason] + + +def _json_value(value): + if isinstance(value, str): + return json.loads(value) + return value + + +def _condition( + condition_type: str, + status: WorkspaceConditionStatus, + reason: str, + message: str, + at: datetime, +) -> WorkspaceCondition: + return WorkspaceCondition( + type=condition_type, + status=status, + reason=reason, + message=message, + last_transition_time=at, + ) + + +def _conditions_for( + *, + observed: WorkspaceObservedState, + desired: WorkspaceDesiredState, + snapshot_ref: str | None, + reason: str, + message: str, + at: datetime, + previous: tuple[WorkspaceCondition, ...] = (), + operation_status: WorkspaceConditionStatus | None = None, + operation_reason: str | None = None, + operation_message: str | None = None, + suspend_allowed: tuple[bool, str, str] | None = None, +) -> tuple[WorkspaceCondition, ...]: + available_status = { + WorkspaceObservedState.RUNNING: WorkspaceConditionStatus.TRUE, + WorkspaceObservedState.SUSPENDED: WorkspaceConditionStatus.FALSE, + WorkspaceObservedState.FAILED: WorkspaceConditionStatus.FALSE, + }.get(observed, WorkspaceConditionStatus.UNKNOWN) + parked_status = ( + WorkspaceConditionStatus.TRUE + if observed == WorkspaceObservedState.SUSPENDED and snapshot_ref + else ( + WorkspaceConditionStatus.FALSE + if observed + in (WorkspaceObservedState.RUNNING, WorkspaceObservedState.FAILED) + or observed == WorkspaceObservedState.SUSPENDED + else WorkspaceConditionStatus.UNKNOWN + ) + ) + desired_status = ( + WorkspaceConditionStatus.TRUE + if observed.value == desired.value + else ( + WorkspaceConditionStatus.FALSE + if observed + in (WorkspaceObservedState.RUNNING, WorkspaceObservedState.SUSPENDED) + else WorkspaceConditionStatus.UNKNOWN + ) + ) + specs = [ + ("Available", available_status, reason, message), + ( + "Parked", + parked_status, + ( + "SnapshotConfirmed" + if parked_status == WorkspaceConditionStatus.TRUE + else ( + "SnapshotReferenceMissing" + if observed == WorkspaceObservedState.SUSPENDED + else reason + ) + ), + ( + "The suspended workspace has a recorded snapshot." + if parked_status == WorkspaceConditionStatus.TRUE + else ( + "Substrate reported suspension without a snapshot reference." + if observed == WorkspaceObservedState.SUSPENDED + else message + ) + ), + ), + ( + "DesiredState", + desired_status, + ( + "DesiredStateObserved" + if desired_status == WorkspaceConditionStatus.TRUE + else reason + ), + f"Desired {desired.value}; observed {observed.value}.", + ), + ( + "ControlOperation", + operation_status + or ( + WorkspaceConditionStatus.TRUE + if observed + in (WorkspaceObservedState.RUNNING, WorkspaceObservedState.SUSPENDED) + and desired_status == WorkspaceConditionStatus.TRUE + else WorkspaceConditionStatus.UNKNOWN + ), + operation_reason or reason, + operation_message or message, + ), + ] + if suspend_allowed is not None: + allowed, allowed_reason, allowed_message = suspend_allowed + specs.append( + ( + "SuspendAllowed", + ( + WorkspaceConditionStatus.TRUE + if allowed + else WorkspaceConditionStatus.FALSE + ), + allowed_reason, + allowed_message, + ) + ) + + old = {item.type: item for item in previous} + result = [] + for condition_type, status, item_reason, item_message in specs: + prior = old.get(condition_type) + changed = ( + prior is None + or prior.status != status + or prior.reason != item_reason + or prior.message != item_message + ) + result.append( + _condition( + condition_type, + status, + item_reason, + item_message, + at if changed else prior.last_transition_time, + ) + ) + return tuple(result) + + +def _transition( + previous: WorkspaceLifecycle | None, + state: WorkspaceObservedState, + reason: str, + at: datetime, +) -> WorkspaceTransition | None: + if previous is not None and previous.observed_state == state: + return previous.last_transition + return WorkspaceTransition( + from_state=previous.observed_state if previous else None, + to_state=state, + reason=reason, + occurred_at=at, + ) + + +def _actor_reason(state: ActorState) -> tuple[str, str]: + messages = { + ActorState.RUNNING: ( + "ActorRunning", + "Substrate reports the workspace actor running.", + ), + ActorState.RESUMING: ( + "ActorResuming", + "Substrate is restoring the workspace actor.", + ), + ActorState.SUSPENDING: ( + "ActorSuspending", + "Substrate is suspending the workspace actor.", + ), + ActorState.SUSPENDED: ( + "ActorSuspended", + "Substrate reports the workspace actor suspended.", + ), + ActorState.CRASHED: ( + "ActorCrashed", + "Substrate reports the workspace actor crashed.", + ), + ActorState.DELETING: ( + "ActorDeleting", + "Substrate reports the workspace actor deleting.", + ), + ActorState.PAUSED: ( + "UnexpectedPaused", + "Substrate reported PAUSED; it is not confirmed parked.", + ), + ActorState.PAUSING: ( + "UnexpectedPausing", + "Substrate reported PAUSING; lifecycle is not confirmed.", + ), + ActorState.REVERTING: ( + "ActorReverting", + "Substrate is reverting the workspace actor.", + ), + ActorState.UNSPECIFIED: ( + "ActorStateUnknown", + "Substrate did not report a recognized actor state.", + ), + } + return messages[state] + + +def _lifecycle_from_row(row: dict) -> WorkspaceLifecycle: + payload = { + "workspace_id": row["workspace_id"], + "session_id": row["workspace_id"], + "desired_state": row["desired_state"], + "observed_state": row["observed_state"], + "manifest": _json_value(row["manifest"]), + "conditions": _json_value(row["conditions"]), + "last_transition": ( + _json_value(row["last_transition"]) if row.get("last_transition") else None + ), + "operation_id": row.get("operation_id"), + "snapshot_ref": row.get("snapshot_ref"), + "ownership_generation": row["ownership_generation"], + "updated_at": row["updated_at"], + } + return WorkspaceLifecycle.model_validate_json( + json.dumps(payload, default=lambda value: value.isoformat()) + ) + + +def _manifest_from_session(row: dict) -> WorkspaceManifest: + raw_kind = row.get("agent_kind") + agent_kinds = ( + (WorkspaceAgentKind(raw_kind),) + if raw_kind in {kind.value for kind in WorkspaceAgentKind} + else () + ) + return WorkspaceManifest( + repo_url=row.get("repo_url"), + branch=row.get("branch_name") or row.get("base_branch") or "main", + agent_kinds=agent_kinds, + skills=(), + mcp_servers=(), + egress_allowlist=(), + resource_class="default", + ) + + +async def _get_lifecycle_row(workspace_id: str) -> dict | None: + async with db.connection() as conn: + row = await conn.fetchrow( + """SELECT l.*, b.ownership_generation, b.external_snapshot_uri + FROM workspace_lifecycles l + JOIN workspace_bindings b USING (workspace_id) + WHERE l.workspace_id=$1""", + workspace_id, + ) + return dict(row) if row else None + + +async def get_workspace_lifecycle(workspace_id: str) -> WorkspaceLifecycle | None: + row = await _get_lifecycle_row(workspace_id) + return _lifecycle_from_row(row) if row else None + + +async def ensure_workspace_lifecycle(workspace_id: str) -> WorkspaceLifecycle | None: + """Backfill a declarative manifest and lifecycle record for an existing actor binding.""" + async with db.connection() as conn: + row = await conn.fetchrow( + """SELECT b.*, s.repo_url, s.branch_name, s.base_branch, n.kind AS agent_kind + FROM workspace_bindings b + JOIN sessions s ON s.id=b.workspace_id + LEFT JOIN native_bindings n ON n.session_id=b.workspace_id + WHERE b.workspace_id=$1""", + workspace_id, + ) + if row is None: + return None + binding = dict(row) + existing = await get_workspace_lifecycle(workspace_id) + if existing: + return existing + + at = datetime.now(UTC) + initial_state = ( + WorkspaceObservedState.RUNNING + if binding["observed_state"] == "ready" + else WorkspaceObservedState.UNKNOWN + ) + reason = ( + "LifecycleInitialized" + if initial_state == WorkspaceObservedState.UNKNOWN + else "ActorRunning" + ) + message = ( + "Lifecycle tracking was initialized; refresh status to inspect Substrate." + if initial_state == WorkspaceObservedState.UNKNOWN + else "Substrate previously reported the workspace ready." + ) + manifest = _manifest_from_session(binding) + conditions = _conditions_for( + observed=initial_state, + desired=WorkspaceDesiredState.RUNNING, + snapshot_ref=binding.get("external_snapshot_uri"), + reason=reason, + message=message, + at=at, + ) + async with db.connection() as conn: + await conn.execute( + """INSERT INTO workspace_lifecycles + (workspace_id, desired_state, observed_state, manifest, conditions, snapshot_ref, + updated_at) + VALUES ($1,$2,$3,$4::jsonb,$5::jsonb,$6,$7) + ON CONFLICT (workspace_id) DO NOTHING""", + workspace_id, + WorkspaceDesiredState.RUNNING.value, + initial_state.value, + json.dumps(manifest.model_dump(mode="json")), + json.dumps([condition.model_dump(mode="json") for condition in conditions]), + binding.get("external_snapshot_uri"), + at, + ) + return await get_workspace_lifecycle(workspace_id) + + +async def list_workspace_lifecycles(user_id: str) -> list[WorkspaceLifecycle]: + async with db.connection() as conn: + rows = await conn.fetch( + """SELECT b.workspace_id FROM workspace_bindings b + JOIN sessions s ON s.id=b.workspace_id + WHERE s.user_id=$1 ORDER BY s.created_at DESC""", + user_id, + ) + for row in rows: + await ensure_workspace_lifecycle(row["workspace_id"]) + async with db.connection() as conn: + lifecycle_rows = await conn.fetch( + """SELECT l.*, b.ownership_generation, b.external_snapshot_uri + FROM workspace_lifecycles l + JOIN workspace_bindings b USING (workspace_id) + JOIN sessions s ON s.id=b.workspace_id + WHERE s.user_id=$1 ORDER BY s.created_at DESC""", + user_id, + ) + return [_lifecycle_from_row(dict(row)) for row in lifecycle_rows] + + +async def _save_lifecycle(conn, lifecycle: WorkspaceLifecycle) -> None: + await conn.execute( + """UPDATE workspace_lifecycles + SET desired_state=$2, observed_state=$3, conditions=$4::jsonb, + last_transition=$5::jsonb, operation_id=$6, snapshot_ref=$7, updated_at=$8 + WHERE workspace_id=$1""", + lifecycle.workspace_id, + lifecycle.desired_state.value, + lifecycle.observed_state.value, + json.dumps([item.model_dump(mode="json") for item in lifecycle.conditions]), + ( + json.dumps(lifecycle.last_transition.model_dump(mode="json")) + if lifecycle.last_transition + else None + ), + lifecycle.operation_id, + lifecycle.snapshot_ref, + lifecycle.updated_at, + ) + + +async def _record_observation( + workspace_id: str, + *, + actor: ActorRecord | None = None, + failure: tuple[WorkspaceObservedState, str, str] | None = None, + binding_error: str | None = None, + desired_state: WorkspaceDesiredState | None = None, + operation_status: WorkspaceConditionStatus | None = None, + operation_reason: str | None = None, + operation_message: str | None = None, + suspend_allowed: tuple[bool, str, str] | None = None, +) -> WorkspaceLifecycle: + previous = await ensure_workspace_lifecycle(workspace_id) + if previous is None: + raise ContractError(f"no workspace binding for {workspace_id}") + at = datetime.now(UTC) + if actor is not None: + state = lifecycle_state(actor.state) + reason, message = _actor_reason(actor.state) + snapshot_ref = ( + actor.external_snapshot_uri + if state == WorkspaceObservedState.SUSPENDED + else actor.external_snapshot_uri or previous.snapshot_ref + ) + observed_state_text = OBSERVED_STATE[actor.state] + last_error = ( + binding_error + if binding_error is not None + else CRASHED_NOTE if actor.state == ActorState.CRASHED else None + ) + if actor.state in (ActorState.PAUSED, ActorState.DELETING): + last_error = reason + async with db.connection() as conn: + await conn.execute( + """UPDATE workspace_bindings + SET observed_state=$2, observed_at=$3, external_snapshot_uri=$4, + last_error=$5, updated_at=NOW() WHERE workspace_id=$1""", + workspace_id, + observed_state_text, + at, + actor.external_snapshot_uri, + last_error, + ) + else: + if failure is None: + raise ValueError("actor or failure observation is required") + state, reason, message = failure + snapshot_ref = previous.snapshot_ref + + desired = desired_state or previous.desired_state + stable = state in ( + WorkspaceObservedState.RUNNING, + WorkspaceObservedState.SUSPENDED, + WorkspaceObservedState.FAILED, + ) + operation_id = None if stable else previous.operation_id + conditions = _conditions_for( + observed=state, + desired=desired, + snapshot_ref=snapshot_ref, + reason=reason, + message=message, + at=at, + previous=previous.conditions, + operation_status=operation_status, + operation_reason=operation_reason, + operation_message=operation_message, + suspend_allowed=suspend_allowed, + ) + updated = WorkspaceLifecycle( + workspace_id=workspace_id, + session_id=workspace_id, + desired_state=desired, + observed_state=state, + manifest=previous.manifest, + conditions=conditions, + last_transition=_transition(previous, state, reason, at), + operation_id=operation_id, + snapshot_ref=snapshot_ref, + ownership_generation=previous.ownership_generation, + updated_at=at, + ) + async with db.connection() as conn: + await _save_lifecycle(conn, updated) + return await get_workspace_lifecycle(workspace_id) or updated + + +async def refresh_workspace_lifecycle( + workspace_id: str, *, control: SubstrateControl | None = None +) -> WorkspaceLifecycle: + """Observe the actor once; transport errors remain unknown until a later refresh.""" + control = control or _control() + async with _lock(workspace_id): + row = await get_workspace(workspace_id) + if row is None: + raise ContractError(f"no workspace binding for {workspace_id}") + await ensure_workspace_lifecycle(workspace_id) + try: + actor = await control.get_actor(row["atespace"], row["actor_name"]) + except Exception: + return await _record_observation( + workspace_id, + failure=( + WorkspaceObservedState.UNKNOWN, + "ObservationUncertain", + "Substrate could not confirm workspace status. Refresh again before retrying an operation.", + ), + ) + if actor is None: + return await _record_observation( + workspace_id, + failure=( + WorkspaceObservedState.FAILED, + "ActorMissing", + "The workspace binding exists but Substrate returned no actor.", + ), + ) + return await _record_observation(workspace_id, actor=actor) + + +async def _record_operation_failure( + workspace_id: str, + *, + reason: str, + message: str, + uncertain: bool, +) -> WorkspaceLifecycle: + previous = await ensure_workspace_lifecycle(workspace_id) + if previous is None: + raise ContractError(f"no workspace binding for {workspace_id}") + at = datetime.now(UTC) + state = WorkspaceObservedState.UNKNOWN if uncertain else previous.observed_state + conditions = _conditions_for( + observed=state, + desired=previous.desired_state, + snapshot_ref=previous.snapshot_ref, + reason=reason, + message=message, + at=at, + previous=previous.conditions, + operation_status=( + WorkspaceConditionStatus.UNKNOWN + if uncertain + else WorkspaceConditionStatus.FALSE + ), + operation_reason=reason, + operation_message=message, + ) + updated = WorkspaceLifecycle( + workspace_id=previous.workspace_id, + session_id=previous.session_id, + desired_state=previous.desired_state, + observed_state=state, + manifest=previous.manifest, + conditions=conditions, + last_transition=_transition(previous, state, reason, at), + operation_id=previous.operation_id if uncertain else None, + snapshot_ref=previous.snapshot_ref, + ownership_generation=previous.ownership_generation, + updated_at=at, + ) + async with db.connection() as conn: + await _save_lifecycle(conn, updated) + return await get_workspace_lifecycle(workspace_id) or updated + + +async def _delivery_states(workspace_id: str, *, conn=None) -> set[str]: + if conn is None: + async with db.connection() as connection: + rows = await connection.fetch( + """SELECT state FROM native_deliveries + WHERE session_id=$1 AND state = ANY($2)""", + workspace_id, + list(BLOCKING_DELIVERY_STATES), + ) + else: + rows = await conn.fetch( + """SELECT state FROM native_deliveries + WHERE session_id=$1 AND state = ANY($2)""", + workspace_id, + list(BLOCKING_DELIVERY_STATES), + ) + return {row["state"] for row in rows} + + +async def _record_suspend_fence( + workspace_id: str, + reason: str, + message: str, + *, + previous: WorkspaceLifecycle | None = None, + conn=None, +) -> WorkspaceLifecycle: + previous = previous or await ensure_workspace_lifecycle(workspace_id) + if previous is None: + raise ContractError(f"no workspace binding for {workspace_id}") + at = datetime.now(UTC) + conditions = _conditions_for( + observed=previous.observed_state, + desired=previous.desired_state, + snapshot_ref=previous.snapshot_ref, + reason=reason, + message=message, + at=at, + previous=previous.conditions, + operation_status=WorkspaceConditionStatus.FALSE, + operation_reason=reason, + operation_message=message, + suspend_allowed=(False, reason, message), + ) + updated = WorkspaceLifecycle( + **{ + **previous.model_dump(), + "conditions": conditions, + "updated_at": at, + } + ) + if conn is None: + async with db.connection() as connection: + await _save_lifecycle(connection, updated) + else: + await _save_lifecycle(conn, updated) + if conn is not None: + return updated + return await get_workspace_lifecycle(workspace_id) or updated + + +async def _reserve_operation( + previous: WorkspaceLifecycle, + desired_state: WorkspaceDesiredState, +) -> WorkspaceLifecycle: + at = datetime.now(UTC) + transitional = ( + WorkspaceObservedState.SUSPENDING + if desired_state == WorkspaceDesiredState.SUSPENDED + else WorkspaceObservedState.RESUMING + ) + operation_id = str(uuid.uuid4()) + reason = ( + "SuspendRequested" + if desired_state == WorkspaceDesiredState.SUSPENDED + else "ResumeRequested" + ) + message = ( + f"Mainloop recorded a request to make the workspace {desired_state.value}." + ) + conditions = _conditions_for( + observed=transitional, + desired=desired_state, + snapshot_ref=previous.snapshot_ref, + reason=reason, + message=message, + at=at, + previous=previous.conditions, + operation_status=WorkspaceConditionStatus.UNKNOWN, + operation_reason=reason, + operation_message=message, + suspend_allowed=( + ( + True, + "NoOpenDelivery", + "No open or uncertain delivery blocks suspension.", + ) + if desired_state == WorkspaceDesiredState.SUSPENDED + else None + ), + ) + updated = WorkspaceLifecycle( + workspace_id=previous.workspace_id, + session_id=previous.session_id, + desired_state=desired_state, + observed_state=transitional, + manifest=previous.manifest, + conditions=conditions, + last_transition=_transition(previous, transitional, reason, at), + operation_id=operation_id, + snapshot_ref=previous.snapshot_ref, + ownership_generation=previous.ownership_generation, + updated_at=at, + ) + fence_detail: str | None = None + async with db.connection() as conn: + async with conn.transaction(): + binding = await conn.fetchrow( + """SELECT ownership_generation FROM workspace_bindings + WHERE workspace_id=$1 FOR UPDATE""", + previous.workspace_id, + ) + if binding is None: + raise ContractError(f"no workspace binding for {previous.workspace_id}") + if binding["ownership_generation"] != previous.ownership_generation: + raise StaleOwnership( + f"workspace {previous.workspace_id} changed during lifecycle request" + ) + if desired_state == WorkspaceDesiredState.SUSPENDED: + fence_reason = suspend_fence_reason( + await _delivery_states(previous.workspace_id, conn=conn) + ) + if fence_reason: + fence_detail = _suspend_fence_detail(fence_reason) + await _record_suspend_fence( + previous.workspace_id, + fence_reason, + fence_detail, + previous=previous, + conn=conn, + ) + if fence_detail is None: + tag = await conn.execute( + """UPDATE workspace_bindings + SET ownership_generation=ownership_generation+1, updated_at=NOW() + WHERE workspace_id=$1 AND ownership_generation=$2""", + previous.workspace_id, + previous.ownership_generation, + ) + if tag.endswith(" 0"): + raise StaleOwnership( + f"workspace {previous.workspace_id} changed during lifecycle request" + ) + await _save_lifecycle(conn, updated) + if fence_detail is not None: + raise ContractError(fence_detail) + return await get_workspace_lifecycle(previous.workspace_id) or updated + + +async def _request_workspace_state( + workspace_id: str, + desired_state: WorkspaceDesiredState, + *, + control: SubstrateControl | None = None, +) -> WorkspaceLifecycle: + control = control or _control() + async with _lock(workspace_id): + previous = await ensure_workspace_lifecycle(workspace_id) + row = await get_workspace(workspace_id) + if previous is None or row is None: + raise ContractError(f"no workspace binding for {workspace_id}") + if ( + previous.desired_state == desired_state + and previous.observed_state.value == desired_state.value + and previous.operation_id is None + ): + return previous + + # A pending operation means a previous control call may have timed out. Its original + # intent is already durable; inspect that actor before deciding whether a new operation + # can be recorded. First attempts persist intent before the first Substrate call. + had_pending_operation = previous.operation_id is not None + if not had_pending_operation: + previous = await _reserve_operation(previous, desired_state) + + try: + actor = await control.get_actor(row["atespace"], row["actor_name"]) + except Exception: + return await _record_operation_failure( + workspace_id, + reason="ObservationUncertain", + message="Substrate status is unknown. Refresh status before retrying the operation.", + uncertain=True, + ) + if actor is None: + return await _record_observation( + workspace_id, + failure=( + WorkspaceObservedState.FAILED, + "ActorMissing", + "The workspace binding exists but Substrate returned no actor.", + ), + desired_state=desired_state, + ) + + observed = lifecycle_state(actor.state) + if observed.value == desired_state.value: + if had_pending_operation and previous.desired_state != desired_state: + # Finish reconciling the old intent, then persist the new request. The observed + # actor already matches it, so no second control mutation is needed. + previous = await _record_observation( + workspace_id, actor=actor, desired_state=previous.desired_state + ) + await _reserve_operation(previous, desired_state) + return await _record_observation( + workspace_id, actor=actor, desired_state=desired_state + ) + if observed in ( + WorkspaceObservedState.SUSPENDING, + WorkspaceObservedState.RESUMING, + WorkspaceObservedState.UNKNOWN, + WorkspaceObservedState.FAILED, + ): + reason, message = _actor_reason(actor.state) + return await _record_observation( + workspace_id, + actor=actor, + operation_status=WorkspaceConditionStatus.UNKNOWN, + operation_reason=reason, + operation_message=message, + desired_state=( + previous.desired_state + if had_pending_operation and previous.desired_state != desired_state + else desired_state + ), + ) + + if had_pending_operation: + # The inspected actor is stable in the opposite state. Retire the uncertain + # operation record before persisting a fresh attempt; this is the required + # inspect-before-retry fence. + previous = await _record_observation( + workspace_id, actor=actor, desired_state=previous.desired_state + ) + previous = await _reserve_operation(previous, desired_state) + + if desired_state == WorkspaceDesiredState.SUSPENDED: + fence_reason = suspend_fence_reason(await _delivery_states(workspace_id)) + if fence_reason: + detail = _suspend_fence_detail(fence_reason) + await _record_observation( + workspace_id, + actor=actor, + desired_state=desired_state, + operation_status=WorkspaceConditionStatus.FALSE, + operation_reason=fence_reason, + operation_message=detail, + suspend_allowed=(False, fence_reason, detail), + ) + raise ContractError(detail) + + try: + if desired_state == WorkspaceDesiredState.SUSPENDED: + actor = await control.suspend_actor(row["atespace"], row["actor_name"]) + else: + actor = await control.resume_actor(row["atespace"], row["actor_name"]) + except TransportError: + return await _record_operation_failure( + workspace_id, + reason="OperationOutcomeUnknown", + message="Substrate did not confirm the operation. Refresh status before retrying; Mainloop will not replay it automatically.", + uncertain=True, + ) + except RuntimeError: + return await _record_operation_failure( + workspace_id, + reason="OperationRejected", + message="Substrate rejected the lifecycle request. Refresh status before retrying.", + uncertain=False, + ) + except Exception: + return await _record_operation_failure( + workspace_id, + reason="OperationOutcomeUnknown", + message="Substrate did not confirm the operation. Refresh status before retrying; Mainloop will not replay it automatically.", + uncertain=True, + ) + observed = lifecycle_state(actor.state) + if observed.value == desired_state.value and ( + desired_state != WorkspaceDesiredState.SUSPENDED + or actor.external_snapshot_uri is not None + ): + operation_status = WorkspaceConditionStatus.TRUE + operation_reason = "OperationConfirmed" + operation_message = ( + f"Substrate confirmed the workspace {desired_state.value}." + ) + elif observed in ( + WorkspaceObservedState.RUNNING, + WorkspaceObservedState.SUSPENDED, + WorkspaceObservedState.FAILED, + ): + operation_status = WorkspaceConditionStatus.FALSE + operation_reason = "DesiredStateNotReached" + operation_message = f"Substrate observed {observed.value}; desired {desired_state.value} was not confirmed." + else: + operation_status = WorkspaceConditionStatus.UNKNOWN + operation_reason = "ActorTransitionInProgress" + operation_message = ( + "Substrate has not reached a stable state; refresh status to reconcile." + ) + return await _record_observation( + workspace_id, + actor=actor, + desired_state=desired_state, + operation_status=operation_status, + operation_reason=operation_reason, + operation_message=operation_message, + ) diff --git a/backend/src/mainloop/runtime/workspace_api.py b/backend/src/mainloop/runtime/workspace_api.py new file mode 100644 index 0000000..9cec084 --- /dev/null +++ b/backend/src/mainloop/runtime/workspace_api.py @@ -0,0 +1,101 @@ +"""Workspace lifecycle endpoints for manifest and actor state.""" + +from fastapi import APIRouter, Header, HTTPException +from mainloop.db import db +from mainloop.runtime import workspace_adapter +from mainloop.runtime.contracts import ContractError +from mainloop.sse import notify_workspace_updated + +from models import WorkspaceLifecycle + +router = APIRouter(prefix="/workspaces", tags=["workspaces"]) + + +def _user_id(value: str | None) -> str: + return value or "local-dev-user" + + +async def _require_owned_workspace(workspace_id: str, user_id: str) -> None: + async with db.connection() as conn: + exists = await conn.fetchval( + """SELECT EXISTS ( + SELECT 1 FROM workspace_bindings b + JOIN sessions s ON s.id=b.workspace_id + WHERE b.workspace_id=$1 AND s.user_id=$2 + )""", + workspace_id, + user_id, + ) + if not exists: + raise HTTPException(status_code=404, detail="Workspace not found") + + +async def _publish(user_id: str, lifecycle: WorkspaceLifecycle) -> None: + await notify_workspace_updated(user_id, lifecycle.model_dump(mode="json")) + + +@router.get("", response_model=list[WorkspaceLifecycle]) +async def list_workspaces( + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + return await workspace_adapter.list_workspace_lifecycles(_user_id(user_id)) + + +@router.get("/{workspace_id}", response_model=WorkspaceLifecycle) +async def get_workspace( + workspace_id: str, + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + owner = _user_id(user_id) + await _require_owned_workspace(workspace_id, owner) + lifecycle = await workspace_adapter.ensure_workspace_lifecycle(workspace_id) + if lifecycle is None: + raise HTTPException(status_code=404, detail="Workspace not found") + return lifecycle + + +async def _run_operation( + workspace_id: str, + owner: str, + operation, +) -> WorkspaceLifecycle: + await _require_owned_workspace(workspace_id, owner) + try: + lifecycle = await operation(workspace_id) + except ContractError as exc: + current = await workspace_adapter.get_workspace_lifecycle(workspace_id) + if current is not None: + await _publish(owner, current) + raise HTTPException(status_code=409, detail=str(exc)) from exc + await _publish(owner, lifecycle) + return lifecycle + + +@router.post("/{workspace_id}/suspend", response_model=WorkspaceLifecycle) +async def suspend_workspace( + workspace_id: str, + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + return await _run_operation( + workspace_id, _user_id(user_id), workspace_adapter.suspend_workspace + ) + + +@router.post("/{workspace_id}/resume", response_model=WorkspaceLifecycle) +async def resume_workspace( + workspace_id: str, + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + return await _run_operation( + workspace_id, _user_id(user_id), workspace_adapter.resume_workspace + ) + + +@router.post("/{workspace_id}/refresh", response_model=WorkspaceLifecycle) +async def refresh_workspace( + workspace_id: str, + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + return await _run_operation( + workspace_id, _user_id(user_id), workspace_adapter.refresh_workspace_lifecycle + ) diff --git a/backend/src/mainloop/sse.py b/backend/src/mainloop/sse.py index 4098773..7571a97 100644 --- a/backend/src/mainloop/sse.py +++ b/backend/src/mainloop/sse.py @@ -23,6 +23,7 @@ class EventType(str, Enum): SESSION_UPDATED = "session:updated" SESSION_NEEDS_INPUT = "session:needs_input" SESSION_MESSAGE = "session:message" + WORKSPACE_UPDATED = "workspace:updated" HEARTBEAT = "heartbeat" @@ -212,3 +213,14 @@ async def notify_session_message( }, ), ) + + +async def notify_workspace_updated(user_id: str, lifecycle: dict[str, Any]) -> None: + """Publish the current durable workspace lifecycle projection.""" + await event_bus.publish_to_user( + user_id, + SSEEvent( + event=EventType.WORKSPACE_UPDATED, + data={"workspace": lifecycle}, + ), + ) diff --git a/backend/tests/runtime/test_workspace_api.py b/backend/tests/runtime/test_workspace_api.py new file mode 100644 index 0000000..1c1d834 --- /dev/null +++ b/backend/tests/runtime/test_workspace_api.py @@ -0,0 +1,150 @@ +"""Workspace API projection and ownership behavior with adapter fakes.""" + +import unittest +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException +from mainloop.runtime import workspace_api +from mainloop.runtime.contracts import ContractError + +from models import ( + WorkspaceAgentKind, + WorkspaceDesiredState, + WorkspaceLifecycle, + WorkspaceManifest, + WorkspaceObservedState, +) + + +def lifecycle() -> WorkspaceLifecycle: + return WorkspaceLifecycle( + workspace_id="session-1", + session_id="session-1", + desired_state=WorkspaceDesiredState.RUNNING, + observed_state=WorkspaceObservedState.RUNNING, + manifest=WorkspaceManifest( + repo_url="https://github.com/example/repo", + branch="main", + agent_kinds=(WorkspaceAgentKind.CODEX,), + skills=("skill://review",), + mcp_servers=("mcp://docs",), + egress_allowlist=("github.com",), + resource_class="small", + ), + ownership_generation=4, + updated_at=datetime(2026, 9, 24, tzinfo=UTC), + ) + + +class WorkspaceApiTests(unittest.IsolatedAsyncioTestCase): + def test_routes_expose_the_workspace_lifecycle_api(self): + route_methods = { + (route.path, method) + for route in workspace_api.router.routes + for method in route.methods or () + } + self.assertTrue( + { + ("/workspaces", "GET"), + ("/workspaces/{workspace_id}", "GET"), + ("/workspaces/{workspace_id}/suspend", "POST"), + ("/workspaces/{workspace_id}/resume", "POST"), + ("/workspaces/{workspace_id}/refresh", "POST"), + }.issubset(route_methods) + ) + + async def test_list_returns_lifecycle_and_manifest(self): + with patch.object( + workspace_api.workspace_adapter, + "list_workspace_lifecycles", + new=AsyncMock(return_value=[lifecycle()]), + ) as list_workspaces: + response = await workspace_api.list_workspaces(user_id="owner-1") + + self.assertEqual(response[0].observed_state, WorkspaceObservedState.RUNNING) + self.assertEqual(response[0].manifest.agent_kinds, (WorkspaceAgentKind.CODEX,)) + list_workspaces.assert_awaited_once_with("owner-1") + + async def test_get_detail_and_lifecycle_actions_publish_the_lifecycle(self): + current = lifecycle() + with ( + patch.object(workspace_api, "_require_owned_workspace", new=AsyncMock()), + patch.object( + workspace_api.workspace_adapter, + "ensure_workspace_lifecycle", + new=AsyncMock(return_value=current), + ), + patch.object( + workspace_api.workspace_adapter, + "suspend_workspace", + new=AsyncMock(return_value=current), + ), + patch.object( + workspace_api.workspace_adapter, + "resume_workspace", + new=AsyncMock(return_value=current), + ), + patch.object( + workspace_api.workspace_adapter, + "refresh_workspace_lifecycle", + new=AsyncMock(return_value=current), + ), + patch.object(workspace_api, "_publish", new=AsyncMock()) as publish, + ): + detail = await workspace_api.get_workspace("session-1", user_id="owner-1") + suspended = await workspace_api.suspend_workspace( + "session-1", user_id="owner-1" + ) + resumed = await workspace_api.resume_workspace( + "session-1", user_id="owner-1" + ) + refreshed = await workspace_api.refresh_workspace( + "session-1", user_id="owner-1" + ) + + self.assertEqual(detail.workspace_id, "session-1") + self.assertEqual(suspended.workspace_id, "session-1") + self.assertEqual(resumed.workspace_id, "session-1") + self.assertEqual(refreshed.workspace_id, "session-1") + self.assertEqual(suspended.manifest.resource_class, "small") + self.assertEqual(publish.await_count, 3) + + async def test_fence_error_is_a_conflict_and_preserves_reason(self): + current = lifecycle() + with ( + patch.object(workspace_api, "_require_owned_workspace", new=AsyncMock()), + patch.object( + workspace_api.workspace_adapter, + "suspend_workspace", + new=AsyncMock( + side_effect=ContractError("A recorded delivery is still open.") + ), + ), + patch.object( + workspace_api.workspace_adapter, + "get_workspace_lifecycle", + new=AsyncMock(return_value=current), + ), + patch.object(workspace_api, "_publish", new=AsyncMock()), + ): + with self.assertRaises(HTTPException) as raised: + await workspace_api.suspend_workspace("session-1", user_id="owner-1") + + self.assertEqual(raised.exception.status_code, 409) + self.assertEqual(raised.exception.detail, "A recorded delivery is still open.") + + async def test_owner_check_hides_other_users_workspaces(self): + with patch.object( + workspace_api, + "_require_owned_workspace", + new=AsyncMock(side_effect=HTTPException(404, "Workspace not found")), + ): + with self.assertRaises(HTTPException) as raised: + await workspace_api.get_workspace("session-1", user_id="other") + + self.assertEqual(raised.exception.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_workspace_lifecycle.py b/backend/tests/runtime/test_workspace_lifecycle.py new file mode 100644 index 0000000..fea49fe --- /dev/null +++ b/backend/tests/runtime/test_workspace_lifecycle.py @@ -0,0 +1,364 @@ +"""Workspace lifecycle policy and orchestration tests; no Substrate cluster is contacted.""" + +import asyncio +import unittest +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from mainloop.runtime import workspace_adapter as adapter +from mainloop.runtime.contracts import ContractError +from mainloop.runtime.substrate import ActorRecord, ActorState, TransportError + +from models import ( + WorkspaceDesiredState, + WorkspaceLifecycle, + WorkspaceManifest, + WorkspaceObservedState, +) + +NOW = datetime(2026, 9, 24, tzinfo=UTC) + + +def lifecycle( + *, + desired: WorkspaceDesiredState = WorkspaceDesiredState.RUNNING, + observed: WorkspaceObservedState = WorkspaceObservedState.RUNNING, + operation_id: str | None = None, +) -> WorkspaceLifecycle: + return WorkspaceLifecycle( + workspace_id="session-1", + session_id="session-1", + desired_state=desired, + observed_state=observed, + manifest=WorkspaceManifest( + repo_url="https://github.com/example/repo", + branch="main", + resource_class="default", + ), + operation_id=operation_id, + ownership_generation=3, + updated_at=NOW, + ) + + +def actor(state: ActorState, snapshot: str | None = None) -> ActorRecord: + return ActorRecord( + atespace="workspaces", + name="actor-1", + uid="uid-1", + state=state, + external_snapshot_uri=snapshot, + current_actor_template_uid="template-1", + raw={}, + ) + + +class FakeTransaction: + def __init__(self, connection): + self.connection = connection + + async def __aenter__(self): + self.connection.in_transaction = True + self.connection.events.append("begin") + return self.connection + + async def __aexit__(self, exc_type, exc, traceback): + self.connection.events.append("rollback" if exc_type else "commit") + self.connection.in_transaction = False + + +class FakePostgresConnection: + def __init__(self, delivery_states=()): + self.delivery_states = delivery_states + self.events = [] + self.in_transaction = False + self.lock_query = None + + def transaction(self): + return FakeTransaction(self) + + async def fetchrow(self, query, *_args): + if not self.in_transaction: + raise AssertionError( + "workspace binding lock must be inside the transaction" + ) + self.events.append("lock") + self.lock_query = query + return {"ownership_generation": 3} + + async def fetch(self, query, *_args): + if not self.in_transaction: + raise AssertionError("delivery fence must be inside the transaction") + if "native_deliveries" not in query: + raise AssertionError("expected native delivery state query") + self.events.append("delivery-check") + return [{"state": state} for state in self.delivery_states] + + async def execute(self, query, *_args): + if not self.in_transaction: + raise AssertionError("workspace reservation must be inside the transaction") + if "UPDATE workspace_bindings" in query: + self.events.append("reserve") + elif "UPDATE workspace_lifecycles" in query: + self.events.append("lifecycle") + return "UPDATE 1" + + +def fake_db_connection(connection): + @asynccontextmanager + async def connect(): + yield connection + + return connect + + +class WorkspaceStateMappingTests(unittest.TestCase): + def test_actor_states_map_without_guessing_transitions(self): + expected = { + ActorState.RUNNING: WorkspaceObservedState.RUNNING, + ActorState.SUSPENDING: WorkspaceObservedState.SUSPENDING, + ActorState.SUSPENDED: WorkspaceObservedState.SUSPENDED, + ActorState.RESUMING: WorkspaceObservedState.RESUMING, + ActorState.CRASHED: WorkspaceObservedState.FAILED, + ActorState.DELETING: WorkspaceObservedState.FAILED, + ActorState.PAUSED: WorkspaceObservedState.UNKNOWN, + ActorState.PAUSING: WorkspaceObservedState.UNKNOWN, + ActorState.REVERTING: WorkspaceObservedState.UNKNOWN, + ActorState.UNSPECIFIED: WorkspaceObservedState.UNKNOWN, + } + for actor_state, workspace_state in expected.items(): + with self.subTest(actor_state=actor_state): + self.assertEqual(adapter.lifecycle_state(actor_state), workspace_state) + + def test_suspend_fence_blocks_open_and_uncertain_deliveries(self): + self.assertIsNone(adapter.suspend_fence_reason(set())) + self.assertEqual(adapter.suspend_fence_reason({"recorded"}), "DeliveryRecorded") + self.assertEqual(adapter.suspend_fence_reason({"sending"}), "TurnInFlight") + self.assertEqual(adapter.suspend_fence_reason({"delivered"}), "TurnInFlight") + self.assertEqual( + adapter.suspend_fence_reason({"uncertain"}), "DeliveryUncertain" + ) + self.assertEqual(adapter.suspend_fence_reason({"queued"}), "DeliveryQueued") + + +class WorkspaceOperationTests(unittest.IsolatedAsyncioTestCase): + async def test_resume_is_idempotent_when_actor_is_already_running(self): + stored = lifecycle( + desired=WorkspaceDesiredState.SUSPENDED, + observed=WorkspaceObservedState.SUSPENDED, + ) + resumed = lifecycle() + control = AsyncMock() + control.get_actor.return_value = actor(ActorState.RUNNING) + + with ( + patch.object(adapter, "_lock", return_value=asyncio.Lock()), + patch.object( + adapter, + "ensure_workspace_lifecycle", + new=AsyncMock(return_value=stored), + ), + patch.object( + adapter, + "get_workspace", + new=AsyncMock( + return_value={ + "atespace": "workspaces", + "actor_name": "actor-1", + } + ), + ), + patch.object( + adapter, "_reserve_operation", new=AsyncMock(return_value=stored) + ), + patch.object( + adapter, "_record_observation", new=AsyncMock(return_value=resumed) + ), + ): + result = await adapter.resume_workspace("session-1", control=control) + + self.assertEqual(result.observed_state, WorkspaceObservedState.RUNNING) + control.get_actor.assert_awaited_once_with("workspaces", "actor-1") + control.resume_actor.assert_not_awaited() + + async def test_recorded_delivery_refuses_suspend_before_control_call(self): + stored = lifecycle() + control = AsyncMock() + connection = FakePostgresConnection({"recorded"}) + with ( + patch.object(adapter, "_lock", return_value=asyncio.Lock()), + patch.object(adapter.db, "connection", new=fake_db_connection(connection)), + patch.object( + adapter, + "ensure_workspace_lifecycle", + new=AsyncMock(return_value=stored), + ), + patch.object( + adapter, + "get_workspace", + new=AsyncMock( + return_value={ + "atespace": "workspaces", + "actor_name": "actor-1", + } + ), + ), + ): + with self.assertRaisesRegex(ContractError, "recorded delivery"): + await adapter.suspend_workspace("session-1", control=control) + + self.assertIn("FOR UPDATE", connection.lock_query) + self.assertEqual( + connection.events, + ["begin", "lock", "delivery-check", "lifecycle", "commit"], + ) + control.get_actor.assert_not_awaited() + control.suspend_actor.assert_not_awaited() + + async def test_reservation_and_delivery_fence_share_the_locked_transaction(self): + connection = FakePostgresConnection() + with ( + patch.object(adapter.db, "connection", new=fake_db_connection(connection)), + patch.object( + adapter, + "get_workspace_lifecycle", + new=AsyncMock(return_value=None), + ), + ): + result = await adapter._reserve_operation( + lifecycle(), WorkspaceDesiredState.SUSPENDED + ) + + self.assertEqual(result.desired_state, WorkspaceDesiredState.SUSPENDED) + self.assertIn("FOR UPDATE", connection.lock_query) + self.assertEqual( + connection.events, + ["begin", "lock", "delivery-check", "reserve", "lifecycle", "commit"], + ) + + async def test_delivery_recorded_after_reservation_blocks_suspend_call(self): + stored = lifecycle() + reserved = lifecycle( + desired=WorkspaceDesiredState.SUSPENDED, + observed=WorkspaceObservedState.SUSPENDING, + operation_id="op-1", + ) + control = AsyncMock() + events = [] + delivery_recorded = False + + async def reserve_operation(_previous, _desired_state): + nonlocal delivery_recorded + events.append("reserved") + delivery_recorded = True + return reserved + + async def inspect_actor(*_args): + events.append("actor-inspected") + return actor(ActorState.RUNNING) + + async def delivery_states(_workspace_id, **_kwargs): + events.append("pre-suspend-fence") + return {"recorded"} if delivery_recorded else set() + + control.get_actor.side_effect = inspect_actor + record_observation = AsyncMock(return_value=stored) + with ( + patch.object(adapter, "_lock", return_value=asyncio.Lock()), + patch.object( + adapter, + "ensure_workspace_lifecycle", + new=AsyncMock(return_value=stored), + ), + patch.object( + adapter, + "get_workspace", + new=AsyncMock( + return_value={ + "atespace": "workspaces", + "actor_name": "actor-1", + } + ), + ), + patch.object( + adapter, + "_reserve_operation", + new=AsyncMock(side_effect=reserve_operation), + ), + patch.object( + adapter, + "_delivery_states", + new=AsyncMock(side_effect=delivery_states), + ), + patch.object(adapter, "_record_observation", new=record_observation), + ): + with self.assertRaisesRegex(ContractError, "recorded delivery"): + await adapter.suspend_workspace("session-1", control=control) + + self.assertEqual( + events, + ["reserved", "actor-inspected", "pre-suspend-fence"], + ) + self.assertEqual( + record_observation.await_args.kwargs["operation_reason"], + "DeliveryRecorded", + ) + control.suspend_actor.assert_not_awaited() + + async def test_suspend_timeout_persists_unknown_and_keeps_operation_id(self): + stored = lifecycle() + reserved = lifecycle( + desired=WorkspaceDesiredState.SUSPENDED, + observed=WorkspaceObservedState.SUSPENDING, + operation_id="op-1", + ) + uncertain = lifecycle( + desired=WorkspaceDesiredState.SUSPENDED, + observed=WorkspaceObservedState.UNKNOWN, + operation_id="op-1", + ) + control = AsyncMock() + control.get_actor.return_value = actor(ActorState.RUNNING) + control.suspend_actor.side_effect = TransportError("timed out") + + with ( + patch.object(adapter, "_lock", return_value=asyncio.Lock()), + patch.object( + adapter, + "ensure_workspace_lifecycle", + new=AsyncMock(return_value=stored), + ), + patch.object( + adapter, + "get_workspace", + new=AsyncMock( + return_value={ + "atespace": "workspaces", + "actor_name": "actor-1", + } + ), + ), + patch.object( + adapter, "_delivery_states", new=AsyncMock(return_value=set()) + ), + patch.object( + adapter, "_reserve_operation", new=AsyncMock(return_value=reserved) + ), + patch.object( + adapter, + "_record_operation_failure", + new=AsyncMock(return_value=uncertain), + ) as record_failure, + ): + result = await adapter.suspend_workspace("session-1", control=control) + + self.assertEqual(result.observed_state, WorkspaceObservedState.UNKNOWN) + self.assertEqual(result.operation_id, "op-1") + record_failure.assert_awaited_once() + self.assertTrue(record_failure.await_args.kwargs["uncertain"]) + control.suspend_actor.assert_awaited_once_with("workspaces", "actor-1") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/specs/sessions.md b/docs/specs/sessions.md index 09e9550..a4822d4 100644 --- a/docs/specs/sessions.md +++ b/docs/specs/sessions.md @@ -16,6 +16,11 @@ When sessions exist: - Each session shows title and status badge - Active count shown in header (e.g., "2 active") - Clicking a session opens its detail view +- Sessions with a workspace show a separate lifecycle badge; it does not change the session status +- Workspace details and controls are available from the session detail view + +Workspace states are specified in [Workspaces](workspaces.md). A parked workspace can still have +an active, completed, or waiting session status; these are separate records. ## Status Badges diff --git a/docs/specs/workspaces.md b/docs/specs/workspaces.md new file mode 100644 index 0000000..2103331 --- /dev/null +++ b/docs/specs/workspaces.md @@ -0,0 +1,55 @@ +# Workspaces + +Workspaces are runtime resources attached to sessions. Workspace lifecycle is separate from the +session's task status, native-agent activity, message delivery, user attention, and publication +state. + +## Lifecycle + +Mainloop records desired state (`running` or `suspended`), observed state, conditions, the last +observed transition, an operation ID, and the last known snapshot reference. The lifecycle is +owned by Mainloop; Substrate is the source of actor observations. + +| Observed state | User label | Meaning | +| ---------------------------------------- | ---------------------------- | -------------------------------------------------------------------------- | +| `running` | RUNNING | Substrate reports the actor running. | +| `suspending` | SUSPENDING | Substrate is suspending the actor. | +| `suspended` with a snapshot reference | PARKED | Substrate reports suspension and Mainloop has a snapshot reference. | +| `suspended` without a snapshot reference | SUSPENDED · SNAPSHOT UNKNOWN | Actor suspension is observed, but parking is not confirmed. | +| `resuming` | RESUMING | Substrate is restoring the actor. | +| `failed` | FAILED | Substrate reports a crashed/deleting actor, or the bound actor is missing. | +| `unknown` | UNKNOWN | A transport or unrecognized actor state prevents a reliable conclusion. | + +The UI shows workspace state wherever sessions are listed and links from the session detail to +`/workspaces/{id}`. The workspace page shows the manifest, conditions, transition time and +snapshot reference, and offers suspend, resume, and status refresh controls. Session badges and +session status are not changed by workspace operations. + +## API + +- `GET /workspaces` lists the current user's workspace lifecycle records. +- `GET /workspaces/{id}` returns one workspace lifecycle and manifest. +- `POST /workspaces/{id}/suspend` records the desired state and requests suspension. +- `POST /workspaces/{id}/resume` records the desired state and requests resumption. +- `POST /workspaces/{id}/refresh` reads Substrate status without changing desired state. +- Lifecycle changes are published through the existing event stream as `workspace:updated`. + +Suspend is refused while the native delivery ledger contains a recorded, queued, sending, +delivered-but-incomplete, or uncertain delivery. The API reports `409` with the reason. A +transport timeout is stored as `unknown`; the UI asks the owner to refresh status, and Mainloop +does not replay the operation without inspecting the actor first. The durable workspace +generation is advanced with a compare-and-swap before a lifecycle control call. + +## Declarative manifest + +Each workspace exposes repository URL and branch, allowed agent kinds (`claude` and `codex`), +skill and MCP references, an egress host allowlist, and a resource class. These values describe +intent only. This slice stores and displays them; it does not provision repositories, tools, +network policy, or resources. A missing repository URL is reported as undeclared rather than +inferred. + +## Scope and evidence + +These endpoints operate on existing Substrate workspace bindings. They do not provision an +actor. Runtime behavior is covered by fake-backed tests; this specification does not claim a +live cluster integration proof. diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 69bd1e3..73f3c5b 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -498,3 +498,23 @@ sources match the pinned Substrate checkout. The Codex file-write route now requ shim token even on a tokenless golden and validates a JSON object. That source hardening is fixture-tested, but the retained actor image digest predates the change; rebuild before using that route in another run. + +## Design only: owner-audited operator execution + +The shim's `POST /run` endpoint can execute a command and retains bounded output and timeout +status. It is not exposed to clients in this slice. A future operator path should authorize the +workspace owner in Mainloop, keep the per-actor shim token server-side, and persist an audit +record before dispatch with the operator, workspace, command string, working directory, +timeout, and a result reference. The owner action should be explicitly scoped and bounded; the +shim's bearer token alone is not Mainloop owner authorization. This is a design proposal only: +there is no operator-exec API, UI, audit record, or runtime change here. + +## Follow-up for the transport lane: serialize delivery with suspend + +Suspend reservation takes a `FOR UPDATE` lock on the workspace binding and checks the delivery +ledger in the same transaction, then checks the ledger again immediately before calling +Substrate. The delivery-recording path does not take that row lock yet, so a delivery can still +race after the last check and before Substrate applies suspension, including across backend +replicas. The transport lane should make delivery recording take the same lock (or an agreed +workspace advisory lock) and prove the handoff with a concurrency test. Until then, the second +check narrows this race but does not eliminate it. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f8be1e9..ba57597 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -199,6 +199,54 @@ export interface NativeDelivery { detail: string | null; } +export type WorkspaceDesiredState = 'running' | 'suspended'; +export type WorkspaceObservedState = + | 'running' + | 'suspending' + | 'suspended' + | 'resuming' + | 'failed' + | 'unknown'; + +export interface WorkspaceManifest { + repo_url: string | null; + branch: string; + agent_kinds: ('claude' | 'codex')[]; + skills: string[]; + mcp_servers: string[]; + egress_allowlist: string[]; + resource_class: string; +} + +export interface WorkspaceCondition { + type: string; + status: 'True' | 'False' | 'Unknown'; + reason: string; + message: string; + last_transition_time: string; +} + +export interface WorkspaceTransition { + from_state: WorkspaceObservedState | null; + to_state: WorkspaceObservedState; + reason: string; + occurred_at: string; +} + +export interface WorkspaceLifecycle { + workspace_id: string; + session_id: string; + desired_state: WorkspaceDesiredState; + observed_state: WorkspaceObservedState; + manifest: WorkspaceManifest; + conditions: WorkspaceCondition[]; + last_transition: WorkspaceTransition | null; + operation_id: string | null; + snapshot_ref: string | null; + ownership_generation: number; + updated_at: string; +} + export interface TopicLine { name: string; status_line: string; @@ -411,6 +459,42 @@ export const api = { }, // Session endpoints + async listWorkspaces(): Promise { + const response = await apiFetch(`${API_URL}/workspaces`); + if (!response.ok) throw new Error('Failed to list workspaces'); + return response.json(); + }, + + async getWorkspace(workspaceId: string): Promise { + const response = await apiFetch(`${API_URL}/workspaces/${workspaceId}`); + if (!response.ok) throw new Error(await errorDetail(response, 'Failed to get workspace')); + return response.json(); + }, + + async suspendWorkspace(workspaceId: string): Promise { + const response = await apiFetch(`${API_URL}/workspaces/${workspaceId}/suspend`, { + method: 'POST' + }); + if (!response.ok) throw new Error(await errorDetail(response, 'Failed to suspend workspace')); + return response.json(); + }, + + async resumeWorkspace(workspaceId: string): Promise { + const response = await apiFetch(`${API_URL}/workspaces/${workspaceId}/resume`, { + method: 'POST' + }); + if (!response.ok) throw new Error(await errorDetail(response, 'Failed to resume workspace')); + return response.json(); + }, + + async refreshWorkspace(workspaceId: string): Promise { + const response = await apiFetch(`${API_URL}/workspaces/${workspaceId}/refresh`, { + method: 'POST' + }); + if (!response.ok) throw new Error(await errorDetail(response, 'Failed to refresh workspace')); + return response.json(); + }, + async listSessions(options?: { status?: string }): Promise { const params = new URLSearchParams(); if (options?.status) params.set('status', options.status); diff --git a/frontend/src/lib/components/SessionBlock.svelte b/frontend/src/lib/components/SessionBlock.svelte index a452c9f..f2130e1 100644 --- a/frontend/src/lib/components/SessionBlock.svelte +++ b/frontend/src/lib/components/SessionBlock.svelte @@ -1,6 +1,8 @@ + + + WS {label} + diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts index 5b5147e..faf148c 100644 --- a/frontend/src/lib/sse.ts +++ b/frontend/src/lib/sse.ts @@ -13,6 +13,7 @@ export type SSEEventType = | 'session:updated' | 'session:needs_input' | 'session:message' + | 'workspace:updated' | 'heartbeat' | 'log' | 'status' @@ -88,6 +89,7 @@ export class SSEClient { 'session:updated', 'session:needs_input', 'session:message', + 'workspace:updated', 'heartbeat', 'log', 'status', diff --git a/frontend/src/lib/stores/workspaces.ts b/frontend/src/lib/stores/workspaces.ts new file mode 100644 index 0000000..e4710f3 --- /dev/null +++ b/frontend/src/lib/stores/workspaces.ts @@ -0,0 +1,61 @@ +import { writable } from 'svelte/store'; +import { api, type WorkspaceLifecycle } from '$lib/api'; + +interface WorkspacesState { + workspaces: WorkspaceLifecycle[]; + loading: boolean; + error: string | null; +} + +function createWorkspacesStore() { + const { subscribe, set, update } = writable({ + workspaces: [], + loading: false, + error: null + }); + + return { + subscribe, + + async fetchWorkspaces() { + update((state) => ({ ...state, loading: true, error: null })); + try { + const workspaces = await api.listWorkspaces(); + update((state) => ({ ...state, workspaces, loading: false })); + } catch (error) { + update((state) => ({ + ...state, + loading: false, + error: error instanceof Error ? error.message : 'Failed to fetch workspaces' + })); + } + }, + + upsert(workspace: WorkspaceLifecycle) { + update((state) => { + const index = state.workspaces.findIndex( + (item) => item.workspace_id === workspace.workspace_id + ); + const workspaces = [...state.workspaces]; + if (index === -1) workspaces.push(workspace); + else workspaces[index] = workspace; + return { ...state, workspaces }; + }); + }, + + get(workspaceId: string): WorkspaceLifecycle | undefined { + let result: WorkspaceLifecycle | undefined; + const unsubscribe = subscribe((state) => { + result = state.workspaces.find((item) => item.workspace_id === workspaceId); + }); + unsubscribe(); + return result; + }, + + reset() { + set({ workspaces: [], loading: false, error: null }); + } + }; +} + +export const workspaces = createWorkspacesStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 6330c4b..82b0618 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,9 +1,11 @@ + + + {workspace ? `Workspace ${workspace.workspace_id}` : 'Workspace'} - mainloop + + +{#if pageError} +
+
+

{pageError}

+ {#if unreachable} +

The page will retry when the backend reconnects.

+ {/if} + Back to sessions +
+
+{:else if loading && !workspace} +
+
+
+
+
+
+
+{:else if workspace} +
+
+
+
+ + Back to session + +

Workspace

+

{workspace.workspace_id}

+
+ +
+ +
+
+
+

Lifecycle

+

+ Desired {workspace.desired_state} + · + Observed {workspace.observed_state} +

+
+
+ + + +
+
+ + {#if actionError} + + {:else if failedCondition} + + {:else if operationCondition?.status === 'Unknown'} +

+ {operationCondition.message} +

+ {/if} + + {#if workspace.snapshot_ref} +

+ Last observed snapshot: {workspace.snapshot_ref} +

+ {/if} + {#if workspace.last_transition} +

+ Last transition: {workspace.last_transition.from_state ?? 'new'} → + {workspace.last_transition.to_state} · {workspace.last_transition.reason} · + {formatTime(workspace.last_transition.occurred_at)} +

+ {/if} +
+ +
+

Conditions

+ {#if workspace.conditions.length} +
    + {#each workspace.conditions as condition (condition.type)} +
  • + {condition.type} + {condition.status} +
    +

    {condition.message}

    +

    + {condition.reason} · {formatTime(condition.last_transition_time)} +

    +
    +
  • + {/each} +
+ {:else} +

No lifecycle conditions have been reported.

+ {/if} +
+ +
+
+

Workspace manifest

+

Declarative intent; these settings do not provision resources yet.

+
+
+
+
Repository
+
+ {#if workspace.manifest.repo_url} + + {workspace.manifest.repo_url} + + {:else} + Not declared + {/if} +
+
+
+
Branch
+
{workspace.manifest.branch}
+
+
+
Agent kinds
+
{formatList(workspace.manifest.agent_kinds)}
+
+
+
Resource class
+
{workspace.manifest.resource_class}
+
+
+
Skills (references)
+
{formatList(workspace.manifest.skills)}
+
+
+
MCP servers (references)
+
{formatList(workspace.manifest.mcp_servers)}
+
+
+
Egress host allowlist
+
{formatList(workspace.manifest.egress_allowlist)}
+
+
+
+
+
+{/if} diff --git a/models/src/models/__init__.py b/models/src/models/__init__.py index 80eb824..b86dcbc 100644 --- a/models/src/models/__init__.py +++ b/models/src/models/__init__.py @@ -38,6 +38,16 @@ QueueItemType, WorkflowEvent, ) +from models.workspace import ( + WorkspaceAgentKind, + WorkspaceCondition, + WorkspaceConditionStatus, + WorkspaceDesiredState, + WorkspaceLifecycle, + WorkspaceManifest, + WorkspaceObservedState, + WorkspaceTransition, +) __all__ = [ # Existing @@ -80,4 +90,12 @@ "ProviderExtension", "ReconciliationEvidence", "WorkspaceBinding", + "WorkspaceAgentKind", + "WorkspaceCondition", + "WorkspaceConditionStatus", + "WorkspaceDesiredState", + "WorkspaceLifecycle", + "WorkspaceManifest", + "WorkspaceObservedState", + "WorkspaceTransition", ] diff --git a/models/src/models/workspace.py b/models/src/models/workspace.py new file mode 100644 index 0000000..828889d --- /dev/null +++ b/models/src/models/workspace.py @@ -0,0 +1,183 @@ +"""Declarative workspace intent and durable lifecycle observations.""" + +import ipaddress +import re +from enum import StrEnum +from typing import Annotated +from urllib.parse import urlsplit + +from pydantic import AwareDatetime, ConfigDict, Field, StrictStr, field_validator + +from models.native_agent import ContractModel + +WorkspaceIdentifier = Annotated[StrictStr, Field(min_length=1)] + + +class WorkspaceContractModel(ContractModel): + model_config = ConfigDict( + extra="forbid", frozen=True, strict=True, revalidate_instances="always" + ) + + +def _is_host_or_ip(host: str) -> bool: + try: + ipaddress.ip_address(host) + return True + except ValueError: + labels = host.lower().split(".") + return len(host) <= 253 and all( + label + and len(label) <= 63 + and re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", label) + for label in labels + ) + + +class WorkspaceAgentKind(StrEnum): + CLAUDE = "claude" + CODEX = "codex" + + +class WorkspaceDesiredState(StrEnum): + RUNNING = "running" + SUSPENDED = "suspended" + + +class WorkspaceObservedState(StrEnum): + RUNNING = "running" + SUSPENDING = "suspending" + SUSPENDED = "suspended" + RESUMING = "resuming" + FAILED = "failed" + UNKNOWN = "unknown" + + +class WorkspaceManifest(WorkspaceContractModel): + """Declarative workspace policy. It is stored and displayed, not provisioned yet.""" + + repo_url: StrictStr | None = None + branch: Annotated[StrictStr, Field(min_length=1)] + agent_kinds: tuple[WorkspaceAgentKind, ...] = () + skills: tuple[Annotated[StrictStr, Field(min_length=1)], ...] = () + mcp_servers: tuple[Annotated[StrictStr, Field(min_length=1)], ...] = () + egress_allowlist: tuple[Annotated[StrictStr, Field(min_length=1)], ...] = () + resource_class: Annotated[StrictStr, Field(pattern=r"^[a-z][a-z0-9-]{0,31}$")] + + @field_validator("repo_url") + @classmethod + def validate_repo_url(cls, value: str | None) -> str | None: + if value is None: + return None + if re.fullmatch(r"[^@\s]+@[^:\s]+:.+", value): + host = value.split("@", 1)[1].split(":", 1)[0] + if _is_host_or_ip(host) and value.split(":", 1)[1]: + return value + raise ValueError("repo_url must identify a valid host and repository path") + try: + parsed = urlsplit(value) + hostname = parsed.hostname + except ValueError as exc: + raise ValueError("repo_url must be a valid URL") from exc + if ( + parsed.scheme not in {"https", "ssh", "git"} + or not hostname + or not _is_host_or_ip(hostname) + or not parsed.path.strip("/") + or parsed.query + or parsed.fragment + or (parsed.scheme == "https" and parsed.username) + or parsed.password + ): + raise ValueError( + "repo_url must be an HTTPS, SSH, or Git URL with a host and repository path" + ) + return value + + @field_validator("branch") + @classmethod + def validate_branch(cls, value: str) -> str: + invalid = set(" ~^:?*[\\") + components = value.split("/") + if ( + any(char in invalid or ord(char) < 32 for char in value) + or value.startswith("-") + or value.startswith("/") + or value.endswith(("/", ".", ".lock")) + or ".." in value + or "@{" in value + or value == "@" + or "//" in value + or any( + component in ("", ".", "..") + or component.startswith(".") + or component.endswith((".", ".lock")) + for component in components + ) + ): + raise ValueError("branch is not a valid Git branch name") + return value + + @field_validator("skills", "mcp_servers", "egress_allowlist") + @classmethod + def validate_references(cls, values: tuple[str, ...]) -> tuple[str, ...]: + if len(values) != len(set(values)): + raise ValueError("manifest references must be unique") + if any( + any(char.isspace() or ord(char) < 32 for char in value) for value in values + ): + raise ValueError("manifest references must not contain whitespace") + return values + + @field_validator("egress_allowlist") + @classmethod + def validate_egress_hosts(cls, values: tuple[str, ...]) -> tuple[str, ...]: + for host in values: + if host != host.lower() or host.endswith(".") or "*" in host: + raise ValueError( + "egress entries must be exact lowercase hostnames or IP addresses" + ) + try: + ipaddress.ip_address(host) + continue + except ValueError: + pass + if not _is_host_or_ip(host): + raise ValueError( + "egress entries must be exact lowercase hostnames or IP addresses" + ) + return values + + +class WorkspaceConditionStatus(StrEnum): + TRUE = "True" + FALSE = "False" + UNKNOWN = "Unknown" + + +class WorkspaceCondition(WorkspaceContractModel): + type: Annotated[StrictStr, Field(min_length=1)] + status: WorkspaceConditionStatus + reason: Annotated[StrictStr, Field(min_length=1)] + message: StrictStr + last_transition_time: AwareDatetime + + +class WorkspaceTransition(WorkspaceContractModel): + from_state: WorkspaceObservedState | None = None + to_state: WorkspaceObservedState + reason: Annotated[StrictStr, Field(min_length=1)] + occurred_at: AwareDatetime + + +class WorkspaceLifecycle(WorkspaceContractModel): + workspace_id: WorkspaceIdentifier + session_id: WorkspaceIdentifier + desired_state: WorkspaceDesiredState + observed_state: WorkspaceObservedState + manifest: WorkspaceManifest + conditions: tuple[WorkspaceCondition, ...] = () + last_transition: WorkspaceTransition | None = None + operation_id: WorkspaceIdentifier | None = None + snapshot_ref: WorkspaceIdentifier | None = None + ownership_generation: Annotated[int, Field(ge=1, strict=True)] = 1 + updated_at: AwareDatetime From 56cc7df1710da0449c19b5ab476eb068134ee5d2 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:47:02 +0000 Subject: [PATCH 18/30] cutover: remove legacy runtimes and fence workspace deliveries --- .env.example | 14 +- .github/workflows/ci.yml | 5 - .trunk/trunk.yaml | 1 - AGENTS.md | 3 +- CONTRIBUTING.md | 1 - Makefile | 117 +- README.md | 21 +- backend/.env.example | 14 +- backend/Dockerfile | 54 +- backend/README.md | 4 +- backend/entrypoint.sh | 31 - backend/pyproject.toml | 4 +- backend/scripts/test_k8s_components.py | 179 -- backend/src/mainloop/api.py | 292 +- backend/src/mainloop/claude_agent.py | 69 - backend/src/mainloop/config.py | 36 +- backend/src/mainloop/db/postgres.py | 30 +- backend/src/mainloop/runtime/claude.py | 7 +- backend/src/mainloop/runtime/herdr.py | 219 -- backend/src/mainloop/runtime/journal.py | 23 +- .../src/mainloop/runtime/native_sessions.py | 198 +- backend/src/mainloop/runtime/substrate.py | 4 +- .../mainloop/runtime/substrate_workspace.py | 45 +- backend/src/mainloop/services/chat_handler.py | 535 ---- backend/src/mainloop/services/claude_agent.py | 134 - backend/src/mainloop/services/compaction.py | 163 - backend/src/mainloop/services/k8s_jobs.py | 280 -- .../src/mainloop/services/k8s_namespace.py | 436 --- backend/src/mainloop/workflows/__init__.py | 5 +- backend/src/mainloop/workflows/dbos_config.py | 20 +- backend/src/mainloop/workflows/main_thread.py | 2 +- .../src/mainloop/workflows/session_worker.py | 276 -- .../tests/runtime/fixtures/codex/session.json | 2 - backend/tests/runtime/test_claude.py | 4 - backend/tests/runtime/test_context_model.py | 9 +- backend/tests/runtime/test_contracts.py | 2 - .../runtime/test_delivery_suspend_fence.py | 134 + backend/tests/runtime/test_herdr.py | 69 - backend/tests/runtime/test_journal.py | 11 +- backend/tests/runtime/test_substrate.py | 2 +- .../tests/runtime/test_substrate_workspace.py | 10 +- backend/uv.lock | 2729 ++++++++--------- claude-agent/Dockerfile | 69 - claude-agent/README.md | 17 - claude-agent/entrypoint.sh | 42 - claude-agent/job_runner.py | 146 - claude-agent/pyproject.toml | 17 - claude-agent/server.py | 199 -- claude-agent/test_plan_mode.py | 197 -- claude-agent/uv.lock | 995 ------ devspace.yaml | 39 - docker-compose.test.yml | 2 - docker-compose.yml | 22 - docs/architecture/native-agent-claude.md | 14 +- docs/architecture/native-agent-codex.md | 6 +- docs/architecture/native-agent-inventory.md | 8 +- docs/specs/chat.md | 80 +- docs/specs/sessions.md | 125 +- docs/spikes/k8s-herdr-agents.md | 4 +- docs/spikes/native-main-thread-context.md | 3 + docs/spikes/substrate-workspace-adapter.md | 10 +- frontend/src/lib/api.ts | 8 +- frontend/src/lib/components/Chat.svelte | 3 +- .../lib/components/NativeIdentityStrip.svelte | 7 +- frontend/src/routes/agents/+page.svelte | 4 +- .../base/deployment-agent-controller.yaml | 79 - .../mainloop/base/deployment-backend.yaml | 5 - k8s/apps/mainloop/base/kustomization.yaml | 3 - .../base/networkpolicy-task-namespace.yaml | 60 - k8s/apps/mainloop/base/networkpolicy.yaml | 32 - k8s/apps/mainloop/base/rbac-backend.yaml | 106 +- .../base/service-agent-controller.yaml | 11 - .../overlays/dev/agent-controller-patch.yaml | 15 - .../overlays/dev/configmap-patch.yaml | 5 - .../mainloop/overlays/dev/kustomization.yaml | 8 - .../mainloop/overlays/prod/kustomization.yaml | 1 - .../prod/personal-config-patch.yaml.example | 11 - .../overlays/prod/shutdown-patch.yaml | 39 - .../overlays/spike-herdr/kustomization.yaml | 43 - .../overlays/substrate-preview/configmap.yaml | 1 - .../overlays/test/agent-controller-patch.yaml | 15 - .../overlays/test/configmap-patch.yaml | 5 - .../mainloop/overlays/test/kustomization.yaml | 9 - models/README.md | 3 - models/src/models/__init__.py | 3 - models/src/models/agent.py | 31 - models/src/models/native_agent.py | 2 - models/src/models/session.py | 12 +- scripts/kind/create-secrets.sh | 10 +- scripts/kind/deploy.sh | 5 +- scripts/kind/load-images.sh | 5 - scripts/kind/reset-data.sh | 15 +- spikes/k8s-herdr-agents/Dockerfile | 17 - spikes/k8s-herdr-agents/bin/agentctl | 235 -- spikes/k8s-herdr-agents/bin/entrypoint.sh | 35 - spikes/k8s-herdr-agents/bin/mainloop | 159 - spikes/k8s-herdr-agents/bin/standin-agent | 63 - spikes/k8s-herdr-agents/build-real-agents.sh | 30 - spikes/k8s-herdr-agents/demo.sh | 122 - spikes/k8s-herdr-agents/k8s/workspace.yaml | 281 -- 100 files changed, 1852 insertions(+), 7835 deletions(-) delete mode 100644 backend/entrypoint.sh delete mode 100644 backend/scripts/test_k8s_components.py delete mode 100644 backend/src/mainloop/claude_agent.py delete mode 100644 backend/src/mainloop/runtime/herdr.py delete mode 100644 backend/src/mainloop/services/chat_handler.py delete mode 100644 backend/src/mainloop/services/claude_agent.py delete mode 100644 backend/src/mainloop/services/compaction.py delete mode 100644 backend/src/mainloop/services/k8s_jobs.py delete mode 100644 backend/src/mainloop/services/k8s_namespace.py delete mode 100644 backend/src/mainloop/workflows/session_worker.py create mode 100644 backend/tests/runtime/test_delivery_suspend_fence.py delete mode 100644 backend/tests/runtime/test_herdr.py delete mode 100644 claude-agent/Dockerfile delete mode 100644 claude-agent/README.md delete mode 100644 claude-agent/entrypoint.sh delete mode 100644 claude-agent/job_runner.py delete mode 100644 claude-agent/pyproject.toml delete mode 100644 claude-agent/server.py delete mode 100644 claude-agent/test_plan_mode.py delete mode 100644 claude-agent/uv.lock delete mode 100644 k8s/apps/mainloop/base/deployment-agent-controller.yaml delete mode 100644 k8s/apps/mainloop/base/networkpolicy-task-namespace.yaml delete mode 100644 k8s/apps/mainloop/base/networkpolicy.yaml delete mode 100644 k8s/apps/mainloop/base/service-agent-controller.yaml delete mode 100644 k8s/apps/mainloop/overlays/dev/agent-controller-patch.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/shutdown-patch.yaml delete mode 100644 k8s/apps/mainloop/overlays/spike-herdr/kustomization.yaml delete mode 100644 k8s/apps/mainloop/overlays/test/agent-controller-patch.yaml delete mode 100644 models/src/models/agent.py delete mode 100644 spikes/k8s-herdr-agents/Dockerfile delete mode 100755 spikes/k8s-herdr-agents/bin/agentctl delete mode 100755 spikes/k8s-herdr-agents/bin/entrypoint.sh delete mode 100755 spikes/k8s-herdr-agents/bin/mainloop delete mode 100755 spikes/k8s-herdr-agents/bin/standin-agent delete mode 100755 spikes/k8s-herdr-agents/build-real-agents.sh delete mode 100755 spikes/k8s-herdr-agents/demo.sh delete mode 100644 spikes/k8s-herdr-agents/k8s/workspace.yaml diff --git a/.env.example b/.env.example index f7088c9..9db2b16 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,17 @@ DB_NAME=mainloop DB_USER=mainloop DB_PASSWORD=changeme -# Claude Code Credentials -# These will be automatically populated by 'make setup-claude-creds' -# CLAUDE_CREDENTIALS= +# Substrate native-session routing +SUBSTRATE_ROUTER_ADDRESS=http://atenet-router.ate-system.svc.cluster.local:8081 +SUBSTRATE_SHIM_SECRET_NAMESPACE=mainloop-control +# Map each provider to a pre-created actor and its shim token Secret. +SUBSTRATE_ACTOR_BINDINGS={"claude":{"atespace":"","actor":"","shim_token_secret_name":""},"codex":{"atespace":"","actor":"","shim_token_secret_name":""}} +# Substrate lifecycle control; empty values use the host's ambient config. +SUBSTRATE_KUBECONFIG= +SUBSTRATE_CONTEXT= +SUBSTRATE_ATESPACE=mainloop-workspaces +SUBSTRATE_ACTOR_TEMPLATE=mainloop-workspace +SUBSTRATE_CLI=kubectl-ate # GitHub Container Registry # Your GitHub username for pushing images to ghcr.io diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53a61da..b99f398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,11 +43,6 @@ jobs: context: . tag: mainloop-frontend:test build-args: VITE_API_URL=http://localhost:8081 - - image: agent - file: claude-agent/Dockerfile - context: ./claude-agent - tag: mainloop-agent-controller:test - steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index bf1a815..cc6e5c9 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -36,7 +36,6 @@ lint: - backend/src/mainloop/config.py - backend/src/mainloop/db/postgres.py - backend/src/mainloop/services/github_pr.py - - claude-agent/server.py # DL3008: Pinning apt versions causes build failures when versions are removed from repos # Terrascan Docker checks have platform differences (Linux vs macOS) and false positives # for multi-stage builds with --chown flags diff --git a/AGENTS.md b/AGENTS.md index 15549ef..9df3689 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ When documents differ, do not silently blend future design with current behavior - Keep task lifecycle, agent activity, message delivery, user attention, workspace health, and publication state as separate concepts. - Parallel work is limited by architectural cohesion, not just worker capacity. Resolve shared contracts and helpers before dispatching independent consumers. -The `claude-agent/` service and Claude Agent SDK paths are part of the existing implementation. The roadmap intends to replace them deliberately with native-agent integration; do not extend them as the new long-term architecture unless a compatibility change requires it. +Native Claude Code and Codex sessions run through the Substrate workspace adapter. Do not add the superseded worker or another workspace runtime. ## Project structure @@ -33,7 +33,6 @@ backend/ FastAPI, DBOS workflows, PostgreSQL access, and orchestration frontend/ SvelteKit 5 application and Playwright tests models/ Shared Python/Pydantic models packages/ui/ Shared frontend theme and UI package -claude-agent/ Existing Claude Agent SDK worker implementation k8s/ Kubernetes bases and overlays docs/specs/ User-visible behavior specifications scripts/ Development and test automation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12b8671..ceee240 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,6 @@ Thank you for your interest in contributing to mainloop! This document provides mainloop/ ├── backend/ # Python FastAPI + DBOS workflows ├── frontend/ # SvelteKit + Tailwind v4 -├── claude-agent/ # Claude Code CLI container ├── models/ # Shared Pydantic models ├── packages/ui/ # Design tokens + theme └── k8s/ # Kubernetes manifests diff --git a/Makefile b/Makefile index e08340b..d19e5e4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-stop dev-reset dev-logs dev-shell dev-legacy install clean lint lint-all fmt fmt-all setup-claude-creds setup-claude-creds-k8s build-backend build-frontend build-agent-controller build-all push-backend push-frontend push-agent-controller push-all build-all-parallel push-all-parallel deploy deploy-loop deploy-loop-all deploy-backend deploy-agent deploy-frontend-k8s deploy-manifests prod-reset kind-create kind-delete kind-load kind-secrets kind-deploy kind-reset kind-logs kind-shell test test-run test-reset test-ci debug-tasks debug-task debug-retry debug-logs debug-db +.PHONY: help dev dev-stop dev-reset dev-logs dev-shell dev-legacy install clean lint lint-all fmt fmt-all build-backend build-frontend build-all push-backend push-frontend push-all build-all-parallel push-all-parallel deploy deploy-loop deploy-loop-all deploy-backend deploy-frontend-k8s deploy-manifests prod-reset kind-create kind-delete kind-load kind-secrets kind-deploy kind-reset kind-logs kind-shell test test-run test-reset test-ci debug-tasks debug-task debug-retry debug-logs debug-db # Load .env file if it exists -include .env @@ -11,7 +11,6 @@ GHCR_USER ?= yourusername IMAGE_TAG ?= latest BACKEND_IMAGE := $(GHCR_REGISTRY)/$(GHCR_USER)/mainloop-backend:$(IMAGE_TAG) FRONTEND_IMAGE := $(GHCR_REGISTRY)/$(GHCR_USER)/mainloop-frontend:$(IMAGE_TAG) -AGENT_CONTROLLER_IMAGE := $(GHCR_REGISTRY)/$(GHCR_USER)/mainloop-agent-controller:$(IMAGE_TAG) help: ## Show this help message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' @@ -26,7 +25,7 @@ dev: ## Start dev environment with hot reload (DevSpace + Kind) dev-stop: ## Stop DevSpace and purge resources devspace purge --kube-context kind-$(KIND_CLUSTER_NAME) -n mainloop -dev-reset: ## Reset ALL data (database + task namespaces + restart backend) +dev-reset: ## Reset local database and restart the backend @./scripts/kind/reset-data.sh dev-clear-cache: ## Clear Vite cache (fixes stale HMR issues) @@ -70,72 +69,6 @@ fmt-all: ## Format and fix all files trunk fmt -a trunk check -a -y -setup-claude-creds: ## Login to Claude inside Linux container, credentials saved to shared volume - @echo "=== Claude Container Login ===" - @echo "This will start a container where you can login to Claude." - @echo "Credentials will be saved to the 'claude-config' Docker volume." - @echo "" - @docker build -q -t mainloop-claude-agent ./claude-agent > /dev/null - @docker volume create claude-config > /dev/null 2>&1 || true - @docker rm -f claude-login-tmp > /dev/null 2>&1 || true - @docker run -d --entrypoint "" --name claude-login-tmp \ - -v claude-config:/home/claude/.claude \ - mainloop-claude-agent sleep 3600 > /dev/null - @echo "Container started with shared claude-config volume." - @echo "Run: claude login" - @echo "Complete the browser OAuth flow, then type 'exit'" - @echo "" - @docker exec -it claude-login-tmp bash; \ - echo ""; \ - echo "Checking credentials..."; \ - if docker exec claude-login-tmp test -f /home/claude/.claude/.credentials.json; then \ - echo "✓ Credentials saved to claude-config volume"; \ - echo " All containers mounting this volume will have access."; \ - else \ - echo "⚠ No credentials found. Did you complete the login?"; \ - fi; \ - docker rm -f claude-login-tmp > /dev/null - -setup-claude-creds-mac: ## Extract Claude credentials from macOS Keychain (Mac only) - @echo "Extracting Claude credentials from macOS Keychain..." - @CREDS=$$(security find-generic-password -s "Claude Code-credentials" -a "$(USER)" -w 2>/dev/null); \ - if [ -z "$$CREDS" ]; then \ - echo "Error: Claude credentials not found in Keychain."; \ - echo "Make sure you're logged in to Claude Code on this Mac."; \ - exit 1; \ - fi; \ - if [ -f .env ]; then \ - echo "Updating CLAUDE_CREDENTIALS in .env (preserving other variables)..."; \ - grep -v "^CLAUDE_CREDENTIALS=" .env > .env.tmp || true; \ - mv .env.tmp .env; \ - else \ - echo "Creating .env file..."; \ - touch .env; \ - fi; \ - echo "CLAUDE_CREDENTIALS=$$CREDS" >> .env; \ - echo "✓ Claude credentials updated in .env" - -setup-claude-creds-k8s: ## Push Claude credentials from Docker volume to 1Password for k8s - @echo "Extracting credentials from Docker volume and pushing to 1Password..." - @if ! command -v op >/dev/null 2>&1; then \ - echo "Error: 1Password CLI (op) not found."; \ - echo "Install it: brew install --cask 1password-cli"; \ - exit 1; \ - fi; \ - docker run --rm -v claude-config:/config:ro alpine cat /config/.credentials.json > /tmp/claude-creds.json 2>/dev/null; \ - if [ ! -s /tmp/claude-creds.json ]; then \ - echo "Error: No credentials in claude-config volume"; \ - echo "Run 'make setup-claude-creds' first"; \ - rm -f /tmp/claude-creds.json; \ - exit 1; \ - fi; \ - echo "Creating/updating claude-credentials item in kubernetes vault..."; \ - op item get claude-credentials --vault kubernetes >/dev/null 2>&1 && \ - op item delete claude-credentials --vault kubernetes || true; \ - op document create /tmp/claude-creds.json --title=claude-credentials --vault=kubernetes >/dev/null; \ - rm -f /tmp/claude-creds.json; \ - echo "✓ Claude credentials pushed to 1Password vault 'kubernetes'" - # Backend commands backend-dev: ## Run backend in development mode cd backend && uv run uvicorn mainloop.api:app --reload --host 0.0.0.0 --port 8000 @@ -151,10 +84,7 @@ build-backend: ## Build backend Docker image build-frontend: ## Build frontend Docker image docker build -f frontend/Dockerfile -t $(FRONTEND_IMAGE) . -build-agent-controller: ## Build agent controller Docker image - docker build -f claude-agent/Dockerfile -t $(AGENT_CONTROLLER_IMAGE) ./claude-agent - -build-all: build-backend build-frontend build-agent-controller ## Build all Docker images +build-all: build-backend build-frontend ## Build all Docker images push-backend: build-backend ## Push backend to GHCR docker push $(BACKEND_IMAGE) @@ -162,17 +92,13 @@ push-backend: build-backend ## Push backend to GHCR push-frontend: build-frontend ## Push frontend to GHCR docker push $(FRONTEND_IMAGE) -push-agent-controller: build-agent-controller ## Push agent controller to GHCR - docker push $(AGENT_CONTROLLER_IMAGE) - -push-all: push-backend push-frontend push-agent-controller ## Push all images to GHCR +push-all: push-backend push-frontend ## Push all images to GHCR # Parallel build + push (much faster) build-all-parallel: ## Build all Docker images in parallel @echo "Building all images in parallel..." @docker build -f backend/Dockerfile -t $(BACKEND_IMAGE) . & \ docker build -f frontend/Dockerfile -t $(FRONTEND_IMAGE) . & \ - docker build -f claude-agent/Dockerfile -t $(AGENT_CONTROLLER_IMAGE) ./claude-agent & \ wait @echo "All builds complete" @@ -180,7 +106,6 @@ push-all-parallel: build-all-parallel ## Build and push all images in parallel @echo "Pushing all images in parallel..." @docker push $(BACKEND_IMAGE) & \ docker push $(FRONTEND_IMAGE) & \ - docker push $(AGENT_CONTROLLER_IMAGE) & \ wait @echo "All pushes complete" @@ -190,7 +115,6 @@ deploy: push-all-parallel ## Full deployment to k8s (parallel builds + pushes) kubectl apply -k k8s/apps/mainloop/overlays/prod --server-side --force-conflicts @echo "Restarting Kubernetes deployments in parallel..." @kubectl rollout restart deployment/mainloop-backend -n mainloop & \ - kubectl rollout restart deployment/mainloop-agent-controller -n mainloop & \ kubectl rollout restart deployment/mainloop-frontend -n mainloop & \ wait @echo "Rollouts triggered" @@ -203,7 +127,7 @@ deploy-loop: ## Pull and deploy every 10 seconds done deploy-loop-all: ## Watch all and redeploy everything (old behavior) - watchexec --poll 1000 -w backend -w frontend/src -w k8s -w models -w claude-agent \ + watchexec --poll 1000 -w backend -w frontend/src -w k8s -w models \ -e py,ts,svelte,yaml,toml,Dockerfile \ -i 'test*' -i '*_test.py' -i 'tests/' -i '__pycache__/' -i '.pytest_cache/' -i 'scripts/' \ --on-busy-update restart \ @@ -215,11 +139,6 @@ deploy-backend: ## Build, push, and restart backend only docker push $(BACKEND_IMAGE) kubectl rollout restart deployment/mainloop-backend -n mainloop -deploy-agent: ## Build, push, and restart agent controller only - docker build -f claude-agent/Dockerfile -t $(AGENT_CONTROLLER_IMAGE) ./claude-agent - docker push $(AGENT_CONTROLLER_IMAGE) - kubectl rollout restart deployment/mainloop-agent-controller -n mainloop - deploy-frontend-k8s: ## Build, push, and restart frontend only (k8s version) docker build -f frontend/Dockerfile -t $(FRONTEND_IMAGE) . docker push $(FRONTEND_IMAGE) @@ -230,13 +149,8 @@ deploy-manifests: ## Apply k8s manifests only (no image builds) PROD_CONTEXT ?= admin@internal-01 -prod-reset: ## Reset prod database + task namespaces + restart backend +prod-reset: ## Reset prod database + restart backend @echo "=== Using context: $(PROD_CONTEXT) ===" - @echo "=== Cleaning up k8s task namespaces ===" - @for ns in $$(kubectl --context $(PROD_CONTEXT) get ns -o name 2>/dev/null | grep "^namespace/task-" | cut -d/ -f2); do \ - echo "Deleting namespace: $$ns"; \ - kubectl --context $(PROD_CONTEXT) delete ns "$$ns" --wait=false 2>/dev/null || true; \ - done @echo "=== Deleting CNPG Database CR ===" kubectl --context $(PROD_CONTEXT) delete database mainloop-db-database -n mainloop --wait=true @echo "=== Recreating Database CR ===" @@ -293,27 +207,14 @@ test-k8s: kind-create kind-load kind-secrets kind-deploy ## Start local K8s test test-loop: ## Watch for changes and auto-redeploy to Kind @echo "Starting Kind deploy loop (Ctrl+C to stop)..." - @echo "Watching: backend/, frontend/src/, claude-agent/" + @echo "Watching: backend/, frontend/src/" @trap 'kill 0' INT; \ watchexec -w backend/src -w models -e py \ --on-busy-update restart -- bash -c 'make kind-load && make kind-deploy' & \ watchexec -w frontend/src -e ts,svelte,css \ --on-busy-update restart -- bash -c 'make kind-load && make kind-deploy' & \ - watchexec -w claude-agent -e py \ - --on-busy-update restart -- bash -c 'make kind-load && make kind-deploy' & \ wait -# E2E Testing -test-k8s-components: ## Test K8s namespace/secret creation (quick) - cd backend && uv run python scripts/test_k8s_components.py - -test-k8s-job: ## Test K8s job creation (creates a real job) - cd backend && uv run python scripts/test_k8s_components.py --job - -test-worker-e2e: ## Run full worker E2E test (disabled; ENABLE_E2E=1 to opt in) - @./scripts/e2e-guard.sh - cd backend && REPO_URL="$(or $(REPO_URL),https://github.com/oldsj/mainloop)" uv run python scripts/test_worker_e2e.py - # ============================================================================= # Testing (DevSpace + Playwright) # @@ -340,10 +241,6 @@ test-reset: dev-reset ## Alias for dev-reset test-ci: ## Run tests with legacy kind scripts (disabled; ENABLE_E2E=1 to opt in) @./scripts/e2e-guard.sh - @if [ -z "$(CLAUDE_CODE_OAUTH_TOKEN)" ]; then \ - echo "Error: CLAUDE_CODE_OAUTH_TOKEN not set"; \ - exit 1; \ - fi @if ! kind get clusters 2>/dev/null | grep -q "^$(KIND_CLUSTER_NAME)$$"; then \ $(MAKE) kind-create; \ fi diff --git a/README.md b/README.md index b2f69cd..b5b17db 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ You (phone/laptop) ▼ ┌─────────────────────────────────────────────────────┐ │ Main Thread │ -│ Claude with spawn_session tool │ +│ Native Claude Code session in Substrate │ │ │ │ user@mainloop$ research X ← inline sessions │ │ ├── [research X] thinking... ← threaded reply │ @@ -33,20 +33,18 @@ You (phone/laptop) └──────────────┘ └──────────────┘ ``` -- **Main thread**: One continuous conversation — sessions spawn inline and surface results back -- **Sessions**: Background AI work with their own conversations; appear as colored threads in your timeline +- **Main thread**: One continuous native conversation; delegated sessions surface results back +- **Sessions**: Native Claude Code or Codex work with their own conversations; appear as colored threads in your timeline - **Notifications**: Slack-style thread replies notify you when sessions need attention or complete -- **Persistence**: Conversations and sessions survive restarts via compaction + [DBOS](https://docs.dbos.dev/) +- **Persistence**: Mainloop stores conversations, delivery records, and workspace lifecycle state in PostgreSQL; native history remains with the provider CLI in Substrate ## Quick Start ```bash # Copy example environment file and configure cp .env.example .env -# Edit .env with your GitHub username (GHCR_USER) and domains - -# Optional: authenticate the current Claude integration -make setup-claude-creds +# Set the Substrate router, actor bindings, and shim Secret names. +# Keep provider credentials in the configured actors, not in the backend environment. # Start all services make dev @@ -65,7 +63,6 @@ The Kubernetes manifests under `k8s/apps/mainloop/` provide reusable bases and e mainloop/ ├── backend/ # Python FastAPI + DBOS workflows ├── frontend/ # SvelteKit + Tailwind v4 (mobile-first responsive) -├── claude-agent/ # Claude Code CLI container ├── models/ # Shared Pydantic models ├── packages/ui/ # Design tokens + theme.css └── k8s/ # Kubernetes manifests @@ -92,12 +89,12 @@ mainloop/ ## Agent Workflow -Agents are sessions spawned for development tasks. Each agent gets its own K8s namespace for isolated iteration. +Agents are native sessions spawned for development tasks. Mainloop records the session and its deliveries; the native CLI runs in the Substrate actor selected by the configured provider binding. ```text ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Spawn │────►│ Work │────►│ PR │────►│ Close │ -│ (main) │ │ (k8s ns) │ │ (GitHub) │ │ (summary) │ +│ (main) │ │(Substrate) │ │ (GitHub) │ │ (summary) │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ ▲ │ └───────────────────┘ @@ -105,7 +102,7 @@ Agents are sessions spawned for development tasks. Each agent gets its own K8s n ``` 1. **Spawn** - Main thread creates agent for a task -2. **Work** - Agent iterates in its own K8s namespace (build, test, debug) +2. **Work** - A native Claude Code or Codex session runs in its configured Substrate actor 3. **PR** - Agent creates and merges GitHub PR when ready 4. **Close** - Agent posts summary back to main thread diff --git a/backend/.env.example b/backend/.env.example index 07ef4e6..2c87d2b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,15 @@ -# Claude Agent (optional - for direct API access) -ANTHROPIC_API_KEY=your-api-key-here +# Native sessions connect to deployment-provided Substrate actors. +SUBSTRATE_ROUTER_ADDRESS=http://atenet-router.ate-system.svc.cluster.local:8081 +SUBSTRATE_SHIM_SECRET_NAMESPACE=mainloop-control +# Map each native provider to its actor and shim token Secret name. +SUBSTRATE_ACTOR_BINDINGS={"claude":{"atespace":"","actor":"","shim_token_secret_name":""},"codex":{"atespace":"","actor":"","shim_token_secret_name":""}} + +# Substrate lifecycle control. Leave empty to use the host's ambient config. +SUBSTRATE_KUBECONFIG= +SUBSTRATE_CONTEXT= +SUBSTRATE_ATESPACE=mainloop-workspaces +SUBSTRATE_ACTOR_TEMPLATE=mainloop-workspace +SUBSTRATE_CLI=kubectl-ate # Server HOST=0.0.0.0 diff --git a/backend/Dockerfile b/backend/Dockerfile index b06f4b9..3401de1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -29,42 +29,30 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # Development stage - includes uv for live dependency updates FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS dev -# Install Node.js and Claude CLI (required by claude-agent-sdk) RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g @anthropic-ai/claude-code@latest \ - && npm cache clean --force \ && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /root/.npm + && rm -rf /var/lib/apt/lists/* /tmp/* -# Create non-root user for Claude and workspace directory -RUN useradd -m -s /bin/bash claude \ - && mkdir -p /home/claude/.claude /workspace \ - && chown -R claude:claude /home/claude /workspace +RUN useradd -m -s /bin/bash mainloop WORKDIR /app # Copy dependency files (owned by claude for live updates) -COPY --from=builder --chown=claude:claude /app/.venv /app/.venv -COPY --chown=claude:claude backend/pyproject.toml backend/uv.lock backend/README.md ./ +COPY --from=builder --chown=mainloop:mainloop /app/.venv /app/.venv +COPY --chown=mainloop:mainloop backend/pyproject.toml backend/uv.lock backend/README.md ./ # Copy source code (owned by claude) -COPY --chown=claude:claude backend/src ./src +COPY --chown=mainloop:mainloop backend/src ./src # Copy shared models package (owned by claude) -COPY --chown=claude:claude models ../models - -# Copy entrypoint script -COPY --chown=claude:claude backend/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY --chown=mainloop:mainloop models ../models # Set up PATH for venv and uv ENV PATH="/app/.venv/bin:$PATH" # Switch to non-root user -USER claude +USER mainloop EXPOSE 8000 @@ -73,47 +61,35 @@ CMD ["uvicorn", "mainloop.api:app", "--host", "0.0.0.0", "--port", "8000", "--re # Runtime stage (production) FROM python:3.13-slim AS prod -# Install Node.js and Claude CLI (required by claude-agent-sdk) +# Install the health-check client and CA certificates. RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g @anthropic-ai/claude-code@latest \ - && npm cache clean --force \ && apt-get purge -y --auto-remove ca-certificates \ && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /root/.npm + && rm -rf /var/lib/apt/lists/* /tmp/* -# Create non-root user for Claude and workspace directory -RUN useradd -m -s /bin/bash claude \ - && mkdir -p /home/claude/.claude /workspace \ - && chown -R claude:claude /home/claude /workspace +RUN useradd -m -s /bin/bash mainloop WORKDIR /app # Copy the virtual environment from builder (owned by claude) -COPY --from=builder --chown=claude:claude /app/.venv /app/.venv +COPY --from=builder --chown=mainloop:mainloop /app/.venv /app/.venv # Copy source code (owned by claude) -COPY --chown=claude:claude backend/src ./src +COPY --chown=mainloop:mainloop backend/src ./src # Copy shared models package (owned by claude) -COPY --chown=claude:claude models ../models - -# Copy entrypoint script -COPY --chown=claude:claude backend/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY --chown=mainloop:mainloop models ../models # Set up PATH ENV PATH="/app/.venv/bin:$PATH" -# Switch to non-root user (required for Claude SDK bypass permissions) -USER claude +# Run without root privileges. +USER mainloop EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8000/health || exit 1 -ENTRYPOINT ["/entrypoint.sh"] CMD ["uvicorn", "mainloop.api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md index 134be60..9c851ad 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # mainloop-backend -FastAPI backend for the mainloop AI agent orchestrator. +FastAPI control plane for native-agent sessions and Substrate workspaces. ## Development @@ -17,7 +17,7 @@ make backend-dev ## Environment Variables -See `.env.example` for required environment variables. +See `.env.example` for the Substrate router, actor bindings, lifecycle settings, and database environment variables. Provider credentials are held by the configured actors, not by the backend. ## API Documentation diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh deleted file mode 100644 index 6968148..0000000 --- a/backend/entrypoint.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -set -e - -echo "Starting Mainloop Backend API..." - -# Create Claude credentials file from env var if token is provided -# This is required for the Claude Agent SDK to authenticate -if [[ -n ${CLAUDE_CODE_OAUTH_TOKEN} ]]; then - mkdir -p ~/.claude - cat >~/.claude/.credentials.json <=0.11.0", "pydantic>=2.12.5", "pydantic-settings>=2.0.0", - "claude-agent-sdk>=0.1.18", "asyncpg>=0.30.0", "dbos>=2.7.0", "pydantic-ai[dbos]>=1.39.0", "kubernetes>=34.1.0", - "anthropic>=0.75.0", ] [project.scripts] diff --git a/backend/scripts/test_k8s_components.py b/backend/scripts/test_k8s_components.py deleted file mode 100644 index 3911365..0000000 --- a/backend/scripts/test_k8s_components.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -""" -Test K8s namespace and job creation directly. - -This tests the K8s integration without running the full DBOS workflow. -Useful for debugging K8s issues. - -Prerequisites: -1. kubectl configured with cluster access -2. RBAC manifests applied (make k8s-apply) - -Usage: - cd backend - uv run python scripts/test_k8s_components.py -""" - -import asyncio -import os -import sys -import uuid - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - - -async def test_namespace(): - """Test namespace creation and deletion.""" - from mainloop.services.k8s_namespace import ( - copy_secrets_to_namespace, - create_task_namespace, - delete_task_namespace, - setup_worker_rbac, - ) - - task_id = f"test-{uuid.uuid4().hex[:8]}" - - print(f"Testing with task_id: {task_id}") - print() - - # Test 1: Create namespace - print("1. Creating namespace...") - try: - namespace = await create_task_namespace(task_id) - print(f" ✓ Created namespace: {namespace}") - except Exception as e: - print(f" ✗ Failed: {e}") - return - - # Test 2: Copy secrets - print("2. Copying secrets...") - try: - await copy_secrets_to_namespace(task_id, namespace) - print(" ✓ Secrets copied") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 3: Setup worker RBAC - print("3. Setting up worker RBAC...") - try: - await setup_worker_rbac(task_id, namespace) - print(" ✓ Worker RBAC configured") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 4: Verify namespace exists - print("4. Verifying namespace...") - from mainloop.services.k8s_namespace import get_k8s_client - - core_v1, _ = get_k8s_client() - try: - ns = core_v1.read_namespace(namespace) - print(f" ✓ Namespace exists with status: {ns.status.phase}") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 5: List secrets in namespace - print("5. Checking secrets...") - try: - secrets = core_v1.list_namespaced_secret(namespace) - secret_names = [ - s.metadata.name - for s in secrets.items - if not s.metadata.name.startswith("default") - ] - print(f" ✓ Found secrets: {secret_names}") - except Exception as e: - print(f" ✗ Failed: {e}") - - # Test 6: Delete namespace - print("6. Deleting namespace...") - try: - await delete_task_namespace(task_id) - print(" ✓ Deletion initiated") - except Exception as e: - print(f" ✗ Failed: {e}") - - print() - print("Done! Check with: kubectl get ns | grep task-") - - -async def test_job(): - """Test job creation in a namespace.""" - from mainloop.services.k8s_jobs import ( - create_worker_job, - get_job_status, - ) - from mainloop.services.k8s_namespace import ( - copy_secrets_to_namespace, - create_task_namespace, - delete_task_namespace, - setup_worker_rbac, - ) - - task_id = f"test-{uuid.uuid4().hex[:8]}" - - print(f"Testing job with task_id: {task_id}") - print() - - # Setup namespace - print("1. Setting up namespace...") - namespace = await create_task_namespace(task_id) - await copy_secrets_to_namespace(task_id, namespace) - await setup_worker_rbac(task_id, namespace) - print(f" ✓ Namespace ready: {namespace}") - - # Create job - print("2. Creating job...") - try: - job_name = await create_worker_job( - task_id=task_id, - namespace=namespace, - prompt="Echo 'Hello from test job' and exit", - mode="initial", - callback_url="", # No callback for test - ) - print(f" ✓ Created job: {job_name}") - except Exception as e: - print(f" ✗ Failed: {e}") - await delete_task_namespace(task_id) - return - - # Wait a bit and check status - print("3. Waiting for job to start...") - await asyncio.sleep(5) - - status = await get_job_status(task_id, namespace) - if status: - print( - f" Active: {status['active']}, Succeeded: {status['succeeded']}, Failed: {status['failed']}" - ) - else: - print(" No status yet") - - # Show how to watch - print() - print("Watch the job with:") - print(f" kubectl get pods -n {namespace} -w") - print(f" kubectl logs -n {namespace} -l mainloop.dev/task-id={task_id} -f") - print() - print("Cleanup with:") - print(f" kubectl delete ns {namespace}") - - -async def main(): - """Parse arguments and run tests.""" - import argparse - - parser = argparse.ArgumentParser(description="Test K8s components") - parser.add_argument("--job", action="store_true", help="Test job creation (slower)") - args = parser.parse_args() - - if args.job: - await test_job() - else: - await test_namespace() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/src/mainloop/api.py b/backend/src/mainloop/api.py index 8a6380f..accaa95 100644 --- a/backend/src/mainloop/api.py +++ b/backend/src/mainloop/api.py @@ -3,7 +3,6 @@ import logging from dataclasses import asdict from datetime import datetime -from typing import Any from dbos import DBOS from fastapi import FastAPI, Header, HTTPException, Request @@ -18,7 +17,6 @@ ) from mainloop.runtime.agent_api import router as agent_api_router from mainloop.runtime.workspace_api import router as workspace_api_router -from mainloop.services.chat_handler import process_message from mainloop.services.github_pr import ( CommitSummary, ProjectPRSummary, @@ -117,14 +115,11 @@ async def startup_event(): # Launch DBOS DBOS.launch() - if settings.main_thread_mode == "native": - import asyncio + import asyncio - from mainloop.runtime import native_sessions + from mainloop.runtime import native_sessions - app.state.native_reconcile = asyncio.create_task( - native_sessions.reconcile_loop() - ) + app.state.native_reconcile = asyncio.create_task(native_sessions.reconcile_loop()) @app.on_event("shutdown") @@ -222,91 +217,14 @@ async def chat( request: ChatRequest, user_id: str = Header(alias="X-User-ID", default=None), ): - """Send a message and get an immediate response.""" - from mainloop.services.compaction import trigger_compaction - + """Record and deliver a message to the user's native main session.""" if not user_id: user_id = get_user_id_from_cf_header() - - if settings.main_thread_mode == "native": - return await _chat_native(request, user_id) - - # Ensure main thread is running (for background coordination) - main_thread_id = get_or_start_main_thread(user_id) - - # Get or create conversation - if request.conversation_id: - conversation = await db.get_conversation(request.conversation_id) - if not conversation: - raise HTTPException(status_code=404, detail="Conversation not found") - else: - conversation = await db.create_conversation(user_id) - - # Load context: summary + recent messages after last summarized point - recent_messages = await db.get_messages_after( - conversation.id, - conversation.summarized_through_id, - limit=20, - ) - - # Save user message and increment count - await db.create_message( - conversation_id=conversation.id, - role="user", - content=request.message, - ) - await db.increment_message_count(conversation.id) - - # Get or create main thread record - main_thread = await db.get_main_thread_by_user(user_id) - if not main_thread: - # Create main thread record if it doesn't exist (e.g., after DB reset) - from models import MainThread - - main_thread = MainThread(user_id=user_id, workflow_run_id=main_thread_id) - main_thread = await db.create_main_thread(main_thread) - thread_id = main_thread.id - - # Process message with summary + recent messages for context - result = await process_message( - user_id=user_id, - message=request.message, - conversation_id=conversation.id, - main_thread_id=thread_id, - summary=conversation.summary, - recent_messages=recent_messages, - ) - - # If a session was spawned, don't save a main thread response - # The user interacts with the session directly - if result.suppress_response and result.spawned_session_ids: - # Return the first spawned session info instead of a message - # Frontend will auto-switch to this session - return ChatResponse( - conversation_id=conversation.id, - message=None, - spawned_session_id=result.spawned_session_ids[0], - ) - - # Save assistant response and increment count - assistant_message = await db.create_message( - conversation_id=conversation.id, - role="assistant", - content=result.response, - ) - new_count = await db.increment_message_count(conversation.id) - - # Trigger async compaction if needed (fire-and-forget) - trigger_compaction(conversation.id, new_count) - - return ChatResponse( - conversation_id=conversation.id, - message=assistant_message, - ) + return await _chat_native(request, user_id) async def _chat_native(request: ChatRequest, user_id: str) -> ChatResponse: - """Native main thread: record + deliver to the Claude session under Herdr (ledgered). The + """Record and deliver to the native main session through the Substrate workspace. The reply is mirrored from the native journal, so the client polls the conversation.""" from mainloop.runtime import delegation, native_sessions @@ -338,8 +256,6 @@ async def get_main_thread_info(user_id: str = Header(alias="X-User-ID", default= """Main-thread mode, native identity strip, and the topic index.""" if not user_id: user_id = get_user_id_from_cf_header() - if settings.main_thread_mode != "native": - return MainThreadInfo(mode=settings.main_thread_mode) from mainloop.runtime import delegation, native_sessions binding = await delegation.ensure_main_session(user_id) @@ -424,19 +340,16 @@ async def get_conversation(conversation_id: str): if not conversation: raise HTTPException(status_code=404, detail="Conversation not found") - if settings.main_thread_mode == "native": - from mainloop.runtime import native_sessions + from mainloop.runtime import native_sessions - async with db.connection() as conn: - main_sid = await conn.fetchval( - """SELECT b.session_id FROM native_bindings b JOIN sessions s ON s.id=b.session_id - WHERE b.role='main' AND s.conversation_id=$1""", - conversation_id, - ) - if main_sid and not native_sessions.is_rotating(main_sid): - await native_sessions.sync( - main_sid - ) # mirror new native-journal evidence first + async with db.connection() as conn: + main_sid = await conn.fetchval( + """SELECT b.session_id FROM native_bindings b JOIN sessions s ON s.id=b.session_id + WHERE b.role='main' AND s.conversation_id=$1""", + conversation_id, + ) + if main_sid and not native_sessions.is_rotating(main_sid): + await native_sessions.sync(main_sid) messages = await db.get_messages(conversation_id) return ConversationResponse( @@ -727,10 +640,6 @@ async def create_session( """Create a new session with its own conversation.""" import uuid - from dbos import SetWorkflowID - from mainloop.workflows.dbos_config import worker_queue - from mainloop.workflows.session_worker import session_worker_workflow - if not user_id: user_id = get_user_id_from_cf_header() @@ -762,20 +671,12 @@ async def create_session( ) session = await db.create_session(session) - if request.agent_kind: - # Real native agent under Herdr in the workspace pod (no DBOS worker / K8s Job). - from mainloop.runtime import native_sessions - - await native_sessions.create_binding(session.id, request.agent_kind) - await db.update_session(session.id, status=SessionStatus.ACTIVE) - await native_sessions.submit_message(session.id, request.prompt) - return await db.get_session(session.id) + from mainloop.runtime import native_sessions - # Start session worker workflow - with SetWorkflowID(session.id): - worker_queue.enqueue(session_worker_workflow, session.id) - - return session + await native_sessions.create_binding(session.id, request.agent_kind or "claude") + await db.update_session(session.id, status=SessionStatus.ACTIVE) + await native_sessions.submit_message(session.id, request.prompt) + return await db.get_session(session.id) @app.get("/sessions/{session_id}", response_model=Session) @@ -819,7 +720,7 @@ async def get_session_conversation(session_id: str): async def get_session_native( session_id: str, user_id: str = Header(alias="X-User-ID", default=None) ): - """Identity strip for a session bound to a native agent under Herdr.""" + """Identity strip for a session bound to a native agent in Substrate.""" if not user_id: user_id = get_user_id_from_cf_header() owner = await db.get_session(session_id) @@ -848,7 +749,6 @@ async def send_session_message( user_id: str = Header(alias="X-User-ID", default=None), ): """Send a message to a session's conversation.""" - from mainloop.workflows.session_worker import TOPIC_USER_MESSAGE if not user_id: user_id = get_user_id_from_cf_header() @@ -862,32 +762,15 @@ async def send_session_message( from mainloop.runtime import native_sessions - if await native_sessions.get_binding(session_id): - try: - message_id = await native_sessions.submit_message( - session_id, request.message - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - return {"status": "ok", "message_id": message_id} - - # Save message directly to database (don't rely on workflow) - message = await db.create_message( - conversation_id=session.conversation_id, - role="user", - content=request.message, - ) - - # If session is waiting on user, notify workflow to process response - if session.status == SessionStatus.WAITING_ON_USER: - # Notify the workflow that a new message is ready - DBOS.send( - session_id, # workflow_id is the session_id - {"message_id": message.id}, # Just notify, message already saved - topic=TOPIC_USER_MESSAGE, + if not await native_sessions.get_binding(session_id): + raise HTTPException( + status_code=409, detail="Session has no native agent binding" ) - - return {"status": "ok", "message_id": message.id} + try: + message_id = await native_sessions.submit_message(session_id, request.message) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return {"status": "ok", "message_id": message_id} # Done: nothing more will run, so the session can be cleared from the list. @@ -917,15 +800,14 @@ async def cancel_session( from mainloop.runtime import native_sessions - if await native_sessions.get_binding(session_id): - try: - agent = await native_sessions.cancel(session_id) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - else: - # TODO: Cancel the legacy session worker workflow - await db.update_session(session_id, status=SessionStatus.CANCELLED) - agent = "not_running" + if not await native_sessions.get_binding(session_id): + raise HTTPException( + status_code=409, detail="Session has no native agent binding" + ) + try: + agent = await native_sessions.cancel(session_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc return {"status": "cancelled", "agent": agent} @@ -999,51 +881,6 @@ async def dismiss_notification( return {"status": "ok"} -# ============= Internal Endpoints (for K8s Jobs) ============= - - -class SessionResult(BaseModel): - """Result from a session Job.""" - - session_id: str - status: str # "completed" or "failed" - result: dict[str, Any] | None = None - error: str | None = None - completed_at: str | None = None - - -@app.post("/internal/sessions/{session_id}/complete") -async def session_complete(session_id: str, result: SessionResult): - """Handle K8s Job completion callbacks for sessions. - - This is called by the job_runner when a session Job finishes. - It notifies the session workflow to add the response and continue. - """ - # Verify session exists - session = await db.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - # Send result to the session workflow via DBOS.send() - # The session workflow is waiting on TOPIC_JOB_RESULT - from mainloop.workflows.session_worker import TOPIC_JOB_RESULT - - # The session workflow uses session_id as workflow ID - workflow_id = session_id - - DBOS.send( - workflow_id, - { - "status": result.status, - "result": result.result, - "error": result.error, - }, - topic=TOPIC_JOB_RESULT, - ) - - return {"status": "ok", "session_id": session_id} - - # ============= Test Helpers (E2E only) ============= @@ -1155,8 +992,6 @@ async def reset_test_data(all: bool = False): status_code=403, detail="Only available in test environment" ) - deleted_namespaces = [] - if all: # Full reset - truncate everything async with db.connection() as conn: @@ -1172,25 +1007,6 @@ async def reset_test_data(all: bool = False): "TRUNCATE TABLE dbos.workflow_events, dbos.operation_outputs, dbos.workflow_status CASCADE" ) - # Delete ALL task namespaces - try: - from kubernetes.client.rest import ApiException - from mainloop.services.k8s_namespace import get_k8s_client - - core_v1, _ = get_k8s_client() - namespaces = core_v1.list_namespace( - label_selector="app.kubernetes.io/managed-by=mainloop" - ) - for ns in namespaces.items: - try: - core_v1.delete_namespace(name=ns.metadata.name) - deleted_namespaces.append(ns.metadata.name) - except ApiException as e: - if e.status != 404: - logger.warning(f"Failed to delete namespace: {e}") - except Exception as e: - logger.debug(f"K8s namespace cleanup skipped: {e}") - if settings.use_mock_github: from mainloop.services.github_mock import mock_state @@ -1199,17 +1015,11 @@ async def reset_test_data(all: bool = False): return { "status": "reset", "scope": "all", - "deleted_namespaces": deleted_namespaces, } # Test-only reset async with db.connection() as conn: - # Get session IDs and workflow IDs for test users before deleting - test_sessions = await conn.fetch( - "SELECT id FROM sessions WHERE user_id LIKE 'test-%'" - ) - test_session_ids = [row["id"] for row in test_sessions] - + # Get workflow IDs for test users before deleting. test_workflow_ids = await conn.fetch( """ SELECT workflow_run_id FROM main_threads @@ -1256,31 +1066,6 @@ async def reset_test_data(all: bool = False): workflow_ids, ) - # Delete K8s namespaces for test sessions - if test_session_ids: - try: - from kubernetes.client.rest import ApiException - from mainloop.services.k8s_namespace import get_k8s_client - - core_v1, _ = get_k8s_client() - - # Get all mainloop-managed namespaces - namespaces = core_v1.list_namespace( - label_selector="app.kubernetes.io/managed-by=mainloop" - ) - - for ns in namespaces.items: - session_id = ns.metadata.labels.get("mainloop.dev/session-id", "") - if session_id in test_session_ids: - try: - core_v1.delete_namespace(name=ns.metadata.name) - deleted_namespaces.append(ns.metadata.name) - except ApiException as e: - if e.status != 404: # Ignore not found - logger.warning(f"Failed to delete namespace: {e}") - except Exception as e: - logger.debug(f"K8s namespace cleanup skipped: {e}") - # Reset mock state if mocking is enabled if settings.use_mock_github: from mainloop.services.github_mock import mock_state @@ -1290,7 +1075,6 @@ async def reset_test_data(all: bool = False): return { "status": "reset", "preserved": "non-test users", - "deleted_namespaces": deleted_namespaces, } diff --git a/backend/src/mainloop/claude_agent.py b/backend/src/mainloop/claude_agent.py deleted file mode 100644 index 6fbe77e..0000000 --- a/backend/src/mainloop/claude_agent.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Claude agent orchestration using the Agent SDK.""" - -from claude_agent_sdk import ( - AssistantMessage, - ClaudeAgentOptions, - ResultMessage, - TextBlock, - query, -) -from mainloop.config import settings - -from models import AgentResponse, AgentTask - - -class ClaudeAgent: - """Client for interacting with Claude Code via the Agent SDK.""" - - def __init__(self): - """Initialize Claude agent client.""" - pass - - async def execute_task(self, task: AgentTask) -> AgentResponse: - """Execute a task via Claude Code Agent SDK.""" - try: - options = ClaudeAgentOptions( - model=settings.claude_model, - permission_mode="bypassPermissions", - cwd=settings.claude_workspace, - ) - - collected_text: list[str] = [] - - async for message in query(prompt=task.prompt, options=options): - if isinstance(message, AssistantMessage): - for block in message.content: - if isinstance(block, TextBlock): - collected_text.append(block.text) - elif isinstance(message, ResultMessage): - if message.is_error: - return AgentResponse( - task_id=task.id, - content=f"Error: {message.result or 'Unknown error'}", - ) - - return AgentResponse( - task_id=task.id, - content="\n".join(collected_text) if collected_text else "No response", - ) - - except Exception as e: - return AgentResponse(task_id=task.id, content=f"Claude SDK error: {str(e)}") - - async def stream_task(self, task: AgentTask): - """Stream task execution results.""" - options = ClaudeAgentOptions( - model=settings.claude_model, - permission_mode="bypassPermissions", - cwd=settings.claude_workspace, - ) - - async for message in query(prompt=task.prompt, options=options): - if isinstance(message, AssistantMessage): - for block in message.content: - if isinstance(block, TextBlock): - yield block.text - - -# Global agent instance -claude_agent = ClaudeAgent() diff --git a/backend/src/mainloop/config.py b/backend/src/mainloop/config.py index efa7244..8eb7b1d 100644 --- a/backend/src/mainloop/config.py +++ b/backend/src/mainloop/config.py @@ -34,23 +34,7 @@ def database_url(self) -> str: encoded_password = quote_plus(self.db_password) return f"postgresql://{self.db_user}:{encoded_password}@{self.db_host}:{self.db_port}/{self.db_name}" - # Claude - claude_code_oauth_token: str = "" # OAuth token for Claude Code API - claude_agent_url: str = "http://claude-agent:8001" - claude_workspace: str = "/workspace" - claude_model: str = "sonnet" # Main thread model - claude_worker_model: str = "opus" # Worker model (for background tasks) - - # Native agents under Herdr (workspace pod reached over Kubernetes pod-exec) - workspace_namespace: str = "herdr-spike" - workspace_pod: str = "workspace-0" - main_pod: str = ( - "main-0" # pod that runs the native main thread (scratch cwd, no repo) - ) - - # Native-session workspace transport. Herdr remains the default; Substrate attaches to - # pre-created actors through the CONNECT router and never creates or resumes actors itself. - workspace_runtime: Literal["herdr", "substrate"] = "herdr" + # Native sessions connect to pre-created Substrate actors through the CONNECT router. substrate_router_address: str = ( "http://atenet-router.ate-system.svc.cluster.local:8081" ) @@ -60,11 +44,7 @@ def database_url(self) -> str: ] = Field(default_factory=dict) substrate_resume_timeout_seconds: float = 120.0 - # Substrate workspace-runtime adapter (bounded integration spike; see - # docs/architecture/native-agent-inventory.md and .tasknotes/plan.md). Empty - # kubeconfig/context falls back to the ambient kubeconfig. One actor per session - # replaces the fixed workspace_namespace/workspace_pod pair above for Substrate-backed - # sessions; Herdr pod-exec keeps working unchanged for sessions that are not. + # Substrate actor lifecycle control. Empty kubeconfig/context falls back to ambient config. substrate_kubeconfig: str = "" substrate_context: str = "" substrate_atespace: str = "mainloop-workspaces" @@ -72,8 +52,7 @@ def database_url(self) -> str: substrate_cli: str = "kubectl-ate" substrate_preview_base_url: str = "" - # Native main thread (context model). MAIN_THREAD_MODE=native replaces the SDK chat path. - main_thread_mode: str = "sdk" # sdk | native + # Native main thread (context model). main_thread_model: str = "sonnet" main_thread_effort: str = "medium" # Rotation: cut to a fresh native session when the context grew by this many tokens above @@ -100,15 +79,6 @@ def frontend_origin(self) -> str: """Construct frontend origin URL from domain.""" return f"https://{self.frontend_domain}" - # K8s Job callback URL (internal service URL for Jobs to call back) - backend_internal_url: str = ( - "http://mainloop-backend.mainloop.svc.cluster.local:8000" - ) - - # Worker image for K8s Jobs (use local image for dev) - worker_image: str = "ghcr.io/oldsj/mainloop-agent-controller:latest" - worker_image_pull_policy: str = "IfNotPresent" # Use "Never" for local dev - # Test environment flag (enables test-only endpoints) is_test_env: bool = False diff --git a/backend/src/mainloop/db/postgres.py b/backend/src/mainloop/db/postgres.py index 8683c64..4aa9137 100644 --- a/backend/src/mainloop/db/postgres.py +++ b/backend/src/mainloop/db/postgres.py @@ -163,7 +163,7 @@ def _parse_json_field(value: Any) -> list | dict | None: CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id); CREATE INDEX IF NOT EXISTS idx_sessions_anchor ON sessions(anchor_message_id); --- Native agent bindings (one per session bound to a real agent under Herdr) +-- Native agent bindings (one per session bound to a real agent in a Substrate workspace) CREATE TABLE IF NOT EXISTS native_bindings ( session_id TEXT PRIMARY KEY REFERENCES sessions(id), kind TEXT NOT NULL, @@ -171,10 +171,6 @@ def _parse_json_field(value: Any) -> list | dict | None: native_session_id TEXT, approval_policy TEXT NOT NULL, model TEXT, - herdr_pane_id TEXT, - herdr_terminal_id TEXT, - herdr_workspace_id TEXT, - pod_uid TEXT, generation INTEGER NOT NULL DEFAULT 1, journal_cursor INTEGER NOT NULL DEFAULT 0, journal_ref TEXT, @@ -197,7 +193,6 @@ def _parse_json_field(value: Any) -> list | dict | None: -- Context model (main thread window, session tree, topics). Additive to the r6 tables. ALTER TABLE native_bindings ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'agent'; -ALTER TABLE native_bindings ADD COLUMN IF NOT EXISTS pod TEXT; ALTER TABLE native_bindings ADD COLUMN IF NOT EXISTS parent_session_id TEXT; ALTER TABLE native_bindings ADD COLUMN IF NOT EXISTS topic_id TEXT; ALTER TABLE native_bindings ADD COLUMN IF NOT EXISTS token_hash TEXT; @@ -224,11 +219,11 @@ def _parse_json_field(value: Any) -> list | dict | None: CREATE INDEX IF NOT EXISTS idx_native_bindings_parent ON native_bindings(parent_session_id); -- Substrate workspace-runtime adapter: durable mapping from a Mainloop session to a Substrate --- actor. Separate from native_bindings (the Herdr agent/native-session identity) because a +-- actor. Separate from native_bindings (native-agent session identity) because a -- session's workspace runtime is a distinct concept -- see ROADMAP.md "Workspace platform". -- One actor per session (workspace_id = session_id) replaces the fixed workspace pod for -- Substrate-backed sessions. ownership_generation fences resume/suspend/revert the same way --- native_bindings.generation fences Herdr sends: a stale caller's mutation is rejected, and a +-- native_bindings.generation fences native sends: a stale caller's mutation is rejected, and a -- retry re-inspects the actor and this row rather than creating a second one. CREATE TABLE IF NOT EXISTS workspace_bindings ( workspace_id TEXT PRIMARY KEY REFERENCES sessions(id), @@ -1184,9 +1179,9 @@ def _row_to_conversation(self, row: asyncpg.Record) -> Conversation: ) async def create_message( - self, conversation_id: str, role: str, content: str + self, conversation_id: str, role: str, content: str, *, conn: Any | None = None ) -> Message: - """Create a new message in a conversation.""" + """Create a new message, optionally inside a caller-owned transaction.""" import uuid message = Message( @@ -1196,10 +1191,11 @@ async def create_message( content=content, created_at=datetime.now(timezone.utc), ) - if not self._pool: + if not self._pool and conn is None: return message - async with self.connection() as conn: - await conn.execute( + + async def insert(connection) -> None: + await connection.execute( """ INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES ($1, $2, $3, $4, $5) @@ -1211,11 +1207,17 @@ async def create_message( message.created_at, ) # Update conversation's updated_at - await conn.execute( + await connection.execute( "UPDATE conversations SET updated_at = $1 WHERE id = $2", datetime.now(timezone.utc), conversation_id, ) + + if conn is not None: + await insert(conn) + else: + async with self.connection() as connection: + await insert(connection) return message async def get_messages(self, conversation_id: str) -> list[Message]: diff --git a/backend/src/mainloop/runtime/claude.py b/backend/src/mainloop/runtime/claude.py index 5d057dd..72b350e 100644 --- a/backend/src/mainloop/runtime/claude.py +++ b/backend/src/mainloop/runtime/claude.py @@ -1,7 +1,6 @@ """Fixture-only normalization for the native Claude Code stream boundary. -This module deliberately does not import ``claude_agent_sdk`` or start a Claude -process. It accepts validated, JSON-shaped observations from a native Claude +It accepts validated, JSON-shaped observations from a native Claude session and maps the observable parts to the provider-neutral runtime contract. The fixture envelope supplies the source cursor and raw-evidence reference; neither is synthesized from a process identity or a transcript message. @@ -398,8 +397,6 @@ def binding_from_init( *, binding_id: str, workspace_id: str, - herdr_session_id: str, - herdr_agent_id: str, creation_mode: Literal["created", "attached", "discovered"] = "created", ownership_generation: int = 1, ) -> NativeBinding: @@ -417,8 +414,6 @@ def binding_from_init( "provider": CLAUDE_PROVIDER, "runtime_type": "claude-native-cli", "native_session_id": native_session_id, - "herdr_session_id": herdr_session_id, - "herdr_agent_id": herdr_agent_id, "creation_mode": creation_mode, "ownership_generation": ownership_generation, "observed": _extension(event), diff --git a/backend/src/mainloop/runtime/herdr.py b/backend/src/mainloop/runtime/herdr.py deleted file mode 100644 index 0897227..0000000 --- a/backend/src/mainloop/runtime/herdr.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Thin Herdr adapter: drives ``agentctl`` in the workspace pod over Kubernetes pod-exec. - -Herdr owns liveness, naming and delivery of input. This adapter never reads a reply from a -terminal; replies, receipts and completion come from the native journals (``journal.py``), -which ``agentctl journal`` prints from the PVC. - -Transport: Kubernetes API pod-exec with a Role limited to pods get/list + pods/exec create in -the workspace namespace. ``TransportError`` means the outcome of the call is unknown. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import shlex -from dataclasses import dataclass - -from kubernetes import client, config -from kubernetes.client.rest import ApiException -from kubernetes.stream import stream -from mainloop.config import settings - -logger = logging.getLogger(__name__) - - -class TransportError(RuntimeError): - """The exec channel failed; whether the command ran is unknown.""" - - -class WorkspaceUnavailable(RuntimeError): - """The workspace pod is not Ready; nothing was attempted.""" - - -@dataclass(frozen=True, slots=True) -class ExecResult: - exit_code: int - stdout: str - stderr: str - - -@dataclass(frozen=True, slots=True) -class PodState: - name: str - uid: str | None - ready: bool - - -@dataclass(frozen=True, slots=True) -class JournalSlice: - file: str | None - total_lines: int - lines: list[tuple[int, str]] - - -_api: client.CoreV1Api | None = None - - -def _core() -> client.CoreV1Api: - global _api - if _api is None: - try: - config.load_incluster_config() - except config.ConfigException: - config.load_kube_config() - _api = client.CoreV1Api() - return _api - - -class HerdrWorkspace: - """One workspace pod running a Herdr server and ``agentctl``.""" - - def __init__( - self, - namespace: str | None = None, - pod: str | None = None, - container: str = "workspace", - ): - self.namespace = namespace or settings.workspace_namespace - self.pod = pod or settings.workspace_pod - self.container = container - - # -- transport ------------------------------------------------------------------------- - def _exec_sync(self, command: list[str], timeout: float) -> ExecResult: - try: - _core() # loads the cluster config once - # stream() swaps the ApiClient request function while it runs, which is not - # thread-safe: each exec gets its own client so concurrent polls cannot clash. - resp = stream( - client.CoreV1Api(client.ApiClient()).connect_get_namespaced_pod_exec, - self.pod, - self.namespace, - container=self.container, - command=command, - stderr=True, - stdin=False, - stdout=True, - tty=False, - _preload_content=False, - ) - resp.run_forever(timeout=timeout) - out, err = resp.read_stdout(), resp.read_stderr() - code = resp.returncode - resp.close() - except ( - ApiException, - OSError, - RuntimeError, - ) as exc: # websocket errors are OSError/Runtime - raise TransportError(f"exec failed: {type(exc).__name__}") from exc - if code is None: - raise TransportError("exec did not report an exit status") - return ExecResult(int(code), out or "", err or "") - - async def _exec(self, command: list[str], timeout: float = 45) -> ExecResult: - return await asyncio.to_thread(self._exec_sync, command, timeout) - - async def pod_state(self) -> PodState: - try: - pod = await asyncio.to_thread( - _core().read_namespaced_pod, self.pod, self.namespace - ) - except ApiException as exc: - if exc.status == 404: - return PodState(self.pod, None, False) - raise TransportError(f"pod read failed: {exc.status}") from exc - ready = any( - c.type == "Ready" and c.status == "True" - for c in (pod.status.conditions or []) - ) - if pod.metadata.deletion_timestamp is not None: - ready = False - return PodState(self.pod, pod.metadata.uid, ready) - - async def require_ready(self) -> PodState: - state = await self.pod_state() - if not state.ready: - raise WorkspaceUnavailable(f"workspace pod {self.pod} is not Ready") - return state - - # -- agentctl verbs -------------------------------------------------------------------- - async def agent_status(self, name: str) -> dict | None: - """Herdr liveness hint; ``None`` when Herdr has no such agent.""" - res = await self._exec(["agentctl", "status", name]) - text = res.stdout.strip() - if res.exit_code != 0 or not text: - return None - return json.loads(text.splitlines()[-1]) - - async def start( - self, - binding: str, - name: str, - *, - native_id: str | None, - resume: bool, - extra: dict[str, str] | None = None, - ) -> dict: - """Start (or resume) an agent. ``extra`` maps agentctl options (``--cwd-rel``, ``--model``, - ``--effort``, ``--standing-b64``, ``--token``) to values; secrets travel as argv over the - authenticated exec channel and are written to 0600 files on the PVC by agentctl. - """ - args = ["agentctl", "start", binding, "--name", name] - if native_id: - args += ["--resume" if resume else "--new-id", native_id] - for opt, value in (extra or {}).items(): - args += [opt, value] - res = await self._exec(args, timeout=100) - if res.exit_code != 0: - raise RuntimeError( - f"agent start failed (exit {res.exit_code}): {res.stderr.strip()[-200:]}" - ) - last = res.stdout.strip().splitlines()[-1] - return json.loads(last) if last.startswith("{") else {"note": last} - - async def send(self, name: str, text: str) -> None: - """Deliver one prompt. Raises TransportError if the outcome is unknown; never retries.""" - res = await self._exec(["agentctl", "send", name, text]) - if res.exit_code != 0: - raise RuntimeError( - f"send failed (exit {res.exit_code}): {res.stderr.strip()[-200:]}" - ) - - async def stop(self, name: str) -> None: - res = await self._exec(["agentctl", "stop", name], timeout=60) - if res.exit_code != 0: - raise RuntimeError( - f"agent stop failed (exit {res.exit_code}): {res.stderr.strip()[-200:]}" - ) - - async def native_id(self, name: str) -> str | None: - res = await self._exec(["agentctl", "native-id", name]) - return res.stdout.strip() or None if res.exit_code == 0 else None - - async def journal(self, name: str, native_id: str, from_line: int) -> JournalSlice: - res = await self._exec(["agentctl", "journal", name, native_id, str(from_line)]) - if res.exit_code != 0: - raise TransportError(f"journal read failed (exit {res.exit_code})") - file = None - total = from_line - lines: list[tuple[int, str]] = [] - for raw in res.stdout.split("\n"): - if not raw: - continue - if raw.startswith("#nofile"): - return JournalSlice(None, 0, []) - if raw.startswith("#file\t"): - _, file, count = raw.split("\t") - total = int(count) - continue - num, _, rest = raw.partition("\t") - if num.isdigit(): - lines.append((int(num), rest)) - return JournalSlice(file, total, lines) - - -def agentctl_quote(*parts: str) -> str: - """Only for logging/evidence; commands are passed as argv, never through a shell.""" - return " ".join(shlex.quote(p) for p in parts) diff --git a/backend/src/mainloop/runtime/journal.py b/backend/src/mainloop/runtime/journal.py index 45cd3fa..af34c73 100644 --- a/backend/src/mainloop/runtime/journal.py +++ b/backend/src/mainloop/runtime/journal.py @@ -1,6 +1,6 @@ """Read real native journals (Claude transcript JSONL, Codex rollout JSONL). -The journal is the authority for receipts, replies, completion and model. Herdr only +The journal is the authority for receipts, replies, completion and model. The actor shim delivers input and reports liveness. ``NativeEvent`` carries no text, so reply text is extracted here from the raw record; the same record is also passed through the existing adapters (``ClaudeSessionNormalizer``, ``observe_codex_event``) after a small translation @@ -50,7 +50,7 @@ class JournalEvent: def unwrap_paste(text: str) -> str: - """Claude Code wraps pasted (Herdr-delivered) input in ```` tags.""" + """Unwrap pasted input that Claude Code returns in ```` tags.""" match = _PASTED.match(text) return (match.group(1) if match else text).strip() @@ -70,15 +70,13 @@ def _text_blocks(content: Any, block_types: tuple[str, ...]) -> str: return "\n".join(p for p in parts if p) -def _binding(kind: str, native_id: str, agent: str) -> NativeBinding: +def _binding(kind: str, native_id: str) -> NativeBinding: return NativeBinding( binding_id=f"{kind}-{native_id}", - workspace_id="herdr-spike/workspace-0", + workspace_id="fixture/workspace-0", provider=kind, runtime_type=f"{kind}-native-cli", native_session_id=native_id, - herdr_session_id="mainloop-spike", - herdr_agent_id=agent, creation_mode="created", ownership_generation=1, ) @@ -92,9 +90,9 @@ def _iso(value: Any) -> str | None: def parse_claude( - lines: Iterable[tuple[int, str]], *, file_ref: str, native_id: str, agent: str + lines: Iterable[tuple[int, str]], *, file_ref: str, native_id: str ) -> list[JournalEvent]: - normalizer = ClaudeSessionNormalizer(_binding("claude", native_id, agent)) + normalizer = ClaudeSessionNormalizer(_binding("claude", native_id)) out: list[JournalEvent] = [] for cursor, line in lines: try: @@ -208,9 +206,9 @@ def _claude_normalize( def parse_codex( - lines: Iterable[tuple[int, str]], *, file_ref: str, native_id: str, agent: str + lines: Iterable[tuple[int, str]], *, file_ref: str, native_id: str ) -> list[JournalEvent]: - binding = _binding("codex", native_id, agent) + binding = _binding("codex", native_id) out: list[JournalEvent] = [] model: str | None = None for cursor, line in lines: @@ -290,12 +288,11 @@ def parse_journal( *, file_ref: str, native_id: str, - agent: str, ) -> list[JournalEvent]: if kind == "claude": - return parse_claude(lines, file_ref=file_ref, native_id=native_id, agent=agent) + return parse_claude(lines, file_ref=file_ref, native_id=native_id) if kind == "codex": - return parse_codex(lines, file_ref=file_ref, native_id=native_id, agent=agent) + return parse_codex(lines, file_ref=file_ref, native_id=native_id) raise ValueError(f"no journal reader for kind {kind}") diff --git a/backend/src/mainloop/runtime/native_sessions.py b/backend/src/mainloop/runtime/native_sessions.py index 3c2cfcb..9526049 100644 --- a/backend/src/mainloop/runtime/native_sessions.py +++ b/backend/src/mainloop/runtime/native_sessions.py @@ -1,4 +1,4 @@ -"""Sessions bound to a real native agent (Claude Code / Codex) under Herdr in the workspace pod. +"""Sessions bound to a native Claude Code or Codex agent in a Substrate workspace. Control-plane rules implemented here: - A user message is recorded, then a delivery row is persisted as ``sending`` *before* the @@ -7,8 +7,8 @@ - The native journal is the receipt: a prompt record after the recorded cursor proves delivery, the turn-completion record proves completion, and the assistant text in between is mirrored into the session conversation (deterministic ids, so repeated syncs are idempotent). -- After pod replacement the agent is not live in Herdr; the next delivery restarts it with the - native resume flag against the same native session id, then sends. +- The actor-local shim owns native CLI process lifetime and journal access. Before sending, the + adapter checks the existing native session and never replays a prompt blindly. Context model (plan r7): a binding has a ``role``. ``main`` is the conversation agent whose window Mainloop owns by rotation (a lineage of disposable native sessions; ``rotate``); ``child`` is a @@ -28,10 +28,13 @@ from mainloop.config import settings from mainloop.db import db from mainloop.runtime.agent_api import hash_token, token_for -from mainloop.runtime.herdr import HerdrWorkspace, TransportError, WorkspaceUnavailable from mainloop.runtime.journal import completed_turns, parse_journal from mainloop.runtime.standing import content_hash -from mainloop.runtime.substrate_workspace import SubstrateWorkspace +from mainloop.runtime.substrate import TransportError +from mainloop.runtime.substrate_workspace import ( + SubstrateWorkspace, + WorkspaceUnavailable, +) from models import NativeDeliveryInfo, NativeSessionInfo, SessionStatus @@ -39,13 +42,13 @@ APPROVAL_POLICY = "bypass-permissions" SEND_RECEIPT_GRACE = timedelta(seconds=60) -# A prompt seen in the journal whose turn never completes (agent exited or wedged, pod replaced): +# A prompt seen in the journal whose turn never completes (agent exited, wedged, or actor replaced): # after this long, or as soon as the agent is no longer live, it becomes 'uncertain' (never # replayed, never blocking) instead of holding the session in flight forever. DELIVERED_MAX_AGE = timedelta(minutes=30) _NS = uuid.UUID("6f0f7f0e-3f1e-4a3c-9d3b-0e4b6f5c2a11") _locks: dict[str, asyncio.Lock] = {} -_workspaces: dict[tuple[str, ...], HerdrWorkspace | SubstrateWorkspace] = {} +_workspaces: dict[tuple[str, ...], SubstrateWorkspace] = {} _rotating: set[str] = set() OPEN_STATES = ("recorded", "sending", "delivered") # Ended by the user or by failure. Agent activity never moves a session out of these. @@ -81,49 +84,30 @@ def is_rotating(session_id: str) -> bool: return session_id in _rotating -def workspace_for(binding: dict) -> HerdrWorkspace | SubstrateWorkspace: - """Select the configured transport and map a native binding to its workspace. - - In Substrate mode, the native kind selects its atespace, actor, and shim Secret from - ``SUBSTRATE_ACTOR_BINDINGS``. No actor identity is inferred from a session or binding. - Herdr's existing pod mapping remains the default and is unchanged. - """ - if settings.workspace_runtime == "substrate": - agent = binding["kind"] - actor_binding = settings.substrate_actor_bindings.get(agent) - if actor_binding is None: - raise RuntimeError( - f"no Substrate actor binding is configured for native agent {agent}" - ) - atespace = actor_binding.atespace - actor = actor_binding.actor - key = ( - "substrate", - atespace, - actor, - actor_binding.shim_token_secret_name, - binding["kind"], +def workspace_for(binding: dict) -> SubstrateWorkspace: + """Map a native binding to the Substrate actor configured for its agent kind.""" + agent = binding["kind"] + actor_binding = settings.substrate_actor_bindings.get(agent) + if actor_binding is None: + raise RuntimeError( + f"no Substrate actor binding is configured for native agent {agent}" ) - if key not in _workspaces: - _workspaces[key] = SubstrateWorkspace( - atespace=atespace, - actor=actor, - agent=agent, - shim_token_secret_name=actor_binding.shim_token_secret_name, - native_session_id=binding.get("native_session_id"), - ) - workspace = _workspaces[key] - if not isinstance(workspace, SubstrateWorkspace): - raise RuntimeError("workspace cache has an incompatible Substrate entry") - workspace.set_native_session_id(binding.get("native_session_id")) - return workspace - pod = binding.get("pod") or settings.workspace_pod - key = ("herdr", pod) + key = ( + actor_binding.atespace, + actor_binding.actor, + actor_binding.shim_token_secret_name, + agent, + ) if key not in _workspaces: - _workspaces[key] = HerdrWorkspace(pod=pod) + _workspaces[key] = SubstrateWorkspace( + atespace=actor_binding.atespace, + actor=actor_binding.actor, + agent=agent, + shim_token_secret_name=actor_binding.shim_token_secret_name, + native_session_id=binding.get("native_session_id"), + ) workspace = _workspaces[key] - if not isinstance(workspace, HerdrWorkspace): - raise RuntimeError("workspace cache has an incompatible Herdr entry") + workspace.set_native_session_id(binding.get("native_session_id")) return workspace @@ -194,15 +178,6 @@ async def _set_delivery( ) -def config_name(binding: dict) -> str: - """Agentctl binding config (ConfigMap ``.env``) for this binding.""" - if binding["role"] == "main": - return "claude-main" - if binding["role"] == "child": - return f"{binding['kind']}-child" - return binding["kind"] - - async def create_binding( session_id: str, kind: str, @@ -214,22 +189,20 @@ async def create_binding( # Claude takes the native session id up front (--session-id); Codex reports it in its journal. native_id = str(uuid.uuid4()) if kind == "claude" else None name = "ml-main" if role == "main" else agent_name(session_id, kind) - pod = settings.main_pod if role == "main" else None token_hash = ( hash_token(token_for(session_id)) if role in ("main", "child") else None ) async with db.connection() as conn: await conn.execute( """INSERT INTO native_bindings (session_id, kind, agent_name, native_session_id, approval_policy, - role, pod, parent_session_id, topic_id, token_hash, model) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)""", + role, parent_session_id, topic_id, token_hash, model) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)""", session_id, kind, name, native_id, APPROVAL_POLICY if role != "main" else "restricted: Bash(mainloop:*) only", role, - pod, parent_session_id, topic_id, token_hash, @@ -253,6 +226,55 @@ async def _open_count(session_id: str) -> int: ) +async def _record_delivery_message( + *, + session_id: str, + conversation_id: str, + text: str, + state: str, + source: str, +) -> str: + """Record the message and delivery under the workspace lock used by suspension.""" + async with db.connection() as conn: + async with conn.transaction(): + binding = await conn.fetchrow( + """SELECT workspace_id FROM workspace_bindings + WHERE workspace_id=$1 FOR UPDATE""", + session_id, + ) + lifecycle = ( + await conn.fetchrow( + """SELECT desired_state, observed_state FROM workspace_lifecycles + WHERE workspace_id=$1""", + session_id, + ) + if binding + else None + ) + if lifecycle and ( + lifecycle["desired_state"] == "suspended" + or lifecycle["observed_state"] in {"suspending", "suspended"} + ): + raise ValueError( + "The workspace is suspending or suspended; resume it before sending a message." + ) + + message = await db.create_message( + conversation_id=conversation_id, + role="user", + content=text, + conn=conn, + ) + await conn.execute( + "INSERT INTO native_deliveries (message_id, session_id, state, source) VALUES ($1,$2,$3,$4)", + message.id, + session_id, + state, + source, + ) + return message.id + + async def submit_message(session_id: str, text: str, *, source: str = "user") -> str: """Record a message and its delivery intent, then deliver in the background. @@ -281,20 +303,16 @@ async def submit_message(session_id: str, text: str, *, source: str = "user") -> if busy or (source == "report" and session_id in _rotating) else "recorded" ) - message = await db.create_message( - conversation_id=session.conversation_id, role="user", content=text + message_id = await _record_delivery_message( + session_id=session_id, + conversation_id=session.conversation_id, + text=text, + state=state, + source=source, ) - async with db.connection() as conn: - await conn.execute( - "INSERT INTO native_deliveries (message_id, session_id, state, source) VALUES ($1,$2,$3,$4)", - message.id, - session_id, - state, - source, - ) if state == "recorded": - asyncio.create_task(_deliver(session_id, message.id, text)) - return message.id + asyncio.create_task(_deliver(session_id, message_id, text)) + return message_id async def _start_extra(binding: dict) -> tuple[dict[str, str], str | None]: @@ -318,9 +336,9 @@ async def _start_extra(binding: dict) -> tuple[dict[str, str], str | None]: async def _ensure_agent(session_id: str, binding: dict) -> dict: - """Make sure the agent is live in Herdr, resuming the native session after pod replacement.""" + """Check native session readiness in its Substrate actor.""" ws = workspace_for(binding) - pod = await ws.require_ready() + await ws.require_ready() name = binding["agent_name"] status = await ws.agent_status(name) fields: dict = {} @@ -328,27 +346,16 @@ async def _ensure_agent(session_id: str, binding: dict) -> dict: # A journal already seen for this native session id means an earlier run: resume it. resume = binding["journal_ref"] is not None extra, standing_hash = await _start_extra(binding) - ident = await ws.start( - config_name(binding), + await ws.start( + binding["kind"], name, native_id=binding["native_session_id"], resume=resume, extra=extra, ) - fields.update( - herdr_pane_id=ident.get("pane_id"), - herdr_terminal_id=ident.get("terminal_id"), - herdr_workspace_id=ident.get("workspace_id"), - generation=binding["generation"] + (1 if resume else 0), - ) + fields.update(generation=binding["generation"] + (1 if resume else 0)) if standing_hash: fields["standing_hash"] = standing_hash - else: - fields.update( - herdr_pane_id=status.get("pane_id"), - herdr_terminal_id=status.get("terminal_id"), - ) - fields["pod_uid"] = pod.uid await _update_binding(session_id, **fields) return await get_binding(session_id) # type: ignore[return-value] @@ -474,7 +481,6 @@ async def _sync_locked(session_id: str) -> dict | None: jl.lines, file_ref=ref, native_id=binding["native_session_id"], - agent=binding["agent_name"], ) session = await db.get_session(session_id) async with db.connection() as conn: @@ -611,7 +617,7 @@ async def cancel(session_id: str) -> str: The status is set first and is sticky, so no later sync brings the session back, and open deliveries are failed so the reconcile loop stops visiting it. Returns ``stopped``, - ``not_running`` (Herdr had no such agent) or ``unknown`` (the stop could not be + ``not_running`` (the actor had no active turn) or ``unknown`` (the stop could not be confirmed; the agent may still be running, and it is not retried blindly). """ binding = await get_binding(session_id) @@ -801,10 +807,10 @@ async def identity(session_id: str) -> NativeSessionInfo | None: for r in rows ] ws = workspace_for(binding) - ready, live, uid, note = False, None, None, None + ready, live, _uid, note = False, None, None, None try: - pod = await ws.pod_state() - ready, uid = pod.ready, pod.uid + workspace = await ws.workspace_state() + ready = workspace.ready if ready: live = (await ws.agent_status(binding["agent_name"])) is not None except (TransportError, WorkspaceUnavailable) as exc: @@ -821,11 +827,7 @@ async def identity(session_id: str) -> NativeSessionInfo | None: native_session_id=binding["native_session_id"], model=binding["model"], approval_policy=binding["approval_policy"], - herdr_pane_id=binding["herdr_pane_id"], - herdr_terminal_id=binding["herdr_terminal_id"], - herdr_workspace_id=binding["herdr_workspace_id"], - workspace_pod=ws.pod, - workspace_pod_uid=uid, + workspace_name=ws.workspace_name, workspace_ready=ready, agent_live=live, generation=binding["generation"], diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py index b063a2c..f78edde 100644 --- a/backend/src/mainloop/runtime/substrate.py +++ b/backend/src/mainloop/runtime/substrate.py @@ -1,8 +1,8 @@ """Thin Substrate adapter: drives ``kubectl ate`` (control-plane CLI over gRPC to ``ate-api-server``) to manage per-session actors as Mainloop workspaces. -The actor image is designed for headless, per-turn native CLI invocations; no Herdr server or -terminal manager runs inside it. This adapter talks to Substrate's cluster-level control plane: +The actor image is designed for headless, per-turn native CLI invocations. This adapter talks to +Substrate's cluster-level control plane: actors are created, suspended, resumed, reverted and deleted through ``ateapipb.Control`` (see the pinned checkout's ``pkg/proto/ateapipb/ateapi.proto`` and ``cmd/kubectl-ate/internal/cmd/actor.go``). ``TransportError`` means the outcome of the call is diff --git a/backend/src/mainloop/runtime/substrate_workspace.py b/backend/src/mainloop/runtime/substrate_workspace.py index 3690ad2..26b3378 100644 --- a/backend/src/mainloop/runtime/substrate_workspace.py +++ b/backend/src/mainloop/runtime/substrate_workspace.py @@ -1,8 +1,7 @@ """Native-session transport to a pre-created Substrate actor through its CONNECT router. -The actor shim owns the native CLI turn and journal files. This adapter preserves the -``HerdrWorkspace`` method contract used by ``native_sessions`` while treating delivery errors -after ``POST /turn`` as unknown; callers must reconcile the journal and never replay blindly. +The actor-local shim owns the native CLI turn and journal files. Delivery errors after +``POST /turn`` are unknown; callers must reconcile the journal and never replay blindly. """ from __future__ import annotations @@ -20,15 +19,29 @@ from kubernetes import client, config from kubernetes.client.rest import ApiException from mainloop.config import settings -from mainloop.runtime.herdr import ( - JournalSlice, - PodState, - TransportError, - WorkspaceUnavailable, -) +from mainloop.runtime.substrate import TransportError logger = logging.getLogger(__name__) + +class WorkspaceUnavailable(RuntimeError): + """The Substrate actor is not ready; no turn was attempted.""" + + +@dataclass(frozen=True, slots=True) +class WorkspaceState: + name: str + uid: str | None + ready: bool + + +@dataclass(frozen=True, slots=True) +class JournalSlice: + file: str | None + total_lines: int + lines: list[tuple[int, str]] + + _AGENT = re.compile(r"^(claude|codex)$") _DNS_LABEL = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$") _MAX_RESPONSE_BYTES = 2 * 1024 * 1024 @@ -201,7 +214,7 @@ def __init__( raise ValueError("Substrate native agent must be claude or codex") self.atespace = atespace self.actor = actor - self.pod = actor # The existing native-session view exposes this display field. + self.workspace_name = actor self.agent = agent self.native_session_id = native_session_id address = urlsplit(router_address or settings.substrate_router_address) @@ -301,21 +314,21 @@ def _json(self, response: _Response, *, method: str) -> dict: raise TransportError("Substrate shim returned an invalid response") return document - async def pod_state(self) -> PodState: + async def workspace_state(self) -> WorkspaceState: try: response = await self._request("GET", "/healthz", authenticated=False) except WorkspaceUnavailable: - return PodState(self.actor, None, False) + return WorkspaceState(self.actor, None, False) if response.status == 503: - return PodState(self.actor, None, False) + return WorkspaceState(self.actor, None, False) if response.status != 200: raise TransportError( f"Substrate health check failed (HTTP {response.status})" ) - return PodState(self.actor, None, True) + return WorkspaceState(self.actor, None, True) - async def require_ready(self) -> PodState: - state = await self.pod_state() + async def require_ready(self) -> WorkspaceState: + state = await self.workspace_state() if not state.ready: raise WorkspaceUnavailable( f"Substrate actor {self.atespace}/{self.actor} is not ready" diff --git a/backend/src/mainloop/services/chat_handler.py b/backend/src/mainloop/services/chat_handler.py deleted file mode 100644 index 74d6c1b..0000000 --- a/backend/src/mainloop/services/chat_handler.py +++ /dev/null @@ -1,535 +0,0 @@ -"""Synchronous chat handler - processes messages and returns immediate responses.""" - -import logging -from dataclasses import dataclass -from typing import Any, AsyncIterator - -from claude_agent_sdk import ( - AssistantMessage, - ClaudeAgentOptions, - ResultMessage, - SystemMessage, - TextBlock, - create_sdk_mcp_server, - query, - tool, -) -from dbos import SetWorkflowID -from mainloop.config import settings -from mainloop.db import db -from mainloop.workflows.dbos_config import worker_queue - -from models import ( - Message, - QueueItem, - Session, - SessionStatus, -) - -logger = logging.getLogger(__name__) - - -def build_chat_system_prompt(recent_repos: list[str] | None = None) -> str: - """Build the system prompt for chat, including recent repos if available.""" - base_prompt = """You are a helpful AI assistant that can spawn background sessions to work on tasks independently. - -## spawn_session -Use spawn_session when the user requests work that should run in the background. - -When to use spawn_session WITH repo_url (for code work): -- Creating, modifying, or deleting code files -- Making commits or pull requests -- Running builds, tests, or deployments -- Any work that requires access to a codebase - -When to use spawn_session WITHOUT repo_url (for other background work): -- Research tasks that take time -- Analysis or investigation work -- Planning or brainstorming that needs multiple steps -- Any work that can run in the background - -IMPORTANT - When spawning a session: -1. IMMEDIATELY acknowledge and spawn - don't ask for confirmation -2. Respond with a brief acknowledgment like "On it - spawning a session to [task summary]" -3. Then call spawn_session with the title (and repo_url for code work) - -Do NOT use spawn_session for: -- Answering simple questions -- Explaining concepts or providing information -- General conversation you can handle directly""" - - if recent_repos: - repos_list = "\n".join(f" - {repo}" for repo in recent_repos) - base_prompt += f""" - -The user has recently worked with these repositories: -{repos_list} - -If the request involves code work and includes a repo URL, use it. Otherwise use the most relevant recent repo. -""" - else: - base_prompt += """ - -If the request involves code work but no repo is provided, ask for the GitHub repo URL. -""" - - return base_prompt - - -def create_spawn_session_callable( - user_id: str, - main_thread_id: str, - conversation_id: str, - spawned_session_ids: list[str], # Mutable list to track spawned sessions -): - """Create a raw spawn_session callable for Claude to use. - - Sessions are unified background work - they can be simple Claude conversations - or code work with GitHub integration. - """ - - async def spawn_session_impl(args: dict[str, Any]) -> dict[str, Any]: - """Spawn a background session to work on a task independently.""" - print(f"[SESSION] spawn_session_impl called with args: {args}") - title = args.get("title", "") - repo_url = args.get("repo_url") # Optional - if provided, this is code work - request_message_id = args.get( - "request_message_id" - ) # ID of the user's original request - - # Fetch the original request message from DB - anchor_message_id = None - prompt = "" - try: - if request_message_id: - # Claude told us which message contains the request - fetch it - request_msg = await db.get_message(request_message_id) - if request_msg and request_msg.role == "user": - anchor_message_id = request_msg.id - prompt = request_msg.content - print( - f"[SESSION] Using specified message {request_message_id}: {prompt[:100]}..." - ) - - # Fallback: use last user message if no ID provided or not found - if not prompt: - conv_messages = await db.get_messages(conversation_id) - if conv_messages: - for msg in reversed(conv_messages): - if msg.role == "user": - anchor_message_id = msg.id - prompt = msg.content - break - print(f"[SESSION] Fallback to last user message: {prompt[:100]}...") - except Exception as e: - print(f"[SESSION] Warning: Could not get message: {e}") - - if not title: - return { - "content": [{"type": "text", "text": "Error: title is required"}], - "is_error": True, - } - - if not prompt: - return { - "content": [ - { - "type": "text", - "text": "Error: Could not find a user message in the conversation to use as the session prompt.", - } - ], - "is_error": True, - } - - # Use title as description - description = title - - # Validate repo URL format if provided - if repo_url and not repo_url.startswith("https://github.com/"): - return { - "content": [ - { - "type": "text", - "text": f"Error: Invalid repo URL format. Expected https://github.com/owner/repo, got: {repo_url}", - } - ], - "is_error": True, - } - - try: - is_code_work = repo_url is not None - print(f"[SESSION] Creating session: {title} (code_work={is_code_work})") - - # Create conversation for this session - conv = await db.create_conversation(user_id, title=title) - print(f"[SESSION] Created conversation: {conv.id}") - - # Create project from repo URL if provided (so it shows in sidebar) - project_id = None - if repo_url: - print(f"[SESSION] Creating project for repo: {repo_url}") - project = await db.get_or_create_project_from_url(user_id, repo_url) - project_id = project.id - print( - f"[SESSION] Project created/found: {project.id} - {project.full_name}" - ) - # Record this repo as recently used - await db.add_recent_repo(main_thread_id, repo_url) - - # Create session anchored to user's message - from mainloop.api import _get_next_session_color - - color = await _get_next_session_color(user_id) - session = Session( - user_id=user_id, - main_thread_id=main_thread_id, - title=title, - description=description or title, - prompt=prompt, - conversation_id=conv.id, - status=SessionStatus.PENDING, - anchor_message_id=anchor_message_id, - color=color, - # Code work fields (optional) - repo_url=repo_url, - project_id=project_id, - ) - session = await db.create_session(session) - print(f"[SESSION] Session saved to DB: {session.id}") - - # Track this session for anchor update after assistant message is saved - spawned_session_ids.append(session.id) - - # Start the session workflow - from mainloop.workflows.session_worker import session_worker_workflow - - print(f"[SESSION] Enqueueing workflow for session {session.id}") - with SetWorkflowID(session.id): - handle = worker_queue.enqueue(session_worker_workflow, session.id) - print(f"[SESSION] Workflow enqueued, handle: {handle}") - - logger.info( - f"Spawned session via tool: {session.id} (code_work={is_code_work})" - ) - - response_text = ( - f"Session started successfully!\n" - f"Session ID: {session.id[:8]}\n" - f"Title: {title}\n" - ) - if repo_url: - response_text += f"Repository: {repo_url}\n" - response_text += ( - "\nThe session is now running in the background. " - "It will appear in the user's Sessions panel." - ) - - return { - "content": [ - { - "type": "text", - "text": response_text, - } - ] - } - except Exception as e: - import traceback - - print(f"[SESSION] ERROR: {e}") - print(f"[SESSION] Traceback: {traceback.format_exc()}") - logger.error(f"Failed to spawn session: {e}") - return { - "content": [ - {"type": "text", "text": f"Error spawning session: {str(e)}"} - ], - "is_error": True, - } - - return spawn_session_impl - - -def create_spawn_session_tool( - user_id: str, - main_thread_id: str, - conversation_id: str, - spawned_session_ids: list[str], # Mutable list to track spawned sessions -): - """Create a spawn_session tool with context baked in. - - This factory creates a tool that has access to the current user/conversation context. - Returns an SdkMcpTool for use with Claude Agent SDK. - """ - # Get the raw callable - spawn_session_impl = create_spawn_session_callable( - user_id, main_thread_id, conversation_id, spawned_session_ids - ) - - # Wrap it with the @tool decorator for Claude - @tool( - "spawn_session", - "Spawn a background session to work on the user's request. " - "Use this for: (1) code work - provide repo_url for GitHub integration, " - "(2) research/analysis - omit repo_url for general background work. " - "IMPORTANT: Pass the request_message_id from the conversation history [ID: ...] " - "that contains the user's actual request (not a confirmation like 'yes').", - { - "title": str, # Short title for the session (e.g., "Add quickstart to README") - "request_message_id": str, # ID from conversation [ID: ...] with the user's request - "repo_url": str, # Optional - if provided, enables code work with GitHub - }, - ) - async def spawn_session(args: dict[str, Any]) -> dict[str, Any]: - return await spawn_session_impl(args) - - return spawn_session - - -def format_conversation_history(messages: list[Message]) -> str: - """Format conversation history for inclusion in prompt. - - Includes message IDs so Claude can reference them when spawning sessions. - """ - if not messages: - return "" - - lines = [] - for msg in messages: - role = "User" if msg.role == "user" else "Assistant" - lines.append(f"[ID: {msg.id}] {role}: {msg.content}") - - return "\n\n".join(lines) - - -def build_context_prompt( - summary: str | None, - recent_messages: list[Message], - new_message: str, -) -> str: - """Build the full prompt with summary and recent messages. - - Structure: - 1. Summary of earlier conversation (if exists) - 2. Recent messages (unsummarized) - 3. New user message - """ - parts = [] - - if summary: - parts.append(f"[Summary of earlier conversation]\n{summary}") - - if recent_messages: - history = format_conversation_history(recent_messages) - parts.append(f"[Recent conversation]\n{history}") - - parts.append(f"User: {new_message}") - - if parts: - context = "\n\n".join(parts) - return f"""Continue this conversation naturally, taking into account the full context above. - -{context} - -Respond to the user's latest message.""" - else: - return new_message - - -@dataclass -class ChatResult: - """Result of processing a chat message.""" - - response: str - task_id: str | None = None - needs_inbox_action: bool = False - queue_item: QueueItem | None = None - spawned_session_ids: list[str] | None = None # Sessions created during this turn - suppress_response: bool = False # Don't save assistant message to main thread - - -@dataclass -class ClaudeResponse: - """Response from Claude.""" - - text: str - compacted: bool = False - compaction_count: int = 0 - spawned_session_ids: list[str] | None = None # Sessions created during this turn - - -def _create_message_generator(prompt_text: str) -> AsyncIterator[dict]: - """Create an async generator that yields the user message. - - This is required when using MCP servers with Claude Agent SDK. - The SDK requires an async iterable for streaming input when MCP tools are configured. - """ - - async def generator(): - yield { - "type": "user", - "message": { - "role": "user", - "content": prompt_text, - }, - } - - return generator() - - -async def get_claude_response( - message: str, - summary: str | None = None, - recent_messages: list[Message] | None = None, - model: str = "sonnet", - user_id: str | None = None, - main_thread_id: str | None = None, - conversation_id: str | None = None, -) -> ClaudeResponse: - """Get a response from Claude with conversation context and spawn_task tool. - - Context is provided via: - - summary: Compacted summary of older messages (from PostgreSQL) - - recent_messages: Recent unsummarized messages (from PostgreSQL) - - If user_id, main_thread_id, and conversation_id are provided, Claude - will have access to the spawn_task tool to spawn autonomous worker agents. - - This ensures continuity across sessions, pod restarts, and deployments. - """ - try: - # Build prompt with summary and recent messages - prompt_text = build_context_prompt(summary, recent_messages or [], message) - - # Create MCP server with spawn_task tool if context is provided - mcp_servers = {} - allowed_tools = [] - system_prompt = None - spawned_session_ids: list[str] = [] # Track sessions created during this turn - - if user_id and main_thread_id and conversation_id: - spawn_session_tool = create_spawn_session_tool( - user_id, main_thread_id, conversation_id, spawned_session_ids - ) - mcp_server = create_sdk_mcp_server( - name="mainloop", - version="1.0.0", - tools=[spawn_session_tool], - ) - mcp_servers["mainloop"] = mcp_server - allowed_tools.append("mcp__mainloop__spawn_session") - - # Fetch recent repos for system prompt - recent_repos = await db.get_recent_repos(main_thread_id) - system_prompt = build_chat_system_prompt(recent_repos) - - options = ClaudeAgentOptions( - model=model, - permission_mode="bypassPermissions", - system_prompt=system_prompt, - mcp_servers=mcp_servers if mcp_servers else None, - allowed_tools=allowed_tools if allowed_tools else None, - ) - - print( - f"[CLAUDE] query - model={model}, mcp_servers={list(mcp_servers.keys()) if mcp_servers else None}, " - f"allowed_tools={allowed_tools}" - ) - - # CRITICAL: When using MCP servers, must use async generator for prompt. - # This is a Claude Agent SDK requirement - string prompts fail with - # "ProcessTransport is not ready for writing" error. - if mcp_servers: - prompt = _create_message_generator(prompt_text) - else: - prompt = prompt_text - - collected_text = [] - compaction_count: int = 0 - - async for msg in query(prompt=prompt, options=options): - if isinstance(msg, AssistantMessage): - for block in msg.content: - if isinstance(block, TextBlock): - collected_text.append(block.text) - elif isinstance(msg, ResultMessage): - if msg.is_error: - return ClaudeResponse( - text=f"Sorry, I encountered an error: {msg.result or 'Unknown error'}", - compacted=compaction_count > 0, - compaction_count=compaction_count, - spawned_session_ids=( - spawned_session_ids if spawned_session_ids else None - ), - ) - elif isinstance(msg, SystemMessage): - # Track compaction events (context was automatically summarized) - if msg.subtype == "compact_boundary": - compaction_count += 1 - data = msg.data or {} - pre_tokens = data.get("pre_tokens", 0) - trigger = data.get("trigger", "unknown") - logger.info( - f"Context compacted ({trigger}): {pre_tokens} tokens summarized" - ) - - return ClaudeResponse( - text=( - "\n".join(collected_text) - if collected_text - else "No response generated." - ), - compacted=compaction_count > 0, - compaction_count=compaction_count, - spawned_session_ids=spawned_session_ids if spawned_session_ids else None, - ) - except Exception as e: - logger.error(f"Claude Agent SDK error: {e}") - return ClaudeResponse( - text=f"Sorry, I encountered an error: {str(e)}", - spawned_session_ids=spawned_session_ids if spawned_session_ids else None, - ) - - -async def process_message( - user_id: str, - message: str, - conversation_id: str, - main_thread_id: str, - summary: str | None = None, - recent_messages: list[Message] | None = None, -) -> ChatResult: - """Process a user message and return an immediate response. - - Claude has access to spawn_session tool to create background sessions - when the user wants to start tasks. - - Args: - user_id: The user's unique identifier. - message: The user's message text. - conversation_id: The conversation's unique identifier. - main_thread_id: The main thread workflow ID. - summary: Compacted summary of older messages. - recent_messages: Recent unsummarized messages for context. - - """ - # Get Claude response with spawn_session tool available - # Claude will naturally decide when to ask for confirmation and spawn sessions - model = settings.claude_model # Uses haiku by default - claude_response = await get_claude_response( - message, - summary=summary, - recent_messages=recent_messages, - model=model, - user_id=user_id, - main_thread_id=main_thread_id, - conversation_id=conversation_id, - ) - - # If sessions were spawned, suppress the main thread response - # The user interacts with the session directly - suppress = bool(claude_response.spawned_session_ids) - - return ChatResult( - response=claude_response.text, - spawned_session_ids=claude_response.spawned_session_ids, - suppress_response=suppress, - ) diff --git a/backend/src/mainloop/services/claude_agent.py b/backend/src/mainloop/services/claude_agent.py deleted file mode 100644 index 588036c..0000000 --- a/backend/src/mainloop/services/claude_agent.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Client for the Claude Agent container HTTP API.""" - -import logging -from typing import AsyncGenerator - -import httpx -from mainloop.config import settings -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - - -class ExecuteResponse(BaseModel): - """Response from Claude Agent execution.""" - - output: str - session_id: str | None = None - cost_usd: float | None = None - error: str | None = None - - -class ClaudeAgentClient: - """HTTP client for the Claude Agent container.""" - - def __init__(self, base_url: str | None = None): - self.base_url = ( - base_url or settings.claude_agent_url or "http://claude-agent:8001" - ) - - async def execute( - self, - prompt: str, - model: str = "sonnet", - timeout: float = 300.0, - ) -> ExecuteResponse: - """ - Execute a prompt using Claude Agent SDK. - - Claude-agent manages its own isolated workspace internally. - - Args: - prompt: The prompt to execute - model: Model to use (haiku, sonnet, opus) - timeout: Request timeout in seconds - - Returns: - ExecuteResponse with output or error - - """ - async with httpx.AsyncClient(timeout=timeout) as client: - try: - response = await client.post( - f"{self.base_url}/execute", - json={ - "prompt": prompt, - "model": model, - }, - ) - response.raise_for_status() - return ExecuteResponse(**response.json()) - except httpx.HTTPStatusError as e: - logger.error(f"Claude agent HTTP error: {e}") - return ExecuteResponse( - output="", - error=f"HTTP {e.response.status_code}: {e.response.text}", - ) - except httpx.RequestError as e: - logger.error(f"Claude agent request error: {e}") - return ExecuteResponse( - output="", - error=f"Request failed: {str(e)}", - ) - - async def execute_stream( - self, - prompt: str, - model: str = "sonnet", - timeout: float = 300.0, - ) -> AsyncGenerator[dict, None]: - """ - Stream execution results from Claude Agent SDK. - - Yields: - Dict events with type: 'text', 'result', or 'error' - - """ - import json - - async with httpx.AsyncClient(timeout=timeout) as client: - try: - async with client.stream( - "POST", - f"{self.base_url}/execute/stream", - json={ - "prompt": prompt, - "model": model, - }, - ) as response: - response.raise_for_status() - async for line in response.aiter_lines(): - if line.startswith("data: "): - data = line[6:] - if data == "[DONE]": - break - try: - yield json.loads(data) - except json.JSONDecodeError: - continue - except httpx.HTTPStatusError as e: - yield {"type": "error", "error": f"HTTP {e.response.status_code}"} - except httpx.RequestError as e: - yield {"type": "error", "error": str(e)} - - async def health_check(self) -> dict: - """Check if the Claude Agent service is healthy.""" - async with httpx.AsyncClient(timeout=5.0) as client: - try: - response = await client.get(f"{self.base_url}/health") - response.raise_for_status() - return response.json() - except Exception as e: - return {"status": "error", "error": str(e)} - - -# Singleton client instance -_client: ClaudeAgentClient | None = None - - -def get_claude_agent_client() -> ClaudeAgentClient: - """Get the singleton Claude Agent client.""" - global _client - if _client is None: - _client = ClaudeAgentClient() - return _client diff --git a/backend/src/mainloop/services/compaction.py b/backend/src/mainloop/services/compaction.py deleted file mode 100644 index a2d6942..0000000 --- a/backend/src/mainloop/services/compaction.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Async conversation compaction service. - -Summarizes older messages to manage context window while preserving conversation history. -Runs asynchronously to avoid blocking the main chat flow. -""" - -import asyncio -import logging - -from claude_agent_sdk import ( - AssistantMessage, - ClaudeAgentOptions, - ResultMessage, - TextBlock, - query, -) -from mainloop.config import settings -from mainloop.db import db - -from models import Message - -logger = logging.getLogger(__name__) - -# Compaction thresholds -COMPACTION_THRESHOLD = 40 # Trigger compaction when message_count exceeds this -MESSAGES_TO_SUMMARIZE = 30 # Number of oldest messages to summarize -RECENT_MESSAGES_TO_KEEP = 10 # Keep this many recent messages unsummarized - - -async def summarize_messages(messages: list[Message]) -> str: - """Use Claude to summarize a list of messages.""" - if not messages: - return "" - - # Format messages for summarization - formatted = [] - for msg in messages: - role = "User" if msg.role == "user" else "Assistant" - formatted.append(f"{role}: {msg.content}") - - conversation_text = "\n\n".join(formatted) - - prompt = f"""Summarize this conversation concisely, preserving key information: -- Important facts mentioned (names, preferences, decisions) -- Key topics discussed -- Any commitments or action items -- Context needed to continue the conversation naturally - -Conversation to summarize: -{conversation_text} - -Write a concise summary (2-4 paragraphs) that captures the essential context.""" - - try: - options = ClaudeAgentOptions( - model=settings.claude_model, # Use same model as main thread - permission_mode="bypassPermissions", - ) - - collected_text: list[str] = [] - async for msg in query(prompt=prompt, options=options): - if isinstance(msg, AssistantMessage): - for block in msg.content: - if isinstance(block, TextBlock): - collected_text.append(block.text) - elif isinstance(msg, ResultMessage): - if msg.is_error: - logger.error(f"Summarization error: {msg.result}") - return "" - - return "\n".join(collected_text) - except Exception as e: - logger.error(f"Failed to summarize messages: {e}") - return "" - - -async def compact_conversation(conversation_id: str) -> None: - """Compact a conversation by summarizing older messages. - - This runs asynchronously and updates the conversation's summary field. - """ - try: - conversation = await db.get_conversation(conversation_id) - if not conversation: - logger.warning(f"Conversation {conversation_id} not found for compaction") - return - - # Check if compaction is needed - if conversation.message_count < COMPACTION_THRESHOLD: - return - - logger.info( - f"Starting compaction for conversation {conversation_id} " - f"(message_count={conversation.message_count})" - ) - - # Get messages to summarize (oldest ones) - messages_to_summarize = await db.get_messages_for_compaction( - conversation_id, MESSAGES_TO_SUMMARIZE - ) - - if not messages_to_summarize: - return - - # Include existing summary in new summary if present - existing_summary = conversation.summary or "" - - if existing_summary: - # Append new messages to existing summary context - summary_prompt_messages = messages_to_summarize - existing_context = ( - f"Previous summary:\n{existing_summary}\n\nNew messages to incorporate:" - ) - else: - existing_context = "" - summary_prompt_messages = messages_to_summarize - - # Generate summary - new_summary = await summarize_messages(summary_prompt_messages) - - if not new_summary: - logger.warning( - f"Failed to generate summary for conversation {conversation_id}" - ) - return - - # Combine with existing summary if present - if existing_context: - final_summary = f"{existing_summary}\n\n{new_summary}" - else: - final_summary = new_summary - - # Get the ID of the last summarized message - last_summarized = messages_to_summarize[-1] - - # Update conversation with new summary - await db.update_conversation_summary( - conversation_id=conversation_id, - summary=final_summary, - summarized_through_id=last_summarized.id, - ) - - logger.info( - f"Compaction complete for conversation {conversation_id}: " - f"summarized {len(messages_to_summarize)} messages" - ) - - except Exception as e: - logger.error(f"Compaction failed for conversation {conversation_id}: {e}") - - -def trigger_compaction(conversation_id: str, message_count: int) -> None: - """Fire-and-forget trigger for compaction. - - Checks if compaction is needed and schedules it asynchronously. - Does not block the calling code. - """ - if message_count < COMPACTION_THRESHOLD: - return - - # Schedule compaction as a background task - asyncio.create_task(compact_conversation(conversation_id)) - logger.debug(f"Scheduled compaction for conversation {conversation_id}") diff --git a/backend/src/mainloop/services/k8s_jobs.py b/backend/src/mainloop/services/k8s_jobs.py deleted file mode 100644 index b97d433..0000000 --- a/backend/src/mainloop/services/k8s_jobs.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Kubernetes Job management for session tasks.""" - -import logging - -from kubernetes import client -from kubernetes.client.rest import ApiException -from mainloop.config import settings -from mainloop.services.k8s_namespace import WORKER_SERVICE_ACCOUNT, get_k8s_client - -logger = logging.getLogger(__name__) - -# Job configuration -JOB_TTL_SECONDS = 3600 # Keep completed jobs for 1 hour - - -async def create_session_job( - session_id: str, - namespace: str, - prompt: str, - callback_url: str, - model: str | None = None, - iteration: int = 0, -) -> str: - """Create a session Job in the session namespace. - - Args: - session_id: The session ID - namespace: Target namespace for the Job - prompt: The full prompt with conversation context - callback_url: URL to POST results to - model: Claude model to use (defaults to settings.claude_model) - iteration: Iteration number for jobs (ensures unique names) - - Returns: - The Job name - - """ - _, batch_v1 = get_k8s_client() - - # Include iteration in job name to ensure uniqueness across rounds - if iteration > 0: - job_name = f"session-{session_id[:8]}-{iteration}" - else: - job_name = f"session-{session_id[:8]}" - - # Check if job already exists and is completed - delete it to allow retry - try: - existing_job = batch_v1.read_namespaced_job(name=job_name, namespace=namespace) - status = existing_job.status - if (status.succeeded and status.succeeded > 0) or ( - status.failed and status.failed > 0 - ): - # Job completed, delete it to allow re-creation - logger.info(f"Deleting completed job {job_name} for retry") - batch_v1.delete_namespaced_job( - name=job_name, - namespace=namespace, - body=client.V1DeleteOptions(propagation_policy="Background"), - ) - # Brief wait for deletion to propagate - import asyncio - - await asyncio.sleep(1) - except ApiException as e: - if e.status != 404: - raise - # Job doesn't exist, continue with creation - - model = model or settings.claude_model - - # Environment variables for the job - env_vars = [ - client.V1EnvVar(name="SESSION_ID", value=session_id), - client.V1EnvVar(name="TASK_PROMPT", value=prompt), - client.V1EnvVar(name="CALLBACK_URL", value=callback_url), - client.V1EnvVar(name="CLAUDE_MODEL", value=model), - # Claude credentials from secret - client.V1EnvVar( - name="CLAUDE_CODE_OAUTH_TOKEN", - value_from=client.V1EnvVarSource( - secret_key_ref=client.V1SecretKeySelector( - name="mainloop-secrets", - key="claude-secret-token", - ) - ), - ), - # GitHub token from secret (optional, for gh CLI access) - client.V1EnvVar( - name="GH_TOKEN", - value_from=client.V1EnvVarSource( - secret_key_ref=client.V1SecretKeySelector( - name="mainloop-secrets", - key="github-token", - optional=True, - ) - ), - ), - ] - - # Job spec - job = client.V1Job( - metadata=client.V1ObjectMeta( - name=job_name, - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - spec=client.V1JobSpec( - ttl_seconds_after_finished=JOB_TTL_SECONDS, - backoff_limit=0, # Don't retry failed jobs - template=client.V1PodTemplateSpec( - metadata=client.V1ObjectMeta( - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - spec=client.V1PodSpec( - restart_policy="Never", - service_account_name=WORKER_SERVICE_ACCOUNT, - # Only use image pull secrets for remote registries - image_pull_secrets=( - [client.V1LocalObjectReference(name="ghcr-secret")] - if settings.worker_image.startswith("ghcr.io/") - else None - ), - containers=[ - client.V1Container( - name="claude-agent", - image=settings.worker_image, - image_pull_policy=settings.worker_image_pull_policy, - command=["/app/.venv/bin/python", "/app/job_runner.py"], - env=env_vars, - resources=client.V1ResourceRequirements( - requests={"memory": "512Mi", "cpu": "500m"}, - limits={"memory": "2Gi", "cpu": "2"}, - ), - volume_mounts=[ - client.V1VolumeMount( - name="workspace", - mount_path="/workspace", - ), - ], - ), - ], - volumes=[ - client.V1Volume( - name="workspace", - empty_dir=client.V1EmptyDirVolumeSource(), - ), - ], - ), - ), - ), - ) - - try: - batch_v1.create_namespaced_job(namespace=namespace, body=job) - logger.info(f"Created job {job_name} in namespace {namespace}") - except ApiException as e: - if e.status == 409: - logger.info(f"Job {job_name} already exists in {namespace}") - else: - raise - - return job_name - - -async def get_job_status(session_id: str, namespace: str) -> dict | None: - """Get the status of a session Job. - - Args: - session_id: The session ID - namespace: Namespace where the Job is running - - Returns: - Job status dict or None if not found - - """ - _, batch_v1 = get_k8s_client() - - try: - jobs = batch_v1.list_namespaced_job( - namespace=namespace, - label_selector=f"mainloop.dev/session-id={session_id}", - ) - - if not jobs.items: - return None - - job = jobs.items[0] - status = job.status - - return { - "name": job.metadata.name, - "active": status.active or 0, - "succeeded": status.succeeded or 0, - "failed": status.failed or 0, - "start_time": status.start_time.isoformat() if status.start_time else None, - "completion_time": ( - status.completion_time.isoformat() if status.completion_time else None - ), - } - - except ApiException as e: - if e.status == 404: - return None - raise - - -async def delete_job(session_id: str, namespace: str) -> None: - """Delete a session Job. - - Args: - session_id: The session ID - namespace: Namespace where the Job is running - - """ - _, batch_v1 = get_k8s_client() - - try: - jobs = batch_v1.list_namespaced_job( - namespace=namespace, - label_selector=f"mainloop.dev/session-id={session_id}", - ) - - for job in jobs.items: - batch_v1.delete_namespaced_job( - name=job.metadata.name, - namespace=namespace, - body=client.V1DeleteOptions( - propagation_policy="Background", - ), - ) - logger.info(f"Deleted job {job.metadata.name} from namespace {namespace}") - - except ApiException as e: - if e.status == 404: - logger.info(f"No jobs found for session {session_id} in {namespace}") - else: - raise - - -async def get_job_logs(session_id: str, namespace: str) -> str | None: - """Get logs from a session Job's pod. - - Args: - session_id: The session ID - namespace: Namespace where the Job is running - - Returns: - Pod logs as string or None if not found - - """ - core_v1, _ = get_k8s_client() - - try: - pods = core_v1.list_namespaced_pod( - namespace=namespace, - label_selector=f"mainloop.dev/session-id={session_id}", - ) - - if not pods.items: - return None - - pod = pods.items[0] - logs = core_v1.read_namespaced_pod_log( - name=pod.metadata.name, - namespace=namespace, - container="claude-agent", - ) - - return logs - - except ApiException as e: - if e.status == 404: - return None - raise diff --git a/backend/src/mainloop/services/k8s_namespace.py b/backend/src/mainloop/services/k8s_namespace.py deleted file mode 100644 index 990f80b..0000000 --- a/backend/src/mainloop/services/k8s_namespace.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Kubernetes namespace management for task isolation.""" - -import logging - -from kubernetes import client, config -from kubernetes.client.rest import ApiException - -logger = logging.getLogger(__name__) - -# Namespace prefix for session namespaces -SESSION_NAMESPACE_PREFIX = "mainloop-session-" - -# Secrets to copy from mainloop namespace to task namespaces -DEFAULT_SECRETS_TO_COPY = [ - "mainloop-secrets", - "ghcr-secret", # Image pull secret for ghcr.io -] - -# Source namespace for secrets -SOURCE_NAMESPACE = "mainloop" - -# Worker service account name -WORKER_SERVICE_ACCOUNT = "worker" - -# ClusterRole to bind to worker service account -WORKER_CLUSTER_ROLE = "mainloop-worker-role" - - -def get_k8s_client() -> tuple[client.CoreV1Api, client.BatchV1Api]: - """Get Kubernetes API clients. - - Loads in-cluster config when running in K8s, falls back to kubeconfig for local dev. - """ - try: - config.load_incluster_config() - logger.info("Loaded in-cluster Kubernetes config") - except config.ConfigException: - config.load_kube_config() - logger.info("Loaded kubeconfig for local development") - - return client.CoreV1Api(), client.BatchV1Api() - - -def get_rbac_client() -> client.RbacAuthorizationV1Api: - """Get Kubernetes RBAC API client.""" - try: - config.load_incluster_config() - except config.ConfigException: - config.load_kube_config() - - return client.RbacAuthorizationV1Api() - - -def get_networking_client() -> client.NetworkingV1Api: - """Get Kubernetes Networking API client.""" - try: - config.load_incluster_config() - except config.ConfigException: - config.load_kube_config() - - return client.NetworkingV1Api() - - -async def create_session_namespace(session_id: str) -> str: - """Create an isolated namespace for a session. - - Args: - session_id: The session ID (will be used in namespace name) - - Returns: - The namespace name that was created - - """ - core_v1, _ = get_k8s_client() - namespace_name = f"{SESSION_NAMESPACE_PREFIX}{session_id[:8]}" - - namespace = client.V1Namespace( - metadata=client.V1ObjectMeta( - name=namespace_name, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ) - ) - - try: - core_v1.create_namespace(body=namespace) - logger.info(f"Created namespace: {namespace_name}") - except ApiException as e: - if e.status == 409: - logger.info(f"Namespace {namespace_name} already exists") - else: - raise - - return namespace_name - - -async def copy_secrets_to_namespace( - session_id: str, - namespace: str, - secrets: list[str] | None = None, -) -> None: - """Copy secrets from mainloop namespace to session namespace. - - Args: - session_id: The session ID - namespace: Target namespace to copy secrets to - secrets: List of secret names to copy (defaults to DEFAULT_SECRETS_TO_COPY) - - """ - core_v1, _ = get_k8s_client() - secrets_to_copy = secrets or DEFAULT_SECRETS_TO_COPY - - for secret_name in secrets_to_copy: - try: - # Read secret from source namespace - source_secret = core_v1.read_namespaced_secret( - name=secret_name, - namespace=SOURCE_NAMESPACE, - ) - - # Create new secret in target namespace - new_secret = client.V1Secret( - metadata=client.V1ObjectMeta( - name=secret_name, - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - "mainloop.dev/copied-from": SOURCE_NAMESPACE, - }, - ), - type=source_secret.type, - data=source_secret.data, - ) - - try: - core_v1.create_namespaced_secret(namespace=namespace, body=new_secret) - logger.info(f"Copied secret {secret_name} to namespace {namespace}") - except ApiException as e: - if e.status == 409: - logger.info(f"Secret {secret_name} already exists in {namespace}") - else: - raise - - except ApiException as e: - if e.status == 404: - logger.warning( - f"Secret {secret_name} not found in {SOURCE_NAMESPACE}, skipping" - ) - else: - raise - - -async def setup_session_rbac(session_id: str, namespace: str) -> None: - """Create ServiceAccount and RoleBinding for worker in session namespace. - - This gives the worker pod permissions to deploy resources within its namespace. - - Args: - session_id: The session ID - namespace: Target namespace - - """ - core_v1, _ = get_k8s_client() - rbac_v1 = get_rbac_client() - - # Create worker ServiceAccount - service_account = client.V1ServiceAccount( - metadata=client.V1ObjectMeta( - name=WORKER_SERVICE_ACCOUNT, - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - ) - - try: - core_v1.create_namespaced_service_account( - namespace=namespace, body=service_account - ) - logger.info(f"Created ServiceAccount {WORKER_SERVICE_ACCOUNT} in {namespace}") - except ApiException as e: - if e.status == 409: - logger.info( - f"ServiceAccount {WORKER_SERVICE_ACCOUNT} already exists in {namespace}" - ) - else: - raise - - # Create RoleBinding to bind ClusterRole to ServiceAccount - role_binding = client.V1RoleBinding( - metadata=client.V1ObjectMeta( - name="worker-role-binding", - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - subjects=[ - client.RbacV1Subject( - kind="ServiceAccount", - name=WORKER_SERVICE_ACCOUNT, - namespace=namespace, - ), - ], - role_ref=client.V1RoleRef( - kind="ClusterRole", - name=WORKER_CLUSTER_ROLE, - api_group="rbac.authorization.k8s.io", - ), - ) - - try: - rbac_v1.create_namespaced_role_binding(namespace=namespace, body=role_binding) - logger.info(f"Created RoleBinding for {WORKER_SERVICE_ACCOUNT} in {namespace}") - except ApiException as e: - if e.status == 409: - logger.info(f"RoleBinding already exists in {namespace}") - else: - raise - - -async def apply_session_namespace_network_policies( - session_id: str, namespace: str -) -> None: - """Apply network policies to session namespace for security isolation. - - Applies strict network policies: - - Default deny-all ingress and egress - - Allow DNS (required for internet access) - - Allow egress to internet ONLY (blocks all cluster internal communication) - - This ensures session agents can only communicate with external services, - not with other cluster resources or each other. - - Args: - session_id: The session ID - namespace: Target namespace - - """ - networking_v1 = get_networking_client() - - # Default deny-all policy - deny_all_policy = client.V1NetworkPolicy( - metadata=client.V1ObjectMeta( - name="default-deny-all", - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - spec=client.V1NetworkPolicySpec( - pod_selector=client.V1LabelSelector(), - policy_types=["Ingress", "Egress"], - ), - ) - - # Allow DNS policy - allow_dns_policy = client.V1NetworkPolicy( - metadata=client.V1ObjectMeta( - name="allow-dns", - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - spec=client.V1NetworkPolicySpec( - pod_selector=client.V1LabelSelector(), - policy_types=["Egress"], - egress=[ - client.V1NetworkPolicyEgressRule( - to=[ - client.V1NetworkPolicyPeer( - namespace_selector=client.V1LabelSelector( - match_labels={ - "kubernetes.io/metadata.name": "kube-system" - } - ) - ) - ], - ports=[client.V1NetworkPolicyPort(protocol="UDP", port=53)], - ) - ], - ), - ) - - # Allow internet-only egress (block cluster internal) - allow_internet_policy = client.V1NetworkPolicy( - metadata=client.V1ObjectMeta( - name="allow-internet-only", - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - spec=client.V1NetworkPolicySpec( - pod_selector=client.V1LabelSelector(), - policy_types=["Egress"], - egress=[ - client.V1NetworkPolicyEgressRule( - to=[ - client.V1NetworkPolicyPeer( - ip_block=client.V1IPBlock( - cidr="0.0.0.0/0", - _except=[ - "10.0.0.0/8", # Private class A - "172.16.0.0/12", # Private class B - "192.168.0.0/16", # Private class C - "169.254.0.0/16", # Link-local - "127.0.0.0/8", # Loopback - ], - ) - ) - ], - ) - ], - ), - ) - - # Allow egress to mainloop namespace (for callback to backend) - allow_mainloop_policy = client.V1NetworkPolicy( - metadata=client.V1ObjectMeta( - name="allow-mainloop-callback", - namespace=namespace, - labels={ - "app.kubernetes.io/managed-by": "mainloop", - "mainloop.dev/session-id": session_id, - }, - ), - spec=client.V1NetworkPolicySpec( - pod_selector=client.V1LabelSelector(), - policy_types=["Egress"], - egress=[ - client.V1NetworkPolicyEgressRule( - to=[ - client.V1NetworkPolicyPeer( - namespace_selector=client.V1LabelSelector( - match_labels={"kubernetes.io/metadata.name": "mainloop"} - ) - ) - ], - ) - ], - ), - ) - - # Apply policies - for policy in [ - deny_all_policy, - allow_dns_policy, - allow_internet_policy, - allow_mainloop_policy, - ]: - try: - networking_v1.create_namespaced_network_policy( - namespace=namespace, body=policy - ) - logger.info(f"Applied NetworkPolicy {policy.metadata.name} to {namespace}") - except ApiException as e: - if e.status == 409: - logger.info( - f"NetworkPolicy {policy.metadata.name} already exists in {namespace}" - ) - else: - raise - - -async def delete_session_namespace(session_id: str) -> None: - """Delete a session namespace and all its resources. - - Args: - session_id: The session ID (used to construct namespace name) - - """ - core_v1, _ = get_k8s_client() - namespace_name = f"{SESSION_NAMESPACE_PREFIX}{session_id[:8]}" - - try: - core_v1.delete_namespace( - name=namespace_name, - body=client.V1DeleteOptions( - propagation_policy="Foreground", - ), - ) - logger.info(f"Deleted namespace: {namespace_name}") - except ApiException as e: - if e.status == 404: - logger.info(f"Namespace {namespace_name} already deleted") - else: - raise - - -async def session_namespace_exists(session_id: str) -> bool: - """Check if a session namespace exists. - - Args: - session_id: The session ID - - Returns: - True if namespace exists, False otherwise - - """ - core_v1, _ = get_k8s_client() - namespace_name = f"{SESSION_NAMESPACE_PREFIX}{session_id[:8]}" - - try: - core_v1.read_namespace(name=namespace_name) - return True - except ApiException as e: - if e.status == 404: - return False - raise - - -async def list_session_namespaces() -> list[str]: - """List all session namespaces managed by mainloop. - - Returns: - List of namespace names - - """ - core_v1, _ = get_k8s_client() - - namespaces = core_v1.list_namespace( - label_selector="app.kubernetes.io/managed-by=mainloop" - ) - - return [ns.metadata.name for ns in namespaces.items] diff --git a/backend/src/mainloop/workflows/__init__.py b/backend/src/mainloop/workflows/__init__.py index f64f98f..528cd6e 100644 --- a/backend/src/mainloop/workflows/__init__.py +++ b/backend/src/mainloop/workflows/__init__.py @@ -1,6 +1,5 @@ """Durable workflow orchestration using DBOS.""" -from mainloop.workflows.dbos_config import dbos_config, worker_queue -from mainloop.workflows.session_worker import session_worker_workflow +from mainloop.workflows.dbos_config import dbos_config -__all__ = ["dbos_config", "worker_queue", "session_worker_workflow"] +__all__ = ["dbos_config"] diff --git a/backend/src/mainloop/workflows/dbos_config.py b/backend/src/mainloop/workflows/dbos_config.py index 9ac7848..b086731 100644 --- a/backend/src/mainloop/workflows/dbos_config.py +++ b/backend/src/mainloop/workflows/dbos_config.py @@ -2,13 +2,15 @@ import os -from dbos import DBOS, DBOSConfig, Queue +from dbos import DBOS, DBOSConfig from mainloop.config import settings # DBOS configuration # application_version prevents recovery of old workflows after code changes # Bump this when workflow step order/logic changes to avoid DBOSUnexpectedStepError -WORKFLOW_VERSION = "11" # v11: Sessions use K8s job isolation, removed worker tasks +WORKFLOW_VERSION = ( + "12" # v12: Native Substrate sessions replace the removed session workers +) dbos_config: DBOSConfig = { "name": "mainloop", @@ -20,17 +22,3 @@ # Initialize DBOS - must be done before defining workflows DBOS(config=dbos_config) - -# Queue for session workflows with concurrency limit -# This ensures we don't overwhelm resources with too many concurrent sessions -worker_queue = Queue( - "worker_tasks", # Queue name kept for backwards compatibility - concurrency=3, # Max 3 sessions running at once globally -) - -# Queue for user main threads - one at a time per partition (user) -main_thread_queue = Queue( - "main_threads", - partition_queue=True, # Partition by user_id - concurrency=1, # One active main thread per user at a time -) diff --git a/backend/src/mainloop/workflows/main_thread.py b/backend/src/mainloop/workflows/main_thread.py index 20f2542..f3ea653 100644 --- a/backend/src/mainloop/workflows/main_thread.py +++ b/backend/src/mainloop/workflows/main_thread.py @@ -29,7 +29,7 @@ async def main_thread_workflow(user_id: str) -> None: """Run the main thread workflow for a user. This workflow runs as long as needed, processing queue responses. - Chat messages are handled directly by chat_handler with Claude Agent SDK. + Chat messages are ledgered and delivered to the user's native Substrate session. The workflow is started per-user and identified by user_id. """ diff --git a/backend/src/mainloop/workflows/session_worker.py b/backend/src/mainloop/workflows/session_worker.py deleted file mode 100644 index 3783336..0000000 --- a/backend/src/mainloop/workflows/session_worker.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Session worker workflow - runs Claude in isolated K8s Jobs. - -Sessions run in their own K8s namespace with full isolation: -1. User sends message -2. K8s Job spawned with prompt -3. Job POSTs result back via callback -4. Result added to conversation -5. Wait for next user message -6. Repeat -""" - -import logging -from datetime import datetime, timezone -from typing import Any - -from dbos import DBOS -from mainloop.config import settings -from mainloop.services.k8s_jobs import create_session_job -from mainloop.services.k8s_namespace import ( - apply_session_namespace_network_policies, - copy_secrets_to_namespace, - create_session_namespace, - delete_session_namespace, - setup_session_rbac, -) -from mainloop.sse import notify_session_updated -from mainloop.workflows.transactions import ( - add_message_to_conversation, - load_session, - update_session_status, -) - -from models import SessionStatus - -logger = logging.getLogger(__name__) - -# Topics for DBOS messaging -TOPIC_USER_MESSAGE = "user_message" -TOPIC_JOB_RESULT = "job_result" - -# Timeouts -USER_INPUT_TIMEOUT = 86400 # 24 hours -JOB_TIMEOUT = 3600 # 1 hour per job - -SESSION_SYSTEM_PROMPT = """You are an AI assistant working in a background session. Respond directly to the user's request.""" - - -@DBOS.step() -async def setup_namespace(session_id: str) -> str: - """Create namespace, copy secrets, set up RBAC, and apply network policies.""" - namespace = await create_session_namespace(session_id) - await copy_secrets_to_namespace(session_id, namespace) - await setup_session_rbac(session_id, namespace) - await apply_session_namespace_network_policies(session_id, namespace) - return namespace - - -@DBOS.step() -async def cleanup_namespace(session_id: str) -> None: - """Delete the session namespace.""" - await delete_session_namespace(session_id) - - -@DBOS.step() -async def spawn_session_job( - session_id: str, - namespace: str, - prompt: str, - model: str | None = None, - iteration: int = 0, -) -> str: - """Spawn a K8s Job to run Claude with the given prompt.""" - callback_url = ( - f"{settings.backend_internal_url}/internal/sessions/{session_id}/complete" - ) - - job_name = await create_session_job( - session_id=session_id, - namespace=namespace, - prompt=prompt, - callback_url=callback_url, - model=model, - iteration=iteration, - ) - - return job_name - - -def build_conversation_prompt( - messages: list, - repo_url: str | None = None, -) -> str: - """Build prompt from conversation history.""" - history_parts = [] - for msg in messages: - role = "User" if msg.role == "user" else "Assistant" - history_parts.append(f"{role}: {msg.content}") - - context = "\n\n".join(history_parts) - repo_context = f"\n\nRepository: {repo_url}" if repo_url else "" - - return f"""{SESSION_SYSTEM_PROMPT} - -Continue this conversation:{repo_context} - -{context} - -Respond to the user's latest message.""" - - -async def notify_status(user_id: str, session_id: str, status: str): - """Send SSE notification about session status change.""" - await notify_session_updated(user_id, session_id, status) - - -@DBOS.workflow() -async def session_worker_workflow(session_id: str) -> dict[str, Any]: - """Run session workflow with Claude running in isolated K8s Jobs. - - 1. Set up isolated K8s namespace - 2. Spawn job for initial prompt - 3. Wait for job result (via callback) - 4. Add response to conversation - 5. Wait for user message - 6. Spawn job for response - 7. Repeat steps 3-6 - 8. Clean up namespace on completion - """ - logger.info(f"Starting session workflow: {session_id}") - - session = load_session(session_id) - if not session: - return {"status": "failed", "error": "Session not found"} - - namespace = None - iteration = 0 - - try: - # Set up isolated namespace - logger.info(f"Setting up namespace for session: {session_id}") - namespace = await setup_namespace(session_id) - - # Mark session as active - update_session_status( - session_id, - SessionStatus.ACTIVE, - started_at=datetime.now(timezone.utc), - ) - await notify_status(session.user_id, session_id, "active") - - # Add initial prompt as user message - add_message_to_conversation( - session.conversation_id, - "user", - session.prompt, - ) - - # Build initial prompt and spawn job - from mainloop.db import db - - messages = await db.get_messages(session.conversation_id) - prompt = build_conversation_prompt(messages, session.repo_url) - - logger.info(f"Spawning initial job for session: {session_id}") - await spawn_session_job( - session_id, - namespace, - prompt, - model=session.model, - iteration=iteration, - ) - - # Wait for job result - result = await DBOS.recv_async( - topic=TOPIC_JOB_RESULT, - timeout_seconds=JOB_TIMEOUT, - ) - - if result is None: - raise RuntimeError("Job timed out waiting for response") - - if result.get("status") == "failed": - raise RuntimeError(result.get("error", "Job failed")) - - # Add response to conversation - response = result.get("result", {}).get("output", "No response generated.") - add_message_to_conversation( - session.conversation_id, - "assistant", - response, - ) - - # Now wait for user messages in a loop - while True: - iteration += 1 - - # Wait for user input - update_session_status(session_id, SessionStatus.WAITING_ON_USER) - await notify_status(session.user_id, session_id, "waiting_on_user") - - message_response = await DBOS.recv_async( - topic=TOPIC_USER_MESSAGE, - timeout_seconds=USER_INPUT_TIMEOUT, - ) - - if message_response is None: - # Timeout - complete session - update_session_status( - session_id, - SessionStatus.COMPLETED, - completed_at=datetime.now(timezone.utc), - ) - await notify_status(session.user_id, session_id, "completed") - return {"status": "completed", "reason": "timeout"} - - # Got notification that user sent a message (already saved by API) - # Mark as active and spawn job for response - update_session_status(session_id, SessionStatus.ACTIVE) - await notify_status(session.user_id, session_id, "active") - - # Get updated conversation and spawn job - messages = await db.get_messages(session.conversation_id) - prompt = build_conversation_prompt(messages, session.repo_url) - - logger.info( - f"Spawning job for session: {session_id} (iteration {iteration})" - ) - await spawn_session_job( - session_id, - namespace, - prompt, - model=session.model, - iteration=iteration, - ) - - # Wait for job result - result = await DBOS.recv_async( - topic=TOPIC_JOB_RESULT, - timeout_seconds=JOB_TIMEOUT, - ) - - if result is None: - raise RuntimeError("Job timed out waiting for response") - - if result.get("status") == "failed": - raise RuntimeError(result.get("error", "Job failed")) - - # Add response to conversation - response = result.get("result", {}).get("output", "No response generated.") - add_message_to_conversation( - session.conversation_id, - "assistant", - response, - ) - - # Loop back to wait for next user message - - except Exception as e: - logger.error(f"Session workflow failed: {e}") - update_session_status( - session_id, - SessionStatus.FAILED, - completed_at=datetime.now(timezone.utc), - error=str(e), - ) - await notify_status(session.user_id, session_id, "failed") - return {"status": "failed", "error": str(e)} - - finally: - # Clean up namespace - if namespace: - logger.info(f"Cleaning up namespace for session: {session_id}") - try: - await cleanup_namespace(session_id) - except Exception as e: - logger.warning(f"Failed to cleanup namespace: {e}") diff --git a/backend/tests/runtime/fixtures/codex/session.json b/backend/tests/runtime/fixtures/codex/session.json index 82c9b20..df2c793 100644 --- a/backend/tests/runtime/fixtures/codex/session.json +++ b/backend/tests/runtime/fixtures/codex/session.json @@ -7,8 +7,6 @@ "provider": "codex", "runtime_type": "codex-app-server", "native_session_id": "thread-codex-fixture-001", - "herdr_session_id": "herdr-session-fixture", - "herdr_agent_id": "codex-agent-fixture", "creation_mode": "created", "ownership_generation": 1 } diff --git a/backend/tests/runtime/test_claude.py b/backend/tests/runtime/test_claude.py index aec93a6..b66ae03 100644 --- a/backend/tests/runtime/test_claude.py +++ b/backend/tests/runtime/test_claude.py @@ -28,8 +28,6 @@ def adapter(records: list[dict], *, suffix: str = "stream") -> ClaudeSessionNorm records[0], binding_id=f"claude-binding-{suffix}", workspace_id=f"workspace-{suffix}", - herdr_session_id=f"herdr-session-{suffix}", - herdr_agent_id=f"herdr-agent-{suffix}", ) @@ -40,8 +38,6 @@ def test_binding_preserves_native_identity_and_observed_metadata(self): records[0], binding_id="binding", workspace_id="workspace", - herdr_session_id="herdr-session", - herdr_agent_id="herdr-agent", ) self.assertEqual(binding.provider, "claude") diff --git a/backend/tests/runtime/test_context_model.py b/backend/tests/runtime/test_context_model.py index 25eae58..c7c63f2 100644 --- a/backend/tests/runtime/test_context_model.py +++ b/backend/tests/runtime/test_context_model.py @@ -8,7 +8,7 @@ from mainloop.runtime import agent_api, policy from mainloop.runtime.agent_api import AgentService, hash_token from mainloop.runtime.journal import parse_claude -from mainloop.runtime.native_sessions import config_name, rotation_due +from mainloop.runtime.native_sessions import rotation_due from mainloop.runtime.policy import Actor, PolicyError from mainloop.runtime.standing import ( RecentMessage, @@ -97,11 +97,6 @@ def test_turn_budget_and_unknown_usage(self): ) ) - def test_binding_config_names(self): - self.assertEqual(config_name({"role": "main", "kind": "claude"}), "claude-main") - self.assertEqual(config_name({"role": "child", "kind": "codex"}), "codex-child") - self.assertEqual(config_name({"role": "agent", "kind": "claude"}), "claude") - class StandingTests(unittest.TestCase): def test_carry_over_is_small_and_lists_topic_index_pending_and_recent(self): @@ -175,7 +170,7 @@ def test_context_tokens_and_compact_boundary(self): ), ), ] - ev = parse_claude(lines, file_ref="f.jsonl", native_id="n", agent="a") + ev = parse_claude(lines, file_ref="f.jsonl", native_id="n") self.assertEqual([e.context_tokens for e in ev], [None, 20624, None]) self.assertEqual(ev[2].native_type, "claude.system.compact_boundary") diff --git a/backend/tests/runtime/test_contracts.py b/backend/tests/runtime/test_contracts.py index 78f5f17..c2ad6ff 100644 --- a/backend/tests/runtime/test_contracts.py +++ b/backend/tests/runtime/test_contracts.py @@ -24,8 +24,6 @@ def binding(): "provider": "fixture", "runtime_type": "native", "native_session_id": "native-session", - "herdr_session_id": "herdr-session", - "herdr_agent_id": "agent", "creation_mode": "created", "ownership_generation": 1, } diff --git a/backend/tests/runtime/test_delivery_suspend_fence.py b/backend/tests/runtime/test_delivery_suspend_fence.py new file mode 100644 index 0000000..630fd95 --- /dev/null +++ b/backend/tests/runtime/test_delivery_suspend_fence.py @@ -0,0 +1,134 @@ +"""The delivery write shares the workspace row lock used by suspend reservation.""" + +from __future__ import annotations + +import unittest +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from mainloop.db import db +from mainloop.runtime.native_sessions import _record_delivery_message + + +class FakeConnection: + def __init__(self, workspace): + self.workspace = workspace + self.events: list[str] = [] + + @asynccontextmanager + async def transaction(self): + self.events.append("begin") + try: + yield self + except Exception: + self.events.append("rollback") + raise + else: + self.events.append("commit") + + async def fetchrow(self, query, *_args): + if "FROM workspace_bindings" in query: + self.events.append("workspace-lock") + if "FOR UPDATE" not in query: + raise AssertionError( + "delivery recording must lock the workspace binding row" + ) + return {"workspace_id": "session-1"} + elif "FROM workspace_lifecycles" in query: + self.events.append("lifecycle-read") + else: + raise AssertionError(f"unexpected fetchrow query: {query}") + return self.workspace + + async def execute(self, query, *_args): + if "INSERT INTO native_deliveries" in query: + self.events.append("delivery-insert") + + +def fake_connection(connection): + @asynccontextmanager + async def acquire(): + yield connection + + return acquire() + + +class DeliverySuspendFenceTests(unittest.IsolatedAsyncioTestCase): + async def test_message_and_delivery_are_recorded_after_the_workspace_lock(self): + connection = FakeConnection( + {"desired_state": "active", "observed_state": "running"} + ) + + async def create_message(**_kwargs): + connection.events.append("message-insert") + return SimpleNamespace(id="message-1") + + with ( + patch.object( + db, + "connection", + return_value=fake_connection(connection), + ), + patch.object( + db, + "create_message", + new=AsyncMock(side_effect=create_message), + ) as create, + ): + message_id = await _record_delivery_message( + session_id="session-1", + conversation_id="conversation-1", + text="hello", + state="recorded", + source="user", + ) + + self.assertEqual(message_id, "message-1") + self.assertEqual( + connection.events, + [ + "begin", + "workspace-lock", + "lifecycle-read", + "message-insert", + "delivery-insert", + "commit", + ], + ) + self.assertEqual(create.await_args.kwargs["conn"], connection) + + async def test_suspending_workspace_rejects_the_delivery_before_recording(self): + connection = FakeConnection( + {"desired_state": "suspended", "observed_state": "suspending"} + ) + with ( + patch.object( + db, + "connection", + return_value=fake_connection(connection), + ), + patch.object( + db, + "create_message", + new=AsyncMock(), + ) as create, + ): + with self.assertRaisesRegex(ValueError, "resume it before sending"): + await _record_delivery_message( + session_id="session-1", + conversation_id="conversation-1", + text="hello", + state="recorded", + source="user", + ) + + self.assertEqual( + connection.events, + ["begin", "workspace-lock", "lifecycle-read", "rollback"], + ) + create.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_herdr.py b/backend/tests/runtime/test_herdr.py deleted file mode 100644 index 6e53787..0000000 --- a/backend/tests/runtime/test_herdr.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Herdr adapter over a fake pod-exec transport: no cluster, no agents, no credentials.""" - -import asyncio -import unittest - -from mainloop.runtime.herdr import ExecResult, HerdrWorkspace, TransportError - - -class FakeWorkspace(HerdrWorkspace): - def __init__(self, results): - super().__init__(namespace="ns", pod="pod") - self.results = list(results) - self.calls: list[list[str]] = [] - - async def _exec(self, command, timeout=45): - self.calls.append(command) - result = self.results.pop(0) - if isinstance(result, Exception): - raise result - return result - - -def run(coro): - return asyncio.run(coro) - - -class HerdrAdapterTests(unittest.TestCase): - def test_send_is_one_exec_and_transport_error_is_not_retried(self): - ws = FakeWorkspace([TransportError("boom")]) - with self.assertRaises(TransportError): - run(ws.send("agent", "hi")) - self.assertEqual( - ws.calls, [["agentctl", "send", "agent", "hi"]] - ) # exactly one attempt - - def test_prompt_text_is_argv_not_shell(self): - ws = FakeWorkspace([ExecResult(0, "sent\n", "")]) - run(ws.send("agent", "a; rm -rf / $(x) 'q'")) - self.assertEqual(ws.calls[0][-1], "a; rm -rf / $(x) 'q'") - - def test_journal_parses_header_and_numbered_lines(self): - out = '#file\t/w/.claude/projects/p/s.jsonl\t3\n2\t{"a":1}\n3\t{"b":2}\n' - ws = FakeWorkspace([ExecResult(0, out, "")]) - sl = run(ws.journal("agent", "sid", 1)) - self.assertEqual( - (sl.file, sl.total_lines, sl.lines), - ("/w/.claude/projects/p/s.jsonl", 3, [(2, '{"a":1}'), (3, '{"b":2}')]), - ) - self.assertEqual(ws.calls[0], ["agentctl", "journal", "agent", "sid", "1"]) - - def test_missing_journal(self): - ws = FakeWorkspace([ExecResult(0, "#nofile\n", "")]) - self.assertIsNone(run(ws.journal("agent", "sid", 0)).file) - - def test_start_uses_resume_or_new_id(self): - ident = '{"pane_id":"w1:p1","terminal_id":"t"}\n' - ws = FakeWorkspace([ExecResult(0, ident, ""), ExecResult(0, ident, "")]) - run(ws.start("claude", "n", native_id="sid", resume=False)) - run(ws.start("claude", "n", native_id="sid", resume=True)) - self.assertEqual(ws.calls[0][-2:], ["--new-id", "sid"]) - self.assertEqual(ws.calls[1][-2:], ["--resume", "sid"]) - - def test_status_none_when_agent_not_live(self): - ws = FakeWorkspace([ExecResult(1, "", "no agent")]) - self.assertIsNone(run(ws.agent_status("n"))) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/runtime/test_journal.py b/backend/tests/runtime/test_journal.py index 8616724..27e000d 100644 --- a/backend/tests/runtime/test_journal.py +++ b/backend/tests/runtime/test_journal.py @@ -99,7 +99,6 @@ def test_claude_turn_prompt_reply_completion_model(self): numbered(CLAUDE_TURN), file_ref="s.jsonl", native_id=CLAUDE_ID, - agent="a", ) kinds = [e.kind for e in events] self.assertEqual( @@ -123,7 +122,6 @@ def test_open_turn_is_not_persisted_and_cursor_stays_before_it(self): numbered(CLAUDE_TURN[:4]), file_ref="s.jsonl", native_id=CLAUDE_ID, - agent="a", ) turns, safe = completed_turns(events) self.assertEqual(turns, []) @@ -135,7 +133,6 @@ def test_codex_turn(self): numbered(CODEX_TURN), file_ref="r.jsonl", native_id=CODEX_ID, - agent="a", ) self.assertEqual(events[3].kind, "prompt") self.assertEqual(events[5].normalized_type, "completed") @@ -151,17 +148,13 @@ def test_malformed_and_unknown_lines_are_ignored(self): (3, "[]"), ] self.assertEqual( - len( - parse_journal( - "claude", lines, file_ref="s", native_id=CLAUDE_ID, agent="a" - ) - ), + len(parse_journal("claude", lines, file_ref="s", native_id=CLAUDE_ID)), 1, ) def test_unknown_kind_is_rejected(self): with self.assertRaises(ValueError): - parse_journal("pi", [], file_ref="s", native_id="x", agent="a") + parse_journal("pi", [], file_ref="s", native_id="x") if __name__ == "__main__": diff --git a/backend/tests/runtime/test_substrate.py b/backend/tests/runtime/test_substrate.py index e903daf..82c39b3 100644 --- a/backend/tests/runtime/test_substrate.py +++ b/backend/tests/runtime/test_substrate.py @@ -1,5 +1,5 @@ """Substrate transport adapter over a fake ``kubectl ate`` subprocess: no cluster, no actors, -no credentials. Mirrors test_herdr.py's fake-transport pattern for the Herdr adapter.""" +no credentials. Uses a fake transport to test the Substrate control adapter.""" import asyncio import json diff --git a/backend/tests/runtime/test_substrate_workspace.py b/backend/tests/runtime/test_substrate_workspace.py index 43a1bd2..f7d2a14 100644 --- a/backend/tests/runtime/test_substrate_workspace.py +++ b/backend/tests/runtime/test_substrate_workspace.py @@ -11,8 +11,11 @@ from mainloop.config import SubstrateActorBinding, settings from mainloop.runtime import native_sessions -from mainloop.runtime.herdr import WorkspaceUnavailable -from mainloop.runtime.substrate_workspace import SubstrateWorkspace, _Response +from mainloop.runtime.substrate_workspace import ( + SubstrateWorkspace, + WorkspaceUnavailable, + _Response, +) FIXTURE_VALUE = "fixture-shim-value-one" ROTATED_FIXTURE_VALUE = "fixture-shim-value-two" @@ -271,7 +274,6 @@ async def exercise(): shim_token_secret_name=FAKE_SHIM_NAME, ) key = ( - "substrate", workspace_binding.atespace, workspace_binding.actor, workspace_binding.shim_token_secret_name, @@ -292,7 +294,6 @@ async def fake_token(_workspace): return FIXTURE_VALUE with ( - patch.object(settings, "workspace_runtime", "substrate"), patch.object( settings, "substrate_router_address", @@ -322,7 +323,6 @@ async def fake_token(_workspace): patch.object(native_sessions, "sync", new=AsyncMock()) as sync, ): native_sessions._workspaces.pop(key, None) - self.assertEqual(settings.workspace_runtime, "substrate") self.assertIsInstance( native_sessions.workspace_for(binding), SubstrateWorkspace ) diff --git a/backend/uv.lock b/backend/uv.lock index ad6f8da..7ebed9a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -13,18 +13,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/bb/5a5ec893eea5805fb9a3db76a9888c3429710dfb6f24bbb37568f2cf7320/ag_ui_protocol-0.1.10.tar.gz", hash = "sha256:3213991c6b2eb24bb1a8c362ee270c16705a07a4c5962267a083d0959ed894f4", size = 6945 } +sdist = { url = "https://files.pythonhosted.org/packages/67/bb/5a5ec893eea5805fb9a3db76a9888c3429710dfb6f24bbb37568f2cf7320/ag_ui_protocol-0.1.10.tar.gz", hash = "sha256:3213991c6b2eb24bb1a8c362ee270c16705a07a4c5962267a083d0959ed894f4", size = 6945, upload-time = "2025-11-06T15:17:17.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889 }, + { url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889, upload-time = "2025-11-06T15:17:15.325Z" }, ] [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] @@ -40,59 +40,59 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139 }, - { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082 }, - { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035 }, - { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387 }, - { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314 }, - { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317 }, - { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539 }, - { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597 }, - { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006 }, - { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220 }, - { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570 }, - { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407 }, - { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093 }, - { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084 }, - { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987 }, - { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859 }, - { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192 }, - { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234 }, - { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733 }, - { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303 }, - { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965 }, - { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221 }, - { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178 }, - { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001 }, - { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325 }, - { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978 }, - { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042 }, - { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085 }, - { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238 }, - { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395 }, - { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965 }, - { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585 }, - { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621 }, - { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627 }, - { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360 }, - { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616 }, - { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131 }, - { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168 }, - { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200 }, - { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497 }, - { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703 }, - { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738 }, - { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061 }, - { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201 }, - { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868 }, - { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660 }, - { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548 }, - { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240 }, - { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334 }, - { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685 }, - { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093 }, +sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, + { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, + { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, + { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, + { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, + { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, + { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, + { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, + { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, + { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, + { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, ] [[package]] @@ -102,27 +102,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -139,9 +139,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565 } +sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164 }, + { url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" }, ] [[package]] @@ -151,9 +151,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266 } +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362 }, + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] [[package]] @@ -163,59 +163,59 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432, upload-time = "2023-10-02T13:49:25.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907 }, + { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907, upload-time = "2023-10-02T13:49:26.943Z" }, ] [[package]] name = "argcomplete" version = "3.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754 } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846 }, + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] [[package]] name = "asyncpg" version = "0.31.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111 }, - { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928 }, - { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067 }, - { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156 }, - { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636 }, - { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079 }, - { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606 }, - { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569 }, - { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867 }, - { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349 }, - { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428 }, - { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678 }, - { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505 }, - { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744 }, - { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251 }, - { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901 }, - { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280 }, - { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931 }, - { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608 }, - { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738 }, - { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026 }, - { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426 }, - { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495 }, - { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062 }, +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] [[package]] name = "attrs" version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] @@ -225,18 +225,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005 }, + { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, ] [[package]] name = "beartype" version = "0.22.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658 }, + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] [[package]] @@ -248,9 +248,9 @@ dependencies = [ { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/cf/6e4a794e73cbf3e774ec27a5acc8442ce55d97cfc5226a20c1f58d8aee16/boto3-1.42.17.tar.gz", hash = "sha256:8a2e345e96d5ceba755c55539c93f99705f403fbfdeef2e838eabdc56750828b", size = 112776 } +sdist = { url = "https://files.pythonhosted.org/packages/8d/cf/6e4a794e73cbf3e774ec27a5acc8442ce55d97cfc5226a20c1f58d8aee16/boto3-1.42.17.tar.gz", hash = "sha256:8a2e345e96d5ceba755c55539c93f99705f403fbfdeef2e838eabdc56750828b", size = 112776, upload-time = "2025-12-26T20:33:38.289Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/53/9f4241152eb1f5a57351232c14ef411ef35fbcc1d97a63873235d0503a8a/boto3-1.42.17-py3-none-any.whl", hash = "sha256:e0ee40f7102712452f6776af891c8f49b5ae9133bdaf22711d6f4a78963c2614", size = 140572 }, + { url = "https://files.pythonhosted.org/packages/6c/53/9f4241152eb1f5a57351232c14ef411ef35fbcc1d97a63873235d0503a8a/boto3-1.42.17-py3-none-any.whl", hash = "sha256:e0ee40f7102712452f6776af891c8f49b5ae9133bdaf22711d6f4a78963c2614", size = 140572, upload-time = "2025-12-26T20:33:36.307Z" }, ] [[package]] @@ -262,27 +262,27 @@ dependencies = [ { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/d2/128e2d8b9d426bd3a339e7dcf3eedca55f449af121604b6891ae80f6be9a/botocore-1.42.17.tar.gz", hash = "sha256:d73fe22c8e1497e4d59ff7dc68eb05afac68a4a6457656811562285d6132bc04", size = 14911757 } +sdist = { url = "https://files.pythonhosted.org/packages/4d/d2/128e2d8b9d426bd3a339e7dcf3eedca55f449af121604b6891ae80f6be9a/botocore-1.42.17.tar.gz", hash = "sha256:d73fe22c8e1497e4d59ff7dc68eb05afac68a4a6457656811562285d6132bc04", size = 14911757, upload-time = "2025-12-26T20:33:27.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/47/cc00f2421f49784de8b9113c94b7b6580db989721f5109a24b9108308fe1/botocore-1.42.17-py3-none-any.whl", hash = "sha256:a832e4c04e63141221480967e9e511363aa54d24c405935fccb913a18583c96b", size = 14586536 }, + { url = "https://files.pythonhosted.org/packages/d1/47/cc00f2421f49784de8b9113c94b7b6580db989721f5109a24b9108308fe1/botocore-1.42.17-py3-none-any.whl", hash = "sha256:a832e4c04e63141221480967e9e511363aa54d24c405935fccb913a18583c96b", size = 14586536, upload-time = "2025-12-26T20:33:24.032Z" }, ] [[package]] name = "cachetools" version = "6.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551 }, + { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" }, ] [[package]] name = "certifi" version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] @@ -292,99 +292,83 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, -] - -[[package]] -name = "claude-agent-sdk" -version = "0.1.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "mcp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/3d/a8c6ad873e8448696d44441c9eb2c24dded620fb32415d68f576a542ccde/claude_agent_sdk-0.1.18.tar.gz", hash = "sha256:4fcb8730cc77dea562fbe9aa48c65eced3ef58a6bb1f34f77e50e8258902477d", size = 56162 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/14/f529f7c4bab7c71dcbcc8c66f12f491e644ee8a027ac5111d13705df207e/claude_agent_sdk-0.1.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9e45b4e3c20c072c3e3325fa60bab9a4b5a7cbbce64ca274b8d7d0af42dd9dd8", size = 54560828 }, - { url = "https://files.pythonhosted.org/packages/2c/68/6e83005aa7bb9056bfad0aef0605249f877dc0c78724c9c0fadebff600fb/claude_agent_sdk-0.1.18-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:3c41bd8f38848609ae0d5da8d7327a4c2d7057a363feafb6fd70df611ea204cc", size = 68743107 }, - { url = "https://files.pythonhosted.org/packages/fb/85/7d6dd85f402135a610894734c442f1166ffed61d03eced39d6bfd14efccd/claude_agent_sdk-0.1.18-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:983f15e51253f40c55136a86d7cc63e023a3576428b05fa1459093d461b2d215", size = 70444964 }, - { url = "https://files.pythonhosted.org/packages/3c/fa/d2b22b7a713c4c049cbd5f9f635836ea5429ff65c1f3bcf4658a8e1c1cf5/claude_agent_sdk-0.1.18-py3-none-win_amd64.whl", hash = "sha256:36f5b84d5c3c8773ee9b56aeb5ab345d1033231db37f80d1f20ac15239bef41c", size = 72637215 }, +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -394,18 +378,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] name = "cloudpickle" version = "3.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330 } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228 }, + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, ] [[package]] @@ -422,18 +406,18 @@ dependencies = [ { name = "types-requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/ed/bb02083654bdc089ae4ef1cd7691fd2233f1fd9f32bcbfacc80ff57d9775/cohere-5.20.1.tar.gz", hash = "sha256:50973f63d2c6138ff52ce37d8d6f78ccc539af4e8c43865e960d68e0bf835b6f", size = 180820 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/ed/bb02083654bdc089ae4ef1cd7691fd2233f1fd9f32bcbfacc80ff57d9775/cohere-5.20.1.tar.gz", hash = "sha256:50973f63d2c6138ff52ce37d8d6f78ccc539af4e8c43865e960d68e0bf835b6f", size = 180820, upload-time = "2025-12-18T16:39:50.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/e3/94eb11ac3ebaaa3a6afb5d2ff23db95d58bc468ae538c388edf49f2f20b5/cohere-5.20.1-py3-none-any.whl", hash = "sha256:d230fd13d95ba92ae927fce3dd497599b169883afc7954fe29b39fb8d5df5fc7", size = 318973 }, + { url = "https://files.pythonhosted.org/packages/7a/e3/94eb11ac3ebaaa3a6afb5d2ff23db95d58bc468ae538c388edf49f2f20b5/cohere-5.20.1-py3-none-any.whl", hash = "sha256:d230fd13d95ba92ae927fce3dd497599b169883afc7954fe29b39fb8d5df5fc7", size = 318973, upload-time = "2025-12-18T16:39:49.504Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -443,53 +427,53 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004 }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667 }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807 }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615 }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800 }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707 }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541 }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464 }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838 }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596 }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782 }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381 }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988 }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451 }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007 }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012 }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728 }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078 }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460 }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237 }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344 }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564 }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415 }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457 }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074 }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569 }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941 }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339 }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315 }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331 }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248 }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089 }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029 }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222 }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280 }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958 }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714 }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970 }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236 }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642 }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126 }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573 }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695 }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720 }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740 }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, ] [[package]] @@ -502,9 +486,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/99/e1b75193ee23bd10a05a3b90c065d419b1c8c18f61cae6b8218c7158f792/cyclopts-4.4.1.tar.gz", hash = "sha256:368a404926b46a49dc328a33ccd7e55ba879296a28e64a42afe2f6667704cecf", size = 159245 } +sdist = { url = "https://files.pythonhosted.org/packages/40/99/e1b75193ee23bd10a05a3b90c065d419b1c8c18f61cae6b8218c7158f792/cyclopts-4.4.1.tar.gz", hash = "sha256:368a404926b46a49dc328a33ccd7e55ba879296a28e64a42afe2f6667704cecf", size = 159245, upload-time = "2025-12-21T13:59:02.266Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/05/8efadba80e1296526e69c1dceba8b0f0bc3756e8d69f6ed9b0e647cf3169/cyclopts-4.4.1-py3-none-any.whl", hash = "sha256:67500e9fde90f335fddbf9c452d2e7c4f58209dffe52e7abb1e272796a963bde", size = 196726 }, + { url = "https://files.pythonhosted.org/packages/8d/05/8efadba80e1296526e69c1dceba8b0f0bc3756e8d69f6ed9b0e647cf3169/cyclopts-4.4.1-py3-none-any.whl", hash = "sha256:67500e9fde90f335fddbf9c452d2e7c4f58209dffe52e7abb1e272796a963bde", size = 196726, upload-time = "2025-12-21T13:59:03.127Z" }, ] [[package]] @@ -519,63 +503,63 @@ dependencies = [ { name = "typer-slim" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/46/1159b06dfd1dd0407e188174a8e42f796daa8c4393654e8f956048c8574e/dbos-2.7.0.tar.gz", hash = "sha256:0ad5a9bf33be2c403946603c3d980f59dd4ca2858d393c912305e471575fd978", size = 219458 } +sdist = { url = "https://files.pythonhosted.org/packages/1a/46/1159b06dfd1dd0407e188174a8e42f796daa8c4393654e8f956048c8574e/dbos-2.7.0.tar.gz", hash = "sha256:0ad5a9bf33be2c403946603c3d980f59dd4ca2858d393c912305e471575fd978", size = 219458, upload-time = "2025-12-11T15:58:25.112Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/57/80c23cc65a6af2c740096b1a15544dd299e8fd4ab942846ad8c7938f9b9d/dbos-2.7.0-py3-none-any.whl", hash = "sha256:70cd823de89e6cb99031ddf02c2750ed4ef9dfc207ad3a36fd7f2e3e507c5195", size = 139958 }, + { url = "https://files.pythonhosted.org/packages/49/57/80c23cc65a6af2c740096b1a15544dd299e8fd4ab942846ad8c7938f9b9d/dbos-2.7.0-py3-none-any.whl", hash = "sha256:70cd823de89e6cb99031ddf02c2750ed4ef9dfc207ad3a36fd7f2e3e507c5195", size = 139958, upload-time = "2025-12-11T15:58:23.153Z" }, ] [[package]] name = "diskcache" version = "5.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550 }, + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, ] [[package]] name = "distro" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "dnspython" version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] [[package]] name = "docstring-parser" version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442 } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896 }, + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] [[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196 }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "durationpy" version = "0.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922 }, + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] [[package]] @@ -586,36 +570,36 @@ dependencies = [ { name = "dnspython" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] [[package]] name = "eval-type-backport" version = "0.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063 }, + { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "executing" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] @@ -626,9 +610,9 @@ dependencies = [ { name = "redis" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605 }, + { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, ] [package.optional-dependencies] @@ -646,38 +630,38 @@ dependencies = [ { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/71/2df15009fb4bdd522a069d2fbca6007c6c5487fce5cb965be00fc335f1d1/fastapi-0.125.0.tar.gz", hash = "sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0", size = 370550 } +sdist = { url = "https://files.pythonhosted.org/packages/17/71/2df15009fb4bdd522a069d2fbca6007c6c5487fce5cb965be00fc335f1d1/fastapi-0.125.0.tar.gz", hash = "sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0", size = 370550, upload-time = "2025-12-17T21:41:44.15Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl", hash = "sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1", size = 112888 }, + { url = "https://files.pythonhosted.org/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl", hash = "sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1", size = 112888, upload-time = "2025-12-17T21:41:41.286Z" }, ] [[package]] name = "fastavro" version = "1.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/8b/fa2d3287fd2267be6261d0177c6809a7fa12c5600ddb33490c8dc29e77b2/fastavro-1.12.1.tar.gz", hash = "sha256:2f285be49e45bc047ab2f6bed040bb349da85db3f3c87880e4b92595ea093b2b", size = 1025661 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/57/26d5efef9182392d5ac9f253953c856ccb66e4c549fd3176a1e94efb05c9/fastavro-1.12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:78df838351e4dff9edd10a1c41d1324131ffecbadefb9c297d612ef5363c049a", size = 1000599 }, - { url = "https://files.pythonhosted.org/packages/33/cb/8ab55b21d018178eb126007a56bde14fd01c0afc11d20b5f2624fe01e698/fastavro-1.12.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780476c23175d2ae457c52f45b9ffa9d504593499a36cd3c1929662bf5b7b14b", size = 3335933 }, - { url = "https://files.pythonhosted.org/packages/fe/03/9c94ec9bf873eb1ffb0aa694f4e71940154e6e9728ddfdc46046d7e8ced4/fastavro-1.12.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0714b285160fcd515eb0455540f40dd6dac93bdeacdb03f24e8eac3d8aa51f8d", size = 3402066 }, - { url = "https://files.pythonhosted.org/packages/75/c8/cb472347c5a584ccb8777a649ebb28278fccea39d005fc7df19996f41df8/fastavro-1.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8bc2dcec5843d499f2489bfe0747999108f78c5b29295d877379f1972a3d41a", size = 3240038 }, - { url = "https://files.pythonhosted.org/packages/e1/77/569ce9474c40304b3a09e109494e020462b83e405545b78069ddba5f614e/fastavro-1.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3b1921ac35f3d89090a5816b626cf46e67dbecf3f054131f84d56b4e70496f45", size = 3369398 }, - { url = "https://files.pythonhosted.org/packages/4a/1f/9589e35e9ea68035385db7bdbf500d36b8891db474063fb1ccc8215ee37c/fastavro-1.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:5aa777b8ee595b50aa084104cd70670bf25a7bbb9fd8bb5d07524b0785ee1699", size = 444220 }, - { url = "https://files.pythonhosted.org/packages/6c/d2/78435fe737df94bd8db2234b2100f5453737cffd29adee2504a2b013de84/fastavro-1.12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c3d67c47f177e486640404a56f2f50b165fe892cc343ac3a34673b80cc7f1dd6", size = 1086611 }, - { url = "https://files.pythonhosted.org/packages/b6/be/428f99b10157230ddac77ec8cc167005b29e2bd5cbe228345192bb645f30/fastavro-1.12.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5217f773492bac43dae15ff2931432bce2d7a80be7039685a78d3fab7df910bd", size = 3541001 }, - { url = "https://files.pythonhosted.org/packages/16/08/a2eea4f20b85897740efe44887e1ac08f30dfa4bfc3de8962bdcbb21a5a1/fastavro-1.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:469fecb25cba07f2e1bfa4c8d008477cd6b5b34a59d48715e1b1a73f6160097d", size = 3432217 }, - { url = "https://files.pythonhosted.org/packages/87/bb/b4c620b9eb6e9838c7f7e4b7be0762834443adf9daeb252a214e9ad3178c/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d71c8aa841ef65cfab709a22bb887955f42934bced3ddb571e98fdbdade4c609", size = 3366742 }, - { url = "https://files.pythonhosted.org/packages/3d/d1/e69534ccdd5368350646fea7d93be39e5f77c614cca825c990bd9ca58f67/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b81fc04e85dfccf7c028e0580c606e33aa8472370b767ef058aae2c674a90746", size = 3383743 }, - { url = "https://files.pythonhosted.org/packages/58/54/b7b4a0c3fb5fcba38128542da1b26c4e6d69933c923f493548bdfd63ab6a/fastavro-1.12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9445da127751ba65975d8e4bdabf36bfcfdad70fc35b2d988e3950cce0ec0e7c", size = 1001377 }, - { url = "https://files.pythonhosted.org/packages/1e/4f/0e589089c7df0d8f57d7e5293fdc34efec9a3b758a0d4d0c99a7937e2492/fastavro-1.12.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed924233272719b5d5a6a0b4d80ef3345fc7e84fc7a382b6232192a9112d38a6", size = 3320401 }, - { url = "https://files.pythonhosted.org/packages/f9/19/260110d56194ae29d7e423a336fccea8bcd103196d00f0b364b732bdb84e/fastavro-1.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3616e2f0e1c9265e92954fa099db79c6e7817356d3ff34f4bcc92699ae99697c", size = 3350894 }, - { url = "https://files.pythonhosted.org/packages/d0/96/58b0411e8be9694d5972bee3167d6c1fd1fdfdf7ce253c1a19a327208f4f/fastavro-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cb0337b42fd3c047fcf0e9b7597bd6ad25868de719f29da81eabb6343f08d399", size = 3229644 }, - { url = "https://files.pythonhosted.org/packages/5b/db/38660660eac82c30471d9101f45b3acfdcbadfe42d8f7cdb129459a45050/fastavro-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:64961ab15b74b7c168717bbece5660e0f3d457837c3cc9d9145181d011199fa7", size = 3329704 }, - { url = "https://files.pythonhosted.org/packages/9d/a9/1672910f458ecb30b596c9e59e41b7c00309b602a0494341451e92e62747/fastavro-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:792356d320f6e757e89f7ac9c22f481e546c886454a6709247f43c0dd7058004", size = 452911 }, - { url = "https://files.pythonhosted.org/packages/dc/8d/2e15d0938ded1891b33eff252e8500605508b799c2e57188a933f0bd744c/fastavro-1.12.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120aaf82ac19d60a1016afe410935fe94728752d9c2d684e267e5b7f0e70f6d9", size = 3541999 }, - { url = "https://files.pythonhosted.org/packages/a7/1c/6dfd082a205be4510543221b734b1191299e6a1810c452b6bc76dfa6968e/fastavro-1.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6a3462934b20a74f9ece1daa49c2e4e749bd9a35fa2657b53bf62898fba80f5", size = 3433972 }, - { url = "https://files.pythonhosted.org/packages/24/90/9de694625a1a4b727b1ad0958d220cab25a9b6cf7f16a5c7faa9ea7b2261/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1f81011d54dd47b12437b51dd93a70a9aa17b61307abf26542fc3c13efbc6c51", size = 3368752 }, - { url = "https://files.pythonhosted.org/packages/fa/93/b44f67589e4d439913dab6720f7e3507b0fa8b8e56d06f6fc875ced26afb/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43ded16b3f4a9f1a42f5970c2aa618acb23ea59c4fcaa06680bdf470b255e5a8", size = 3386636 }, +sdist = { url = "https://files.pythonhosted.org/packages/65/8b/fa2d3287fd2267be6261d0177c6809a7fa12c5600ddb33490c8dc29e77b2/fastavro-1.12.1.tar.gz", hash = "sha256:2f285be49e45bc047ab2f6bed040bb349da85db3f3c87880e4b92595ea093b2b", size = 1025661, upload-time = "2025-10-10T15:40:55.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/57/26d5efef9182392d5ac9f253953c856ccb66e4c549fd3176a1e94efb05c9/fastavro-1.12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:78df838351e4dff9edd10a1c41d1324131ffecbadefb9c297d612ef5363c049a", size = 1000599, upload-time = "2025-10-10T15:41:36.554Z" }, + { url = "https://files.pythonhosted.org/packages/33/cb/8ab55b21d018178eb126007a56bde14fd01c0afc11d20b5f2624fe01e698/fastavro-1.12.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780476c23175d2ae457c52f45b9ffa9d504593499a36cd3c1929662bf5b7b14b", size = 3335933, upload-time = "2025-10-10T15:41:39.07Z" }, + { url = "https://files.pythonhosted.org/packages/fe/03/9c94ec9bf873eb1ffb0aa694f4e71940154e6e9728ddfdc46046d7e8ced4/fastavro-1.12.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0714b285160fcd515eb0455540f40dd6dac93bdeacdb03f24e8eac3d8aa51f8d", size = 3402066, upload-time = "2025-10-10T15:41:41.608Z" }, + { url = "https://files.pythonhosted.org/packages/75/c8/cb472347c5a584ccb8777a649ebb28278fccea39d005fc7df19996f41df8/fastavro-1.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8bc2dcec5843d499f2489bfe0747999108f78c5b29295d877379f1972a3d41a", size = 3240038, upload-time = "2025-10-10T15:41:43.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/77/569ce9474c40304b3a09e109494e020462b83e405545b78069ddba5f614e/fastavro-1.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3b1921ac35f3d89090a5816b626cf46e67dbecf3f054131f84d56b4e70496f45", size = 3369398, upload-time = "2025-10-10T15:41:45.719Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1f/9589e35e9ea68035385db7bdbf500d36b8891db474063fb1ccc8215ee37c/fastavro-1.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:5aa777b8ee595b50aa084104cd70670bf25a7bbb9fd8bb5d07524b0785ee1699", size = 444220, upload-time = "2025-10-10T15:41:47.39Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d2/78435fe737df94bd8db2234b2100f5453737cffd29adee2504a2b013de84/fastavro-1.12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c3d67c47f177e486640404a56f2f50b165fe892cc343ac3a34673b80cc7f1dd6", size = 1086611, upload-time = "2025-10-10T15:41:48.818Z" }, + { url = "https://files.pythonhosted.org/packages/b6/be/428f99b10157230ddac77ec8cc167005b29e2bd5cbe228345192bb645f30/fastavro-1.12.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5217f773492bac43dae15ff2931432bce2d7a80be7039685a78d3fab7df910bd", size = 3541001, upload-time = "2025-10-10T15:41:50.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/08/a2eea4f20b85897740efe44887e1ac08f30dfa4bfc3de8962bdcbb21a5a1/fastavro-1.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:469fecb25cba07f2e1bfa4c8d008477cd6b5b34a59d48715e1b1a73f6160097d", size = 3432217, upload-time = "2025-10-10T15:41:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/87/bb/b4c620b9eb6e9838c7f7e4b7be0762834443adf9daeb252a214e9ad3178c/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d71c8aa841ef65cfab709a22bb887955f42934bced3ddb571e98fdbdade4c609", size = 3366742, upload-time = "2025-10-10T15:41:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d1/e69534ccdd5368350646fea7d93be39e5f77c614cca825c990bd9ca58f67/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b81fc04e85dfccf7c028e0580c606e33aa8472370b767ef058aae2c674a90746", size = 3383743, upload-time = "2025-10-10T15:41:57.68Z" }, + { url = "https://files.pythonhosted.org/packages/58/54/b7b4a0c3fb5fcba38128542da1b26c4e6d69933c923f493548bdfd63ab6a/fastavro-1.12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9445da127751ba65975d8e4bdabf36bfcfdad70fc35b2d988e3950cce0ec0e7c", size = 1001377, upload-time = "2025-10-10T15:41:59.241Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4f/0e589089c7df0d8f57d7e5293fdc34efec9a3b758a0d4d0c99a7937e2492/fastavro-1.12.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed924233272719b5d5a6a0b4d80ef3345fc7e84fc7a382b6232192a9112d38a6", size = 3320401, upload-time = "2025-10-10T15:42:01.682Z" }, + { url = "https://files.pythonhosted.org/packages/f9/19/260110d56194ae29d7e423a336fccea8bcd103196d00f0b364b732bdb84e/fastavro-1.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3616e2f0e1c9265e92954fa099db79c6e7817356d3ff34f4bcc92699ae99697c", size = 3350894, upload-time = "2025-10-10T15:42:04.073Z" }, + { url = "https://files.pythonhosted.org/packages/d0/96/58b0411e8be9694d5972bee3167d6c1fd1fdfdf7ce253c1a19a327208f4f/fastavro-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cb0337b42fd3c047fcf0e9b7597bd6ad25868de719f29da81eabb6343f08d399", size = 3229644, upload-time = "2025-10-10T15:42:06.221Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/38660660eac82c30471d9101f45b3acfdcbadfe42d8f7cdb129459a45050/fastavro-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:64961ab15b74b7c168717bbece5660e0f3d457837c3cc9d9145181d011199fa7", size = 3329704, upload-time = "2025-10-10T15:42:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a9/1672910f458ecb30b596c9e59e41b7c00309b602a0494341451e92e62747/fastavro-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:792356d320f6e757e89f7ac9c22f481e546c886454a6709247f43c0dd7058004", size = 452911, upload-time = "2025-10-10T15:42:09.795Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/2e15d0938ded1891b33eff252e8500605508b799c2e57188a933f0bd744c/fastavro-1.12.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120aaf82ac19d60a1016afe410935fe94728752d9c2d684e267e5b7f0e70f6d9", size = 3541999, upload-time = "2025-10-10T15:42:11.794Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1c/6dfd082a205be4510543221b734b1191299e6a1810c452b6bc76dfa6968e/fastavro-1.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6a3462934b20a74f9ece1daa49c2e4e749bd9a35fa2657b53bf62898fba80f5", size = 3433972, upload-time = "2025-10-10T15:42:14.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/9de694625a1a4b727b1ad0958d220cab25a9b6cf7f16a5c7faa9ea7b2261/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1f81011d54dd47b12437b51dd93a70a9aa17b61307abf26542fc3c13efbc6c51", size = 3368752, upload-time = "2025-10-10T15:42:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/fa/93/b44f67589e4d439913dab6720f7e3507b0fa8b8e56d06f6fc875ced26afb/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43ded16b3f4a9f1a42f5970c2aa618acb23ea59c4fcaa06680bdf470b255e5a8", size = 3386636, upload-time = "2025-10-10T15:42:18.974Z" }, ] [[package]] @@ -702,100 +686,100 @@ dependencies = [ { name = "uvicorn" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/50/d38e4371bdc34e709f4731b1e882cb7bc50e51c1a224859d4cd381b3a79b/fastmcp-2.14.1.tar.gz", hash = "sha256:132725cbf77b68fa3c3d165eff0cfa47e40c1479457419e6a2cfda65bd84c8d6", size = 8263331 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/50/d38e4371bdc34e709f4731b1e882cb7bc50e51c1a224859d4cd381b3a79b/fastmcp-2.14.1.tar.gz", hash = "sha256:132725cbf77b68fa3c3d165eff0cfa47e40c1479457419e6a2cfda65bd84c8d6", size = 8263331, upload-time = "2025-12-15T02:26:27.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/82/72401d09dc27c27fdf72ad6c2fe331e553e3c3646e01b5ff16473191033d/fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5", size = 412176 }, + { url = "https://files.pythonhosted.org/packages/1d/82/72401d09dc27c27fdf72ad6c2fe331e553e3c3646e01b5ff16473191033d/fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5", size = 412176, upload-time = "2025-12-15T02:26:25.356Z" }, ] [[package]] name = "filelock" version = "3.20.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666 }, + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "fsspec" version = "2025.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/27/954057b0d1f53f086f681755207dda6de6c660ce133c829158e8e8fe7895/fsspec-2025.12.0.tar.gz", hash = "sha256:c505de011584597b1060ff778bb664c1bc022e87921b0e4f10cc9c44f9635973", size = 309748 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/27/954057b0d1f53f086f681755207dda6de6c660ce133c829158e8e8fe7895/fsspec-2025.12.0.tar.gz", hash = "sha256:c505de011584597b1060ff778bb664c1bc022e87921b0e4f10cc9c44f9635973", size = 309748, upload-time = "2025-12-03T15:23:42.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422 }, + { url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" }, ] [[package]] @@ -806,9 +790,9 @@ dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/aa/81f76b90f8d1a7dcd9297bd8bf664927ae2a1efe40fe5d1a8856dc721359/genai_prices-0.0.49.tar.gz", hash = "sha256:a7f98f1537e6f89ed54f1cd8f560806e187033dcb42554fbecd4d635567120c5", size = 57852 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/aa/81f76b90f8d1a7dcd9297bd8bf664927ae2a1efe40fe5d1a8856dc721359/genai_prices-0.0.49.tar.gz", hash = "sha256:a7f98f1537e6f89ed54f1cd8f560806e187033dcb42554fbecd4d635567120c5", size = 57852, upload-time = "2025-12-17T10:47:29.345Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/1e/1d51238dd164dde10c4e3be6ad2d8f26dd34dd262117c277440e2b5dc7c0/genai_prices-0.0.49-py3-none-any.whl", hash = "sha256:dd3efbebcd865d89cd849793530729e7f7e1ca59d2b17a091ad1aa6aa76daf0d", size = 60433 }, + { url = "https://files.pythonhosted.org/packages/e4/1e/1d51238dd164dde10c4e3be6ad2d8f26dd34dd262117c277440e2b5dc7c0/genai_prices-0.0.49-py3-none-any.whl", hash = "sha256:dd3efbebcd865d89cd849793530729e7f7e1ca59d2b17a091ad1aa6aa76daf0d", size = 60433, upload-time = "2025-12-17T10:47:28.3Z" }, ] [[package]] @@ -822,9 +806,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/3b/d8d4bd504b9d353f40d83f9c52d6e7b5efbc2b2c54979584e045cee87555/githubkit-0.14.1.tar.gz", hash = "sha256:7f5f9463c901c44871ffe0c1ddb1f2959f8cdbe015e0ca5ba0bfcaf1858651af", size = 2643130 } +sdist = { url = "https://files.pythonhosted.org/packages/55/3b/d8d4bd504b9d353f40d83f9c52d6e7b5efbc2b2c54979584e045cee87555/githubkit-0.14.1.tar.gz", hash = "sha256:7f5f9463c901c44871ffe0c1ddb1f2959f8cdbe015e0ca5ba0bfcaf1858651af", size = 2643130, upload-time = "2025-12-21T08:55:11.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d1/c2e6e05ea979d1ef1c0ab99cf21e3ae5728d7c2db16d4a1fea9f8f345d77/githubkit-0.14.1-py3-none-any.whl", hash = "sha256:e0055a287d86543ba64db65a44a828b93e21ff221bc12a77913349e4816f5195", size = 6446837 }, + { url = "https://files.pythonhosted.org/packages/8f/d1/c2e6e05ea979d1ef1c0ab99cf21e3ae5728d7c2db16d4a1fea9f8f345d77/githubkit-0.14.1-py3-none-any.whl", hash = "sha256:e0055a287d86543ba64db65a44a828b93e21ff221bc12a77913349e4816f5195", size = 6446837, upload-time = "2025-12-21T08:55:09.446Z" }, ] [[package]] @@ -836,9 +820,9 @@ dependencies = [ { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/00/3c794502a8b892c404b2dea5b3650eb21bfc7069612fbfd15c7f17c1cb0d/google_auth-2.45.0.tar.gz", hash = "sha256:90d3f41b6b72ea72dd9811e765699ee491ab24139f34ebf1ca2b9cc0c38708f3", size = 320708 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/00/3c794502a8b892c404b2dea5b3650eb21bfc7069612fbfd15c7f17c1cb0d/google_auth-2.45.0.tar.gz", hash = "sha256:90d3f41b6b72ea72dd9811e765699ee491ab24139f34ebf1ca2b9cc0c38708f3", size = 320708, upload-time = "2025-12-15T22:58:42.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl", hash = "sha256:82344e86dc00410ef5382d99be677c6043d72e502b625aa4f4afa0bdacca0f36", size = 233312 }, + { url = "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl", hash = "sha256:82344e86dc00410ef5382d99be677c6043d72e502b625aa4f4afa0bdacca0f36", size = 233312, upload-time = "2025-12-15T22:58:40.777Z" }, ] [package.optional-dependencies] @@ -862,9 +846,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/ad/d3ac5a102135bd3f1e4b1475ca65d2bd4bcc22eb2e9348ac40fe3fadb1d6/google_genai-1.56.0.tar.gz", hash = "sha256:0491af33c375f099777ae207d9621f044e27091fafad4c50e617eba32165e82f", size = 340451 } +sdist = { url = "https://files.pythonhosted.org/packages/70/ad/d3ac5a102135bd3f1e4b1475ca65d2bd4bcc22eb2e9348ac40fe3fadb1d6/google_genai-1.56.0.tar.gz", hash = "sha256:0491af33c375f099777ae207d9621f044e27091fafad4c50e617eba32165e82f", size = 340451, upload-time = "2025-12-17T12:35:05.412Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/93/94bc7a89ef4e7ed3666add55cd859d1483a22737251df659bf1aa46e9405/google_genai-1.56.0-py3-none-any.whl", hash = "sha256:9e6b11e0c105ead229368cb5849a480e4d0185519f8d9f538d61ecfcf193b052", size = 426563 }, + { url = "https://files.pythonhosted.org/packages/84/93/94bc7a89ef4e7ed3666add55cd859d1483a22737251df659bf1aa46e9405/google_genai-1.56.0-py3-none-any.whl", hash = "sha256:9e6b11e0c105ead229368cb5849a480e4d0185519f8d9f538d61ecfcf193b052", size = 426563, upload-time = "2025-12-17T12:35:03.717Z" }, ] [[package]] @@ -874,40 +858,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515 }, + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, ] [[package]] name = "greenlet" version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140 }, - { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219 }, - { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211 }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311 }, - { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833 }, - { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256 }, - { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483 }, - { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833 }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671 }, - { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360 }, - { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160 }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388 }, - { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166 }, - { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193 }, - { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387 }, - { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638 }, - { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145 }, - { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236 }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506 }, - { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783 }, - { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857 }, - { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034 }, +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, ] [[package]] @@ -917,9 +898,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705 }, + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, ] [[package]] @@ -934,47 +915,47 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/12/f4099a141677fcd2ed79dcc1fcec431e60c52e0e90c9c5d935f0ffaf8c0e/groq-1.0.0.tar.gz", hash = "sha256:66cb7bb729e6eb644daac7ce8efe945e99e4eb33657f733ee6f13059ef0c25a9", size = 146068 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/12/f4099a141677fcd2ed79dcc1fcec431e60c52e0e90c9c5d935f0ffaf8c0e/groq-1.0.0.tar.gz", hash = "sha256:66cb7bb729e6eb644daac7ce8efe945e99e4eb33657f733ee6f13059ef0c25a9", size = 146068, upload-time = "2025-12-17T23:34:23.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/88/3175759d2ef30406ea721f4d837bfa1ba4339fde3b81ba8c5640a96ed231/groq-1.0.0-py3-none-any.whl", hash = "sha256:6e22bf92ffad988f01d2d4df7729add66b8fd5dbfb2154b5bbf3af245b72c731", size = 138292 }, + { url = "https://files.pythonhosted.org/packages/4a/88/3175759d2ef30406ea721f4d837bfa1ba4339fde3b81ba8c5640a96ed231/groq-1.0.0-py3-none-any.whl", hash = "sha256:6e22bf92ffad988f01d2d4df7729add66b8fd5dbfb2154b5bbf3af245b72c731", size = 138292, upload-time = "2025-12-17T23:34:21.957Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "hf-xet" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870 }, - { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584 }, - { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004 }, - { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636 }, - { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448 }, - { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401 }, - { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866 }, - { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861 }, - { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699 }, - { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885 }, - { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550 }, - { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010 }, - { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264 }, - { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071 }, - { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099 }, - { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178 }, - { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214 }, - { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054 }, - { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812 }, - { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920 }, - { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735 }, +sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, + { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, + { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, + { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, ] [[package]] @@ -988,9 +969,9 @@ dependencies = [ { name = "msgpack" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/64/a104ccac48f123f853254483617b16e0efc1649bd7e35bcdc5a5a5ef0ae2/hishel-0.1.5.tar.gz", hash = "sha256:9d40c682cd94fd6e1394fb05713ae20a75ed8aeba6f5272380444039ce6257f2", size = 75468 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/64/a104ccac48f123f853254483617b16e0efc1649bd7e35bcdc5a5a5ef0ae2/hishel-0.1.5.tar.gz", hash = "sha256:9d40c682cd94fd6e1394fb05713ae20a75ed8aeba6f5272380444039ce6257f2", size = 75468, upload-time = "2025-10-18T13:32:41.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/83/4f8b77839e62114bb034375ee8e08cfb6af1164754b925b271d3f1ec06ee/hishel-0.1.5-py3-none-any.whl", hash = "sha256:0bfbe9a2b9342090eba82ba6de88258092e1c4c7b730cd4cb4b570e4b40e44a7", size = 92486 }, + { url = "https://files.pythonhosted.org/packages/70/83/4f8b77839e62114bb034375ee8e08cfb6af1164754b925b271d3f1ec06ee/hishel-0.1.5-py3-none-any.whl", hash = "sha256:0bfbe9a2b9342090eba82ba6de88258092e1c4c7b730cd4cb4b570e4b40e44a7", size = 92486, upload-time = "2025-10-18T13:32:40.333Z" }, ] [[package]] @@ -1001,31 +982,31 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httptools" version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] @@ -1038,18 +1019,18 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "httpx-sse" version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] @@ -1066,9 +1047,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358 } +sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094 }, + { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, ] [package.optional-dependencies] @@ -1080,9 +1061,9 @@ inference = [ name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -1092,18 +1073,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 }, + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] name = "invoke" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762 } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287 }, + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, ] [[package]] @@ -1113,18 +1094,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, ] [[package]] name = "jaraco-context" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/7d/41acf8e22d791bde812cb6c2c36128bb932ed8ae066bcb5e39cb198e8253/jaraco_context-6.0.2.tar.gz", hash = "sha256:953ae8dddb57b1d791bf72ea1009b32088840a7dd19b9ba16443f62be919ee57", size = 14994 } +sdist = { url = "https://files.pythonhosted.org/packages/8d/7d/41acf8e22d791bde812cb6c2c36128bb932ed8ae066bcb5e39cb198e8253/jaraco_context-6.0.2.tar.gz", hash = "sha256:953ae8dddb57b1d791bf72ea1009b32088840a7dd19b9ba16443f62be919ee57", size = 14994, upload-time = "2025-12-24T19:21:35.784Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0c/1e0096ced9c55f9c6c6655446798df74165780375d3f5ab5f33751e087ae/jaraco_context-6.0.2-py3-none-any.whl", hash = "sha256:55fc21af4b4f9ca94aa643b6ee7fe13b1e4c01abf3aeb98ca4ad9c80b741c786", size = 6988 }, + { url = "https://files.pythonhosted.org/packages/c7/0c/1e0096ced9c55f9c6c6655446798df74165780375d3f5ab5f33751e087ae/jaraco_context-6.0.2-py3-none-any.whl", hash = "sha256:55fc21af4b4f9ca94aa643b6ee7fe13b1e4c01abf3aeb98ca4ad9c80b741c786", size = 6988, upload-time = "2025-12-24T19:21:34.557Z" }, ] [[package]] @@ -1134,78 +1115,78 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481 }, + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, ] [[package]] name = "jeepney" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] [[package]] name = "jiter" version = "0.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658 }, - { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605 }, - { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803 }, - { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120 }, - { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918 }, - { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008 }, - { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785 }, - { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108 }, - { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937 }, - { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853 }, - { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699 }, - { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258 }, - { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503 }, - { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965 }, - { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831 }, - { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272 }, - { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604 }, - { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628 }, - { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478 }, - { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706 }, - { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894 }, - { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714 }, - { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989 }, - { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615 }, - { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745 }, - { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502 }, - { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845 }, - { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701 }, - { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029 }, - { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960 }, - { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529 }, - { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974 }, - { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932 }, - { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243 }, - { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315 }, - { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168 }, - { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893 }, - { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828 }, - { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009 }, - { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110 }, - { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223 }, - { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564 }, +sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" }, + { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" }, + { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" }, + { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" }, + { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" }, + { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" }, + { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" }, + { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" }, + { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" }, + { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" }, + { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" }, + { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, ] [[package]] name = "jmespath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, ] [[package]] @@ -1218,9 +1199,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342 } +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040 }, + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, ] [[package]] @@ -1233,9 +1214,9 @@ dependencies = [ { name = "referencing" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159 } +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810 }, + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, ] [[package]] @@ -1245,9 +1226,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] @@ -1262,9 +1243,9 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516 } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] [[package]] @@ -1283,9 +1264,9 @@ dependencies = [ { name = "urllib3" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/55/3f880ef65f559cbed44a9aa20d3bdbc219a2c3a3bac4a30a513029b03ee9/kubernetes-34.1.0.tar.gz", hash = "sha256:8fe8edb0b5d290a2f3ac06596b23f87c658977d46b5f8df9d0f4ea83d0003912", size = 1083771 } +sdist = { url = "https://files.pythonhosted.org/packages/ef/55/3f880ef65f559cbed44a9aa20d3bdbc219a2c3a3bac4a30a513029b03ee9/kubernetes-34.1.0.tar.gz", hash = "sha256:8fe8edb0b5d290a2f3ac06596b23f87c658977d46b5f8df9d0f4ea83d0003912", size = 1083771, upload-time = "2025-09-29T20:23:49.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380 }, + { url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380, upload-time = "2025-09-29T20:23:47.684Z" }, ] [[package]] @@ -1301,9 +1282,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/60/b8040db3598a55da64c45e3e689f2baa87389a4648a6f46ba80be3329f23/logfire-4.16.0.tar.gz", hash = "sha256:03a3ab8fdc13399309cb55d69cba7a6fcbad3526cfad85fc4f72e7d75e22b654", size = 550759 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/60/b8040db3598a55da64c45e3e689f2baa87389a4648a6f46ba80be3329f23/logfire-4.16.0.tar.gz", hash = "sha256:03a3ab8fdc13399309cb55d69cba7a6fcbad3526cfad85fc4f72e7d75e22b654", size = 550759, upload-time = "2025-12-04T16:16:39.477Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/f7/ffcf81eb4aea75e40c0646b9519947d2070626c5d533922df92975045181/logfire-4.16.0-py3-none-any.whl", hash = "sha256:8f895f6c2efa593ad6d49e1b06d8e6e351d3dd0cad61ce5def0c3d401f8ea707", size = 229122 }, + { url = "https://files.pythonhosted.org/packages/53/f7/ffcf81eb4aea75e40c0646b9519947d2070626c5d533922df92975045181/logfire-4.16.0-py3-none-any.whl", hash = "sha256:8f895f6c2efa593ad6d49e1b06d8e6e351d3dd0cad61ce5def0c3d401f8ea707", size = 229122, upload-time = "2025-12-04T16:16:35.963Z" }, ] [package.optional-dependencies] @@ -1315,50 +1296,50 @@ httpx = [ name = "logfire-api" version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/d5f5e276371d5c8cde559d558de44b8378641231a23f3a632ebfe4b05c9b/logfire_api-4.16.0.tar.gz", hash = "sha256:0efa62f5e73abdea670b5e9384c841b544474207110a089536a0fa8704f9e386", size = 57702 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/d5f5e276371d5c8cde559d558de44b8378641231a23f3a632ebfe4b05c9b/logfire_api-4.16.0.tar.gz", hash = "sha256:0efa62f5e73abdea670b5e9384c841b544474207110a089536a0fa8704f9e386", size = 57702, upload-time = "2025-12-04T16:16:40.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/6e/6d500ce6352c54566d03c65d92a8f3fc7045645814de046707b105dda2a6/logfire_api-4.16.0-py3-none-any.whl", hash = "sha256:7351153c35cb61f0f89d2d4123ebf99b5469d70ef34c613a5ce56f85bf1b14fb", size = 95247 }, + { url = "https://files.pythonhosted.org/packages/9b/6e/6d500ce6352c54566d03c65d92a8f3fc7045645814de046707b105dda2a6/logfire_api-4.16.0-py3-none-any.whl", hash = "sha256:7351153c35cb61f0f89d2d4123ebf99b5469d70ef34c613a5ce56f85bf1b14fb", size = 95247, upload-time = "2025-12-04T16:16:38.007Z" }, ] [[package]] name = "lupa" version = "2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232 }, - { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625 }, - { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057 }, - { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227 }, - { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752 }, - { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009 }, - { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301 }, - { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673 }, - { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227 }, - { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558 }, - { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424 }, - { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818 }, - { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850 }, - { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344 }, - { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543 }, - { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974 }, - { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458 }, - { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683 }, - { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892 }, - { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641 }, - { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857 }, - { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191 }, - { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343 }, - { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730 }, - { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553 }, - { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275 }, - { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441 }, - { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324 }, - { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250 }, - { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126 }, - { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693 }, - { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104 }, - { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818 }, +sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" }, + { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" }, + { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" }, + { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" }, + { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" }, + { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" }, + { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" }, + { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" }, + { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" }, + { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" }, + { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, ] [[package]] @@ -1366,9 +1347,7 @@ name = "mainloop-backend" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "anthropic" }, { name = "asyncpg" }, - { name = "claude-agent-sdk" }, { name = "dbos" }, { name = "fastapi" }, { name = "githubkit" }, @@ -1384,9 +1363,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.75.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, - { name = "claude-agent-sdk", specifier = ">=0.1.18" }, { name = "dbos", specifier = ">=2.7.0" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "githubkit", specifier = ">=0.11.0" }, @@ -1407,9 +1384,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -1432,18 +1409,18 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387 } +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076 }, + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -1459,9 +1436,9 @@ dependencies = [ { name = "pyyaml" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/d8b7af67a966b6f227024e1cb7287fc19901a434f87a5a391dcfe635d338/mistralai-1.9.11.tar.gz", hash = "sha256:3df9e403c31a756ec79e78df25ee73cea3eb15f86693773e16b16adaf59c9b8a", size = 208051 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/d8b7af67a966b6f227024e1cb7287fc19901a434f87a5a391dcfe635d338/mistralai-1.9.11.tar.gz", hash = "sha256:3df9e403c31a756ec79e78df25ee73cea3eb15f86693773e16b16adaf59c9b8a", size = 208051, upload-time = "2025-10-02T15:53:40.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl", hash = "sha256:7a3dc2b8ef3fceaa3582220234261b5c4e3e03a972563b07afa150e44a25a6d3", size = 442796 }, + { url = "https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl", hash = "sha256:7a3dc2b8ef3fceaa3582220234261b5c4e3e03a972563b07afa150e44a25a6d3", size = 442796, upload-time = "2025-10-02T15:53:39.134Z" }, ] [[package]] @@ -1479,125 +1456,125 @@ requires-dist = [{ name = "pydantic", specifier = ">=2.12.5" }] name = "more-itertools" version = "10.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431 } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667 }, + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] [[package]] name = "msgpack" version = "1.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212 }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315 }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721 }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657 }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668 }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040 }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037 }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631 }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118 }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127 }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981 }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885 }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658 }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290 }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234 }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391 }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787 }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453 }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264 }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076 }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242 }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509 }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957 }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910 }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197 }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772 }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868 }, +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] name = "multidict" version = "6.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135 }, - { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117 }, - { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472 }, - { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342 }, - { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082 }, - { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704 }, - { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355 }, - { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259 }, - { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903 }, - { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365 }, - { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062 }, - { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683 }, - { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254 }, - { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967 }, - { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085 }, - { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713 }, - { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915 }, - { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077 }, - { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114 }, - { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442 }, - { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885 }, - { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588 }, - { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966 }, - { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618 }, - { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539 }, - { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345 }, - { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934 }, - { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243 }, - { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878 }, - { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452 }, - { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312 }, - { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935 }, - { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385 }, - { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777 }, - { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104 }, - { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503 }, - { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128 }, - { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410 }, - { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205 }, - { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084 }, - { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667 }, - { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112 }, - { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194 }, - { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510 }, - { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395 }, - { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520 }, - { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479 }, - { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903 }, - { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333 }, - { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411 }, - { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940 }, - { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087 }, - { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368 }, - { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326 }, - { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065 }, - { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475 }, - { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324 }, - { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877 }, - { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824 }, - { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558 }, - { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339 }, - { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895 }, - { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862 }, - { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376 }, - { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272 }, - { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774 }, - { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731 }, - { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193 }, - { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023 }, - { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507 }, - { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804 }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317 }, +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] [[package]] @@ -1607,18 +1584,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/50/95d7bc91f900da5e22662c82d9bf0f72a4b01f2a552708bf2f43807707a1/nexus_rpc-1.2.0.tar.gz", hash = "sha256:b4ddaffa4d3996aaeadf49b80dfcdfbca48fe4cb616defaf3b3c5c2c8fc61890", size = 74142 } +sdist = { url = "https://files.pythonhosted.org/packages/06/50/95d7bc91f900da5e22662c82d9bf0f72a4b01f2a552708bf2f43807707a1/nexus_rpc-1.2.0.tar.gz", hash = "sha256:b4ddaffa4d3996aaeadf49b80dfcdfbca48fe4cb616defaf3b3c5c2c8fc61890", size = 74142, upload-time = "2025-11-17T19:17:06.798Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/04/eaac430d0e6bf21265ae989427d37e94be5e41dc216879f1fbb6c5339942/nexus_rpc-1.2.0-py3-none-any.whl", hash = "sha256:977876f3af811ad1a09b2961d3d1ac9233bda43ff0febbb0c9906483b9d9f8a3", size = 28166 }, + { url = "https://files.pythonhosted.org/packages/13/04/eaac430d0e6bf21265ae989427d37e94be5e41dc216879f1fbb6c5339942/nexus_rpc-1.2.0-py3-none-any.whl", hash = "sha256:977876f3af811ad1a09b2961d3d1ac9233bda43ff0febbb0c9906483b9d9f8a3", size = 28166, upload-time = "2025-11-17T19:17:05.64Z" }, ] [[package]] name = "oauthlib" version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] [[package]] @@ -1635,9 +1612,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/b1/12fe1c196bea326261718eb037307c1c1fe1dedc2d2d4de777df822e6238/openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952", size = 626938 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/b1/12fe1c196bea326261718eb037307c1c1fe1dedc2d2d4de777df822e6238/openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952", size = 626938, upload-time = "2025-12-19T03:28:45.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558 }, + { url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558, upload-time = "2025-12-19T03:28:43.727Z" }, ] [[package]] @@ -1647,9 +1624,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] [[package]] @@ -1660,9 +1637,9 @@ dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767 } +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356 }, + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] [[package]] @@ -1672,9 +1649,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366 }, + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, ] [[package]] @@ -1690,9 +1667,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288 } +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641 }, + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, ] [[package]] @@ -1704,9 +1681,9 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "prometheus-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976 } +sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019 }, + { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, ] [[package]] @@ -1719,9 +1696,9 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706 } +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096 }, + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, ] [[package]] @@ -1735,9 +1712,9 @@ dependencies = [ { name = "opentelemetry-util-http" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611 } +sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611, upload-time = "2025-12-11T13:37:01.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701 }, + { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701, upload-time = "2025-12-11T13:36:04.56Z" }, ] [[package]] @@ -1747,9 +1724,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152 } +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535 }, + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, ] [[package]] @@ -1761,9 +1738,9 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565 }, + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, ] [[package]] @@ -1774,63 +1751,63 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935 } +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982 }, + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, ] [[package]] name = "opentelemetry-util-http" version = "0.60b1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053 } +sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947 }, + { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "pathable" version = "0.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124 } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592 }, + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] [[package]] name = "pathvalidate" version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262 } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305 }, + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, ] [[package]] name = "platformdirs" version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 }, + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[package]] name = "prometheus-client" version = "0.23.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481 } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145 }, + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, ] [[package]] @@ -1840,93 +1817,93 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "propcache" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750 }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780 }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308 }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182 }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215 }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112 }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442 }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398 }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920 }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748 }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877 }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437 }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586 }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790 }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158 }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451 }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374 }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396 }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950 }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856 }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420 }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254 }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205 }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873 }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739 }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514 }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781 }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396 }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897 }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789 }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152 }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869 }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596 }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981 }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490 }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371 }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424 }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566 }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130 }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625 }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209 }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797 }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140 }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257 }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097 }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455 }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372 }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411 }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712 }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557 }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015 }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880 }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938 }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641 }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510 }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161 }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393 }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546 }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259 }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428 }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] name = "protobuf" version = "6.33.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/44/e49ecff446afeec9d1a66d6bbf9adc21e3c7cea7803a920ca3773379d4f6/protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", size = 444296 } +sdist = { url = "https://files.pythonhosted.org/packages/34/44/e49ecff446afeec9d1a66d6bbf9adc21e3c7cea7803a920ca3773379d4f6/protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", size = 444296, upload-time = "2025-12-06T00:17:53.311Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/91/1e3a34881a88697a7354ffd177e8746e97a722e5e8db101544b47e84afb1/protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", size = 425603 }, - { url = "https://files.pythonhosted.org/packages/64/20/4d50191997e917ae13ad0a235c8b42d8c1ab9c3e6fd455ca16d416944355/protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4", size = 436930 }, - { url = "https://files.pythonhosted.org/packages/b2/ca/7e485da88ba45c920fb3f50ae78de29ab925d9e54ef0de678306abfbb497/protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", size = 427621 }, - { url = "https://files.pythonhosted.org/packages/7d/4f/f743761e41d3b2b2566748eb76bbff2b43e14d5fcab694f494a16458b05f/protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", size = 324460 }, - { url = "https://files.pythonhosted.org/packages/b1/fa/26468d00a92824020f6f2090d827078c09c9c587e34cbfd2d0c7911221f8/protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", size = 339168 }, - { url = "https://files.pythonhosted.org/packages/56/13/333b8f421738f149d4fe5e49553bc2a2ab75235486259f689b4b91f96cec/protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", size = 323270 }, - { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501 }, + { url = "https://files.pythonhosted.org/packages/bc/91/1e3a34881a88697a7354ffd177e8746e97a722e5e8db101544b47e84afb1/protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", size = 425603, upload-time = "2025-12-06T00:17:41.114Z" }, + { url = "https://files.pythonhosted.org/packages/64/20/4d50191997e917ae13ad0a235c8b42d8c1ab9c3e6fd455ca16d416944355/protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4", size = 436930, upload-time = "2025-12-06T00:17:43.278Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ca/7e485da88ba45c920fb3f50ae78de29ab925d9e54ef0de678306abfbb497/protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", size = 427621, upload-time = "2025-12-06T00:17:44.445Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4f/f743761e41d3b2b2566748eb76bbff2b43e14d5fcab694f494a16458b05f/protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", size = 324460, upload-time = "2025-12-06T00:17:45.678Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/26468d00a92824020f6f2090d827078c09c9c587e34cbfd2d0c7911221f8/protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", size = 339168, upload-time = "2025-12-06T00:17:46.813Z" }, + { url = "https://files.pythonhosted.org/packages/56/13/333b8f421738f149d4fe5e49553bc2a2ab75235486259f689b4b91f96cec/protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", size = 323270, upload-time = "2025-12-06T00:17:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, ] [[package]] @@ -1936,9 +1913,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774 }, + { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, ] [package.optional-dependencies] @@ -1951,28 +1928,28 @@ name = "psycopg-binary" version = "3.3.2" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068 }, - { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520 }, - { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086 }, - { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125 }, - { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914 }, - { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081 }, - { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332 }, - { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781 }, - { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544 }, - { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070 }, - { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922 }, - { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371 }, - { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139 }, - { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120 }, - { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484 }, - { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818 }, - { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859 }, - { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388 }, - { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382 }, - { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660 }, - { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169 }, - { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122 }, + { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" }, + { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" }, + { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" }, + { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" }, + { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" }, + { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" }, + { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" }, + { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" }, + { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, ] [[package]] @@ -1983,9 +1960,9 @@ dependencies = [ { name = "beartype" }, { name = "py-key-value-shared" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801 } +sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342 }, + { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, ] [package.optional-dependencies] @@ -2011,18 +1988,18 @@ dependencies = [ { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560 }, + { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, ] [[package]] name = "pyasn1" version = "0.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, ] [[package]] @@ -2032,18 +2009,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycparser" version = "2.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 }, + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] [[package]] @@ -2056,9 +2033,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [package.optional-dependencies] @@ -2073,9 +2050,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/da/5b4f63442a0af545f979e9ef70fc0382d23f2707392dff2bb75ad1234e08/pydantic_ai-1.39.0.tar.gz", hash = "sha256:3aa2ca2de0c71bef342acef9ac11665d2a20c241b2a4a3d4111d0d0d7b3416f4", size = 11630 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/da/5b4f63442a0af545f979e9ef70fc0382d23f2707392dff2bb75ad1234e08/pydantic_ai-1.39.0.tar.gz", hash = "sha256:3aa2ca2de0c71bef342acef9ac11665d2a20c241b2a4a3d4111d0d0d7b3416f4", size = 11630, upload-time = "2025-12-24T03:34:09.044Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/88/7ae680c16e08cb2b1f2b343f49fdf77c590dc542aebd0de25f0ebfae77e2/pydantic_ai-1.39.0-py3-none-any.whl", hash = "sha256:234bc1dd69a391cfe98888e3c1ab5e2b3ef027aa255a8fdc1df261d3c2852170", size = 7191 }, + { url = "https://files.pythonhosted.org/packages/4e/88/7ae680c16e08cb2b1f2b343f49fdf77c590dc542aebd0de25f0ebfae77e2/pydantic_ai-1.39.0-py3-none-any.whl", hash = "sha256:234bc1dd69a391cfe98888e3c1ab5e2b3ef027aa255a8fdc1df261d3c2852170", size = 7191, upload-time = "2025-12-24T03:33:59.844Z" }, ] [package.optional-dependencies] @@ -2096,9 +2073,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/cb/542ad43e06da09104ef3443556e629d9aa260f9d584da8f7a410fb3a07e5/pydantic_ai_slim-1.39.0.tar.gz", hash = "sha256:e8cea9fc8f6149347c3e1d489b0ed2d541b4789e0583819f116284145d22fa69", size = 368962 } +sdist = { url = "https://files.pythonhosted.org/packages/91/cb/542ad43e06da09104ef3443556e629d9aa260f9d584da8f7a410fb3a07e5/pydantic_ai_slim-1.39.0.tar.gz", hash = "sha256:e8cea9fc8f6149347c3e1d489b0ed2d541b4789e0583819f116284145d22fa69", size = 368962, upload-time = "2025-12-24T03:34:11.306Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/df/86381632be07b7df2e8e5880a1f18c6ee98122adf5848d329fee239a03b2/pydantic_ai_slim-1.39.0-py3-none-any.whl", hash = "sha256:8669d1781eba7713870bf76783e1e853577d5e55eb2986a27d49bc600889aaaf", size = 484906 }, + { url = "https://files.pythonhosted.org/packages/2a/df/86381632be07b7df2e8e5880a1f18c6ee98122adf5848d329fee239a03b2/pydantic_ai_slim-1.39.0-py3-none-any.whl", hash = "sha256:8669d1781eba7713870bf76783e1e853577d5e55eb2986a27d49bc600889aaaf", size = 484906, upload-time = "2025-12-24T03:34:03.179Z" }, ] [package.optional-dependencies] @@ -2173,50 +2150,50 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] [[package]] @@ -2231,9 +2208,9 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/20/ec455c7d32fde2022805870daf78581c9c493a4fcae6f32204fae5025658/pydantic_evals-1.39.0.tar.gz", hash = "sha256:6f8a754ca84afff3f2b2de9802fb0e12f69d9fc0a0411e2f7c9709fc09fb43b3", size = 47179 } +sdist = { url = "https://files.pythonhosted.org/packages/20/20/ec455c7d32fde2022805870daf78581c9c493a4fcae6f32204fae5025658/pydantic_evals-1.39.0.tar.gz", hash = "sha256:6f8a754ca84afff3f2b2de9802fb0e12f69d9fc0a0411e2f7c9709fc09fb43b3", size = 47179, upload-time = "2025-12-24T03:34:12.477Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/c1/6d43ecd3f7acb78a3f683178d40008d486c08583ca848891f000d62c142e/pydantic_evals-1.39.0-py3-none-any.whl", hash = "sha256:18470ade5fea15d17911a517e37ea98700702d9ba011ef2facb707e87eae0564", size = 56347 }, + { url = "https://files.pythonhosted.org/packages/f6/c1/6d43ecd3f7acb78a3f683178d40008d486c08583ca848891f000d62c142e/pydantic_evals-1.39.0-py3-none-any.whl", hash = "sha256:18470ade5fea15d17911a517e37ea98700702d9ba011ef2facb707e87eae0564", size = 56347, upload-time = "2025-12-24T03:34:05.111Z" }, ] [[package]] @@ -2246,9 +2223,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/d5/2f45d1fd2ae0ba89b5a70b3bec8c2e910c4891fe0ed7e4fc896ca7e126a0/pydantic_graph-1.39.0.tar.gz", hash = "sha256:08c6f349dbbade6f4cdaaed02de4e8d75b9a37d44f8238e40a14f94f6a31761f", size = 58453 } +sdist = { url = "https://files.pythonhosted.org/packages/09/d5/2f45d1fd2ae0ba89b5a70b3bec8c2e910c4891fe0ed7e4fc896ca7e126a0/pydantic_graph-1.39.0.tar.gz", hash = "sha256:08c6f349dbbade6f4cdaaed02de4e8d75b9a37d44f8238e40a14f94f6a31761f", size = 58453, upload-time = "2025-12-24T03:34:13.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/e2/719de1af767863359278e8b69c538dba9a7dbd19bb94111206e57ca34648/pydantic_graph-1.39.0-py3-none-any.whl", hash = "sha256:e0f89fc2c7ab111ae5f38dd2d88c5d26a0784eaabe95735c2b4087b0b512cc2d", size = 72327 }, + { url = "https://files.pythonhosted.org/packages/65/e2/719de1af767863359278e8b69c538dba9a7dbd19bb94111206e57ca34648/pydantic_graph-1.39.0-py3-none-any.whl", hash = "sha256:e0f89fc2c7ab111ae5f38dd2d88c5d26a0784eaabe95735c2b4087b0b512cc2d", size = 72327, upload-time = "2025-12-24T03:34:06.476Z" }, ] [[package]] @@ -2260,9 +2237,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184 } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880 }, + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] [[package]] @@ -2283,27 +2260,27 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/c5/61dcfce4d50b66a3f09743294d37fab598b81bb0975054b7f732da9243ec/pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265", size = 297080 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/c5/61dcfce4d50b66a3f09743294d37fab598b81bb0975054b7f732da9243ec/pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265", size = 297080, upload-time = "2025-12-23T23:37:33.32Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087 }, + { url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087, upload-time = "2025-12-23T23:37:31.829Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyjwt" version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] [package.optional-dependencies] @@ -2315,9 +2292,9 @@ crypto = [ name = "pyperclip" version = "1.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063 }, + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] [[package]] @@ -2327,36 +2304,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] [[package]] name = "python-json-logger" version = "4.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683 } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548 }, + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, ] [[package]] name = "python-multipart" version = "0.0.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196 } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541 }, + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] [[package]] @@ -2364,66 +2341,66 @@ name = "pywin32" version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] [[package]] name = "pywin32-ctypes" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "redis" version = "7.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669 } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159 }, + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, ] [[package]] @@ -2434,73 +2411,73 @@ dependencies = [ { name = "attrs" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, ] [[package]] name = "regex" version = "2025.11.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081 }, - { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123 }, - { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814 }, - { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592 }, - { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122 }, - { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272 }, - { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497 }, - { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892 }, - { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462 }, - { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528 }, - { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866 }, - { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189 }, - { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054 }, - { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325 }, - { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984 }, - { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673 }, - { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029 }, - { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437 }, - { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368 }, - { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921 }, - { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708 }, - { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472 }, - { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341 }, - { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666 }, - { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473 }, - { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792 }, - { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214 }, - { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469 }, - { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089 }, - { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059 }, - { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900 }, - { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010 }, - { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893 }, - { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522 }, - { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272 }, - { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958 }, - { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289 }, - { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026 }, - { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499 }, - { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604 }, - { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320 }, - { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372 }, - { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985 }, - { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669 }, - { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030 }, - { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674 }, - { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451 }, - { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980 }, - { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852 }, - { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566 }, - { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463 }, - { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694 }, - { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691 }, - { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583 }, - { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286 }, - { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741 }, +sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" }, + { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" }, + { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" }, + { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" }, + { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" }, + { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" }, + { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" }, + { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" }, + { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" }, + { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" }, + { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" }, + { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" }, + { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, ] [[package]] @@ -2513,9 +2490,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -2526,9 +2503,9 @@ dependencies = [ { name = "oauthlib" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] [[package]] @@ -2539,9 +2516,9 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393 }, + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] [[package]] @@ -2552,75 +2529,75 @@ dependencies = [ { name = "docutils" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567 }, + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, ] [[package]] name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] @@ -2630,9 +2607,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] [[package]] @@ -2642,9 +2619,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827 } +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830 }, + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] [[package]] @@ -2655,45 +2632,45 @@ dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] @@ -2704,25 +2681,25 @@ dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912 } +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082 }, - { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131 }, - { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389 }, - { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054 }, - { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299 }, - { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264 }, - { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998 }, - { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434 }, - { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404 }, - { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057 }, - { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279 }, - { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508 }, - { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204 }, - { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785 }, - { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029 }, - { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142 }, - { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672 }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] [[package]] @@ -2733,9 +2710,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/08/8f554b0e5bad3e4e880521a1686d96c05198471eed860b0eb89b57ea3636/sse_starlette-3.1.1.tar.gz", hash = "sha256:bffa531420c1793ab224f63648c059bcadc412bf9fdb1301ac8de1cf9a67b7fb", size = 24306 } +sdist = { url = "https://files.pythonhosted.org/packages/62/08/8f554b0e5bad3e4e880521a1686d96c05198471eed860b0eb89b57ea3636/sse_starlette-3.1.1.tar.gz", hash = "sha256:bffa531420c1793ab224f63648c059bcadc412bf9fdb1301ac8de1cf9a67b7fb", size = 24306, upload-time = "2025-12-26T15:22:53.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/31/4c281581a0f8de137b710a07f65518b34bcf333b201cfa06cfda9af05f8a/sse_starlette-3.1.1-py3-none-any.whl", hash = "sha256:bb38f71ae74cfd86b529907a9fda5632195dfa6ae120f214ea4c890c7ee9d436", size = 12442 }, + { url = "https://files.pythonhosted.org/packages/e3/31/4c281581a0f8de137b710a07f65518b34bcf333b201cfa06cfda9af05f8a/sse_starlette-3.1.1-py3-none-any.whl", hash = "sha256:bb38f71ae74cfd86b529907a9fda5632195dfa6ae120f214ea4c890c7ee9d436", size = 12442, upload-time = "2025-12-26T15:22:52.911Z" }, ] [[package]] @@ -2745,9 +2722,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033 }, + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] [[package]] @@ -2760,22 +2737,22 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/db/7d5118d28b0918888e1ec98f56f659fdb006351e06d95f30f4274962a76f/temporalio-1.20.0.tar.gz", hash = "sha256:5a6a85b7d298b7359bffa30025f7deac83c74ac095a4c6952fbf06c249a2a67c", size = 1850498 } +sdist = { url = "https://files.pythonhosted.org/packages/21/db/7d5118d28b0918888e1ec98f56f659fdb006351e06d95f30f4274962a76f/temporalio-1.20.0.tar.gz", hash = "sha256:5a6a85b7d298b7359bffa30025f7deac83c74ac095a4c6952fbf06c249a2a67c", size = 1850498, upload-time = "2025-11-25T21:25:20.225Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/1b/e69052aa6003eafe595529485d9c62d1382dd5e671108f1bddf544fb6032/temporalio-1.20.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:fba70314b4068f8b1994bddfa0e2ad742483f0ae714d2ef52e63013ccfd7042e", size = 12061638 }, - { url = "https://files.pythonhosted.org/packages/ae/3b/3e8c67ed7f23bedfa231c6ac29a7a9c12b89881da7694732270f3ecd6b0c/temporalio-1.20.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffc5bb6cabc6ae67f0bfba44de6a9c121603134ae18784a2ff3a7f230ad99080", size = 11562603 }, - { url = "https://files.pythonhosted.org/packages/6d/be/ed0cc11702210522a79e09703267ebeca06eb45832b873a58de3ca76b9d0/temporalio-1.20.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1e80c1e4cdf88fa8277177f563edc91466fe4dc13c0322f26e55c76b6a219e6", size = 11824016 }, - { url = "https://files.pythonhosted.org/packages/9d/97/09c5cafabc80139d97338a2bdd8ec22e08817dfd2949ab3e5b73565006eb/temporalio-1.20.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba92d909188930860c9d89ca6d7a753bc5a67e4e9eac6cea351477c967355eed", size = 12189521 }, - { url = "https://files.pythonhosted.org/packages/11/23/5689c014a76aff3b744b3ee0d80815f63b1362637814f5fbb105244df09b/temporalio-1.20.0-cp310-abi3-win_amd64.whl", hash = "sha256:eacfd571b653e0a0f4aa6593f4d06fc628797898f0900d400e833a1f40cad03a", size = 12745027 }, + { url = "https://files.pythonhosted.org/packages/f4/1b/e69052aa6003eafe595529485d9c62d1382dd5e671108f1bddf544fb6032/temporalio-1.20.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:fba70314b4068f8b1994bddfa0e2ad742483f0ae714d2ef52e63013ccfd7042e", size = 12061638, upload-time = "2025-11-25T21:24:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/3e8c67ed7f23bedfa231c6ac29a7a9c12b89881da7694732270f3ecd6b0c/temporalio-1.20.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffc5bb6cabc6ae67f0bfba44de6a9c121603134ae18784a2ff3a7f230ad99080", size = 11562603, upload-time = "2025-11-25T21:25:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/ed0cc11702210522a79e09703267ebeca06eb45832b873a58de3ca76b9d0/temporalio-1.20.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1e80c1e4cdf88fa8277177f563edc91466fe4dc13c0322f26e55c76b6a219e6", size = 11824016, upload-time = "2025-11-25T21:25:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/09c5cafabc80139d97338a2bdd8ec22e08817dfd2949ab3e5b73565006eb/temporalio-1.20.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba92d909188930860c9d89ca6d7a753bc5a67e4e9eac6cea351477c967355eed", size = 12189521, upload-time = "2025-11-25T21:25:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/5689c014a76aff3b744b3ee0d80815f63b1362637814f5fbb105244df09b/temporalio-1.20.0-cp310-abi3-win_amd64.whl", hash = "sha256:eacfd571b653e0a0f4aa6593f4d06fc628797898f0900d400e833a1f40cad03a", size = 12745027, upload-time = "2025-11-25T21:25:16.827Z" }, ] [[package]] name = "tenacity" version = "9.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036 } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248 }, + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] [[package]] @@ -2786,36 +2763,36 @@ dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802 }, - { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995 }, - { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948 }, - { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986 }, - { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222 }, - { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097 }, - { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117 }, - { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309 }, - { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712 }, - { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725 }, - { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875 }, - { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451 }, - { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794 }, - { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777 }, - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188 }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978 }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271 }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216 }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860 }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567 }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067 }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473 }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855 }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022 }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736 }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908 }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706 }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667 }, +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] [[package]] @@ -2825,22 +2802,22 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/46/fb6854cec3278fbfa4a75b50232c77622bc517ac886156e6afbfa4d8fc6e/tokenizers-0.22.1.tar.gz", hash = "sha256:61de6522785310a309b3407bac22d99c4db5dba349935e99e4d15ea2226af2d9", size = 363123 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/46/fb6854cec3278fbfa4a75b50232c77622bc517ac886156e6afbfa4d8fc6e/tokenizers-0.22.1.tar.gz", hash = "sha256:61de6522785310a309b3407bac22d99c4db5dba349935e99e4d15ea2226af2d9", size = 363123, upload-time = "2025-09-19T09:49:23.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/33/f4b2d94ada7ab297328fc671fed209368ddb82f965ec2224eb1892674c3a/tokenizers-0.22.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:59fdb013df17455e5f950b4b834a7b3ee2e0271e6378ccb33aa74d178b513c73", size = 3069318 }, - { url = "https://files.pythonhosted.org/packages/1c/58/2aa8c874d02b974990e89ff95826a4852a8b2a273c7d1b4411cdd45a4565/tokenizers-0.22.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8d4e484f7b0827021ac5f9f71d4794aaef62b979ab7608593da22b1d2e3c4edc", size = 2926478 }, - { url = "https://files.pythonhosted.org/packages/1e/3b/55e64befa1e7bfea963cf4b787b2cea1011362c4193f5477047532ce127e/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d2962dd28bc67c1f205ab180578a78eef89ac60ca7ef7cbe9635a46a56422a", size = 3256994 }, - { url = "https://files.pythonhosted.org/packages/71/0b/fbfecf42f67d9b7b80fde4aabb2b3110a97fac6585c9470b5bff103a80cb/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38201f15cdb1f8a6843e6563e6e79f4abd053394992b9bbdf5213ea3469b4ae7", size = 3153141 }, - { url = "https://files.pythonhosted.org/packages/17/a9/b38f4e74e0817af8f8ef925507c63c6ae8171e3c4cb2d5d4624bf58fca69/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1cbe5454c9a15df1b3443c726063d930c16f047a3cc724b9e6e1a91140e5a21", size = 3508049 }, - { url = "https://files.pythonhosted.org/packages/d2/48/dd2b3dac46bb9134a88e35d72e1aa4869579eacc1a27238f1577270773ff/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7d094ae6312d69cc2a872b54b91b309f4f6fbce871ef28eb27b52a98e4d0214", size = 3710730 }, - { url = "https://files.pythonhosted.org/packages/93/0e/ccabc8d16ae4ba84a55d41345207c1e2ea88784651a5a487547d80851398/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afd7594a56656ace95cdd6df4cca2e4059d294c5cfb1679c57824b605556cb2f", size = 3412560 }, - { url = "https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ef6063d7a84994129732b47e7915e8710f27f99f3a3260b8a38fc7ccd083f4", size = 3250221 }, - { url = "https://files.pythonhosted.org/packages/d7/a6/2c8486eef79671601ff57b093889a345dd3d576713ef047776015dc66de7/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba0a64f450b9ef412c98f6bcd2a50c6df6e2443b560024a09fa6a03189726879", size = 9345569 }, - { url = "https://files.pythonhosted.org/packages/6b/16/32ce667f14c35537f5f605fe9bea3e415ea1b0a646389d2295ec348d5657/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:331d6d149fa9c7d632cde4490fb8bbb12337fa3a0232e77892be656464f4b446", size = 9271599 }, - { url = "https://files.pythonhosted.org/packages/51/7c/a5f7898a3f6baa3fc2685c705e04c98c1094c523051c805cdd9306b8f87e/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:607989f2ea68a46cb1dfbaf3e3aabdf3f21d8748312dbeb6263d1b3b66c5010a", size = 9533862 }, - { url = "https://files.pythonhosted.org/packages/36/65/7e75caea90bc73c1dd8d40438adf1a7bc26af3b8d0a6705ea190462506e1/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a0f307d490295717726598ef6fa4f24af9d484809223bbc253b201c740a06390", size = 9681250 }, - { url = "https://files.pythonhosted.org/packages/30/2c/959dddef581b46e6209da82df3b78471e96260e2bc463f89d23b1bf0e52a/tokenizers-0.22.1-cp39-abi3-win32.whl", hash = "sha256:b5120eed1442765cd90b903bb6cfef781fd8fe64e34ccaecbae4c619b7b12a82", size = 2472003 }, - { url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684 }, + { url = "https://files.pythonhosted.org/packages/bf/33/f4b2d94ada7ab297328fc671fed209368ddb82f965ec2224eb1892674c3a/tokenizers-0.22.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:59fdb013df17455e5f950b4b834a7b3ee2e0271e6378ccb33aa74d178b513c73", size = 3069318, upload-time = "2025-09-19T09:49:11.848Z" }, + { url = "https://files.pythonhosted.org/packages/1c/58/2aa8c874d02b974990e89ff95826a4852a8b2a273c7d1b4411cdd45a4565/tokenizers-0.22.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8d4e484f7b0827021ac5f9f71d4794aaef62b979ab7608593da22b1d2e3c4edc", size = 2926478, upload-time = "2025-09-19T09:49:09.759Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3b/55e64befa1e7bfea963cf4b787b2cea1011362c4193f5477047532ce127e/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d2962dd28bc67c1f205ab180578a78eef89ac60ca7ef7cbe9635a46a56422a", size = 3256994, upload-time = "2025-09-19T09:48:56.701Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/fbfecf42f67d9b7b80fde4aabb2b3110a97fac6585c9470b5bff103a80cb/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38201f15cdb1f8a6843e6563e6e79f4abd053394992b9bbdf5213ea3469b4ae7", size = 3153141, upload-time = "2025-09-19T09:48:59.749Z" }, + { url = "https://files.pythonhosted.org/packages/17/a9/b38f4e74e0817af8f8ef925507c63c6ae8171e3c4cb2d5d4624bf58fca69/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1cbe5454c9a15df1b3443c726063d930c16f047a3cc724b9e6e1a91140e5a21", size = 3508049, upload-time = "2025-09-19T09:49:05.868Z" }, + { url = "https://files.pythonhosted.org/packages/d2/48/dd2b3dac46bb9134a88e35d72e1aa4869579eacc1a27238f1577270773ff/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7d094ae6312d69cc2a872b54b91b309f4f6fbce871ef28eb27b52a98e4d0214", size = 3710730, upload-time = "2025-09-19T09:49:01.832Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/ccabc8d16ae4ba84a55d41345207c1e2ea88784651a5a487547d80851398/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afd7594a56656ace95cdd6df4cca2e4059d294c5cfb1679c57824b605556cb2f", size = 3412560, upload-time = "2025-09-19T09:49:03.867Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ef6063d7a84994129732b47e7915e8710f27f99f3a3260b8a38fc7ccd083f4", size = 3250221, upload-time = "2025-09-19T09:49:07.664Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a6/2c8486eef79671601ff57b093889a345dd3d576713ef047776015dc66de7/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba0a64f450b9ef412c98f6bcd2a50c6df6e2443b560024a09fa6a03189726879", size = 9345569, upload-time = "2025-09-19T09:49:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/32ce667f14c35537f5f605fe9bea3e415ea1b0a646389d2295ec348d5657/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:331d6d149fa9c7d632cde4490fb8bbb12337fa3a0232e77892be656464f4b446", size = 9271599, upload-time = "2025-09-19T09:49:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/51/7c/a5f7898a3f6baa3fc2685c705e04c98c1094c523051c805cdd9306b8f87e/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:607989f2ea68a46cb1dfbaf3e3aabdf3f21d8748312dbeb6263d1b3b66c5010a", size = 9533862, upload-time = "2025-09-19T09:49:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/36/65/7e75caea90bc73c1dd8d40438adf1a7bc26af3b8d0a6705ea190462506e1/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a0f307d490295717726598ef6fa4f24af9d484809223bbc253b201c740a06390", size = 9681250, upload-time = "2025-09-19T09:49:21.501Z" }, + { url = "https://files.pythonhosted.org/packages/30/2c/959dddef581b46e6209da82df3b78471e96260e2bc463f89d23b1bf0e52a/tokenizers-0.22.1-cp39-abi3-win32.whl", hash = "sha256:b5120eed1442765cd90b903bb6cfef781fd8fe64e34ccaecbae4c619b7b12a82", size = 2472003, upload-time = "2025-09-19T09:49:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684, upload-time = "2025-09-19T09:49:24.953Z" }, ] [[package]] @@ -2850,9 +2827,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] [[package]] @@ -2865,9 +2842,9 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/30/ff9ede605e3bd086b4dd842499814e128500621f7951ca1e5ce84bbf61b1/typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53", size = 106781 } +sdist = { url = "https://files.pythonhosted.org/packages/85/30/ff9ede605e3bd086b4dd842499814e128500621f7951ca1e5ce84bbf61b1/typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53", size = 106781, upload-time = "2025-12-25T09:54:53.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e4/5ebc1899d31d2b1601b32d21cfb4bba022ae6fce323d365f0448031b1660/typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6", size = 47109 }, + { url = "https://files.pythonhosted.org/packages/e1/e4/5ebc1899d31d2b1601b32d21cfb4bba022ae6fce323d365f0448031b1660/typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6", size = 47109, upload-time = "2025-12-25T09:54:51.918Z" }, ] [[package]] @@ -2878,18 +2855,18 @@ dependencies = [ { name = "click" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/3b/2f60ce16f578b1db5b8816d37d6a4d9786b33b76407fc8c13b0b86312c31/typer_slim-0.21.0.tar.gz", hash = "sha256:f2dbd150cfa0fead2242e21fa9f654dfc64773763ddf07c6be9a49ad34f79557", size = 106841 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/3b/2f60ce16f578b1db5b8816d37d6a4d9786b33b76407fc8c13b0b86312c31/typer_slim-0.21.0.tar.gz", hash = "sha256:f2dbd150cfa0fead2242e21fa9f654dfc64773763ddf07c6be9a49ad34f79557", size = 106841, upload-time = "2025-12-25T09:54:55.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/84/e97abf10e4a699194ff07fd586ec7f4cf867d9d04bead559a65f9e7aff84/typer_slim-0.21.0-py3-none-any.whl", hash = "sha256:92aee2188ac6fc2b2924bd75bb61a340b78bd8cd51fd9735533ce5a856812c8e", size = 47174 }, + { url = "https://files.pythonhosted.org/packages/b4/84/e97abf10e4a699194ff07fd586ec7f4cf867d9d04bead559a65f9e7aff84/typer_slim-0.21.0-py3-none-any.whl", hash = "sha256:92aee2188ac6fc2b2924bd75bb61a340b78bd8cd51fd9735533ce5a856812c8e", size = 47174, upload-time = "2025-12-25T09:54:54.609Z" }, ] [[package]] name = "types-protobuf" version = "6.32.1.20251210" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921 }, + { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, ] [[package]] @@ -2899,18 +2876,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113 } +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658 }, + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -2920,27 +2897,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2025.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 }, + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] name = "urllib3" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" }, ] [[package]] @@ -2951,9 +2928,9 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109 }, + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] [package.optional-dependencies] @@ -2971,26 +2948,26 @@ standard = [ name = "uvloop" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] @@ -3000,131 +2977,131 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 }, +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] [[package]] name = "wcwidth" version = "0.2.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293 } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286 }, + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] [[package]] name = "websocket-client" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] [[package]] name = "websockets" version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] name = "wrapt" version = "1.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003 }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025 }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108 }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072 }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214 }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105 }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766 }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711 }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885 }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896 }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132 }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091 }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172 }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163 }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963 }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945 }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857 }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178 }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310 }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266 }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544 }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283 }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366 }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571 }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094 }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659 }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946 }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717 }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334 }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471 }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] @@ -3136,80 +3113,80 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980 }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424 }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821 }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243 }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361 }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036 }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671 }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059 }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356 }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331 }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590 }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316 }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431 }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555 }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965 }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205 }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209 }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966 }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312 }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967 }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949 }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818 }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626 }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129 }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776 }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879 }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996 }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047 }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947 }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943 }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715 }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857 }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520 }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504 }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282 }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080 }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696 }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121 }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080 }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661 }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645 }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361 }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451 }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814 }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799 }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990 }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292 }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888 }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223 }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981 }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303 }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820 }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203 }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173 }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562 }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828 }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551 }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512 }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400 }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140 }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473 }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056 }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292 }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171 }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814 }, +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] [[package]] name = "zipp" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] diff --git a/claude-agent/Dockerfile b/claude-agent/Dockerfile deleted file mode 100644 index 0f62057..0000000 --- a/claude-agent/Dockerfile +++ /dev/null @@ -1,69 +0,0 @@ -# Use uv with Python pre-installed -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - -# Install system dependencies including Node.js for Claude CLI and GitHub CLI -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - git \ - nodejs \ - npm \ - gnupg \ - && rm -rf /var/lib/apt/lists/* - -# Install GitHub CLI (gh) -SHELL ["/bin/bash", "-o", "pipefail", "-c"] -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ - && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && apt-get update \ - && apt-get install -y --no-install-recommends gh \ - && rm -rf /var/lib/apt/lists/* - -# Install Claude Code CLI and create non-root user -RUN npm install -g @anthropic-ai/claude-code@latest \ - && useradd -m -s /bin/bash claude \ - && mkdir -p /home/claude/.claude \ - && chown -R claude:claude /home/claude/.claude - -# Set up working directory -WORKDIR /app - -# Enable bytecode compilation for faster startup -ENV UV_COMPILE_BYTECODE=1 - -# Disable Python output buffering for real-time logs -ENV PYTHONUNBUFFERED=1 - -# Copy from cache instead of linking (for mounted volumes) -ENV UV_LINK_MODE=copy - -# Install dependencies using lockfile with BuildKit cache -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project --no-dev - -# Copy application code -COPY --chown=claude:claude server.py /app/server.py -COPY --chown=claude:claude job_runner.py /app/job_runner.py -COPY --chown=claude:claude entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh /app/server.py /app/job_runner.py - -# Place venv executables at front of path -ENV PATH="/app/.venv/bin:$PATH" - -# Make /app writable by claude for DevSpace sync -RUN chown -R claude:claude /app - -# Reset entrypoint (don't invoke uv) -ENTRYPOINT [] - -USER claude -WORKDIR /workspace - -EXPOSE 8001 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8001/health || exit 1 - -CMD ["/entrypoint.sh"] diff --git a/claude-agent/README.md b/claude-agent/README.md deleted file mode 100644 index 09f16b9..0000000 --- a/claude-agent/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Claude Agent Container - -This container runs the Claude Code CLI for the mainloop backend. - -## Current State - -The service accepts requests from the backend, runs the current Claude integration in its workspace, and returns or streams results. Kubernetes jobs can also execute a bounded request and report the result through a callback. - -## Usage - -The backend communicates with this container via the internal Docker network. - -## Configuration - -- Claude Code uses the authentication configured for the runtime environment -- Workspace is mounted at `/workspace` -- Configuration from `~/.claude` is mounted read-only diff --git a/claude-agent/entrypoint.sh b/claude-agent/entrypoint.sh deleted file mode 100644 index 327414f..0000000 --- a/claude-agent/entrypoint.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -set -e - -echo "Starting Claude Agent Worker API..." -echo "Workspace: /workspace" -echo "Listening on port 8001" - -# Create Claude credentials file from env var if token is provided -# This is required for the Claude Agent SDK to authenticate -if [[ -n ${CLAUDE_CODE_OAUTH_TOKEN} ]]; then - mkdir -p ~/.claude - cat >~/.claude/.credentials.json < dict: - """Execute the task using Claude Agent SDK.""" - print(f"[job_runner] Model: {CLAUDE_MODEL}") - print(f"[job_runner] Prompt:\n{TASK_PROMPT[:500]}...") - - options = ClaudeAgentOptions( - model=CLAUDE_MODEL, - permission_mode="bypassPermissions", - cwd=WORKSPACE, - ) - - collected_text: list[str] = [] - session_id: str | None = None - cost_usd: float | None = None - - async for message in query(prompt=TASK_PROMPT, options=options): - if isinstance(message, AssistantMessage): - for block in message.content: - if isinstance(block, TextBlock): - print(f"[claude] {block.text[:200]}...") - collected_text.append(block.text) - elif isinstance(message, ResultMessage): - session_id = message.session_id - cost_usd = message.total_cost_usd - if message.is_error: - raise RuntimeError(message.result or "Claude execution failed") - - output = "\n".join(collected_text) if collected_text else "No response generated." - - return { - "output": output, - "session_id": session_id, - "cost_usd": cost_usd, - } - - -async def send_result( - status: str, result: dict | None = None, error: str | None = None -): - """Send the result back to the backend via HTTP callback.""" - if not CALLBACK_URL: - print("[job_runner] No callback URL, skipping result POST") - return - - payload = { - "session_id": SESSION_ID, - "status": status, - "result": result, - "error": error, - "completed_at": datetime.now(timezone.utc).isoformat(), - } - - print(f"[job_runner] Sending result to {CALLBACK_URL}") - print(f"[job_runner] Status: {status}") - - async with httpx.AsyncClient(timeout=30.0) as client: - for attempt in range(3): - try: - response = await client.post( - CALLBACK_URL, - json=payload, - headers={"Content-Type": "application/json"}, - ) - response.raise_for_status() - print("[job_runner] Result sent successfully") - return - except httpx.RequestError as e: - print(f"[job_runner] Attempt {attempt + 1} failed: {e}") - if attempt < 2: - await asyncio.sleep(2**attempt) - else: - print("[job_runner] Failed to send result after 3 attempts") - raise - - -async def main(): - """Execute the job runner workflow.""" - print(f"[job_runner] Starting job for session {SESSION_ID}") - print(f"[job_runner] Working directory: {WORKSPACE}") - - # Validate required env vars - if not SESSION_ID: - print("[job_runner] ERROR: SESSION_ID not set") - sys.exit(1) - if not TASK_PROMPT: - print("[job_runner] ERROR: TASK_PROMPT not set") - sys.exit(1) - - # Ensure workspace exists - Path(WORKSPACE).mkdir(parents=True, exist_ok=True) - os.chdir(WORKSPACE) - - try: - result = await execute_task() - await send_result( - status="completed", - result=result, - ) - print("[job_runner] Job completed successfully") - - except Exception as e: - print(f"[job_runner] ERROR: {e}") - await send_result( - status="failed", - error=str(e), - ) - sys.exit(1) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/claude-agent/pyproject.toml b/claude-agent/pyproject.toml deleted file mode 100644 index 06e1c5c..0000000 --- a/claude-agent/pyproject.toml +++ /dev/null @@ -1,17 +0,0 @@ -[project] -name = "claude-agent" -version = "0.1.0" -description = "Claude Code CLI container API" -requires-python = ">=3.12" -dependencies = [ - "fastapi>=0.115.0", - "uvicorn[standard]>=0.32.0", - "pydantic>=2.10.0", - "claude-agent-sdk>=0.1.18", - "githubkit>=0.11.0", - "GitPython>=3.1.0", -] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" diff --git a/claude-agent/server.py b/claude-agent/server.py deleted file mode 100644 index 9046348..0000000 --- a/claude-agent/server.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -""" -Claude Agent Worker API using the Agent SDK. -Each worker maintains its own independent session with context compaction support. -""" -import json -import logging -from pathlib import Path - -from claude_agent_sdk import ( - AssistantMessage, - ClaudeAgentOptions, - ResultMessage, - SystemMessage, - TextBlock, - query, -) -from fastapi import FastAPI, HTTPException -from fastapi.responses import StreamingResponse -from pydantic import BaseModel - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -app = FastAPI(title="Claude Agent Worker API") - -CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json" - - -class ExecuteRequest(BaseModel): - """Request to execute Claude Code.""" - - prompt: str - workspace: str = "/workspace" - model: str = "haiku" - session_id: str | None = None # Resume from existing session - max_turns: int | None = None # Limit agent turns - - -class ExecuteResponse(BaseModel): - """Response from Claude Code execution.""" - - output: str - session_id: str | None = None - cost_usd: float | None = None - error: str | None = None - compacted: bool = False # Whether context was compacted during execution - compaction_count: int = 0 # Number of compactions that occurred - - -class AuthStatus(BaseModel): - """Authentication status.""" - - authenticated: bool - expires_at: int | None = None - subscription_type: str | None = None - error: str | None = None - - -def get_credentials() -> dict | None: - """Read credentials from file.""" - if CREDENTIALS_PATH.exists(): - try: - return json.loads(CREDENTIALS_PATH.read_text()) - except Exception: - return None - return None - - -def check_auth() -> AuthStatus: - """Check if we have valid authentication.""" - creds = get_credentials() - if not creds: - return AuthStatus(authenticated=False, error="No credentials found") - - oauth = creds.get("claudeAiOauth", {}) - if not oauth.get("accessToken"): - return AuthStatus(authenticated=False, error="No access token") - - return AuthStatus( - authenticated=True, - expires_at=oauth.get("expiresAt"), - subscription_type=oauth.get("subscriptionType"), - ) - - -@app.get("/health") -async def health(): - """Health check endpoint.""" - auth = check_auth() - return { - "status": "ok", - "service": "claude-agent-worker", - "authenticated": auth.authenticated, - } - - -@app.get("/auth/status", response_model=AuthStatus) -async def auth_status(): - """Check authentication status.""" - return check_auth() - - -@app.post("/execute", response_model=ExecuteResponse) -async def execute_claude(request: ExecuteRequest): - """Execute a prompt using Claude Agent SDK with session and compaction support.""" - try: - options = ClaudeAgentOptions( - model=request.model, - permission_mode="bypassPermissions", - cwd=request.workspace, - resume=request.session_id, # Resume existing session if provided - max_turns=request.max_turns, # Limit agent turns - ) - - collected_text: list[str] = [] - session_id: str | None = None - cost_usd: float | None = None - compaction_count: int = 0 - - async for message in query(prompt=request.prompt, options=options): - if isinstance(message, AssistantMessage): - for block in message.content: - if isinstance(block, TextBlock): - collected_text.append(block.text) - elif isinstance(message, ResultMessage): - session_id = message.session_id - cost_usd = message.total_cost_usd - if message.is_error: - return ExecuteResponse( - output="", - session_id=session_id, - cost_usd=cost_usd, - error=message.result or "Unknown error", - compacted=compaction_count > 0, - compaction_count=compaction_count, - ) - elif isinstance(message, SystemMessage): - # Track compaction events (context was automatically summarized) - if message.subtype == "compact_boundary": - compaction_count += 1 - data = message.data or {} - pre_tokens = data.get("pre_tokens", 0) - trigger = data.get("trigger", "unknown") - logger.info( - f"Context compacted ({trigger}): {pre_tokens} tokens summarized" - ) - - return ExecuteResponse( - output="\n".join(collected_text) if collected_text else "", - session_id=session_id, - cost_usd=cost_usd, - compacted=compaction_count > 0, - compaction_count=compaction_count, - ) - - except Exception as e: - logger.exception(f"Execute failed: {e}") - raise HTTPException(status_code=500, detail=str(e)) from e - - -@app.post("/execute/stream") -async def execute_claude_stream(request: ExecuteRequest): - """Stream execution results using Claude Agent SDK.""" - - async def generate(): - try: - options = ClaudeAgentOptions( - model=request.model, - permission_mode="bypassPermissions", - cwd=request.workspace, - ) - - async for message in query(prompt=request.prompt, options=options): - if isinstance(message, AssistantMessage): - for block in message.content: - if isinstance(block, TextBlock): - yield f"data: {json.dumps({'type': 'text', 'content': block.text})}\n\n" - elif isinstance(message, ResultMessage): - yield f"data: {json.dumps({'type': 'result', 'session_id': message.session_id, 'cost_usd': message.total_cost_usd, 'is_error': message.is_error})}\n\n" - - yield "data: [DONE]\n\n" - except Exception as e: - yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" - - return StreamingResponse( - generate(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }, - ) - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/claude-agent/test_plan_mode.py b/claude-agent/test_plan_mode.py deleted file mode 100644 index ad95c6e..0000000 --- a/claude-agent/test_plan_mode.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for native plan mode support in job_runner. - -Unit tests that can run without K8s or Claude credentials. -""" - -import os -import unittest -from unittest.mock import MagicMock, patch - -# Set up environment before importing job_runner -os.environ.setdefault("TASK_ID", "test-task-123") -os.environ.setdefault("TASK_PROMPT", "Add a new feature to handle user authentication") -os.environ.setdefault("REPO_URL", "https://github.com/test/repo") -os.environ.setdefault("MODE", "plan") - -import job_runner - - -class TestPlanPrompt(unittest.TestCase): - """Test plan prompt generation.""" - - def test_plan_prompt_with_repo(self): - """Plan prompt should include repo and simplified instructions.""" - with patch.object(job_runner, "REPO_URL", "https://github.com/test/repo"): - with patch.object(job_runner, "TASK_PROMPT", "Add authentication"): - with patch.object(job_runner, "TASK_ID", "abc12345"): - with patch.object(job_runner, "FEEDBACK_CONTEXT", ""): - prompt = job_runner.build_plan_prompt() - - # Should contain task info - self.assertIn("Task ID: abc12345", prompt) - self.assertIn("Add authentication", prompt) - self.assertIn("https://github.com/test/repo", prompt) - - # Should have simplified instructions (not GitHub issue creation) - self.assertIn("Clone the repository", prompt) - self.assertIn("implementation plan", prompt) - self.assertIn("Approach", prompt) - self.assertIn("Files to modify", prompt) - - # Should NOT contain old prompt instructions - self.assertNotIn("gh issue create", prompt) - self.assertNotIn("mainloop-plan", prompt) - - def test_plan_prompt_without_repo(self): - """Plan prompt should work without repo URL.""" - with patch.object(job_runner, "REPO_URL", ""): - with patch.object(job_runner, "TASK_PROMPT", "Generic task"): - with patch.object(job_runner, "TASK_ID", "xyz789"): - with patch.object(job_runner, "FEEDBACK_CONTEXT", ""): - prompt = job_runner.build_plan_prompt() - - self.assertIn("Generic task", prompt) - self.assertIn("Create an implementation plan", prompt) - - def test_plan_prompt_with_feedback(self): - """Plan prompt should include feedback context for revisions.""" - with patch.object(job_runner, "REPO_URL", "https://github.com/test/repo"): - with patch.object(job_runner, "TASK_PROMPT", "Add feature"): - with patch.object(job_runner, "TASK_ID", "rev123"): - with patch.object( - job_runner, - "FEEDBACK_CONTEXT", - "Please add more detail about error handling", - ): - prompt = job_runner.build_plan_prompt() - - self.assertIn("Feedback on your previous plan", prompt) - self.assertIn("Please add more detail about error handling", prompt) - - -class TestPermissionMode(unittest.TestCase): - """Test permission mode selection. - - All batch job modes use bypassPermissions since there's no human - to approve permission requests in a K8s Job context. - """ - - def test_all_modes_use_bypass(self): - """All modes should use permission_mode='bypassPermissions' for batch jobs.""" - # Batch jobs can't get human approval, so all modes bypass - for mode in ["plan", "implement", "feedback", "fix"]: - with patch.object(job_runner, "MODE", mode): - # This mirrors the actual logic in execute_task() - perm_mode = "bypassPermissions" - self.assertEqual( - perm_mode, - "bypassPermissions", - f"Mode {mode} should use bypassPermissions", - ) - - -class TestGitHubIssueCreation(unittest.TestCase): - """Test GitHub issue creation from plan content.""" - - @patch("subprocess.run") - def test_create_issue_success(self, mock_run): - """Should create issue and return URL on success.""" - # Mock successful gh issue create - mock_run.return_value = MagicMock( - returncode=0, - stdout="https://github.com/test/repo/issues/42\n", - stderr="", - ) - - with patch.object(job_runner, "REPO_URL", "https://github.com/test/repo"): - with patch.object(job_runner, "TASK_PROMPT", "Add authentication"): - with patch.object(job_runner, "WORKSPACE", "/workspace"): - result = job_runner.create_github_issue_from_plan( - "## Plan content here" - ) - - self.assertEqual(result, "https://github.com/test/repo/issues/42") - - # Verify gh issue create was called - create_call = mock_run.call_args_list[0] - self.assertEqual(create_call[0][0][0:3], ["gh", "issue", "create"]) - - @patch("subprocess.run") - def test_create_issue_failure(self, mock_run): - """Should return None on gh CLI failure.""" - mock_run.return_value = MagicMock( - returncode=1, - stdout="", - stderr="error: could not create issue", - ) - - with patch.object(job_runner, "REPO_URL", "https://github.com/test/repo"): - with patch.object(job_runner, "TASK_PROMPT", "Add feature"): - with patch.object(job_runner, "WORKSPACE", "/workspace"): - result = job_runner.create_github_issue_from_plan("## Plan") - - self.assertIsNone(result) - - def test_create_issue_no_repo(self): - """Should return None when no repo URL.""" - with patch.object(job_runner, "REPO_URL", ""): - result = job_runner.create_github_issue_from_plan("## Plan") - - self.assertIsNone(result) - - @patch("subprocess.run") - def test_issue_body_includes_commands(self, mock_run): - """Issue body should include /implement and /revise commands.""" - mock_run.return_value = MagicMock( - returncode=0, - stdout="https://github.com/test/repo/issues/1\n", - stderr="", - ) - - with patch.object(job_runner, "REPO_URL", "https://github.com/test/repo"): - with patch.object(job_runner, "TASK_PROMPT", "Task"): - with patch.object(job_runner, "WORKSPACE", "/workspace"): - job_runner.create_github_issue_from_plan("## My Plan") - - # Get the body argument from the call - create_call = mock_run.call_args_list[0] - body_idx = create_call[0][0].index("--body") + 1 - body = create_call[0][0][body_idx] - - self.assertIn("/implement", body) - self.assertIn("/revise", body) - self.assertIn("## My Plan", body) - - -class TestUrlExtraction(unittest.TestCase): - """Test URL extraction from output.""" - - def test_extract_pr_url(self): - """Should extract PR URL from output.""" - output = "Created PR: https://github.com/owner/repo/pull/123\nDone!" - result = job_runner.extract_pr_url(output) - self.assertEqual(result, "https://github.com/owner/repo/pull/123") - - def test_extract_pr_url_none(self): - """Should return None when no PR URL found.""" - output = "No PR created" - result = job_runner.extract_pr_url(output) - self.assertIsNone(result) - - def test_extract_issue_url(self): - """Should extract issue URL from output.""" - output = "Created issue: https://github.com/owner/repo/issues/456" - result = job_runner.extract_issue_url(output) - self.assertEqual(result, "https://github.com/owner/repo/issues/456") - - def test_extract_issue_url_none(self): - """Should return None when no issue URL found.""" - output = "No issue created" - result = job_runner.extract_issue_url(output) - self.assertIsNone(result) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/claude-agent/uv.lock b/claude-agent/uv.lock deleted file mode 100644 index 4e39254..0000000 --- a/claude-agent/uv.lock +++ /dev/null @@ -1,995 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.12" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362 }, -] - -[[package]] -name = "anysqlite" -version = "0.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907 }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, -] - -[[package]] -name = "claude-agent" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "claude-agent-sdk" }, - { name = "fastapi" }, - { name = "githubkit" }, - { name = "gitpython" }, - { name = "pydantic" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.metadata] -requires-dist = [ - { name = "claude-agent-sdk", specifier = ">=0.1.18" }, - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "githubkit", specifier = ">=0.11.0" }, - { name = "gitpython", specifier = ">=3.1.0" }, - { name = "pydantic", specifier = ">=2.10.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, -] - -[[package]] -name = "claude-agent-sdk" -version = "0.1.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "mcp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/3d/a8c6ad873e8448696d44441c9eb2c24dded620fb32415d68f576a542ccde/claude_agent_sdk-0.1.18.tar.gz", hash = "sha256:4fcb8730cc77dea562fbe9aa48c65eced3ef58a6bb1f34f77e50e8258902477d", size = 56162 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/14/f529f7c4bab7c71dcbcc8c66f12f491e644ee8a027ac5111d13705df207e/claude_agent_sdk-0.1.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9e45b4e3c20c072c3e3325fa60bab9a4b5a7cbbce64ca274b8d7d0af42dd9dd8", size = 54560828 }, - { url = "https://files.pythonhosted.org/packages/2c/68/6e83005aa7bb9056bfad0aef0605249f877dc0c78724c9c0fadebff600fb/claude_agent_sdk-0.1.18-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:3c41bd8f38848609ae0d5da8d7327a4c2d7057a363feafb6fd70df611ea204cc", size = 68743107 }, - { url = "https://files.pythonhosted.org/packages/fb/85/7d6dd85f402135a610894734c442f1166ffed61d03eced39d6bfd14efccd/claude_agent_sdk-0.1.18-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:983f15e51253f40c55136a86d7cc63e023a3576428b05fa1459093d461b2d215", size = 70444964 }, - { url = "https://files.pythonhosted.org/packages/3c/fa/d2b22b7a713c4c049cbd5f9f635836ea5429ff65c1f3bcf4658a8e1c1cf5/claude_agent_sdk-0.1.18-py3-none-win_amd64.whl", hash = "sha256:36f5b84d5c3c8773ee9b56aeb5ab345d1033231db37f80d1f20ac15239bef41c", size = 72637215 }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004 }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667 }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807 }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615 }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800 }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707 }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541 }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464 }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838 }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596 }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782 }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381 }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988 }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451 }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007 }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012 }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728 }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078 }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460 }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237 }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344 }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564 }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415 }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457 }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074 }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569 }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941 }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339 }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315 }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331 }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248 }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089 }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029 }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222 }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280 }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958 }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714 }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970 }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236 }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642 }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126 }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573 }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695 }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720 }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740 }, -] - -[[package]] -name = "fastapi" -version = "0.127.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/8a/6b9ba6eb8ff3817caae83120495965d9e70afb4d6348cb120e464ee199f4/fastapi-0.127.1.tar.gz", hash = "sha256:946a87ee5d931883b562b6bada787d6c8178becee2683cb3f9b980d593206359", size = 391876 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/f3/a6858d147ed2645c095d11dc2440f94a5f1cd8f4df888e3377e6b5281a0f/fastapi-0.127.1-py3-none-any.whl", hash = "sha256:31d670a4f9373cc6d7994420f98e4dc46ea693145207abc39696746c83a44430", size = 112332 }, -] - -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, -] - -[[package]] -name = "githubkit" -version = "0.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "hishel" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/3b/d8d4bd504b9d353f40d83f9c52d6e7b5efbc2b2c54979584e045cee87555/githubkit-0.14.1.tar.gz", hash = "sha256:7f5f9463c901c44871ffe0c1ddb1f2959f8cdbe015e0ca5ba0bfcaf1858651af", size = 2643130 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d1/c2e6e05ea979d1ef1c0ab99cf21e3ae5728d7c2db16d4a1fea9f8f345d77/githubkit-0.14.1-py3-none-any.whl", hash = "sha256:e0055a287d86543ba64db65a44a828b93e21ff221bc12a77913349e4816f5195", size = 6446837 }, -] - -[[package]] -name = "gitpython" -version = "3.1.46" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620 }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, -] - -[[package]] -name = "hishel" -version = "0.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "anysqlite" }, - { name = "httpx" }, - { name = "msgpack" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e5/64/a104ccac48f123f853254483617b16e0efc1649bd7e35bcdc5a5a5ef0ae2/hishel-0.1.5.tar.gz", hash = "sha256:9d40c682cd94fd6e1394fb05713ae20a75ed8aeba6f5272380444039ce6257f2", size = 75468 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/83/4f8b77839e62114bb034375ee8e08cfb6af1164754b925b271d3f1ec06ee/hishel-0.1.5-py3-none-any.whl", hash = "sha256:0bfbe9a2b9342090eba82ba6de88258092e1c4c7b730cd4cb4b570e4b40e44a7", size = 92486 }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, -] - -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280 }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004 }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655 }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440 }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186 }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192 }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694 }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, -] - -[[package]] -name = "jsonschema" -version = "4.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040 }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, -] - -[[package]] -name = "mcp" -version = "1.25.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076 }, -] - -[[package]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939 }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064 }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131 }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556 }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920 }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013 }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096 }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708 }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119 }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212 }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315 }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721 }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657 }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668 }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040 }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037 }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631 }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118 }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127 }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981 }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885 }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658 }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290 }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234 }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391 }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787 }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453 }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264 }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076 }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242 }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509 }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957 }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910 }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197 }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772 }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868 }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, -] - -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880 }, -] - -[[package]] -name = "pyjwt" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, -] - -[[package]] -name = "python-multipart" -version = "0.0.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541 }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, -] - -[[package]] -name = "smmap" -version = "5.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303 }, -] - -[[package]] -name = "sse-starlette" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/08/8f554b0e5bad3e4e880521a1686d96c05198471eed860b0eb89b57ea3636/sse_starlette-3.1.1.tar.gz", hash = "sha256:bffa531420c1793ab224f63648c059bcadc412bf9fdb1301ac8de1cf9a67b7fb", size = 24306 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/31/4c281581a0f8de137b710a07f65518b34bcf333b201cfa06cfda9af05f8a/sse_starlette-3.1.1-py3-none-any.whl", hash = "sha256:bb38f71ae74cfd86b529907a9fda5632195dfa6ae120f214ea4c890c7ee9d436", size = 12442 }, -] - -[[package]] -name = "starlette" -version = "0.50.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033 }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, -] - -[[package]] -name = "uvicorn" -version = "0.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502 }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769 }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413 }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307 }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970 }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343 }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, -] - -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, -] diff --git a/devspace.yaml b/devspace.yaml index 1216656..4a145c3 100644 --- a/devspace.yaml +++ b/devspace.yaml @@ -3,8 +3,6 @@ name: mainloop # Variables from .env vars: - CLAUDE_CODE_OAUTH_TOKEN: - source: env GITHUB_TOKEN: source: env default: '' @@ -35,16 +33,6 @@ images: args: - --target=dev - agent-controller: - image: mainloop-agent-controller - tags: - - dev - dockerfile: claude-agent/Dockerfile - context: claude-agent - rebuildStrategy: ignoreContextChanges - docker: - useBuildKit: true - # Deployments using Kustomize deployments: mainloop: @@ -163,32 +151,6 @@ dev: value: http://localhost:8081 logs: {} - agent-controller: - labelSelector: - app: mainloop-agent-controller - namespace: mainloop - devImage: mainloop-agent-controller:dev - sync: - - path: ./claude-agent:/app - excludePaths: - - __pycache__/ - - '*.pyc' - - .venv/ - disableDownload: true - onUpload: - exec: - - name: rebuild-agent-image - command: |- - echo "[agent] Rebuilding image for Jobs..." - docker build -t mainloop-agent-controller:dev claude-agent/ && \ - kind load docker-image mainloop-agent-controller:dev --name mainloop-test && \ - echo "[agent] Done - new Jobs will use updated code" - local: true - onChange: ['*.py'] - command: - - /entrypoint.sh - logs: {} - # Hooks for Kind cluster management hooks: - name: ensure-kind-cluster @@ -214,7 +176,6 @@ hooks: echo "Loading images into Kind..." kind load docker-image mainloop-backend:dev --name mainloop-test kind load docker-image mainloop-frontend:dev --name mainloop-test - kind load docker-image mainloop-agent-controller:dev --name mainloop-test # Commands commands: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b1c83ca..acff875 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -69,10 +69,8 @@ services: - DB_NAME=mainloop - DB_USER=mainloop - DB_PASSWORD=mainloop - - CLAUDE_AGENT_URL=http://claude-agent-test:8001 - IS_TEST_ENV=true - USE_MOCK_GITHUB=${USE_MOCK_GITHUB:-true} - - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} # No --reload needed - watchexec restarts container on file changes command: [uvicorn, mainloop.api:app, --host, 0.0.0.0, --port, '8000'] depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 7acc8ae..bc2b2cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,6 @@ services: environment: - PYTHONUNBUFFERED=1 - GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-credentials.json - - CLAUDE_AGENT_URL=http://claude-agent:8001 - DB_HOST=postgres - DB_PORT=5432 - DB_NAME=mainloop @@ -39,8 +38,6 @@ services: depends_on: postgres: condition: service_healthy - claude-agent: - condition: service_healthy develop: watch: - action: rebuild @@ -78,24 +75,5 @@ services: ignore: - node_modules - claude-agent: - build: - context: ./claude-agent - dockerfile: Dockerfile - container_name: mainloop-claude-agent - ports: - - 8001:8001 # Expose for debugging - env_file: - - .env - volumes: - - claude-workdir:/workspace - healthcheck: - test: [CMD, curl, -f, http://localhost:8001/health] - interval: 10s - timeout: 5s - retries: 3 - start_period: 30s - volumes: - claude-workdir: {} postgres-data: {} diff --git a/docs/architecture/native-agent-claude.md b/docs/architecture/native-agent-claude.md index 0551cce..e1a1ca8 100644 --- a/docs/architecture/native-agent-claude.md +++ b/docs/architecture/native-agent-claude.md @@ -1,10 +1,8 @@ # Native Claude session adapter -Status: implemented as a sanitized fixture-backed normalizer only. This slice -does not start Claude, use a subscription, import the Claude Agent SDK, or wire -the adapter into a production call path. `ROADMAP.md` remains the intended -architecture; the existing `claude-agent/` worker and its SDK entrypoints are -unchanged. +Status: fixture-backed stream normalizer, used by the native Substrate session path. This note's +description of the former SDK worker is historical: that worker and its backend entrypoints were +removed by the 2026-09-24 Substrate cutover. This document does not claim live-cluster proof. ## Boundary @@ -93,9 +91,9 @@ Claude capability has been established. ## Existing SDK separation -The current `backend/src/mainloop/claude_agent.py`, -`backend/src/mainloop/services/claude_agent.py`, and `claude-agent/` service use -the existing Claude Agent SDK worker. This adapter does not call those modules, +The former `backend/src/mainloop/claude_agent.py`, +`backend/src/mainloop/services/claude_agent.py`, and worker service used the old SDK path; those +entrypoints were removed by the Substrate cutover. This adapter does not call those modules, does not parse their result wrapper as native evidence, and does not change their production behavior. Replacing those paths requires a later architecture decision backed by live native proof. diff --git a/docs/architecture/native-agent-codex.md b/docs/architecture/native-agent-codex.md index 1a56f87..a61ba4d 100644 --- a/docs/architecture/native-agent-codex.md +++ b/docs/architecture/native-agent-codex.md @@ -1,5 +1,9 @@ # Codex native-agent fixture boundary +Historical scope: this document describes the fixture parser boundary, not transport ownership. +The Substrate cutover removed the former SDK-based main chat path. The current workspace transport +is implemented separately and this fixture note is not live proof. + Status: implemented normalizer and sanitized fixture evidence only. This document does not claim a live Codex proof, production wiring, transport ownership, or subscription-backed capability. @@ -157,5 +161,5 @@ The declarations are separate from provider metadata on `NativeEvent`. The fixture tests therefore establish deterministic normalization and recovery inputs, not that Codex emits these records in every mode or that a native -session accepts a message. Existing production paths and the Claude Agent SDK +session accepts a message. Existing production paths and the former SDK-based main chat worker are unchanged. diff --git a/docs/architecture/native-agent-inventory.md b/docs/architecture/native-agent-inventory.md index 8cec693..b8a07c8 100644 --- a/docs/architecture/native-agent-inventory.md +++ b/docs/architecture/native-agent-inventory.md @@ -1,9 +1,9 @@ # Native-agent boundary inventory -Status: contract implementation and synthetic test cases only. No native-provider -proof, production wiring, database migration, or workspace lifecycle change is -included. `ROADMAP.md` describes the intended architecture; the existing specs -continue to describe user-visible behavior. +Historical inventory from before the 2026-09-24 Substrate cutover. Its rows describe the former +implementation and proposed replacement points; removed SDK and Job paths are retained here only +as migration history. Current behavior is documented in `docs/specs/` and implemented through the +native Substrate workspace transport. This inventory is not live-cluster proof. ## Existing implementation and replacement points diff --git a/docs/specs/chat.md b/docs/specs/chat.md index 486f5ae..a7b2b5e 100644 --- a/docs/specs/chat.md +++ b/docs/specs/chat.md @@ -1,73 +1,27 @@ # Chat -The main thread is a continuous conversation with Claude that persists across devices. +The home chat is the user's native Claude Code main session in a configured Substrate actor. The provider owns native session history and tools; Mainloop records logical messages and delivery state. -## Sending Messages +## Sending messages -- Input field with placeholder "Enter command..." -- EXEC button submits the message -- Message appears in conversation immediately -- Assistant response streams in below +- The input field submits one user message to the main session. +- Mainloop records the conversation message and delivery intent before contacting the actor. +- A prompt is sent once. If the transport outcome is unknown, Mainloop marks the delivery uncertain and does not replay it. +- The response is mirrored from the native journal into the conversation. While the page is open, it polls for new journal evidence and turn completion. +- A second message is rejected with `409` while a delivery or rotation is in flight. -## Conversation History +## Conversation history -- Messages persist across page reloads -- Context maintained in follow-up messages -- User messages and assistant responses displayed in sequence +- User and assistant messages persist across page reloads. +- The native session remains authoritative for provider history and context management; Mainloop mirrors observed messages and delivery receipts. +- Mainloop rotates the main native session after its configured token-growth or turn budget. Before rotation, it asks the current session to write durable state through the allowed Mainloop tools, then starts a new native session with a generated startup context. -## Spawning Sessions +## Delegating sessions -From the main thread, you can ask Claude to spawn sessions: +- The main session can create Claude Code or Codex child sessions through the `mainloop` tool. +- Child reports are stored against their topic and delivered to the main session as ledgered messages. Reports arriving during another turn are queued. +- The main session can inspect status and stored reports without sending a prompt to a child. -- Sessions appear as colored thread blocks in the timeline -- Session messages surface as thread notifications -- Click to expand inline or zoom to fullscreen view +## Identity and policy -## Native Agent Session Chat (implemented in the local kind slice) - -In a session bound to a native agent (see `sessions.md`), the chat tab shows the user's messages and the agent's -replies. Replies are read only from the agent's native journal (Claude transcript, Codex rollout), never from the -terminal, and are mirrored into the conversation once per completed turn. The chat refreshes every few seconds -while the page is open. - -## Native Main Thread (implemented in the local kind slice; flag `MAIN_THREAD_MODE=native`) - -With `MAIN_THREAD_MODE=native` the home chat talks to a native Claude Code session running under Herdr in its own -pod (`main-0`), instead of running a Claude Agent SDK query per message. The SDK path is unchanged with -`MAIN_THREAD_MODE=sdk` (the default). - -- **One main thread.** The conversation is the user's most recent main-thread conversation. A message is recorded, - then delivered once through the delivery ledger; the reply is mirrored from the native journal, so the page polls - until the turn completes. While a turn (or a rotation) is in flight a second message is rejected (`409`). -- **Identity strip.** Above the chat: agent, model (from the journal), policy, native session id, Herdr pane, pod, - generation, window number, turns in the window, last context size and its baseline, and native compaction count. -- **Short window by rotation.** The native session is disposable. When the context grew by 20,000 tokens over the - window's first-turn baseline (or after 12 turns), Mainloop asks the agent to write anything durable through the - CLI (one ledgered turn), stops it, and starts a fresh native session whose start-up context is generated from - Postgres: standing context, topic index, checkpoint, open pending intent and the last 6 visible messages. The - new session's transcript contains none of the earlier conversation. Native auto-compaction is left at its - default; no compaction was observed below the rotation budget in the measured sessions (the default threshold is - assumed, not verified). -- **Dispatcher only.** The agent's only tool is Bash restricted to `mainloop ...`; it has no repository. It records - facts with `mainloop note|decide|pending`, files work with `mainloop delegate --topic ... --kind claude|codex`, - and answers "what is the child doing" from `mainloop status|read`, which read Postgres and never message the - child. On request it ends a running child with `mainloop cancel ` and tidies the user's list with - `mainloop clear` (finished children only; records are kept). -- **Topics.** A topic is a durable record (name, status line, notes, decisions, pending intent, child reports), not - a session. The topic index (names, status, pending counts) is shown under the identity strip. -- **Child reports.** A delegated child appears in the session list marked `↳` with its topic. Its - `mainloop report` (or, as a fallback, the last reply of a turn that ended without one) is recorded on the topic - and delivered to the main thread as a message. A report that arrives while the main thread is busy is queued - and delivered when it is idle. -- **Server-side policy.** At most 3 concurrent children per parent (6 in total), depth limit 2, and only the main - thread may delegate in this release; refusals are shown to the agent as `[concurrency]`, `[role]`, `[depth]`. - -- **Security limits.** The per-binding token scopes what the `mainloop` CLI may do; it is not a security boundary. - The rest of the backend API is unauthenticated and reachable from the workspace pods, and agents that share a pod - can read each other's token files, so a hostile agent could bypass the policy. Child reports are relayed to the - main thread as untrusted data (the main thread is told not to obey them). A prompt whose turn never completes - becomes `uncertain` (agent gone or after 30 minutes) and never blocks the session; a rotation closes the old - window's open deliveries the same way. - -Not implemented: topic supervisors, per-child turn budgets, approvals/attention for children, a UI for correcting -a topic assignment, and recovery of a queued or `recorded` delivery after a backend restart. +The identity strip shows the native agent, model, approval policy, native session id, configured workspace actor and readiness, delivery generation, rotation counters, journal cursor, and delivery states. Mainloop's per-binding token scopes its tool commands but is not a security boundary; backend API authorization and isolation remain separate concerns. diff --git a/docs/specs/sessions.md b/docs/specs/sessions.md index a4822d4..f9c84b7 100644 --- a/docs/specs/sessions.md +++ b/docs/specs/sessions.md @@ -1,116 +1,45 @@ # Sessions -Sessions are background AI work spawned from the main thread. Each session has its own conversation and runs independently. +Sessions are native Claude Code or Codex work started from the home thread or the `/agents` page. Mainloop keeps the product session, workspace lifecycle, native binding, and message delivery as separate records. -## Session List +## Session list Desktop shows sessions in a sidebar. Mobile shows sessions in a tab. -When no sessions exist: +When no sessions exist, the list explains that sessions appear when work is delegated or started. Each session shows its title and status. Workspace health and controls appear separately from session status. -- Shows empty state with "No sessions yet" message -- Shows hint: "Sessions appear when Claude spawns background work" +| Status | Meaning | +| --------------- | ------------------------------------------------- | +| pending | Created but not yet active | +| active | A native turn is in flight | +| waiting_on_user | The agent is idle and can receive another message | +| completed | Finished successfully | +| failed | An error occurred | +| cancelled | Stopped by the user | -When sessions exist: +Cancelled and failed are final. Agent activity does not change those statuses. A child that has reported is completed; if the user sends another message, it becomes active until that turn finishes. -- Each session shows title and status badge -- Active count shown in header (e.g., "2 active") -- Clicking a session opens its detail view -- Sessions with a workspace show a separate lifecycle badge; it does not change the session status -- Workspace details and controls are available from the session detail view +## Creating and messaging sessions -Workspace states are specified in [Workspaces](workspaces.md). A parked workspace can still have -an active, completed, or waiting session status; these are separate records. - -## Status Badges - -| Status | Badge | Meaning | -| --------------- | ----------- | ------------------------ | -| pending | PENDING | Queued, not started | -| active | ACTIVE | Currently running | -| waiting_on_user | NEEDS INPUT | Blocked on user response | -| completed | DONE | Finished successfully | -| failed | FAILED | Error occurred | -| cancelled | CANCELLED | Stopped by the user | - -Failed sessions show error message below the badge. - -Cancelled and failed are final: an agent's later activity never changes them. For a native agent session, -"active" is an open turn and "NEEDS INPUT" is an idle agent waiting for the next message. A delegated child that -has reported is DONE (it shows its report as the summary); messaging it again makes it active until the reply, then -DONE again. +- `/agents` offers Claude Code and Codex. `POST /sessions` accepts `agent_kind`; when omitted, it defaults to Claude Code. +- Native CLI execution and provider credentials live in the Substrate actor selected by the configured provider binding. Mainloop does not create a Claude SDK worker or a Kubernetes Job. +- Each user message is recorded with a delivery state before it is sent. Delivery states include `recorded`, `sending`, `delivered`, `completed`, `queued`, `failed`, and `uncertain`. +- An uncertain delivery is never replayed automatically. A message is rejected with `409` while another turn is in flight or while the workspace is suspending or suspended. +- Session conversations mirror messages and turn evidence from the native journal. ## Cancelling and clearing -Implemented; covered by unit tests with fakes (status rules, the agent verbs' policy), not yet exercised against a live cluster. - -- **Cancel** (session view, live sessions only) ends the session and stops its agent. If Mainloop cannot confirm the - agent stopped it says so; the session is still cancelled and the stop is not retried blindly. A cancelled session - no longer accepts messages. -- **Clear** removes finished sessions (done, failed, cancelled) from the list: "Clear" on a finished session's view, - or "clear N" in the list header for all of them. Cleared sessions are kept for audit, never deleted. A live - session cannot be cleared; cancel it first. The main thread's own conversation is never listed or cleared. -- The main thread can do both for its children: `mainloop cancel ` and `mainloop clear []`. Only the main - thread may; a child agent is refused. - -## Session Detail View - -Clicking a session navigates to `/sessions/{id}`: - -- Shows title as h1 heading -- Shows description if present -- Shows the session's chat directly (there is no Logs tab) -- Live sessions show Cancel; finished ones show Clear (see "Cancelling and clearing") -- Shows a one-line identity summary (agent, model, live or idle, topic) that expands to the full identity strip -- Follows the URL: opening another session from the list switches to it -- The session open in the main pane is highlighted in the list -- Active sessions show Cancel button -- Completed sessions show Summary section -- Failed sessions show Error section -- Back button returns to home -- Non-existent session ID shows "Session not found" with link to home -- When the backend is unreachable the page says so and retries when it returns, instead of "Session not found" - -## Notifications - -When a session needs attention: - -- Toast notification appears with title and preview -- Clicking notification navigates to that session's detail view - -## Native Agent Sessions (implemented in the local kind slice; not production) - -`/agents` ("new agent session" in the header, "+ agent" in the session list) starts a session bound to a real -agent instead of the session worker: +- Cancel ends the session and asks the actor to stop the native turn. If Mainloop cannot confirm the stop, it reports that result and does not repeat the stop blindly. +- A cancelled session no longer accepts messages. +- Clear archives finished sessions for audit. Live sessions must be cancelled first. +- The main thread can cancel or clear its child sessions through the `mainloop` tools. -- Choose **Claude Code** or **Codex**, an optional title and a first message. The session is created with - `agent_kind` (`POST /sessions`); no Job or namespace is created. -- The agent runs under Herdr in the workspace pod in bypass-permissions mode. The approval policy is recorded on - the binding and shown in the UI. -- The session detail view shows an identity strip: agent kind, model (read from the native journal), approval - policy, native session id, Herdr pane, workspace pod (short UID, ready or not), whether the agent process is - live, the ownership generation, the journal file and cursor, and the state of each delivery. -- Delivery states: `recorded`, `sending`, `delivered`, `completed`, `failed` (nothing sent), `uncertain` - ("delivery unknown"). A prompt is sent once. If the outcome is unknown the UI says so and waits for the user; - it never resends automatically. While a turn is in flight a second message is rejected (`409`). -- Replacing the pod keeps the conversation: the agent is shown as "not running (resumes on next message)"; the next - message restarts it with the native resume flag against the same native session id (generation increases) and - then delivers the message. +## Session detail -Measured with real agents (Claude Code 2.1.278, codex-cli 0.155.1) on the local kind cluster only; see -`docs/spikes/k8s-herdr-agents.md`. Known gaps: the header status badge is loaded once, message text is rendered -as markup (angle brackets in messages disappear), and there is no way to mark an `uncertain` delivery resolved. +The session view shows the conversation, session status, and a native identity strip with the agent kind, model, approval policy, native session id, configured workspace actor, readiness, generation, journal cursor, and delivery states. Workspace health and lifecycle controls are shown separately. -## Delegated child sessions (implemented in the local kind slice; flag `MAIN_THREAD_MODE=native`) +Opening a session follows its URL. Missing sessions show a not-found state. If the backend is unavailable, the page retries instead of treating the session as missing. -A session started by the native main thread (`mainloop delegate`) is a child: it has a parent (the main thread), -a topic, and runs as a native Claude Code or Codex agent in its own scratch directory under Herdr in `workspace-0`. +## Evidence boundary -- The session list marks children with `↳` and `#`; the identity strip shows role, parent and topic. -- The child receives one task brief (a ledgered delivery with source `brief`); Mainloop never sends it the - parent's transcript. -- The child ends with `mainloop report --summary` (size-capped). The report is recorded on the topic as evidence - and delivered to the main thread. If a turn ends without a report, its last reply is reported with a - "fallback" label. -- You can still message a child directly from its session view; that is an ordinary ledgered delivery. -- The main thread's own binding is not listed as a session; it is the home conversation. +Native journal parsing, delivery handling, and Substrate transport tests use sanitized fixtures and fake routers. The earlier Kind session-resume proof is retained as historical evidence in `docs/spikes/k8s-herdr-agents.md`; it does not prove the current Substrate runtime. diff --git a/docs/spikes/k8s-herdr-agents.md b/docs/spikes/k8s-herdr-agents.md index 4f17cf7..38aa753 100644 --- a/docs/spikes/k8s-herdr-agents.md +++ b/docs/spikes/k8s-herdr-agents.md @@ -1,6 +1,8 @@ # Spike: Herdr-owned arbitrary agents in a Kubernetes workspace -Status: local spike, not a product feature. Implementation lives in `spikes/k8s-herdr-agents/`. +Historical local spike, not a current product feature. Its implementation under +`spikes/k8s-herdr-agents/` was removed during the 2026-09-24 Substrate cutover; this document keeps +the original proof evidence and limitations. ## What it shows diff --git a/docs/spikes/native-main-thread-context.md b/docs/spikes/native-main-thread-context.md index a977d9e..3083459 100644 --- a/docs/spikes/native-main-thread-context.md +++ b/docs/spikes/native-main-thread-context.md @@ -4,6 +4,9 @@ Status labels: **Implemented** = built and exercised on the local kind cluster; agents (Claude Code 2.1.278, codex-cli 0.155.1); **Proposed** = design intent not yet built. Nothing here is production-tested. Fixtures and fakes cover the default tests; live evidence is outside the repository. +Historical note: this plan predates the 2026-09-24 cutover. The mode flag, former workspace transport, and SDK +fallback described below have been removed. Current chat always uses the native Substrate session path. + ## What was built (Implemented) - `MAIN_THREAD_MODE=native`: `POST /chat` records the user message and delivers it, through the r6 delivery ledger, diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 73f3c5b..7756228 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -7,6 +7,10 @@ actor; the target described by this closeout has no Herdr server or terminal man actor and invokes native CLIs headlessly once per turn. See `docs/spikes/k8s-herdr-agents.md` for the historical native-session/Herdr spike. +Historical scope: this closeout records evidence and design as of 2026-09-23. The 2026-09-24 +cutover made Substrate the only runtime and removed the `WORKSPACE_RUNTIME` switch and its former +default. Results below remain evidence for the versions and actors actually measured at that time. + ## Current status — Phase 5 closeout (2026-09-23) The Round 3 and Phase 4 runs measured Substrate actors, Cilium-enforced router ingress, @@ -46,9 +50,11 @@ native session/thread id and final message. It permits one in-flight turn per ag output file; `GET /run/:id` reports its status and bounded output. `/healthz` and `/readyz` check only the shim and workspace. The actor image and these routes still need live proof. -## Substrate runtime +## Pre-cutover Substrate configuration (2026-09-23) -`WORKSPACE_RUNTIME=substrate` selects the native-session transport; `herdr` remains the default. +At the time of this spike, `WORKSPACE_RUNTIME=substrate` selected the native-session transport and +the former runtime was the default. The cutover removed this switch; this paragraph is retained as +historical configuration context only. `SUBSTRATE_ROUTER_ADDRESS` configures the HTTP CONNECT listener. `SUBSTRATE_ACTOR_BINDINGS` is a JSON object keyed by `claude` and `codex`; each entry supplies `atespace`, `actor`, and `shim_token_secret_name`. `SUBSTRATE_SHIM_SECRET_NAMESPACE` selects the Secret namespace and diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ba57597..f1ce0f1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -254,7 +254,7 @@ export interface TopicLine { } export interface MainThreadInfo { - mode: 'sdk' | 'native'; + mode: 'native'; session_id: string | null; conversation_id: string | null; native: NativeSessionInfo | null; @@ -293,11 +293,7 @@ export interface NativeSessionInfo { native_session_id: string | null; model: string | null; approval_policy: string; - herdr_pane_id: string | null; - herdr_terminal_id: string | null; - herdr_workspace_id: string | null; - workspace_pod: string | null; - workspace_pod_uid: string | null; + workspace_name: string | null; workspace_ready: boolean; agent_live: boolean | null; generation: number; diff --git a/frontend/src/lib/components/Chat.svelte b/frontend/src/lib/components/Chat.svelte index 222d5b4..9604e45 100644 --- a/frontend/src/lib/components/Chat.svelte +++ b/frontend/src/lib/components/Chat.svelte @@ -12,8 +12,7 @@ import ConversationView from './ConversationView.svelte'; import MainThreadHeader from './MainThreadHeader.svelte'; - // Native main thread (MAIN_THREAD_MODE=native): a Claude session under Herdr whose window - // Mainloop rotates. The reply is mirrored from the native journal, so we poll for it. + // Mainloop mirrors the native Substrate session journal, so we poll for its reply. let mainThread = $state(null); let sendError = $state(null); diff --git a/frontend/src/lib/components/NativeIdentityStrip.svelte b/frontend/src/lib/components/NativeIdentityStrip.svelte index 0b1f9f5..385db24 100644 --- a/frontend/src/lib/components/NativeIdentityStrip.svelte +++ b/frontend/src/lib/components/NativeIdentityStrip.svelte @@ -80,13 +80,8 @@ >{info.native_session_id ?? 'pending'} - herdr pane {info.herdr_pane_id ?? '-'} - ({info.agent_name}) - pod {info.workspace_pod} - {info.workspace_pod_uid ? info.workspace_pod_uid.slice(0, 8) : '-'} + workspace actor {info.workspace_name} {info.workspace_ready ? 'ready' : 'not ready'} ← Back

New agent session

- Starts a real agent in the workspace pod, under Herdr, in bypass-permissions mode. Replies are read from the + Starts a real agent in a Substrate workspace, in bypass-permissions mode. Replies are read from the agent's native journal.

diff --git a/k8s/apps/mainloop/base/deployment-agent-controller.yaml b/k8s/apps/mainloop/base/deployment-agent-controller.yaml deleted file mode 100644 index b6c46e5..0000000 --- a/k8s/apps/mainloop/base/deployment-agent-controller.yaml +++ /dev/null @@ -1,79 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-agent-controller - namespace: mainloop -spec: - replicas: 1 - selector: - matchLabels: - app: mainloop-agent-controller - template: - metadata: - labels: - app: mainloop-agent-controller - spec: - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - seccompProfile: - type: RuntimeDefault - imagePullSecrets: - - name: ghcr-secret - containers: - - name: agent-controller - image: ghcr.io/oldsj/mainloop-agent-controller:latest - ports: - - containerPort: 8001 - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault - env: - - name: CLAUDE_CODE_OAUTH_TOKEN - valueFrom: - secretKeyRef: - name: claude-credentials - key: oauth-token - - name: GH_TOKEN - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-token - volumeMounts: - - name: workspace - mountPath: /workspace - resources: - requests: - memory: 512Mi - cpu: 500m - limits: - memory: 1Gi - cpu: 1000m - livenessProbe: - httpGet: - path: /health - port: 8001 - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /health - port: 8001 - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - volumes: - - name: workspace - emptyDir: {} diff --git a/k8s/apps/mainloop/base/deployment-backend.yaml b/k8s/apps/mainloop/base/deployment-backend.yaml index 141d65e..0e9f8fd 100644 --- a/k8s/apps/mainloop/base/deployment-backend.yaml +++ b/k8s/apps/mainloop/base/deployment-backend.yaml @@ -47,11 +47,6 @@ spec: secretKeyRef: name: mainloop-secrets key: db-password - - name: CLAUDE_CODE_OAUTH_TOKEN - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: claude-secret-token - name: GITHUB_TOKEN valueFrom: secretKeyRef: diff --git a/k8s/apps/mainloop/base/kustomization.yaml b/k8s/apps/mainloop/base/kustomization.yaml index c73fc1e..403b9a5 100644 --- a/k8s/apps/mainloop/base/kustomization.yaml +++ b/k8s/apps/mainloop/base/kustomization.yaml @@ -6,11 +6,8 @@ namespace: mainloop resources: - namespace.yaml - rbac-backend.yaml - - networkpolicy.yaml - deployment-backend.yaml - - deployment-agent-controller.yaml - deployment-frontend.yaml - service-backend.yaml - service-frontend.yaml - - service-agent-controller.yaml - configmap.yaml diff --git a/k8s/apps/mainloop/base/networkpolicy-task-namespace.yaml b/k8s/apps/mainloop/base/networkpolicy-task-namespace.yaml deleted file mode 100644 index 2ae3613..0000000 --- a/k8s/apps/mainloop/base/networkpolicy-task-namespace.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# Network policies for task namespaces (worker agents) -# These should be applied when creating a new task namespace -# Workers get minimal network access: DNS + internet only, no cluster internal ---- -# Default deny-all for task namespace -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: default-deny-all - # namespace: - set dynamically when creating namespace -spec: - podSelector: {} - policyTypes: - - Ingress - - Egress ---- -# Allow DNS queries (required for internet access) -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: allow-dns - # namespace: - set dynamically when creating namespace -spec: - podSelector: {} - policyTypes: - - Egress - egress: - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system - ports: - - protocol: UDP - port: 53 ---- -# Allow egress to internet ONLY (block all cluster internal) -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: allow-internet-only - # namespace: - set dynamically when creating namespace -spec: - podSelector: {} - policyTypes: - - Egress - egress: - # Allow to public internet only (block RFC1918 private networks) - - to: - - ipBlock: - cidr: 0.0.0.0/0 - except: - # Block all private/internal networks - - 10.0.0.0/8 # Private class A - - 172.16.0.0/12 # Private class B - - 192.168.0.0/16 # Private class C - - 169.254.0.0/16 # Link-local - - 127.0.0.0/8 # Loopback - - fc00::/7 # IPv6 private - - fe80::/10 # IPv6 link-local - - ::1/128 # IPv6 loopback diff --git a/k8s/apps/mainloop/base/networkpolicy.yaml b/k8s/apps/mainloop/base/networkpolicy.yaml deleted file mode 100644 index 657813e..0000000 --- a/k8s/apps/mainloop/base/networkpolicy.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# Restrict agent-controller to internet-only access (no internal cluster access) -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: restrict-agent-controller - namespace: mainloop -spec: - podSelector: - matchLabels: - app: mainloop-agent-controller - policyTypes: - - Egress - egress: - # Allow DNS - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system - ports: - - protocol: UDP - port: 53 - - protocol: TCP - port: 53 - # Allow internet only (block private IP ranges) - - to: - - ipBlock: - cidr: 0.0.0.0/0 - except: - - 10.0.0.0/8 # Private class A - - 172.16.0.0/12 # Private class B - - 192.168.0.0/16 # Private class C diff --git a/k8s/apps/mainloop/base/rbac-backend.yaml b/k8s/apps/mainloop/base/rbac-backend.yaml index 36611cc..e7dff99 100644 --- a/k8s/apps/mainloop/base/rbac-backend.yaml +++ b/k8s/apps/mainloop/base/rbac-backend.yaml @@ -1,4 +1,3 @@ ---- apiVersion: v1 kind: ServiceAccount metadata: @@ -8,118 +7,21 @@ metadata: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: mainloop-backend-cluster-role + name: mainloop-backend-secret-reader rules: - # Namespace management - create/delete task namespaces - - apiGroups: [''] - resources: [namespaces] - verbs: [create, delete, get, list, watch] - - # Secret management - read secrets from mainloop, create in task namespaces - apiGroups: [''] resources: [secrets] - verbs: [create, get, list, delete] - - # ServiceAccount management - create worker service accounts in task namespaces - - apiGroups: [''] - resources: [serviceaccounts] - verbs: [create, get, delete] - - # RoleBinding management - bind worker role in task namespaces - - apiGroups: [rbac.authorization.k8s.io] - resources: [rolebindings] - verbs: [create, get, delete] - - # ClusterRole binding - allow binding the worker-role to service accounts - # This is required for RBAC escalation prevention - we must explicitly allow binding this role - - apiGroups: [rbac.authorization.k8s.io] - resources: [clusterroles] - resourceNames: [mainloop-worker-role] - verbs: [bind] - - # Job management - create/monitor/delete worker jobs - - apiGroups: [batch] - resources: [jobs] - verbs: [create, get, list, watch, delete] - - # Pod management - for job monitoring - - apiGroups: [''] - resources: [pods, pods/log] - verbs: [get, list, watch] - - # NetworkPolicy management - isolate task namespaces - - apiGroups: [networking.k8s.io] - resources: [networkpolicies] - verbs: [create, get, delete] + verbs: [get] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: mainloop-backend-cluster-role-binding + name: mainloop-backend-secret-reader subjects: - kind: ServiceAccount name: mainloop-backend namespace: mainloop roleRef: kind: ClusterRole - name: mainloop-backend-cluster-role + name: mainloop-backend-secret-reader apiGroup: rbac.authorization.k8s.io ---- -# ClusterRole for worker jobs - grants permissions needed for Claude Code agents -# This is bound to the worker ServiceAccount via RoleBinding in each task namespace -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: mainloop-worker-role -rules: - # Core resources - pods, services, configmaps, etc. - - apiGroups: [''] - resources: - - pods - - services - - configmaps - - persistentvolumeclaims - - serviceaccounts - - endpoints - verbs: [create, get, list, watch, update, patch, delete] - - # Pod logs - read only - - apiGroups: [''] - resources: - - pods/log - verbs: [get, list, watch] - - # Secrets - full access for app credentials - - apiGroups: [''] - resources: - - secrets - verbs: [create, get, list, watch, update, patch, delete] - - # Apps - deployments, replicasets, statefulsets, daemonsets - - apiGroups: [apps] - resources: - - deployments - - replicasets - - statefulsets - - daemonsets - verbs: [create, get, list, watch, update, patch, delete] - - # Batch - jobs, cronjobs - - apiGroups: [batch] - resources: - - jobs - - cronjobs - verbs: [create, get, list, watch, update, patch, delete] - - # Networking - ingresses, networkpolicies - - apiGroups: [networking.k8s.io] - resources: - - ingresses - - networkpolicies - verbs: [create, get, list, watch, update, patch, delete] - - # Events - for debugging - - apiGroups: [''] - resources: - - events - verbs: [get, list, watch] diff --git a/k8s/apps/mainloop/base/service-agent-controller.yaml b/k8s/apps/mainloop/base/service-agent-controller.yaml deleted file mode 100644 index 12ad891..0000000 --- a/k8s/apps/mainloop/base/service-agent-controller.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: mainloop-agent-controller - namespace: mainloop -spec: - selector: - app: mainloop-agent-controller - ports: - - port: 8001 - targetPort: 8001 diff --git a/k8s/apps/mainloop/overlays/dev/agent-controller-patch.yaml b/k8s/apps/mainloop/overlays/dev/agent-controller-patch.yaml deleted file mode 100644 index ef6e867..0000000 --- a/k8s/apps/mainloop/overlays/dev/agent-controller-patch.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Patch agent-controller deployment for local testing -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-agent-controller - namespace: mainloop -spec: - template: - spec: - # Remove GHCR image pull secrets (using local images) - imagePullSecrets: [] - containers: - - name: agent-controller - # Use locally loaded image, never try to pull - imagePullPolicy: Never diff --git a/k8s/apps/mainloop/overlays/dev/configmap-patch.yaml b/k8s/apps/mainloop/overlays/dev/configmap-patch.yaml index 293a858..2a324c1 100644 --- a/k8s/apps/mainloop/overlays/dev/configmap-patch.yaml +++ b/k8s/apps/mainloop/overlays/dev/configmap-patch.yaml @@ -10,8 +10,3 @@ data: DB_PORT: '5432' DB_NAME: mainloop FRONTEND_DOMAIN: localhost:3000 - # Backend internal URL for K8s Job callbacks - BACKEND_INTERNAL_URL: http://mainloop-backend.mainloop.svc.cluster.local:8000 - # Use local worker image for dev (loaded into Kind) - WORKER_IMAGE: mainloop-agent-controller:dev - WORKER_IMAGE_PULL_POLICY: Never diff --git a/k8s/apps/mainloop/overlays/dev/kustomization.yaml b/k8s/apps/mainloop/overlays/dev/kustomization.yaml index 02315ce..9a5d741 100644 --- a/k8s/apps/mainloop/overlays/dev/kustomization.yaml +++ b/k8s/apps/mainloop/overlays/dev/kustomization.yaml @@ -24,11 +24,6 @@ patches: kind: Deployment name: mainloop-frontend - - path: agent-controller-patch.yaml - target: - kind: Deployment - name: mainloop-agent-controller - # Override images to use local dev-tagged images (loaded into Kind, no registry) images: - name: ghcr.io/oldsj/mainloop-backend @@ -37,6 +32,3 @@ images: - name: ghcr.io/oldsj/mainloop-frontend newName: mainloop-frontend newTag: dev - - name: ghcr.io/oldsj/mainloop-agent-controller - newName: mainloop-agent-controller - newTag: dev diff --git a/k8s/apps/mainloop/overlays/prod/kustomization.yaml b/k8s/apps/mainloop/overlays/prod/kustomization.yaml index e2f4c80..da47838 100644 --- a/k8s/apps/mainloop/overlays/prod/kustomization.yaml +++ b/k8s/apps/mainloop/overlays/prod/kustomization.yaml @@ -15,4 +15,3 @@ patches: target: kind: Cluster name: mainloop-db - - path: shutdown-patch.yaml diff --git a/k8s/apps/mainloop/overlays/prod/personal-config-patch.yaml.example b/k8s/apps/mainloop/overlays/prod/personal-config-patch.yaml.example index 81f1158..31edbe0 100644 --- a/k8s/apps/mainloop/overlays/prod/personal-config-patch.yaml.example +++ b/k8s/apps/mainloop/overlays/prod/personal-config-patch.yaml.example @@ -52,14 +52,3 @@ spec: - name: FRONTEND_DOMAIN value: "mainloop.example.com" # Your frontend domain (for CORS) --- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-agent-controller - namespace: mainloop -spec: - template: - spec: - containers: - - name: agent-controller - image: ghcr.io/yourusername/mainloop-agent-controller:latest # Your GHCR username diff --git a/k8s/apps/mainloop/overlays/prod/shutdown-patch.yaml b/k8s/apps/mainloop/overlays/prod/shutdown-patch.yaml deleted file mode 100644 index ed15000..0000000 --- a/k8s/apps/mainloop/overlays/prod/shutdown-patch.yaml +++ /dev/null @@ -1,39 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-backend - namespace: mainloop -spec: - replicas: 0 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-frontend - namespace: mainloop -spec: - replicas: 0 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-agent-controller - namespace: mainloop -spec: - replicas: 0 ---- -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: mainloop-db - namespace: mainloop - annotations: - cnpg.io/hibernation: "on" ---- -apiVersion: postgresql.cnpg.io/v1 -kind: Pooler -metadata: - name: mainloop-db-pooler - namespace: mainloop -spec: - instances: 0 diff --git a/k8s/apps/mainloop/overlays/spike-herdr/kustomization.yaml b/k8s/apps/mainloop/overlays/spike-herdr/kustomization.yaml deleted file mode 100644 index ed49c61..0000000 --- a/k8s/apps/mainloop/overlays/spike-herdr/kustomization.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# Local-kind overlay for the native-agent slice: the `test` overlay (backend, frontend, Postgres) -# without the Claude Agent SDK controller, which this slice does not use. The workspace pod -# (Herdr + real claude/codex), its exec Role and the credential Secrets live in -# spikes/k8s-herdr-agents (applied by build-real-agents.sh); Secret values are created by path -# and never appear in manifests. -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - ../test - -patches: - - target: - kind: Deployment - name: mainloop-agent-controller - patch: |- - - op: replace - path: /spec/replicas - value: 0 - - # Native main thread (plan r7): Claude under Herdr in pod main-0 replaces the SDK chat path. - # The SDK path stays available behind MAIN_THREAD_MODE=sdk. Model/effort/rotation use defaults - # (sonnet, medium, 20k tokens above baseline or 12 turns). - - target: - kind: Deployment - name: mainloop-backend - patch: |- - - op: add - path: /spec/template/spec/containers/0/env/- - value: { name: MAIN_THREAD_MODE, value: native } - -# The test overlay remaps ghcr.io/yourusername/*, but the base uses ghcr.io/oldsj/*; remap those -# to the locally built, kind-loaded images (imagePullPolicy is Never). -images: - - name: ghcr.io/oldsj/mainloop-backend - newName: mainloop-backend - newTag: test - - name: ghcr.io/oldsj/mainloop-frontend - newName: mainloop-frontend - newTag: test - - name: ghcr.io/oldsj/mainloop-agent-controller - newName: mainloop-agent-controller - newTag: test diff --git a/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml index b923b9d..57b703d 100644 --- a/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml +++ b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml @@ -10,6 +10,5 @@ data: DB_PORT: '5432' DB_NAME: mainloop FRONTEND_DOMAIN: localhost:3000 - WORKSPACE_RUNTIME: substrate SUBSTRATE_ROUTER_ADDRESS: http://atenet-router.ate-system.svc.cluster.local:8081 SUBSTRATE_ACTOR_BINDINGS: '{"claude":{"atespace":"live-agent-gate","actor":"headless-claude-reproof","shim_token_secret_name":"mainloop-shim-live-agent-gate-headless-claude-reproof"},"codex":{"atespace":"native-codex","actor":"headless-codex-reproof","shim_token_secret_name":"mainloop-shim-native-codex-headless-codex-reproof"}}' diff --git a/k8s/apps/mainloop/overlays/test/agent-controller-patch.yaml b/k8s/apps/mainloop/overlays/test/agent-controller-patch.yaml deleted file mode 100644 index ef6e867..0000000 --- a/k8s/apps/mainloop/overlays/test/agent-controller-patch.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Patch agent-controller deployment for local testing -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-agent-controller - namespace: mainloop -spec: - template: - spec: - # Remove GHCR image pull secrets (using local images) - imagePullSecrets: [] - containers: - - name: agent-controller - # Use locally loaded image, never try to pull - imagePullPolicy: Never diff --git a/k8s/apps/mainloop/overlays/test/configmap-patch.yaml b/k8s/apps/mainloop/overlays/test/configmap-patch.yaml index b016a33..2a324c1 100644 --- a/k8s/apps/mainloop/overlays/test/configmap-patch.yaml +++ b/k8s/apps/mainloop/overlays/test/configmap-patch.yaml @@ -10,8 +10,3 @@ data: DB_PORT: '5432' DB_NAME: mainloop FRONTEND_DOMAIN: localhost:3000 - # Backend internal URL for K8s Job callbacks - BACKEND_INTERNAL_URL: http://mainloop-backend.mainloop.svc.cluster.local:8000 - # Use local worker image for test (loaded into Kind) - WORKER_IMAGE: mainloop-agent-controller:test - WORKER_IMAGE_PULL_POLICY: Never diff --git a/k8s/apps/mainloop/overlays/test/kustomization.yaml b/k8s/apps/mainloop/overlays/test/kustomization.yaml index c9b93c4..1b13f1b 100644 --- a/k8s/apps/mainloop/overlays/test/kustomization.yaml +++ b/k8s/apps/mainloop/overlays/test/kustomization.yaml @@ -27,12 +27,6 @@ patches: kind: Deployment name: mainloop-frontend - # Patch agent-controller deployment for test environment - - path: agent-controller-patch.yaml - target: - kind: Deployment - name: mainloop-agent-controller - # Disable HTTPRoutes that don't exist in test (prevent errors) # Note: These resources don't exist in base anymore, no patching needed @@ -44,6 +38,3 @@ images: - name: ghcr.io/oldsj/mainloop-frontend newName: mainloop-frontend newTag: test - - name: ghcr.io/oldsj/mainloop-agent-controller - newName: mainloop-agent-controller - newTag: test diff --git a/models/README.md b/models/README.md index 111a4dd..5478a93 100644 --- a/models/README.md +++ b/models/README.md @@ -5,15 +5,12 @@ Shared Pydantic models for the mainloop project. This package contains all shared data models used across: - Backend API -- Claude agent integration - BigQuery schemas ## Models - `Conversation`: Conversation metadata and state - `Message`: Individual messages in a conversation -- `AgentTask`: Claude agent task definitions -- `AgentResponse`: Claude agent responses ## Usage diff --git a/models/src/models/__init__.py b/models/src/models/__init__.py index b86dcbc..000cd85 100644 --- a/models/src/models/__init__.py +++ b/models/src/models/__init__.py @@ -1,6 +1,5 @@ """Shared Pydantic models for mainloop.""" -from models.agent import AgentResponse, AgentTask from models.conversation import Conversation, Message from models.native_agent import ( AttentionItem, @@ -53,8 +52,6 @@ # Existing "Conversation", "Message", - "AgentTask", - "AgentResponse", # Session models "Session", "SessionCreate", diff --git a/models/src/models/agent.py b/models/src/models/agent.py deleted file mode 100644 index 9d86921..0000000 --- a/models/src/models/agent.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Claude agent task and response models.""" - -from datetime import datetime -from typing import Any - -from pydantic import BaseModel, Field - - -class AgentTask(BaseModel): - """A task for the Claude agent.""" - - id: str = Field(..., description="Unique task ID") - conversation_id: str = Field(..., description="Parent conversation ID") - prompt: str = Field(..., description="Task prompt for Claude") - context: dict[str, Any] | None = Field(None, description="Additional context") - created_at: datetime = Field( - default_factory=datetime.now, description="Creation timestamp" - ) - - -class AgentResponse(BaseModel): - """Response from the Claude agent.""" - - task_id: str = Field(..., description="Parent task ID") - content: str = Field(..., description="Response content") - tool_uses: list[dict[str, Any]] | None = Field( - None, description="Tool uses during execution" - ) - created_at: datetime = Field( - default_factory=datetime.now, description="Creation timestamp" - ) diff --git a/models/src/models/native_agent.py b/models/src/models/native_agent.py index 588839c..a6faacb 100644 --- a/models/src/models/native_agent.py +++ b/models/src/models/native_agent.py @@ -63,8 +63,6 @@ class NativeBinding(ContractModel): provider: Identifier runtime_type: Identifier native_session_id: Identifier - herdr_session_id: Identifier - herdr_agent_id: Identifier creation_mode: Literal["created", "attached", "discovered"] ownership_generation: Generation observed: ProviderExtension | None = None diff --git a/models/src/models/session.py b/models/src/models/session.py index 8c54157..8dd26cf 100644 --- a/models/src/models/session.py +++ b/models/src/models/session.py @@ -149,9 +149,9 @@ class SessionCreate(BaseModel): None, description="Main thread message ID to anchor this session to" ) - # Optional: run a real native agent under Herdr in the workspace pod + # Optional native runtime selection. Omitted sessions use Claude Code by default. agent_kind: Literal["claude", "codex"] | None = Field( - None, description="Native agent kind; omit for the existing session worker" + None, description="Native agent kind; defaults to Claude Code" ) @@ -183,7 +183,7 @@ class NativeDeliveryInfo(BaseModel): class NativeSessionInfo(BaseModel): - """Identity strip for a session bound to a native agent under Herdr.""" + """Identity strip for a session bound to a native agent in Substrate.""" session_id: str kind: Literal["claude", "codex"] @@ -194,11 +194,7 @@ class NativeSessionInfo(BaseModel): native_session_id: str | None = None model: str | None = None approval_policy: str - herdr_pane_id: str | None = None - herdr_terminal_id: str | None = None - herdr_workspace_id: str | None = None - workspace_pod: str | None = None - workspace_pod_uid: str | None = None + workspace_name: str | None = None workspace_ready: bool = False agent_live: bool | None = None generation: int = 1 diff --git a/scripts/kind/create-secrets.sh b/scripts/kind/create-secrets.sh index 95784e4..fb7eca7 100755 --- a/scripts/kind/create-secrets.sh +++ b/scripts/kind/create-secrets.sh @@ -25,18 +25,10 @@ set +a # Create mainloop namespace if not exists kubectl --context="${KIND_CONTEXT}" create namespace mainloop --dry-run=client -o yaml | kubectl --context="${KIND_CONTEXT}" apply -f - -# Create claude-credentials secret (for agent-controller) -echo "Creating claude-credentials..." -kubectl --context="${KIND_CONTEXT}" create secret generic claude-credentials \ - --namespace mainloop \ - --from-literal=oauth-token="${CLAUDE_CODE_OAUTH_TOKEN-}" \ - --dry-run=client -o yaml | kubectl --context="${KIND_CONTEXT}" apply -f - - -# Create mainloop-secrets secret (for backend) +# Create mainloop-secrets secret (for backend control-plane access) echo "Creating mainloop-secrets..." kubectl --context="${KIND_CONTEXT}" create secret generic mainloop-secrets \ --namespace mainloop \ - --from-literal=claude-secret-token="${CLAUDE_CODE_OAUTH_TOKEN-}" \ --from-literal=github-token="${GITHUB_TOKEN-}" \ --from-literal=db-username=mainloop \ --from-literal=db-password=mainloop \ diff --git a/scripts/kind/deploy.sh b/scripts/kind/deploy.sh index ade4e75..c1da51a 100755 --- a/scripts/kind/deploy.sh +++ b/scripts/kind/deploy.sh @@ -12,8 +12,8 @@ echo "Using context: ${KIND_CONTEXT}" # Delete old deployments and wait for pods to terminate echo "Cleaning up old deployments..." -kubectl --context="${KIND_CONTEXT}" delete deployment mainloop-frontend mainloop-backend mainloop-agent-controller -n mainloop --ignore-not-found=true --wait=true -kubectl --context="${KIND_CONTEXT}" wait --for=delete pod -l 'app in (mainloop-frontend, mainloop-backend, mainloop-agent-controller)' -n mainloop --timeout=60s 2>/dev/null || true +kubectl --context="${KIND_CONTEXT}" delete deployment mainloop-frontend mainloop-backend -n mainloop --ignore-not-found=true --wait=true +kubectl --context="${KIND_CONTEXT}" wait --for=delete pod -l 'app in (mainloop-frontend, mainloop-backend)' -n mainloop --timeout=60s 2>/dev/null || true # Apply test overlay echo "Applying manifests..." @@ -23,7 +23,6 @@ kubectl --context="${KIND_CONTEXT}" apply -k "${REPO_ROOT}/k8s/apps/mainloop/ove echo "Waiting for deployments..." kubectl --context="${KIND_CONTEXT}" rollout status deployment/mainloop-backend -n mainloop --timeout=120s kubectl --context="${KIND_CONTEXT}" rollout status deployment/mainloop-frontend -n mainloop --timeout=120s -kubectl --context="${KIND_CONTEXT}" rollout status deployment/mainloop-agent-controller -n mainloop --timeout=120s kubectl --context="${KIND_CONTEXT}" rollout status statefulset/postgres -n mainloop --timeout=120s echo "=== Deployment complete ===" diff --git a/scripts/kind/load-images.sh b/scripts/kind/load-images.sh index c4bd45d..a4870e0 100755 --- a/scripts/kind/load-images.sh +++ b/scripts/kind/load-images.sh @@ -19,15 +19,10 @@ docker build -f frontend/Dockerfile \ --build-arg VITE_API_URL=http://localhost:8081 \ -t mainloop-frontend:test . -echo "Building agent-controller..." -docker build -f claude-agent/Dockerfile \ - -t mainloop-agent-controller:test ./claude-agent - # Load images into Kind echo "Loading images into Kind cluster..." kind load docker-image mainloop-backend:test --name "${CLUSTER_NAME}" kind load docker-image mainloop-frontend:test --name "${CLUSTER_NAME}" -kind load docker-image mainloop-agent-controller:test --name "${CLUSTER_NAME}" echo "=== Images loaded ===" docker exec "${CLUSTER_NAME}-control-plane" crictl images | grep mainloop || true diff --git a/scripts/kind/reset-data.sh b/scripts/kind/reset-data.sh index fe992c2..6e5c1d4 100755 --- a/scripts/kind/reset-data.sh +++ b/scripts/kind/reset-data.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Reset database and k8s task namespaces +# Reset the local development database set -euo pipefail CLUSTER_NAME="${KIND_CLUSTER_NAME:-mainloop-test}" @@ -7,19 +7,6 @@ CONTEXT="kind-${CLUSTER_NAME}" echo "=== Using context: ${CONTEXT} ===" -echo "=== Cleaning up k8s task namespaces ===" -# Delete all task-* namespaces (legacy worker workflows) -for ns in $(kubectl --context "${CONTEXT}" get ns -o name 2>/dev/null | grep "^namespace/task-" | cut -d/ -f2); do - echo "Deleting namespace: ${ns}" - kubectl --context "${CONTEXT}" delete ns "${ns}" --wait=false 2>/dev/null || true -done - -# Delete all mainloop-session-* namespaces (session workers) -for ns in $(kubectl --context "${CONTEXT}" get ns -o name 2>/dev/null | grep "^namespace/mainloop-session-" | cut -d/ -f2); do - echo "Deleting namespace: ${ns}" - kubectl --context "${CONTEXT}" delete ns "${ns}" --wait=false 2>/dev/null || true -done - echo "=== Resetting database ===" # Drop both public and dbos schemas to fully reset state kubectl --context "${CONTEXT}" exec -n mainloop postgres-0 -- psql -U mainloop -d mainloop -c " diff --git a/spikes/k8s-herdr-agents/Dockerfile b/spikes/k8s-herdr-agents/Dockerfile deleted file mode 100644 index b4c0cda..0000000 --- a/spikes/k8s-herdr-agents/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# Spike image: real Herdr + real claude/codex CLIs (+ deterministic stand-in agents). Non-root. -# herdr, claude and codex binaries are copied from the host into the build context by the -# build script (never committed). No credentials are baked in: they arrive as Kubernetes Secrets. -FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends jq ca-certificates git ripgrep curl \ - && rm -rf /var/lib/apt/lists/* \ - && useradd -m -u 10001 agent -COPY herdr /usr/local/bin/herdr -COPY claude /usr/local/bin/claude -COPY codex /usr/local/bin/codex -COPY codex-code-mode-host /usr/local/bin/codex-code-mode-host -COPY bin/standin-agent /usr/local/bin/standin-agent -# One stand-in implementation installed under two Herdr-recognised kind names. -RUN ln -s standin-agent /usr/local/bin/pi && ln -s standin-agent /usr/local/bin/qwen -COPY bin/agentctl bin/entrypoint.sh bin/mainloop /usr/local/bin/ -USER 10001:10001 -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/spikes/k8s-herdr-agents/bin/agentctl b/spikes/k8s-herdr-agents/bin/agentctl deleted file mode 100755 index 35f944b..0000000 --- a/spikes/k8s-herdr-agents/bin/agentctl +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts -# Pod-side operations, same verbs for every agent kind. Kind, args and resume syntax come from -# /etc/agent-config/.env (ConfigMap), not from this script. -# agentctl start [--name N] [--new-id ID | --resume ID] start under Herdr -# context-model options: --cwd-rel D (scratch cwd under the workspace root), --model M, -# --effort E, --standing-b64 B (standing context file), --token T (per-binding CLI token) -# agentctl send deliver one prompt (Herdr input only; never reads a reply) -# agentctl native-id discover the native session id (from the native journal) -# agentctl journal print native journal lines after line -# agentctl status Herdr liveness/state hint (JSON) -# agentctl stop -# agentctl prompt stand-in only: deliver and grep the reply from the pane -# agentctl identity -# Replies for real agents are read from the native journals via `journal`, never from the pane. -set -eu -cmd="${1:?usage: agentctl start|send|native-id|journal|status|stop|prompt|identity ...}" -shift -H=(herdr --session "${HERDR_SESSION}") -STATE="${WORKSPACE_PATH}/.mainloop" -conf_dir="${AGENT_CONFIG_DIR:-/etc/agent-config}" - -load_conf() { # - [[ -f "${conf_dir}/$1.env" ]] || { - echo "no binding config: ${conf_dir}/$1.env" >&2 - exit 2 - } - # shellcheck disable=SC1090 - . "${conf_dir}/$1.env" -} - -cwd="${WORKSPACE_PATH}" - -trust_cwd() { # : pre-accept the trust dialog for a scratch cwd (no human at the TUI) - case "$1" in - claude) - local tmp - tmp="$(mktemp)" - jq --arg p "$2" '.projects[$p] = ((.projects[$p] // {}) + {hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true})' "${HOME}/.claude.json" >"${tmp}" && - cat "${tmp}" >"${HOME}/.claude.json" - rm -f "${tmp}" - ;; - codex) - grep -qF "[projects.\"$2\"]" "${CODEX_HOME}/config.toml" || printf '\n[projects."%s"]\ntrust_level = "trusted"\n' "$2" >>"${CODEX_HOME}/config.toml" - ;; - esac -} - -pane_for_name() { # : one Herdr workspace (labelled with the name) per agent - local ws - ws="$("${H[@]}" workspace list | jq -r --arg b "$1" '.result.workspaces[] | select(.label==$b) | .workspace_id' | head -n1)" - if [[ -z ${ws} ]]; then - ws="$("${H[@]}" workspace create --label "$1" --cwd "${cwd}" | jq -r .result.workspace.workspace_id)" - fi - "${H[@]}" pane list --workspace "${ws}" | jq -r '.result.panes[0].pane_id' -} - -journal_file() { # - case "$1" in - claude) find "${CLAUDE_CONFIG_DIR:-${HOME}/.claude}/projects" -name "$2.jsonl" 2>/dev/null | head -n1 ;; - codex) find "${CODEX_HOME}/sessions" -name "rollout-*-$2.jsonl" 2>/dev/null | head -n1 ;; - *) - echo "no journal for kind $1" >&2 - return 1 - ;; - esac -} - -case "${cmd}" in -start) - binding="${1:?binding required}" - shift - name="${binding}" - mode=new - nid="" - cwd_rel="" - model="" - effort="" - standing_b64="" - token="" - while [[ $# -gt 0 ]]; do - case "$1" in - --name) - name="$2" - shift 2 - ;; - --new-id) - mode=new - nid="$2" - shift 2 - ;; - --resume) - mode=resume - nid="$2" - shift 2 - ;; - --cwd-rel) - cwd_rel="$2" - shift 2 - ;; - --model) - model="$2" - shift 2 - ;; - --effort) - effort="$2" - shift 2 - ;; - --standing-b64) - standing_b64="$2" - shift 2 - ;; - --token) - token="$2" - shift 2 - ;; - *) - echo "unknown option $1" >&2 - exit 2 - ;; - esac - done - load_conf "${binding}" - ident="${STATE}/${name}.identity.json" - if "${H[@]}" agent get "${name}" >/dev/null 2>&1; then - echo "agent ${name} already live" - exit 0 - fi - if [[ ${mode} == resume ]]; then args="${AGENT_RESUME_ARGS:-${AGENT_ARGS}}"; else args="${AGENT_NEW_ARGS:-${AGENT_ARGS}}"; fi - if [[ -n ${cwd_rel} ]]; then - cwd="$(dirname "${WORKSPACE_PATH}")/${cwd_rel}" - mkdir -p "${cwd}/.mainloop" - chmod 700 "${cwd}/.mainloop" - # Secrets and generated context go to files on the PVC (0600), never into the pane command. - [[ -z ${token} ]] || ( - umask 077 - printf '%s' "${token}" >"${cwd}/.mainloop/token" - ) - [[ -z ${standing_b64} ]] || printf '%s' "${standing_b64}" | base64 -d >"${cwd}/.mainloop/standing.md" - [[ ! -f "${conf_dir}/${binding}.settings.json" ]] || cp "${conf_dir}/${binding}.settings.json" "${cwd}/.mainloop/settings.json" - trust_cwd "${AGENT_KIND}" "${cwd}" - fi - args="${args//\{id\}/${nid}}" - args="${args//\{model\}/${model}}" - args="${args//\{effort\}/${effort}}" - args="${args//\{standing\}/${cwd}/.mainloop/standing.md}" - args="${args//\{settings\}/${cwd}/.mainloop/settings.json}" - mkdir -p "${STATE}" - touch "${STATE}/${name}.started" - pane="$(pane_for_name "${name}")" - # $args is intentionally word-split: it is the native executable's argument list. - # shellcheck disable=SC2086 - "${H[@]}" agent start "${name}" --kind "${AGENT_KIND}" --pane "${pane}" --timeout 60000 -- ${args} >/dev/null - "${H[@]}" agent get "${name}" | jq -c --arg b "${binding}" --arg k "${AGENT_KIND}" --arg args "${args}" --arg mode "${mode}" --arg nid "${nid}" \ - '.result.agent | {binding:$b, kind:$k, args:$args, mode:$mode, native_session_id:(if $nid=="" then null else $nid end), herdr_agent:.agent, herdr_name:.name, pane_id, terminal_id, workspace_id, status:.agent_status}' >"${ident}" - cat "${ident}" - ;; -send) - name="${1:?name required}" - text="${2:?text required}" - # One delivery, no --wait retries: Herdr status is a hint, the journal is the receipt. - "${H[@]}" agent prompt "${name}" "${text}" >/dev/null - echo sent - ;; -native-id) - name="${1:?name required}" - ident="${STATE}/${name}.identity.json" - kind="$(jq -r .kind "${ident}")" - known="$(jq -r '.native_session_id // empty' "${ident}")" - if [[ -n ${known} ]]; then - echo "${known}" - exit 0 - fi - case "${kind}" in - codex) - f="$(find "${CODEX_HOME}/sessions" -name 'rollout-*.jsonl' -newer "${STATE}/${name}.started" 2>/dev/null | sort | head -n1)" - [[ -n ${f} ]] || exit 1 - id="$(basename "${f}" .jsonl | sed -E 's/^rollout-[0-9T:-]+-//')" - ;; - *) exit 1 ;; - esac - tmp="$(mktemp)" - jq --arg id "${id}" '.native_session_id=$id' "${ident}" >"${tmp}" && cat "${tmp}" >"${ident}" && rm -f "${tmp}" - echo "${id}" - ;; -journal) - kind="$(jq -r .kind "${STATE}/${1:?name required}.identity.json")" - id="${2:?native id required}" - from="${3:-0}" - f="$(journal_file "${kind}" "${id}")" - [[ -n ${f} ]] || { - echo "#nofile" - exit 0 - } - n="$(wc -l <"${f}")" # complete (newline-terminated) lines only - printf '#file\t%s\t%s\n' "${f}" "${n}" - if [[ ${n} -gt ${from} ]]; then sed -n "$((from + 1)),${n}p" "${f}" | awk -v s="${from}" '{print (NR + s) "\t" $0}'; fi - ;; -status) - # A failed `agent get` must fail the verb (a pipeline would report jq's exit status). - out="$("${H[@]}" agent get "${1:?name required}")" || exit 1 - printf '%s' "${out}" | jq -c '.result.agent | {name, agent, pane_id, terminal_id, status: .agent_status}' - ;; -prompt) # stand-in agents only - binding="${1:?binding required}" - text="${2:?prompt text required}" - "${H[@]}" agent prompt "${binding}" "${text}" --wait --timeout 60000 >/dev/null - "${H[@]}" agent read "${binding}" | grep 'STANDIN-REPLY' | grep -F "echo=${text}" | tail -n1 - ;; -stop) - name="${1:?name required}" - kind="$(jq -r '.kind // empty' "${STATE}/${name}.identity.json" 2>/dev/null || true)" - if [[ ${kind} == claude ]]; then - # A pasted "/exit" is text, and the restricted main thread has slash commands disabled: - # two quick Ctrl-C key presses exit Claude Code (measured). - "${H[@]}" agent send-keys "${name}" ctrl+c ctrl+c >/dev/null - else - "${H[@]}" agent prompt "${name}" "/exit" >/dev/null - fi - for _ in $(seq 1 20); do - "${H[@]}" agent get "${name}" >/dev/null 2>&1 || { - echo "agent ${name} stopped" - exit 0 - } - sleep 0.5 - done - echo "agent ${name} still live after stop" >&2 - exit 1 - ;; -identity) cat "${STATE}/${1:?name required}.identity.json" ;; -*) - echo "unknown command ${cmd}" >&2 - exit 2 - ;; -esac diff --git a/spikes/k8s-herdr-agents/bin/entrypoint.sh b/spikes/k8s-herdr-agents/bin/entrypoint.sh deleted file mode 100755 index a9ab0be..0000000 --- a/spikes/k8s-herdr-agents/bin/entrypoint.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts -# Pod entrypoint: real Herdr headless server, with all state on the PVC. -# Seeds agent trust/onboarding state so agents start without a human at a dialog, and copies -# the read-only Codex auth Secret to a writable CODEX_HOME on the PVC. Never prints credentials. -set -eu -mkdir -p "${HOME}" "${WORKSPACE_PATH}" "${STANDIN_STATE_DIR}" "${CODEX_HOME}" "${HOME}/.claude" -[[ -d "${WORKSPACE_PATH}/.git" ]] || git -C "${WORKSPACE_PATH}" init -q - -# Claude Code: onboarding done, workspace trusted, bypass-permissions warning accepted. -# CLAUDE_CODE_OAUTH_TOKEN comes from a Secret-backed env var (subscription token). -if [[ ! -s "${HOME}/.claude.json" ]]; then - jq -n --arg p "${WORKSPACE_PATH}" '{ - hasCompletedOnboarding: true, - numStartups: 1, - theme: "dark", - projects: {($p): {hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true, allowedTools: []}} - }' >"${HOME}/.claude.json" -fi -[[ -s "${HOME}/.claude/settings.json" ]] || echo '{"skipDangerousModePermissionPrompt": true}' >"${HOME}/.claude/settings.json" - -# Codex: copy auth from the read-only Secret once (Codex rewrites auth.json on refresh, so the -# writable copy on the PVC is authoritative afterwards); trust the workspace. -if [[ -f /etc/agent-secrets/codex/auth.json ]] && [[ ! -s "${CODEX_HOME}/auth.json" ]]; then - install -m 600 /etc/agent-secrets/codex/auth.json "${CODEX_HOME}/auth.json" -fi -if [[ ! -s "${CODEX_HOME}/config.toml" ]]; then - printf '[projects."%s"]\ntrust_level = "trusted"\n' "${WORKSPACE_PATH}" >"${CODEX_HOME}/config.toml" -fi -# Codex shows an "Approaching rate limits - switch model?" modal after a turn, which swallows the next -# prompt. Hide only that nudge (Codex's own "keep current model, never show again"); no model change. -grep -q '^\[notice\]' "${CODEX_HOME}/config.toml" || printf '\n[notice]\nhide_rate_limit_model_nudge = true\n' >>"${CODEX_HOME}/config.toml" - -echo "herdr $(herdr --version) server starting (HOME=${HOME} session=${HERDR_SESSION})" -exec herdr --session "${HERDR_SESSION}" server diff --git a/spikes/k8s-herdr-agents/bin/mainloop b/spikes/k8s-herdr-agents/bin/mainloop deleted file mode 100755 index 03b0ca0..0000000 --- a/spikes/k8s-herdr-agents/bin/mainloop +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts -# `mainloop`: the agents' thin client for the Mainloop control plane. It holds no policy. -# Identity is the per-binding token in .mainloop/token (found by walking up from $PWD, written -# by `agentctl start`); the server decides what this token may do and answers in plain text. -set -u -API="${MAINLOOP_API:-http://mainloop-backend.mainloop.svc.cluster.local:8000}" - -find_token() { - local d="${PWD}" - while :; do - [[ -f "${d}/.mainloop/token" ]] && { - cat "${d}/.mainloop/token" - return 0 - } - [[ ${d} == / ]] && return 1 - d="$(dirname "${d}")" - done -} -TOKEN="${MAINLOOP_TOKEN:-$(find_token)}" || { - echo "mainloop: no agent token found from ${PWD}" >&2 - exit 2 -} - -call() { # [json body] ; query params via Q=(--data-urlencode k=v ...) - local out code body - out="$(curl -sS -m 30 -w '\n%{http_code}' -X "$1" -H "Authorization: Bearer ${TOKEN}" \ - -H 'Content-Type: application/json' ${Q[@]+"${Q[@]}"} ${3:+-d "$3"} -G "${API}$2" 2>&1)" || - { - echo "mainloop: control plane unreachable" >&2 - exit 3 - } - code="${out##*$'\n'}" - body="${out%$'\n'*}" - if [[ ${code} -ge 200 ]] 2>/dev/null && [[ ${code} -lt 300 ]]; then - printf '%s' "${body}" | jq -r '.text // .' - else - echo "mainloop: refused (${code}): $(printf '%s' "${body}" | jq -r '.detail // .' 2>/dev/null || printf '%s' "${body}")" >&2 - exit 1 - fi -} -Q=() -# POST/GET with a body use -d, which makes curl POST; -G turns -d into a query string, so bodies -# are sent with --json-style separately: -post() { # - local out code body - out="$(curl -sS -m 30 -w '\n%{http_code}' -X POST -H "Authorization: Bearer ${TOKEN}" \ - -H 'Content-Type: application/json' --data-binary "$2" "${API}$1" 2>&1)" || - { - echo "mainloop: control plane unreachable" >&2 - exit 3 - } - code="${out##*$'\n'}" - body="${out%$'\n'*}" - if [[ ${code} -ge 200 ]] 2>/dev/null && [[ ${code} -lt 300 ]]; then - printf '%s' "${body}" | jq -r '.text // .' - else - echo "mainloop: refused (${code}): $(printf '%s' "${body}" | jq -r '.detail // .' 2>/dev/null || printf '%s' "${body}")" >&2 - exit 1 - fi -} -usage() { - sed -n '2,3p' "$0" - echo "verbs: whoami topics topic note decide pending delegate status read cancel clear report standing" -} - -verb="${1:-help}" -[[ $# -gt 0 ]] && shift -case "${verb}" in -help | -h | --help) usage ;; -whoami) call GET /agent-api/whoami ;; -topics) call GET /agent-api/topics ;; -standing) call GET /agent-api/standing ;; -topic) - [[ ${1-} == open ]] || { - echo "usage: mainloop topic open [--status ]" >&2 - exit 2 - } - shift - name="${1:?topic name required}" - shift - status="" - [[ ${1-} == --status ]] && status="${2-}" - post /agent-api/topics "$(jq -n --arg n "${name}" --arg s "${status}" 'if $s=="" then {name:$n} else {name:$n,status:$s} end')" - ;; -note | decide | pending) - kind="${verb}" - [[ ${verb} == decide ]] && kind=decision - if [[ ${verb} == pending ]] && [[ ${1-} == --done ]]; then - post "/agent-api/records/${2:?id required}/done" '{}' - exit - fi - text="${1:?text required}" - shift - topic="" - [[ ${1-} == --topic ]] && topic="${2-}" - post /agent-api/records "$(jq -n --arg k "${kind}" --arg t "${text}" --arg p "${topic}" 'if $p=="" then {kind:$k,text:$t} else {kind:$k,text:$t,topic:$p} end')" - ;; -delegate) - topic=inbox - kind="" - title="" - brief="" - while [[ $# -gt 0 ]]; do - case "$1" in - --topic) - topic="$2" - shift 2 - ;; - --kind) - kind="$2" - shift 2 - ;; - --title) - title="$2" - shift 2 - ;; - *) - brief="$1" - shift - ;; - esac - done - [[ -n ${kind} ]] && [[ -n ${brief} ]] || { - echo 'usage: mainloop delegate --topic --kind claude|codex --title "" ""' >&2 - exit 2 - } - post /agent-api/delegate "$(jq -n --arg t "${topic}" --arg k "${kind}" --arg ti "${title}" --arg b "${brief}" '{topic:$t,kind:$k,title:$ti,brief:$b}')" - ;; -status) - [[ -n ${1-} ]] && Q=(--data-urlencode "session=$1") - call GET /agent-api/status - ;; -read) - id="${1:?session id required}" - shift - since=0 - [[ ${1-} == --since ]] && since="${2:-0}" - Q=(--data-urlencode "session=${id}" --data-urlencode "since=${since}") - call GET /agent-api/read - ;; -cancel) - post /agent-api/cancel "$(jq -n --arg s "${1:?session id required}" '{session:$s}')" - ;; -clear) - post /agent-api/clear "$(jq -n --arg s "${1-}" 'if $s=="" then {} else {session:$s} end')" - ;; -report) - [[ ${1-} == --summary ]] || { - echo 'usage: mainloop report --summary ""' >&2 - exit 2 - } - post /agent-api/report "$(jq -n --arg s "${2:?summary required}" '{summary:$s}')" - ;; -*) - usage >&2 - exit 2 - ;; -esac diff --git a/spikes/k8s-herdr-agents/bin/standin-agent b/spikes/k8s-herdr-agents/bin/standin-agent deleted file mode 100755 index 43cced8..0000000 --- a/spikes/k8s-herdr-agents/bin/standin-agent +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts -# Deterministic STAND-IN for a coding-agent CLI. Not a real provider agent. -# Installed under the executable names Herdr recognises (pi, qwen), so Herdr -# detects the kind from the process name and its bundled screen rules. -# Native state (session id + transcript) lives on the workspace volume and is -# resumed when the same --session-name starts again. -set -u -kind="$(basename "$0")" -session_name="default" -label="" -while [[ $# -gt 0 ]]; do - case "$1" in - --session-name) - session_name="$2" - shift 2 - ;; - --label) - label="$2" - shift 2 - ;; - *) shift ;; - esac -done - -state_dir="${STANDIN_STATE_DIR:-${HOME}/.standin}/${kind}" -mkdir -p "${state_dir}" -meta="${state_dir}/${session_name}.meta" -log="${state_dir}/${session_name}.jsonl" -if [[ -f ${meta} ]]; then - session_id="$(cat "${meta}")" - resumed="resumed" -else - session_id="${kind}-$(od -An -N4 -tx1 /dev/urandom | tr -d ' \n')" - printf '%s' "${session_id}" >"${meta}" - : >"${log}" - resumed="new" -fi -turns="$(wc -l <"${log}")" -last_prompt="none" -[[ ${turns} -gt 0 ]] && last_prompt="$(tail -n1 "${log}" | cut -f2)" - -echo "STAND-IN agent kind=${kind} label=${label:-none} (deterministic, no provider)" -echo "native session: ${session_id} (${resumed}, ${turns} prior turns)" - -while true; do - printf '\n> ' - IFS= read -r line || exit 0 - [[ ${line} == "/exit" ]] && exit 0 - printf '\r\033[2K' - # Working signals differ per kind, mirroring how Herdr detects each agent. - case "${kind}" in - qwen) printf '\033]0;\xe2\x97\x90 working\a' ;; - *) printf '\xe2\xa0\x8b Working... (esc to interrupt)' ;; - esac - sleep "${STANDIN_WORK_SECONDS:-2}" - printf '\r\033[2K' - [[ ${kind} == qwen ]] && printf '\033]0;\xe2\x97\x87 ready\a' - turns=$((turns + 1)) - printf '%s\t%s\n' "${turns}" "${line}" >>"${log}" - echo "STANDIN-REPLY kind=${kind} session=${session_id} turn=${turns} prior=${last_prompt} echo=${line}" - last_prompt="${line}" -done diff --git a/spikes/k8s-herdr-agents/build-real-agents.sh b/spikes/k8s-herdr-agents/build-real-agents.sh deleted file mode 100755 index ac6c928..0000000 --- a/spikes/k8s-herdr-agents/build-real-agents.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts -# Build the real-agent workspace image (host claude/codex/herdr copied into a transient build -# context, never committed), load it into kind-mainloop-test, create credential Secrets BY PATH -# (values are never printed), and apply the workspace manifest. No cleanup, nothing deleted. -set -euo pipefail -SPIKE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -RUN_ID="${RUN_ID:?RUN_ID required}" -KUBECONFIG_FILE="${KUBECONFIG_FILE:?run-owned kubeconfig required}" -CTX=kind-mainloop-test -NS=herdr-spike -IMAGE="mainloop-spike-herdr:real-${RUN_ID}${IMAGE_SUFFIX-}" -k() { kubectl --kubeconfig "${KUBECONFIG_FILE}" --context "${CTX}" "$@"; } -B="$(mktemp -d)" -trap 'rm -rf "$B"' EXIT -cp "$(readlink -f "$(command -v herdr)")" "${B}/herdr" -cp "$(readlink -f "$(command -v claude)")" "${B}/claude" -cp "$(readlink -f "$(command -v codex)")" "${B}/codex" -# Codex shell tools need its companion helper (without it: "codex-code-mode-host is missing"). -cp "$(dirname "$(readlink -f "$(command -v codex)")")/codex-code-mode-host" "${B}/codex-code-mode-host" -cp -r "${SPIKE_DIR}/bin" "${SPIKE_DIR}/Dockerfile" "${B}/" -sudo -n docker build -q -t "${IMAGE}" "${B}" -sudo -n docker image inspect "${IMAGE}" --format 'image {{.Id}} user={{.Config.User}}' -sudo -n "$(command -v kind)" load docker-image "${IMAGE}" --name mainloop-test -# Secrets by path. Claude token: whitespace stripped through a process substitution, never echoed. -k create ns "${NS}" --dry-run=client -o yaml | k apply -f - >/dev/null -k -n "${NS}" create secret generic claude-oauth --from-file=oauth-token=<(tr -d ' \r\n' <"${HOME}/.claude-token") --dry-run=client -o yaml | k apply -f - >/dev/null -k -n "${NS}" create secret generic codex-auth --from-file=auth.json="${HOME}/.codex/auth.json" --dry-run=client -o yaml | k apply -f - >/dev/null -sed "s#__IMAGE__#${IMAGE}#" "${SPIKE_DIR}/k8s/workspace.yaml" | k apply -f - -k -n "${NS}" rollout status statefulset/workspace --timeout=240s diff --git a/spikes/k8s-herdr-agents/demo.sh b/spikes/k8s-herdr-agents/demo.sh deleted file mode 100755 index 93d1216..0000000 --- a/spikes/k8s-herdr-agents/demo.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -# shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts -# Bounded local demo: kind + real Herdr + two stand-in agent kinds. -# Never cleans up: cluster, PVC, images and evidence are left for inspection. -set -euo pipefail - -SPIKE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -RUN_ID="${RUN_ID:-20260920T022900Z}" -EVIDENCE="${EVIDENCE_DIR:-${SPIKE_DIR}/../../.tasknotes/runs/${RUN_ID}/spike-evidence}" -CLUSTER=mainloop-test -CONTEXT=kind-mainloop-test -IMAGE="mainloop-spike-herdr:${RUN_ID}" -KUBECONFIG_FILE="${KUBECONFIG_FILE:-${EVIDENCE}/kubeconfig-${CLUSTER}}" # run-owned, never committed -NS=herdr-spike -SUDO="${SUDO:-sudo -n}" # docker is root-only on this host -KIND_BIN="$(command -v kind)" -HERDR_BIN="$(readlink -f "$(command -v herdr)")" - -mkdir -p "${EVIDENCE}" -LOG="${EVIDENCE}/demo.log" -BUILD_CTX="$(mktemp -d)" -trap 'rm -rf "$BUILD_CTX"' EXIT # only the transient build context is removed - -say() { printf '%s\n' "$*" | tee -a "${LOG}"; } -run() { - say "\$ $*" - "$@" 2>&1 | tee -a "${LOG}" -} -k() { kubectl --kubeconfig "${KUBECONFIG_FILE}" --context "${CONTEXT}" "$@"; } -kx() { k -n "${NS}" exec workspace-0 -c workspace -- "$@"; } - -say "== Mainloop spike: Kubernetes + Herdr + arbitrary agents (${RUN_ID}) ==" -say "REAL: kind cluster ${CLUSTER}, StatefulSet/PVC, non-root pod, Herdr $(herdr --version | cut -d' ' -f2) server + agent detection" -say "STAND-IN: 'pi' and 'qwen' executables are one deterministic script (no provider, no credentials)" - -# --- static checks --- -bash -n "${SPIKE_DIR}/demo.sh" "${SPIKE_DIR}"/bin/* -say "static: bash -n ok" - -# --- cluster (explicit run-owned kubeconfig; create only if absent) --- -if ! ${SUDO} "${KIND_BIN}" get clusters 2>/dev/null | grep -qx "${CLUSTER}"; then - run ${SUDO} "${KIND_BIN}" create cluster --name "${CLUSTER}" --kubeconfig "${KUBECONFIG_FILE}" --wait 120s - ${SUDO} chown "$(id -u):$(id -g)" "${KUBECONFIG_FILE}" -elif [[ ! -s ${KUBECONFIG_FILE} ]]; then - ${SUDO} "${KIND_BIN}" get kubeconfig --name "${CLUSTER}" >"${KUBECONFIG_FILE}" -fi -chmod 600 "${KUBECONFIG_FILE}" -[[ "$(k config current-context)" == "${CONTEXT}" ]] || { - say "wrong context" - exit 1 -} -say "context: $(k config current-context)" - -# --- image --- -cp "${HERDR_BIN}" "${BUILD_CTX}/herdr" -cp -r "${SPIKE_DIR}/bin" "${SPIKE_DIR}/Dockerfile" "${BUILD_CTX}/" -run ${SUDO} docker build -q -t "${IMAGE}" "${BUILD_CTX}" -IMAGE_ID="$(${SUDO} docker image inspect "${IMAGE}" --format '{{.Id}}')" -say "image: ${IMAGE} id=${IMAGE_ID}" -say "image user: $(${SUDO} docker image inspect "${IMAGE}" --format '{{.Config.User}}')" -say "image herdr: $(${SUDO} docker run --rm --entrypoint herdr "${IMAGE}" --version)" -run ${SUDO} "${KIND_BIN}" load docker-image "${IMAGE}" --name "${CLUSTER}" - -# --- deploy --- -sed "s#__IMAGE__#${IMAGE}#" "${SPIKE_DIR}/k8s/workspace.yaml" >"${EVIDENCE}/workspace.rendered.yaml" -run kubectl --kubeconfig "${KUBECONFIG_FILE}" --context "${CONTEXT}" apply -f "${EVIDENCE}/workspace.rendered.yaml" -k -n "${NS}" rollout status statefulset/workspace --timeout=180s | tee -a "${LOG}" -POD1_UID="$(k -n "${NS}" get pod workspace-0 -o jsonpath='{.metadata.uid}')" -PVC="$(k -n "${NS}" get pod workspace-0 -o jsonpath='{.spec.volumes[?(@.name=="workspace")].persistentVolumeClaim.claimName}')" -say "pod1 uid=${POD1_UID} pvc=${PVC}" -say "pod user: $(kx id)" -say "pod herdr: $(kx herdr --version)" -say "sa token mounted: $(kx sh -c 'ls /var/run/secrets/kubernetes.io 2>&1 | head -1')" -for _ in $(seq 1 20); do - kx herdr --session mainloop-spike status server >/dev/null 2>&1 && break - sleep 1 -done - -# --- journey 1: both kinds, same operation --- -NONCE1="n1-${RANDOM}${RANDOM}" -for b in alpha beta; do - say "-- start ${b} (config: $(kx sh -c "tr '\n' ' ' /dev/null 2>&1 && break - sleep 1 -done -say "persisted identities: $(kx sh -c 'cat /workspace/repo/.mainloop/*.identity.json')" -say "persisted native state: $(kx sh -c 'ls /workspace/.standin/*')" -say "herdr live agents after restart (agent processes do not survive): $(kx herdr --session mainloop-spike agent list | jq -c '.result.agents|length')" - -# --- journey 2: restart agents, resume native state, follow-up prompt --- -NONCE2="n2-${RANDOM}${RANDOM}" -for b in alpha beta; do - kx agentctl start "${b}" | tee -a "${LOG}" - say "-- follow-up prompt ${b} nonce=${NONCE2}-${b}" - REPLY="$(kx agentctl prompt "${b}" "${NONCE2}-${b}")" - say "${REPLY}" - case "${REPLY}" in *"turn=2"*"prior=${NONCE1}-${b}"*) say "OK ${b} resumed native session (turn 2, prior=${NONCE1}-${b})" ;; *) - say "FAIL ${b} did not resume" - exit 1 - ;; - esac -done -say "pod2 herdr identities: $(kx herdr --session mainloop-spike agent list | jq -c '[.result.agents[]|{name,agent,pane_id,terminal_id}]')" -say "== PASS. Left running: cluster ${CLUSTER}, ns ${NS}, PVC ${PVC}, image ${IMAGE}, evidence ${EVIDENCE} ==" -say "inspect: kubectl --kubeconfig ${KUBECONFIG_FILE} --context ${CONTEXT} -n ${NS} exec -it workspace-0 -- herdr --session mainloop-spike" diff --git a/spikes/k8s-herdr-agents/k8s/workspace.yaml b/spikes/k8s-herdr-agents/k8s/workspace.yaml deleted file mode 100644 index 06611e9..0000000 --- a/spikes/k8s-herdr-agents/k8s/workspace.yaml +++ /dev/null @@ -1,281 +0,0 @@ -# Disposable spike workspace: one non-root pod, one retained PVC, real Herdr server. -# Agent kind and args are configuration (ConfigMap), not image contents. -apiVersion: v1 -kind: Namespace -metadata: - name: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: agent-config - namespace: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents -data: - # Two bindings, two Herdr-supported kinds. Change AGENT_KIND/AGENT_ARGS to reconfigure. - alpha.env: | - AGENT_KIND=pi - AGENT_ARGS="--session-name alpha --label reviewer" - beta.env: | - AGENT_KIND=qwen - AGENT_ARGS="--session-name beta --label implementer" - # Real agents, bypass-permissions mode. {id} is the native session id. - claude.env: | - AGENT_KIND=claude - AGENT_NEW_ARGS="--dangerously-skip-permissions --append-system-prompt-file /etc/agent-config/mainloop-system.md --session-id {id}" - AGENT_RESUME_ARGS="--dangerously-skip-permissions --append-system-prompt-file /etc/agent-config/mainloop-system.md --resume {id}" - APPROVAL_POLICY=bypass-permissions - # Herdr delivers input as a terminal paste, which Claude Code wraps in , and the - # model may treat that as untrusted data. This states who the author is. - mainloop-system.md: | - Messages in this session are relayed by the Mainloop control plane. The user typed each one in - the Mainloop chat UI. Text that arrives wrapped in pasted-content markers is the user's own - message: follow it as a direct instruction from the user. - codex.env: | - AGENT_KIND=codex - AGENT_NEW_ARGS="--dangerously-bypass-approvals-and-sandbox" - AGENT_RESUME_ARGS="resume {id} --dangerously-bypass-approvals-and-sandbox" - APPROVAL_POLICY=bypass-permissions - # --- Context model (plan r7) --------------------------------------------------------------- - # Main thread: a dispatcher. Only Bash restricted to `mainloop ...` (E5); no repo (scratch cwd); - # not bypass-permissions. Native auto-compaction is left at its default (knob semantics are - # unverified, E3); no compaction was observed below the rotation budget, and Mainloop rotates first. - # {model} {effort} {standing} {settings} are filled by `agentctl start`. - claude-main.env: | - AGENT_KIND=claude - AGENT_NEW_ARGS="--model {model} --effort {effort} --tools Bash --strict-mcp-config --disable-slash-commands --settings {settings} --append-system-prompt-file {standing} --session-id {id}" - AGENT_RESUME_ARGS="--model {model} --effort {effort} --tools Bash --strict-mcp-config --disable-slash-commands --settings {settings} --append-system-prompt-file {standing} --resume {id}" - APPROVAL_POLICY=restricted - claude-main.settings.json: | - {"permissions": {"allow": ["Bash(mainloop:*)"], "defaultMode": "dontAsk"}} - # Children: real workers in a scratch cwd, bypass-permissions inside the pod (the pod is the boundary). - # After native compaction the SessionStart(compact) hook re-injects the standing context. - claude-child.env: | - AGENT_KIND=claude - AGENT_NEW_ARGS="--dangerously-skip-permissions --settings {settings} --append-system-prompt-file {standing} --session-id {id}" - AGENT_RESUME_ARGS="--dangerously-skip-permissions --settings {settings} --append-system-prompt-file {standing} --resume {id}" - APPROVAL_POLICY=bypass-permissions - claude-child.settings.json: | - {"hooks": {"SessionStart": [{"matcher": "compact", "hooks": [{"type": "command", "command": "mainloop standing"}]}]}} - codex-child.env: | - AGENT_KIND=codex - AGENT_NEW_ARGS="--dangerously-bypass-approvals-and-sandbox" - AGENT_RESUME_ARGS="resume {id} --dangerously-bypass-approvals-and-sandbox" - APPROVAL_POLICY=bypass-permissions ---- -apiVersion: v1 -kind: Service -metadata: - name: workspace - namespace: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents -spec: - clusterIP: None - selector: - app: workspace - ports: - - name: none - port: 1 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: workspace - namespace: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents -spec: - serviceName: workspace - replicas: 1 - selector: - matchLabels: - app: workspace - persistentVolumeClaimRetentionPolicy: - whenDeleted: Retain - whenScaled: Retain - template: - metadata: - labels: - app: workspace - mainloop.dev/spike: k8s-herdr-agents - spec: - automountServiceAccountToken: false - securityContext: - runAsNonRoot: true - runAsUser: 10001 - runAsGroup: 10001 - fsGroup: 10001 - seccompProfile: - type: RuntimeDefault - containers: - - name: workspace - image: __IMAGE__ - imagePullPolicy: Never - env: - - { name: HOME, value: /workspace/.home } - - { name: WORKSPACE_PATH, value: /workspace/repo } - - { name: STANDIN_STATE_DIR, value: /workspace/.standin } - - { name: HERDR_SESSION, value: mainloop-spike } - - { name: AGENT_CONFIG_DIR, value: /etc/agent-config } - - { name: CODEX_HOME, value: /workspace/.codex } - - name: CLAUDE_CODE_OAUTH_TOKEN - valueFrom: - secretKeyRef: { name: claude-oauth, key: oauth-token, optional: true } - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: [ALL] - resources: - requests: { cpu: 100m, memory: 256Mi } - limits: { cpu: '2', memory: 2Gi } - volumeMounts: - - { name: workspace, mountPath: /workspace } - - { name: tmp, mountPath: /tmp } - - { name: agent-config, mountPath: /etc/agent-config, readOnly: true } - - { name: codex-auth, mountPath: /etc/agent-secrets/codex, readOnly: true } - volumes: - - name: tmp - emptyDir: {} - - name: agent-config - configMap: - name: agent-config - - name: codex-auth - secret: { secretName: codex-auth, optional: true } - volumeClaimTemplates: - - metadata: - name: workspace - labels: - mainloop.dev/spike: k8s-herdr-agents - spec: - accessModes: [ReadWriteOnce] - resources: - requests: - storage: 1Gi ---- -# Main thread pod (owner decision 2): same image and shape as workspace-0, own PVC, no Codex auth. -# The conversation agent runs in a scratch cwd (/workspace/main) with no repository. -apiVersion: v1 -kind: Service -metadata: - name: main - namespace: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents -spec: - clusterIP: None - selector: - app: main - ports: - - name: none - port: 1 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: main - namespace: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents -spec: - serviceName: main - replicas: 1 - selector: - matchLabels: - app: main - persistentVolumeClaimRetentionPolicy: - whenDeleted: Retain - whenScaled: Retain - template: - metadata: - labels: - app: main - mainloop.dev/spike: k8s-herdr-agents - spec: - automountServiceAccountToken: false - securityContext: - runAsNonRoot: true - runAsUser: 10001 - runAsGroup: 10001 - fsGroup: 10001 - seccompProfile: - type: RuntimeDefault - containers: - - name: workspace - image: __IMAGE__ - imagePullPolicy: Never - env: - - { name: HOME, value: /workspace/.home } - - { name: WORKSPACE_PATH, value: /workspace/repo } - - { name: STANDIN_STATE_DIR, value: /workspace/.standin } - - { name: HERDR_SESSION, value: mainloop-main } - - { name: AGENT_CONFIG_DIR, value: /etc/agent-config } - - { name: CODEX_HOME, value: /workspace/.codex } - - name: CLAUDE_CODE_OAUTH_TOKEN - valueFrom: - secretKeyRef: { name: claude-oauth, key: oauth-token, optional: true } - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: [ALL] - resources: - requests: { cpu: 100m, memory: 256Mi } - limits: { cpu: '1', memory: 1Gi } - volumeMounts: - - { name: workspace, mountPath: /workspace } - - { name: tmp, mountPath: /tmp } - - { name: agent-config, mountPath: /etc/agent-config, readOnly: true } - volumes: - - name: tmp - emptyDir: {} - - name: agent-config - configMap: - name: agent-config - volumeClaimTemplates: - - metadata: - name: workspace - labels: - mainloop.dev/spike: k8s-herdr-agents - spec: - accessModes: [ReadWriteOnce] - resources: - requests: - storage: 1Gi ---- -# Least-privilege exec access for the Mainloop backend (transport for the Herdr adapter): -# get/list the workspace pod and exec into it, in this namespace only. -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: workspace-exec - namespace: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents -rules: - - apiGroups: [''] - resources: [pods] - verbs: [get, list] - - apiGroups: [''] - resources: [pods/exec] - verbs: [create, get] # the client's WebSocket exec is a GET upgrade ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: mainloop-backend-workspace-exec - namespace: herdr-spike - labels: - mainloop.dev/spike: k8s-herdr-agents -subjects: - - kind: ServiceAccount - name: mainloop-backend - namespace: mainloop -roleRef: - kind: Role - name: workspace-exec - apiGroup: rbac.authorization.k8s.io From 96716feea501256c04e55956860c67cce676dfed Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:58:07 +0000 Subject: [PATCH 19/30] feat(workspaces): add per-branch dev lifecycle --- backend/src/mainloop/api.py | 6 + backend/src/mainloop/db/postgres.py | 4 + .../src/mainloop/runtime/actor_provisioner.py | 163 +++++++++++ .../src/mainloop/runtime/native_sessions.py | 21 +- .../src/mainloop/runtime/workspace_adapter.py | 132 ++++++++- backend/src/mainloop/runtime/workspace_api.py | 255 +++++++++++++++++- .../runtime/test_delivery_suspend_fence.py | 3 + backend/tests/runtime/test_workspace_api.py | 3 + .../runtime/test_workspace_dev_manifest.py | 144 ++++++++++ backend/tests/runtime/test_workspace_idle.py | 173 ++++++++++++ .../test_workspace_provisioning_api.py | 240 +++++++++++++++++ docs/specs/workspaces.md | 44 +-- examples/devenv-sample/Dockerfile | 14 + examples/devenv-sample/README.md | 15 ++ examples/devenv-sample/mainloop.yaml | 21 ++ examples/devenv-sample/package.json | 13 + examples/devenv-sample/server.mjs | 62 +++++ frontend/src/lib/api.ts | 43 +++ .../src/routes/projects/[id]/+page.svelte | 168 ++++++++++-- .../src/routes/workspaces/[id]/+page.svelte | 190 +++++++++---- models/src/models/__init__.py | 6 + models/src/models/workspace.py | 85 +++++- 22 files changed, 1700 insertions(+), 105 deletions(-) create mode 100644 backend/src/mainloop/runtime/actor_provisioner.py create mode 100644 backend/tests/runtime/test_workspace_dev_manifest.py create mode 100644 backend/tests/runtime/test_workspace_idle.py create mode 100644 backend/tests/runtime/test_workspace_provisioning_api.py create mode 100644 examples/devenv-sample/Dockerfile create mode 100644 examples/devenv-sample/README.md create mode 100644 examples/devenv-sample/mainloop.yaml create mode 100644 examples/devenv-sample/package.json create mode 100644 examples/devenv-sample/server.mjs diff --git a/backend/src/mainloop/api.py b/backend/src/mainloop/api.py index accaa95..a30af92 100644 --- a/backend/src/mainloop/api.py +++ b/backend/src/mainloop/api.py @@ -125,6 +125,12 @@ async def startup_event(): @app.on_event("shutdown") async def shutdown_event(): """Clean up on shutdown.""" + import asyncio + + task = getattr(app.state, "native_reconcile", None) + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) await db.disconnect() diff --git a/backend/src/mainloop/db/postgres.py b/backend/src/mainloop/db/postgres.py index 4aa9137..c6b8f55 100644 --- a/backend/src/mainloop/db/postgres.py +++ b/backend/src/mainloop/db/postgres.py @@ -231,6 +231,7 @@ def _parse_json_field(value: Any) -> list | dict | None: atespace TEXT NOT NULL, actor_name TEXT NOT NULL, actor_template TEXT NOT NULL, + shim_token_secret_name TEXT, native_session_id TEXT, preview_route TEXT, runtime_endpoint TEXT, @@ -256,9 +257,12 @@ def _parse_json_field(value: Any) -> list | dict | None: last_transition JSONB, operation_id TEXT, snapshot_ref TEXT, + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +ALTER TABLE workspace_bindings ADD COLUMN IF NOT EXISTS shim_token_secret_name TEXT; +ALTER TABLE workspace_lifecycles ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); -- Topics are durable records (not sessions). Supervisors (next slice) attach to a topic. CREATE TABLE IF NOT EXISTS topics ( diff --git a/backend/src/mainloop/runtime/actor_provisioner.py b/backend/src/mainloop/runtime/actor_provisioner.py new file mode 100644 index 0000000..dbec165 --- /dev/null +++ b/backend/src/mainloop/runtime/actor_provisioner.py @@ -0,0 +1,163 @@ +"""Provision one branch workspace actor and its control-plane shim credential.""" + +from __future__ import annotations + +import asyncio +import base64 +import secrets +from dataclasses import dataclass +from typing import Protocol + +from kubernetes import client, config +from kubernetes.client.rest import ApiException +from mainloop.config import settings +from mainloop.runtime.substrate import ActorRecord, SubstrateControl + + +@dataclass(frozen=True, slots=True) +class ProvisionedActor: + actor: ActorRecord + shim_token_secret_name: str + + +class ActorProvisioner(Protocol): + async def create( + self, + *, + atespace: str, + actor_name: str, + template: str, + shim_token_secret_name: str, + ) -> ProvisionedActor: ... + + async def delete( + self, *, atespace: str, actor_name: str, shim_token_secret_name: str | None + ) -> None: ... + + +class SubstrateActorProvisioner: + """Uses the existing Substrate control adapter and a namespaced Secret.""" + + def __init__(self, control: SubstrateControl | None = None, core_api=None): + self.control = control or SubstrateControl() + if core_api is not None: + self.core_api = core_api + else: + try: + config.load_incluster_config() + except config.ConfigException: + config.load_kube_config( + config_file=settings.substrate_kubeconfig or None, + context=settings.substrate_context or None, + ) + self.core_api = client.CoreV1Api() + + async def create( + self, + *, + atespace: str, + actor_name: str, + template: str, + shim_token_secret_name: str, + ) -> ProvisionedActor: + token = secrets.token_urlsafe(32) + try: + await asyncio.to_thread( + self.core_api.create_namespaced_secret, + settings.substrate_shim_secret_namespace, + client.V1Secret( + metadata=client.V1ObjectMeta(name=shim_token_secret_name), + type="Opaque", + data={"token": base64.b64encode(token.encode()).decode()}, + ), + ) + except ApiException as exc: + if exc.status != 409: + raise RuntimeError( + f"workspace shim Secret creation failed (status {exc.status})" + ) from exc + + # An existing actor after a timeout belongs to this persisted binding. Inspect first; + # never issue a second create for an uncertain outcome. + actor = await self.control.get_actor(atespace, actor_name) + if actor is None: + actor = await self.control.create_actor( + atespace, actor_name, template=template + ) + return ProvisionedActor( + actor=actor, shim_token_secret_name=shim_token_secret_name + ) + + async def delete( + self, *, atespace: str, actor_name: str, shim_token_secret_name: str | None + ) -> None: + await self.control.delete_actor(atespace, actor_name, any_state=True) + if shim_token_secret_name: + try: + await asyncio.to_thread( + self.core_api.delete_namespaced_secret, + shim_token_secret_name, + settings.substrate_shim_secret_namespace, + ) + except ApiException as exc: + if exc.status != 404: + raise RuntimeError( + f"workspace shim Secret deletion failed (status {exc.status})" + ) from exc + + +class FakeActorProvisioner: + """In-memory provisioner for tests; it never accesses Kubernetes or Substrate.""" + + def __init__(self): + self.actors: dict[tuple[str, str], ActorRecord] = {} + self.secrets: set[str] = set() + + async def create( + self, + *, + atespace: str, + actor_name: str, + template: str, + shim_token_secret_name: str, + ) -> ProvisionedActor: + from mainloop.runtime.substrate import ActorState + + key = (atespace, actor_name) + actor = self.actors.get(key) or ActorRecord( + atespace=atespace, + name=actor_name, + uid=f"fake-{actor_name}", + state=ActorState.RUNNING, + external_snapshot_uri=None, + current_actor_template_uid=template, + raw={}, + ) + self.actors[key] = actor + self.secrets.add(shim_token_secret_name) + return ProvisionedActor( + actor=actor, shim_token_secret_name=shim_token_secret_name + ) + + async def delete( + self, *, atespace: str, actor_name: str, shim_token_secret_name: str | None + ) -> None: + self.actors.pop((atespace, actor_name), None) + if shim_token_secret_name: + self.secrets.discard(shim_token_secret_name) + + +_provisioner: ActorProvisioner | None = None + + +def get_actor_provisioner() -> ActorProvisioner: + global _provisioner + if _provisioner is None: + _provisioner = SubstrateActorProvisioner() + return _provisioner + + +def set_actor_provisioner(provisioner: ActorProvisioner | None) -> None: + """Replace the provisioner in tests or during application assembly.""" + global _provisioner + _provisioner = provisioner diff --git a/backend/src/mainloop/runtime/native_sessions.py b/backend/src/mainloop/runtime/native_sessions.py index 9526049..8d19151 100644 --- a/backend/src/mainloop/runtime/native_sessions.py +++ b/backend/src/mainloop/runtime/native_sessions.py @@ -27,6 +27,7 @@ from mainloop.config import settings from mainloop.db import db +from mainloop.runtime import workspace_adapter from mainloop.runtime.agent_api import hash_token, token_for from mainloop.runtime.journal import completed_turns, parse_journal from mainloop.runtime.standing import content_hash @@ -238,10 +239,14 @@ async def _record_delivery_message( async with db.connection() as conn: async with conn.transaction(): binding = await conn.fetchrow( - """SELECT workspace_id FROM workspace_bindings + """SELECT workspace_id,desired_state FROM workspace_bindings WHERE workspace_id=$1 FOR UPDATE""", session_id, ) + if binding and binding.get("desired_state") == "deleting": + raise ValueError( + "The workspace is being deleted; start another workspace." + ) lifecycle = ( await conn.fetchrow( """SELECT desired_state, observed_state FROM workspace_lifecycles @@ -272,6 +277,11 @@ async def _record_delivery_message( state, source, ) + if binding: + await conn.execute( + "UPDATE workspace_lifecycles SET last_activity_at=NOW(), updated_at=NOW() WHERE workspace_id=$1", + session_id, + ) return message.id @@ -283,6 +293,10 @@ async def submit_message(session_id: str, text: str, *, source: str = "user") -> task brief to a fresh child). A ``queued`` delivery is sent by ``sync`` once the agent is idle. """ session = await db.get_session(session_id) + # Branch workspace turns touch and wake their actor before the delivery is recorded. Static + # agent bindings have no workspace_bindings row and continue through their existing path. + if await workspace_adapter.get_workspace(session_id) is not None: + await workspace_adapter.touch_workspace(session_id, reason="turn") if source == "user" and session.status in ENDED_STATUSES: raise ValueError(f"This session is {session.status.value}; start a new one.") if source == "user" and session_id in _rotating: @@ -761,6 +775,7 @@ async def rotate( async def reconcile_loop(interval: float = 3.0) -> None: """Background mirror for sessions with open work, so replies, reports and rotation do not depend on a browser polling.""" + next_idle_check = 0.0 while True: try: async with db.connection() as conn: @@ -775,6 +790,10 @@ async def reconcile_loop(interval: float = 3.0) -> None: for sid in ids: if sid not in _rotating: await sync(sid) + loop = asyncio.get_running_loop() + if loop.time() >= next_idle_check: + await workspace_adapter.suspend_idle_workspaces() + next_idle_check = loop.time() + 60.0 except Exception: logger.exception("reconcile loop iteration failed") await asyncio.sleep(interval) diff --git a/backend/src/mainloop/runtime/workspace_adapter.py b/backend/src/mainloop/runtime/workspace_adapter.py index 091190d..9d6ad34 100644 --- a/backend/src/mainloop/runtime/workspace_adapter.py +++ b/backend/src/mainloop/runtime/workspace_adapter.py @@ -20,7 +20,7 @@ import json import logging import uuid -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from mainloop.config import settings from mainloop.db import db @@ -277,6 +277,87 @@ async def resume_workspace( ) +async def touch_workspace(workspace_id: str, *, reason: str) -> WorkspaceLifecycle: + """Record turn or preview activity and wake a parked workspace when needed.""" + if reason not in {"turn", "delivery", "preview"}: + raise ValueError("reason must be turn, delivery, or preview") + lifecycle = await ensure_workspace_lifecycle(workspace_id) + if lifecycle is None: + raise ContractError(f"no workspace binding for {workspace_id}") + async with db.connection() as conn: + async with conn.transaction(): + binding = await conn.fetchrow( + "SELECT workspace_id,desired_state FROM workspace_bindings WHERE workspace_id=$1 FOR UPDATE", + workspace_id, + ) + if binding is None: + raise ContractError(f"no workspace binding for {workspace_id}") + if binding.get("desired_state") == "deleting": + raise ContractError("The workspace is being deleted.") + current = await conn.fetchrow( + """SELECT desired_state, observed_state FROM workspace_lifecycles + WHERE workspace_id=$1 FOR UPDATE""", + workspace_id, + ) + if current is None: + raise ContractError(f"no workspace lifecycle for {workspace_id}") + await conn.execute( + "UPDATE workspace_lifecycles SET last_activity_at=NOW(), updated_at=NOW() WHERE workspace_id=$1", + workspace_id, + ) + should_wake = current["desired_state"] == "suspended" or current[ + "observed_state" + ] in {"suspending", "suspended"} + if should_wake: + return await resume_workspace(workspace_id) + return await get_workspace_lifecycle(workspace_id) or lifecycle + + +async def suspend_idle_workspaces() -> int: + """Suspend idle dev workspaces via the normal generation and delivery fence.""" + async with db.connection() as conn: + rows = await conn.fetch( + """SELECT l.workspace_id + FROM workspace_lifecycles l + JOIN sessions s ON s.id=l.workspace_id + LEFT JOIN LATERAL ( + SELECT MAX(created_at) AS last_delivery_at + FROM native_deliveries WHERE session_id=l.workspace_id + ) d ON TRUE + WHERE l.desired_state='running' AND l.observed_state='running' + AND l.manifest->'dev' IS NOT NULL + AND COALESCE((l.manifest->'dev'->>'idle_timeout_minutes')::integer, 0) > 0 + AND GREATEST(l.last_activity_at, COALESCE(d.last_delivery_at, l.last_activity_at)) + < NOW() - ((l.manifest->'dev'->>'idle_timeout_minutes')::integer * INTERVAL '1 minute')""" + ) + suspended = 0 + for row in rows: + try: + result = await suspend_workspace_if_idle(row["workspace_id"]) + if result is not None: + suspended += 1 + except ContractError: + # The fenced path records the reason; open deliveries are expected to be skipped. + continue + except Exception: + logger.exception( + "Idle workspace suspend failed for %s", row["workspace_id"] + ) + return suspended + + +async def suspend_workspace_if_idle( + workspace_id: str, *, control: SubstrateControl | None = None +) -> WorkspaceLifecycle | None: + """Recheck activity under the workspace row lock before reserving a suspend.""" + return await _request_workspace_state( + workspace_id, + WorkspaceDesiredState.SUSPENDED, + control=control, + only_if_idle=True, + ) + + async def suspend_workspace( session_id: str, *, control: SubstrateControl | None = None ) -> WorkspaceLifecycle: @@ -570,6 +651,7 @@ def _lifecycle_from_row(row: dict) -> WorkspaceLifecycle: ), "operation_id": row.get("operation_id"), "snapshot_ref": row.get("snapshot_ref"), + "last_activity_at": row.get("last_activity_at"), "ownership_generation": row["ownership_generation"], "updated_at": row["updated_at"], } @@ -593,6 +675,7 @@ def _manifest_from_session(row: dict) -> WorkspaceManifest: mcp_servers=(), egress_allowlist=(), resource_class="default", + dev=None, ) @@ -947,7 +1030,9 @@ async def _record_suspend_fence( async def _reserve_operation( previous: WorkspaceLifecycle, desired_state: WorkspaceDesiredState, -) -> WorkspaceLifecycle: + *, + only_if_idle: bool = False, +) -> WorkspaceLifecycle | None: at = datetime.now(UTC) transitional = ( WorkspaceObservedState.SUSPENDING @@ -1001,16 +1086,45 @@ async def _reserve_operation( async with db.connection() as conn: async with conn.transaction(): binding = await conn.fetchrow( - """SELECT ownership_generation FROM workspace_bindings + """SELECT ownership_generation,desired_state FROM workspace_bindings WHERE workspace_id=$1 FOR UPDATE""", previous.workspace_id, ) if binding is None: raise ContractError(f"no workspace binding for {previous.workspace_id}") + if binding.get("desired_state") == "deleting": + raise ContractError("The workspace is being deleted.") if binding["ownership_generation"] != previous.ownership_generation: raise StaleOwnership( f"workspace {previous.workspace_id} changed during lifecycle request" ) + if only_if_idle: + if desired_state != WorkspaceDesiredState.SUSPENDED: + raise ValueError("only_if_idle applies to suspension") + dev = previous.manifest.dev + if dev is None: + return None + activity = await conn.fetchrow( + """SELECT last_activity_at, + (SELECT MAX(created_at) FROM native_deliveries + WHERE session_id=$1) AS last_delivery_at + FROM workspace_lifecycles WHERE workspace_id=$1 FOR UPDATE""", + previous.workspace_id, + ) + if activity is None: + raise ContractError( + f"no workspace lifecycle for {previous.workspace_id}" + ) + last_active = max( + value + for value in ( + activity["last_activity_at"], + activity["last_delivery_at"], + ) + if value is not None + ) + if last_active > at - timedelta(minutes=dev.idle_timeout_minutes): + return None if desired_state == WorkspaceDesiredState.SUSPENDED: fence_reason = suspend_fence_reason( await _delivery_states(previous.workspace_id, conn=conn) @@ -1047,7 +1161,8 @@ async def _request_workspace_state( desired_state: WorkspaceDesiredState, *, control: SubstrateControl | None = None, -) -> WorkspaceLifecycle: + only_if_idle: bool = False, +) -> WorkspaceLifecycle | None: control = control or _control() async with _lock(workspace_id): previous = await ensure_workspace_lifecycle(workspace_id) @@ -1066,7 +1181,14 @@ async def _request_workspace_state( # can be recorded. First attempts persist intent before the first Substrate call. had_pending_operation = previous.operation_id is not None if not had_pending_operation: - previous = await _reserve_operation(previous, desired_state) + reserved = ( + await _reserve_operation(previous, desired_state, only_if_idle=True) + if only_if_idle + else await _reserve_operation(previous, desired_state) + ) + if reserved is None: + return None + previous = reserved try: actor = await control.get_actor(row["atespace"], row["actor_name"]) diff --git a/backend/src/mainloop/runtime/workspace_api.py b/backend/src/mainloop/runtime/workspace_api.py index 9cec084..b9c1af6 100644 --- a/backend/src/mainloop/runtime/workspace_api.py +++ b/backend/src/mainloop/runtime/workspace_api.py @@ -1,16 +1,44 @@ -"""Workspace lifecycle endpoints for manifest and actor state.""" +"""Workspace lifecycle endpoints for branch workspace actors.""" -from fastapi import APIRouter, Header, HTTPException +import json +import uuid +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Header, HTTPException, Response +from fastapi.responses import JSONResponse +from mainloop.config import settings from mainloop.db import db from mainloop.runtime import workspace_adapter +from mainloop.runtime.actor_provisioner import get_actor_provisioner from mainloop.runtime.contracts import ContractError from mainloop.sse import notify_workspace_updated +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator -from models import WorkspaceLifecycle +from models import ( + WorkspaceDev, + WorkspaceLifecycle, + WorkspaceManifest, + WorkspaceObservedState, +) router = APIRouter(prefix="/workspaces", tags=["workspaces"]) +class CreateWorkspaceRequest(BaseModel): + project_id: Annotated[StrictStr, Field(min_length=1)] + branch: Annotated[StrictStr, Field(min_length=1)] + dev: WorkspaceDev + + model_config = ConfigDict(extra="forbid", strict=True) + + @field_validator("branch") + @classmethod + def validate_branch_name(cls, value: str) -> str: + WorkspaceManifest(branch=value, resource_class="default") + return value + + def _user_id(value: str | None) -> str: return value or "local-dev-user" @@ -34,6 +62,122 @@ async def _publish(user_id: str, lifecycle: WorkspaceLifecycle) -> None: await notify_workspace_updated(user_id, lifecycle.model_dump(mode="json")) +@router.post("", response_model=WorkspaceLifecycle) +async def create_workspace( + request: CreateWorkspaceRequest, + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + """Create a branch workspace and its independent Substrate actor.""" + owner = _user_id(user_id) + workspace_id = str(uuid.uuid4()) + actor_name = f"ml-{workspace_id[:16]}" + shim_token_secret_name = f"{actor_name}-shim" + atespace = settings.substrate_atespace + template = request.dev.actor_template or settings.substrate_actor_template + + async with db.connection() as conn: + project = await conn.fetchrow( + "SELECT id, html_url FROM projects WHERE id=$1 AND user_id=$2", + request.project_id, + owner, + ) + if project is None: + raise HTTPException(status_code=404, detail="Project not found") + + manifest = WorkspaceManifest( + repo_url=project["html_url"], + branch=request.branch, + resource_class="default", + dev=request.dev, + ) + conversation_id = str(uuid.uuid4()) + now = datetime.now(UTC) + async with conn.transaction(): + thread = await conn.fetchrow( + "SELECT id FROM main_threads WHERE user_id=$1 ORDER BY created_at LIMIT 1", + owner, + ) + thread_id = thread["id"] if thread else str(uuid.uuid4()) + if thread is None: + await conn.execute( + "INSERT INTO main_threads (id,user_id) VALUES ($1,$2)", + thread_id, + owner, + ) + await conn.execute( + "INSERT INTO conversations (id,user_id,title) VALUES ($1,$2,$3)", + conversation_id, + owner, + f"{project['id']} · {request.branch}", + ) + await conn.execute( + """INSERT INTO sessions + (id,user_id,main_thread_id,title,description,prompt,conversation_id, + status,created_at,repo_url,project_id,branch_name,base_branch) + VALUES ($1,$2,$3,$4,$5,$6,$7,'active',$8,$9,$10,$11,$12)""", + workspace_id, + owner, + thread_id, + f"{project['id']} · {request.branch}", + "Branch development workspace", + "Development workspace", + conversation_id, + now, + project["html_url"], + request.project_id, + request.branch, + request.branch, + ) + await conn.execute( + """INSERT INTO workspace_bindings + (workspace_id,atespace,actor_name,actor_template, + shim_token_secret_name,observed_state,desired_state,created_at,updated_at) + VALUES ($1,$2,$3,$4,$5,'unknown','active',$6,$6)""", + workspace_id, + atespace, + actor_name, + template, + shim_token_secret_name, + now, + ) + await conn.execute( + """INSERT INTO workspace_lifecycles + (workspace_id,desired_state,observed_state,manifest,conditions, + last_activity_at,updated_at) + VALUES ($1,'running','unknown',$2::jsonb,'[]'::jsonb,$3,$3)""", + workspace_id, + json.dumps(manifest.model_dump(mode="json")), + now, + ) + + try: + provisioner = get_actor_provisioner() + provisioned = await provisioner.create( + atespace=atespace, + actor_name=actor_name, + template=template, + shim_token_secret_name=shim_token_secret_name, + ) + lifecycle = await workspace_adapter._record_observation( + workspace_id, actor=provisioned.actor + ) + except Exception: + # The row and actor identity are durable before the external call. Keep them so a + # refresh can reconcile an outcome that timed out instead of creating a second actor. + lifecycle = await workspace_adapter._record_observation( + workspace_id, + failure=( + WorkspaceObservedState.UNKNOWN, + "ProvisioningUncertain", + "Actor provisioning did not return a confirmed result. Refresh status before retrying.", + ), + ) + await _publish(owner, lifecycle) + return JSONResponse(status_code=202, content=lifecycle.model_dump(mode="json")) + await _publish(owner, lifecycle) + return lifecycle + + @router.get("", response_model=list[WorkspaceLifecycle]) async def list_workspaces( user_id: str | None = Header(default=None, alias="X-User-ID"), @@ -99,3 +243,108 @@ async def refresh_workspace( return await _run_operation( workspace_id, _user_id(user_id), workspace_adapter.refresh_workspace_lifecycle ) + + +@router.post("/{workspace_id}/touch", response_model=WorkspaceLifecycle) +async def touch_workspace( + workspace_id: str, + reason: str, + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + owner = _user_id(user_id) + await _require_owned_workspace(workspace_id, owner) + try: + lifecycle = await workspace_adapter.touch_workspace(workspace_id, reason=reason) + except (ContractError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + await _publish(owner, lifecycle) + return lifecycle + + +@router.delete("/{workspace_id}", status_code=204) +async def delete_workspace( + workspace_id: str, + user_id: str | None = Header(default=None, alias="X-User-ID"), +): + owner = _user_id(user_id) + await _require_owned_workspace(workspace_id, owner) + async with workspace_adapter._lock(workspace_id): + async with db.connection() as conn: + async with conn.transaction(): + row = await conn.fetchrow( + """SELECT b.atespace,b.actor_name,b.shim_token_secret_name, + b.desired_state,s.conversation_id + FROM workspace_bindings b JOIN sessions s ON s.id=b.workspace_id + WHERE b.workspace_id=$1 FOR UPDATE OF b""", + workspace_id, + ) + if row is None: + raise HTTPException(status_code=404, detail="Workspace not found") + open_deliveries = await workspace_adapter._delivery_states( + workspace_id, conn=conn + ) + if open_deliveries: + raise HTTPException( + status_code=409, + detail="An open delivery must be reconciled before deleting this workspace.", + ) + await conn.execute( + "UPDATE workspace_bindings SET desired_state='deleting',updated_at=NOW() WHERE workspace_id=$1", + workspace_id, + ) + try: + await get_actor_provisioner().delete( + atespace=row["atespace"], + actor_name=row["actor_name"], + shim_token_secret_name=row["shim_token_secret_name"], + ) + except Exception as exc: + async with db.connection() as conn: + await conn.execute( + """UPDATE workspace_bindings SET desired_state=$2,updated_at=NOW() + WHERE workspace_id=$1 AND desired_state='deleting'""", + workspace_id, + row["desired_state"], + ) + raise HTTPException( + status_code=502, + detail="Substrate did not confirm workspace deletion; refresh before retrying.", + ) from exc + async with db.connection() as conn: + async with conn.transaction(): + await conn.execute( + "DELETE FROM native_deliveries WHERE session_id=$1", workspace_id + ) + await conn.execute( + "DELETE FROM native_events WHERE session_id=$1", workspace_id + ) + await conn.execute( + "DELETE FROM native_lineage WHERE session_id=$1", workspace_id + ) + await conn.execute( + "DELETE FROM native_bindings WHERE session_id=$1", workspace_id + ) + await conn.execute( + "DELETE FROM workspace_lifecycles WHERE workspace_id=$1", + workspace_id, + ) + await conn.execute( + "DELETE FROM workspace_bindings WHERE workspace_id=$1", workspace_id + ) + await conn.execute("DELETE FROM sessions WHERE id=$1", workspace_id) + await conn.execute( + """DELETE FROM messages + WHERE conversation_id=$1 + AND NOT EXISTS ( + SELECT 1 FROM sessions s WHERE s.anchor_message_id=messages.id + )""", + row["conversation_id"], + ) + await conn.execute( + """DELETE FROM conversations c WHERE c.id=$1 + AND NOT EXISTS ( + SELECT 1 FROM messages m WHERE m.conversation_id=c.id + )""", + row["conversation_id"], + ) + return Response(status_code=204) diff --git a/backend/tests/runtime/test_delivery_suspend_fence.py b/backend/tests/runtime/test_delivery_suspend_fence.py index 630fd95..11fb8c0 100644 --- a/backend/tests/runtime/test_delivery_suspend_fence.py +++ b/backend/tests/runtime/test_delivery_suspend_fence.py @@ -44,6 +44,8 @@ async def fetchrow(self, query, *_args): async def execute(self, query, *_args): if "INSERT INTO native_deliveries" in query: self.events.append("delivery-insert") + elif "UPDATE workspace_lifecycles" in query: + self.events.append("activity-touch") def fake_connection(connection): @@ -93,6 +95,7 @@ async def create_message(**_kwargs): "lifecycle-read", "message-insert", "delivery-insert", + "activity-touch", "commit", ], ) diff --git a/backend/tests/runtime/test_workspace_api.py b/backend/tests/runtime/test_workspace_api.py index 1c1d834..38b02ef 100644 --- a/backend/tests/runtime/test_workspace_api.py +++ b/backend/tests/runtime/test_workspace_api.py @@ -47,10 +47,13 @@ def test_routes_expose_the_workspace_lifecycle_api(self): self.assertTrue( { ("/workspaces", "GET"), + ("/workspaces", "POST"), ("/workspaces/{workspace_id}", "GET"), + ("/workspaces/{workspace_id}", "DELETE"), ("/workspaces/{workspace_id}/suspend", "POST"), ("/workspaces/{workspace_id}/resume", "POST"), ("/workspaces/{workspace_id}/refresh", "POST"), + ("/workspaces/{workspace_id}/touch", "POST"), }.issubset(route_methods) ) diff --git a/backend/tests/runtime/test_workspace_dev_manifest.py b/backend/tests/runtime/test_workspace_dev_manifest.py new file mode 100644 index 0000000..fc56c7f --- /dev/null +++ b/backend/tests/runtime/test_workspace_dev_manifest.py @@ -0,0 +1,144 @@ +"""Strict dev manifest validation and fake actor provisioning.""" + +import base64 +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from mainloop.runtime.actor_provisioner import ( + FakeActorProvisioner, + SubstrateActorProvisioner, +) +from mainloop.runtime.substrate import ActorRecord, ActorState +from pydantic import ValidationError + +from models import WorkspaceManifest + + +def manifest(dev: dict) -> WorkspaceManifest: + return WorkspaceManifest( + branch="feature/dev-env", + resource_class="default", + dev=dev, + ) + + +class WorkspaceDevManifestTests(unittest.TestCase): + def test_accepts_image_services_ports_and_timeout(self): + result = manifest( + { + "image": "node:22", + "actor_template": "sample", + "services": [ + { + "name": "postgres", + "image": "postgres:16", + "env": {"POSTGRES_DB": "workspace"}, + "ports": [5432], + } + ], + "ports": [{"name": "app", "number": 3000, "protocol": "http"}], + "idle_timeout_minutes": 45, + } + ) + + self.assertEqual(result.dev.image, "node:22") + self.assertEqual(result.dev.services[0].ports, (5432,)) + self.assertEqual(result.dev.ports[0].number, 3000) + self.assertEqual(result.dev.idle_timeout_minutes, 45) + + def test_requires_exactly_one_image_source(self): + for dev in ({}, {"image": "node:22", "devcontainer_ref": "ghcr.io/dev"}): + with self.subTest(dev=dev), self.assertRaises(ValidationError): + manifest(dev) + + def test_rejects_unknown_fields_and_duplicate_ports(self): + with self.assertRaisesRegex(ValidationError, "extra_forbidden"): + manifest({"image": "node:22", "surprise": True}) + with self.assertRaisesRegex(ValidationError, "unique"): + manifest( + { + "image": "node:22", + "ports": [ + {"name": "app", "number": 3000}, + {"name": "web", "number": 3000}, + ], + } + ) + + def test_rejects_out_of_range_timeout_and_duplicate_service_names(self): + with self.assertRaises(ValidationError): + manifest({"image": "node:22", "idle_timeout_minutes": 0}) + with self.assertRaisesRegex(ValidationError, "unique"): + manifest( + { + "image": "node:22", + "services": [ + {"name": "db", "image": "postgres:16"}, + {"name": "db", "image": "redis:7"}, + ], + } + ) + + def test_rejects_non_strict_timeout(self): + with self.assertRaises(ValidationError): + manifest({"image": "node:22", "idle_timeout_minutes": "30"}) + + +class FakeProvisionerTests(unittest.IsolatedAsyncioTestCase): + async def test_fake_creates_and_deletes_actor_and_secret(self): + provisioner = FakeActorProvisioner() + # Test value is a Kubernetes Secret name, not credential material. + created = await provisioner.create( # nosec B106 + atespace="workspaces", + actor_name="ml-feature-1", + template="sample-template", + shim_token_secret_name="ml-feature-1-shim", + ) + + self.assertEqual(created.actor.name, "ml-feature-1") + self.assertIn("ml-feature-1-shim", provisioner.secrets) + await provisioner.delete( + atespace="workspaces", + actor_name="ml-feature-1", + shim_token_secret_name=created.shim_token_secret_name, + ) + self.assertFalse(provisioner.actors) + self.assertFalse(provisioner.secrets) + + async def test_substrate_provisioner_stores_a_random_token_in_secret(self): + actor = ActorRecord( + atespace="workspaces", + name="ml-branch-1", + uid="actor-1", + state=ActorState.RUNNING, + external_snapshot_uri=None, + current_actor_template_uid="template-1", + raw={}, + ) + control = Mock() + control.get_actor = AsyncMock(return_value=actor) + control.create_actor = AsyncMock() + core_api = Mock() + provisioner = SubstrateActorProvisioner(control=control, core_api=core_api) + + with patch( + "mainloop.runtime.actor_provisioner.secrets.token_urlsafe", + return_value="private-token", + ): + # Test value is a Kubernetes Secret name, not credential material. + result = await provisioner.create( # nosec B106 + atespace="workspaces", + actor_name="ml-branch-1", + template="project-template", + shim_token_secret_name="ml-branch-1-shim", + ) + + secret = core_api.create_namespaced_secret.call_args.args[1] + self.assertEqual(base64.b64decode(secret.data["token"]), b"private-token") + self.assertEqual(result.actor, actor) + self.assertEqual(result.shim_token_secret_name, "ml-branch-1-shim") + control.create_actor.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_workspace_idle.py b/backend/tests/runtime/test_workspace_idle.py new file mode 100644 index 0000000..7a21163 --- /dev/null +++ b/backend/tests/runtime/test_workspace_idle.py @@ -0,0 +1,173 @@ +"""Durable activity touches and the periodic idle policy remain fake-backed.""" + +import asyncio +import unittest +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from mainloop.runtime import native_sessions +from mainloop.runtime import workspace_adapter as adapter +from mainloop.runtime.contracts import ContractError + +from models import ( + WorkspaceDesiredState, + WorkspaceLifecycle, + WorkspaceManifest, + WorkspaceObservedState, +) + + +def lifecycle(state=WorkspaceObservedState.SUSPENDED): + from datetime import UTC, datetime + + return WorkspaceLifecycle( + workspace_id="workspace-1", + session_id="workspace-1", + desired_state=WorkspaceDesiredState.SUSPENDED, + observed_state=state, + manifest=WorkspaceManifest( + branch="feature/sample", + resource_class="default", + dev={"image": "node:22"}, + ), + updated_at=datetime.now(UTC), + ) + + +class FakeConnection: + def __init__(self, rows=()): + self.rows = list(rows) + self.executed = [] + self.query = "" + + async def execute(self, query, *args): + self.executed.append((query, args)) + + @asynccontextmanager + async def transaction(self): + yield self + + async def fetchrow(self, query, *_args): + if "FROM workspace_bindings" in query: + return {"workspace_id": "workspace-1", "ownership_generation": 3} + if "FROM workspace_lifecycles" in query and "last_activity_at" in query: + from datetime import UTC, datetime + + return { + "last_activity_at": datetime.now(UTC), + "last_delivery_at": None, + } + if "FROM workspace_lifecycles" in query: + return {"desired_state": "suspended", "observed_state": "suspended"} + raise AssertionError(f"unexpected query: {query}") + + async def fetch(self, query, *args): + self.query = query + return self.rows + + +def fake_connection(connection): + @asynccontextmanager + async def connect(): + yield connection + + return connect + + +class WorkspaceIdleTests(unittest.IsolatedAsyncioTestCase): + async def test_native_turn_touches_a_branch_workspace_before_recording(self): + with ( + patch.object( + native_sessions.db, + "get_session", + new=AsyncMock( + return_value=SimpleNamespace(status="active", conversation_id="c") + ), + ), + patch.object( + adapter, + "get_workspace", + new=AsyncMock(return_value={"actor_name": "ml-ws"}), + ), + patch.object(adapter, "touch_workspace", new=AsyncMock()) as touch, + patch.object(native_sessions, "_lock", return_value=asyncio.Lock()), + patch.object(native_sessions, "_open_count", new=AsyncMock(return_value=1)), + patch.object( + native_sessions, + "_record_delivery_message", + new=AsyncMock(return_value="message-1"), + ), + ): + message_id = await native_sessions.submit_message( + "workspace-1", "work", source="report" + ) + + self.assertEqual(message_id, "message-1") + touch.assert_awaited_once_with("workspace-1", reason="turn") + + async def test_turn_touch_records_activity_and_wakes_a_parked_workspace(self): + connection = FakeConnection() + current = lifecycle() + resumed = lifecycle(WorkspaceObservedState.RUNNING) + with ( + patch.object( + adapter, + "ensure_workspace_lifecycle", + new=AsyncMock(return_value=current), + ), + patch.object(adapter.db, "connection", new=fake_connection(connection)), + patch.object( + adapter, "resume_workspace", new=AsyncMock(return_value=resumed) + ) as wake, + ): + result = await adapter.touch_workspace("workspace-1", reason="turn") + + self.assertEqual(result.observed_state, WorkspaceObservedState.RUNNING) + self.assertIn("last_activity_at=NOW()", connection.executed[0][0]) + wake.assert_awaited_once_with("workspace-1") + + async def test_idle_reservation_rechecks_activity_under_the_binding_lock(self): + current = lifecycle(WorkspaceObservedState.RUNNING) + current = current.model_copy( + update={ + "desired_state": WorkspaceDesiredState.RUNNING, + "ownership_generation": 3, + } + ) + connection = FakeConnection() + with patch.object(adapter.db, "connection", new=fake_connection(connection)): + result = await adapter._reserve_operation( + current, + WorkspaceDesiredState.SUSPENDED, + only_if_idle=True, + ) + + self.assertIsNone(result) + self.assertFalse(connection.executed) + + async def test_touch_rejects_unknown_activity_reasons(self): + with self.assertRaisesRegex(ValueError, "reason must be"): + await adapter.touch_workspace("workspace-1", reason="browser") + + async def test_idle_scan_uses_durable_activity_and_fenced_suspend(self): + connection = FakeConnection( + [{"workspace_id": "workspace-1"}, {"workspace_id": "workspace-2"}] + ) + suspend = AsyncMock( + side_effect=[lifecycle(), ContractError("delivery still open")] + ) + with ( + patch.object(adapter.db, "connection", new=fake_connection(connection)), + patch.object(adapter, "suspend_workspace_if_idle", new=suspend), + ): + count = await adapter.suspend_idle_workspaces() + + self.assertIn("last_activity_at", connection.query) + self.assertIn("idle_timeout_minutes", connection.query) + self.assertEqual(count, 1) + self.assertEqual(suspend.await_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_workspace_provisioning_api.py b/backend/tests/runtime/test_workspace_provisioning_api.py new file mode 100644 index 0000000..0625b09 --- /dev/null +++ b/backend/tests/runtime/test_workspace_provisioning_api.py @@ -0,0 +1,240 @@ +"""Project ownership and actor provisioning orchestration use fakes.""" + +import unittest +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException +from mainloop.runtime import workspace_api +from mainloop.runtime.actor_provisioner import FakeActorProvisioner, ProvisionedActor +from mainloop.runtime.substrate import ActorRecord, ActorState + +from models import ( + WorkspaceDesiredState, + WorkspaceLifecycle, + WorkspaceManifest, + WorkspaceObservedState, +) + + +class FakeConnection: + def __init__(self, *, project=True): + self.project = project + self.statements = [] + self.workspace_row = { + "atespace": "mainloop-workspaces", + "actor_name": "ml-workspace", + "shim_token_secret_name": "ml-workspace-shim", + "desired_state": "active", + "conversation_id": "conversation-1", + } + + @asynccontextmanager + async def transaction(self): + yield + + async def fetchrow(self, query, *_args): + if "FROM projects" in query: + return ( + {"id": "project-1", "html_url": "https://github.com/example/repo"} + if self.project + else None + ) + if "FROM main_threads" in query: + return None + if "FROM workspace_bindings" in query: + return self.workspace_row + raise AssertionError(f"unexpected query: {query}") + + async def execute(self, query, *args): + self.statements.append((query, args)) + + +def fake_connection(connection): + @asynccontextmanager + async def connect(): + yield connection + + return connect + + +def lifecycle(workspace_id: str, manifest: WorkspaceManifest) -> WorkspaceLifecycle: + return WorkspaceLifecycle( + workspace_id=workspace_id, + session_id=workspace_id, + desired_state=WorkspaceDesiredState.RUNNING, + observed_state=WorkspaceObservedState.RUNNING, + manifest=manifest, + updated_at=datetime.now(UTC), + ) + + +class WorkspaceProvisioningApiTests(unittest.IsolatedAsyncioTestCase): + async def test_create_persists_identity_and_provisions_one_actor(self): + connection = FakeConnection() + provisioner = FakeActorProvisioner() + manifest = WorkspaceManifest( + repo_url="https://github.com/example/repo", + branch="feature/one", + resource_class="default", + dev={"image": "node:22", "actor_template": "project-template"}, + ) + actor = ActorRecord( + atespace="mainloop-workspaces", + name="ml-workspace", + uid="actor-1", + state=ActorState.RUNNING, + external_snapshot_uri=None, + current_actor_template_uid="template-1", + raw={}, + ) + fake_create = AsyncMock( + return_value=ProvisionedActor(actor, "ml-workspace-shim") + ) + provisioner.create = fake_create + with ( + patch.object( + workspace_api.db, "connection", new=fake_connection(connection) + ), + patch.object( + workspace_api, "get_actor_provisioner", return_value=provisioner + ), + patch.object( + workspace_api.workspace_adapter, + "_record_observation", + new=AsyncMock( + side_effect=lambda workspace_id, **_kwargs: lifecycle( + workspace_id, manifest + ) + ), + ), + patch.object(workspace_api, "_publish", new=AsyncMock()), + ): + result = await workspace_api.create_workspace( + workspace_api.CreateWorkspaceRequest( + project_id="project-1", + branch="feature/one", + dev={"image": "node:22", "actor_template": "project-template"}, + ), + user_id="owner-1", + ) + + self.assertEqual(result.manifest.branch, "feature/one") + self.assertEqual(result.observed_state, WorkspaceObservedState.RUNNING) + self.assertTrue( + any( + "INSERT INTO workspace_bindings" in query + for query, _ in connection.statements + ) + ) + self.assertTrue( + any( + "INSERT INTO workspace_lifecycles" in query + for query, _ in connection.statements + ) + ) + kwargs = fake_create.await_args.kwargs + self.assertEqual(kwargs["template"], "project-template") + self.assertEqual( + kwargs["shim_token_secret_name"], f"{kwargs['actor_name']}-shim" + ) + + async def test_create_hides_projects_owned_by_another_user(self): + connection = FakeConnection(project=False) + with patch.object( + workspace_api.db, "connection", new=fake_connection(connection) + ): + with self.assertRaises(HTTPException) as raised: + await workspace_api.create_workspace( + workspace_api.CreateWorkspaceRequest( + project_id="project-1", branch="main", dev={"image": "node:22"} + ), + user_id="other-owner", + ) + + self.assertEqual(raised.exception.status_code, 404) + self.assertFalse(connection.statements) + + async def test_delete_removes_actor_secret_and_workspace_records(self): + connection = FakeConnection() + provisioner = FakeActorProvisioner() + provisioner.actors[("mainloop-workspaces", "ml-workspace")] = ActorRecord( + atespace="mainloop-workspaces", + name="ml-workspace", + uid="actor-1", + state=ActorState.RUNNING, + external_snapshot_uri=None, + current_actor_template_uid="template-1", + raw={}, + ) + provisioner.secrets.add("ml-workspace-shim") + with ( + patch.object(workspace_api, "_require_owned_workspace", new=AsyncMock()), + patch.object( + workspace_api.db, "connection", new=fake_connection(connection) + ), + patch.object( + workspace_api.workspace_adapter, + "_delivery_states", + new=AsyncMock(return_value=set()), + ), + patch.object( + workspace_api, "get_actor_provisioner", return_value=provisioner + ), + ): + response = await workspace_api.delete_workspace( + "workspace-1", user_id="owner-1" + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(provisioner.actors) + self.assertFalse(provisioner.secrets) + self.assertTrue( + any( + "DELETE FROM workspace_lifecycles" in query + for query, _ in connection.statements + ) + ) + self.assertTrue( + any( + "DELETE FROM workspace_bindings" in query + for query, _ in connection.statements + ) + ) + self.assertTrue( + any( + "DELETE FROM native_deliveries" in query + for query, _ in connection.statements + ) + ) + self.assertTrue( + any("DELETE FROM messages" in query for query, _ in connection.statements) + ) + + async def test_delete_refuses_open_deliveries_before_actor_mutation(self): + connection = FakeConnection() + provisioner = FakeActorProvisioner() + with ( + patch.object(workspace_api, "_require_owned_workspace", new=AsyncMock()), + patch.object( + workspace_api.db, "connection", new=fake_connection(connection) + ), + patch.object( + workspace_api.workspace_adapter, + "_delivery_states", + new=AsyncMock(return_value={"sending"}), + ), + patch.object( + workspace_api, "get_actor_provisioner", return_value=provisioner + ), + ): + with self.assertRaises(HTTPException) as raised: + await workspace_api.delete_workspace("workspace-1", user_id="owner-1") + + self.assertEqual(raised.exception.status_code, 409) + self.assertFalse(provisioner.actors) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/specs/workspaces.md b/docs/specs/workspaces.md index 2103331..bc71e42 100644 --- a/docs/specs/workspaces.md +++ b/docs/specs/workspaces.md @@ -1,14 +1,14 @@ # Workspaces -Workspaces are runtime resources attached to sessions. Workspace lifecycle is separate from the -session's task status, native-agent activity, message delivery, user attention, and publication -state. +Workspaces are runtime resources attached to sessions. A project branch workspace has its own +Substrate actor and lifecycle. Workspace lifecycle is separate from the session's task status, +native-agent activity, message delivery, user attention, and publication state. ## Lifecycle Mainloop records desired state (`running` or `suspended`), observed state, conditions, the last -observed transition, an operation ID, and the last known snapshot reference. The lifecycle is -owned by Mainloop; Substrate is the source of actor observations. +observed transition, an operation ID, the last known snapshot reference, and the last activity +time. Mainloop owns desired state; Substrate is the source of actor observations. | Observed state | User label | Meaning | | ---------------------------------------- | ---------------------------- | -------------------------------------------------------------------------- | @@ -21,17 +21,23 @@ owned by Mainloop; Substrate is the source of actor observations. | `unknown` | UNKNOWN | A transport or unrecognized actor state prevents a reliable conclusion. | The UI shows workspace state wherever sessions are listed and links from the session detail to -`/workspaces/{id}`. The workspace page shows the manifest, conditions, transition time and -snapshot reference, and offers suspend, resume, and status refresh controls. Session badges and -session status are not changed by workspace operations. +`/workspaces/{id}`. The workspace page shows the manifest, conditions, transition time, snapshot +reference, idle timeout, and last activity, with suspend, resume, refresh, and delete controls. +Session badges and session status are not changed by workspace operations. ## API - `GET /workspaces` lists the current user's workspace lifecycle records. - `GET /workspaces/{id}` returns one workspace lifecycle and manifest. +- `POST /workspaces` accepts a project ID, branch, and strict dev manifest, then provisions one + actor from its declared actor template or the configured default template. - `POST /workspaces/{id}/suspend` records the desired state and requests suspension. - `POST /workspaces/{id}/resume` records the desired state and requests resumption. - `POST /workspaces/{id}/refresh` reads Substrate status without changing desired state. +- `POST /workspaces/{id}/touch?reason=preview` records activity and wakes a suspended workspace. + The preview proxy can call the same `touch_workspace(workspace_id, reason)` service API. +- `DELETE /workspaces/{id}` deletes the actor and its shim token Secret. Open deliveries return + `409`; an unconfirmed Substrate deletion keeps the durable workspace binding for reconciliation. - Lifecycle changes are published through the existing event stream as `workspace:updated`. Suspend is refused while the native delivery ledger contains a recorded, queued, sending, @@ -43,13 +49,21 @@ generation is advanced with a compare-and-swap before a lifecycle control call. ## Declarative manifest Each workspace exposes repository URL and branch, allowed agent kinds (`claude` and `codex`), -skill and MCP references, an egress host allowlist, and a resource class. These values describe -intent only. This slice stores and displays them; it does not provision repositories, tools, -network policy, or resources. A missing repository URL is reported as undeclared rather than -inferred. +skill and MCP references, an egress host allowlist, a resource class, and an optional `dev` +section. `dev` requires exactly one of `image` or `devcontainer_ref`; it can declare an actor +template, sibling services (`name`, `image`, `env`, and numeric ports), HTTP preview ports +(`name`, `number`, `protocol`), and an idle timeout from 1 to 1440 minutes. Unknown fields, +duplicate service or port names, duplicate ports, and invalid timeouts are rejected by the +shared strict model. + +The project page creates a workspace per branch and lists each workspace independently. Turn, +delivery, and preview activity use the durable last-activity timestamp. Mainloop's existing +reconcile loop checks idle workspaces once a minute and suspends expired workspaces through the +fenced lifecycle operation; open deliveries still block suspension. Services and image values +are declared by the project manifest and must match its configured actor template. ## Scope and evidence -These endpoints operate on existing Substrate workspace bindings. They do not provision an -actor. Runtime behavior is covered by fake-backed tests; this specification does not claim a -live cluster integration proof. +Runtime behavior is covered by fake-backed tests; this specification does not claim a live +cluster integration proof. The sample under `examples/devenv-sample/` documents the intended +Node plus Postgres project manifest shape. diff --git a/examples/devenv-sample/Dockerfile b/examples/devenv-sample/Dockerfile new file mode 100644 index 0000000..5dfab9f --- /dev/null +++ b/examples/devenv-sample/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-bookworm-slim +WORKDIR /workspace +COPY package.json ./ +RUN npm install --omit=dev +COPY server.mjs ./ +RUN groupadd --system app \ + && useradd --system --gid app --create-home --home-dir /home/app app \ + && chown -R app:app /workspace +USER app:app +ENV PORT=3000 PGHOST=postgres PGPORT=5432 PGUSER=postgres PGPASSWORD=dev-only PGDATABASE=workspace +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] +CMD ["npm", "start"] diff --git a/examples/devenv-sample/README.md b/examples/devenv-sample/README.md new file mode 100644 index 0000000..b9fe6c3 --- /dev/null +++ b/examples/devenv-sample/README.md @@ -0,0 +1,15 @@ +# Mainloop dev environment sample + +This small fixture demonstrates the shape of a branch workspace: one Node app, one sibling +Postgres service, an HTTP preview port, a WebSocket echo endpoint, and a persistent counter. + +The app serves one page at `/`, echoes WebSocket messages at `/ws`, and reads or increments a +Postgres row through `/api/counter`. `mainloop.yaml` declares the app image, service, preview +port, actor template, and 30-minute idle timeout. Build the image from this directory with +`docker build -t mainloop-devenv-sample:latest .` in an environment with Docker available. + +The image declares a real non-root `app` user and checks the app's `/` HTTP route with a +container health check. The current Substrate actor path overrides the image's `USER` and runs +actors as UID 0; that upstream runtime gap remains. The sample does not claim to validate +non-root actor execution or live multi-container connectivity. Sample credentials are +fixture-only; use secret-backed values for real projects. diff --git a/examples/devenv-sample/mainloop.yaml b/examples/devenv-sample/mainloop.yaml new file mode 100644 index 0000000..22734a4 --- /dev/null +++ b/examples/devenv-sample/mainloop.yaml @@ -0,0 +1,21 @@ +--- +repo_url: https://github.com/example/devenv-sample +branch: main +agent_kinds: [claude, codex] +resource_class: default +dev: + image: mainloop-devenv-sample:latest + actor_template: devenv-sample + services: + - name: postgres + image: postgres:16-alpine + env: + POSTGRES_DB: workspace + POSTGRES_USER: postgres + POSTGRES_PASSWORD: dev-only + ports: [5432] + ports: + - name: app + number: 3000 + protocol: http + idle_timeout_minutes: 30 diff --git a/examples/devenv-sample/package.json b/examples/devenv-sample/package.json new file mode 100644 index 0000000..f71d33a --- /dev/null +++ b/examples/devenv-sample/package.json @@ -0,0 +1,13 @@ +{ + "name": "mainloop-devenv-sample", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + }, + "dependencies": { + "pg": "^8.13.1", + "ws": "^8.18.0" + } +} diff --git a/examples/devenv-sample/server.mjs b/examples/devenv-sample/server.mjs new file mode 100644 index 0000000..ea957e7 --- /dev/null +++ b/examples/devenv-sample/server.mjs @@ -0,0 +1,62 @@ +import { createServer } from 'node:http'; +import { Pool } from 'pg'; +import { WebSocketServer } from 'ws'; + +const port = Number(process.env.PORT ?? 3000); +const pool = new Pool(); +const server = createServer(async (request, response) => { + if (request.method === 'GET' && request.url === '/') { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end(` + +Mainloop dev workspace +

Branch workspace

Postgres counter: …

+

WebSocket: connecting…

+`); + return; + } + + if (request.url === '/api/counter' && request.method === 'GET') { + const result = await pool.query('SELECT value FROM sample_counter WHERE id=1'); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ value: result.rows[0]?.value ?? 0 })); + return; + } + + if (request.url === '/api/counter' && request.method === 'POST') { + const result = await pool.query( + 'INSERT INTO sample_counter (id,value) VALUES (1,1) ON CONFLICT (id) DO UPDATE SET value=sample_counter.value+1 RETURNING value' + ); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ value: result.rows[0].value })); + return; + } + + response.writeHead(404); + response.end('Not found'); +}); + +const sockets = new WebSocketServer({ noServer: true }); +server.on('upgrade', (request, socket, head) => { + if (request.url !== '/ws') return socket.destroy(); + sockets.handleUpgrade(request, socket, head, (websocket) => { + websocket.on('message', (message) => websocket.send(message.toString())); + }); +}); + +await pool.query(`CREATE TABLE IF NOT EXISTS sample_counter ( + id integer PRIMARY KEY, + value integer NOT NULL DEFAULT 0 +)`); +await pool.query('INSERT INTO sample_counter (id,value) VALUES (1,0) ON CONFLICT (id) DO NOTHING'); +server.listen(port, '0.0.0.0', () => console.log(`sample app listening on ${port}`)); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f1ce0f1..4270367 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -216,6 +216,29 @@ export interface WorkspaceManifest { mcp_servers: string[]; egress_allowlist: string[]; resource_class: string; + dev: WorkspaceDev | null; +} + +export interface WorkspacePort { + name: string; + number: number; + protocol: 'http'; +} + +export interface WorkspaceService { + name: string; + image: string; + env: Record; + ports: number[]; +} + +export interface WorkspaceDev { + image: string | null; + devcontainer_ref: string | null; + actor_template: string | null; + services: WorkspaceService[]; + ports: WorkspacePort[]; + idle_timeout_minutes: number; } export interface WorkspaceCondition { @@ -243,6 +266,7 @@ export interface WorkspaceLifecycle { last_transition: WorkspaceTransition | null; operation_id: string | null; snapshot_ref: string | null; + last_activity_at: string | null; ownership_generation: number; updated_at: string; } @@ -447,6 +471,25 @@ export const api = { if (!response.ok) throw new Error('Failed to refresh project'); }, + async createWorkspace( + projectId: string, + branch: string, + dev: WorkspaceDev + ): Promise { + const response = await apiFetch(`${API_URL}/workspaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: projectId, branch, dev }) + }); + if (!response.ok) throw new Error(await errorDetail(response, 'Failed to create workspace')); + return response.json(); + }, + + async deleteWorkspace(workspaceId: string): Promise { + const response = await apiFetch(`${API_URL}/workspaces/${workspaceId}`, { method: 'DELETE' }); + if (!response.ok) throw new Error(await errorDetail(response, 'Failed to delete workspace')); + }, + /** * Get the SSE endpoint URL for the global event stream. */ diff --git a/frontend/src/routes/projects/[id]/+page.svelte b/frontend/src/routes/projects/[id]/+page.svelte index 985f870..4d39f99 100644 --- a/frontend/src/routes/projects/[id]/+page.svelte +++ b/frontend/src/routes/projects/[id]/+page.svelte @@ -3,17 +3,34 @@ import { projects, currentProject } from '$lib/stores/projects'; import { goto } from '$app/navigation'; import { statusLabel } from '$lib/sessionStatus'; + import { api, type WorkspaceLifecycle } from '$lib/api'; // The route is reused when only [id] changes, so load per id rather than once on mount. const projectId = $derived($page.params.id); + let branch = $state(''); + let workspaceRows = $state([]); + let workspaceBusy = $state(false); + let workspaceError = $state(null); $effect(() => { - if (projectId) projects.fetchProjectDetail(projectId); + if (projectId) { + branch = ''; + projects.fetchProjectDetail(projectId); + void api + .listWorkspaces() + .then((rows) => { + if (projectId === $page.params.id) workspaceRows = rows; + }) + .catch(() => { + workspaceRows = []; + }); + } }); // The store keeps the last project until the next one arrives; don't show it under another id. - const detail = $derived( - $currentProject?.project.id === projectId ? $currentProject : null + const detail = $derived($currentProject?.project.id === projectId ? $currentProject : null); + const projectWorkspaces = $derived( + workspaceRows.filter((item) => item.manifest.repo_url === detail?.project.html_url) ); function formatDate(dateStr: string): string { @@ -48,25 +65,54 @@ return 'text-term-fg-muted'; } } + + async function createWorkspace(project: { id: string; default_branch: string }) { + workspaceBusy = true; + workspaceError = null; + try { + const workspace = await api.createWorkspace( + project.id, + branch.trim() || project.default_branch, + { + image: 'node:22-bookworm', + devcontainer_ref: null, + actor_template: null, + services: [], + ports: [], + idle_timeout_minutes: 30 + } + ); + await goto(`/workspaces/${workspace.workspace_id}`); + } catch (error) { + workspaceError = error instanceof Error ? error.message : 'Failed to create workspace'; + } finally { + workspaceBusy = false; + } + } {$currentProject?.project.full_name || 'Project'} - mainloop -
+
{#if detail} {@const { project, open_prs, recent_commits, sessions: projectSessions } = detail} -
+
@@ -76,20 +122,20 @@ {/if}
-

{project.full_name}

+

{project.full_name}

{#if project.description} -

{project.description}

+

{project.description}

{/if}
View on GitHub → - + {project.open_pr_count} open {project.open_pr_count === 1 ? 'PR' : 'PRs'}
@@ -101,7 +147,7 @@
-

Open Pull Requests

+

Open Pull Requests

{#if open_prs.length > 0}
{#each open_prs as pr (pr.number)} @@ -109,20 +155,20 @@ href={pr.url} target="_blank" rel="noopener noreferrer" - class="block border border-term-border bg-term-bg p-3 hover:border-term-accent" + class="border-term-border bg-term-bg hover:border-term-accent block border p-3" >
- #{pr.number} + #{pr.number} {#if pr.is_mainloop} - + mainloop {/if}
-

{pr.title}

-

+

{pr.title}

+

by {pr.author} · {formatDate(pr.created_at)}

@@ -131,13 +177,75 @@ {/each}
{:else} -

No open pull requests

+

No open pull requests

+ {/if} +
+ +
+
+

+ Branch workspaces +

+
{ + event.preventDefault(); + void createWorkspace(project); + }} + > + + +
+
+ {#if workspaceError} + + {/if} + {#if projectWorkspaces.length} + + {:else} +

No branch workspaces yet.

{/if}
-

Recent Commits

+

Recent Commits

{#if recent_commits.length > 0}
{#each recent_commits as commit (commit.sha)} @@ -145,10 +253,10 @@ href={commit.url} target="_blank" rel="noopener noreferrer" - class="block border border-term-border bg-term-bg p-3 hover:border-term-accent" + class="border-term-border bg-term-bg hover:border-term-accent block border p-3" > -

{commit.message.split('\n')[0]}

-
+

{commit.message.split('\n')[0]}

+
{commit.author} · {formatDate(commit.date)} @@ -159,24 +267,26 @@ {/each}
{:else} -

No recent commits

+

No recent commits

{/if}
-

Sessions

+

Sessions

{#if projectSessions.length > 0}
{:else}
-

Loading project...

+

Loading project...

{/if}
diff --git a/frontend/src/routes/workspaces/[id]/+page.svelte b/frontend/src/routes/workspaces/[id]/+page.svelte index 2aea9af..0eea691 100644 --- a/frontend/src/routes/workspaces/[id]/+page.svelte +++ b/frontend/src/routes/workspaces/[id]/+page.svelte @@ -1,12 +1,13 @@ - - diff --git a/spikes/substrate-workspace-adapter/image/vite-fixture/main.js b/spikes/substrate-workspace-adapter/image/vite-fixture/main.js deleted file mode 100644 index 7183e38..0000000 --- a/spikes/substrate-workspace-adapter/image/vite-fixture/main.js +++ /dev/null @@ -1,4 +0,0 @@ -document.getElementById('label').textContent = 'Preview one'; -if (import.meta.hot) { - import.meta.hot.accept(); -} diff --git a/spikes/substrate-workspace-adapter/image/vite-fixture/package.json b/spikes/substrate-workspace-adapter/image/vite-fixture/package.json deleted file mode 100644 index 4dc9408..0000000 --- a/spikes/substrate-workspace-adapter/image/vite-fixture/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "mainloop-preview-fixture", - "private": true, - "type": "module", - "scripts": { - "dev": "vite" - }, - "devDependencies": { - "vite": "7.3.1" - } -} diff --git a/spikes/substrate-workspace-adapter/image/vite-fixture/vite.config.js b/spikes/substrate-workspace-adapter/image/vite-fixture/vite.config.js deleted file mode 100644 index 5ae1260..0000000 --- a/spikes/substrate-workspace-adapter/image/vite-fixture/vite.config.js +++ /dev/null @@ -1,24 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({ - server: { - host: '0.0.0.0', - port: 80, - strictPort: true, - allowedHosts: true, - // No hmr.clientPort override: the browser reaches this actor through a proxy whose port - // varies by deployment (port-forward, ingress, ...). Vite infers the HMR client's port from - // window.location by default, which is correct for same-origin proxying (our NGINX - // header-proxy) and was the actual bug the first time this was set to the actor's internal - // port 80 -- the browser tried to open a WebSocket to its own port 80, not the proxy's port. - // Measured live: the actual variable was NOT inotify-vs-polling (the default inotify watch - // picks up an atomic rename-replace write, e.g. `sed -i`, correctly, with polling enabled or - // not). It was the write method -- a plain shell-redirect truncate-in-place write (`cmd > - // file`) was never observed by Vite's watcher on this gVisor-sandboxed filesystem, with or - // without polling, while an atomic rename-replace write (`sed -i`, or any editor/tool that - // writes-then-renames, which is how most real editors and Node's own atomic-write helpers - // behave) was picked up every time and produced a true HMR update, not a reload. usePolling - // is left enabled here only as defense in depth; it was not the fix. - watch: { usePolling: true, interval: 300 } - } -}); diff --git a/spikes/substrate-workspace-adapter/k8s/dev-service-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/dev-service-gate-template.yaml.tmpl deleted file mode 100644 index 6b47103..0000000 --- a/spikes/substrate-workspace-adapter/k8s/dev-service-gate-template.yaml.tmpl +++ /dev/null @@ -1,47 +0,0 @@ -# WorkerPool + ActorTemplate for the dev-service gate measurement (gate 4 in -# .tasknotes/plan.md): a real psql client, driven by the same generic exec shim as the -# preview-gate template, to test egress connectivity to an external PostgreSQL Service. See -# spikes/substrate-workspace-adapter/dev-service-image/. -apiVersion: v1 -kind: Namespace -metadata: - name: ${ATESPACE} ---- -apiVersion: ate.dev/v1alpha1 -kind: WorkerPool -metadata: - name: dev-service-gate - namespace: ${ATESPACE} - labels: - workload: dev-service-gate -spec: - replicas: 1 - workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor - template: - resources: - limits: { cpu: '1', memory: 1Gi } - requests: { cpu: 250m, memory: 1Gi } ---- -metadata: - atespace: ${ATESPACE} - name: dev-service-gate -workerSelector: - matchLabels: - workload: dev-service-gate -containers: -- name: dev-service - image: __IMAGE__ - env: - - { name: HOME, value: /home/agent } - - { name: HERDR_SESSION, value: mainloop-dev-service } - resources: - limits: - - { name: cpu, quantity: "1" } - - { name: memory, quantity: 1Gi } -snapshotsConfig: - onPause: SNAPSHOT_CONTENT_SCOPE_FULL - onCommit: SNAPSHOT_CONTENT_SCOPE_FULL - storageLocation: gs://${BUCKET_NAME}/dev-service-gate/ -sandboxConfig: - sandboxClass: SANDBOX_CLASS_GVISOR - configName: gvisor-default diff --git a/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl deleted file mode 100644 index 68c9ae2..0000000 --- a/spikes/substrate-workspace-adapter/k8s/preview-gate-template.yaml.tmpl +++ /dev/null @@ -1,56 +0,0 @@ -# WorkerPool + ActorTemplate for the preview/HMR gate measurement (gate 3 in .tasknotes/plan.md): -# a real Vite dev server plus a generic exec shim standing in for a credentialed native agent's -# Bash tool (see spikes/substrate-workspace-adapter/image/). No durable volume: this template -# relies on SNAPSHOT_CONTENT_SCOPE_FULL to preserve /work (including node_modules and any edits) -# across suspend/resume, which is what the gate is actually measuring. -apiVersion: v1 -kind: Namespace -metadata: - name: ${ATESPACE} ---- -apiVersion: ate.dev/v1alpha1 -kind: WorkerPool -metadata: - name: ${TEMPLATE_NAME} - namespace: ${ATESPACE} - labels: - workload: ${TEMPLATE_NAME} -spec: - replicas: 1 - workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor - template: - resources: - limits: - cpu: '1' - memory: 1Gi - requests: - cpu: 250m - memory: 1Gi ---- -metadata: - atespace: ${ATESPACE} - # ActorTemplates are immutable; use a new name after the old image failed warmup. - name: ${TEMPLATE_NAME} -workerSelector: - matchLabels: - workload: ${TEMPLATE_NAME} -containers: -- name: preview - image: __IMAGE__ - env: - - { name: HOME, value: /home/agent } - - { name: HERDR_SESSION, value: mainloop-preview } - resources: - limits: - - { name: cpu, quantity: "1" } - - { name: memory, quantity: 1Gi } - readyz: - httpGet: { path: /, port: 80 } - timeoutSeconds: 60 -snapshotsConfig: - onPause: SNAPSHOT_CONTENT_SCOPE_FULL - onCommit: SNAPSHOT_CONTENT_SCOPE_FULL - storageLocation: gs://${BUCKET_NAME}/preview-gate/ -sandboxConfig: - sandboxClass: SANDBOX_CLASS_GVISOR - configName: gvisor-default diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop b/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop index 03b0ca0..36d7a68 100755 --- a/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop @@ -1,8 +1,8 @@ #!/usr/bin/env bash # shellcheck disable=SC2312,SC2310,SC2311,SC2249 # pedantic optional checks; spike scripts # `mainloop`: the agents' thin client for the Mainloop control plane. It holds no policy. -# Identity is the per-binding token in .mainloop/token (found by walking up from $PWD, written -# by `agentctl start`); the server decides what this token may do and answers in plain text. +# Identity is the per-binding token in .mainloop/token; the server decides what this token may +# do and answers in plain text. set -u API="${MAINLOOP_API:-http://mainloop-backend.mainloop.svc.cluster.local:8000}" From 6f44fee4a84daf4c0f39e1d2797ff0ba44487b37 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:18:01 +0000 Subject: [PATCH 26/30] fix(preview): authenticate preview owners and bound proxying Take preview identity only from a configured trusted-ingress header, or from an explicit local development mode used by the kind overlay, and reject anonymous, forged and wrong-owner HTTP and WebSocket requests. Retry only failures that precede forwarding, keep long-lived WebSocket and streaming traffic counted as workspace activity, and bound upstream chunk reads and stalled reads. Seed pre-created empty credential Secrets once from configured files, distinguishing them from rejected or expired credentials. Cover each case with fake-backed tests and update the specs. --- .../src/mainloop/runtime/credential_broker.py | 45 +- backend/src/mainloop/runtime/preview_proxy.py | 220 ++++++-- .../tests/runtime/test_credential_broker.py | 122 ++++- .../tests/runtime/test_credential_reauth.py | 15 + backend/tests/runtime/test_preview_proxy.py | 475 ++++++++++++++++++ docs/specs/credentials.md | 8 +- docs/specs/workspaces.md | 15 +- .../overlays/substrate-preview/configmap.yaml | 2 + 8 files changed, 841 insertions(+), 61 deletions(-) diff --git a/backend/src/mainloop/runtime/credential_broker.py b/backend/src/mainloop/runtime/credential_broker.py index bc74aa0..bb46826 100644 --- a/backend/src/mainloop/runtime/credential_broker.py +++ b/backend/src/mainloop/runtime/credential_broker.py @@ -26,6 +26,7 @@ _MAX_CODEX_AUTH_BYTES = 256 * 1024 _MAX_CLAUDE_TOKEN_BYTES = 16 * 1024 _DNS_LABEL = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$") +_SEED_LOCKS: dict[tuple[int, str, str], asyncio.Lock] = {} class CredentialBrokerError(RuntimeError): @@ -42,12 +43,17 @@ def __init__(self, provider: str): super().__init__(f"{provider.title()} needs sign-in") +class CredentialSecretMissing(CredentialBrokerError): + """The Secret must be created through GitOps before it can be seeded.""" + + @dataclass(frozen=True, slots=True) class CredentialStatus: provider: str available: bool needs_signin: bool expires_at: datetime | None = None + state: str = "needs_signin" class CredentialSecretStore(Protocol): @@ -173,6 +179,22 @@ async def _publish(self, provider: str, values: Mapping[str, str]) -> None: raise CredentialBrokerError("credential Secret update failed") from exc async def seed_configured(self, provider: str) -> CredentialStatus: + lock_key = ( + id(asyncio.get_running_loop()), + self.namespace, + self.secret_name(provider), + ) + lock = _SEED_LOCKS.setdefault(lock_key, asyncio.Lock()) + async with lock: + return await self._seed_configured_once(provider) + + async def _seed_configured_once(self, provider: str) -> CredentialStatus: + existing = await self._read(provider) + if existing is None: + raise CredentialSecretMissing("credential Secret is not pre-created") + if existing: + # Never replace an existing rejected, expired or valid credential from a file. + return self._status_from_values(provider, existing) if provider == "codex": path = self.codex_auth_path if not path: @@ -191,6 +213,8 @@ async def seed_configured(self, provider: str) -> CredentialStatus: else: raise ValueError("unsupported credential provider") + # An empty data map is the sole uninitialized state. A second sequential call sees + # the published values above and leaves them untouched. await self._publish(provider, values) return self._status_from_values(provider, values) @@ -277,15 +301,19 @@ def _codex_secret_values(auth: object, raw: bytes) -> dict[str, str]: def _status_from_values( provider: str, values: Mapping[str, str] ) -> CredentialStatus: + if not values: + return CredentialStatus(provider, False, True, state="uninitialized") available = bool(values.get("injection-value")) needs_signin = values.get("needs-signin") == "true" or not available expires_at = None + state = "available" if available else "needs_signin" raw_expiry = values.get("expires-at") if raw_expiry: try: expires_at = datetime.fromtimestamp(int(raw_expiry), UTC) except (ValueError, OverflowError, OSError): needs_signin = True + state = "rejected" if ( provider == "codex" and expires_at is not None @@ -293,18 +321,27 @@ def _status_from_values( <= datetime.now(UTC).timestamp() + _JWT_EXPIRY_MARGIN_SECONDS ): needs_signin = True + state = "expired" + elif values.get("needs-signin") == "true": + state = "rejected" return CredentialStatus( - provider, available and not needs_signin, needs_signin, expires_at + provider, + available and not needs_signin, + needs_signin, + expires_at, + state, ) async def status(self, provider: str) -> CredentialStatus: values = await self._read(provider) if values is None: + return CredentialStatus(provider, False, True, state="missing") + if not values: path = ( self.codex_auth_path if provider == "codex" else self.claude_token_path ) if not path: - return CredentialStatus(provider, False, True) + return CredentialStatus(provider, False, True, state="uninitialized") return await self.seed_configured(provider) status = self._status_from_values(provider, values) if provider == "codex" and status.needs_signin: @@ -314,9 +351,11 @@ async def status(self, provider: str) -> CredentialStatus: async def codex_placeholder_auth(self) -> str: values = await self._read("codex") if values is None: + raise CredentialNeedsSignin("codex") + if not values: await self.seed_configured("codex") values = await self._read("codex") - if values is None: + if not values: raise CredentialNeedsSignin("codex") status = self._status_from_values("codex", values) if not status.available or status.needs_signin: diff --git a/backend/src/mainloop/runtime/preview_proxy.py b/backend/src/mainloop/runtime/preview_proxy.py index 2e065c2..8abac10 100644 --- a/backend/src/mainloop/runtime/preview_proxy.py +++ b/backend/src/mainloop/runtime/preview_proxy.py @@ -4,10 +4,12 @@ import asyncio import base64 +import contextlib import hashlib import http.client import io import json +import logging import os import re import socket @@ -29,7 +31,11 @@ _MAX_REQUEST_BYTES = 10 * 1024 * 1024 _MAX_HEADER_BYTES = 64 * 1024 _MAX_WEBSOCKET_MESSAGE_BYTES = 16 * 1024 * 1024 +_PREVIEW_TOUCH_INTERVAL_SECONDS = 20.0 +_RESPONSE_READ_BLOCK_BYTES = 64 * 1024 _SHIM_PORT = 8090 +_LOGGER = logging.getLogger(__name__) +_ACTIVE_PREVIEW_LEASES: dict[str, tuple[asyncio.Task[None], int]] = {} _HOP_HEADERS = { "connection", "keep-alive", @@ -67,6 +73,14 @@ class PreviewTarget: observed_state: str = "unknown" +class _PreviewPreForwardFailure(ConnectionError): + """The actor request was not sent, so retrying cannot duplicate application work.""" + + +class _PreviewForwardedFailure(ConnectionError): + """The actor request may have been applied, so its outcome is uncertain.""" + + @dataclass(slots=True) class _UpstreamHTTP: sock: socket.socket @@ -242,6 +256,59 @@ async def _touch_preview(workspace_id: str) -> None: await workspace_adapter.touch_workspace(workspace_id, reason="preview") +def _preview_user_id(headers) -> str | None: + """Use only an explicitly trusted ingress identity or the Kind-only local identity.""" + if os.environ.get("SUBSTRATE_PREVIEW_LOCAL_DEV_MODE", "").strip().lower() in { + "1", + "true", + "yes", + }: + return "local-dev-user" + if os.environ.get("SUBSTRATE_PREVIEW_TRUSTED_INGRESS", "").strip().lower() not in { + "1", + "true", + "yes", + }: + return None + value = headers.get("cf-access-authenticated-user-email", "").strip() + return value or None + + +async def _keep_preview_awake(workspace_id: str) -> None: + while True: + await asyncio.sleep(_PREVIEW_TOUCH_INTERVAL_SECONDS) + try: + await _touch_preview(workspace_id) + except Exception as exc: + # Activity accounting must not terminate an otherwise healthy preview stream. + _LOGGER.warning( + "preview activity touch failed for workspace %s (%s)", + workspace_id, + type(exc).__name__, + ) + + +@contextlib.asynccontextmanager +async def _preview_activity_lease(workspace_id: str): + lease = _ACTIVE_PREVIEW_LEASES.get(workspace_id) + if lease is None: + task = asyncio.create_task(_keep_preview_awake(workspace_id)) + _ACTIVE_PREVIEW_LEASES[workspace_id] = (task, 1) + else: + task, count = lease + _ACTIVE_PREVIEW_LEASES[workspace_id] = (task, count + 1) + try: + yield + finally: + current_task, count = _ACTIVE_PREVIEW_LEASES[workspace_id] + if count == 1: + del _ACTIVE_PREVIEW_LEASES[workspace_id] + current_task.cancel() + await asyncio.gather(current_task, return_exceptions=True) + else: + _ACTIVE_PREVIEW_LEASES[workspace_id] = (current_task, count - 1) + + def _read_head(reader) -> tuple[int, http.client.HTTPMessage]: status_line = reader.readline(8192) if not status_line.endswith(b"\r\n"): @@ -269,12 +336,15 @@ def _connect_router( router = urlsplit(settings.substrate_router_address) if router.scheme != "http" or not router.hostname: raise ValueError("preview router address must be an HTTP origin") - sock = socket.create_connection( - (router.hostname, router.port or 80), timeout=timeout - ) - sock.settimeout(timeout) - reader = sock.makefile("rb") + sock = None + reader = None + forwarding_started = False try: + sock = socket.create_connection( + (router.hostname, router.port or 80), timeout=timeout + ) + sock.settimeout(timeout) + reader = sock.makefile("rb") upstream_host = f"actor-upstream:{port}" connect_request = ( f"CONNECT {upstream_host} HTTP/1.1\r\n" @@ -285,7 +355,9 @@ def _connect_router( sock.sendall(connect_request) connect_status, _ = _read_head(reader) if connect_status != 200: - raise ConnectionError(f"router CONNECT returned HTTP {connect_status}") + raise _PreviewPreForwardFailure( + f"router CONNECT returned HTTP {connect_status}" + ) request_headers = [ (name, value) for name, value in headers @@ -303,14 +375,26 @@ def _connect_router( + "".join(f"{name}: {value}\r\n" for name, value in request_headers) + "\r\n" ) + # A failing sendall may still have transmitted a prefix of a mutating request. + forwarding_started = True sock.sendall(wire.encode("latin1") + body) status, response_headers = _read_head(reader) - sock.settimeout(None) return _UpstreamHTTP(sock, reader, status, response_headers) - except Exception: - reader.close() - sock.close() - raise + except Exception as exc: + if reader is not None: + with contextlib.suppress(Exception): + reader.close() + if sock is not None: + with contextlib.suppress(Exception): + sock.close() + if isinstance(exc, (_PreviewPreForwardFailure, _PreviewForwardedFailure)): + raise + failure = ( + _PreviewForwardedFailure + if forwarding_started + else _PreviewPreForwardFailure + ) + raise failure("preview router connection failed") from exc def _response_headers(headers: http.client.HTTPMessage) -> list[tuple[str, str]]: @@ -334,34 +418,76 @@ def _response_body(reader, headers: http.client.HTTPMessage, no_body: bool): if "chunked" in transfer: while True: line = reader.readline(8192) - if not line: - return - size = int(line.split(b";", 1)[0].strip(), 16) + if not line or len(line) >= 8192 or not line.endswith(b"\r\n"): + raise ValueError("invalid upstream chunk header") + raw_size = line.split(b";", 1)[0].strip() + if not re.fullmatch(rb"[0-9a-fA-F]+", raw_size): + raise ValueError("invalid upstream chunk size") + size = int(raw_size, 16) if size == 0: - while reader.readline(8192) not in (b"\r\n", b"\n", b""): - pass + trailer_bytes = 0 + while True: + trailer = reader.readline(8192) + trailer_bytes += len(trailer) + if not trailer or trailer_bytes > _MAX_HEADER_BYTES: + raise ValueError("invalid upstream chunk trailers") + if trailer in (b"\r\n", b"\n"): + break return - chunk = reader.read(size) - if len(chunk) != size or reader.read(2) != b"\r\n": + remaining = size + while remaining: + chunk = reader.read(min(_RESPONSE_READ_BLOCK_BYTES, remaining)) + if not chunk: + raise ValueError("truncated upstream response") + remaining -= len(chunk) + yield chunk + if reader.read(2) != b"\r\n": raise ValueError("truncated upstream response") - yield chunk length = headers.get("content-length") if length is not None: + if not re.fullmatch(r"[0-9]+", length.strip()): + raise ValueError("invalid upstream content length") remaining = int(length) while remaining: - chunk = reader.read(min(64 * 1024, remaining)) + chunk = reader.read(min(_RESPONSE_READ_BLOCK_BYTES, remaining)) if not chunk: raise ValueError("truncated upstream response") remaining -= len(chunk) yield chunk return while True: - chunk = reader.read(64 * 1024) + chunk = reader.read(_RESPONSE_READ_BLOCK_BYTES) if not chunk: return yield chunk +def _next_response_chunk(iterator) -> tuple[bool, bytes]: + try: + return True, next(iterator) + except StopIteration: + return False, b"" + + +async def _stream_response_body( + upstream: _UpstreamHTTP, + workspace_id: str, + no_body: bool, +): + iterator = iter(_response_body(upstream.reader, upstream.headers, no_body)) + try: + async with _preview_activity_lease(workspace_id): + while True: + available, chunk = await asyncio.to_thread( + _next_response_chunk, iterator + ) + if not available: + return + yield chunk + finally: + upstream.close() + + def _waking_page() -> HTMLResponse: return HTMLResponse( "Workspace waking up" @@ -373,11 +499,9 @@ def _waking_page() -> HTMLResponse: async def _preview_http(request: Request, parsed: PreviewHost) -> Response: - user_id = ( - request.headers.get("x-user-id") - or request.headers.get("cf-access-authenticated-user-email") - or "local-dev-user" - ) + user_id = _preview_user_id(request.headers) + if user_id is None: + return Response("Authentication required", status_code=401) target = await _resolve_target(parsed.workspace_id, user_id) if target is None: return Response("Workspace not found", status_code=404) @@ -416,28 +540,24 @@ async def _preview_http(request: Request, parsed: PreviewHost) -> Response: headers=headers, body=body, ) - if upstream.status not in (502, 503) or attempt == 1: - break - upstream.close() - upstream = None - except Exception: + break + except _PreviewPreForwardFailure: if attempt == 1: return _waking_page() + except _PreviewForwardedFailure: + return Response( + "Preview request was forwarded but its outcome is unknown", + status_code=502, + headers={"cache-control": "no-store"}, + ) + except Exception: + return Response("Preview router is unavailable", status_code=502) if upstream is None: return _waking_page() - if upstream.status in (502, 503): - upstream.close() - return _waking_page() no_body = request.method == "HEAD" or upstream.status in (204, 304) - def stream_body(): - try: - yield from _response_body(upstream.reader, upstream.headers, no_body) - finally: - upstream.close() - response = StreamingResponse( - stream_body(), + _stream_response_body(upstream, parsed.workspace_id, no_body), status_code=upstream.status, media_type=None, ) @@ -584,11 +704,10 @@ async def actor_to_downstream() -> None: async def _preview_websocket(websocket: WebSocket, parsed: PreviewHost) -> None: - user_id = ( - websocket.headers.get("x-user-id") - or websocket.headers.get("cf-access-authenticated-user-email") - or "local-dev-user" - ) + user_id = _preview_user_id(websocket.headers) + if user_id is None: + await websocket.close(code=4401, reason="Authentication required") + return target = await _resolve_target(parsed.workspace_id, user_id) if target is None: await websocket.close(code=4404, reason="Workspace not found") @@ -623,6 +742,7 @@ async def _preview_websocket(websocket: WebSocket, parsed: PreviewHost) -> None: for attempt in range(2): writer = None + forwarding_started = False try: reader, writer = await asyncio.wait_for( asyncio.open_connection(router.hostname, router.port or 80), timeout @@ -681,14 +801,13 @@ async def _preview_websocket(websocket: WebSocket, parsed: PreviewHost) -> None: + "".join(f"{name}: {value}\r\n" for name, value in handshake_headers) + "\r\n" ) + forwarding_started = True writer.write(wire.encode("latin1")) await writer.drain() status, headers = await asyncio.wait_for(_read_async_head(reader), timeout) if status != 101: writer.close() await writer.wait_closed() - if attempt == 0 and status in (502, 503): - continue break expected = base64.b64encode( hashlib.sha1( @@ -702,7 +821,8 @@ async def _preview_websocket(websocket: WebSocket, parsed: PreviewHost) -> None: if protocol and protocol not in offered_protocols: raise ValueError("upstream selected an unoffered websocket protocol") await websocket.accept(subprotocol=protocol) - await _relay_websocket(websocket, reader, writer) + async with _preview_activity_lease(parsed.workspace_id): + await _relay_websocket(websocket, reader, writer) return except Exception: if writer is not None: @@ -711,7 +831,7 @@ async def _preview_websocket(websocket: WebSocket, parsed: PreviewHost) -> None: await writer.wait_closed() except OSError: pass - if attempt == 1: + if forwarding_started or attempt == 1: break await websocket.close(code=1013, reason="Workspace waking up; retry shortly") diff --git a/backend/tests/runtime/test_credential_broker.py b/backend/tests/runtime/test_credential_broker.py index 558f02c..617bad5 100644 --- a/backend/tests/runtime/test_credential_broker.py +++ b/backend/tests/runtime/test_credential_broker.py @@ -9,13 +9,21 @@ import unittest from datetime import UTC, datetime, timedelta from pathlib import Path +from unittest.mock import patch from mainloop.runtime.credential_broker import ( CredentialBroker, CredentialNeedsSignin, + CredentialSecretMissing, ) +async def _run_sync_in_test(function, *args, **kwargs): + """Keep fake Secret and temporary-file operations deterministic in tests.""" + await asyncio.sleep(0) + return function(*args, **kwargs) + + def jwt(expiry: int) -> str: payload = ( base64.urlsafe_b64encode( @@ -30,16 +38,32 @@ def jwt(expiry: int) -> str: class MemorySecretStore: def __init__(self): self.values: dict[tuple[str, str], dict[str, str]] = {} + self.publish_count = 0 + + def precreate(self, namespace: str, name: str): + self.values[(namespace, name)] = {} def read(self, namespace: str, name: str): value = self.values.get((namespace, name)) return dict(value) if value is not None else None def publish(self, namespace: str, name: str, values): + if (namespace, name) not in self.values: + raise AssertionError("credential Secret must be pre-created") self.values[(namespace, name)] = dict(values) + self.publish_count += 1 class CredentialBrokerTests(unittest.TestCase): + def setUp(self): + self._to_thread_patch = patch.object( + asyncio, "to_thread", new=_run_sync_in_test + ) + self._to_thread_patch.start() + + def tearDown(self): + self._to_thread_patch.stop() + def test_codex_seed_publishes_access_token_and_delivers_only_synthetic_auth(self): async def exercise(root: Path): store = MemorySecretStore() @@ -65,11 +89,17 @@ async def exercise(root: Path): namespace="test-control", account="fixture-owner", ) + store.precreate("test-control", broker.secret_name("codex")) - status = await broker.seed_configured("codex") + statuses = await asyncio.gather( + broker.status("codex"), broker.status("codex") + ) + status = statuses[0] self.assertTrue(status.available) self.assertFalse(status.needs_signin) self.assertEqual(status.expires_at.tzinfo, UTC) + self.assertEqual(status.state, "available") + self.assertEqual(store.publish_count, 1) secret = store.read("test-control", broker.secret_name("codex")) self.assertEqual(secret["injection-value"], source_access) @@ -100,12 +130,33 @@ async def exercise(root: Path): path = root / "auth.json" path.write_text(json.dumps(source), encoding="utf-8") broker = CredentialBroker(store=store, codex_auth_path=str(path)) + store.precreate("mainloop-control", broker.secret_name("codex")) status = await broker.seed_configured("codex") self.assertFalse(status.available) self.assertTrue(status.needs_signin) + self.assertEqual(status.state, "expired") values = store.read("mainloop-control", broker.secret_name("codex")) self.assertEqual(values["injection-value"], "") + + valid_expiry = int((datetime.now(UTC) + timedelta(hours=1)).timestamp()) + path.write_text( + json.dumps( + { + "tokens": { + "id_token": jwt(valid_expiry), + "access_token": jwt(valid_expiry), + "refresh_token": "new-synthetic-refresh", + "account_id": "fixture-account-expired", + }, + "last_refresh": datetime.now(UTC).isoformat(), + } + ), + encoding="utf-8", + ) + status = await broker.status("codex") + self.assertEqual(status.state, "expired") + self.assertEqual(store.publish_count, 1) with self.assertRaises(CredentialNeedsSignin) as raised: await broker.codex_placeholder_auth() self.assertEqual(str(raised.exception), "Codex needs sign-in") @@ -130,6 +181,7 @@ async def exercise(root: Path): path = root / "auth.json" path.write_text(json.dumps(source), encoding="utf-8") broker = CredentialBroker(store=store, codex_auth_path=str(path)) + store.precreate("mainloop-control", broker.secret_name("codex")) await broker.seed_configured("codex") name = broker.secret_name("codex") values = store.read("mainloop-control", name) @@ -155,15 +207,18 @@ async def exercise(root: Path): path = root / "claude-token" path.write_text("synthetic-claude-token\n", encoding="utf-8") broker = CredentialBroker(store=store, claude_token_path=str(path)) - status = await broker.seed_configured("claude") + store.precreate("mainloop-control", broker.secret_name("claude")) + status = await broker.status("claude") self.assertTrue(status.available) self.assertFalse(status.needs_signin) values = store.read("mainloop-control", broker.secret_name("claude")) self.assertEqual(values["injection-value"], "synthetic-claude-token") + missing_store = MemorySecretStore() missing = CredentialBroker( - store=store, claude_token_path=str(root / "missing-token") + store=missing_store, claude_token_path=str(root / "missing-token") ) + missing_store.precreate("mainloop-control", missing.secret_name("claude")) with self.assertRaises(CredentialNeedsSignin) as raised: await missing.seed_configured("claude") self.assertEqual(str(raised.exception), "Claude needs sign-in") @@ -175,6 +230,8 @@ def test_reauth_result_methods_publish_only_valid_synthetic_fixture_values(self) async def exercise(): store = MemorySecretStore() broker = CredentialBroker(store=store) + store.precreate("mainloop-control", broker.secret_name("codex")) + store.precreate("mainloop-control", broker.secret_name("claude")) access = jwt(int((datetime.now(UTC) + timedelta(hours=1)).timestamp())) document = { "auth_mode": "chatgpt", @@ -210,6 +267,65 @@ async def exercise(): asyncio.run(exercise()) + def test_missing_secret_does_not_attempt_file_seeding(self): + async def exercise(root: Path): + store = MemorySecretStore() + path = root / "auth.json" + expiry = int((datetime.now(UTC) + timedelta(hours=1)).timestamp()) + path.write_text( + json.dumps( + { + "tokens": { + "id_token": jwt(expiry), + "access_token": jwt(expiry), + "refresh_token": "synthetic-refresh-fixture", + "account_id": "fixture-account-missing-secret", + }, + "last_refresh": datetime.now(UTC).isoformat(), + } + ), + encoding="utf-8", + ) + broker = CredentialBroker(store=store, codex_auth_path=str(path)) + + status = await broker.status("codex") + self.assertEqual(status.state, "missing") + self.assertFalse(status.available) + self.assertEqual(store.values, {}) + self.assertEqual(store.publish_count, 0) + + with self.assertRaises(CredentialSecretMissing): + await broker.seed_configured("codex") + self.assertEqual(store.publish_count, 0) + + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + + def test_rejected_secret_is_not_replaced_from_a_configured_file(self): + async def exercise(root: Path): + store = MemorySecretStore() + path = root / "claude-token" + path.write_text("synthetic-new-claude-token", encoding="utf-8") + broker = CredentialBroker(store=store, claude_token_path=str(path)) + secret_name = broker.secret_name("claude") + store.values[("mainloop-control", secret_name)] = { + "oauth-token": "synthetic-rejected-token", + "injection-value": "", + "needs-signin": "true", + } + + status = await broker.status("claude") + self.assertEqual(status.state, "rejected") + self.assertFalse(status.available) + self.assertEqual( + store.read("mainloop-control", secret_name)["oauth-token"], + "synthetic-rejected-token", + ) + self.assertEqual(store.publish_count, 0) + + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/runtime/test_credential_reauth.py b/backend/tests/runtime/test_credential_reauth.py index c5d0b20..436e276 100644 --- a/backend/tests/runtime/test_credential_reauth.py +++ b/backend/tests/runtime/test_credential_reauth.py @@ -17,7 +17,22 @@ from starlette.requests import Request +async def _run_sync_in_test(function, *args, **kwargs): + """Keep fake Kubernetes API calls deterministic in tests.""" + await asyncio.sleep(0) + return function(*args, **kwargs) + + class CredentialReauthTests(unittest.TestCase): + def setUp(self): + self._to_thread_patch = patch.object( + asyncio, "to_thread", new=_run_sync_in_test + ) + self._to_thread_patch.start() + + def tearDown(self): + self._to_thread_patch.stop() + def test_fake_runner_keeps_callback_token_private_and_completes(self): async def exercise(): runner = FakeCredentialReauthRunner() diff --git a/backend/tests/runtime/test_preview_proxy.py b/backend/tests/runtime/test_preview_proxy.py index 920657a..0a98355 100644 --- a/backend/tests/runtime/test_preview_proxy.py +++ b/backend/tests/runtime/test_preview_proxy.py @@ -3,12 +3,86 @@ from __future__ import annotations import asyncio +import http.client import io +import os +import socket import unittest from unittest.mock import AsyncMock, patch from mainloop.config import settings from mainloop.runtime import preview_proxy +from starlette.requests import Request +from starlette.websockets import WebSocket + +_PREVIEW_HOST = "5173--workspace-1.preview.localhost:8001" + + +def make_request(method: str, headers: dict[str, str], body: bytes = b"") -> Request: + raw_headers = [ + (key.lower().encode(), value.encode()) for key, value in headers.items() + ] + + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + return Request( + { + "type": "http", + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "root_path": "", + "headers": raw_headers, + "server": ("preview.localhost", 8001), + "client": ("127.0.0.1", 12345), + }, + receive, + ) + + +def make_websocket(headers: dict[str, str]) -> tuple[WebSocket, list[dict]]: + raw_headers = [ + (key.lower().encode(), value.encode()) for key, value in headers.items() + ] + sent: list[dict] = [] + + async def receive(): + return {"type": "websocket.disconnect", "code": 1000} + + async def send(message): + sent.append(message) + + return ( + WebSocket( + { + "type": "websocket", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "scheme": "ws", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "root_path": "", + "headers": raw_headers, + "server": ("preview.localhost", 8001), + "client": ("127.0.0.1", 12345), + "subprotocols": [], + }, + receive, + send, + ), + sent, + ) + + +async def _run_sync_in_test(function, *args, **kwargs): + """Keep fake router operations deterministic without a test executor thread.""" + await asyncio.sleep(0) + return function(*args, **kwargs) class PreviewProxyTests(unittest.TestCase): @@ -71,6 +145,295 @@ async def exercise(): asyncio.run(exercise()) + def test_http_preview_requires_trusted_identity_and_ignores_forged_user_id(self): + async def exercise(): + with patch.object( + preview_proxy, "_resolve_target", new_callable=AsyncMock + ) as resolve: + anonymous = await preview_proxy._preview_http( + make_request("GET", {"host": _PREVIEW_HOST}), + preview_proxy.PreviewHost(5173, "workspace-1"), + ) + forged = await preview_proxy._preview_http( + make_request( + "GET", + {"host": _PREVIEW_HOST, "x-user-id": "local-dev-user"}, + ), + preview_proxy.PreviewHost(5173, "workspace-1"), + ) + + self.assertEqual(anonymous.status_code, 401) + self.assertEqual(forged.status_code, 401) + resolve.assert_not_awaited() + + with patch.dict( + os.environ, + { + "SUBSTRATE_PREVIEW_LOCAL_DEV_MODE": "false", + "SUBSTRATE_PREVIEW_TRUSTED_INGRESS": "false", + }, + ): + asyncio.run(exercise()) + + def test_http_preview_uses_trusted_identity_for_workspace_ownership(self): + async def exercise(): + with patch.object( + preview_proxy, "_resolve_target", new=AsyncMock(return_value=None) + ) as resolve: + response = await preview_proxy._preview_http( + make_request( + "GET", + { + "host": _PREVIEW_HOST, + "x-user-id": "workspace-owner", + "cf-access-authenticated-user-email": "other-owner", + }, + ), + preview_proxy.PreviewHost(5173, "workspace-1"), + ) + + self.assertEqual(response.status_code, 404) + resolve.assert_awaited_once_with("workspace-1", "other-owner") + + with patch.dict( + os.environ, + { + "SUBSTRATE_PREVIEW_LOCAL_DEV_MODE": "false", + "SUBSTRATE_PREVIEW_TRUSTED_INGRESS": "true", + }, + ): + asyncio.run(exercise()) + + def test_websocket_preview_requires_trusted_identity_and_checks_owner(self): + async def exercise_anonymous(): + with patch.object( + preview_proxy, "_resolve_target", new_callable=AsyncMock + ) as resolve: + for headers in ( + {"host": _PREVIEW_HOST}, + {"host": _PREVIEW_HOST, "x-user-id": "local-dev-user"}, + ): + websocket, sent = make_websocket(headers) + await preview_proxy._preview_websocket( + websocket, preview_proxy.PreviewHost(5173, "workspace-1") + ) + self.assertEqual(sent[0]["type"], "websocket.close") + self.assertEqual(sent[0]["code"], 4401) + resolve.assert_not_awaited() + + async def exercise_wrong_owner(): + with patch.object( + preview_proxy, "_resolve_target", new=AsyncMock(return_value=None) + ) as resolve: + websocket, sent = make_websocket( + { + "host": _PREVIEW_HOST, + "x-user-id": "workspace-owner", + "cf-access-authenticated-user-email": "other-owner", + } + ) + await preview_proxy._preview_websocket( + websocket, preview_proxy.PreviewHost(5173, "workspace-1") + ) + self.assertEqual(sent[0]["type"], "websocket.close") + self.assertEqual(sent[0]["code"], 4404) + resolve.assert_awaited_once_with("workspace-1", "other-owner") + + with patch.dict( + os.environ, + { + "SUBSTRATE_PREVIEW_LOCAL_DEV_MODE": "false", + "SUBSTRATE_PREVIEW_TRUSTED_INGRESS": "false", + }, + ): + asyncio.run(exercise_anonymous()) + with patch.dict( + os.environ, + { + "SUBSTRATE_PREVIEW_LOCAL_DEV_MODE": "false", + "SUBSTRATE_PREVIEW_TRUSTED_INGRESS": "true", + }, + ): + asyncio.run(exercise_wrong_owner()) + + def test_post_that_commits_then_disconnects_is_not_replayed(self): + target = preview_proxy.PreviewTarget( + workspace_id="workspace-1", + user_id="local-dev-user", + atespace="fixture-space", + actor="fixture-actor", + agent="claude", + shim_secret_name="shim" + "-fixture", + manifest={"dev": {"ports": [{"name": "web", "number": 5173}]}}, + observed_state="running", + ) + committed: list[bytes] = [] + + class DisconnectingReader: + def __init__(self): + self._lines = iter( + (b"HTTP/1.1 200 Connection Established\r\n", b"\r\n") + ) + self.closed = False + + def readline(self, _size=-1): + try: + return next(self._lines) + except StopIteration as exc: + raise socket.timeout("fake upstream disconnected") from exc + + def close(self): + self.closed = True + + class CommitThenDisconnectSocket: + def __init__(self): + self.reader = DisconnectingReader() + self.sent: list[bytes] = [] + self.closed = False + + def settimeout(self, _timeout): + pass + + def makefile(self, _mode): + return self.reader + + def sendall(self, payload): + self.sent.append(payload) + if payload.startswith(b"POST "): + _headers, body = payload.split(b"\r\n\r\n", 1) + committed.append(body) + + def close(self): + self.closed = True + + fake_upstream = CommitThenDisconnectSocket() + + async def exercise(): + with ( + patch.object(preview_proxy.asyncio, "to_thread", new=_run_sync_in_test), + patch.object( + preview_proxy, "_resolve_target", new=AsyncMock(return_value=target) + ), + patch.object(preview_proxy, "_touch_preview", new=AsyncMock()), + patch.object( + preview_proxy, "_reported_ports", new=AsyncMock(return_value=()) + ), + patch.object( + preview_proxy.socket, + "create_connection", + return_value=fake_upstream, + ), + patch.object( + settings, "substrate_router_address", "http://router.fixture:8081" + ), + ): + response = await preview_proxy._preview_http( + make_request( + "POST", {"host": _PREVIEW_HOST}, b"synthetic mutation" + ), + preview_proxy.PreviewHost(5173, "workspace-1"), + ) + return response + + with patch.dict(os.environ, {"SUBSTRATE_PREVIEW_LOCAL_DEV_MODE": "true"}): + response = asyncio.run(exercise()) + self.assertEqual(response.status_code, 502) + self.assertIn(b"outcome is unknown", response.body) + self.assertEqual(committed, [b"synthetic mutation"]) + self.assertEqual(len(fake_upstream.sent), 2) + self.assertTrue(fake_upstream.reader.closed) + self.assertTrue(fake_upstream.closed) + + def test_router_connect_failure_before_forwarding_is_retried_once(self): + target = preview_proxy.PreviewTarget( + workspace_id="workspace-1", + user_id="local-dev-user", + atespace="fixture-space", + actor="fixture-actor", + agent="claude", + shim_secret_name="shim" + "-fixture", + manifest={"dev": {"ports": [{"name": "web", "number": 5173}]}}, + observed_state="running", + ) + headers = http.client.HTTPMessage() + headers["Content-Length"] = "2" + upstream = preview_proxy._UpstreamHTTP( + sock=type("FakeSocket", (), {"close": lambda _self: None})(), + reader=io.BytesIO(b"ok"), + status=200, + headers=headers, + ) + failures = 0 + + def connect(*_args, **_kwargs): + nonlocal failures + failures += 1 + if failures == 1: + raise preview_proxy._PreviewPreForwardFailure("CONNECT unavailable") + return upstream + + async def exercise(): + with ( + patch.object(preview_proxy.asyncio, "to_thread", new=_run_sync_in_test), + patch.object( + preview_proxy, "_resolve_target", new=AsyncMock(return_value=target) + ), + patch.object(preview_proxy, "_touch_preview", new=AsyncMock()), + patch.object( + preview_proxy, "_reported_ports", new=AsyncMock(return_value=()) + ), + patch.object(preview_proxy, "_connect_router", side_effect=connect), + ): + response = await preview_proxy._preview_http( + make_request("GET", {"host": _PREVIEW_HOST}), + preview_proxy.PreviewHost(5173, "workspace-1"), + ) + body = b"".join([chunk async for chunk in response.body_iterator]) + return response, body + + with patch.dict(os.environ, {"SUBSTRATE_PREVIEW_LOCAL_DEV_MODE": "true"}): + response, body = asyncio.run(exercise()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(body, b"ok") + self.assertEqual(failures, 2) + + def test_activity_lease_refreshes_before_idle_deadline_and_stops_on_close(self): + async def exercise(): + touches: list[float] = [] + + async def touch(_workspace_id: str): + touches.append(asyncio.get_running_loop().time()) + + with ( + patch.object(preview_proxy, "_PREVIEW_TOUCH_INTERVAL_SECONDS", 0.015), + patch.object( + preview_proxy, "_touch_preview", new=AsyncMock(side_effect=touch) + ), + ): + for active_connection in ("websocket", "http-stream"): + async with preview_proxy._preview_activity_lease( + f"workspace-{active_connection}" + ): + await asyncio.sleep(0.07) + self.assertTrue(touches) + now = asyncio.get_running_loop().time() + self.assertLess(now - touches[-1], 0.05) + count_at_close = len(touches) + await asyncio.sleep(0.04) + self.assertEqual(len(touches), count_at_close) + + touches.clear() + async with preview_proxy._preview_activity_lease("shared-workspace"): + async with preview_proxy._preview_activity_lease( + "shared-workspace" + ): + await asyncio.sleep(0.07) + self.assertGreaterEqual(len(touches), 3) + self.assertLessEqual(len(touches), 5) + + asyncio.run(exercise()) + def test_chunked_response_body_is_decoded_for_streaming_response(self): import http.client @@ -82,6 +445,118 @@ def test_chunked_response_body_is_decoded_for_streaming_response(self): [b"Wiki", b"pedia"], ) + def test_large_chunk_is_read_in_bounded_blocks(self): + class TrackingReader(io.BytesIO): + max_requested = 0 + + def read(self, size=-1): + self.max_requested = max(self.max_requested, size) + return super().read(size) + + payload = b"x" * (preview_proxy._RESPONSE_READ_BLOCK_BYTES * 3 + 17) + reader = TrackingReader( + f"{len(payload):X}\r\n".encode() + payload + b"\r\n0\r\n\r\n" + ) + headers = http.client.HTTPMessage() + headers["Transfer-Encoding"] = "chunked" + + chunks = list(preview_proxy._response_body(reader, headers, no_body=False)) + + self.assertEqual(b"".join(chunks), payload) + self.assertGreater(len(chunks), 1) + self.assertLessEqual( + reader.max_requested, preview_proxy._RESPONSE_READ_BLOCK_BYTES + ) + + def test_stalled_response_read_closes_upstream(self): + class StalledReader: + closed = False + + def read(self, _size=-1): + raise socket.timeout("fixture stalled") + + def close(self): + self.closed = True + + class FakeSocket: + closed = False + + def close(self): + self.closed = True + + async def exercise(): + reader = StalledReader() + sock = FakeSocket() + headers = http.client.HTTPMessage() + headers["Content-Length"] = "1" + upstream = preview_proxy._UpstreamHTTP(sock, reader, 200, headers) + body = preview_proxy._stream_response_body(upstream, "workspace-1", False) + with patch.object( + preview_proxy.asyncio, "to_thread", new=_run_sync_in_test + ): + with self.assertRaises(socket.timeout): + await anext(body) + self.assertTrue(reader.closed) + self.assertTrue(sock.closed) + + asyncio.run(exercise()) + + def test_router_socket_read_timeout_remains_set_for_stream_body(self): + class FakeSocket: + def __init__(self): + self.reader = io.BytesIO( + b"HTTP/1.1 200 Connection Established\r\n\r\n" + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" + ) + self.timeout = None + self.sent: list[bytes] = [] + self.closed = False + + def settimeout(self, value): + self.timeout = value + + def makefile(self, _mode): + return self.reader + + def sendall(self, payload): + self.sent.append(payload) + + def close(self): + self.closed = True + + target = preview_proxy.PreviewTarget( + workspace_id="workspace-1", + user_id="owner", + atespace="fixture-space", + actor="fixture-actor", + agent="claude", + shim_secret_name="shim" + "-fixture", + manifest={}, + ) + upstream_socket = FakeSocket() + with ( + patch.object( + settings, "substrate_router_address", "http://router.fixture:8081" + ), + patch.object( + preview_proxy.socket, "create_connection", return_value=upstream_socket + ), + ): + upstream = preview_proxy._connect_router( + target, + 5173, + 2.5, + method="GET", + path="/", + headers=[], + body=b"", + ) + + self.assertEqual(upstream_socket.timeout, 2.5) + self.assertEqual(len(upstream_socket.sent), 2) + upstream.close() + self.assertTrue(upstream_socket.closed) + def test_websocket_frame_parser_handles_masked_and_unmasked_payloads(self): async def exercise(): reader = asyncio.StreamReader() diff --git a/docs/specs/credentials.md b/docs/specs/credentials.md index a80092d..4d19027 100644 --- a/docs/specs/credentials.md +++ b/docs/specs/credentials.md @@ -3,8 +3,12 @@ Mainloop owns one credential set per account and provider. Real Codex `auth.json` and Claude tokens are stored in pre-created Kubernetes Secrets. The backend may seed those Secrets from configured file paths (`SUBSTRATE_CODEX_AUTH_PATH` and `SUBSTRATE_CLAUDE_TOKEN_PATH`); file -contents are never exposed through the API or logs. The egress credential provider consumes -the Secret's `injection-value` key. +contents are never exposed through the API or logs. Seeding applies only to an existing Secret +whose data map is empty, the uninitialized state, and subsequent reads leave the seeded value +alone. A missing Secret is a deployment error; a non-empty rejected or expired credential is +reported as such and is never silently replaced from a file. The owner must complete sign-in to +replace rejected or expired data. The egress credential provider consumes the Secret's +`injection-value` key. ## Actor boundary diff --git a/docs/specs/workspaces.md b/docs/specs/workspaces.md index aab9d92..7f9b380 100644 --- a/docs/specs/workspaces.md +++ b/docs/specs/workspaces.md @@ -53,9 +53,18 @@ from the manifest until the workspace is running. A preview request itself may w Preview hosts have the form `--.preview.`. Mainloop checks workspace ownership and permits only ports declared by the manifest or reported by the authenticated -actor shim. It strips browser cookies and authorization headers before using the router's -CONNECT stream, including WebSocket upgrades. A single bounded retry is used after a wake/connect -failure, followed by a clear waking response. +actor shim. The preview listener accepts identity only from the Cloudflare Access email header +when `SUBSTRATE_PREVIEW_TRUSTED_INGRESS=true`; that ingress must remove any client-supplied copy +before setting its authenticated value. It never treats `X-User-ID` as identity. Without trusted +ingress, requests receive `401` (WebSocket close `4401`). The Kind overlay alone enables +`SUBSTRATE_PREVIEW_LOCAL_DEV_MODE=true`, which uses the fixed local development owner. + +The proxy strips browser cookies, authorization, and identity headers before using the router's +CONNECT stream, including WebSocket upgrades. It retries only a router CONNECT failure before +forwarding the request to the actor. After forwarding, a disconnect has an unknown outcome and +the proxy does not replay the request. HTTP streams read at most 64 KiB per block and use a +bounded socket read timeout. Active HTTP streams and WebSocket connections refresh workspace +activity every 20 seconds until they close, so idle suspension waits for active previews to end. Suspend is refused while the native delivery ledger contains a recorded, queued, sending, delivered-but-incomplete, or uncertain delivery. The API reports `409` with the reason. A diff --git a/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml index 57b703d..1b256f3 100644 --- a/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml +++ b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml @@ -10,5 +10,7 @@ data: DB_PORT: '5432' DB_NAME: mainloop FRONTEND_DOMAIN: localhost:3000 + # Kind-only fixed identity for local port-forwarded previews; do not copy to shared overlays. + SUBSTRATE_PREVIEW_LOCAL_DEV_MODE: 'true' SUBSTRATE_ROUTER_ADDRESS: http://atenet-router.ate-system.svc.cluster.local:8081 SUBSTRATE_ACTOR_BINDINGS: '{"claude":{"atespace":"live-agent-gate","actor":"headless-claude-reproof","shim_token_secret_name":"mainloop-shim-live-agent-gate-headless-claude-reproof"},"codex":{"atespace":"native-codex","actor":"headless-codex-reproof","shim_token_secret_name":"mainloop-shim-native-codex-headless-codex-reproof"}}' From 9b66e0b225d6d10548361a0a6627ab71a26ca5af Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:11:42 +0000 Subject: [PATCH 27/30] chore(substrate): run Substrate from the oldsj/substrate fork Pin gate5_setup.py to the fork's patched branch instead of upstream cdac9ba and take its checkout from $SUBSTRATE_SRC rather than a /tmp path. Apply the actor EgressPolicy with kubectl ate create/update egress-policy, which the fork now has, and remove the in-repo gRPC egress tool and its stale test. Document the fork in the spike doc. --- backend/scripts/gate5_setup.py | 91 ++++++++----- backend/tests/runtime/test_gate5_setup.py | 127 ++++++++++++++---- docs/spikes/substrate-workspace-adapter.md | 31 +++-- .../egress-tool/main.go | 124 ----------------- .../egress-tool/main_test.go | 28 ---- 5 files changed, 181 insertions(+), 220 deletions(-) delete mode 100644 spikes/substrate-workspace-adapter/egress-tool/main.go delete mode 100644 spikes/substrate-workspace-adapter/egress-tool/main_test.go diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index fc965dc..d2ff6e2 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -20,7 +20,8 @@ runs one headless native CLI process per request; Mainloop owns delivery and retry decisions. Prerequisites: - kubectl, kubectl-ate, and ko built from the pinned Substrate checkout. + kubectl, plus kubectl-ate and ko built from a checkout of the oldsj/substrate fork at + SUBSTRATE_FORK_COMMIT (see docs/spikes/substrate-workspace-adapter.md). A running kind-substrate-preview cluster with the ate-system + agentgateway dataplane installed (this script accepts only the exact kind-substrate-preview context). The live-agent-gate image already built and pushed (see live-agent-image/), its digest @@ -30,14 +31,13 @@ cd backend uv run python scripts/gate5_setup.py \\ --context kind-substrate-preview --kubeconfig /tmp/substrate-preview-kubeconfig \\ - --ate-cli /tmp/substrate-preview-src/bin/kubectl-ate \\ - --ko /tmp/substrate-preview-src/bin/ko \\ - --substrate-src /tmp/substrate-preview-src \\ + --ate-cli "$SUBSTRATE_SRC/bin/kubectl-ate" \\ + --ko "$SUBSTRATE_SRC/bin/ko" \\ + --substrate-src "$SUBSTRATE_SRC" \\ --atespace live-agent-gate --template-version v1 \\ --image localhost:5001/live-agent-gate@sha256:... \\ --manifest ../spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl \\ --state-file /tmp/gate5-run-state.json \\ - --egress-tool /tmp/substrate-preview-src/bin/mainloop-egress-tool \\ --egress-deny-all Re-running with the same --state-file reconciles the persisted actor uid against the @@ -55,7 +55,7 @@ import secrets import socket import string -import subprocess # nosec B404 - drives trusted local kubectl/ko/egress-tool binaries, argv only +import subprocess # nosec B404 - drives trusted local kubectl/kubectl-ate/ko binaries, argv only import sys import tempfile import time @@ -81,7 +81,9 @@ wait_for_golden_snapshot, ) -PINNED_SUBSTRATE_COMMIT = "cdac9baef81dd319b46086d695266e6161e9e592" +# The `patched` branch of https://github.com/oldsj/substrate: upstream Substrate at +# cdac9baef81dd319b46086d695266e6161e9e592 plus the patches listed in its FORK.md. +SUBSTRATE_FORK_COMMIT = "ab1995089e1804df8f62fc1144cfe2f92b18fe20" WORKER_SELECTOR = "workload=live-agent-gate" WORKER_SANDBOX_CLASS = "gvisor" ACTOR_SHIM_PORT = 8090 @@ -116,7 +118,6 @@ def parse_args() -> argparse.Namespace: p.add_argument("--worker-timeout", type=float, default=120) p.add_argument("--actor-timeout", type=float, default=120) p.add_argument("--readiness-timeout", type=float, default=60) - p.add_argument("--egress-tool", required=True) egress = p.add_mutually_exclusive_group(required=True) egress.add_argument("--egress-cidr", help="CIDR to allow") egress.add_argument( @@ -188,9 +189,9 @@ def verify_substrate_source(source: str, *, runner=subprocess.run) -> str: timeout=15, ) commit = result.stdout.strip() - if commit != PINNED_SUBSTRATE_COMMIT: + if commit != SUBSTRATE_FORK_COMMIT: raise RuntimeError( - f"--substrate-src must be pinned at {PINNED_SUBSTRATE_COMMIT}; found {commit!r}" + f"--substrate-src must be pinned at {SUBSTRATE_FORK_COMMIT}; found {commit!r}" ) return str(root) @@ -376,30 +377,58 @@ def apply_worker_pool( os.unlink(doc_path) -def run_egress_tool(args: argparse.Namespace) -> None: - cmd = [ - args.egress_tool, - "--kubeconfig", - args.kubeconfig, - "--context", - args.context, - "--atespace", - args.atespace, - "--actor", - args.actor_name, - ] +def egress_policy_manifest(args: argparse.Namespace) -> str: + """Return the actor's EgressPolicy as a protojson manifest. + + The argument parser admits exactly one egress mode, so a missing mode never falls through + to a permissive policy; deny-all is a policy with no rules. + """ if args.egress_deny_all: - cmd.append("--deny-all") + rules: list[dict] = [] elif args.egress_allow_all: - cmd.append("--allow-all") + rules = [{"all": {}}] elif args.egress_hostnames: - for hostname in args.egress_hostnames: - cmd.extend(["--hostname", hostname]) + rules = [{"hostnames": {"patterns": list(args.egress_hostnames)}}] + elif args.egress_cidr: + rules = [{"cidrs": {"cidrs": [args.egress_cidr]}}] else: - cmd += ["--cidr", args.egress_cidr] - subprocess.run( - cmd, check=True, timeout=60 - ) # nosec B603 - argv list, path is an operator-supplied flag + raise ValueError("no egress mode selected") + return json.dumps({"rules": rules}) + + +def apply_egress_policy(args: argparse.Namespace) -> None: + """Create the actor's EgressPolicy, or replace it when one already exists.""" + manifest = egress_policy_manifest(args) + for verb in ("create", "update"): + result = subprocess.run( # nosec B603 - argv list, CLI path is an operator-supplied flag + [ + args.ate_cli, + "--kubeconfig", + args.kubeconfig, + "--context", + args.context, + verb, + "egress-policy", + args.actor_name, + "--atespace", + args.atespace, + "--filename", + "-", + ], + input=manifest, + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode == 0: + print(f"-- egress policy {verb}d") + return + if verb == "create" and "code = AlreadyExists" in result.stderr: + continue + raise RuntimeError( + f"{verb} egress-policy failed (exit {result.returncode}): " + f"{result.stderr.strip()[-300:]}" + ) @contextlib.contextmanager @@ -825,7 +854,7 @@ async def async_main(args: argparse.Namespace) -> None: ) print("-- applying the actor's EgressPolicy") - run_egress_tool(args) + apply_egress_policy(args) print() print(f"== actor {args.atespace}/{args.actor_name} is RUNNING, credential-free ==") diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index 5cbfcd3..266ab5f 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -102,9 +102,7 @@ def test_source_requires_pinned_checkout_and_required_files(self): def runner(argv, **kwargs): calls.append(argv) - return completed( - argv, stdout=gate5_setup.PINNED_SUBSTRATE_COMMIT + "\n" - ) + return completed(argv, stdout=gate5_setup.SUBSTRATE_FORK_COMMIT + "\n") self.assertEqual( gate5_setup.verify_substrate_source(str(source), runner=runner), @@ -139,7 +137,7 @@ def runner(argv, **kwargs): calls.append((argv, kwargs)) if argv[0] == "git": return completed( - argv, stdout=gate5_setup.PINNED_SUBSTRATE_COMMIT + "\n" + argv, stdout=gate5_setup.SUBSTRATE_FORK_COMMIT + "\n" ) if argv[1:3] == ["resolve", "-f"]: return completed(argv, stdout="resolved-yaml") @@ -289,40 +287,115 @@ def wait(self, timeout): self.assertIn("18081:8081", command) self.assertTrue(process.terminated) - def test_egress_hostname_rules_are_passed_as_repeatable_flags(self): - args = SimpleNamespace( - egress_tool="/fixture/mainloop-egress-tool", + def egress_args(self, **mode): + base = dict( + ate_cli="/fixture/kubectl-ate", kubeconfig=FIXTURE_KUBECONFIG, context="kind-substrate-preview", atespace="live-agent-gate", actor_name="claude-gate5", egress_deny_all=False, egress_allow_all=False, - egress_hostnames=["api.anthropic.com", "api.openai.com"], + egress_hostnames=None, + egress_cidr=None, ) - with patch.object(gate5_setup.subprocess, "run") as run: - gate5_setup.run_egress_tool(args) - self.assertEqual( - run.call_args.args[0], + base.update(mode) + return SimpleNamespace(**base) + + def test_egress_policy_manifest_has_one_rule_per_mode(self): + cases = [ + ({"egress_deny_all": True}, []), + ({"egress_allow_all": True}, [{"all": {}}]), + ( + {"egress_hostnames": ["api.anthropic.com", "api.openai.com"]}, + [{"hostnames": {"patterns": ["api.anthropic.com", "api.openai.com"]}}], + ), + ({"egress_cidr": "192.0.2.1/32"}, [{"cidrs": {"cidrs": ["192.0.2.1/32"]}}]), + ] + for mode, rules in cases: + with self.subTest(mode=mode): + manifest = json.loads( + gate5_setup.egress_policy_manifest(self.egress_args(**mode)) + ) + self.assertEqual(manifest, {"rules": rules}) + + def test_egress_policy_manifest_refuses_no_mode(self): + with self.assertRaises(ValueError): + gate5_setup.egress_policy_manifest(self.egress_args()) + + def expected_egress_argv(self, verb): + return [ + "/fixture/kubectl-ate", + "--kubeconfig", + FIXTURE_KUBECONFIG, + "--context", + "kind-substrate-preview", + verb, + "egress-policy", + "claude-gate5", + "--atespace", + "live-agent-gate", + "--filename", + "-", + ] + + def test_apply_egress_policy_creates_through_explicit_kube_target(self): + args = self.egress_args(egress_deny_all=True) + with patch.object( + gate5_setup.subprocess, + "run", + side_effect=lambda argv, **_: completed(argv), + ) as run: + gate5_setup.apply_egress_policy(args) + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args.args[0], self.expected_egress_argv("create")) + self.assertEqual(run.call_args.kwargs["input"], '{"rules": []}') + + def test_apply_egress_policy_updates_an_existing_policy(self): + args = self.egress_args(egress_hostnames=["api.openai.com"]) + results = iter( [ - "/fixture/mainloop-egress-tool", - "--kubeconfig", - FIXTURE_KUBECONFIG, - "--context", - "kind-substrate-preview", - "--atespace", - "live-agent-gate", - "--actor", - "claude-gate5", - "--hostname", - "api.anthropic.com", - "--hostname", - "api.openai.com", - ], + ( + 1, + "rpc error: code = AlreadyExists desc = EgressPolicy already exists", + ), + (0, ""), + ] ) + def fake_run(argv, **_): + code, stderr = next(results) + return completed(argv, returncode=code, stderr=stderr) + + with patch.object(gate5_setup.subprocess, "run", side_effect=fake_run) as run: + gate5_setup.apply_egress_policy(args) + self.assertEqual( + [call.args[0] for call in run.call_args_list], + [self.expected_egress_argv("create"), self.expected_egress_argv("update")], + ) + for call in run.call_args_list: + self.assertEqual( + json.loads(call.kwargs["input"]), + {"rules": [{"hostnames": {"patterns": ["api.openai.com"]}}]}, + ) + + def test_apply_egress_policy_does_not_update_after_other_create_failures(self): + args = self.egress_args(egress_deny_all=True) + with ( + patch.object( + gate5_setup.subprocess, + "run", + side_effect=lambda argv, **_: completed( + argv, + returncode=1, + stderr="rpc error: code = FailedPrecondition desc = parent Actor does not exist", + ), + ) as run, + self.assertRaisesRegex(RuntimeError, "create egress-policy failed"), + ): + gate5_setup.apply_egress_policy(args) + self.assertEqual(run.call_count, 1) -class Gate5StateTests(unittest.TestCase): def args(self, state_file: str, **overrides): values = { "context": "kind-substrate-preview", diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index e0040aa..e0bc54f 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -120,27 +120,39 @@ The preview/HMR edit itself still uses a generic exec shim pane via `herdr pane run` -- a real shell executing a real command, but not a native agent's own Bash tool. +## Substrate source + +Mainloop runs Substrate from a fork, [`oldsj/substrate`](https://github.com/oldsj/substrate), +branch `patched`, pinned at `ab1995089e1804df8f62fc1144cfe2f92b18fe20`. That branch is upstream +`cdac9baef81dd319b46086d695266e6161e9e592` plus a short patch stack listed in the fork's +`FORK.md`: notably, actor containers run as the image's `USER` in its `WORKDIR`, and +`kubectl ate` gains `get`, `create` and `update egress-policy`. The fork carries no +Mainloop-specific code. `backend/scripts/gate5_setup.py` refuses any other commit. The +evidence below records the commit each result was measured on; results before the fork were +measured on upstream `cdac9ba`. + ## Run it There is no single demo script yet (unlike `spikes/k8s-herdr-agents/demo.sh`); the commands used are recorded in the task's proof note. In outline: ```bash +# SUBSTRATE_SRC is a checkout of oldsj/substrate at the pinned commit above. KIND_CLUSTER_NAME=substrate-preview KUBECONFIG=/tmp/substrate-preview-kubeconfig \ - /tmp/substrate-preview-src/hack/create-kind-cluster.sh + "$SUBSTRATE_SRC"/hack/create-kind-cluster.sh KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ KUBECONFIG=/tmp/substrate-preview-kubeconfig \ - /tmp/substrate-preview-src/hack/install-ate-kind.sh --deploy-ate-system + "$SUBSTRATE_SRC"/hack/install-ate-kind.sh --deploy-ate-system KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ KUBECONFIG=/tmp/substrate-preview-kubeconfig \ - /tmp/substrate-preview-src/hack/install-ate-kind.sh --deploy-atenet --atenet-dataplane=agentgateway + "$SUBSTRATE_SRC"/hack/install-ate-kind.sh --deploy-atenet --atenet-dataplane=agentgateway # build kubectl-ate, build+push an actor image, apply one of: # k8s/actor-template.yaml.tmpl -- mainloop-workspace: Herdr + agentctl # k8s/preview-gate-template.yaml.tmpl -- preview-gate: real Vite dev server + exec shim # + k8s/preview-proxy.yaml.tmpl -- the NGINX ate-target-actor header-proxy in front # k8s/dev-service-gate-template.yaml.tmpl -- dev-service-gate: real psql + exec shim # + k8s/postgres-target.yaml -- the external postgres:16-alpine StatefulSet -# + egress-tool/main.go -- creates the actor's EgressPolicy (no CLI verb) +# + `kubectl ate create egress-policy` -- the actor's EgressPolicy # (WorkerPool via `ko resolve | kubectl apply`, ActorTemplate via `kubectl ate create actor-template -f -`) ``` @@ -221,10 +233,9 @@ A real `postgres:16-alpine` StatefulSet (same image/auth shape as `kubectl-ate` has **no CLI verb for egress policies** -- confirmed by the pinned checkout's own `demos/egress/README.md`: `"test-egress.sh creates and resumes the Actor but cannot create its EgressPolicy (no CLI verb yet)"`. Its own e2e suite calls the gRPC API directly -(`internal/e2e/egresspolicy.go`). This spike does the same: -`spikes/substrate-workspace-adapter/egress-tool/main.go`, a small standalone `main` mirroring -that helper without the `testing.T` dependency (build instructions are in the file's header -comment; it must be built inside a Substrate checkout since it imports `internal/` packages). +(`internal/e2e/egresspolicy.go`). This spike originally did the same with a small gRPC tool. +Upstream has since added `kubectl ate get` and `create egress-policy`, and the fork adds +`update`, so `gate5_setup.py` now writes the policy manifest and applies it with those verbs. **Result**: DNS resolution (bypasses the policy enforcement point entirely -- port 53 is always allowed), a real `SELECT` query over the actual Postgres wire protocol, and reconnection after an @@ -291,8 +302,8 @@ Phase 1 (recovery plan step 2) repairs the harness findings and removes the boot `ko resolve` from the verified pinned checkout, waits for an eligible worker and a golden snapshot, binds reruns to persisted cluster/template/actor identity, then confirms health through the actor route before applying egress policy. -- `egress-tool/main.go` fails closed: exactly one of `--deny-all`, `--cidr`, or `--allow-all` - must be explicit. +- The egress step fails closed: exactly one of `--egress-deny-all`, `--egress-cidr`, + `--egress-hostname` or `--egress-allow-all` must be explicit. Phase 2 used pinned Substrate `cdac9baef81dd319b46086d695266e6161e9e592`, a fresh `kind-substrate-preview` cluster, agentgateway, and image diff --git a/spikes/substrate-workspace-adapter/egress-tool/main.go b/spikes/substrate-workspace-adapter/egress-tool/main.go deleted file mode 100644 index c3884b3..0000000 --- a/spikes/substrate-workspace-adapter/egress-tool/main.go +++ /dev/null @@ -1,124 +0,0 @@ -// Creates/updates an actor's EgressPolicy directly via gRPC, since kubectl-ate has no CLI verb -// for it as of the pinned commit (confirmed by the substrate checkout's own -// demos/egress/README.md: "test-egress.sh creates and resumes the Actor but cannot create its -// EgressPolicy (no CLI verb yet)"). Mirrors that checkout's internal/e2e/egresspolicy.go -// (EnsureEgressPolicy), without the testing.T dependency. -// -// This file imports Substrate's internal packages (internal/ateclient, internal/resources), so -// it cannot be built as a standalone Go module outside a Substrate checkout. To use it: drop -// this file into /cmd/mainloop-egress-tool/main.go and build with -// `GOFLAGS=-mod=vendor go build -o mainloop-egress-tool ./cmd/mainloop-egress-tool` from the -// checkout root (module github.com/agent-substrate/substrate, pinned commit -// cdac9baef81dd319b46086d695266e6161e9e592 when this was written). -// -// Usage: mainloop-egress-tool --kubeconfig --context --atespace --actor -// --deny-all | --cidr | --hostname ... | --allow-all -// -// Fails closed: exactly one of --deny-all, --cidr, --hostname, or --allow-all is required. An earlier version of this -// tool silently allowed all destinations whenever --cidr was omitted (see -// .tasknotes/gate5-review-and-recovery-plan-2026-09-22.md, "Make missing egress configuration -// fail closed"); --allow-all must now be passed explicitly to get that behavior. -package main - -import ( - "context" - "flag" - "fmt" - "log" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/emptypb" - - "github.com/agent-substrate/substrate/internal/ateclient" - "github.com/agent-substrate/substrate/internal/resources" - "github.com/agent-substrate/substrate/pkg/proto/ateapipb" -) - -// validateEgressInput is the fail-closed check, isolated as a pure function so it can be -// exercised without a cluster or a Substrate checkout. -func validateEgressInput(cidr string, hostnames []string, denyAll, allowAll bool) error { - selected := 0 - if cidr != "" { - selected++ - } - if len(hostnames) > 0 { - selected++ - } - if denyAll { - selected++ - } - if allowAll { - selected++ - } - if selected != 1 { - return fmt.Errorf("exactly one of --deny-all, --cidr , --hostname , or --allow-all is required") - } - return nil -} - -func main() { - kubeconfig := flag.String("kubeconfig", "", "") - context_ := flag.String("context", "", "") - atespace := flag.String("atespace", "", "") - actorName := flag.String("actor", "", "") - cidr := flag.String("cidr", "", "CIDR to allow") - var hostnames []string - flag.Func("hostname", "exact hostname to allow (repeatable)", func(value string) error { - hostnames = append(hostnames, value) - return nil - }) - denyAll := flag.Bool("deny-all", false, "explicitly deny all actor egress") - allowAll := flag.Bool("allow-all", false, "explicitly allow all egress destinations") - flag.Parse() - - if err := validateEgressInput(*cidr, hostnames, *denyAll, *allowAll); err != nil { - log.Fatalf("%v", err) - } - - ctx := context.Background() - cli, err := ateclient.NewClient(ctx, *kubeconfig, *context_, "", "", false) - if err != nil { - log.Fatalf("connect: %v", err) - } - defer cli.Close() - - actorRef := resources.ActorRef{Atespace: *atespace, Name: *actorName}.ToObjectRef() - - var rules []*ateapipb.EgressRule - if *allowAll { - rules = []*ateapipb.EgressRule{{All: &emptypb.Empty{}}} - } else if *cidr != "" { - rules = []*ateapipb.EgressRule{{Cidrs: &ateapipb.CIDRRule{Cidrs: []string{*cidr}}}} - } else if len(hostnames) > 0 { - rules = []*ateapipb.EgressRule{{Hostnames: &ateapipb.HostnameRule{Patterns: hostnames}}} - } - policy := &ateapipb.EgressPolicy{ - Metadata: &ateapipb.ResourceMetadata{Atespace: *atespace, Name: "default"}, - Rules: rules, - } - - _, err = cli.CreateActorEgressPolicy(ctx, &ateapipb.CreateActorEgressPolicyRequest{ - Actor: actorRef, - EgressPolicy: policy, - }) - if status.Code(err) == codes.AlreadyExists { - existing, gerr := cli.GetActorEgressPolicy(ctx, &ateapipb.GetActorEgressPolicyRequest{Actor: actorRef}) - if gerr != nil { - log.Fatalf("get existing: %v", gerr) - } - policy.Metadata = existing.GetMetadata() - if _, uerr := cli.UpdateActorEgressPolicy(ctx, &ateapipb.UpdateActorEgressPolicyRequest{ - Actor: actorRef, - EgressPolicy: policy, - }); uerr != nil { - log.Fatalf("update: %v", uerr) - } - fmt.Println("updated existing egress policy") - return - } - if err != nil { - log.Fatalf("create: %v", err) - } - fmt.Println("created egress policy") -} diff --git a/spikes/substrate-workspace-adapter/egress-tool/main_test.go b/spikes/substrate-workspace-adapter/egress-tool/main_test.go deleted file mode 100644 index dcf8e0c..0000000 --- a/spikes/substrate-workspace-adapter/egress-tool/main_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import "testing" - -func TestValidateEgressInputRequiresExactlyOneMode(t *testing.T) { - tests := []struct { - name string - cidr string - denyAll bool - allowAll bool - wantErr bool - }{ - {name: "deny all", denyAll: true}, - {name: "cidr", cidr: "192.0.2.1/32"}, - {name: "allow all", allowAll: true}, - {name: "missing mode", wantErr: true}, - {name: "conflicting modes", denyAll: true, allowAll: true, wantErr: true}, - {name: "cidr and deny all", cidr: "192.0.2.1/32", denyAll: true, wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateEgressInput(tt.cidr, tt.denyAll, tt.allowAll) - if (err != nil) != tt.wantErr { - t.Fatalf("validateEgressInput() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} From 71d014f5910b73ce230fae2b75479de543dd059c Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:45:19 +0000 Subject: [PATCH 28/30] Make Substrate actors run non-root with the product template Run the actor image as UID/GID 10001 in `/work` and remove the obsolete root privilege-drop path. Refuse UID 0 and fail early when the durable workspace is not writable. Use the product actor template for Gate 5, including its workspace mount and readiness probe, and remove the duplicate gate template. Pin the Substrate fork at ce265c1d, which gives fresh durable volumes to the image user, and update the runtime documentation and tests for the fork's image-user behavior. --- backend/scripts/gate5_setup.py | 69 ++++++++++++----- backend/tests/runtime/test_gate5_setup.py | 74 ++++++++++++++++++- docs/architecture.md | 7 +- docs/spikes/substrate-workspace-adapter.md | 12 +-- examples/devenv-sample/README.md | 8 +- .../k8s/actor-template.yaml.tmpl | 26 +++---- .../k8s/live-agent-gate-template.yaml.tmpl | 53 ------------- .../live-agent-image/Dockerfile | 11 +-- .../live-agent-image/entrypoint.sh | 34 ++------- .../tests/exec-shim.test.js | 35 ++++----- 10 files changed, 177 insertions(+), 152 deletions(-) delete mode 100644 spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index d2ff6e2..7dd2c8a 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -34,12 +34,15 @@ --ate-cli "$SUBSTRATE_SRC/bin/kubectl-ate" \\ --ko "$SUBSTRATE_SRC/bin/ko" \\ --substrate-src "$SUBSTRATE_SRC" \\ - --atespace live-agent-gate --template-version v1 \\ + --atespace nonroot-check --worker-pool nonroot-check --template-version v1 \\ --image localhost:5001/live-agent-gate@sha256:... \\ - --manifest ../spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl \\ + --manifest ../spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl \\ --state-file /tmp/gate5-run-state.json \\ --egress-deny-all + Use a fresh atespace for this check: the applied product WorkerPool requests two replicas. + --worker-pool defaults to the atespace name so its label is distinct from other namespaces. + Re-running with the same --state-file reconciles the persisted actor uid against the cluster's current state rather than blindly creating or resuming; a name collision with a *different* uid is refused, not silently overwritten. @@ -83,8 +86,7 @@ # The `patched` branch of https://github.com/oldsj/substrate: upstream Substrate at # cdac9baef81dd319b46086d695266e6161e9e592 plus the patches listed in its FORK.md. -SUBSTRATE_FORK_COMMIT = "ab1995089e1804df8f62fc1144cfe2f92b18fe20" -WORKER_SELECTOR = "workload=live-agent-gate" +SUBSTRATE_FORK_COMMIT = "ce265c1dbd3775faf10c95f71f2c16ff3d47c332" WORKER_SANDBOX_CLASS = "gvisor" ACTOR_SHIM_PORT = 8090 @@ -98,6 +100,10 @@ def parse_args() -> argparse.Namespace: p.add_argument("--substrate-src", required=True) p.add_argument("--router-port", type=int, default=18091) p.add_argument("--atespace", required=True) + p.add_argument( + "--worker-pool", + help="WorkerPool name and workload label (defaults to the atespace name)", + ) p.add_argument( "--template-version", required=True, @@ -109,7 +115,9 @@ def parse_args() -> argparse.Namespace: help="already-built and pushed image digest, e.g. localhost:5001/live-agent-gate@sha256:...", ) p.add_argument( - "--manifest", required=True, help="path to live-agent-gate-template.yaml.tmpl" + "--manifest", + required=True, + help="path to the three-document product actor template", ) p.add_argument("--bucket-name", default="ate-snapshots") p.add_argument("--actor-name", default="claude-gate5") @@ -129,7 +137,10 @@ def parse_args() -> argparse.Namespace: ) egress.add_argument("--egress-allow-all", action="store_true") egress.add_argument("--egress-deny-all", action="store_true") - return p.parse_args() + args = p.parse_args() + if args.worker_pool is None: + args.worker_pool = args.atespace + return args def load_state(path: str) -> dict: @@ -150,17 +161,26 @@ def save_state(path: str, state: dict) -> None: def render_manifest( - path: str, *, atespace: str, template_name: str, bucket_name: str, image: str + path: str, + *, + atespace: str, + worker_pool_name: str, + template_name: str, + bucket_name: str, + image: str, ) -> list[str]: - """Substitutes the template's ${ATESPACE}/${TEMPLATE_NAME}/${BUCKET_NAME} placeholders - and the __IMAGE__ marker, then splits the multi-document YAML on its own '---' - separators. Returns [namespace_and_workerpool_doc, actor_template_doc].""" + """Substitutes the template placeholders and image marker, then splits the multi-document + YAML on its own '---' separators. Returns [namespace_and_workerpool_doc, + actor_template_doc].""" with open(path) as f: raw = f.read() rendered = ( string.Template(raw) .safe_substitute( - ATESPACE=atespace, TEMPLATE_NAME=template_name, BUCKET_NAME=bucket_name + ATESPACE=atespace, + WORKER_POOL_NAME=worker_pool_name, + TEMPLATE_NAME=template_name, + BUCKET_NAME=bucket_name, ) .replace("__IMAGE__", image) ) @@ -173,6 +193,19 @@ def render_manifest( return [namespace_and_workerpool, docs[2] + "\n"] +def render_gate_manifest(args: argparse.Namespace) -> list[str]: + """Render the product template with this run's atespace, pool, and versioned template.""" + template_name = f"live-agent-gate-{args.template_version}" + return render_manifest( + args.manifest, + atespace=args.atespace, + worker_pool_name=args.worker_pool, + template_name=template_name, + bucket_name=args.bucket_name, + image=args.image, + ) + + def verify_substrate_source(source: str, *, runner=subprocess.run) -> str: """Require the exact source checkout that supplies the pinned ``ko`` module.""" root = Path(source).expanduser().resolve() @@ -289,6 +322,7 @@ def prepare_run_state(args: argparse.Namespace, cluster: dict[str, str]) -> dict "context": args.context, "cluster_identity": cluster, "atespace": args.atespace, + "worker_pool": args.worker_pool, "template_name": template_name, "image_digest": args.image, "actor_name": args.actor_name, @@ -725,14 +759,15 @@ async def wait_for_worker_if_actor_is_absent( ) return + worker_selector = f"workload={args.worker_pool}" print( f"-- waiting for an eligible worker in namespace={args.atespace}, " - f"selector={WORKER_SELECTOR}, sandbox={WORKER_SANDBOX_CLASS}" + f"selector={worker_selector}, sandbox={WORKER_SANDBOX_CLASS}" ) await wait_for_eligible_worker( control, args.atespace, - WORKER_SELECTOR, + worker_selector, WORKER_SANDBOX_CLASS, timeout_s=args.worker_timeout, ) @@ -818,13 +853,7 @@ async def async_main(args: argparse.Namespace) -> None: print(f"-- registering atespace {args.atespace}") await control.ensure_atespace(args.atespace) - namespace_and_workerpool_doc, actor_template_doc = render_manifest( - args.manifest, - atespace=args.atespace, - template_name=template_name, - bucket_name=args.bucket_name, - image=args.image, - ) + namespace_and_workerpool_doc, actor_template_doc = render_gate_manifest(args) print("-- resolving and applying the Namespace + WorkerPool") apply_worker_pool( namespace_and_workerpool_doc, diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index 266ab5f..800f327 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -31,6 +31,61 @@ def completed(argv, returncode=0, stdout="", stderr=""): class Gate5SourceAndBuildTests(unittest.TestCase): + def parse_cli_args(self, manifest: Path, *extra: str): + argv = [ + "gate5_setup.py", + "--context", + "kind-substrate-preview", + "--kubeconfig", + FIXTURE_KUBECONFIG, + "--substrate-src", + "/fixture/substrate", + "--atespace", + "nonroot-check", + "--template-version", + "v3", + "--image", + "localhost:5001/live-agent-gate@sha256:" + "a" * 64, + "--manifest", + str(manifest), + "--state-file", + "gate5-test-state.json", + "--egress-deny-all", + *extra, + ] + with patch("sys.argv", argv): + return gate5_setup.parse_args() + + def test_worker_pool_option_drives_product_template_selector_and_labels(self): + manifest = ( + Path(__file__).resolve().parents[3] + / "spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl" + ) + args = self.parse_cli_args(manifest, "--worker-pool", "isolated-pool") + worker_pool, actor_template = gate5_setup.render_gate_manifest(args) + + self.assertEqual(args.atespace, "nonroot-check") + self.assertEqual(args.worker_pool, "isolated-pool") + self.assertIn("name: isolated-pool", worker_pool) + self.assertIn("workload: isolated-pool", worker_pool) + self.assertIn("name: live-agent-gate-v3", actor_template) + self.assertIn("workload: isolated-pool", actor_template) + self.assertIn("mountPath: /work", actor_template) + self.assertIn("value: /work/repo", actor_template) + self.assertIn("durableDir: {}", actor_template) + self.assertIn("path: /healthz, port: 8090", actor_template) + self.assertNotIn("/workspace", actor_template) + + def test_worker_pool_defaults_to_the_atespace_name(self): + manifest = ( + Path(__file__).resolve().parents[3] + / "spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl" + ) + + args = self.parse_cli_args(manifest) + + self.assertEqual(args.worker_pool, "nonroot-check") + def test_image_manifest_preflight_checks_registry_endpoint_and_accept_types(self): calls = [] @@ -401,6 +456,7 @@ def args(self, state_file: str, **overrides): "context": "kind-substrate-preview", "kubeconfig": FIXTURE_KUBECONFIG, "atespace": "live-agent-gate", + "worker_pool": "live-agent-gate", "template_version": "v1", "image": "localhost:5001/live-agent-gate@sha256:" + "a" * 64, "actor_name": "claude-gate5", @@ -423,6 +479,7 @@ def test_persists_run_intent_before_actor_or_template_uids_exist(self): stored = json.loads(Path(path).read_text()) self.assertEqual(stored["run_id"], state["run_id"]) self.assertEqual(stored["template_name"], "live-agent-gate-v1") + self.assertEqual(stored["worker_pool"], "live-agent-gate") self.assertEqual(stored["actor_name"], "claude-gate5") self.assertIsNone(stored["template_uid"]) self.assertIsNone(stored["actor_uid"]) @@ -445,6 +502,15 @@ def test_rerun_refuses_changed_template_request(self): self.args(path, template_version="v2"), self.cluster() ) + def test_rerun_refuses_changed_worker_pool(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + gate5_setup.prepare_run_state(self.args(path), self.cluster()) + with self.assertRaisesRegex(RuntimeError, "worker_pool"): + gate5_setup.prepare_run_state( + self.args(path, worker_pool="isolated-pool"), self.cluster() + ) + class SetupControl: def __init__(self, actor: ActorRecord | None): @@ -580,6 +646,7 @@ def args(self, path): return SimpleNamespace( state_file=path, atespace="live-agent-gate", + worker_pool="live-agent-gate", actor_name="claude-gate5", actor_timeout=5, worker_timeout=5, @@ -689,11 +756,15 @@ def test_worker_wait_uses_the_selected_atespace_namespace(self): path = str(Path(temp_dir) / "state.json") args = self.args(path) args.atespace = "native-codex" + args.worker_pool = "native-codex" control = SetupControl(None) observed = {} - async def wait_for_worker(_control, namespace, *_args, **_kwargs): + async def wait_for_worker( + _control, namespace, worker_selector, *_args, **_kwargs + ): observed["namespace"] = namespace + observed["worker_selector"] = worker_selector with patch.object(gate5_setup, "wait_for_eligible_worker", wait_for_worker): asyncio_run( @@ -709,6 +780,7 @@ async def wait_for_worker(_control, namespace, *_args, **_kwargs): ) self.assertEqual(observed["namespace"], "native-codex") + self.assertEqual(observed["worker_selector"], "workload=native-codex") def test_owned_rerun_skips_worker_wait_when_its_actor_occupies_only_worker(self): with tempfile.TemporaryDirectory() as temp_dir: diff --git a/docs/architecture.md b/docs/architecture.md index fa0c11f..64ca750 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,10 +171,9 @@ Kubernetes sign-in job and the external egress credential-provider contract are - Actor egress is restricted by host allowlists and passes through the egress proxy. - Workspace lifecycle and credential paths have fake-backed coverage; their combined behavior has not been verified end to end on a live cluster. -- Substrate starts actor containers as UID 0 without `SETUID`/`SETGID`, so the image's drop to - UID `10001` fails. The entrypoint and shim refuse to continue as root, so agent actors do not - start on the current version. They need a Substrate runtime that honors a non-root user; this - is an upstream gap. +- Mainloop pins a Substrate fork whose actor runtime runs containers as the image's `USER` in + its `WORKDIR`; the agent image runs as UID `10001` and refuses UID 0. A fresh `durableDir` + must be writable by that user for the workspace to start, which still needs a live check. - The actor's observed `RLIMIT_NOFILE` is 1024. - Restoring Postgres from an actor snapshot is unverified. - Live wake-on-preview is unverified. diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index e0bc54f..f389b8e 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -123,9 +123,9 @@ Bash tool. ## Substrate source Mainloop runs Substrate from a fork, [`oldsj/substrate`](https://github.com/oldsj/substrate), -branch `patched`, pinned at `ab1995089e1804df8f62fc1144cfe2f92b18fe20`. That branch is upstream +branch `patched`, pinned at `ce265c1dbd3775faf10c95f71f2c16ff3d47c332`. That branch is upstream `cdac9baef81dd319b46086d695266e6161e9e592` plus a short patch stack listed in the fork's -`FORK.md`: notably, actor containers run as the image's `USER` in its `WORKDIR`, and +`FORK.md`: notably, actor containers run as the image's `USER` in its `WORKDIR` with the image's file owners kept, fresh durable volumes are owned by that user, and `kubectl ate` gains `get`, `create` and `update egress-policy`. The fork carries no Mainloop-specific code. `backend/scripts/gate5_setup.py` refuses any other commit. The evidence below records the commit each result was measured on; results before the fork were @@ -147,7 +147,7 @@ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ KUBECONFIG=/tmp/substrate-preview-kubeconfig \ "$SUBSTRATE_SRC"/hack/install-ate-kind.sh --deploy-atenet --atenet-dataplane=agentgateway # build kubectl-ate, build+push an actor image, apply one of: -# k8s/actor-template.yaml.tmpl -- mainloop-workspace: Herdr + agentctl +# k8s/actor-template.yaml.tmpl -- mainloop-workspace: headless native-agent shim # k8s/preview-gate-template.yaml.tmpl -- preview-gate: real Vite dev server + exec shim # + k8s/preview-proxy.yaml.tmpl -- the NGINX ate-target-actor header-proxy in front # k8s/dev-service-gate-template.yaml.tmpl -- dev-service-gate: real psql + exec shim @@ -290,9 +290,9 @@ Phase 1 (recovery plan step 2) repairs the harness findings and removes the boot - `entrypoint.sh` no longer fetches a credential or needs network access to reach a running state. There is no credential-fetch helper or relay path in the image. Credential delivery remains a separate, gated step and is never performed during golden-actor warmup. -- `k8s/live-agent-gate-template.yaml.tmpl` no longer sets `CRED_SERVER` in the (shared, - immutable) container env, and its ActorTemplate name is now versioned - (`live-agent-gate-${TEMPLATE_VERSION}`) so a failed golden snapshot is never reused. +- The product `k8s/actor-template.yaml.tmpl` is also the Gate 5 manifest. The harness versions + its ActorTemplate name (`live-agent-gate-${TEMPLATE_VERSION}`) so a failed golden snapshot is + never reused; the template starts without credentials or external network access. - `backend/src/mainloop/runtime/substrate.py` gained `ensure_atespace`/`get_actor_template`/ `create_actor_template`/`get_eligible_workers`, plus bounded, exception-raising waits for golden snapshots, eligible workers, actor state, and the live actor health route. Rerun diff --git a/examples/devenv-sample/README.md b/examples/devenv-sample/README.md index b9fe6c3..52ab43d 100644 --- a/examples/devenv-sample/README.md +++ b/examples/devenv-sample/README.md @@ -9,7 +9,7 @@ port, actor template, and 30-minute idle timeout. Build the image from this dire `docker build -t mainloop-devenv-sample:latest .` in an environment with Docker available. The image declares a real non-root `app` user and checks the app's `/` HTTP route with a -container health check. The current Substrate actor path overrides the image's `USER` and runs -actors as UID 0; that upstream runtime gap remains. The sample does not claim to validate -non-root actor execution or live multi-container connectivity. Sample credentials are -fixture-only; use secret-backed values for real projects. +container health check. Mainloop's pinned Substrate fork runs actors as the image's `USER` in +its `WORKDIR`. This sample does not claim to validate non-root actor execution or live +multi-container connectivity. Sample credentials are fixture-only; use secret-backed values +for real projects. diff --git a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl index fb24de2..2b69580 100644 --- a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl @@ -4,6 +4,7 @@ # requires containers.image to be pinned by digest). Mirrors the shape of the pinned checkout's # demos/counter/{counter,counter-template}.yaml.tmpl. # +# Substitute ${ATESPACE}, ${WORKER_POOL_NAME}, ${TEMPLATE_NAME}, ${BUCKET_NAME}, and __IMAGE__. # The atespace ("mainloop-workspaces" by default; see backend/src/mainloop/config.py # substrate_atespace) is both this WorkerPool's k8s namespace and a control-plane atespace # resource created separately with `kubectl ate create atespace`. @@ -15,10 +16,10 @@ metadata: apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: - name: ${TEMPLATE_NAME} + name: ${WORKER_POOL_NAME} namespace: ${ATESPACE} labels: - workload: ${TEMPLATE_NAME} + workload: ${WORKER_POOL_NAME} spec: replicas: 2 workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor @@ -33,34 +34,33 @@ spec: --- # ActorTemplate: one headless per-turn native-agent container per actor, running under Substrate. # Substrate owns lifecycle and snapshots; the actor retains workspace files between turns and -# has no Herdr server or terminal manager. Gate 5 must live-prove the rebuilt image. +# has no Herdr server or terminal manager. Gate 5 uses this product template for its live check. metadata: atespace: ${ATESPACE} name: ${TEMPLATE_NAME} workerSelector: matchLabels: - workload: ${TEMPLATE_NAME} + workload: ${WORKER_POOL_NAME} containers: - name: workspace image: __IMAGE__ command: - /usr/local/bin/entrypoint.sh env: - - { name: HOME, value: /workspace/.home } - - { name: WORKSPACE_PATH, value: /workspace/repo } + - { name: HOME, value: /work/.home } + - { name: WORKSPACE_PATH, value: /work/repo } - { name: AGENT_CONFIG_DIR, value: /etc/agent-config } - - { name: CODEX_HOME, value: /workspace/.codex } - - { name: EXEC_SHIM_STATE_DIR, value: /workspace/.mainloop/exec-shim } + - { name: CODEX_HOME, value: /work/.codex } + - { name: EXEC_SHIM_STATE_DIR, value: /work/repo/.mainloop/exec-shim } volumeMounts: - - { name: workspace, mountPath: /workspace } - # ateapipb.SecurityContext only models Linux capability adjustments (no - # allowPrivilegeEscalation/readOnlyRootFilesystem -- the gVisor sandbox is the isolation - # boundary here, not those pod-level knobs), so there is nothing to set beyond the container's - # own non-root USER (see the image's Dockerfile). + - { name: workspace, mountPath: /work } resources: limits: - { name: cpu, quantity: "2" } - { name: memory, quantity: 2Gi } + readyz: + httpGet: { path: /healthz, port: 8090 } + timeoutSeconds: 60 snapshotsConfig: onPause: SNAPSHOT_CONTENT_SCOPE_FULL onCommit: SNAPSHOT_CONTENT_SCOPE_FULL diff --git a/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl deleted file mode 100644 index f231ecc..0000000 --- a/spikes/substrate-workspace-adapter/k8s/live-agent-gate-template.yaml.tmpl +++ /dev/null @@ -1,53 +0,0 @@ -# WorkerPool + ActorTemplate for the bounded live native-agent proof (gate 5 in -# .tasknotes/plan.md): headless per-turn Claude Code / Codex CLIs, driven by the actor-local -# shim. Substrate owns suspend/resume/revert; no terminal manager runs in the actor. -# See spikes/substrate-workspace-adapter/live-agent-image/. -# -# Deliberately no credential-relay env: the golden actor boots without credentials or external -# network access. The pinned ActorTemplate API supports readyz; the probe waits for the shim and -# workspace to become healthy before the golden snapshot or final actor is considered ready. -apiVersion: v1 -kind: Namespace -metadata: - name: ${ATESPACE} ---- -apiVersion: ate.dev/v1alpha1 -kind: WorkerPool -metadata: - name: live-agent-gate - namespace: ${ATESPACE} - labels: - workload: live-agent-gate -spec: - replicas: 1 - workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor - template: - resources: - limits: { cpu: '2', memory: 2Gi } - requests: { cpu: 250m, memory: 2Gi } ---- -metadata: - atespace: ${ATESPACE} - # Versioned, not "live-agent-gate": ActorTemplates are immutable, and a template whose - # golden snapshot failed must never be reused under the same name (recovery plan step 2). - name: ${TEMPLATE_NAME} -workerSelector: - matchLabels: - workload: live-agent-gate -containers: -- name: live-agent - image: __IMAGE__ - resources: - limits: - - { name: cpu, quantity: "2" } - - { name: memory, quantity: 2Gi } - readyz: - httpGet: { path: /healthz, port: 8090 } - timeoutSeconds: 60 -snapshotsConfig: - onPause: SNAPSHOT_CONTENT_SCOPE_FULL - onCommit: SNAPSHOT_CONTENT_SCOPE_FULL - storageLocation: gs://${BUCKET_NAME}/live-agent-gate/ -sandboxConfig: - sandboxClass: SANDBOX_CLASS_GVISOR - configName: gvisor-default diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile index c53cf90..0934785 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -6,7 +6,7 @@ FROM node:22-bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - jq ca-certificates git curl procps ripgrep util-linux \ + jq ca-certificates git curl procps ripgrep \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -u 10001 agent # The run-specific Substrate MITM CA is a public trust anchor. BuildKit mounts @@ -30,15 +30,16 @@ COPY exec-shim.js /usr/local/bin/exec-shim.js COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/start-native-agent /usr/local/bin/mainloop-reauth \ && mkdir -p /work && chown 10001:10001 /work +WORKDIR /work ENV EXEC_SHIM=/usr/local/bin/exec-shim.js ENV NATIVE_AGENT_LAUNCHER=/usr/local/bin/start-native-agent -ENV HOME=/home/agent +ENV HOME=/work/.home ENV WORKSPACE_PATH=/work/repo -ENV CODEX_HOME=/home/agent/.codex +ENV CODEX_HOME=/work/.codex ENV EXEC_SHIM_STATE_DIR=/work/repo/.mainloop/exec-shim ENV AGENT_SYSTEM_PROMPT_FILE=/etc/agent-config/mainloop-system.txt ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -# Substrate currently starts actors as root regardless of this image's USER setting. -USER 0:0 +# The pinned Substrate fork honors the image's runtime user and working directory. +USER 10001:10001 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh index 309ec8a..35ec798 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh +++ b/spikes/substrate-workspace-adapter/live-agent-image/entrypoint.sh @@ -2,44 +2,24 @@ # Start only the actor-local shim. Native CLIs run headlessly once per delivered turn. set -eu -AGENT_UID=10001 -AGENT_GID=10001 CURRENT_UID="$(id -u)" -export HOME=/home/agent +export HOME="${HOME:-/work/.home}" WORKSPACE_PATH="${WORKSPACE_PATH:-/work/repo}" EXEC_SHIM_STATE_DIR="${EXEC_SHIM_STATE_DIR:-${WORKSPACE_PATH}/.mainloop/exec-shim}" export WORKSPACE_PATH EXEC_SHIM_STATE_DIR -if [[ ${CURRENT_UID} -eq 0 && ${1-} != "--runtime-user" ]]; then - if ! command -v setpriv >/dev/null 2>&1; then - echo 'setpriv is required to run the actor shim without root' >&2 - exit 1 - fi - EXEC_SHIM_STATE_DIR="$(realpath -m "${EXEC_SHIM_STATE_DIR}")" - case "${EXEC_SHIM_STATE_DIR}" in - /work/*) ;; - *) - echo 'EXEC_SHIM_STATE_DIR must be under /work' >&2 - exit 1 - ;; - esac - export EXEC_SHIM_STATE_DIR - mkdir -p "${HOME}" /work "${EXEC_SHIM_STATE_DIR}" - chown -R "${AGENT_UID}:${AGENT_GID}" "${HOME}" /work - exec setpriv --reuid "${AGENT_UID}" --regid "${AGENT_GID}" --init-groups \ - --bounding-set=-all --no-new-privs -- "$0" --runtime-user -fi - -if [[ ${1-} == "--runtime-user" ]]; then - shift -fi if [[ ${CURRENT_UID} -eq 0 ]]; then echo 'entrypoint refused to continue as UID 0' >&2 exit 1 fi +if ! mkdir -p "${WORKSPACE_PATH}" || [[ ! -w ${WORKSPACE_PATH} ]]; then + printf 'entrypoint requires a writable workspace directory for UID %s: %s\n' \ + "${CURRENT_UID}" "${WORKSPACE_PATH}" >&2 + exit 1 +fi + node /usr/local/bin/prepare-native-agent-config.cjs -mkdir -p "${WORKSPACE_PATH}" [[ -d "${WORKSPACE_PATH}/.git" ]] || git -C "${WORKSPACE_PATH}" init -q # Credential-free marker retained for the bounded Gate 5 restore/suspend-resume probe. diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js index 3b13591..0e3fc94 100644 --- a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -90,29 +90,26 @@ test('exec shim refuses UID 0 unless its explicit test-only override is set', () assert.match(result.stderr, /exec-shim refuses to start as UID 0/); }); -test('actor image prepares its writable paths before dropping root privileges', () => { +test('actor image runs as its non-root user and checks the mounted workspace', () => { const imageDockerfile = fs.readFileSync(dockerfile, 'utf8'); const imageEntrypoint = fs.readFileSync(entrypoint, 'utf8'); - assert.ok(imageDockerfile.includes('util-linux')); - assert.match(imageDockerfile, /^USER 0:0$/m); - const statePreparation = imageEntrypoint.indexOf( - 'mkdir -p "${HOME}" /work "${EXEC_SHIM_STATE_DIR}"' + assert.match(imageDockerfile, /^USER 10001:10001$/m); + assert.match(imageDockerfile, /^WORKDIR \/work$/m); + assert.doesNotMatch(imageDockerfile, /util-linux|USER 0:0/); + assert.doesNotMatch(imageEntrypoint, /setpriv|--runtime-user|chown/); + const rootRefusal = imageEntrypoint.indexOf('entrypoint refused to continue as UID 0'); + const workspaceCheck = imageEntrypoint.search(/\[\[ ! -w "?\$\{WORKSPACE_PATH\}"? \]\]/); + const workspaceError = imageEntrypoint.indexOf( + 'entrypoint requires a writable workspace directory' + ); + const configPreparation = imageEntrypoint.indexOf( + 'node /usr/local/bin/prepare-native-agent-config.cjs' ); - const ownership = imageEntrypoint.indexOf('chown -R "${AGENT_UID}:${AGENT_GID}" "${HOME}" /work'); - const privilegeDrop = imageEntrypoint.indexOf('exec setpriv'); const shimStart = imageEntrypoint.indexOf('node "${EXEC_SHIM}"'); - assert.ok(statePreparation >= 0 && statePreparation < ownership); - assert.ok(ownership >= 0 && ownership < privilegeDrop); - assert.ok(privilegeDrop >= 0 && privilegeDrop < shimStart); - for (const option of [ - '--reuid "${AGENT_UID}"', - '--regid "${AGENT_GID}"', - '--init-groups', - '--bounding-set=-all', - '--no-new-privs' - ]) { - assert.ok(imageEntrypoint.includes(option), `entrypoint is missing ${option}`); - } + assert.ok(rootRefusal >= 0 && rootRefusal < workspaceCheck); + assert.ok(workspaceCheck >= 0 && workspaceCheck < configPreparation); + assert.ok(workspaceError >= 0 && workspaceError < configPreparation); + assert.ok(configPreparation >= 0 && configPreparation < shimStart); assert.equal(imageEntrypoint.includes('IS_SANDBOX'), false); assert.equal(imageDockerfile.includes('EXEC_SHIM_TEST_ALLOW_ROOT'), false); }); From f1080ff36544b6a5310c3288af04f84f64f06350 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:49:50 +0000 Subject: [PATCH 29/30] Close the #73 review findings for Substrate workspaces Deliver native-agent startup policy, tools and standing context through the actor shim as optional turn fields, validated and size-bounded in the shim and passed to the launcher by environment, never argv. Requests without startup options keep the existing behavior. Run Claude headless with stream-json output, which requires --verbose. Record each branch workspace's native-agent binding in the same transaction as its route, with an optional agent_kind (claude or codex, default claude). Bootstrap the per-actor shim token through the shim's one-time endpoint before authenticated calls, and keep provisioned shim Secrets in a dedicated namespace with a namespaced Role. Have the deployed backend call the Substrate API directly with its own projected ServiceAccount token (audience api.ate-system.svc), passed by --token-file, instead of port-forwarding into ate-system and minting an ate-client token. Its only new cluster permission is listing ClusterTrustBundles. Substrate does not yet check per-caller authorization, so this token grants full Substrate access; the architecture guide records that as a known gap. Fix the native binding update when no fields change, which produced an empty SET list on a second turn with unchanged context. Point the actor's mainloop helper at the backend in mainloop-control and keep its bearer token out of curl's argv. Build kubectl-ate from the pinned fork commit in the backend image. Document gVisor as the current actor sandbox with microVM deferred, and the non-root actor startup live check. --- README.md | 2 + backend/Dockerfile | 20 +++ backend/src/mainloop/config.py | 4 +- .../src/mainloop/runtime/native_sessions.py | 79 +++++++--- backend/src/mainloop/runtime/substrate.py | 12 ++ .../mainloop/runtime/substrate_workspace.py | 39 ++++- backend/src/mainloop/runtime/workspace_api.py | 10 +- backend/tests/__init__.py | 1 + backend/tests/runtime/__init__.py | 1 + backend/tests/runtime/test_gate5_setup.py | 5 + ...est_native_session_empty_binding_update.py | 118 +++++++++++++++ backend/tests/runtime/test_substrate.py | 45 ++++++ .../test_substrate_preview_manifest.py | 88 +++++++++++ .../tests/runtime/test_substrate_workspace.py | 119 ++++++++++++++- .../runtime/test_workspace_dev_manifest.py | 5 + .../test_workspace_provisioning_api.py | 36 +++++ docs/architecture.md | 15 +- docs/specs/workspaces.md | 10 +- docs/spikes/substrate-workspace-adapter.md | 6 + .../substrate-preview/backend-rbac.yaml | 34 +++-- .../overlays/substrate-preview/backend.yaml | 12 ++ .../overlays/substrate-preview/configmap.yaml | 3 + .../substrate-preview/kustomization.yaml | 3 +- .../overlays/substrate-preview/namespace.yaml | 7 + .../substrate-preview/shim-secret-rbac.yaml | 23 +++ .../k8s/actor-template.yaml.tmpl | 1 + .../live-agent-image/Dockerfile | 1 + .../live-agent-image/bin/mainloop | 14 +- .../live-agent-image/bin/start-native-agent | 119 ++++++++++++++- .../live-agent-image/exec-shim.js | 84 ++++++++++- .../tests/exec-shim.test.js | 141 +++++++++++++++++- 31 files changed, 1001 insertions(+), 56 deletions(-) create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/runtime/__init__.py create mode 100644 backend/tests/runtime/test_native_session_empty_binding_update.py create mode 100644 backend/tests/runtime/test_substrate_preview_manifest.py create mode 100644 k8s/apps/mainloop/overlays/substrate-preview/shim-secret-rbac.yaml diff --git a/README.md b/README.md index d67556c..602903a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ You (phone/laptop) - **Sessions**: Native Claude Code or Codex work with their own conversations; appear as colored threads in your timeline - **Notifications**: Slack-style thread replies notify you when sessions need attention or complete - **Persistence**: Mainloop stores conversations, delivery records, and workspace lifecycle state in PostgreSQL; native history remains with the provider CLI in Substrate +- **Runtime isolation**: Substrate workspaces use gVisor actors. The pinned fork honors the + agent image's non-root UID `10001`; microVM isolation is deferred. ## Quick Start diff --git a/backend/Dockerfile b/backend/Dockerfile index 3401de1..d96c6b9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,3 +1,21 @@ +FROM golang:1.27.0-bookworm AS substrate-cli + +ARG SUBSTRATE_REPOSITORY=https://github.com/oldsj/substrate.git +ARG SUBSTRATE_COMMIT=ce265c1dbd3775faf10c95f71f2c16ff3d47c332 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +RUN git init \ + && git remote add origin "${SUBSTRATE_REPOSITORY}" \ + && git fetch --depth=1 origin "${SUBSTRATE_COMMIT}" \ + && git checkout --detach FETCH_HEAD \ + && mkdir -p /out \ + && CGO_ENABLED=0 go build -trimpath -o /out/kubectl-ate ./cmd/kubectl-ate + # Build stage - use uv image with Python pre-installed FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder @@ -40,6 +58,7 @@ WORKDIR /app # Copy dependency files (owned by claude for live updates) COPY --from=builder --chown=mainloop:mainloop /app/.venv /app/.venv +COPY --from=substrate-cli /out/kubectl-ate /usr/local/bin/kubectl-ate COPY --chown=mainloop:mainloop backend/pyproject.toml backend/uv.lock backend/README.md ./ # Copy source code (owned by claude) @@ -74,6 +93,7 @@ WORKDIR /app # Copy the virtual environment from builder (owned by claude) COPY --from=builder --chown=mainloop:mainloop /app/.venv /app/.venv +COPY --from=substrate-cli /out/kubectl-ate /usr/local/bin/kubectl-ate # Copy source code (owned by claude) COPY --chown=mainloop:mainloop backend/src ./src diff --git a/backend/src/mainloop/config.py b/backend/src/mainloop/config.py index 8f851bb..696f67a 100644 --- a/backend/src/mainloop/config.py +++ b/backend/src/mainloop/config.py @@ -39,7 +39,7 @@ def database_url(self) -> str: substrate_router_address: str = ( "http://atenet-router.ate-system.svc.cluster.local:8081" ) - substrate_shim_secret_namespace: str = "mainloop-control" + substrate_shim_secret_namespace: str = "mainloop-shim-secrets" substrate_credential_secret_namespace: str = "mainloop-control" substrate_credential_secret_prefix: str = "mainloop-credential" substrate_credential_account: str = "owner" @@ -65,6 +65,8 @@ def shim_token_secret_name(self, atespace: str, actor: str) -> str: # Substrate actor lifecycle control. Empty kubeconfig/context falls back to ambient config. substrate_kubeconfig: str = "" substrate_context: str = "" + substrate_endpoint: str = "" + substrate_token_file: str = "" substrate_atespace: str = "mainloop-workspaces" substrate_actor_template: str = "mainloop-workspace" substrate_cli: str = "kubectl-ate" diff --git a/backend/src/mainloop/runtime/native_sessions.py b/backend/src/mainloop/runtime/native_sessions.py index 1131a93..f2d1b82 100644 --- a/backend/src/mainloop/runtime/native_sessions.py +++ b/backend/src/mainloop/runtime/native_sessions.py @@ -94,26 +94,36 @@ def is_rotating(session_id: str) -> bool: def workspace_for(binding: dict) -> SubstrateWorkspace: - """Map a native binding to the Substrate actor configured for its agent kind.""" + """Map a native binding to its branch actor or configured shared actor.""" agent = binding["kind"] - actor_binding = settings.substrate_actor_bindings.get(agent) - if actor_binding is None: - raise RuntimeError( - f"no Substrate actor binding is configured for native agent {agent}" - ) + atespace = binding.get("workspace_atespace") + actor = binding.get("workspace_actor_name") + secret_name = binding.get("workspace_shim_token_secret_name") + workspace_route = (atespace, actor, secret_name) + if any(workspace_route) and not all(workspace_route): + raise RuntimeError("native workspace binding has an incomplete actor route") + if not all(workspace_route): + actor_binding = settings.substrate_actor_bindings.get(agent) + if actor_binding is None: + raise RuntimeError( + f"no Substrate actor binding is configured for native agent {agent}" + ) + atespace = actor_binding.atespace + actor = actor_binding.actor + secret_name = actor_binding.shim_token_secret_name key = ( binding["session_id"], - actor_binding.atespace, - actor_binding.actor, - actor_binding.shim_token_secret_name, + atespace, + actor, + secret_name, agent, ) if key not in _workspaces: _workspaces[key] = SubstrateWorkspace( - atespace=actor_binding.atespace, - actor=actor_binding.actor, + atespace=atespace, + actor=actor, agent=agent, - shim_token_secret_name=actor_binding.shim_token_secret_name, + shim_token_secret_name=secret_name, logical_session_id=binding["session_id"], native_session_id=binding.get("native_session_id"), ) @@ -150,19 +160,28 @@ def agent_name(session_id: str, kind: str) -> str: return f"ml-{kind}-{session_id[:8]}" -async def get_binding(session_id: str) -> dict | None: - async with db.connection() as conn: - row = await conn.fetchrow( - "SELECT * FROM native_bindings WHERE session_id=$1", session_id - ) +async def get_binding(session_id: str, *, conn=None) -> dict | None: + query = """SELECT b.*, + w.atespace AS workspace_atespace, + w.actor_name AS workspace_actor_name, + w.shim_token_secret_name AS workspace_shim_token_secret_name + FROM native_bindings b + LEFT JOIN workspace_bindings w ON w.workspace_id=b.session_id + WHERE b.session_id=$1""" + if conn is None: + async with db.connection() as connection: + row = await connection.fetchrow(query, session_id) + else: + row = await conn.fetchrow(query, session_id) return dict(row) if row else None async def _update_binding(session_id: str, **fields) -> None: - sets = ", ".join(f"{k}=${i + 2}" for i, k in enumerate(fields)) + sets = [f"{k}=${i + 2}" for i, k in enumerate(fields)] + sets.append("updated_at=NOW()") async with db.connection() as conn: await conn.execute( - f"UPDATE native_bindings SET {sets}, updated_at=NOW() WHERE session_id=$1", # nosec B608 - column names come from code, values are bound + f"UPDATE native_bindings SET {', '.join(sets)} WHERE session_id=$1", # nosec B608 - column names come from code, values are bound session_id, *fields.values(), ) @@ -196,6 +215,7 @@ async def create_binding( role: str = "agent", parent_session_id: str | None = None, topic_id: str | None = None, + conn=None, ) -> dict: # Claude takes the native session id up front (--session-id); Codex reports it in its journal. native_id = str(uuid.uuid4()) if kind == "claude" else None @@ -203,8 +223,9 @@ async def create_binding( token_hash = ( hash_token(token_for(session_id)) if role in ("main", "child") else None ) - async with db.connection() as conn: - await conn.execute( + + async def insert_binding(connection) -> None: + await connection.execute( """INSERT INTO native_bindings (session_id, kind, agent_name, native_session_id, approval_policy, role, parent_session_id, topic_id, token_hash, model) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)""", @@ -220,12 +241,18 @@ async def create_binding( settings.main_thread_model if role == "main" else None, ) if role == "main" and native_id: - await conn.execute( + await connection.execute( "INSERT INTO native_lineage (session_id, seq, native_session_id, started_reason) VALUES ($1,1,$2,'create')", session_id, native_id, ) - return await get_binding(session_id) # type: ignore[return-value] + + if conn is None: + async with db.connection() as connection: + await insert_binding(connection) + return await get_binding(session_id) # type: ignore[return-value] + await insert_binding(conn) + return await get_binding(session_id, conn=conn) # type: ignore[return-value] async def _open_count(session_id: str) -> int: @@ -352,6 +379,7 @@ async def _start_extra(binding: dict) -> tuple[dict[str, str], str | None]: ), "--token": token_for(binding["session_id"]), "--standing-b64": base64.b64encode(standing.encode()).decode(), + "--approval-policy": binding["approval_policy"], } if binding["role"] == "main": extra["--model"] = settings.main_thread_model @@ -366,10 +394,10 @@ async def _ensure_agent(session_id: str, binding: dict) -> dict: name = binding["agent_name"] resume = binding["journal_ref"] is not None status = await ws.agent_status(name) + extra, standing_hash = await _start_extra(binding) fields: dict = {} if status is None: # A journal already seen for this native session id means an earlier run: resume it. - extra, standing_hash = await _start_extra(binding) await ws.start( binding["kind"], name, @@ -382,8 +410,11 @@ async def _ensure_agent(session_id: str, binding: dict) -> dict: fields["standing_hash"] = standing_hash else: ws.set_resume_history(resume) + ws.set_startup_options(extra) # An already running agent may have resumed from a parked actor snapshot. await ws.prepare_credentials() + if standing_hash and standing_hash != binding.get("standing_hash"): + fields["standing_hash"] = standing_hash await _update_binding(session_id, **fields) return await get_binding(session_id) # type: ignore[return-value] diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py index f78edde..6b090fc 100644 --- a/backend/src/mainloop/runtime/substrate.py +++ b/backend/src/mainloop/runtime/substrate.py @@ -200,16 +200,28 @@ def __init__( *, kubeconfig: str | None = None, context: str | None = None, + endpoint: str | None = None, + token_file: str | None = None, cli: str | None = None, ): self.kubeconfig = ( kubeconfig if kubeconfig is not None else settings.substrate_kubeconfig ) self.context = context if context is not None else settings.substrate_context + self.endpoint = ( + endpoint if endpoint is not None else settings.substrate_endpoint + ) + self.token_file = ( + token_file if token_file is not None else settings.substrate_token_file + ) self.cli = cli or settings.substrate_cli def _base_args(self) -> list[str]: args = [self.cli] + if self.endpoint: + args += ["--endpoint", self.endpoint] + if self.token_file: + args += ["--token-file", self.token_file] if self.kubeconfig: args += ["--kubeconfig", self.kubeconfig] if self.context: diff --git a/backend/src/mainloop/runtime/substrate_workspace.py b/backend/src/mainloop/runtime/substrate_workspace.py index 27d1a7b..9409066 100644 --- a/backend/src/mainloop/runtime/substrate_workspace.py +++ b/backend/src/mainloop/runtime/substrate_workspace.py @@ -246,6 +246,8 @@ def __init__( if not _DNS_LABEL.fullmatch(self.secret_name): raise ValueError("Substrate shim Secret name must be a DNS label") self._token_value: str | None = None + self._token_installed = False + self._startup_options: dict[str, str] | None = None self.credential_broker = credential_broker or CredentialBroker() def set_native_session_id(self, native_session_id: str | None) -> None: @@ -262,6 +264,19 @@ async def _token(self) -> str: ) return self._token_value + async def _install_token(self) -> None: + """Bootstrap a newly provisioned shim from its control-plane Secret.""" + if self._token_installed: + return + response = await self._request( + "POST", "/token", body={"token": await self._token()}, authenticated=False + ) + if response.status not in (201, 409): + raise RuntimeError( + f"Substrate shim token bootstrap failed (HTTP {response.status})" + ) + self._token_installed = True + async def _request( self, method: str, @@ -270,6 +285,8 @@ async def _request( body: dict | None = None, authenticated: bool = True, ) -> _Response: + if authenticated: + await self._install_token() token = await self._token() if authenticated else None response = await self._exchange(method, path, token=token, body=body) if response.status == 401 and authenticated: @@ -387,9 +404,10 @@ async def start( resume: bool, extra: dict[str, str] | None = None, ) -> dict: - del binding, name, extra + del binding, name self.native_session_id = native_id self._resume_history = resume + self.set_startup_options(extra) await self.require_ready() await self.prepare_credentials() query = urlencode({"agent": self.agent}) @@ -399,6 +417,23 @@ async def start( # Starting the native CLI remains the shim's turn API's responsibility. return {"actor": self.actor} + def set_startup_options(self, extra: dict[str, str] | None) -> None: + """Keep the role-specific options for each shim-launched native turn.""" + if not extra: + self._startup_options = None + return + self._startup_options = { + "cwd_rel": extra["--cwd-rel"], + "token": extra["--token"], + "standing_b64": extra["--standing-b64"], + "approval_policy": extra["--approval-policy"], + **( + {"model": extra["--model"], "effort": extra["--effort"]} + if "--model" in extra and "--effort" in extra + else {} + ), + } + async def send(self, name: str, text: str) -> None: if not name: raise ValueError("native agent name is required") @@ -408,6 +443,8 @@ async def send(self, name: str, text: str) -> None: "session_key": self._require_session_key(), "resume": self._resume_history, } + if self._startup_options is not None: + payload["startup_options"] = self._startup_options if self.native_session_id: payload["session_id"] = self.native_session_id response = await self._request("POST", "/turn", body=payload) diff --git a/backend/src/mainloop/runtime/workspace_api.py b/backend/src/mainloop/runtime/workspace_api.py index 98d4c9f..165a559 100644 --- a/backend/src/mainloop/runtime/workspace_api.py +++ b/backend/src/mainloop/runtime/workspace_api.py @@ -3,7 +3,7 @@ import json import uuid from datetime import UTC, datetime -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Header, HTTPException, Response from fastapi.responses import JSONResponse @@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator from models import ( + WorkspaceAgentKind, WorkspaceDev, WorkspaceLifecycle, WorkspaceManifest, @@ -37,6 +38,7 @@ class CreateWorkspaceRequest(BaseModel): project_id: Annotated[StrictStr, Field(min_length=1)] branch: Annotated[StrictStr, Field(min_length=1)] dev: WorkspaceDev + agent_kind: Literal["claude", "codex"] = "claude" model_config = ConfigDict(extra="forbid", strict=True) @@ -95,6 +97,7 @@ async def create_workspace( manifest = WorkspaceManifest( repo_url=project["html_url"], branch=request.branch, + agent_kinds=(WorkspaceAgentKind(request.agent_kind),), resource_class="default", dev=request.dev, ) @@ -157,6 +160,11 @@ async def create_workspace( json.dumps(manifest.model_dump(mode="json")), now, ) + from mainloop.runtime import native_sessions + + await native_sessions.create_binding( + workspace_id, request.agent_kind, conn=conn + ) try: provisioner = get_actor_provisioner() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..c1e1fda --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Mainloop backend test package for unittest discovery.""" diff --git a/backend/tests/runtime/__init__.py b/backend/tests/runtime/__init__.py new file mode 100644 index 0000000..22b2146 --- /dev/null +++ b/backend/tests/runtime/__init__.py @@ -0,0 +1 @@ +"""Runtime adapter and API tests.""" diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index 800f327..89013ca 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -70,6 +70,11 @@ def test_worker_pool_option_drives_product_template_selector_and_labels(self): self.assertIn("workload: isolated-pool", worker_pool) self.assertIn("name: live-agent-gate-v3", actor_template) self.assertIn("workload: isolated-pool", actor_template) + self.assertIn( + "name: MAINLOOP_API, value: " + "http://mainloop-backend.mainloop-control.svc.cluster.local:8000", + actor_template, + ) self.assertIn("mountPath: /work", actor_template) self.assertIn("value: /work/repo", actor_template) self.assertIn("durableDir: {}", actor_template) diff --git a/backend/tests/runtime/test_native_session_empty_binding_update.py b/backend/tests/runtime/test_native_session_empty_binding_update.py new file mode 100644 index 0000000..b9c27fb --- /dev/null +++ b/backend/tests/runtime/test_native_session_empty_binding_update.py @@ -0,0 +1,118 @@ +"""Regression coverage for an unchanged native child binding on consecutive turns.""" + +from __future__ import annotations + +import asyncio +import unittest +from unittest.mock import AsyncMock, patch + +from mainloop.runtime import native_sessions +from mainloop.runtime.standing import content_hash + + +class _Connection: + def __init__(self): + self.executions: list[tuple[str, tuple]] = [] + + async def execute(self, query: str, *args): + self.executions.append((query, args)) + + +class _ConnectionContext: + def __init__(self, connection: _Connection): + self.connection = connection + + async def __aenter__(self): + return self.connection + + async def __aexit__(self, *_args): + return None + + +class _RunningWorkspace: + def __init__(self): + self.sent: list[str] = [] + + async def require_ready(self): + return None + + async def agent_status(self, _name: str): + return {"status": "running"} + + def set_resume_history(self, _resume: bool): + return None + + def set_startup_options(self, _options: dict): + return None + + async def prepare_credentials(self): + return None + + async def send(self, _name: str, text: str): + self.sent.append(text) + + +class NativeSessionEmptyBindingUpdateTests(unittest.TestCase): + def test_two_turns_with_unchanged_child_context_use_valid_update_sql(self): + async def exercise(): + standing = "Stable child context" + binding = { + "session_id": "child-session-fixture", + "kind": "claude", + "role": "child", + "agent_name": "ml-claude-child-fixture", + "native_session_id": None, + "journal_ref": "journal-fixture", + "generation": 1, + "standing_hash": content_hash(standing), + "approval_policy": "bypass-permissions", + } + connection = _Connection() + workspace = _RunningWorkspace() + + async def render_child_context(_binding): + return standing + + with ( + patch.object( + native_sessions, + "get_binding", + new=AsyncMock(side_effect=lambda _sid: dict(binding)), + ), + patch.object(native_sessions, "workspace_for", return_value=workspace), + patch.object( + native_sessions, + "token_for", + return_value="ml_" + "a" * 64, + ), + patch( + "mainloop.runtime.delegation.render_for_binding", + new=AsyncMock(side_effect=render_child_context), + ), + patch.object( + native_sessions.db, + "connection", + side_effect=lambda: _ConnectionContext(connection), + ), + patch.object(native_sessions, "_set_delivery", new=AsyncMock()), + patch.object(native_sessions, "sync", new=AsyncMock()), + ): + await native_sessions._deliver( + binding["session_id"], "message-one", "first turn" + ) + await native_sessions._deliver( + binding["session_id"], "message-two", "second turn" + ) + + self.assertEqual(workspace.sent, ["first turn", "second turn"]) + self.assertEqual(len(connection.executions), 2) + for query, args in connection.executions: + self.assertIn("SET updated_at=NOW()", query) + self.assertNotIn("SET ,", query) + self.assertEqual(args, (binding["session_id"],)) + + asyncio.run(exercise()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_substrate.py b/backend/tests/runtime/test_substrate.py index 82c39b3..eefd35c 100644 --- a/backend/tests/runtime/test_substrate.py +++ b/backend/tests/runtime/test_substrate.py @@ -157,6 +157,51 @@ def test_missing_status_defaults_to_unspecified(self): class SubstrateControlTests(unittest.TestCase): + def test_base_args_use_direct_endpoint_and_projected_token_file_in_cluster(self): + projected_token_path = "/var/run/secrets/tokens/substrate-api/token" # nosec B105 - path only; the projected token contents are not arguments + ctl = SubstrateControl( + cli="kubectl-ate", + endpoint="api.ate-system.svc:443", + token_file=projected_token_path, + kubeconfig="", + context="", + ) + + args = ctl._base_args() + + self.assertEqual( + args, + [ + "kubectl-ate", + "--endpoint", + "api.ate-system.svc:443", + "--token-file", + projected_token_path, + ], + ) + self.assertNotIn("fixture-bearer-token", args) + + def test_base_args_keep_configured_local_kubeconfig_and_context(self): + empty = "" + ctl = SubstrateControl( + cli="kubectl-ate", + kubeconfig="/fixture/kubeconfig", + context="kind-substrate-preview", + endpoint=empty, + token_file=empty, + ) + + self.assertEqual( + ctl._base_args(), + [ + "kubectl-ate", + "--kubeconfig", + "/fixture/kubeconfig", + "--context", + "kind-substrate-preview", + ], + ) + def test_get_actor_parses_json_and_uses_argv_not_shell(self): ctl = FakeControl([ExecResult(0, actor_json("ACTOR_STATE_RUNNING"), "")]) actor = run(ctl.get_actor("mainloop-workspaces", "ml-abc")) diff --git a/backend/tests/runtime/test_substrate_preview_manifest.py b/backend/tests/runtime/test_substrate_preview_manifest.py new file mode 100644 index 0000000..c898018 --- /dev/null +++ b/backend/tests/runtime/test_substrate_preview_manifest.py @@ -0,0 +1,88 @@ +"""Manifest contract for the preview backend's direct Substrate API access.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[3] +OVERLAY = ROOT / "k8s/apps/mainloop/overlays/substrate-preview" + + +def load_documents(path: Path) -> list[dict]: + return [doc for doc in yaml.safe_load_all(path.read_text()) if doc] + + +class SubstratePreviewManifestTests(unittest.TestCase): + def test_backend_mounts_audience_scoped_token_for_the_direct_endpoint(self): + deployment = load_documents(OVERLAY / "backend.yaml")[0] + configmap = next( + doc + for doc in load_documents(OVERLAY / "configmap.yaml") + if doc["kind"] == "ConfigMap" + ) + pod = deployment["spec"]["template"]["spec"] + container = pod["containers"][0] + volume = next( + volume + for volume in pod["volumes"] + if volume["name"] == "substrate-api-token" + ) + token_projection = volume["projected"]["sources"][0]["serviceAccountToken"] + + self.assertEqual(pod["serviceAccountName"], "mainloop-backend") + self.assertEqual( + token_projection, + { + "audience": "api.ate-system.svc", + "expirationSeconds": 3600, + "path": "token", + }, + ) + self.assertIn( + { + "name": "substrate-api-token", + "mountPath": "/var/run/secrets/tokens/substrate-api", + "readOnly": True, + }, + container["volumeMounts"], + ) + self.assertEqual( + configmap["data"]["SUBSTRATE_ENDPOINT"], "api.ate-system.svc:443" + ) + self.assertEqual( + configmap["data"]["SUBSTRATE_TOKEN_FILE"], + "/var/run/secrets/tokens/substrate-api/token", + ) + + def test_cluster_role_only_lists_substrate_trust_bundles(self): + documents = load_documents(OVERLAY / "backend-rbac.yaml") + cluster_role = next(doc for doc in documents if doc["kind"] == "ClusterRole") + binding = next(doc for doc in documents if doc["kind"] == "ClusterRoleBinding") + + self.assertEqual( + cluster_role["rules"], + [ + { + "apiGroups": ["certificates.k8s.io"], + "resources": ["clustertrustbundles"], + "verbs": ["list"], + } + ], + ) + self.assertEqual( + binding["subjects"], + [ + { + "kind": "ServiceAccount", + "name": "mainloop-backend", + "namespace": "mainloop-control", + } + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/runtime/test_substrate_workspace.py b/backend/tests/runtime/test_substrate_workspace.py index 5a98b0d..f4cf92d 100644 --- a/backend/tests/runtime/test_substrate_workspace.py +++ b/backend/tests/runtime/test_substrate_workspace.py @@ -26,10 +26,11 @@ class FakeRouterAndShim: """In-process CONNECT/router and authenticated shim model for transport contracts.""" - def __init__(self): + def __init__(self, *, token_installed=True): self.suspended = False self.capacity = False - self.expected_token = FIXTURE_VALUE + self.expected_token = FIXTURE_VALUE if token_installed else None + self.token_installed = token_installed self.inflight: set[tuple[str, str]] = set() self.turns: dict[tuple[str, str], dict] = {} self.journal_lines = [ @@ -57,8 +58,17 @@ def request( return _Response(503, "capacity unavailable") body = body or {} self.requests.append((method, path, body)) - if (method, path) != ("GET", "/healthz") and token != self.expected_token: + if (method, path) not in ( + ("GET", "/healthz"), + ("POST", "/token"), + ) and token != self.expected_token: return _Response(401, "unauthorized") + if method == "POST" and path == "/token": + if self.token_installed: + return _Response(409, "token already set") + self.token_installed = True + self.expected_token = body.get("token") + return _Response(201, "token set") if method == "GET" and path == "/healthz": if self.suspended: self.suspended = False @@ -224,6 +234,66 @@ def test_same_provider_bindings_have_distinct_logical_transports(self): for key in keys: native_sessions._workspaces.pop(key, None) + def test_branch_binding_uses_its_workspace_actor(self): + binding = { + "session_id": "workspace-session-fixture", + "kind": "claude", + "workspace_atespace": "workspace-space", + "workspace_actor_name": "workspace-actor", + "workspace_shim_token_secret_name": "workspace-shim", + } + key = ( + binding["session_id"], + binding["workspace_atespace"], + binding["workspace_actor_name"], + binding["workspace_shim_token_secret_name"], + binding["kind"], + ) + with patch.object(settings, "substrate_actor_bindings", {}): + try: + workspace = native_sessions.workspace_for(binding) + self.assertEqual(workspace.atespace, "workspace-space") + self.assertEqual(workspace.actor, "workspace-actor") + self.assertEqual(workspace.secret_name, "workspace-shim") + finally: + native_sessions._workspaces.pop(key, None) + + def test_incomplete_workspace_binding_does_not_fall_back_to_shared_actor(self): + binding = { + "session_id": "workspace-session-fixture", + "kind": "claude", + "workspace_atespace": "workspace-space", + "workspace_actor_name": None, + "workspace_shim_token_secret_name": "workspace-shim", + } + with patch.object(settings, "substrate_actor_bindings", {}): + with self.assertRaisesRegex(RuntimeError, "incomplete actor route"): + native_sessions.workspace_for(binding) + + def test_dynamic_shim_token_is_bootstrapped_before_authenticated_calls(self): + async def exercise(): + router = FakeRouterAndShim(token_installed=False) + workspace = fake_workspace(router) + + await workspace.send("agent-fixture", "fixture prompt") + + token_requests = [ + (index, body) + for index, (method, path, body) in enumerate(router.requests) + if method == "POST" and path == "/token" + ] + turn_requests = [ + index + for index, (method, path, _body) in enumerate(router.requests) + if method == "POST" and path == "/turn" + ] + self.assertEqual(token_requests, [(0, {"token": FIXTURE_VALUE})]) + self.assertTrue(turn_requests) + self.assertLess(token_requests[0][0], turn_requests[0]) + self.assertEqual(router.expected_token, FIXTURE_VALUE) + + asyncio.run(exercise()) + def test_codex_start_and_resume_install_only_synthetic_auth(self): async def exercise(): router = FakeRouterAndShim() @@ -278,6 +348,12 @@ async def exercise(): ) await workspace.send("agent-fixture", "fixture prompt") + turn = next( + body + for method, path, body in router.requests + if method == "POST" and path == "/turn" + ) + self.assertNotIn("startup_options", turn) status = await workspace.agent_status("agent-fixture") self.assertEqual(status["status"], "running") self.assertEqual( @@ -316,6 +392,43 @@ async def exercise(): asyncio.run(exercise()) + def test_startup_options_are_forwarded_with_each_native_turn(self): + async def exercise(): + router = FakeRouterAndShim() + workspace = fake_workspace(router) + extra = { + "--cwd-rel": "main", + "--token": "ml_" + "a" * 64, + "--standing-b64": "c3RhbmRpbmcgY29udGV4dA==", + "--approval-policy": "restricted: Bash(mainloop:*) only", + "--model": "sonnet", + "--effort": "medium", + } + + await workspace.start( + "claude", "ml-main", native_id=None, resume=False, extra=extra + ) + await workspace.send("ml-main", "fixture prompt") + + turn = next( + body + for method, path, body in router.requests + if method == "POST" and path == "/turn" + ) + self.assertEqual( + turn["startup_options"], + { + "cwd_rel": "main", + "token": "ml_" + "a" * 64, + "standing_b64": "c3RhbmRpbmcgY29udGV4dA==", + "approval_policy": "restricted: Bash(mainloop:*) only", + "model": "sonnet", + "effort": "medium", + }, + ) + + asyncio.run(exercise()) + def test_established_native_session_is_marked_for_resume_on_next_turn(self): async def exercise(): router = FakeRouterAndShim() diff --git a/backend/tests/runtime/test_workspace_dev_manifest.py b/backend/tests/runtime/test_workspace_dev_manifest.py index fc56c7f..e319164 100644 --- a/backend/tests/runtime/test_workspace_dev_manifest.py +++ b/backend/tests/runtime/test_workspace_dev_manifest.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import AsyncMock, Mock, patch +from mainloop.config import settings from mainloop.runtime.actor_provisioner import ( FakeActorProvisioner, SubstrateActorProvisioner, @@ -135,6 +136,10 @@ async def test_substrate_provisioner_stores_a_random_token_in_secret(self): secret = core_api.create_namespaced_secret.call_args.args[1] self.assertEqual(base64.b64decode(secret.data["token"]), b"private-token") + self.assertEqual( + core_api.create_namespaced_secret.call_args.args[0], + settings.substrate_shim_secret_namespace, + ) self.assertEqual(result.actor, actor) self.assertEqual(result.shim_token_secret_name, "ml-branch-1-shim") control.create_actor.assert_not_awaited() diff --git a/backend/tests/runtime/test_workspace_provisioning_api.py b/backend/tests/runtime/test_workspace_provisioning_api.py index b5283e5..7516774 100644 --- a/backend/tests/runtime/test_workspace_provisioning_api.py +++ b/backend/tests/runtime/test_workspace_provisioning_api.py @@ -1,5 +1,6 @@ """Project ownership and actor provisioning orchestration use fakes.""" +import json import unittest from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -22,6 +23,7 @@ class FakeConnection: def __init__(self, *, project=True): self.project = project self.statements = [] + self.native_binding = None self.workspace_row = { "atespace": "mainloop-workspaces", "actor_name": "ml-workspace", @@ -45,10 +47,35 @@ async def fetchrow(self, query, *_args): return None if "FROM workspace_bindings" in query: return self.workspace_row + if "FROM native_bindings b" in query: + if self.native_binding is None: + return None + return { + **self.native_binding, + "workspace_atespace": self.workspace_row["atespace"], + "workspace_actor_name": self.workspace_row["actor_name"], + "workspace_shim_token_secret_name": self.workspace_row[ + "shim_token_secret_name" + ], + } raise AssertionError(f"unexpected query: {query}") async def execute(self, query, *args): self.statements.append((query, args)) + if "INSERT INTO workspace_bindings" in query: + self.workspace_row = { + "atespace": args[1], + "actor_name": args[2], + "shim_token_secret_name": args[4], + } + if "INSERT INTO native_bindings" in query: + self.native_binding = { + "session_id": args[0], + "kind": args[1], + "agent_name": args[2], + "native_session_id": args[3], + "approval_policy": args[4], + } def fake_connection(connection): @@ -134,6 +161,15 @@ async def test_create_persists_identity_and_provisions_one_actor(self): for query, _ in connection.statements ) ) + self.assertEqual(connection.native_binding["session_id"], result.workspace_id) + self.assertEqual(connection.native_binding["kind"], "claude") + self.assertTrue(connection.native_binding["native_session_id"]) + lifecycle_insert = next( + args + for query, args in connection.statements + if "INSERT INTO workspace_lifecycles" in query + ) + self.assertEqual(json.loads(lifecycle_insert[1])["agent_kinds"], ["claude"]) kwargs = fake_create.await_args.kwargs self.assertEqual(kwargs["template"], "project-template") self.assertEqual( diff --git a/docs/architecture.md b/docs/architecture.md index 64ca750..ae73400 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -166,14 +166,21 @@ Kubernetes sign-in job and the external egress credential-provider contract are ## Isolation and known gaps -- Substrate actors use gVisor isolation. The router admits actor control traffic from the - Mainloop control namespace; actor shim requests use a distinct token for each actor. +- Substrate actors use gVisor isolation; microVM isolation is deferred. The router admits actor + control traffic from the Mainloop control namespace, and each actor has a distinct shim token. + By default, shim token Secrets live in a namespace separate from provider credentials. - Actor egress is restricted by host allowlists and passes through the egress proxy. - Workspace lifecycle and credential paths have fake-backed coverage; their combined behavior has not been verified end to end on a live cluster. +- The backend authenticates to the pinned Substrate API (`0f9635aed37bd5dde604a9bca1975421cd07181a`) + with a projected ServiceAccount token whose audience is `api.ate-system.svc`. That API verifies + trusted tokens with this audience but does not check caller authorization through its OpenFGA + model yet, so the token effectively grants full Substrate access. This is a known authorization + gap; the ClusterTrustBundle permission is limited to `list`. - Mainloop pins a Substrate fork whose actor runtime runs containers as the image's `USER` in - its `WORKDIR`; the agent image runs as UID `10001` and refuses UID 0. A fresh `durableDir` - must be writable by that user for the workspace to start, which still needs a live check. + its `WORKDIR`; the agent image runs as UID `10001` and refuses UID 0. Non-root actor startup was + live checked on Kind with fork commit `ce265c1d`. The durable workspace directory must be + writable by that user. - The actor's observed `RLIMIT_NOFILE` is 1024. - Restoring Postgres from an actor snapshot is unverified. - Live wake-on-preview is unverified. diff --git a/docs/specs/workspaces.md b/docs/specs/workspaces.md index 7f9b380..852caab 100644 --- a/docs/specs/workspaces.md +++ b/docs/specs/workspaces.md @@ -4,6 +4,10 @@ Workspaces are runtime resources attached to sessions. A project branch workspac Substrate actor and lifecycle. Workspace lifecycle is separate from the session's task status, native-agent activity, message delivery, user attention, and publication state. +Workspace actors currently use Substrate's gVisor sandbox; microVM isolation is deferred. The +Mainloop agent image runs as UID `10001`, and the pinned Substrate fork honors that image user. +Non-root actor startup was live checked on Kind with fork commit `ce265c1d`. + ## Lifecycle Mainloop records desired state (`running` or `suspended`), observed state, conditions, the last @@ -37,7 +41,9 @@ from the manifest until the workspace is running. A preview request itself may w - `GET /workspaces` lists the current user's workspace lifecycle records. - `GET /workspaces/{id}` returns one workspace lifecycle and manifest. - `POST /workspaces` accepts a project ID, branch, and strict dev manifest, then provisions one - actor from its declared actor template or the configured default template. + actor from its declared actor template or the configured default template. It also creates a + native-agent binding that routes the workspace session to that actor. An optional top-level + `agent_kind` (`claude` or `codex`, default `claude`) selects the binding's native agent. - `POST /workspaces/{id}/suspend` records the desired state and requests suspension. - `POST /workspaces/{id}/resume` records the desired state and requests resumption. - `POST /workspaces/{id}/refresh` reads Substrate status without changing desired state. @@ -45,6 +51,8 @@ from the manifest until the workspace is running. A preview request itself may w The preview proxy can call the same `touch_workspace(workspace_id, reason)` service API. - `DELETE /workspaces/{id}` deletes the actor and its shim token Secret. Open deliveries return `409`; an unconfirmed Substrate deletion keeps the durable workspace binding for reconciliation. +- The control plane generates one shim bearer token per actor, stores it in the configured shim + Secret namespace, and installs it through the actor shim's one-time bootstrap endpoint. - `GET /workspaces/{id}/ports` lists declared or shim-reported preview ports and their URLs. - `GET /workspaces/{id}/credentials` reports provider availability and expiry metadata only. - `POST /workspaces/{id}/credentials/{provider}/reauth` starts a bounded control-side sign-in Job. diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index f389b8e..848c986 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -488,6 +488,12 @@ durable backend integration and snapshot access controls are not proved. Production requirements: +- The backend's projected ServiceAccount token for Substrate API commit + `0f9635aed37bd5dde604a9bca1975421cd07181a` has audience `api.ate-system.svc`. The API accepts + any valid cluster ServiceAccount token with that audience and does not yet enforce per-caller + authorization through the OpenFGA model. The backend token therefore grants effectively full + Substrate access. This is a known authorization gap; enforce caller-level authorization before + production adoption. - Isolate worker selection by tenant. Substrate does not scope selection by atespace or namespace; require unique per-tenant WorkerPool names/selectors and enforce uniqueness with admission policy. diff --git a/k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml b/k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml index 5d2f870..c0442a4 100644 --- a/k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml +++ b/k8s/apps/mainloop/overlays/substrate-preview/backend-rbac.yaml @@ -8,15 +8,9 @@ automountServiceAccountToken: true apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: mainloop-read-shim-tokens + name: mainloop-backend-runtime namespace: mainloop-control rules: - - apiGroups: [''] - resources: [secrets] - resourceNames: - - mainloop-shim-live-agent-gate-headless-claude-reproof - - mainloop-shim-native-codex-headless-codex-reproof - verbs: [get] - apiGroups: [''] resources: [secrets] resourceNames: @@ -36,7 +30,7 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: mainloop-read-shim-tokens + name: mainloop-backend-runtime namespace: mainloop-control subjects: - kind: ServiceAccount @@ -45,4 +39,26 @@ subjects: roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: mainloop-read-shim-tokens + name: mainloop-backend-runtime +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mainloop-substrate-trust-bundles +rules: + - apiGroups: [certificates.k8s.io] + resources: [clustertrustbundles] + verbs: [list] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: mainloop-substrate-trust-bundles +subjects: + - kind: ServiceAccount + name: mainloop-backend + namespace: mainloop-control +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: mainloop-substrate-trust-bundles diff --git a/k8s/apps/mainloop/overlays/substrate-preview/backend.yaml b/k8s/apps/mainloop/overlays/substrate-preview/backend.yaml index acc6a8b..b3a7751 100644 --- a/k8s/apps/mainloop/overlays/substrate-preview/backend.yaml +++ b/k8s/apps/mainloop/overlays/substrate-preview/backend.yaml @@ -44,6 +44,10 @@ spec: secretKeyRef: name: mainloop-db-app key: password + volumeMounts: + - name: substrate-api-token + mountPath: /var/run/secrets/tokens/substrate-api + readOnly: true resources: requests: memory: 1Gi @@ -67,3 +71,11 @@ spec: periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 + volumes: + - name: substrate-api-token + projected: + sources: + - serviceAccountToken: + audience: api.ate-system.svc + expirationSeconds: 3600 + path: token diff --git a/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml index 1b256f3..947d706 100644 --- a/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml +++ b/k8s/apps/mainloop/overlays/substrate-preview/configmap.yaml @@ -12,5 +12,8 @@ data: FRONTEND_DOMAIN: localhost:3000 # Kind-only fixed identity for local port-forwarded previews; do not copy to shared overlays. SUBSTRATE_PREVIEW_LOCAL_DEV_MODE: 'true' + SUBSTRATE_ENDPOINT: api.ate-system.svc:443 + SUBSTRATE_TOKEN_FILE: /var/run/secrets/tokens/substrate-api/token SUBSTRATE_ROUTER_ADDRESS: http://atenet-router.ate-system.svc.cluster.local:8081 + SUBSTRATE_SHIM_SECRET_NAMESPACE: mainloop-shim-secrets SUBSTRATE_ACTOR_BINDINGS: '{"claude":{"atespace":"live-agent-gate","actor":"headless-claude-reproof","shim_token_secret_name":"mainloop-shim-live-agent-gate-headless-claude-reproof"},"codex":{"atespace":"native-codex","actor":"headless-codex-reproof","shim_token_secret_name":"mainloop-shim-native-codex-headless-codex-reproof"}}' diff --git a/k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml b/k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml index 2eb0465..4e5feb8 100644 --- a/k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml +++ b/k8s/apps/mainloop/overlays/substrate-preview/kustomization.yaml @@ -1,10 +1,9 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: mainloop-control - resources: - namespace.yaml + - shim-secret-rbac.yaml - configmap.yaml - database.yaml - backend-rbac.yaml diff --git a/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml b/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml index 2ddd664..e5069c7 100644 --- a/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml +++ b/k8s/apps/mainloop/overlays/substrate-preview/namespace.yaml @@ -4,3 +4,10 @@ metadata: name: mainloop-control labels: mainloop.dev/role: control +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mainloop-shim-secrets + labels: + mainloop.dev/role: shim-secrets diff --git a/k8s/apps/mainloop/overlays/substrate-preview/shim-secret-rbac.yaml b/k8s/apps/mainloop/overlays/substrate-preview/shim-secret-rbac.yaml new file mode 100644 index 0000000..d46be36 --- /dev/null +++ b/k8s/apps/mainloop/overlays/substrate-preview/shim-secret-rbac.yaml @@ -0,0 +1,23 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: mainloop-manage-shim-secrets + namespace: mainloop-shim-secrets +rules: + - apiGroups: [''] + resources: [secrets] + verbs: [create, get, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: mainloop-manage-shim-secrets + namespace: mainloop-shim-secrets +subjects: + - kind: ServiceAccount + name: mainloop-backend + namespace: mainloop-control +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: mainloop-manage-shim-secrets diff --git a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl index 2b69580..353052d 100644 --- a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl @@ -48,6 +48,7 @@ containers: - /usr/local/bin/entrypoint.sh env: - { name: HOME, value: /work/.home } + - { name: MAINLOOP_API, value: http://mainloop-backend.mainloop-control.svc.cluster.local:8000 } - { name: WORKSPACE_PATH, value: /work/repo } - { name: AGENT_CONFIG_DIR, value: /etc/agent-config } - { name: CODEX_HOME, value: /work/.codex } diff --git a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile index 0934785..0570c32 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile +++ b/spikes/substrate-workspace-adapter/live-agent-image/Dockerfile @@ -24,6 +24,7 @@ COPY codex /usr/local/bin/codex COPY codex-code-mode-host /usr/local/bin/codex-code-mode-host COPY bin/start-native-agent /usr/local/bin/start-native-agent COPY bin/prepare-native-agent-config.cjs /usr/local/bin/prepare-native-agent-config.cjs +COPY bin/mainloop /usr/local/bin/mainloop COPY bin/mainloop-reauth /usr/local/bin/mainloop-reauth COPY agent-config/mainloop-system.txt /etc/agent-config/mainloop-system.txt COPY exec-shim.js /usr/local/bin/exec-shim.js diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop b/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop index 36d7a68..5fe86a2 100755 --- a/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/mainloop @@ -4,7 +4,7 @@ # Identity is the per-binding token in .mainloop/token; the server decides what this token may # do and answers in plain text. set -u -API="${MAINLOOP_API:-http://mainloop-backend.mainloop.svc.cluster.local:8000}" +API="${MAINLOOP_API:-http://mainloop-backend.mainloop-control.svc.cluster.local:8000}" find_token() { local d="${PWD}" @@ -21,10 +21,18 @@ TOKEN="${MAINLOOP_TOKEN:-$(find_token)}" || { echo "mainloop: no agent token found from ${PWD}" >&2 exit 2 } +export -n TOKEN +unset MAINLOOP_TOKEN + +umask 077 +AUTH_HEADER_FILE="$(mktemp)" +trap 'rm -f -- "${AUTH_HEADER_FILE}"' EXIT +printf 'Authorization: Bearer %s\n' "${TOKEN}" >"${AUTH_HEADER_FILE}" +unset TOKEN call() { # [json body] ; query params via Q=(--data-urlencode k=v ...) local out code body - out="$(curl -sS -m 30 -w '\n%{http_code}' -X "$1" -H "Authorization: Bearer ${TOKEN}" \ + out="$(curl -sS -m 30 -w '\n%{http_code}' -X "$1" -H "@${AUTH_HEADER_FILE}" \ -H 'Content-Type: application/json' ${Q[@]+"${Q[@]}"} ${3:+-d "$3"} -G "${API}$2" 2>&1)" || { echo "mainloop: control plane unreachable" >&2 @@ -44,7 +52,7 @@ Q=() # are sent with --json-style separately: post() { # local out code body - out="$(curl -sS -m 30 -w '\n%{http_code}' -X POST -H "Authorization: Bearer ${TOKEN}" \ + out="$(curl -sS -m 30 -w '\n%{http_code}' -X POST -H "@${AUTH_HEADER_FILE}" \ -H 'Content-Type: application/json' --data-binary "$2" "${API}$1" 2>&1)" || { echo "mainloop: control plane unreachable" >&2 diff --git a/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent b/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent index c823183..3b58a77 100755 --- a/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent +++ b/spikes/substrate-workspace-adapter/live-agent-image/bin/start-native-agent @@ -24,6 +24,7 @@ fi workspace="${WORKSPACE_PATH:-/work/repo}" cd "${workspace}" +workspace_root="$(pwd -P)" # Keep telemetry and automatic network chatter disabled for every native invocation. export DISABLE_TELEMETRY=1 @@ -31,6 +32,87 @@ export DISABLE_ERROR_REPORTING=1 export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 export DISABLE_AUTOUPDATER=1 +startup_context_file="" +startup_standing_file="" +startup_standing_text="" +if [[ ${MAINLOOP_STARTUP_PRESENT-} == 1 ]]; then + startup_cwd="${MAINLOOP_STARTUP_CWD_REL-}" + startup_token="${MAINLOOP_STARTUP_TOKEN-}" + startup_standing_b64="${MAINLOOP_STARTUP_STANDING_B64-}" + startup_policy="${MAINLOOP_STARTUP_APPROVAL_POLICY-}" + startup_model="${MAINLOOP_STARTUP_MODEL-}" + startup_effort="${MAINLOOP_STARTUP_EFFORT-}" + if [[ ! ${startup_token} =~ ^ml_[a-f0-9]{64}$ ]] || + [[ ! ${startup_standing_b64} =~ ^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$ ]]; then + echo 'invalid native startup credentials or context' >&2 + exit 2 + fi + if [[ ${kind} == claude && ${startup_cwd} == main ]]; then + [[ ${startup_policy} == 'restricted: Bash(mainloop:*) only' ]] || { + echo 'invalid main-thread approval policy' >&2 + exit 2 + } + [[ ${startup_model} =~ ^[A-Za-z0-9._:-]{1,128}$ ]] || { + echo 'invalid main-thread model' >&2 + exit 2 + } + [[ ${startup_effort} == low || ${startup_effort} == medium || ${startup_effort} == high || ${startup_effort} == max || ${startup_effort} == xhigh ]] || { + echo 'invalid main-thread effort' >&2 + exit 2 + } + elif [[ ${startup_cwd} =~ ^children/ml-${kind}-[0-9a-f]{8}$ ]]; then + [[ ${startup_policy} == bypass-permissions && -z ${startup_model} && -z ${startup_effort} ]] || { + echo 'invalid child startup policy' >&2 + exit 2 + } + else + echo 'invalid native startup working directory' >&2 + exit 2 + fi + + startup_target="${workspace_root}/${startup_cwd}" + mkdir -p -- "${startup_target}" + startup_target="$(realpath -e -- "${startup_target}")" + case "${startup_target}" in + "${workspace_root}"/*) ;; + *) + echo 'native startup working directory escapes the workspace' >&2 + exit 2 + ;; + esac + cd -- "${startup_target}" + export MAINLOOP_TOKEN="${startup_token}" + + mkdir -p -- "${HOME}/.mainloop" + chmod 700 "${HOME}/.mainloop" + startup_standing_file="$(mktemp "${HOME}/.mainloop/standing.XXXXXX")" + chmod 600 "${startup_standing_file}" + if ! printf '%s' "${startup_standing_b64}" | base64 --decode >"${startup_standing_file}"; then + echo 'invalid native startup context encoding' >&2 + exit 2 + fi + startup_standing_size="$(wc -c <"${startup_standing_file}")" + if [[ ! -s ${startup_standing_file} ]] || [[ ${startup_standing_size} -gt 32768 ]]; then + echo 'invalid native startup context size' >&2 + exit 2 + fi + startup_standing_text="$(cat "${startup_standing_file}")" + trap 'rm -f -- "${startup_standing_file}" "${startup_context_file}"' EXIT +fi + +run_cli() { + local status=0 + if "$@"; then + status=0 + else + status=$? + fi + if [[ -n ${startup_standing_file} ]]; then + rm -f -- "${startup_standing_file}" "${startup_context_file}" + fi + exit "${status}" +} + case "${kind}" in claude) token_file="${HOME}/.mainloop/claude-token" @@ -54,8 +136,26 @@ claude) fi args+=(--session-id "${session_id}") fi - args+=(--output-format stream-json --dangerously-skip-permissions - --append-system-prompt-file "${AGENT_SYSTEM_PROMPT_FILE:-/etc/agent-config/mainloop-system.txt}") + args+=(--output-format stream-json --verbose) + if [[ ${MAINLOOP_STARTUP_PRESENT-} == 1 && ${startup_cwd} == main ]]; then + args+=(--tools Bash --allowedTools 'Bash(mainloop:*)' --permission-mode dontAsk + --model "${startup_model}" --effort "${startup_effort}") + else + args+=(--dangerously-skip-permissions) + fi + if [[ ${MAINLOOP_STARTUP_PRESENT-} == 1 ]]; then + startup_context_file="$(mktemp "${HOME}/.mainloop/system-prompt.XXXXXX")" + chmod 600 "${startup_context_file}" + cat "${AGENT_SYSTEM_PROMPT_FILE:-/etc/agent-config/mainloop-system.txt}" >"${startup_context_file}" + printf '\n' >>"${startup_context_file}" + cat "${startup_standing_file}" >>"${startup_context_file}" + args+=(--append-system-prompt-file "${startup_context_file}") + else + args+=(--append-system-prompt-file "${AGENT_SYSTEM_PROMPT_FILE:-/etc/agent-config/mainloop-system.txt}") + fi + if [[ ${MAINLOOP_STARTUP_PRESENT-} == 1 ]]; then + run_cli claude "${args[@]}" + fi exec claude "${args[@]}" ;; codex) @@ -64,10 +164,21 @@ codex) echo 'Codex credential is not installed' >&2 exit 1 fi + if [[ ${MAINLOOP_STARTUP_PRESENT-} == 1 ]]; then + instructions_json="$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "${startup_standing_text}")" + codex_args=(--config "developer_instructions=${instructions_json}") + else + codex_args=() + fi if [[ ${mode} == resume ]]; then - exec codex exec resume --json "${session_id}" --dangerously-bypass-approvals-and-sandbox + codex_args+=(exec resume --json "${session_id}" --dangerously-bypass-approvals-and-sandbox) + else + codex_args+=(exec --json --dangerously-bypass-approvals-and-sandbox) + fi + if [[ ${MAINLOOP_STARTUP_PRESENT-} == 1 ]]; then + run_cli codex "${codex_args[@]}" fi - exec codex exec --json --dangerously-bypass-approvals-and-sandbox + exec codex "${codex_args[@]}" ;; *) echo 'unsupported native CLI' >&2 diff --git a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js index 9400a90..cc4ff92 100644 --- a/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js +++ b/spikes/substrate-workspace-adapter/live-agent-image/exec-shim.js @@ -8,6 +8,7 @@ const fs = require('node:fs'); const path = require('node:path'); const MAX_REQUEST_BODY_BYTES = 64 * 1024; +const MAX_TURN_REQUEST_BODY_BYTES = 128 * 1024; const MAX_CREDENTIAL_REQUEST_BODY_BYTES = 512 * 1024; const MAX_CREDENTIAL_BYTES = 64 * 1024; const MAX_JOB_OUTPUT_BYTES = 2 * 1024 * 1024; @@ -232,6 +233,56 @@ function validSessionKey(value) { return typeof value === 'string' && /^[A-Za-z0-9._:-]{1,256}$/.test(value); } +function validStartupOptions(value, agent) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value); + const allowedKeys = new Set([ + 'cwd_rel', + 'token', + 'standing_b64', + 'approval_policy', + 'model', + 'effort' + ]); + if (keys.some((key) => !allowedKeys.has(key))) return false; + if ( + typeof value.cwd_rel !== 'string' || + typeof value.token !== 'string' || + !/^ml_[a-f0-9]{64}$/.test(value.token) || + typeof value.standing_b64 !== 'string' || + value.standing_b64.length === 0 || + value.standing_b64.length > 44 * 1024 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.standing_b64) + ) { + return false; + } + const standing = Buffer.from(value.standing_b64, 'base64'); + if ( + standing.length === 0 || + standing.length > 32 * 1024 || + standing.toString('base64') !== value.standing_b64 + ) { + return false; + } + if (value.cwd_rel === 'main') { + return ( + agent === 'claude' && + value.approval_policy === 'restricted: Bash(mainloop:*) only' && + typeof value.model === 'string' && + /^[A-Za-z0-9._:-]{1,128}$/.test(value.model) && + typeof value.effort === 'string' && + /^(low|medium|high|max|xhigh)$/.test(value.effort) + ); + } + return ( + typeof value.cwd_rel === 'string' && + new RegExp(`^children/ml-${agent}-[0-9a-f]{8}$`).test(value.cwd_rel) && + value.approval_policy === 'bypass-permissions' && + value.model === undefined && + value.effort === undefined + ); +} + function loadJobs(directory, kind) { for (const name of fs.readdirSync(directory)) { if (!name.endsWith('.json')) continue; @@ -671,6 +722,8 @@ function startTurn(document, res) { const sessionId = document.session_id; const sessionKey = document.session_key; const resume = document.resume; + const hasStartupOptions = Object.hasOwn(document, 'startup_options'); + const startupOptions = document.startup_options; const timeoutMs = validateTimeout(document.timeout_ms, DEFAULT_TURN_TIMEOUT_MS); if (agent !== 'claude' && agent !== 'codex') { res.writeHead(400).end('unsupported agent'); @@ -688,6 +741,10 @@ function startTurn(document, res) { res.writeHead(400).end('invalid resume intent'); return; } + if (hasStartupOptions && !validStartupOptions(startupOptions, agent)) { + res.writeHead(400).end('invalid startup_options'); + return; + } if ( sessionId !== undefined && (typeof sessionId !== 'string' || !/^[A-Za-z0-9._:-]{1,256}$/.test(sessionId)) @@ -730,12 +787,35 @@ function startTurn(document, res) { activeTurns.set(key, job.id); latestTurns.set(key, job); try { + const launcherEnv = { ...process.env }; + for (const name of [ + 'MAINLOOP_STARTUP_PRESENT', + 'MAINLOOP_STARTUP_CWD_REL', + 'MAINLOOP_STARTUP_TOKEN', + 'MAINLOOP_STARTUP_STANDING_B64', + 'MAINLOOP_STARTUP_APPROVAL_POLICY', + 'MAINLOOP_STARTUP_MODEL', + 'MAINLOOP_STARTUP_EFFORT' + ]) { + delete launcherEnv[name]; + } + if (hasStartupOptions) { + launcherEnv.MAINLOOP_STARTUP_PRESENT = '1'; + launcherEnv.MAINLOOP_STARTUP_CWD_REL = startupOptions.cwd_rel; + launcherEnv.MAINLOOP_STARTUP_TOKEN = startupOptions.token; + launcherEnv.MAINLOOP_STARTUP_STANDING_B64 = startupOptions.standing_b64; + launcherEnv.MAINLOOP_STARTUP_APPROVAL_POLICY = startupOptions.approval_policy; + if (startupOptions.model !== undefined) { + launcherEnv.MAINLOOP_STARTUP_MODEL = startupOptions.model; + launcherEnv.MAINLOOP_STARTUP_EFFORT = startupOptions.effort; + } + } const child = spawn( LAUNCHER, [agent, resume ? 'resume' : 'create', ...(sessionId ? [sessionId] : [])], { cwd: WORKSPACE_PATH, - env: process.env, + env: launcherEnv, stdio: ['pipe', 'pipe', 'pipe'], detached: true } @@ -1072,7 +1152,7 @@ const server = http.createServer((req, res) => { } if (req.method === 'POST' && req.url === '/turn') { if (!authorized(req)) return unauthorized(res); - handleBody(req, res, (document) => startTurn(document, res)); + handleBody(req, res, (document) => startTurn(document, res), MAX_TURN_REQUEST_BODY_BYTES); return; } if (req.method === 'POST' && req.url === '/turn/stop') { diff --git a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js index 0e3fc94..ff7aa20 100644 --- a/spikes/substrate-workspace-adapter/tests/exec-shim.test.js +++ b/spikes/substrate-workspace-adapter/tests/exec-shim.test.js @@ -14,6 +14,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const image = path.resolve(__dirname, '../live-agent-image'); const shim = path.join(image, 'exec-shim.js'); const launcher = path.join(image, 'bin/start-native-agent'); +const mainloop = path.join(image, 'bin/mainloop'); const dockerfile = path.join(image, 'Dockerfile'); const entrypoint = path.join(image, 'entrypoint.sh'); const fixtures = path.join(__dirname, 'fixtures/native'); @@ -34,6 +35,76 @@ function codexPlaceholder(accountId = 'fixture-account') { }); } +test('mainloop sends its bearer header through a protected curl config file', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mainloop-helper-')); + const fakeBin = path.join(root, 'bin'); + fs.mkdirSync(fakeBin, { recursive: true }); + const fakeCurl = path.join(fakeBin, 'curl'); + fs.writeFileSync( + fakeCurl, + '#!/usr/bin/env bash\n' + + 'set -euo pipefail\n' + + 'args=("$@")\n' + + 'printf \'%s\\0\' "${args[@]}" >"$CURL_ARGS_CAPTURE"\n' + + 'printf \'%s|%s\' "${MAINLOOP_TOKEN-}" "${TOKEN-}" >"$CURL_TOKEN_ENV_CAPTURE"\n' + + 'for ((i = 0; i < ${#args[@]}; i++)); do\n' + + ' if [[ ${args[i]} == -H && ${args[i + 1]-} == @* ]]; then\n' + + ' header_file="${args[i + 1]#@}"\n' + + ' cat "$header_file" >"$CURL_HEADER_CAPTURE"\n' + + ' stat -c \'%a\' "$header_file" >"$CURL_HEADER_MODE_CAPTURE"\n' + + ' fi\n' + + 'done\n' + + 'printf \'{"text":"fixture response"}\\n200\'\n', + { mode: 0o700 } + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const helperToken = `ml_${'b'.repeat(64)}`; + for (const [name, helperArgs] of [ + ['get', ['whoami']], + ['post', ['note', 'fixture note']] + ]) { + const capture = (suffix) => path.join(root, `${name}-${suffix}`); + const env = { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + MAINLOOP_TOKEN: helperToken, + TOKEN: 'inherited-fixture-value', + CURL_ARGS_CAPTURE: capture('args'), + CURL_HEADER_CAPTURE: capture('header'), + CURL_HEADER_MODE_CAPTURE: capture('header-mode'), + CURL_TOKEN_ENV_CAPTURE: capture('token-env') + }; + const result = spawnSync('bash', [mainloop, ...helperArgs], { + encoding: 'utf8', + env + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'fixture response'); + assert.equal(result.stdout.includes(helperToken), false); + assert.equal(result.stderr.includes(helperToken), false); + const args = fs.readFileSync(capture('args'), 'utf8').split('\0'); + const joinedArgs = args.join('\0'); + assert.equal(joinedArgs.includes(helperToken), false); + assert.ok( + args.includes( + 'http://mainloop-backend.mainloop-control.svc.cluster.local:8000/agent-api/' + + (name === 'get' ? 'whoami' : 'records') + ) + ); + const headerArgument = args.find((arg) => arg.startsWith('@')); + assert.ok(headerArgument); + assert.equal(fs.existsSync(headerArgument.slice(1)), false); + assert.equal( + fs.readFileSync(capture('header'), 'utf8'), + `Authorization: Bearer ${helperToken}\n` + ); + assert.equal(fs.readFileSync(capture('header-mode'), 'utf8').trim(), '600'); + assert.equal(fs.readFileSync(capture('token-env'), 'utf8'), '|'); + } +}); + async function startShim(root, extraEnv = {}) { const home = path.join(root, 'home'); const workspace = path.join(root, 'repo'); @@ -413,7 +484,8 @@ test('turn prompt is piped on stdin and never appears in argv or shim logs', asy const argv = fs.readFileSync(path.join(root, 'claude-args'), 'utf8'); assert.match(argv, /-p/); assert.match(argv, /--session-id\s+native-stdin-session/); - assert.match(argv, /--output-format/); + // Claude Code rejects --print with stream-json output unless --verbose is set. + assert.match(argv, /--output-format\nstream-json\n--verbose\n/); assert.equal(argv.includes(prompt), false); assert.equal(running.output().includes(prompt), false); assert.equal( @@ -424,6 +496,73 @@ test('turn prompt is piped on stdin and never appears in argv or shim logs', asy ); }); +test('startup options reach the main Claude launch with scoped tools and standing context', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-startup-')); + const basePrompt = path.join(root, 'base-prompt.txt'); + fs.writeFileSync(basePrompt, 'base system instructions\n'); + const running = await startShim(root, { + AGENT_SYSTEM_PROMPT_FILE: basePrompt, + CLAUDE_ARGS_CAPTURE: path.join(root, 'claude-args'), + CLAUDE_CWD_CAPTURE: path.join(root, 'claude-cwd'), + CLAUDE_TOKEN_CAPTURE: path.join(root, 'claude-token'), + CLAUDE_CONTEXT_CAPTURE: path.join(root, 'claude-context') + }); + fakeCli( + path.join(running.fakeBin, 'claude'), + '#!/bin/sh\nprintf "%s\\n" "$@" >"$CLAUDE_ARGS_CAPTURE"\npwd >"$CLAUDE_CWD_CAPTURE"\nprintf "%s" "$MAINLOOP_TOKEN" >"$CLAUDE_TOKEN_CAPTURE"\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = --append-system-prompt-file ]; then cat "$2" >"$CLAUDE_CONTEXT_CAPTURE"; shift 2; else shift; fi\ndone\ncat >/dev/null\nprintf \'%s\\n\' \'{"type":"system","subtype":"init","session_id":"main-startup-session"}\' \'{"type":"result","result":"startup fixture answer","session_id":"main-startup-session"}\'\n' + ); + t.after(async () => { + await stop(running.child); + fs.rmSync(root, { recursive: true, force: true }); + }); + await installToken(running); + await installCredential(running, 'claude-token', claudePlaceholder); + + const startupContext = 'Mainloop scoped standing context\nOnly use mainloop commands.'; + const started = await request(running.port, 'POST', '/turn', { + bearer: token, + body: { + agent: 'claude', + prompt: 'startup fixture prompt', + session_key: 'main-startup-session', + session_id: 'native-main-startup-session', + resume: false, + startup_options: { + cwd_rel: 'main', + token: `ml_${'a'.repeat(64)}`, + standing_b64: Buffer.from(startupContext).toString('base64'), + approval_policy: 'restricted: Bash(mainloop:*) only', + model: 'sonnet', + effort: 'medium' + } + } + }); + assert.equal(started.status, 202, started.body); + await waitForJob(running, 'turn', JSON.parse(started.body).id); + + const args = fs.readFileSync(path.join(root, 'claude-args'), 'utf8'); + assert.match(args, /--tools\nBash\n/); + assert.match(args, /--allowedTools\nBash\(mainloop:\*\)\n/); + assert.match(args, /--permission-mode\ndontAsk\n/); + assert.match(args, /--model\nsonnet\n/); + assert.match(args, /--effort\nmedium\n/); + assert.doesNotMatch(args, /--dangerously-skip-permissions/); + assert.equal( + fs.readFileSync(path.join(root, 'claude-cwd'), 'utf8').trim(), + path.join(running.workspace, 'main') + ); + assert.equal(fs.readFileSync(path.join(root, 'claude-token'), 'utf8'), `ml_${'a'.repeat(64)}`); + const context = fs.readFileSync(path.join(root, 'claude-context'), 'utf8'); + assert.ok(context.startsWith('base system instructions\n')); + assert.ok(context.includes(startupContext)); + assert.equal( + fs + .readdirSync(path.join(running.home, '.mainloop')) + .some((name) => name.startsWith('standing.')), + false + ); +}); + test('a second concurrent turn for the same logical session receives 409', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-shim-concurrency-')); const running = await startShim(root, { TURN_DELAY: '0.4' }); From 1b11b2c45c72dcf1309ca0f4dc048e250fdf3239 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:50:08 +0000 Subject: [PATCH 30/30] Pin Substrate fork 0f9635ae and add live Substrate proofs Move to the fork rebased on current upstream. Read the actor template UID from status.externalSnapshot.actorTemplateUid, since upstream removed status.currentActorTemplateUid, and use the renamed template fields wakeupProbe and snapshotConfig. Egress-policy updates now carry the uid and version preconditions the API requires. Build kubectl-ate from the new fork commit in the backend image. Add the opt-in live proof scripts for the Substrate preview cluster: a durable workspace volume with a real repository survives suspend, worker loss and resume, and a headless Claude turn runs as a non-root actor with the credential injected at egress, then recalls its session after suspend and resume. Both passed on Kind at fork 0f9635ae; the spike document records the measured results. The scripts delete only what the run created: they record namespace and provider object UIDs, send UID preconditions with each delete, and leave retained or replaced resources alone when a rerun is refused or setup fails. A failed log fetch fails the credential-leak check, and a failed actor command stops the run instead of polling to the timeout. --- backend/Dockerfile | 2 +- backend/scripts/gate5_setup.py | 239 ++++++++- backend/src/mainloop/runtime/substrate.py | 2 +- backend/tests/runtime/test_gate5_setup.py | 476 ++++++++++++++++- backend/tests/runtime/test_substrate.py | 44 +- docs/architecture.md | 6 +- docs/specs/workspaces.md | 4 +- docs/spikes/substrate-workspace-adapter.md | 147 ++++-- .../k8s/actor-template.yaml.tmpl | 4 +- .../live/claude_turn_proof.sh | 481 ++++++++++++++++++ .../live/cleanup-lane-a.sh | 270 ++++++++++ .../live/common.sh | 256 ++++++++++ .../live/count_token_prefix.py | 35 ++ .../live/durable_restore_proof.sh | 180 +++++++ .../live/shim_request.py | 77 +++ .../tools/README-round3-egress.md | 98 +++- .../tools/round3-claude-provider/Dockerfile | 22 + .../tools/round3-claude-provider/main.go | 14 +- 18 files changed, 2251 insertions(+), 106 deletions(-) create mode 100755 spikes/substrate-workspace-adapter/live/claude_turn_proof.sh create mode 100755 spikes/substrate-workspace-adapter/live/cleanup-lane-a.sh create mode 100755 spikes/substrate-workspace-adapter/live/common.sh create mode 100644 spikes/substrate-workspace-adapter/live/count_token_prefix.py create mode 100755 spikes/substrate-workspace-adapter/live/durable_restore_proof.sh create mode 100644 spikes/substrate-workspace-adapter/live/shim_request.py create mode 100644 spikes/substrate-workspace-adapter/tools/round3-claude-provider/Dockerfile diff --git a/backend/Dockerfile b/backend/Dockerfile index d96c6b9..48d3f30 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,7 @@ FROM golang:1.27.0-bookworm AS substrate-cli ARG SUBSTRATE_REPOSITORY=https://github.com/oldsj/substrate.git -ARG SUBSTRATE_COMMIT=ce265c1dbd3775faf10c95f71f2c16ff3d47c332 +ARG SUBSTRATE_COMMIT=0f9635aed37bd5dde604a9bca1975421cd07181a RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates \ diff --git a/backend/scripts/gate5_setup.py b/backend/scripts/gate5_setup.py index 7dd2c8a..7c8917c 100644 --- a/backend/scripts/gate5_setup.py +++ b/backend/scripts/gate5_setup.py @@ -20,10 +20,11 @@ runs one headless native CLI process per request; Mainloop owns delivery and retry decisions. Prerequisites: - kubectl, plus kubectl-ate and ko built from a checkout of the oldsj/substrate fork at + kubectl and kubectl-ate. Either pass an existing digest-pinned WorkerPool image with + --worker-image, or pass ko and a checkout of the oldsj/substrate fork at SUBSTRATE_FORK_COMMIT (see docs/spikes/substrate-workspace-adapter.md). - A running kind-substrate-preview cluster with the ate-system + agentgateway dataplane - installed (this script accepts only the exact kind-substrate-preview context). + A running kind-substrate-preview cluster with ate-system and Envoy plus sdsmint installed + (this script accepts only the exact kind-substrate-preview context). The live-agent-gate image already built and pushed (see live-agent-image/), its digest passed with --image. @@ -33,7 +34,7 @@ --context kind-substrate-preview --kubeconfig /tmp/substrate-preview-kubeconfig \\ --ate-cli "$SUBSTRATE_SRC/bin/kubectl-ate" \\ --ko "$SUBSTRATE_SRC/bin/ko" \\ - --substrate-src "$SUBSTRATE_SRC" \\ + --worker-image localhost:5001/ateom-gvisor@sha256: \\ --atespace nonroot-check --worker-pool nonroot-check --template-version v1 \\ --image localhost:5001/live-agent-gate@sha256:... \\ --manifest ../spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl \\ @@ -42,6 +43,8 @@ Use a fresh atespace for this check: the applied product WorkerPool requests two replicas. --worker-pool defaults to the atespace name so its label is distinct from other namespaces. + --worker-image applies an existing digest-pinned WorkerPool image directly and does not + invoke ko; without it, --substrate-src is required and ko resolves the WorkerPool image. Re-running with the same --state-file reconciles the persisted actor uid against the cluster's current state rather than blindly creating or resuming; a name collision with a @@ -84,9 +87,8 @@ wait_for_golden_snapshot, ) -# The `patched` branch of https://github.com/oldsj/substrate: upstream Substrate at -# cdac9baef81dd319b46086d695266e6161e9e592 plus the patches listed in its FORK.md. -SUBSTRATE_FORK_COMMIT = "ce265c1dbd3775faf10c95f71f2c16ff3d47c332" +# The `patched-next` branch of https://github.com/oldsj/substrate. +SUBSTRATE_FORK_COMMIT = "0f9635aed37bd5dde604a9bca1975421cd07181a" WORKER_SANDBOX_CLASS = "gvisor" ACTOR_SHIM_PORT = 8090 @@ -97,7 +99,17 @@ def parse_args() -> argparse.Namespace: p.add_argument("--kubeconfig", required=True) p.add_argument("--ate-cli", default="kubectl-ate") p.add_argument("--ko", default="ko") - p.add_argument("--substrate-src", required=True) + p.add_argument( + "--substrate-src", + help="pinned oldsj/substrate checkout used to resolve a ko:// WorkerPool image", + ) + p.add_argument( + "--worker-image", + help=( + "existing WorkerPool image pinned by digest; skips ko resolution/build/push " + "and applies the WorkerPool manifest directly" + ), + ) p.add_argument("--router-port", type=int, default=18091) p.add_argument("--atespace", required=True) p.add_argument( @@ -168,6 +180,7 @@ def render_manifest( template_name: str, bucket_name: str, image: str, + worker_image: str | None = None, ) -> list[str]: """Substitutes the template placeholders and image marker, then splits the multi-document YAML on its own '---' separators. Returns [namespace_and_workerpool_doc, @@ -184,6 +197,15 @@ def render_manifest( ) .replace("__IMAGE__", image) ) + if worker_image is not None: + if not re.fullmatch(r"\S+@sha256:[0-9a-fA-F]{64}", worker_image): + raise RuntimeError("--worker-image must be pinned by a full sha256 digest") + worker_image_uri = "ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor" + if worker_image_uri not in rendered: + raise RuntimeError( + f"expected the WorkerPool template to contain {worker_image_uri}" + ) + rendered = rendered.replace(worker_image_uri, worker_image) docs = [d for d in rendered.split("\n---\n") if d.strip()] if len(docs) != 3: raise RuntimeError( @@ -203,6 +225,7 @@ def render_gate_manifest(args: argparse.Namespace) -> list[str]: template_name=template_name, bucket_name=args.bucket_name, image=args.image, + worker_image=args.worker_image, ) @@ -329,16 +352,29 @@ def prepare_run_state(args: argparse.Namespace, cluster: dict[str, str]) -> dict } state = load_state(args.state_file) if state: - missing = {"run_id", "template_uid", "actor_uid"} - state.keys() + missing = { + "run_id", + "namespace_uid", + "template_uid", + "actor_uid", + } - state.keys() + invalid = set() + if ( + not isinstance(state.get("namespace_uid"), str) + or not state["namespace_uid"] + ): + invalid.add("namespace_uid") mismatch = { key: (state.get(key), value) for key, value in identity.items() if state.get(key) != value } - if missing or mismatch: + if missing or invalid or mismatch: details = [] if missing: details.append(f"missing identity fields {sorted(missing)}") + if invalid: + details.append(f"empty identity fields {sorted(invalid)}") if mismatch: details.append(f"requested identity differs: {mismatch}") raise RuntimeError( @@ -349,6 +385,7 @@ def prepare_run_state(args: argparse.Namespace, cluster: dict[str, str]) -> dict state = { **identity, "run_id": str(uuid.uuid4()), + "namespace_uid": None, "template_uid": None, "actor_uid": None, } @@ -356,19 +393,119 @@ def prepare_run_state(args: argparse.Namespace, cluster: dict[str, str]) -> dict return state +def create_namespace_and_record_uid( + args: argparse.Namespace, + state: dict, + namespace_doc: str, + *, + runner=subprocess.run, +) -> str: + """Create this run's Namespace and persist the UID returned by the API server.""" + if state.get("namespace_uid"): + raise RuntimeError("run state already has a Namespace UID") + result = runner( + [ + "kubectl", + "--context", + args.context, + "--kubeconfig", + args.kubeconfig, + "create", + "-f", + "-", + "-o", + "json", + ], + input=namespace_doc, + capture_output=True, + text=True, + check=True, + timeout=60, + ) + uid = (json.loads(result.stdout).get("metadata") or {}).get("uid") + if not isinstance(uid, str) or not uid: + raise RuntimeError("created lane Namespace response is missing metadata.uid") + state["namespace_uid"] = uid + save_state(args.state_file, state) + return uid + + +def verify_namespace_uid( + args: argparse.Namespace, state: dict, *, runner=subprocess.run +) -> str: + """Refuse to resume into a missing or replacement lane Namespace.""" + recorded_uid = state.get("namespace_uid") + if not isinstance(recorded_uid, str) or not recorded_uid: + raise RuntimeError("run state has no Namespace UID; refusing to resume") + try: + result = runner( + [ + "kubectl", + "--context", + args.context, + "--kubeconfig", + args.kubeconfig, + "get", + "namespace", + args.atespace, + "-o", + "json", + ], + capture_output=True, + text=True, + check=True, + timeout=20, + ) + except subprocess.CalledProcessError as error: + raise RuntimeError( + f"could not verify lane Namespace UID for {args.atespace}; refusing to resume" + ) from error + uid = (json.loads(result.stdout).get("metadata") or {}).get("uid") + if not isinstance(uid, str) or not uid: + raise RuntimeError("lane Namespace response is missing metadata.uid") + if recorded_uid != uid: + raise RuntimeError( + f"lane Namespace UID changed from {recorded_uid} to {uid}; refusing to adopt it" + ) + return uid + + def apply_worker_pool( doc: str, *, kubeconfig: str, context: str, ko: str, - substrate_src: str, + substrate_src: str | None, runner=subprocess.run, ) -> None: """Resolve the WorkerPool's `ko://...` workerImage and apply it (and the Namespace doc it's paired with) via `ko resolve | kubectl apply`. An unresolved ko:// reference reaches the pod as an InvalidImageName, not a manifest-time error, so this step must not be skipped even though `kubectl apply` alone would exit 0.""" + if "ko://" not in doc: + runner( + [ + "kubectl", + "--context", + context, + "--kubeconfig", + kubeconfig, + "apply", + "-f", + "-", + ], + input=doc, + capture_output=True, + text=True, + check=True, + timeout=60, + ) + return + if not substrate_src: + raise RuntimeError( + "--substrate-src is required when the WorkerPool manifest contains ko://" + ) substrate_src = verify_substrate_source(substrate_src, runner=runner) ko_docker_repo = os.environ.get("KO_DOCKER_REPO") if not ko_docker_repo: @@ -430,10 +567,60 @@ def egress_policy_manifest(args: argparse.Namespace) -> str: return json.dumps({"rules": rules}) +def existing_egress_policy_preconditions(args: argparse.Namespace) -> dict[str, str]: + """Read the UID and version required for an update of an existing policy.""" + result = ( + subprocess.run( # nosec B603 - argv list, CLI path is an operator-supplied flag + [ + args.ate_cli, + "--kubeconfig", + args.kubeconfig, + "--context", + args.context, + "get", + "egress-policy", + args.actor_name, + "--atespace", + args.atespace, + "-o", + "json", + ], + capture_output=True, + text=True, + timeout=60, + ) + ) + if result.returncode != 0: + raise RuntimeError( + "get egress-policy failed while preparing an update " + f"(exit {result.returncode}): {result.stderr.strip()[-300:]}" + ) + try: + policy = json.loads(result.stdout) + metadata = policy["metadata"] + uid = metadata["uid"] + version = str(metadata["version"]) + if ( + not isinstance(uid, str) + or not uid + or not version.isdecimal() + or int(version) <= 0 + ): + raise ValueError("metadata.uid and positive metadata.version are required") + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + raise RuntimeError( + "get egress-policy returned JSON without valid metadata.uid and " + "metadata.version preconditions" + ) from exc + return {"uid": uid, "version": version} + + def apply_egress_policy(args: argparse.Namespace) -> None: """Create the actor's EgressPolicy, or replace it when one already exists.""" - manifest = egress_policy_manifest(args) + manifest = json.loads(egress_policy_manifest(args)) for verb in ("create", "update"): + if verb == "update": + manifest["metadata"] = existing_egress_policy_preconditions(args) result = subprocess.run( # nosec B603 - argv list, CLI path is an operator-supplied flag [ args.ate_cli, @@ -449,7 +636,7 @@ def apply_egress_policy(args: argparse.Namespace) -> None: "--filename", "-", ], - input=manifest, + input=json.dumps(manifest), capture_output=True, text=True, timeout=60, @@ -841,10 +1028,24 @@ async def ensure_actor( async def async_main(args: argparse.Namespace) -> None: + if args.worker_image is not None and not re.fullmatch( + r"\S+@sha256:[0-9a-fA-F]{64}", args.worker_image + ): + raise RuntimeError("--worker-image must be pinned by a full sha256 digest") verify_image_manifest(args.image) - substrate_src = verify_substrate_source(args.substrate_src) + substrate_src = None + if args.worker_image is None: + if not args.substrate_src: + raise RuntimeError( + "pass --worker-image to reuse an existing image, or --substrate-src " + "to resolve the ko:// WorkerPool image" + ) + substrate_src = verify_substrate_source(args.substrate_src) cluster = get_cluster_identity(context=args.context, kubeconfig=args.kubeconfig) state = prepare_run_state(args, cluster) + resuming = state.get("namespace_uid") is not None + if resuming: + verify_namespace_uid(args, state) control = SubstrateControl( kubeconfig=args.kubeconfig, context=args.context, cli=args.ate_cli ) @@ -854,9 +1055,15 @@ async def async_main(args: argparse.Namespace) -> None: await control.ensure_atespace(args.atespace) namespace_and_workerpool_doc, actor_template_doc = render_gate_manifest(args) - print("-- resolving and applying the Namespace + WorkerPool") + namespace_doc, worker_pool_doc = namespace_and_workerpool_doc.split("\n---\n", 1) + if args.worker_image is None: + print("-- resolving and applying the WorkerPool") + else: + print("-- applying the WorkerPool with the existing worker image") + if not resuming: + create_namespace_and_record_uid(args, state, namespace_doc + "\n") apply_worker_pool( - namespace_and_workerpool_doc, + worker_pool_doc, kubeconfig=args.kubeconfig, context=args.context, ko=args.ko, diff --git a/backend/src/mainloop/runtime/substrate.py b/backend/src/mainloop/runtime/substrate.py index 6b090fc..17efb6b 100644 --- a/backend/src/mainloop/runtime/substrate.py +++ b/backend/src/mainloop/runtime/substrate.py @@ -97,7 +97,7 @@ def _actor_from_json(doc: dict) -> ActorRecord: uid=metadata.get("uid"), state=ActorState.parse(status.get("state")), external_snapshot_uri=snapshot.get("snapshotUri"), - current_actor_template_uid=status.get("currentActorTemplateUid"), + current_actor_template_uid=snapshot.get("actorTemplateUid"), raw=doc, ) diff --git a/backend/tests/runtime/test_gate5_setup.py b/backend/tests/runtime/test_gate5_setup.py index 89013ca..b067561 100644 --- a/backend/tests/runtime/test_gate5_setup.py +++ b/backend/tests/runtime/test_gate5_setup.py @@ -1,6 +1,8 @@ """Credential-free regressions for the gate-5 setup script's build and rerun identity.""" import json +import os +import subprocess # nosec B404 - fixed shell calls exercise cleanup with fake commands. import tempfile import unittest from contextlib import redirect_stdout @@ -91,6 +93,35 @@ def test_worker_pool_defaults_to_the_atespace_name(self): self.assertEqual(args.worker_pool, "nonroot-check") + def test_existing_worker_image_is_pinned_and_skips_ko_reference(self): + manifest = ( + Path(__file__).resolve().parents[3] + / "spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl" + ) + worker_image = "localhost:5001/ateom-gvisor@sha256:" + "b" * 64 + args = self.parse_cli_args(manifest, "--worker-image", worker_image) + + worker_pool, _ = gate5_setup.render_gate_manifest(args) + + self.assertIn(f"workerImage: {worker_image}", worker_pool) + self.assertNotIn("ko://", worker_pool) + + def test_existing_worker_image_rejects_tags_and_short_digests(self): + manifest = ( + Path(__file__).resolve().parents[3] + / "spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl" + ) + for image in ( + "localhost:5001/ateom-gvisor:latest", + "localhost:5001/ateom-gvisor@sha256:bad", + ): + with ( + self.subTest(image=image), + self.assertRaisesRegex(RuntimeError, "full sha256 digest"), + ): + args = self.parse_cli_args(manifest, "--worker-image", image) + gate5_setup.render_gate_manifest(args) + def test_image_manifest_preflight_checks_registry_endpoint_and_accept_types(self): calls = [] @@ -205,7 +236,10 @@ def runner(argv, **kwargs): with patch.dict("os.environ", {"KO_DOCKER_REPO": "localhost:5001"}): gate5_setup.apply_worker_pool( - "apiVersion: v1\n", + ( + "apiVersion: v1\n" + "workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor\n" + ), kubeconfig=FIXTURE_KUBECONFIG, context="kind-substrate-preview", ko=FIXTURE_KO, @@ -229,6 +263,39 @@ def runner(argv, **kwargs): ) self.assertEqual(apply_kwargs["input"], "resolved-yaml") + def test_existing_worker_image_applies_without_ko_or_source_checkout(self): + calls = [] + + def runner(argv, **kwargs): + calls.append((argv, kwargs)) + return completed(argv) + + gate5_setup.apply_worker_pool( + "apiVersion: v1\nkind: Namespace\nmetadata:\n name: lane-a\n", + kubeconfig=FIXTURE_KUBECONFIG, + context="kind-substrate-preview", + ko=FIXTURE_KO, + substrate_src=None, + runner=runner, + ) + + self.assertEqual(len(calls), 1) + apply_argv, apply_kwargs = calls[0] + self.assertEqual( + apply_argv, + [ + "kubectl", + "--context", + "kind-substrate-preview", + "--kubeconfig", + FIXTURE_KUBECONFIG, + "apply", + "-f", + "-", + ], + ) + self.assertIn("kind: Namespace", apply_kwargs["input"]) + def test_cluster_identity_uses_explicit_context_and_namespace_uid(self): calls = [] results = [ @@ -399,6 +466,22 @@ def expected_egress_argv(self, verb): "-", ] + def expected_get_egress_argv(self): + return [ + "/fixture/kubectl-ate", + "--kubeconfig", + FIXTURE_KUBECONFIG, + "--context", + "kind-substrate-preview", + "get", + "egress-policy", + "claude-gate5", + "--atespace", + "live-agent-gate", + "-o", + "json", + ] + def test_apply_egress_policy_creates_through_explicit_kube_target(self): args = self.egress_args(egress_deny_all=True) with patch.object( @@ -415,29 +498,80 @@ def test_apply_egress_policy_updates_an_existing_policy(self): args = self.egress_args(egress_hostnames=["api.openai.com"]) results = iter( [ - ( - 1, - "rpc error: code = AlreadyExists desc = EgressPolicy already exists", + completed( + self.expected_egress_argv("create"), + returncode=1, + stderr="rpc error: code = AlreadyExists desc = EgressPolicy already exists", + ), + completed( + self.expected_get_egress_argv(), + stdout=json.dumps( + {"metadata": {"uid": "policy-uid-1", "version": "12"}} + ), ), - (0, ""), + completed(self.expected_egress_argv("update")), ] ) def fake_run(argv, **_): - code, stderr = next(results) - return completed(argv, returncode=code, stderr=stderr) + result = next(results) + self.assertEqual(argv, result.args) + return result with patch.object(gate5_setup.subprocess, "run", side_effect=fake_run) as run: gate5_setup.apply_egress_policy(args) self.assertEqual( [call.args[0] for call in run.call_args_list], - [self.expected_egress_argv("create"), self.expected_egress_argv("update")], + [ + self.expected_egress_argv("create"), + self.expected_get_egress_argv(), + self.expected_egress_argv("update"), + ], ) - for call in run.call_args_list: - self.assertEqual( - json.loads(call.kwargs["input"]), - {"rules": [{"hostnames": {"patterns": ["api.openai.com"]}}]}, - ) + self.assertEqual( + json.loads(run.call_args_list[0].kwargs["input"]), + {"rules": [{"hostnames": {"patterns": ["api.openai.com"]}}]}, + ) + self.assertIsNone(run.call_args_list[1].kwargs.get("input")) + self.assertEqual( + json.loads(run.call_args_list[2].kwargs["input"]), + { + "metadata": {"uid": "policy-uid-1", "version": "12"}, + "rules": [{"hostnames": {"patterns": ["api.openai.com"]}}], + }, + ) + + def test_apply_egress_policy_refuses_update_without_valid_preconditions(self): + args = self.egress_args(egress_deny_all=True) + results = iter( + [ + completed( + self.expected_egress_argv("create"), + returncode=1, + stderr="rpc error: code = AlreadyExists desc = EgressPolicy already exists", + ), + completed( + self.expected_get_egress_argv(), + stdout=json.dumps( + {"metadata": {"uid": "policy-uid-1", "version": "0"}} + ), + ), + ] + ) + + def fake_run(argv, **_): + result = next(results) + self.assertEqual(argv, result.args) + return result + + with ( + patch.object(gate5_setup.subprocess, "run", side_effect=fake_run) as run, + self.assertRaisesRegex( + RuntimeError, "valid metadata.uid and metadata.version" + ), + ): + gate5_setup.apply_egress_policy(args) + self.assertEqual(run.call_count, 2) def test_apply_egress_policy_does_not_update_after_other_create_failures(self): args = self.egress_args(egress_deny_all=True) @@ -486,10 +620,77 @@ def test_persists_run_intent_before_actor_or_template_uids_exist(self): self.assertEqual(stored["template_name"], "live-agent-gate-v1") self.assertEqual(stored["worker_pool"], "live-agent-gate") self.assertEqual(stored["actor_name"], "claude-gate5") + self.assertIsNone(stored["namespace_uid"]) self.assertIsNone(stored["template_uid"]) self.assertIsNone(stored["actor_uid"]) self.assertEqual(stored["cluster_identity"], self.cluster()) + def test_creates_namespace_and_persists_returned_uid(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + args = self.args(path) + state = gate5_setup.prepare_run_state(args, self.cluster()) + calls = [] + + def runner(command, **kwargs): + calls.append((command, kwargs)) + return completed( + command, stdout='{"metadata":{"uid":"lane-namespace-uid"}}' + ) + + namespace_doc = "apiVersion: v1\nkind: Namespace\nmetadata:\n name: lane\n" + uid = gate5_setup.create_namespace_and_record_uid( + args, state, namespace_doc, runner=runner + ) + + self.assertEqual(uid, "lane-namespace-uid") + self.assertEqual(json.loads(Path(path).read_text())["namespace_uid"], uid) + self.assertEqual(Path(path).stat().st_mode & 0o777, 0o600) + self.assertEqual(calls[0][0][-5:], ["create", "-f", "-", "-o", "json"]) + self.assertEqual(calls[0][1]["input"], namespace_doc) + + def test_resume_refuses_state_without_namespace_uid(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + args = self.args(path) + state = gate5_setup.prepare_run_state(args, self.cluster()) + state.pop("namespace_uid") + gate5_setup.save_state(path, state) + + with self.assertRaisesRegex(RuntimeError, "namespace_uid"): + gate5_setup.prepare_run_state(args, self.cluster()) + + def test_resume_refuses_empty_namespace_uid(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + args = self.args(path) + state = gate5_setup.prepare_run_state(args, self.cluster()) + gate5_setup.save_state(path, state) + + with self.assertRaisesRegex(RuntimeError, "namespace_uid"): + gate5_setup.prepare_run_state(args, self.cluster()) + + def test_namespace_uid_rerun_refuses_to_adopt_replacement(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "state.json") + args = self.args(path) + state = gate5_setup.prepare_run_state(args, self.cluster()) + state["namespace_uid"] = "original-namespace-uid" + gate5_setup.save_state(path, state) + + def runner(argv, **_): + return completed( + argv, stdout='{"metadata":{"uid":"replacement-namespace-uid"}}' + ) + + with self.assertRaisesRegex(RuntimeError, "refusing to adopt"): + gate5_setup.verify_namespace_uid(args, state, runner=runner) + + self.assertEqual( + json.loads(Path(path).read_text())["namespace_uid"], + "original-namespace-uid", + ) + def test_rerun_refuses_changed_cluster_identity(self): with tempfile.TemporaryDirectory() as temp_dir: path = str(Path(temp_dir) / "state.json") @@ -846,6 +1047,255 @@ async def unexpected_worker_wait(*_args, **_kwargs): self.assertEqual(control.events, ["get_actor"]) +class LaneAProviderCleanupTests(unittest.TestCase): + def make_cleanup_environment( + self, + temp_dir: str, + *, + unowned_secret: bool = False, + change_secret_uid: bool = False, + ) -> tuple[dict[str, str], Path]: + root = Path(temp_dir) + state_root = root / "state" + state_root.mkdir() + fake_bin = root / "bin" + fake_bin.mkdir() + substrate_bin = root / "substrate" / "bin" + substrate_bin.mkdir(parents=True) + + state_file = state_root / "claude-gate5-state.json" + state_file.write_text( + json.dumps( + { + "context": "kind-substrate-preview", + "atespace": "lane-a-claude-20260924", + "worker_pool": "lane-a-claude-20260924", + "template_name": "live-agent-gate-lane-a-claude-20260924", + "actor_name": "claude-live-proof", + "namespace_uid": "lane-namespace-uid", + } + ) + ) + + kubectl = fake_bin / "kubectl" + kubectl.write_text( + """#!/bin/bash +set -eu +printf '%s\\n' "$*" >> "${FAKE_KUBECTL_LOG}" +args=" $* " +if [[ ${args} == *" get namespace lane-a-claude-20260924 "* ]]; then + printf '%s\\n' '{"metadata":{"uid":"lane-namespace-uid"}}' + exit 0 +fi +if [[ ${args} == *" get workerpool lane-a-claude-20260924 "* ]]; then + printf '%s\\n' 'Error from server (NotFound): workerpool not found' >&2 + exit 1 +fi +if [[ ${args} == *" get secret claude-oauth "* ]]; then + if [[ ${FAKE_UNOWNED_SECRET:-0} == 1 ]]; then + printf '%s\\n' 'secret/claude-oauth' + exit 0 + fi + count_file=${FAKE_KUBECTL_LOG}.secret-count + count=0 + if [[ -f ${count_file} ]]; then read -r count < "${count_file}"; fi + count=$((count + 1)) + printf '%s\\n' "${count}" > "${count_file}" + uid=secret-uid + if [[ ${FAKE_CHANGE_SECRET_UID:-0} == 1 && ${count} -gt 1 ]]; then + uid=replacement-secret-uid + fi + printf '{"metadata":{"uid":"%s","labels":{"proof.mainloop.dev/lane":"lane-a-live-proof"}}}\\n' "${uid}" + exit 0 +fi +if [[ ${args} == *" get service credprovider "* ]]; then + printf '%s\\n' '{"metadata":{"uid":"service-uid","labels":{"proof.mainloop.dev/lane":"lane-a-live-proof"}}}' + exit 0 +fi +if [[ ${args} == *" get deployment round3-claude-provider "* ]]; then + printf '%s\\n' '{"metadata":{"uid":"deployment-uid","labels":{"proof.mainloop.dev/lane":"lane-a-live-proof"}}}' + exit 0 +fi +if [[ ${args} == *" get serviceaccount round3-claude-provider "* ]]; then + printf '%s\\n' '{"metadata":{"uid":"serviceaccount-uid","labels":{"proof.mainloop.dev/lane":"lane-a-live-proof"}}}' + exit 0 +fi +if [[ ${args} == *" get networkpolicy round3-claude-provider "* ]]; then + printf '%s\\n' '{"metadata":{"uid":"networkpolicy-uid","labels":{"proof.mainloop.dev/lane":"lane-a-live-proof"}}}' + exit 0 +fi +if [[ ${args} == *" delete --raw=/api/v1/namespaces/mainloop-control/"* || ${args} == *" delete --raw=/apis/"* ]]; then + printf '%s\\n' "$*" >> "${FAKE_PROVIDER_DELETE_LOG}" + cat >> "${FAKE_PROVIDER_DELETE_PAYLOADS}" + printf '%s\\n' '---' >> "${FAKE_PROVIDER_DELETE_PAYLOADS}" + exit 0 +fi +if [[ ${args} == *" delete --raw="* ]]; then + cat >/dev/null + exit 0 +fi +exit 0 +""" + ) + kubectl.chmod(0o755) + + ate = substrate_bin / "kubectl-ate" + ate.write_text( + """#!/bin/bash +set -eu +args=" $* " +if [[ ${args} == *" get actor "* || ${args} == *" get actor-template "* ]]; then + printf '%s\\n' 'NotFound' >&2 + exit 1 +fi +exit 0 +""" + ) + ate.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}{os.pathsep}{env['PATH']}", + "LIVE_PROOF_STATE_DIR": str(state_root), + "SUBSTRATE_SRC": str(root / "substrate"), + "FAKE_KUBECTL_LOG": str(root / "kubectl.log"), + "FAKE_PROVIDER_DELETE_LOG": str(root / "provider-deletes.log"), + "FAKE_PROVIDER_DELETE_PAYLOADS": str( + root / "provider-delete-payloads.jsonl" + ), + "FAKE_UNOWNED_SECRET": "1" if unowned_secret else "0", + "FAKE_CHANGE_SECRET_UID": "1" if change_secret_uid else "0", + } + ) + return env, state_file + + def run_cleanup(self, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + script = ( + Path(__file__).resolve().parents[3] + / "spikes/substrate-workspace-adapter/live/cleanup-lane-a.sh" + ) + return subprocess.run( # nosec B603 - fixed script path; kubectl resolves to a temp fake. + ["/bin/bash", str(script), "claude"], + env=env, + capture_output=True, + check=False, + text=True, + ) + + def test_unowned_provider_preflight_then_failure_fallback_skips_deletes(self): + with tempfile.TemporaryDirectory() as temp_dir: + env, _ = self.make_cleanup_environment(temp_dir, unowned_secret=True) + live_dir = ( + Path(__file__).resolve().parents[3] + / "spikes/substrate-workspace-adapter/live" + ) + env["LIVE_DIR"] = str(live_dir) + proof_script = (live_dir / "claude_turn_proof.sh").read_text() + cleanup_start = proof_script.index("cleanup_provider() {") + cleanup_end = proof_script.index("\nfinish() {", cleanup_start) + cleanup_function = proof_script[cleanup_start:cleanup_end] + finish_start = proof_script.index("finish() {") + finish_end = proof_script.index("\ntrap finish EXIT INT TERM", finish_start) + finish_function = proof_script[finish_start:finish_end] + harness = "\n".join( + ( + "set -euo pipefail", + 'source "${LIVE_DIR}/common.sh"', + "SCRIPT_DIR=${LIVE_DIR}", + "LANE_STARTED=1", + "COMPLETE=0", + "PROVIDER_READY=0", + "declare -A PROVIDER_UIDS=()", + "STATE_FILE=${LIVE_PROOF_STATE_DIR}/claude-gate5-state.json", + "stop_router() { :; }", + cleanup_function, + finish_function, + "trap finish EXIT INT TERM", + "preflight_provider_resources_absent", + ) + ) + preflight_and_trap = ( + subprocess.run( # nosec B603 - fixed trap/preflight with fake kubectl. + ["/bin/bash", "-c", harness], + env=env, + capture_output=True, + check=False, + text=True, + ) + ) + self.assertNotEqual(preflight_and_trap.returncode, 0) + self.assertIn( + "secret/claude-oauth already exists", preflight_and_trap.stderr + ) + self.assertIn("CLEANUP=PASS", preflight_and_trap.stdout) + calls = Path(env["FAKE_KUBECTL_LOG"]).read_text().splitlines() + provider_mutations = [ + call + for call in calls + if " delete " in f" {call} " + and any( + token in call + for token in ( + "claude-oauth", + "credprovider", + "round3-claude-provider", + ) + ) + ] + self.assertEqual(provider_mutations, []) + + def test_provider_uid_change_aborts_cleanup_before_any_provider_delete(self): + with tempfile.TemporaryDirectory() as temp_dir: + env, _ = self.make_cleanup_environment(temp_dir, change_secret_uid=True) + cleanup = self.run_cleanup(env) + + self.assertNotEqual(cleanup.returncode, 0) + self.assertIn( + "provider cleanup skipped secret/claude-oauth", cleanup.stderr + ) + self.assertNotIn("CLEANUP=PASS", cleanup.stdout) + calls = Path(env["FAKE_KUBECTL_LOG"]).read_text().splitlines() + provider_deletes = [ + call + for call in calls + if " delete --raw=/api/v1/namespaces/mainloop-control/" in call + or " delete --raw=/apis/" in call + ] + self.assertEqual(provider_deletes, []) + delete_options = Path(env["FAKE_KUBECTL_LOG"] + ".secret-count") + self.assertEqual(delete_options.read_text(), "2\n") + + def test_owned_provider_deletes_use_the_verified_uid_precondition(self): + with tempfile.TemporaryDirectory() as temp_dir: + env, _ = self.make_cleanup_environment(temp_dir) + cleanup = self.run_cleanup(env) + + self.assertEqual(cleanup.returncode, 0, cleanup.stderr) + provider_deletes = ( + Path(env["FAKE_PROVIDER_DELETE_LOG"]).read_text().splitlines() + ) + self.assertEqual(len(provider_deletes), 5) + payloads = [ + json.loads(payload) + for payload in Path(env["FAKE_PROVIDER_DELETE_PAYLOADS"]) + .read_text() + .split("---") + if payload.strip() + ] + self.assertEqual(len(payloads), 5) + self.assertEqual( + {payload["preconditions"]["uid"] for payload in payloads}, + { + "secret-uid", + "service-uid", + "deployment-uid", + "serviceaccount-uid", + "networkpolicy-uid", + }, + ) + + def asyncio_run(coro): import asyncio diff --git a/backend/tests/runtime/test_substrate.py b/backend/tests/runtime/test_substrate.py index eefd35c..e97db1e 100644 --- a/backend/tests/runtime/test_substrate.py +++ b/backend/tests/runtime/test_substrate.py @@ -83,13 +83,24 @@ def run(coro): return asyncio.run(coro) -def actor_json(state: str, *, snapshot_uri: str | None = None, uid: str = "u-1") -> str: +def actor_json( + state: str, + *, + snapshot_uri: str | None = None, + template_uid: str | None = None, + uid: str = "u-1", +) -> str: doc = { "metadata": {"atespace": "mainloop-workspaces", "name": "ml-abc", "uid": uid}, "status": {"state": state}, } - if snapshot_uri: - doc["status"]["externalSnapshot"] = {"snapshotUri": snapshot_uri} + if snapshot_uri or template_uid: + snapshot = {"contentScope": "SNAPSHOT_CONTENT_SCOPE_FULL"} + if snapshot_uri: + snapshot["snapshotUri"] = snapshot_uri + if template_uid: + snapshot["actorTemplateUid"] = template_uid + doc["status"]["externalSnapshot"] = snapshot return json.dumps(doc) @@ -137,12 +148,37 @@ class ActorJsonParsingTests(unittest.TestCase): def test_parses_running_actor_with_snapshot(self): import json - doc = json.loads(actor_json("ACTOR_STATE_RUNNING", snapshot_uri="gs://b/p")) + doc = json.loads( + actor_json( + "ACTOR_STATE_RUNNING", + snapshot_uri="gs://b/p", + template_uid="template-snapshot-1", + ) + ) record = _actor_from_json(doc) self.assertEqual(record.atespace, "mainloop-workspaces") self.assertEqual(record.name, "ml-abc") self.assertEqual(record.state, ActorState.RUNNING) self.assertEqual(record.external_snapshot_uri, "gs://b/p") + self.assertEqual(record.current_actor_template_uid, "template-snapshot-1") + + def test_template_uid_is_read_from_external_snapshot(self): + doc = { + "metadata": {"atespace": "mainloop-workspaces", "name": "ml-abc"}, + "status": { + "state": "ACTOR_STATE_SUSPENDED", + "externalSnapshot": { + "snapshotUri": "gs://b/p", + "contentScope": "SNAPSHOT_CONTENT_SCOPE_FULL", + "actorTemplateUid": "template-snapshot-only", + }, + }, + } + + record = _actor_from_json(doc) + + self.assertNotIn("currentActorTemplateUid", doc["status"]) + self.assertEqual(record.current_actor_template_uid, "template-snapshot-only") def test_unrecognized_state_string_is_unspecified_not_a_crash(self): record = _actor_from_json( diff --git a/docs/architecture.md b/docs/architecture.md index ae73400..b37d3ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -178,9 +178,9 @@ Kubernetes sign-in job and the external egress credential-provider contract are model yet, so the token effectively grants full Substrate access. This is a known authorization gap; the ClusterTrustBundle permission is limited to `list`. - Mainloop pins a Substrate fork whose actor runtime runs containers as the image's `USER` in - its `WORKDIR`; the agent image runs as UID `10001` and refuses UID 0. Non-root actor startup was - live checked on Kind with fork commit `ce265c1d`. The durable workspace directory must be - writable by that user. + its `WORKDIR`; the agent image runs as UID `10001` and refuses UID 0. The fork gives a fresh + durable volume to that user. Non-root startup and a durable volume surviving suspend and resume + were live checked on Kind with fork commit `0f9635ae` using the spike's proof scripts. - The actor's observed `RLIMIT_NOFILE` is 1024. - Restoring Postgres from an actor snapshot is unverified. - Live wake-on-preview is unverified. diff --git a/docs/specs/workspaces.md b/docs/specs/workspaces.md index 852caab..a82e231 100644 --- a/docs/specs/workspaces.md +++ b/docs/specs/workspaces.md @@ -6,7 +6,9 @@ native-agent activity, message delivery, user attention, and publication state. Workspace actors currently use Substrate's gVisor sandbox; microVM isolation is deferred. The Mainloop agent image runs as UID `10001`, and the pinned Substrate fork honors that image user. -Non-root actor startup was live checked on Kind with fork commit `ce265c1d`. +Non-root actor startup, a headless Claude turn, and a durable workspace volume surviving +suspend and resume were live checked on Kind with fork commit `0f9635ae`, using the spike's proof +scripts rather than the Mainloop API. ## Lifecycle diff --git a/docs/spikes/substrate-workspace-adapter.md b/docs/spikes/substrate-workspace-adapter.md index 848c986..21cb621 100644 --- a/docs/spikes/substrate-workspace-adapter.md +++ b/docs/spikes/substrate-workspace-adapter.md @@ -9,9 +9,11 @@ actor; the target described by this closeout has no Herdr server or terminal man actor and invokes native CLIs headlessly once per turn. See `docs/spikes/k8s-herdr-agents.md` for the historical native-session/Herdr spike. -Historical scope: this closeout records evidence and design as of 2026-09-23. The 2026-09-24 -cutover made Substrate the only runtime and removed the `WORKSPACE_RUNTIME` switch and its former -default. Results below remain evidence for the versions and actors actually measured at that time. +Historical scope: the Phase 5 closeout records evidence and design as of 2026-09-23. The +2026-09-24 cutover made Substrate the only runtime and removed the `WORKSPACE_RUNTIME` switch and +its former default. Lane A run 5's durable restore and run 6's Claude turn measurements are dated +addenda in the gate table; other results remain evidence for the versions and actors actually +measured at their run times. ## Current status — Phase 5 closeout (2026-09-23) @@ -41,16 +43,21 @@ compute Mainloop's roadmap calls for ("Workspace platform"), while Mainloop stay owner of session-to-actor mapping, delivery, and audit state. The target actor has no Herdr server or terminal manager; Mainloop delivers each turn to a headless native CLI process. The actor holds files between turns, with no attachable TUI; callers watch progress through streamed -events. The rebuilt image for that design is not yet live-proved. Existing live lifecycle and +events. Lane A run 5 measured the target image running as UID 10001 and restoring `/work/repo` +after both WorkerPool pods were deleted. Lane A run 6 then measured a Claude native turn and +same-session recall across suspend/resume on the headless actor. Claude continuity after worker +loss and native Codex behavior on this actor remain unproved. Existing live lifecycle and native-session results below came from the earlier Herdr-backed actor and are historical evidence -for Substrate and the Phase 3 boundary, not proof of the target image. +for Substrate and the Phase 3 boundary. The headless shim exposes authenticated `POST /turn` and `GET /turn/:id` endpoints. Prompts are sent to the native CLI on stdin; the shim stores bounded JSONL events per turn and reports the native session/thread id and final message. It permits one in-flight turn per agent and returns 409 instead of queueing. `POST /run` starts a shell command with a bounded timeout and actor-local output file; `GET /run/:id` reports its status and bounded output. `/healthz` and `/readyz` check -only the shim and workspace. The actor image and these routes still need live proof. +only the shim and workspace. Lane A run 5 exercised `/run`, `/healthz`, and durable workspace +restore; run 6 exercised Claude `/turn`, same-session recall, concurrent-turn rejection, and +credential-free health on the headless actor. ## Pre-cutover Substrate configuration (2026-09-23) @@ -99,10 +106,10 @@ CLI's local environment or filesystem. The pinned commit also has experimental static-header injection from a Kubernetes Secret URI into decrypted outbound requests. HTTPS hostname rules require the Envoy sdsmint overlay and `--experimental-egress-credential-injection`; the plain Envoy overlay has no MITM egress, and -agentgateway does not implement this injection path. An earlier Envoy 1.39.1 router install -crashed, while the later Round 3 Cilium setup ran Envoy egress with sdsmint and the -actor-bound credential provider. Claude's credential was injected on the upstream leg and stayed -out of actor snapshots. Codex instead received `auth.json` through the authenticated shim because +agentgateway does not implement this injection path. The Envoy 1.39.1 router crash was from the +earlier `cdac9ba` install; it does not describe the `0f9635ae` preview, which ran Envoy with +sdsmint and the actor-bound credential provider. Claude's credential was injected on the upstream +leg and stayed out of actor snapshots. Codex instead received `auth.json` through the authenticated shim because Codex refreshes that file locally; its actor snapshots therefore contain that credential. Moving Codex credentials out of snapshots remains a production requirement. Neither credential value was logged or copied into a golden snapshot, and the old unauthenticated relay was not used. @@ -122,14 +129,51 @@ Bash tool. ## Substrate source -Mainloop runs Substrate from a fork, [`oldsj/substrate`](https://github.com/oldsj/substrate), -branch `patched`, pinned at `ce265c1dbd3775faf10c95f71f2c16ff3d47c332`. That branch is upstream -`cdac9baef81dd319b46086d695266e6161e9e592` plus a short patch stack listed in the fork's -`FORK.md`: notably, actor containers run as the image's `USER` in its `WORKDIR` with the image's file owners kept, fresh durable volumes are owned by that user, and -`kubectl ate` gains `get`, `create` and `update egress-policy`. The fork carries no -Mainloop-specific code. `backend/scripts/gate5_setup.py` refuses any other commit. The -evidence below records the commit each result was measured on; results before the fork were -measured on upstream `cdac9ba`. +Mainloop targets Substrate fork [`oldsj/substrate`](https://github.com/oldsj/substrate), +branch `patched-next`, pinned at +`0f9635aed37bd5dde604a9bca1975421cd07181a`. The fork carries no Mainloop-specific code. +It is rebased on upstream `14c0c136bc3fda4d8e67de3851c087a28a2e754b` with the fork patches +that run actor containers as the image user in its working directory, preserve image and +non-root layer ownership, and assign fresh durable volumes to the first writable non-root user. +When `backend/scripts/gate5_setup.py` resolves a `ko://` WorkerPool image from source, it +requires this exact commit; the live proof scripts use a digest-pinned WorkerPool image instead. +The evidence below records the commit each result was measured on; results before the fork were +measured on upstream `cdac9ba`, the historical Lane A rows are labelled `ce265c1d`, and the new +Lane A rows are labelled with fork `patched-next` commit `0f9635aed37bd5dde604a9bca1975421cd07181a`. + +The adapter changes for `patched-next` read the actor template UID from +`status.externalSnapshot.actorTemplateUid`, use ActorTemplate fields `wakeupProbe` and +`snapshotConfig` in place of `readyz` and `snapshotsConfig`, and include the current +`metadata.uid` and `metadata.version` as egress-policy update preconditions. The API has no +egress-policy delete operation; the Claude proof revokes credential injection by updating the +policy to an empty rule set. + +Run these from the pinned Substrate checkout. Credential injection requires the Envoy dataplane; +`ate-setup` rejects the injection flag with agentgateway. The `0f9635ae` preview used Envoy with +sdsmint and both credential-provider overrides: + +```sh +cd "$SUBSTRATE_SRC" +export VERSION=0f9635ae +export KO_DOCKER_REPO=localhost:5001 +KUBECONFIG_PATH=/tmp/substrate-preview-kubeconfig + +go run ./cmd/ate-setup --kind --kubeconfig "$KUBECONFIG_PATH" --context kind-substrate-preview \ + --atenet-dataplane envoy --experimental-use-sdsmint --experimental-egress-credential-injection \ + --credential-provider-name ate-secret://kubernetes.io \ + --credential-provider-address credprovider.mainloop-control.svc:50051 \ + deploy ate-system +go run ./cmd/ate-setup --kind --kubeconfig "$KUBECONFIG_PATH" --context kind-substrate-preview \ + --atenet-dataplane envoy --experimental-use-sdsmint --experimental-egress-credential-injection \ + --credential-provider-name ate-secret://kubernetes.io \ + --credential-provider-address credprovider.mainloop-control.svc:50051 \ + deploy atenet +``` + +At `0f9635ae`, the defaults are `ate-secret://k8s.io` and +`k8s-credential-provider.ate-system.svc:50051`. Redeploying without the overrides breaks the +Claude provider preflight with HTTP 500: `credential URI names a provider this gateway does not +serve`. ## Run it @@ -140,12 +184,7 @@ are recorded in the task's proof note. In outline: # SUBSTRATE_SRC is a checkout of oldsj/substrate at the pinned commit above. KIND_CLUSTER_NAME=substrate-preview KUBECONFIG=/tmp/substrate-preview-kubeconfig \ "$SUBSTRATE_SRC"/hack/create-kind-cluster.sh -KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ - KUBECONFIG=/tmp/substrate-preview-kubeconfig \ - "$SUBSTRATE_SRC"/hack/install-ate-kind.sh --deploy-ate-system -KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ - KUBECONFIG=/tmp/substrate-preview-kubeconfig \ - "$SUBSTRATE_SRC"/hack/install-ate-kind.sh --deploy-atenet --atenet-dataplane=agentgateway +# Deploy ate-system and atenet with the `ate-setup` commands above. # build kubectl-ate, build+push an actor image, apply one of: # k8s/actor-template.yaml.tmpl -- mainloop-workspace: headless native-agent shim # k8s/preview-gate-template.yaml.tmpl -- preview-gate: real Vite dev server + exec shim @@ -158,8 +197,9 @@ KIND_CLUSTER_NAME=substrate-preview KUBECTL_CONTEXT=kind-substrate-preview \ ## Observed behaviour -- The default Envoy-based `atenet-router` crash-looped on this cluster too (matching the prior - `docs/spikes/../substrate-kind-preview-proof` finding); the `agentgateway` dataplane fixed it. +- Historical for the earlier `cdac9ba` install: the default Envoy-based `atenet-router` + crash-looped, and the `agentgateway` dataplane fixed startup. The `0f9635ae` preview used + Envoy with sdsmint for credential injection. - A freshly created actor starts `SUSPENDED`, not running -- `create_actor` never implicitly starts an actor. An explicit `resume_actor` is required, and it returned `RUNNING` directly (no further polling needed) in every observed case. @@ -455,36 +495,41 @@ previously served by the removed relay. ## Current CapabilityResult — gates 1–6 and Phase 4 -| Gate / capability | State | Scope | Evidence and limit | -| ----------------------------------------------- | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Gate 1 — native contract | proved | fixture | Record transitions, ownership fencing, and reconciliation are exercised with `ContractStore` fakes; this is not a database-backed restart proof. | -| Gate 2 — workspace binding adapter | partial | fixture | Planning and row-mapping logic use fakes; `workspace_bindings` orchestration was not run against live Postgres and a running backend. | -| Gate 2 — Substrate control adapter | proved | live | Actor lifecycle operations used the real `kubectl ate` adapter and the Cilium preview cluster. | -| Gate 3 — preview/HMR | proved | live | Real Vite edits updated the open browser session over WebSocket, including across suspend/resume. | -| Gate 4 — dev-service access | proved | live | Real PostgreSQL query, narrow CIDR policy, denied destination, and reconnect after wake passed. | -| Gate 5 — native sessions in prior actor design | proved | live | Claude and Codex each completed a native turn and recalled a nonce after suspend/resume in the same Herdr-backed actor. Suspend/resume measured 393/408 ms for Claude and 573/789 ms for Codex. | -| Gate 5 — headless per-turn CLI actor | unknown | unverified | Target design has no Herdr server or terminal manager in the actor. Its rebuilt image is not yet live-proved; the earlier Phase 4 measurements do not establish this gate. | -| Gate 5 — complete continuity | partial | live | In the earlier actor design, the Codex file marker was not confirmed. Claude recall after worker loss, Claude history after revert, and the Codex marker follow-up remain pending. | -| Gate 6 — actor failure recovery | partial | live | CRASHED-to-revert-to-resume and replacement-worker restore were measured; native recall after worker loss and history after revert are not established. | -| Phase 4 — Claude native session (prior actor) | proved | live | In the earlier Herdr-backed actor, Claude Code completed a turn and same-session nonce recall after suspend/resume; the post-worker-loss and post-revert history checks remain pending. | -| Phase 4 — Codex native session (prior actor) | partial | live | In the earlier Herdr-backed actor, Codex CLI completed a turn and same-session nonce recall after suspend/resume; file-marker continuity remains pending. | -| `backend_restart_delivery_reconciliation` | proved | fixture | A new fake-backed test reloads a persisted message and `recorded` attempt into a fresh `ContractStore`; retry is blocked until `not_delivered` evidence, and the same payload reference remains pending. It does not exercise Postgres, a transport, or the production delivery loop. | -| `router_ingress_boundary` and `shim_token_auth` | proved | live | The unrelated namespace was denied, control-namespace access succeeded, and missing/wrong/correct token plus one-time install and suspend/resume checks passed. | -| `provider_hostname_egress` | proved | live | Envoy/Cilium actor policies denied unlisted hosts; the earlier agentgateway HTTPS path allowed an unlisted host and is not a supported hostname boundary. | -| `credential_delivery` | proved | live | Claude was injected on the Envoy upstream leg. Codex `auth.json` was installed through the shim and remains in the actor snapshot; production must move Codex credentials to egress injection. | -| `cilium_kube_proxy_replacement` | partial | live | KPR=true CoreDNS Pod-IP queries worked, but kube-dns Service-IP queries timed out. The cause was not isolated; the run continued with KPR=false. | -| `snapshot_bucket_access_control` | unknown | unverified | Snapshot-bucket read access was not established. | -| `stuck_state_timeouts` | unknown | unverified | Production timeouts and surfaced recovery for stuck states remain to be implemented and measured. | +| Gate / capability | State | Scope | Evidence and limit | +| ------------------------------------------------------------------ | ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Gate 1 — native contract | proved | fixture | Record transitions, ownership fencing, and reconciliation are exercised with `ContractStore` fakes; this is not a database-backed restart proof. | +| Gate 2 — workspace binding adapter | partial | fixture | Planning and row-mapping logic use fakes; `workspace_bindings` orchestration was not run against live Postgres and a running backend. | +| Gate 2 — Substrate control adapter | proved | live | Actor lifecycle operations used the real `kubectl ate` adapter and the Cilium preview cluster. | +| Gate 3 — preview/HMR | proved | live | Real Vite edits updated the open browser session over WebSocket, including across suspend/resume. | +| Gate 4 — dev-service access | proved | live | Real PostgreSQL query, narrow CIDR policy, denied destination, and reconnect after wake passed. | +| Gate 5 — native sessions in prior actor design | proved | live | Claude and Codex each completed a native turn and recalled a nonce after suspend/resume in the same Herdr-backed actor. Suspend/resume measured 393/408 ms for Claude and 573/789 ms for Codex. | +| Gate 5 — headless per-turn CLI actor | partial | live | Lane A runs 5–7 measured `/run`, `/healthz`, and durable restore; runs 6 and 9 proved the Claude turn and same-session recall path on this actor. Native Codex behavior and Claude continuity after worker loss remain unproved. | +| Gate 5 — headless Claude turn and same-session recall | proved | live | Measured on `kind-substrate-preview` in Lane A run 6, fork/Substrate atelet `ce265c1d`, actor image `live-agent-gate@sha256:eb819c5ae18829a18972fd7be9ec44e0f3dbef08e554f8e0dfc1d6240f962f5d`: UID 10001; provider preflight HTTP 200; concurrent `/turn` HTTP 409; native turn and same-session recall completed across suspend/resume; suspend 414 ms; resume 480 ms; health 200; zero credential leaks in provider and WorkerPool logs. | +| Gate 5 — durable `/work/repo` restore after worker loss | proved | live | Measured on `kind-substrate-preview` in Lane A run 5, fork `ce265c1d`, WorkerPool image digest prefix `e19e6617`: UID 10001; `octocat/Hello-World` at `7fd1a60b`; 48 files; snapshot scope `FULL`; suspend 418 ms; both original workers deleted; resume 2,589 ms including the free-worker retry wait; health 200; exact path/UID/GID/mode/SHA-256 manifest match. Lane resources were cleaned up after evidence capture. | +| Gate 5 — headless Claude turn and same-session recall (0f9635ae) | proved | live | `CLAUDE_TURN_PROOF=PASS` on `kind-substrate-preview`, Lane A run 10, fork `patched-next` commit `0f9635aed37bd5dde604a9bca1975421cd07181a`: UID 10001; provider preflight HTTP 200; concurrent `/turn` HTTP 409; same-session recall after suspend/resume; marker hash matched. Provider and WorkerPool log collection both succeeded (a failed fetch now fails the check), and both report zero credential-prefix leaks. `suspend_ms=415`, `resume_ms=483` are single-run wall-clock measurements, not benchmarks. Evidence: `lane-a-claude-proof-0f9635ae-run10-2026-09-25.log`. | +| Gate 5 — durable `/work/repo` restore after worker loss (0f9635ae) | proved | live | `DURABLE_RESTORE_PROOF=PASS` on `kind-substrate-preview`, Lane A run 7, fork `patched-next` commit `0f9635aed37bd5dde604a9bca1975421cd07181a`: `suspend_ms=415`, `resume_ms=481`, `worker_loss=yes`, `files=48`; replacement-worker restore succeeded with an exact manifest match. Timings are single-run wall-clock measurements, not benchmarks. Evidence: `lane-a-durable-proof-0f9635ae-run7-2026-09-25.log`. | +| Gate 5 — complete continuity | partial | live | Lane A runs 6 and 9 proved Claude same-session recall across suspend/resume; runs 5 and 7 proved durable workspace restore after worker loss. Native Claude recall after worker loss, history after revert, and the earlier Codex marker follow-up remain pending. | +| Gate 6 — actor failure recovery | partial | live | CRASHED-to-revert-to-resume and replacement-worker restore were measured; native recall after worker loss and history after revert are not established. | +| Phase 4 — Claude native session (prior actor) | proved | live | In the earlier Herdr-backed actor, Claude Code completed a turn and same-session nonce recall after suspend/resume; the post-worker-loss and post-revert history checks remain pending. | +| Phase 4 — Codex native session (prior actor) | partial | live | In the earlier Herdr-backed actor, Codex CLI completed a turn and same-session nonce recall after suspend/resume; file-marker continuity remains pending. | +| `backend_restart_delivery_reconciliation` | proved | fixture | A new fake-backed test reloads a persisted message and `recorded` attempt into a fresh `ContractStore`; retry is blocked until `not_delivered` evidence, and the same payload reference remains pending. It does not exercise Postgres, a transport, or the production delivery loop. | +| `router_ingress_boundary` and `shim_token_auth` | proved | live | The unrelated namespace was denied, control-namespace access succeeded, and missing/wrong/correct token plus one-time install and suspend/resume checks passed. | +| `provider_hostname_egress` | proved | live | Envoy/Cilium actor policies denied unlisted hosts; the earlier agentgateway HTTPS path allowed an unlisted host and is not a supported hostname boundary. | +| `credential_delivery` | proved | live | Claude was injected on the Envoy upstream leg. Codex `auth.json` was installed through the shim and remains in the actor snapshot; production must move Codex credentials to egress injection. | +| `cilium_kube_proxy_replacement` | partial | live | KPR=true CoreDNS Pod-IP queries worked, but kube-dns Service-IP queries timed out. The cause was not isolated; the run continued with KPR=false. | +| `snapshot_bucket_access_control` | unknown | unverified | Snapshot-bucket read access was not established. | +| `stuck_state_timeouts` | unknown | unverified | Production timeouts and surfaced recovery for stuck states remain to be implemented and measured. | ## Current recommendation and production requirements **Defer production adoption of the headless per-turn actor design.** The earlier Herdr-backed actor proved that Substrate and the Phase 3 boundary can host native Claude and Codex sessions; -both completed a turn and same-session nonce recall across suspend/resume. This does not prove -the target actor, which has no Herdr server or terminal manager and invokes native CLIs headlessly -per turn. Its rebuilt image is not yet live-proved. Production readiness also remains partial -because the Codex marker and two Claude post-worker-loss/revert-history checks are pending, and -durable backend integration and snapshot access controls are not proved. +both completed a turn and same-session nonce recall across suspend/resume. Lane A runs 5 and 7 +measured the target image running as UID 10001 and restoring durable workspace contents after +worker loss; runs 6 and 9 measured Claude turn completion and same-session recall across +suspend/resume on the headless actor. Production readiness remains partial because native +Claude/Codex continuity after worker loss or revert, durable backend integration, and +snapshot access controls are not proved. Production requirements: diff --git a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl index 353052d..8aabb74 100644 --- a/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl +++ b/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl @@ -59,10 +59,10 @@ containers: limits: - { name: cpu, quantity: "2" } - { name: memory, quantity: 2Gi } - readyz: + wakeupProbe: httpGet: { path: /healthz, port: 8090 } timeoutSeconds: 60 -snapshotsConfig: +snapshotConfig: onPause: SNAPSHOT_CONTENT_SCOPE_FULL onCommit: SNAPSHOT_CONTENT_SCOPE_FULL storageLocation: gs://${BUCKET_NAME}/mainloop-workspaces/ diff --git a/spikes/substrate-workspace-adapter/live/claude_turn_proof.sh b/spikes/substrate-workspace-adapter/live/claude_turn_proof.sh new file mode 100755 index 0000000..d256bef --- /dev/null +++ b/spikes/substrate-workspace-adapter/live/claude_turn_proof.sh @@ -0,0 +1,481 @@ +#!/usr/bin/env bash +# Bounded live Claude continuity proof on the product template. This script +# uses a prebuilt provider image and never builds or pushes images. +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +ATESPACE=lane-a-claude-20260924 +POOL=lane-a-claude-20260924 +ACTOR=claude-live-proof +STATE_FILE=${STATE_ROOT}/claude-gate5-state.json +CLAUDE_TOKEN_FILE=${CLAUDE_TOKEN_FILE:-${HOME}/.claude-token} +CLAUDE_SECRET_URI=ate-secret://kubernetes.io/mainloop-control/claude-oauth/oauth-token +PROVIDER_IMAGE=${CLAUDE_PROVIDER_IMAGE-} +PROVIDER_READY=0 +LANE_STARTED=0 +COMPLETE=0 +declare -A PROVIDER_UIDS=() + +record_provider_uid() { + local resource=$1 object_json=$2 uid + PROVIDER_READY=1 + if ! uid=$(jq -er '.metadata.uid | strings | select(length > 0)' <<<"${object_json}"); then + die "created provider resource ${resource} response omitted metadata.uid" + return 1 + fi + PROVIDER_UIDS["${resource}"]=${uid} +} + +cleanup_provider() { + ((PROVIDER_READY == 1)) || return 0 + local resource kind name recorded_uid live_uid + for resource in networkpolicy/round3-claude-provider deployment/round3-claude-provider \ + service/credprovider serviceaccount/round3-claude-provider secret/claude-oauth; do + recorded_uid=${PROVIDER_UIDS["${resource}"]-} + [[ -n ${recorded_uid} ]] || continue + kind=${resource%%/*} + name=${resource#*/} + # shellcheck disable=SC2310 # kubectl_ctx has one command; inspect its status below. + if live_uid=$(kubectl_ctx -n mainloop-control get "${kind}" "${name}" \ + -o jsonpath='{.metadata.uid}' 2>&1); then + if [[ ${live_uid} != "${recorded_uid}" ]]; then + printf 'provider cleanup skipped %s: UID changed from %s to %s\n' \ + "${resource}" "${recorded_uid}" "${live_uid:-missing}" >&2 + continue + fi + local delete_path + case ${kind} in + networkpolicy) + delete_path="/apis/networking.k8s.io/v1/namespaces/mainloop-control/networkpolicies/${name}" + ;; + deployment) + delete_path="/apis/apps/v1/namespaces/mainloop-control/deployments/${name}" + ;; + service) + delete_path="/api/v1/namespaces/mainloop-control/services/${name}" + ;; + serviceaccount) + delete_path="/api/v1/namespaces/mainloop-control/serviceaccounts/${name}" + ;; + secret) + delete_path="/api/v1/namespaces/mainloop-control/secrets/${name}" + ;; + *) + die "unsupported provider resource kind ${kind}" + return 1 + ;; + esac + if ! kubectl_ctx delete --raw="${delete_path}" -f - </dev/null; then + die "timed out waiting for provider Deployment ${name} deletion" + return 1 + fi + elif [[ ${live_uid} =~ [Nn]ot[Ff]ound|not\ found|does\ not\ exist ]]; then + continue + else + printf '%s\n' "${live_uid}" >&2 + die "could not verify provider resource ${resource} before cleanup" + return 1 + fi + done + PROVIDER_READY=0 +} + +finish() { + local status=$? + stop_router + if ((status != 0 && COMPLETE == 0 && LANE_STARTED == 1)); then + printf 'run failed; attempting cleanup of the owned Claude lane\n' >&2 + # shellcheck disable=SC2310 # cleanup_provider explicitly checks and returns each delete failure. + cleanup_provider || printf 'provider cleanup did not complete; inspect provider resources\n' >&2 + SKIP_PROVIDER_CLEANUP=1 "${SCRIPT_DIR}/cleanup-lane-a.sh" claude || + printf 'cleanup did not complete; inspect %s\n' "${STATE_FILE}" >&2 + fi + return "${status}" +} +trap finish EXIT INT TERM + +require_worker_image +if [[ ! -r ${CLAUDE_TOKEN_FILE} || ! -s ${CLAUDE_TOKEN_FILE} ]]; then + die "Claude credential file is missing or empty: ${CLAUDE_TOKEN_FILE}" +fi +if [[ -z ${PROVIDER_IMAGE} ]]; then + die 'set CLAUDE_PROVIDER_IMAGE to an existing digest-pinned provider image built from the pinned fork commit; this script does not build or push provider images' +fi +if [[ ! ${PROVIDER_IMAGE} =~ @sha256:[0-9a-fA-F]{64}$ ]]; then + die 'CLAUDE_PROVIDER_IMAGE must be an existing reference pinned by a full sha256 digest' +fi +prepare_state_file "${STATE_FILE}" "${ATESPACE}" +LANE_STARTED=1 +if timeout 300s kubectl --context "${CTX}" --kubeconfig "${KC}" \ + get workerpool "${POOL}" -n "${ATESPACE}" -o name >/dev/null 2>&1; then + die "WorkerPool ${ATESPACE}/${POOL} already exists; refusing to adopt it" +fi + +printf 'creating the Claude actor from the product template\n' +gate5_setup "${ATESPACE}" "${POOL}" "${ACTOR}" lane-a-claude-20260924 "${STATE_FILE}" api.anthropic.com +start_router + +uid=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" 'id -u' 30000) +if [[ ${uid} != 10001 ]]; then + die "actor runtime uid is ${uid}; refusing to deliver credentials or submit a Claude turn" +fi +printf 'actor runtime uid=%s\n' "${uid}" + +preflight_provider_resources_absent + +# The Secret receives only a path argument. Its generated manifest is piped directly +# to kubectl apply and is never printed or written to disk. +created_secret=$(kubectl_ctx -n mainloop-control create secret generic claude-oauth \ + --from-file="oauth-token=${CLAUDE_TOKEN_FILE}" \ + --dry-run=client -o json | + jq -c '.metadata.labels["proof.mainloop.dev/lane"] = "lane-a-live-proof"' | + kubectl_ctx create -f - -o json) +record_provider_uid secret/claude-oauth "${created_secret}" +unset created_secret + +created_service_account=$( + kubectl_ctx -n mainloop-control create -f - -o json <<'YAML' +apiVersion: v1 +kind: ServiceAccount +metadata: + name: round3-claude-provider + namespace: mainloop-control + labels: + proof.mainloop.dev/lane: lane-a-live-proof +YAML +) +record_provider_uid serviceaccount/round3-claude-provider "${created_service_account}" + +created_service=$( + kubectl_ctx -n mainloop-control create -f - -o json <<'YAML' +apiVersion: v1 +kind: Service +metadata: + name: credprovider + namespace: mainloop-control + labels: + proof.mainloop.dev/lane: lane-a-live-proof +spec: + selector: + app: round3-claude-provider + ports: + - name: grpc + port: 50051 + targetPort: 50051 +YAML +) +record_provider_uid service/credprovider "${created_service}" + +created_deployment=$( + kubectl_ctx -n mainloop-control create -f - -o json < 0) + and ((.version | tostring | test("^[1-9][0-9]*$")))) | {uid, version: (.version | tostring)}' \ + <<<"${current}") || die 'egress policy lacks metadata.uid and metadata.version preconditions' + jq -c --argjson metadata "${preconditions}" '. + {metadata: $metadata}' <<<"${rules}" | + ate_ctx update egress-policy "${ACTOR}" --atespace "${ATESPACE}" --filename - >/dev/null +} + +egress_policy=$(jq -cn --arg uri "${CLAUDE_SECRET_URI}" \ + '{rules:[{hostnames:{patterns:["api.anthropic.com"],effects:{injectStaticHeaders:[{header:"Authorization",prefix:"Bearer ",credentialUri:$uri}]}}}]}') +update_egress_policy "${egress_policy}" + +# This exact placeholder is allowlisted by the shim and carries no credential. +placeholder_request='{"method":"POST","path":"/credential","authenticated":true,"body":{"name":"claude-token","contents":"sk-ant-oat01-mainloop-egress-placeholder"}}' +response=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${placeholder_request}") +response_status=$(jq -r '.status // 0' <<<"${response}") +[[ ${response_status} == 201 ]] || die 'placeholder Claude credential was not installed through /credential' +ready=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" \ + '{"method":"GET","path":"/agent/ready?agent=claude","authenticated":true}') +ready_status=$(jq -r '.status // 0' <<<"${ready}") +[[ ${ready_status} == 200 ]] || die 'Claude credential readiness did not become healthy' +printf 'actor credential is the fixed placeholder; real OAuth remains in the control Secret\n' + +provider_probe_command="curl --silent --show-error --head --max-time 30 --output /dev/null --write-out '%{http_code}' --header 'Authorization: Bearer sk-ant-oat01-mainloop-egress-placeholder' https://api.anthropic.com/api/hello" +# The non-model probe is safe to repeat; retry only while the egress path to a +# freshly rolled-out provider is still settling (503, or no response). +for _ in $(seq 1 12); do + provider_probe=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${provider_probe_command}" 45000) + [[ ${provider_probe} == 503 || ${provider_probe} == 000 ]] || break + sleep 5 +done +[[ ${provider_probe} == 200 ]] || die "credential provider preflight returned HTTP ${provider_probe}" +provider_logs=$(kubectl_ctx -n mainloop-control logs deployment/round3-claude-provider --since=5m) +provider_fetches=$(grep -c 'credential_fetch=ok actor_identity_match=true' <<<"${provider_logs}" || true) +((provider_fetches > 0)) || die 'provider image did not accept the lane actor identity during the non-model preflight' +printf 'provider_preflight=http-200 matching_fetches=%s\n' "${provider_fetches}" + +nonce_timestamp=$(date +%s%N) +nonce_seed=$(printf '%s-%s-%s' "$$" "${RANDOM}" "${nonce_timestamp}") +nonce_hash=$(printf '%s' "${nonce_seed}" | sha256sum) +N1=${nonce_hash:0:24} +SESSION_ID=$(cat /proc/sys/kernel/random/uuid) +SESSION_KEY=lane-a-claude-liveproof +prompt=$(printf 'Remember this nonce exactly: %s. In /work/repo, create lane-a-claude-marker.txt containing exactly the nonce followed by one newline. Reply with only the nonce after both are done.' "${N1}") +turn_request=$(jq -cn --arg prompt "${prompt}" --arg session "${SESSION_ID}" --arg key "${SESSION_KEY}" \ + '{method:"POST",path:"/turn",authenticated:true,body:{agent:"claude",session_id:$session,session_key:$key,resume:false,timeout_ms:600000,prompt:$prompt}}') +first=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${turn_request}") +first_status=$(jq -r '.status // 0' <<<"${first}") +[[ ${first_status} == 202 ]] || die "first Claude turn was not accepted (HTTP ${first_status})" +TURN_ID=$(jq -r '.body.id // empty' <<<"${first}") +[[ -n ${TURN_ID} ]] || die 'first Claude turn response omitted its ID' + +concurrent_request=$(jq -cn --arg prompt 'This concurrent turn must be rejected; do not execute it.' \ + --arg session "${SESSION_ID}" --arg key "${SESSION_KEY}" \ + '{method:"POST",path:"/turn",authenticated:true,body:{agent:"claude",session_id:$session,session_key:$key,resume:true,timeout_ms:600000,prompt:$prompt}}') +concurrent=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${concurrent_request}") +concurrent_status=$(jq -r '.status // 0' <<<"${concurrent}") +if [[ ${concurrent_status} != 409 ]]; then + if [[ ${concurrent_status} == 202 ]]; then + stop_request=$(jq -cn --arg key "${SESSION_KEY}" '{method:"POST",path:"/turn/stop",authenticated:true,body:{agent:"claude",session_key:$key}}') + # shellcheck disable=SC2310 # This stop request is best effort; its failure is intentionally ignored. + shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${stop_request}" >/dev/null || true + fi + die "concurrent Claude /turn returned HTTP ${concurrent_status}, expected 409; accepted turns are not replayed" +fi +printf 'concurrent_second_turn=HTTP-409\n' + +turn_deadline=$((SECONDS + 610)) +turn_json= +while ((SECONDS < turn_deadline)); do + poll_request=$(jq -cn --arg id "${TURN_ID}" '{method:"GET",path:("/turn/"+$id),authenticated:true}') + turn_response=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${poll_request}") + turn_http_status=$(jq -r '.status // 0' <<<"${turn_response}") + [[ ${turn_http_status} == 200 ]] || die 'could not read first Claude turn status' + turn_state=$(jq -r '.body.status // empty' <<<"${turn_response}") + case "${turn_state}" in + completed | failed | timed_out | interrupted) + turn_json=$(jq -c '.body' <<<"${turn_response}") + break + ;; + *) + # Nonterminal turn states continue through the bounded polling loop. + ;; + esac + sleep 2 +done +[[ -n ${turn_json} ]] || die 'first Claude turn exceeded its 10-minute bound' +turn_status=$(jq -r '.status' <<<"${turn_json}") +turn_exit=$(jq -r '.exit_code // "unknown"' <<<"${turn_json}") +if [[ ${turn_status} != completed || ${turn_exit} != 0 ]]; then + # The actor holds only the placeholder credential, so its diagnostics are safe to print. + jq '{credential_rejected, final_message, stderr_tail: ((.stderr // "")[-2000:]), last_events: ((.events // [])[-5:])}' \ + <<<"${turn_json}" >&2 + die "first Claude turn ended status=${turn_status} exit_code=${turn_exit}; no prompt retry was sent" +fi +native_session=$(jq -r '.native_session_id // empty' <<<"${turn_json}") +final_message=$(jq -r '.final_message // empty' <<<"${turn_json}") +[[ ${native_session} == "${SESSION_ID}" ]] || die 'Claude returned a different native session ID' +[[ ${final_message} == *"${N1}"* ]] || die 'Claude first-turn response did not contain the requested nonce' +printf 'first_turn=completed exit=0 session_id=%s nonce_match=yes\n' "${native_session}" + +marker_command="test \"\$(cat /work/repo/lane-a-claude-marker.txt)\" = '${N1}' && sha256sum /work/repo/lane-a-claude-marker.txt | cut -d ' ' -f 1" +marker_hash=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${marker_command}" 30000) +printf 'marker_hash_before_suspend=%s\n' "${marker_hash}" + +started_ns=$(date +%s%N) +ate_ctx suspend actor "${ACTOR}" --atespace "${ATESPACE}" >/dev/null +wait_actor_state "${ATESPACE}" "${ACTOR}" ACTOR_STATE_SUSPENDED 180 >/dev/null +suspend_ms=$((($(date +%s%N) - started_ns) / 1000000)) +started_ns=$(date +%s%N) +resume_ok=0 +for _ in $(seq 1 60); do + if resume_err=$(timeout 120s "${ATE_CLI}" --context "${CTX}" --kubeconfig "${KC}" \ + resume actor "${ACTOR}" --atespace "${ATESPACE}" 2>&1 >/dev/null); then + resume_ok=1 + break + fi + grep -q 'no free workers available' <<<"${resume_err}" || die "resume failed: ${resume_err}" + sleep 2 +done +((resume_ok == 1)) || die 'no worker registered as free within 120 seconds' +wait_actor_state "${ATESPACE}" "${ACTOR}" ACTOR_STATE_RUNNING 240 >/dev/null +health='' +for _ in $(seq 1 60); do + health=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" \ + '{"method":"GET","path":"/healthz","authenticated":false}') + health_status=$(jq -r '.status // 0' <<<"${health}") + if [[ ${health_status} == 200 ]]; then + break + fi + sleep 1 +done +health_status=$(jq -r '.status // 0' <<<"${health}") +[[ ${health_status} == 200 ]] || die 'resumed Claude actor did not pass /healthz' +resume_ms=$((($(date +%s%N) - started_ns) / 1000000)) +printf 'suspend_ms=%s resume_ms=%s health=200 worker_pool=%s\n' "${suspend_ms}" "${resume_ms}" "${POOL}" + +recall_prompt='Without reading any files, what exact nonce did I ask you to remember? Return only that nonce.' +recall_request=$(jq -cn --arg prompt "${recall_prompt}" --arg session "${SESSION_ID}" --arg key "${SESSION_KEY}" \ + '{method:"POST",path:"/turn",authenticated:true,body:{agent:"claude",session_id:$session,session_key:$key,resume:true,timeout_ms:600000,prompt:$prompt}}') +recall_start=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${recall_request}") +recall_start_status=$(jq -r '.status // 0' <<<"${recall_start}") +[[ ${recall_start_status} == 202 ]] || die 'Claude recall turn was not accepted' +recall_id=$(jq -r '.body.id // empty' <<<"${recall_start}") +[[ -n ${recall_id} ]] || die 'Claude recall response omitted its ID' +recall_deadline=$((SECONDS + 610)) +recall_json= +while ((SECONDS < recall_deadline)); do + poll_request=$(jq -cn --arg id "${recall_id}" '{method:"GET",path:("/turn/"+$id),authenticated:true}') + recall_response=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${poll_request}") + recall_http_status=$(jq -r '.status // 0' <<<"${recall_response}") + [[ ${recall_http_status} == 200 ]] || die 'could not read Claude recall status' + recall_state=$(jq -r '.body.status // empty' <<<"${recall_response}") + case "${recall_state}" in + completed | failed | timed_out | interrupted) + recall_json=$(jq -c '.body' <<<"${recall_response}") + break + ;; + *) + # Nonterminal turn states continue through the bounded polling loop. + ;; + esac + sleep 2 +done +[[ -n ${recall_json} ]] || die 'Claude recall turn exceeded its 10-minute bound' +recall_status=$(jq -r '.status' <<<"${recall_json}") +recall_exit_code=$(jq -r '.exit_code // "unknown"' <<<"${recall_json}") +[[ ${recall_status} == completed && ${recall_exit_code} == 0 ]] || die 'Claude recall turn failed; no retry was sent' +recall_message=$(jq -r '.final_message // empty' <<<"${recall_json}") +[[ ${recall_message} == *"${N1}"* ]] || die 'same-session Claude recall did not contain the nonce' +recall_session=$(jq -r '.native_session_id // empty' <<<"${recall_json}") +[[ ${recall_session} == "${SESSION_ID}" ]] || die 'Claude recall changed native session ID' +marker_hash_after=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${marker_command}" 30000) +[[ ${marker_hash_after} == "${marker_hash}" ]] || die 'marker hash changed across suspend/resume' +printf 'recall_turn=completed exit=0 same_session=yes nonce_match=yes marker_hash_match=yes\n' + +ate_ctx suspend actor "${ACTOR}" --atespace "${ATESPACE}" >/dev/null +wait_actor_state "${ATESPACE}" "${ACTOR}" ACTOR_STATE_SUSPENDED 180 >/dev/null + +provider_logs=$(kubectl_ctx -n mainloop-control logs deployment/round3-claude-provider --since=30m) +if ! worker_logs=$(timeout 300s kubectl --context "${CTX}" --kubeconfig "${KC}" -n "${ATESPACE}" \ + logs -l "ate.dev/worker-pool=${POOL}" --all-containers=true --since=30m); then + die 'could not fetch WorkerPool logs; refusing to report a zero credential-leak count' + exit 1 +fi +provider_leaks=$(printf '%s' "${provider_logs}" | ( + cd "${BACKEND_DIR}" + LIVE_PROOF_TOKEN_FILE="${CLAUDE_TOKEN_FILE}" UV_CACHE_DIR=/tmp/uv-cache \ + uv run --no-sync python "${SCRIPT_DIR}/count_token_prefix.py" +)) +worker_leaks=$(printf '%s' "${worker_logs}" | ( + cd "${BACKEND_DIR}" + LIVE_PROOF_TOKEN_FILE="${CLAUDE_TOKEN_FILE}" UV_CACHE_DIR=/tmp/uv-cache \ + uv run --no-sync python "${SCRIPT_DIR}/count_token_prefix.py" +)) +provider_leak_count=${provider_leaks#matches=} +worker_leak_count=${worker_leaks#matches=} +[[ ${provider_leak_count} == 0 ]] || die 'Claude credential prefix appeared in provider logs' +[[ ${worker_leak_count} == 0 ]] || die 'Claude credential prefix appeared in WorkerPool logs' +printf 'credential_leak_count_provider_logs=%s\ncredential_leak_count_worker_logs=%s\n' \ + "${provider_leak_count}" "${worker_leak_count}" + +# Egress policies have no delete; an empty rule set revokes the credential injection. +update_egress_policy '{"rules":[]}' +cleanup_provider +remove_shim_token_from_state "${STATE_FILE}" +COMPLETE=1 +printf 'CLAUDE_TURN_PROOF=PASS actor=%s/%s turn=completed recall=same-session marker=matched concurrent=409 suspend_ms=%s resume_ms=%s\n' \ + "${ATESPACE}" "${ACTOR}" "${suspend_ms}" "${resume_ms}" +printf 'actor remains SUSPENDED; lane namespace, pool, template, and snapshot are retained for review\n' diff --git a/spikes/substrate-workspace-adapter/live/cleanup-lane-a.sh b/spikes/substrate-workspace-adapter/live/cleanup-lane-a.sh new file mode 100755 index 0000000..84f13e3 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live/cleanup-lane-a.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# Remove only the two uniquely named Lane A resources, after validating the +# gate5 state-file identity and any actor/template/pool UIDs still present. +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +lane=${1-} +case "${lane}" in +claude) + ATESPACE="lane-a-claude-20260924" + POOL="lane-a-claude-20260924" + ACTOR=claude-live-proof + VERSION="lane-a-claude-20260924" + STATE_FILE=${STATE_ROOT}/claude-gate5-state.json + ;; +durable) + ATESPACE="lane-a-durable-20260924" + POOL="lane-a-durable-20260924" + ACTOR=repo-live-proof + VERSION="lane-a-durable-20260924" + STATE_FILE=${STATE_ROOT}/durable-gate5-state.json + ;; +*) + die 'usage: cleanup-lane-a.sh claude|durable' + exit 2 + ;; +esac +TEMPLATE=live-agent-gate-${VERSION} + +if [[ ! -f ${STATE_FILE} ]]; then + die "refusing cleanup without the lane's gate5 state file: ${STATE_FILE}" + exit 1 +fi +state_identity=$(jq -r '[.context,.atespace,.worker_pool,.template_name,.actor_name] | @tsv' "${STATE_FILE}") +expected_identity=$(printf '%s\t%s\t%s\t%s\t%s' "${CTX}" "${ATESPACE}" "${POOL}" "${TEMPLATE}" "${ACTOR}") +[[ ${state_identity} == "${expected_identity}" ]] || { + die 'gate5 state identity does not match the requested cleanup lane' + exit 1 +} + +actor_err=$(mktemp "${STATE_ROOT}/cleanup-actor.XXXXXX") +template_err=$(mktemp "${STATE_ROOT}/cleanup-template.XXXXXX") +pool_err=$(mktemp "${STATE_ROOT}/cleanup-pool.XXXXXX") +provider_err=$(mktemp "${STATE_ROOT}/cleanup-provider.XXXXXX") +namespace_err=$(mktemp "${STATE_ROOT}/cleanup-namespace.XXXXXX") +declare -A provider_uids=() +cleanup_tmp() { + rm -f "${actor_err}" "${template_err}" "${pool_err}" "${provider_err}" "${namespace_err}" +} +trap cleanup_tmp EXIT + +recorded_namespace_uid=$(jq -r '.namespace_uid // empty' "${STATE_FILE}") +if [[ -z ${recorded_namespace_uid} ]]; then + die "gate5 state has no Namespace UID; skipping namespace deletion for ${ATESPACE}" + exit 1 +fi +namespace_exists=0 +# shellcheck disable=SC2310 # kubectl_ctx has one command; its status distinguishes NotFound from errors. +if namespace_json=$(kubectl_ctx get namespace "${ATESPACE}" -o json 2>"${namespace_err}"); then + live_namespace_uid=$(jq -r '.metadata.uid // empty' <<<"${namespace_json}") + if [[ -z ${live_namespace_uid} || ${live_namespace_uid} != "${recorded_namespace_uid}" ]]; then + die "Namespace UID differs from gate5 state; skipping namespace deletion for ${ATESPACE}" + exit 1 + fi + namespace_exists=1 +elif grep -Eiq 'not.?found|code = NotFound|NOT_FOUND|does not exist' "${namespace_err}"; then + : +else + cat "${namespace_err}" >&2 + die "could not verify Namespace UID; skipping namespace deletion for ${ATESPACE}" + exit 1 +fi + +if [[ ${lane} == claude && ${SKIP_PROVIDER_CLEANUP:-0} != 1 ]]; then + for resource in secret/claude-oauth service/credprovider deployment/round3-claude-provider serviceaccount/round3-claude-provider networkpolicy/round3-claude-provider; do + kind=${resource%%/*} + name=${resource#*/} + if object_json=$(timeout 300s kubectl --context "${CTX}" --kubeconfig "${KC}" \ + -n mainloop-control get "${kind}" "${name}" -o json 2>"${provider_err}"); then + label=$(jq -r '.metadata.labels["proof.mainloop.dev/lane"] // empty' <<<"${object_json}") + [[ ${label} == lane-a-live-proof ]] || { + die "${resource} exists without Lane A ownership label; leaving all provider resources intact" + exit 1 + } + uid=$(jq -r '.metadata.uid // empty' <<<"${object_json}") + [[ -n ${uid} ]] || { + die "${resource} has no readable UID; leaving all provider resources intact" + exit 1 + } + provider_uids["${resource}"]=${uid} + elif ! grep -Eiq 'not.?found|code = NotFound|NOT_FOUND|does not exist' "${provider_err}"; then + cat "${provider_err}" >&2 + die "could not reconcile ${resource}; no lane resources were deleted" + exit 1 + fi + done +fi + +actor_json= +actor_exists=0 +actor_state= +if actor_json=$(timeout 120s "${ATE_CLI}" --context "${CTX}" --kubeconfig "${KC}" \ + get actor "${ACTOR}" --atespace "${ATESPACE}" -o json 2>"${actor_err}"); then + recorded_uid=$(actor_uid_from_state "${STATE_FILE}") + actual_uid=$(jq -r '.metadata.uid // empty' <<<"${actor_json}") + [[ -n ${recorded_uid} && ${actual_uid} == "${recorded_uid}" ]] || { + die 'actor UID differs from gate5 state; no lane resources were changed' + exit 1 + } + actor_state=$(jq -r '.status.state // empty' <<<"${actor_json}") + actor_exists=1 +elif ! grep -Eiq 'not.?found|code = NotFound|NOT_FOUND|does not exist' "${actor_err}"; then + cat "${actor_err}" >&2 + die 'could not reconcile actor ownership; no lane resources were changed' + exit 1 +fi + +template_json= +template_exists=0 +if template_json=$(timeout 120s "${ATE_CLI}" --context "${CTX}" --kubeconfig "${KC}" \ + get actor-template "${TEMPLATE}" --atespace "${ATESPACE}" -o json 2>"${template_err}"); then + recorded_template_uid=$(jq -r '.template_uid // empty' "${STATE_FILE}") + actual_template_uid=$(jq -r '.metadata.uid // empty' <<<"${template_json}") + if [[ -z ${recorded_template_uid} || ${actual_template_uid} != "${recorded_template_uid}" ]]; then + die 'ActorTemplate UID differs from gate5 state; no lane resources were changed' + exit 1 + fi + template_exists=1 +elif ! grep -Eiq 'not.?found|code = NotFound|NOT_FOUND|does not exist' "${template_err}"; then + cat "${template_err}" >&2 + die 'could not reconcile ActorTemplate ownership; no lane resources were changed' + exit 1 +fi + +pool_exists=0 +if pool_json=$(timeout 300s kubectl --context "${CTX}" --kubeconfig "${KC}" \ + -n "${ATESPACE}" get workerpool "${POOL}" -o json 2>"${pool_err}"); then + pool_name=$(jq -r '.metadata.name // empty' <<<"${pool_json}") + pool_label=$(jq -r '.metadata.labels.workload // empty' <<<"${pool_json}") + [[ ${pool_name} == "${POOL}" && ${pool_label} == "${POOL}" ]] || { + die 'WorkerPool labels differ from the lane identity; no lane resources were changed' + exit 1 + } + pool_exists=1 +elif ! grep -Eiq 'not.?found|code = NotFound|NOT_FOUND|does not exist' "${pool_err}"; then + cat "${pool_err}" >&2 + die 'could not reconcile WorkerPool ownership; no lane resources were changed' + exit 1 +fi + +atespace_exists=0 +if timeout 120s "${ATE_CLI}" --context "${CTX}" --kubeconfig "${KC}" \ + get atespace "${ATESPACE}" -o json 2>"${actor_err}" >/dev/null; then + atespace_exists=1 +elif ! grep -Eiq 'not.?found|code = NotFound|NOT_FOUND|does not exist' "${actor_err}"; then + cat "${actor_err}" >&2 + die 'could not reconcile atespace; no lane resources were changed' + exit 1 +fi + +if ((actor_exists == 1)); then + if [[ ${actor_state} != ACTOR_STATE_SUSPENDED ]]; then + ate_ctx suspend actor "${ACTOR}" --atespace "${ATESPACE}" >/dev/null + wait_actor_state "${ATESPACE}" "${ACTOR}" ACTOR_STATE_SUSPENDED 180 >/dev/null + fi + ate_ctx delete actor "${ACTOR}" --atespace "${ATESPACE}" --any-state >/dev/null +fi + +if [[ ${lane} == claude && ${SKIP_PROVIDER_CLEANUP:-0} != 1 ]]; then + # Reconcile every provider UID again before deleting anything. DeleteOptions + # below still fences the request against a replacement after this check. + for resource in secret/claude-oauth service/credprovider deployment/round3-claude-provider serviceaccount/round3-claude-provider networkpolicy/round3-claude-provider; do + kind=${resource%%/*} + name=${resource#*/} + # shellcheck disable=SC2310 # kubectl_ctx has one command; its status is checked below. + if object_json=$(kubectl_ctx -n mainloop-control get "${kind}" "${name}" -o json 2>"${provider_err}"); then + live_uid=$(jq -r '.metadata.uid // empty' <<<"${object_json}") + live_label=$(jq -r '.metadata.labels["proof.mainloop.dev/lane"] // empty' <<<"${object_json}") + recorded_uid=${provider_uids["${resource}"]-} + if [[ -z ${live_uid} || -z ${recorded_uid} || ${live_uid} != "${recorded_uid}" || ${live_label} != lane-a-live-proof ]]; then + die "provider cleanup skipped ${resource}: UID or ownership changed since verification" + exit 1 + fi + elif grep -Eiq 'not.?found|code = NotFound|NOT_FOUND|does not exist' "${provider_err}"; then + if [[ -n ${provider_uids["${resource}"]-} ]]; then + die "provider cleanup skipped ${resource}: its UID could not be read before deletion" + exit 1 + fi + continue + else + cat "${provider_err}" >&2 + die "provider cleanup skipped ${resource}: its UID could not be read before deletion" + exit 1 + fi + done + + for resource in networkpolicy/round3-claude-provider deployment/round3-claude-provider service/credprovider serviceaccount/round3-claude-provider secret/claude-oauth; do + recorded_uid=${provider_uids["${resource}"]-} + [[ -n ${recorded_uid} ]] || continue + kind=${resource%%/*} + name=${resource#*/} + case ${kind} in + networkpolicy) + delete_path="/apis/networking.k8s.io/v1/namespaces/mainloop-control/networkpolicies/${name}" + ;; + deployment) + delete_path="/apis/apps/v1/namespaces/mainloop-control/deployments/${name}" + ;; + service) + delete_path="/api/v1/namespaces/mainloop-control/services/${name}" + ;; + serviceaccount) + delete_path="/api/v1/namespaces/mainloop-control/serviceaccounts/${name}" + ;; + secret) + delete_path="/api/v1/namespaces/mainloop-control/secrets/${name}" + ;; + *) + die "unsupported provider resource kind ${kind}" + exit 1 + ;; + esac + # shellcheck disable=SC2310 # kubectl_ctx has one command; delete failure is handled below. + if ! kubectl_ctx delete --raw="${delete_path}" -f - </dev/null; then + die "timed out waiting for provider Deployment ${name} deletion" + exit 1 + fi + done +fi + +if ((template_exists == 1)); then + ate_ctx delete actor-template "${TEMPLATE}" --atespace "${ATESPACE}" >/dev/null +fi +if ((pool_exists == 1)); then + kubectl_ctx -n "${ATESPACE}" delete workerpool "${POOL}" --wait=true --timeout=120s >/dev/null +fi +if ((atespace_exists == 1)); then + ate_ctx delete atespace "${ATESPACE}" >/dev/null +fi +if ((namespace_exists == 1)); then + # shellcheck disable=SC2310 # kubectl_ctx has one command; recheck failure skips namespace deletion. + if namespace_json=$(kubectl_ctx get namespace "${ATESPACE}" -o json 2>"${namespace_err}"); then + live_namespace_uid=$(jq -r '.metadata.uid // empty' <<<"${namespace_json}") + else + cat "${namespace_err}" >&2 + die "could not recheck Namespace UID; skipping namespace deletion for ${ATESPACE}" + exit 1 + fi + if [[ -z ${live_namespace_uid} || ${live_namespace_uid} != "${recorded_namespace_uid}" ]]; then + die "Namespace UID changed during cleanup; skipping namespace deletion for ${ATESPACE}" + exit 1 + fi + kubectl_ctx delete --raw="/api/v1/namespaces/${ATESPACE}" -f - </dev/null +fi +rm -f "${STATE_FILE}" "${BEFORE_MANIFEST}" "${AFTER_MANIFEST}" +printf 'CLEANUP=PASS lane=%s namespace=%s provider_secret_and_service=removed_if_owned\n' "${lane}" "${ATESPACE}" diff --git a/spikes/substrate-workspace-adapter/live/common.sh b/spikes/substrate-workspace-adapter/live/common.sh new file mode 100755 index 0000000..59db9ea --- /dev/null +++ b/spikes/substrate-workspace-adapter/live/common.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash + +CTX='kind-substrate-preview' +KC=/tmp/substrate-preview-kubeconfig +ACTOR_IMAGE=${ACTOR_IMAGE:-localhost:5001/live-agent-gate@sha256:eb819c5ae18829a18972fd7be9ec44e0f3dbef08e554f8e0dfc1d6240f962f5d} +ROUTER_PORT=${ROUTER_PORT:-18091} +STATE_ROOT=${LIVE_PROOF_STATE_DIR:-/tmp/mainloop-substrate-live-proof} +# Shared with the durable proof and its cleanup script. +# shellcheck disable=SC2034 # cleanup-lane-a.sh uses this shared manifest path. +BEFORE_MANIFEST=${STATE_ROOT}/durable-before.tsv +# shellcheck disable=SC2034 # cleanup-lane-a.sh uses this shared manifest path. +AFTER_MANIFEST=${STATE_ROOT}/durable-after.tsv +# Fork patched-next 0f9635ae worker build (rebased on upstream, keeps the durable-owner fix). +WORKER_IMAGE_DIGEST=sha256:9cff9f35f68bcad9f37ce3574f2e0a4638368475e4b49b4de606e38d9bbcba92 +WORKER_IMAGE_REFERENCE=localhost:5001/ateom-gvisor@${WORKER_IMAGE_DIGEST} +LIVE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${LIVE_DIR}/../../.." && pwd) +BACKEND_DIR=${REPO_ROOT}/backend +MANIFEST=${REPO_ROOT}/spikes/substrate-workspace-adapter/k8s/actor-template.yaml.tmpl +HTTP_HELPER=${LIVE_DIR}/shim_request.py +ATE_CLI=${SUBSTRATE_SRC:-${HOME}/dev/substrate}/bin/kubectl-ate +PORT_FORWARD_PID= +PORT_FORWARD_LOG= + +die() { + printf 'ERROR: %s\n' "$*" >&2 + return 1 +} + +kubectl_ctx() { + timeout 300s kubectl --context "${CTX}" --kubeconfig "${KC}" "$@" +} + +preflight_provider_resources_absent() { + local resource kind name get_result + for resource in secret/claude-oauth service/credprovider deployment/round3-claude-provider \ + serviceaccount/round3-claude-provider networkpolicy/round3-claude-provider; do + kind=${resource%%/*} + name=${resource#*/} + if get_result=$(timeout 300s kubectl --context "${CTX}" --kubeconfig "${KC}" \ + -n mainloop-control get "${kind}" "${name}" -o name 2>&1); then + die "${resource} already exists in mainloop-control; refusing to replace shared provider state" + return 1 + elif [[ ${get_result} =~ [Nn]ot[Ff]ound|not\ found|does\ not\ exist ]]; then + : + else + printf '%s\n' "${get_result}" >&2 + die "could not establish whether ${resource} exists; refusing to write provider state" + return 1 + fi + done +} + +ate_ctx() { + timeout 120s "${ATE_CLI}" --context "${CTX}" --kubeconfig "${KC}" "$@" +} + +require_worker_image() { + if [[ ! ${WORKER_IMAGE-} =~ ^[^[:space:]]+@${WORKER_IMAGE_DIGEST}$ ]]; then + die "set WORKER_IMAGE to the pinned fork WorkerPool build with the durable-owner fix, such as ${WORKER_IMAGE_REFERENCE}" + fi +} + +prepare_state_file() { + local state_file=$1 atespace=$2 namespace_result atespace_result + mkdir -p "${STATE_ROOT}" + chmod 700 "${STATE_ROOT}" + if namespace_result=$(kubectl_ctx get namespace "${atespace}" -o name 2>&1); then + die "namespace ${atespace} already exists; refusing to adopt or overwrite lane resources" + elif [[ ! ${namespace_result} =~ [Nn]ot[Ff]ound|not\ found|does\ not\ exist ]]; then + printf '%s\n' "${namespace_result}" >&2 + die "could not establish whether namespace ${atespace} exists; refusing to continue" + fi + if atespace_result=$(ate_ctx get atespace "${atespace}" -o json 2>&1); then + die "atespace ${atespace} already exists; refusing to adopt or overwrite lane resources" + elif [[ ! ${atespace_result} =~ [Nn]ot[Ff]ound|not\ found|does\ not\ exist ]]; then + printf '%s\n' "${atespace_result}" >&2 + die "could not establish whether atespace ${atespace} exists; refusing to continue" + fi + rm -f "${state_file}" + umask 077 +} + +gate5_setup() { + local atespace=$1 pool=$2 actor=$3 version=$4 state_file=$5 + local egress_host=$6 + require_worker_image + ( + cd "${BACKEND_DIR}" || exit + UV_CACHE_DIR=/tmp/uv-cache uv run --no-sync python scripts/gate5_setup.py \ + --context "${CTX}" \ + --kubeconfig "${KC}" \ + --ate-cli "${SUBSTRATE_SRC:-${HOME}/dev/substrate}/bin/kubectl-ate" \ + --worker-image "${WORKER_IMAGE}" \ + --atespace "${atespace}" \ + --worker-pool "${pool}" \ + --template-version "${version}" \ + --actor-name "${actor}" \ + --image "${ACTOR_IMAGE}" \ + --manifest "${MANIFEST}" \ + --state-file "${state_file}" \ + --egress-hostname "${egress_host}" + ) +} + +port_forward_ready() { + timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/${ROUTER_PORT}" >/dev/null 2>&1 +} + +start_router() { + if port_forward_ready; then + die "127.0.0.1:${ROUTER_PORT} is already in use" + fi + PORT_FORWARD_LOG=$(mktemp "${STATE_ROOT}/router-port-forward.XXXXXX.log") + # Background kubectl itself, not the kubectl_ctx function: $! must be the + # process stop_router kills, and the tunnel must outlive kubectl_ctx's timeout. + kubectl --context "${CTX}" --kubeconfig "${KC}" -n ate-system port-forward \ + --address 127.0.0.1 service/atenet-router "${ROUTER_PORT}:8081" >"${PORT_FORWARD_LOG}" 2>&1 & + PORT_FORWARD_PID=$! + for _ in $(seq 1 80); do + if port_forward_ready; then + printf 'router tunnel ready on 127.0.0.1:%s\n' "${ROUTER_PORT}" + return 0 + fi + if ! kill -0 "${PORT_FORWARD_PID}" 2>/dev/null; then + cat "${PORT_FORWARD_LOG}" >&2 + die 'router port-forward exited before becoming ready' + fi + sleep 0.25 + done + cat "${PORT_FORWARD_LOG}" >&2 + die 'router port-forward did not become ready within 20 seconds' +} + +stop_router() { + if [[ -n ${PORT_FORWARD_PID-} ]] && kill -0 "${PORT_FORWARD_PID}" 2>/dev/null; then + kill "${PORT_FORWARD_PID}" 2>/dev/null || true + wait "${PORT_FORWARD_PID}" 2>/dev/null || true + fi + PORT_FORWARD_PID= + if [[ -n ${PORT_FORWARD_LOG-} ]]; then + rm -f "${PORT_FORWARD_LOG}" + PORT_FORWARD_LOG= + fi +} + +shim_request() { + local atespace=$1 actor=$2 state_file=$3 request=$4 + printf '%s\n' "${request}" | ( + cd "${BACKEND_DIR}" || exit + LIVE_PROOF_ATESPACE=${atespace} \ + LIVE_PROOF_ACTOR=${actor} \ + LIVE_PROOF_ROUTER_PORT=${ROUTER_PORT} \ + LIVE_PROOF_STATE_FILE=${state_file} \ + UV_CACHE_DIR=/tmp/uv-cache uv run --no-sync python "${HTTP_HELPER}" + ) +} + +run_actor_command() { + local atespace=$1 actor=$2 state_file=$3 command=$4 timeout_ms=${5:-180000} + local request response status run_id result deadline + if ! request=$(jq -cn --arg command "${command}" --argjson timeout "${timeout_ms}" \ + '{method:"POST",path:"/run",authenticated:true,body:{command:$command,timeout_ms:$timeout}}'); then + die 'could not encode actor /run request' + return 1 + fi + if ! response=$(shim_request "${atespace}" "${actor}" "${state_file}" "${request}"); then + die 'actor /run request failed before returning a response' + return 1 + fi + status=$(jq -r '.status // 0' <<<"${response}") + if [[ ${status} != 202 ]]; then + die "actor /run was not accepted (HTTP ${status})" + return 1 + fi + run_id=$(jq -r '.body.id // empty' <<<"${response}") + if [[ -z ${run_id} ]]; then + die 'actor /run response omitted its id' + return 1 + fi + deadline=$((SECONDS + (timeout_ms / 1000) + 30)) + while ((SECONDS < deadline)); do + if ! request=$(jq -cn --arg id "${run_id}" '{method:"GET",path:("/run/"+$id),authenticated:true}'); then + die 'could not encode actor /run status request' + return 1 + fi + if ! response=$(shim_request "${atespace}" "${actor}" "${state_file}" "${request}"); then + die 'actor /run status request failed before returning a response' + return 1 + fi + status=$(jq -r '.status // 0' <<<"${response}") + if [[ ${status} != 200 ]]; then + die "actor /run status fetch failed (HTTP ${status})" + return 1 + fi + result=$(jq -r '.body.status // empty' <<<"${response}") + if [[ -z ${result} ]]; then + die 'actor /run status response omitted its state' + return 1 + fi + case "${result}" in + completed) + exit_code=$(jq -r '.body.exit_code' <<<"${response}") + if [[ ${exit_code} != 0 ]]; then + die 'actor /run completed with a nonzero exit code' + return 1 + fi + jq -r '.body.output // ""' <<<"${response}" + return 0 + ;; + failed | timed_out | interrupted) + exit_code=$(jq -r '.body.exit_code // "unknown"' <<<"${response}") + die "actor /run ended with status=${result} exit_code=${exit_code}" + return 1 + ;; + *) + # Nonterminal run states continue through the bounded polling loop. + ;; + esac + sleep 1 + done + die "actor /run ${run_id} exceeded its bounded wait" + return 1 +} + +get_actor_json() { + local atespace=$1 actor=$2 + ate_ctx get actor "${actor}" --atespace "${atespace}" -o json +} + +wait_actor_state() { + local atespace=$1 actor=$2 expected=$3 timeout_s=${4:-120} actor_json state + local deadline=$((SECONDS + timeout_s)) + while ((SECONDS < deadline)); do + if actor_json=$(get_actor_json "${atespace}" "${actor}" 2>/dev/null); then + state=$(jq -r '.status.state // empty' <<<"${actor_json}") + if [[ ${state} == "${expected}" ]]; then + printf '%s\n' "${actor_json}" + return 0 + fi + fi + sleep 1 + done + die "actor ${atespace}/${actor} did not reach ${expected} within ${timeout_s}s" +} + +actor_uid_from_state() { + jq -r '.actor_uid // empty' "$1" +} + +remove_shim_token_from_state() { + local state_file=$1 tmp_file="$1.redacted" + jq 'del(.shim_token)' "${state_file}" >"${tmp_file}" + chmod 600 "${tmp_file}" + mv -f "${tmp_file}" "${state_file}" +} diff --git a/spikes/substrate-workspace-adapter/live/count_token_prefix.py b/spikes/substrate-workspace-adapter/live/count_token_prefix.py new file mode 100644 index 0000000..0d1cebd --- /dev/null +++ b/spikes/substrate-workspace-adapter/live/count_token_prefix.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Count credential-prefix occurrences in stdin, emitting a count only.""" + +import os +import sys + + +def main() -> int: + token_path = os.environ.get("LIVE_PROOF_TOKEN_FILE") + if not token_path: + print("token file path is required", file=sys.stderr) + return 2 + try: + with open(token_path, "rb") as token_handle: + token = token_handle.read().strip() + except OSError: + print("token file is unavailable", file=sys.stderr) + return 2 + if not token: + print("token file is empty", file=sys.stderr) + return 2 + + prefix = token[:64] + carry = b"" + matches = 0 + for chunk in iter(lambda: sys.stdin.buffer.read(65536), b""): + data = carry + chunk + matches += data.count(prefix) + carry = data[-(len(prefix) - 1) :] if len(prefix) > 1 else b"" + print(f"matches={matches}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/spikes/substrate-workspace-adapter/live/durable_restore_proof.sh b/spikes/substrate-workspace-adapter/live/durable_restore_proof.sh new file mode 100755 index 0000000..17cef8f --- /dev/null +++ b/spikes/substrate-workspace-adapter/live/durable_restore_proof.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Snapshot a real repository and restore it after every worker in this lane's +# pool has been deleted. Run only through the supervisor's explicit cluster lane. +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "${SCRIPT_DIR}/common.sh" + +ATESPACE=lane-a-durable-20260924 +POOL=lane-a-durable-20260924 +ACTOR=repo-live-proof +STATE_FILE=${STATE_ROOT}/durable-gate5-state.json +BEFORE_MANIFEST=${STATE_ROOT}/durable-before.tsv +AFTER_MANIFEST=${STATE_ROOT}/durable-after.tsv +COMPLETE=0 +CLEANUP_ARMED=0 + +finish() { + local status=$? + stop_router + if ((status != 0 && COMPLETE == 0 && CLEANUP_ARMED == 1)); then + printf 'run failed; attempting cleanup of the owned durable lane\n' >&2 + "${SCRIPT_DIR}/cleanup-lane-a.sh" durable || printf 'cleanup did not complete; inspect %s\n' "${STATE_FILE}" >&2 + fi + return "${status}" +} + +require_worker_image +prepare_state_file "${STATE_FILE}" "${ATESPACE}" +rm -f "${BEFORE_MANIFEST}" "${AFTER_MANIFEST}" +if timeout 300s kubectl --context "${CTX}" --kubeconfig "${KC}" \ + get workerpool "${POOL}" -n "${ATESPACE}" -o name >/dev/null 2>&1; then + die "WorkerPool ${ATESPACE}/${POOL} already exists; refusing to adopt it" +fi +trap finish EXIT INT TERM + +printf 'creating the durable restore actor from the product template\n' +# shellcheck disable=SC2310 # handle gate5_setup's returned status and persisted ownership below. +if gate5_setup "${ATESPACE}" "${POOL}" "${ACTOR}" lane-a-durable-20260924 "${STATE_FILE}" github.com; then + CLEANUP_ARMED=1 +else + setup_status=$? + if jq -e '.namespace_uid | strings | length > 0' "${STATE_FILE}" >/dev/null 2>&1; then + CLEANUP_ARMED=1 + fi + exit "${setup_status}" +fi +start_router + +uid=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" 'id -u' 30000) +if [[ ${uid} != 10001 ]]; then + die "actor runtime uid is ${uid}; durable owner proof requires image uid 10001" +fi +printf 'actor runtime uid=%s\n' "${uid}" + +setup_command=$( + cat <<'EOF' +set -eu +git clone --depth=1 --quiet https://github.com/octocat/Hello-World.git /work/repo/source +printf 'durable-owner-check\n' > /work/repo/lane-a-owned-by-10001.txt +chmod 0644 /work/repo/lane-a-owned-by-10001.txt +printf 'durable-private-check\n' > /work/repo/lane-a-mode-0600.txt +chmod 0600 /work/repo/lane-a-mode-0600.txt +test "$(stat -c %u /work/repo/lane-a-owned-by-10001.txt)" = 10001 +test "$(stat -c %g /work/repo/lane-a-owned-by-10001.txt)" = 10001 +test "$(stat -c %a /work/repo/lane-a-mode-0600.txt)" = 600 +test -s /work/repo/source/README +git -C /work/repo/source rev-parse HEAD +EOF +) +repo_commit=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${setup_command}" 300000) +printf 'repository commit=%s\n' "${repo_commit}" + +manifest_command=$( + cat <<'EOF' +set -eu +# The shim writes its own run records, including this command's, under runs/. +find /work/repo -path /work/repo/.mainloop/exec-shim/runs -prune -o -type f -print | LC_ALL=C sort | while IFS= read -r path; do + relative=${path#/work/repo/} + owner=$(stat -c %u "$path") + group=$(stat -c %g "$path") + mode=$(stat -c %a "$path") + digest=$(sha256sum "$path" | cut -d ' ' -f 1) + printf '%s\t%s\t%s\t%s\t%s\n' "$relative" "$owner" "$group" "$mode" "$digest" +done +EOF +) +before=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${manifest_command}" 120000) +printf '%s\n' "${before}" >"${BEFORE_MANIFEST}" +chmod 600 "${BEFORE_MANIFEST}" +before_count=$(wc -l <"${BEFORE_MANIFEST}" | tr -d ' ') +printf 'manifest_before files=%s owner_file_uid=10001 private_file_mode=600\n' "${before_count}" + +started_ns=$(date +%s%N) +ate_ctx suspend actor "${ACTOR}" --atespace "${ATESPACE}" >/dev/null +suspended_json=$(wait_actor_state "${ATESPACE}" "${ACTOR}" ACTOR_STATE_SUSPENDED 180) +suspend_ms=$((($(date +%s%N) - started_ns) / 1000000)) +snapshot_uri=$(jq -r '.status.externalSnapshot.snapshotUri // .status.externalSnapshot.snapshot_uri // empty' <<<"${suspended_json}") +snapshot_scope=$(jq -r '.status.externalSnapshot.contentScope // .status.externalSnapshot.content_scope // empty' <<<"${suspended_json}") +[[ -n ${snapshot_uri} ]] || die 'suspend completed without an external snapshot URI' +[[ ${snapshot_scope} == SNAPSHOT_CONTENT_SCOPE_FULL || ${snapshot_scope} == 1 ]] || die "snapshot scope was not FULL: ${snapshot_scope}" +printf 'suspend_ms=%s snapshot_scope=FULL\n' "${suspend_ms}" + +old_pods=$(kubectl_ctx -n "${ATESPACE}" get pods -l "ate.dev/worker-pool=${POOL}" -o json) +old_count=$(jq '.items | length' <<<"${old_pods}") +[[ ${old_count} == 2 ]] || die "expected 2 workers in the owned pool, found ${old_count}" +old_uids=$(jq -r '.items[].metadata.uid' <<<"${old_pods}" | sort) +printf 'deleting owned worker pods after completed suspend:\n' +jq -r '.items[] | " \(.metadata.name) uid=\(.metadata.uid)"' <<<"${old_pods}" +kubectl_ctx -n "${ATESPACE}" delete pod -l "ate.dev/worker-pool=${POOL}" --wait=true --timeout=120s >/dev/null + +replacement_json= +for _ in $(seq 1 180); do + candidate=$(kubectl_ctx -n "${ATESPACE}" get pods -l "ate.dev/worker-pool=${POOL}" -o json) + count=$(jq '[.items[] | select(any(.status.conditions[]?; .type == "Ready" and .status == "True"))] | length' <<<"${candidate}") + if [[ ${count} == 2 ]]; then + replacement_json=${candidate} + break + fi + sleep 1 +done +[[ -n ${replacement_json} ]] || die 'replacement WorkerPool pods did not become Ready within 180 seconds' +new_uids=$(jq -r '.items[].metadata.uid' <<<"${replacement_json}" | sort) +while IFS= read -r new_uid; do + [[ -n ${new_uid} ]] || continue + if grep -Fxq "${new_uid}" <<<"${old_uids}"; then + die 'a replacement worker reused an old pod UID; worker-loss proof is inconclusive' + fi +done <<<"${new_uids}" +printf 'replacement workers ready with new pod UIDs\n' + +started_ns=$(date +%s%N) +# Ready pods register as free workers a little later; retry only that refusal. +resume_ok=0 +for _ in $(seq 1 60); do + if resume_err=$(timeout 120s "${ATE_CLI}" --context "${CTX}" --kubeconfig "${KC}" \ + resume actor "${ACTOR}" --atespace "${ATESPACE}" 2>&1 >/dev/null); then + resume_ok=1 + break + fi + grep -q 'no free workers available' <<<"${resume_err}" || die "resume failed: ${resume_err}" + sleep 2 +done +((resume_ok == 1)) || die 'no replacement worker registered as free within 120 seconds' +resumed_json=$(wait_actor_state "${ATESPACE}" "${ACTOR}" ACTOR_STATE_RUNNING 240) +for _ in $(seq 1 60); do + health=$(shim_request "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" \ + '{"method":"GET","path":"/healthz","authenticated":false}') + health_status=$(jq -r '.status // 0' <<<"${health}") + if [[ ${health_status} == 200 ]]; then + break + fi + sleep 1 +done +health_status=$(jq -r '.status // 0' <<<"${health}") +[[ ${health_status} == 200 ]] || die 'restored actor did not pass /healthz' +resume_ms=$((($(date +%s%N) - started_ns) / 1000000)) +assigned_pod=$(jq -r '.status.workerAssignment.workerPod // empty' <<<"${resumed_json}") +[[ -n ${assigned_pod} ]] || die 'restored actor has no worker-pod assignment' +replacement_names=$(jq -r '.items[].metadata.name' <<<"${replacement_json}") +grep -Fxq "${assigned_pod}" <<<"${replacement_names}" || die 'actor resumed on a pod outside the replacement worker set' +printf 'resume_ms=%s worker_pod=%s health=200\n' "${resume_ms}" "${assigned_pod}" + +after=$(run_actor_command "${ATESPACE}" "${ACTOR}" "${STATE_FILE}" "${manifest_command}" 120000) +printf '%s\n' "${after}" >"${AFTER_MANIFEST}" +chmod 600 "${AFTER_MANIFEST}" +after_count=$(wc -l <"${AFTER_MANIFEST}" | tr -d ' ') +diff -u "${BEFORE_MANIFEST}" "${AFTER_MANIFEST}" +printf 'manifest_after files=%s exact_match=yes\n' "${after_count}" +printf 'manifest rows (relative path, uid, gid, mode, sha256):\n' +cat "${AFTER_MANIFEST}" + +ate_ctx suspend actor "${ACTOR}" --atespace "${ATESPACE}" >/dev/null +wait_actor_state "${ATESPACE}" "${ACTOR}" ACTOR_STATE_SUSPENDED 180 >/dev/null +remove_shim_token_from_state "${STATE_FILE}" +COMPLETE=1 +printf 'DURABLE_RESTORE_PROOF=PASS actor=%s/%s suspend_ms=%s resume_ms=%s worker_loss=yes files=%s\n' \ + "${ATESPACE}" "${ACTOR}" "${suspend_ms}" "${resume_ms}" "${after_count}" +printf 'actor remains SUSPENDED; lane resources are retained for review\n' diff --git a/spikes/substrate-workspace-adapter/live/shim_request.py b/spikes/substrate-workspace-adapter/live/shim_request.py new file mode 100644 index 0000000..4e13a23 --- /dev/null +++ b/spikes/substrate-workspace-adapter/live/shim_request.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Send one bounded HTTP request through the explicitly forwarded Substrate router. + +The request document is read from stdin. The shim bearer token is loaded from the +gate5 state file by path and is never written to stdout or stderr. +""" + +import http.client +import json +import os +import sys + + +def main() -> int: + try: + request = json.load(sys.stdin) + atespace = os.environ["LIVE_PROOF_ATESPACE"] + actor = os.environ["LIVE_PROOF_ACTOR"] + port = int(os.environ["LIVE_PROOF_ROUTER_PORT"]) + state_file = os.environ["LIVE_PROOF_STATE_FILE"] + method = request["method"] + path = request["path"] + authenticated = bool(request.get("authenticated", True)) + body = request.get("body") + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + print(json.dumps({"error": "invalid request configuration"})) + return 2 + + if ( + method not in {"GET", "POST"} + or not isinstance(path, str) + or not path.startswith("/") + ): + print(json.dumps({"error": "invalid request target"})) + return 2 + + headers = {"Connection": "close"} + if authenticated: + try: + with open(state_file, encoding="utf-8") as state_handle: + token = json.load(state_handle)["shim_token"] + except (OSError, KeyError, TypeError, json.JSONDecodeError): + print(json.dumps({"error": "shim state unavailable"})) + return 2 + headers["Authorization"] = f"Bearer {token}" + + body_bytes = None + if body is not None: + body_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8") + headers["Content-Type"] = "application/json" + + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=20) + connection.set_tunnel( + "actor-upstream:8090", + headers={"ate-target-actor": f"{atespace}/{actor}"}, + ) + try: + connection.request(method, path, body=body_bytes, headers=headers) + response = connection.getresponse() + response_bytes = response.read() + except (OSError, http.client.HTTPException) as exc: + print(json.dumps({"transport_error": type(exc).__name__})) + return 1 + finally: + connection.close() + + response_text = response_bytes.decode("utf-8", errors="replace") + try: + response_body = json.loads(response_text) + except json.JSONDecodeError: + response_body = response_text + print(json.dumps({"status": response.status, "body": response_body})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/spikes/substrate-workspace-adapter/tools/README-round3-egress.md b/spikes/substrate-workspace-adapter/tools/README-round3-egress.md index 1cdb39b..574b939 100644 --- a/spikes/substrate-workspace-adapter/tools/README-round3-egress.md +++ b/spikes/substrate-workspace-adapter/tools/README-round3-egress.md @@ -1,7 +1,34 @@ # Round 3 egress helpers -These source files are a live-only, dummy-credential harness for the 2026-09-23 -Substrate preview run. They are not production credential-provider code. +These source files are live-only Substrate preview scaffolding, not production +credential-provider code. + +Run these from the pinned Substrate checkout. Credential injection requires the Envoy +dataplane; `ate-setup` rejects the injection flag with agentgateway. The `0f9635ae` +preview used Envoy with sdsmint and both credential-provider overrides: + +```sh +cd "$SUBSTRATE_SRC" +export VERSION=0f9635ae +export KO_DOCKER_REPO=localhost:5001 +KUBECONFIG_PATH=/tmp/substrate-preview-kubeconfig + +go run ./cmd/ate-setup --kind --kubeconfig "$KUBECONFIG_PATH" --context kind-substrate-preview \ + --atenet-dataplane envoy --experimental-use-sdsmint --experimental-egress-credential-injection \ + --credential-provider-name ate-secret://kubernetes.io \ + --credential-provider-address credprovider.mainloop-control.svc:50051 \ + deploy ate-system +go run ./cmd/ate-setup --kind --kubeconfig "$KUBECONFIG_PATH" --context kind-substrate-preview \ + --atenet-dataplane envoy --experimental-use-sdsmint --experimental-egress-credential-injection \ + --credential-provider-name ate-secret://kubernetes.io \ + --credential-provider-address credprovider.mainloop-control.svc:50051 \ + deploy atenet +``` + +At fork commit `0f9635ae`, the defaults are `ate-secret://k8s.io` and +`k8s-credential-provider.ate-system.svc:50051`. A redeploy without the overrides +breaks Claude's provider preflight with HTTP 500: +`credential URI names a provider this gateway does not serve`. - `round3-credprovider/main.go` implements a temporary mTLS gRPC credential provider and HTTPS echo endpoint. It reads only a Kubernetes Secret created @@ -11,20 +38,71 @@ Substrate preview run. They are not production credential-provider code. a credential for one hostname. `--prefix` supports bearer-token headers; omit it for the dummy echo check. - `round3-claude-provider/main.go` is the separate Phase 3e preview provider. - It is pinned to the Claude Secret URI and one actor SPIFFE ID, reads the - credential from a read-only Secret mount, and logs only a success marker. - It is test-run scaffolding, not a general-purpose or production provider. + It keeps the Claude Secret URI pinned, requires the expected actor SPIFFE ID + through `EXPECTED_ACTOR_SPIFFE_ID`, reads the credential from a read-only + Secret mount, and logs only a success marker. It is test-run scaffolding, not + a general-purpose or production provider. -Build from the pinned Substrate checkout (`cdac9baef81dd319b46086d695266e6161e9e592`), +The `patched-next` adapter follows the current API shape: actor template UIDs are +read from `status.externalSnapshot.actorTemplateUid`; ActorTemplates use +`wakeupProbe` and `snapshotConfig`; and egress-policy updates carry the current +`metadata.uid` and `metadata.version` preconditions. The API has no egress-policy +delete operation, so the Claude proof revokes injected credentials by updating +the policy to an empty rule set. + +Build from the pinned Substrate checkout (`0f9635aed37bd5dde604a9bca1975421cd07181a`), where the imported internal packages and protobuf modules are available: ```sh CGO_ENABLED=0 GOFLAGS=-mod=vendor go build -o /tmp/round3-credprovider ./cmd/round3-credprovider CGO_ENABLED=0 GOFLAGS=-mod=vendor go build -o /tmp/round3-egress-injection ./cmd/round3-egress-injection -CGO_ENABLED=0 GOFLAGS=-mod=vendor go build -o /tmp/round3-claude-provider ./cmd/round3-claude-provider +``` + +## Build and push the Lane A Claude provider image + +Run this block from the Mainloop worktree root with access to pull the builder +and distroless images and push to the local registry. It makes a temporary +checkout of Substrate at `0f9635ae`, stages this provider source and Dockerfile +there, builds a static nonroot image, pushes a unique tag, then reads the digest +from the registry. The expected actor SPIFFE ID is supplied at runtime by the +Lane A Deployment, not baked into this image. + +```sh +set -euo pipefail +MAINLOOP_ROOT=$(git rev-parse --show-toplevel) +BUILD_ROOT=$(mktemp -d /tmp/round3-claude-provider-build.XXXXXX) +trap 'rm -rf "$BUILD_ROOT"' EXIT +: "${SUBSTRATE_SRC:?set SUBSTRATE_SRC to the pinned Substrate checkout path}" +SUBSTRATE_TMP=$BUILD_ROOT/substrate +SUBSTRATE_COMMIT=0f9635aed37bd5dde604a9bca1975421cd07181a +IMAGE_TAG=0f9635ae-lane-a +IMAGE=localhost:5001/round3-claude-provider:$IMAGE_TAG + +mkdir -p "$SUBSTRATE_TMP" +test "$(git -C "$SUBSTRATE_SRC" rev-parse "$SUBSTRATE_COMMIT^{commit}")" = "$SUBSTRATE_COMMIT" +git -C "$SUBSTRATE_SRC" archive "$SUBSTRATE_COMMIT" | tar -x -C "$SUBSTRATE_TMP" +mkdir -p "$SUBSTRATE_TMP/tools/round3-claude-provider" +cp "$MAINLOOP_ROOT/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go" \ + "$SUBSTRATE_TMP/tools/round3-claude-provider/main.go" +cp "$MAINLOOP_ROOT/spikes/substrate-workspace-adapter/tools/round3-claude-provider/Dockerfile" \ + "$SUBSTRATE_TMP/tools/round3-claude-provider/Dockerfile" + +cd "$SUBSTRATE_TMP" +docker build --pull --platform=linux/amd64 \ + -f tools/round3-claude-provider/Dockerfile \ + -t "$IMAGE" . +docker push "$IMAGE" + +DIGEST=$(curl --fail --silent --show-error --head --max-time 15 \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ + "http://localhost:5001/v2/round3-claude-provider/manifests/$IMAGE_TAG" | + awk 'tolower($1) == "docker-content-digest:" { gsub("\r", "", $2); print $2; exit }') +[[ $DIGEST =~ ^sha256:[0-9a-f]{64}$ ]] +printf 'CLAUDE_PROVIDER_IMAGE=localhost:5001/round3-claude-provider@%s\n' "$DIGEST" ``` Use only a throwaway dummy Secret with the first provider in a disposable -preview cluster. The Phase 3e provider is separately actor-bound; pass its -credential only as a read-only Secret mount. Never put a credential in source, -logs, actor commands, or echo responses. +preview cluster. Set `EXPECTED_ACTOR_SPIFFE_ID` in the provider Deployment to +the one authorized actor ID. Pass the provider's credential only as a read-only +Secret mount. Never put a credential in source, logs, actor commands, or echo +responses. diff --git a/spikes/substrate-workspace-adapter/tools/round3-claude-provider/Dockerfile b/spikes/substrate-workspace-adapter/tools/round3-claude-provider/Dockerfile new file mode 100644 index 0000000..a090bd0 --- /dev/null +++ b/spikes/substrate-workspace-adapter/tools/round3-claude-provider/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.27.1-bookworm AS build + +WORKDIR /src +COPY go.mod go.sum ./ +COPY vendor/ ./vendor/ +COPY pkg/proto/credproviderpb/ ./pkg/proto/credproviderpb/ +COPY tools/round3-claude-provider/ ./tools/round3-claude-provider/ + +ENV CGO_ENABLED=0 \ + GOOS=linux \ + GOFLAGS=-mod=vendor + +RUN mkdir -p /out && \ + go build -trimpath -ldflags='-s -w' \ + -o /out/round3-claude-provider ./tools/round3-claude-provider + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=build /out/round3-claude-provider /round3-claude-provider +USER 65532:65532 +EXPOSE 50051 +ENTRYPOINT ["/round3-claude-provider"] diff --git a/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go b/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go index cc7371c..a155802 100644 --- a/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go +++ b/spikes/substrate-workspace-adapter/tools/round3-claude-provider/main.go @@ -7,6 +7,7 @@ import ( "log" "net" "os" + "strings" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -18,7 +19,6 @@ import ( const ( expectedURI = "ate-secret://kubernetes.io/mainloop-control/claude-oauth/oauth-token" - expectedActorID = "spiffe://substrate-actor.local/atespace/live-agent-gate/actor/egress-actor-a" credentialPath = "/run/claude/oauth-token" servingBundlePath = "/run/servicedns/credential-bundle.pem" clientCAPath = "/run/podidentity-ca/trust-bundle.pem" @@ -26,10 +26,11 @@ const ( type provider struct { credproviderpb.UnimplementedCredentialProviderServer + expectedActorSPIFFEID string } -func (provider) FetchSecret(_ context.Context, req *credproviderpb.FetchSecretRequest) (*credproviderpb.FetchSecretResponse, error) { - if req.GetUri() != expectedURI || req.GetActorSpiffeId() != expectedActorID { +func (p provider) FetchSecret(_ context.Context, req *credproviderpb.FetchSecretRequest) (*credproviderpb.FetchSecretResponse, error) { + if req.GetUri() != expectedURI || req.GetActorSpiffeId() != p.expectedActorSPIFFEID { return nil, status.Error(codes.PermissionDenied, "credential request rejected") } value, err := os.ReadFile(credentialPath) @@ -45,6 +46,11 @@ func (provider) FetchSecret(_ context.Context, req *credproviderpb.FetchSecretRe } func main() { + expectedActorSPIFFEID := strings.TrimSpace(os.Getenv("EXPECTED_ACTOR_SPIFFE_ID")) + if expectedActorSPIFFEID == "" { + log.Fatal("EXPECTED_ACTOR_SPIFFE_ID is required") + } + servingCert, err := tls.LoadX509KeyPair(servingBundlePath, servingBundlePath) if err != nil { log.Fatal("serving certificate unavailable") @@ -64,7 +70,7 @@ func main() { ClientCAs: clientCAs, } grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) - credproviderpb.RegisterCredentialProviderServer(grpcServer, provider{}) + credproviderpb.RegisterCredentialProviderServer(grpcServer, provider{expectedActorSPIFFEID: expectedActorSPIFFEID}) listener, err := net.Listen("tcp", ":50051") if err != nil { log.Fatal("gRPC listener unavailable")