gabriel / musehub public
coord.py python
172 lines 6.4 KB
Raw
sha256:969ddc5e88776e70af33c016b8777bb721356508ae5f304b3fb46c451494ece7 test(mwp3): Phase 4 — fix adjacent suite regressions, lock … Sonnet 4.6 22 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:969ddc5e88776e70af33c016b8777bb721356508ae5f304b3fb46c451494ece7 test(mwp3): Phase 4 — fix adjacent suite regressions, lock … Sonnet 4.6 22 days ago
sha256:1749b9cc5cd2583c56d3261c4c00a342c27435f3946d4bc3e70f76aa2795458a docs(mwp-3): TDD implementation plan for job-enqueue idempo… Opus 4.8 22 days ago