coord.py
python
sha256:1dd27e6d5c24550177b01b0a171e8375c07cf046b549e7683364706b6522d3bc
docs(#139): mark all 11 checklist items resolved, document …
Sonnet 5
12 days ago
| 1 | """Pydantic models for the MuseHub coordination bus API. |
| 2 | |
| 3 | Push/pull/watch endpoints use these models for request validation and |
| 4 | response serialization. All models are strict about field types so that |
| 5 | malformed agent payloads are rejected at the boundary rather than |
| 6 | propagating corrupt data into the database. |
| 7 | |
| 8 | Security |
| 9 | -------- |
| 10 | - ``record_id`` is validated against a safe-ID pattern — prevents agents from |
| 11 | using path-traversal characters (``..``, ``/``, null bytes) as record IDs. |
| 12 | Accepts sha256 genesis IDs (``sha256:<64-hex>``, 71 chars) and opaque |
| 13 | alphanumeric run_id strings up to 128 chars. |
| 14 | - ``kind`` is constrained to the known coordination record types — prevents |
| 15 | arbitrary string injection into the ``kind`` column. |
| 16 | - ``run_id`` is capped at 255 characters and stripped of control characters |
| 17 | at the service layer before storage. |
| 18 | - ``payload`` is accepted as a free-form dict — content is agent-defined and |
| 19 | opaque to the server. The server stores it verbatim and returns it verbatim. |
| 20 | """ |
| 21 | |
| 22 | import re |
| 23 | from datetime import datetime |
| 24 | from typing import Literal |
| 25 | |
| 26 | from pydantic import BaseModel, Field, field_validator |
| 27 | from musehub.types.pydantic_types import PydanticJson |
| 28 | |
| 29 | # Coordination record kinds that the server accepts. |
| 30 | _VALID_KINDS = frozenset({ |
| 31 | "reservation", |
| 32 | "intent", |
| 33 | "release", |
| 34 | "heartbeat", |
| 35 | "dependency", |
| 36 | "task", |
| 37 | "claim", |
| 38 | }) |
| 39 | |
| 40 | # Safe record ID pattern — accepts sha256 genesis IDs (sha256:<64-hex>) |
| 41 | # and opaque alphanumeric run_id strings. Rejects path traversal chars. |
| 42 | _SAFE_RECORD_ID_RE = re.compile(r"^[a-zA-Z0-9_\-:]{1,128}$") |
| 43 | |
| 44 | def _validate_record_id(v: str) -> str: |
| 45 | if not _SAFE_RECORD_ID_RE.match(v): |
| 46 | raise ValueError(f"record_id must be alphanumeric/hyphens/underscores/colons (max 128 chars), got: {v!r}") |
| 47 | return v |
| 48 | |
| 49 | # ── Shared record shape ──────────────────────────────────────────────────────── |
| 50 | |
| 51 | class CoordRecordIn(BaseModel): |
| 52 | """A single coordination record to be pushed to MuseHub. |
| 53 | |
| 54 | Attributes: |
| 55 | kind: Record type — must be one of the known coordination kinds. |
| 56 | record_id: ID of the local coordination record — a sha256 genesis ID |
| 57 | (``sha256:<64-hex>``) or an opaque alphanumeric string up |
| 58 | to 128 chars. |
| 59 | run_id: Agent/pipeline identifier. Empty string is allowed |
| 60 | (for records that have no explicit agent identity). |
| 61 | payload: Full JSON body of the coordination record. |
| 62 | expires_at: Optional expiry timestamp. ISO-8601 string or ``None``. |
| 63 | """ |
| 64 | |
| 65 | kind: str = Field(..., description="Coordination record kind.") |
| 66 | record_id: str = Field(..., description="ID of the local coordination record.") |
| 67 | run_id: str = Field(default="", max_length=255) |
| 68 | payload: PydanticJson = Field(..., description="Full record payload.") |
| 69 | expires_at: datetime | None = Field(default=None) |
| 70 | |
| 71 | @field_validator("kind") |
| 72 | @classmethod |
| 73 | def _validate_kind(cls, v: str) -> str: |
| 74 | if v not in _VALID_KINDS: |
| 75 | raise ValueError( |
| 76 | f"kind must be one of {sorted(_VALID_KINDS)}, got: {v!r}" |
| 77 | ) |
| 78 | return v |
| 79 | |
| 80 | @field_validator("record_id") |
| 81 | @classmethod |
| 82 | def _validate_record_id_field(cls, v: str) -> str: |
| 83 | return _validate_record_id(v) |
| 84 | |
| 85 | class CoordRecordOut(BaseModel): |
| 86 | """A coordination record returned from MuseHub (pull or watch). |
| 87 | |
| 88 | Attributes: |
| 89 | id: Server-assigned auto-increment cursor ID. |
| 90 | repo_id: Repository ID. |
| 91 | kind: Record type. |
| 92 | record_id: ID of the original local coordination record. |
| 93 | run_id: Agent/pipeline identifier. |
| 94 | payload: Full record payload. |
| 95 | created_at: Server-side insertion timestamp (UTC). |
| 96 | expires_at: Optional expiry timestamp (UTC). |
| 97 | """ |
| 98 | |
| 99 | id: int |
| 100 | repo_id: str |
| 101 | kind: str |
| 102 | record_id: str |
| 103 | run_id: str |
| 104 | payload: PydanticJson |
| 105 | created_at: datetime |
| 106 | expires_at: datetime | None = None |
| 107 | |
| 108 | # ── Push ─────────────────────────────────────────────────────────────────────── |
| 109 | |
| 110 | class CoordPushRequest(BaseModel): |
| 111 | """Request body for ``POST /{owner}/{slug}/coord/push``. |
| 112 | |
| 113 | Attributes: |
| 114 | records: List of coordination records to push. At most 500 per call. |
| 115 | """ |
| 116 | |
| 117 | records: list[CoordRecordIn] = Field( |
| 118 | ..., |
| 119 | min_length=1, |
| 120 | max_length=500, |
| 121 | description="Coordination records to push (1–500).", |
| 122 | ) |
| 123 | |
| 124 | class CoordPushResponse(BaseModel): |
| 125 | """Response for ``POST /{owner}/{slug}/coord/push``. |
| 126 | |
| 127 | Attributes: |
| 128 | inserted: Count of records newly written to the database. |
| 129 | skipped: Count of records that already existed (idempotent re-push). |
| 130 | """ |
| 131 | |
| 132 | inserted: int |
| 133 | skipped: int |
| 134 | |
| 135 | # ── Pull ─────────────────────────────────────────────────────────────────────── |
| 136 | |
| 137 | class CoordPollRequest(BaseModel): |
| 138 | """Request body for ``POST /{owner}/{slug}/coord/pull``. |
| 139 | |
| 140 | Attributes: |
| 141 | since_id: Return only records with ``id > since_id``. |
| 142 | Pass ``0`` (default) to fetch all records. |
| 143 | kinds: Filter by record kind. Empty list = all kinds. |
| 144 | limit: Maximum number of records to return (1–1000, default 500). |
| 145 | """ |
| 146 | |
| 147 | since_id: int = Field(default=0, ge=0) |
| 148 | kinds: list[str] = Field(default_factory=list) |
| 149 | limit: int = Field(default=500, ge=1, le=1000) |
| 150 | |
| 151 | @field_validator("kinds") |
| 152 | @classmethod |
| 153 | def _validate_kinds(cls, v: list[str]) -> list[str]: |
| 154 | for kind in v: |
| 155 | if kind not in _VALID_KINDS: |
| 156 | raise ValueError( |
| 157 | f"kind must be one of {sorted(_VALID_KINDS)}, got: {kind!r}" |
| 158 | ) |
| 159 | return v |
| 160 | |
| 161 | class CoordPullResponse(BaseModel): |
| 162 | """Response for ``POST /{owner}/{slug}/coord/pull``. |
| 163 | |
| 164 | Attributes: |
| 165 | records: Coordination records in insertion order (oldest first). |
| 166 | cursor: The ``id`` of the last record in this response. |
| 167 | Pass this as ``since_id`` in the next pull to resume. |
| 168 | ``0`` if no records were returned. |
| 169 | """ |
| 170 | |
| 171 | records: list[CoordRecordOut] |
| 172 | cursor: int |
File History
2 commits
sha256:1dd27e6d5c24550177b01b0a171e8375c07cf046b549e7683364706b6522d3bc
docs(#139): mark all 11 checklist items resolved, document …
Sonnet 5
12 days ago
sha256:649011bedd713e22f7dca4c4be94bdefbf3b10950d9fc80235928d0b0b823be8
docs: track issue #139 (two-column app-shell refactor) plan…
Sonnet 5
12 days ago