|
8 | 8 | ingests them here, normalizes, and turns the governance signal (e.g. refused |
9 | 9 | slots) into an ops finding. |
10 | 10 |
|
11 | | -Transport is a filesystem mailbox (MESH_INPUT_DIR / MESH_OUTPUT_DIR) — a real, |
12 | | -dependency-free minimal mesh; swap for a bus later without changing this contract. |
| 11 | +Transport is **pluggable** (``MeshTransport``, GDI-2b): the default |
| 12 | +``FilesystemMailbox`` (MESH_INPUT_DIR / MESH_OUTPUT_DIR) is a real, dependency-free |
| 13 | +minimal mesh; ``InMemoryBus`` is an in-process message bus and the **agentplane |
| 14 | +live-wire seam** — agentplane's MeshRush adapter calls ``publish_telemetry`` and GDI |
| 15 | +drains it with the *same* ``consume`` contract. A networked bus (NATS/Redis/Kafka) |
| 16 | +is a further transport behind this same interface — no heavy broker dependency is |
| 17 | +pinned into GDI (sovereign, dependency-light). |
13 | 18 |
|
14 | 19 | Fail-closed: an envelope that does not conform is rejected (recorded, no finding |
15 | 20 | produced) — a malformed telemetry item can never silently become a finding. |
|
26 | 31 | import os |
27 | 32 | import re |
28 | 33 | import sys |
| 34 | +import threading |
| 35 | +from collections import OrderedDict |
29 | 36 | from pathlib import Path |
| 37 | +from typing import Protocol |
30 | 38 |
|
31 | 39 | ROOT = Path(__file__).resolve().parents[1] |
32 | 40 | SCHEMA = ROOT / "open-ai4it-spec/contracts/schemas/event-envelope.schema.json" |
@@ -104,57 +112,166 @@ def _finding_for(event: dict, ts_ms: int, utc: str) -> dict: |
104 | 112 | return finding |
105 | 113 |
|
106 | 114 |
|
107 | | -def consume_once(input_dir: Path, output_dir: Path, schema: dict, *, clock=_now_fields) -> dict: |
108 | | - """Consume every *.json envelope in ``input_dir``; write findings to ``output_dir``. |
| 115 | +def _dedup_key(raw: "bytes | str") -> str: |
| 116 | + """Content id of a source event — the idempotency key for its finding.""" |
| 117 | + data = raw if isinstance(raw, bytes) else raw.encode("utf-8") |
| 118 | + return hashlib.sha256(data).hexdigest()[:16] |
| 119 | + |
| 120 | + |
| 121 | +class MeshTransport(Protocol): |
| 122 | + """The consume-loop's transport contract. A filesystem mailbox, an in-process |
| 123 | + bus, or a networked broker all satisfy it; ``consume`` is identical over any.""" |
| 124 | + |
| 125 | + def poll(self) -> "list[tuple[str, bytes]]": |
| 126 | + """Return pending ``(message_id, raw_bytes)`` telemetry, oldest first.""" |
| 127 | + |
| 128 | + def ack(self, message_id: str) -> None: |
| 129 | + """Mark a message consumed (it must not be polled again).""" |
| 130 | + |
| 131 | + def reject(self, message_id: str, reason: str) -> None: |
| 132 | + """Mark a message rejected (recorded, removed from the inbox).""" |
| 133 | + |
| 134 | + def publish_finding(self, finding: dict, *, dedup_key: str) -> "tuple[str, bool]": |
| 135 | + """Publish a finding idempotently by ``dedup_key``; return ``(id, created)``.""" |
109 | 136 |
|
110 | | - Returns stats ``{consumed, produced, rejected}``. Rejected envelopes are recorded |
111 | | - in ``output_dir/_rejected.log`` with their errors; no finding is produced for them. |
112 | | - """ |
113 | | - output_dir.mkdir(parents=True, exist_ok=True) |
114 | | - processed_dir = output_dir / "_processed" |
115 | | - rejected_dir = output_dir / "_rejected" |
116 | | - stats = {"consumed": 0, "produced": 0, "rejected": 0} |
117 | | - rejected_log: list[str] = [] |
118 | 137 |
|
119 | | - def _drain(path: Path, dest_dir: Path) -> None: |
120 | | - # Move the source out of the inbox so a long-running loop consumes it once. |
| 138 | +class FilesystemMailbox: |
| 139 | + """A ``MeshTransport`` over a directory pair (the GDI-2 default). Telemetry is |
| 140 | + ``input_dir/*.json``; findings and drained sources land under ``output_dir``.""" |
| 141 | + |
| 142 | + def __init__(self, input_dir: Path, output_dir: Path) -> None: |
| 143 | + self.input_dir = input_dir |
| 144 | + self.output_dir = output_dir |
| 145 | + self._pending: "dict[str, Path]" = {} |
| 146 | + |
| 147 | + def poll(self) -> "list[tuple[str, bytes]]": |
| 148 | + out: "list[tuple[str, bytes]]" = [] |
| 149 | + if not self.input_dir.exists(): |
| 150 | + return out |
| 151 | + for path in sorted(self.input_dir.glob("*.json")): |
| 152 | + self._pending[path.name] = path |
| 153 | + out.append((path.name, path.read_bytes())) |
| 154 | + return out |
| 155 | + |
| 156 | + def _drain(self, message_id: str, subdir: str) -> None: |
| 157 | + path = self._pending.pop(message_id, None) |
| 158 | + if path is None or not path.exists(): |
| 159 | + return |
| 160 | + dest_dir = self.output_dir / subdir |
121 | 161 | dest_dir.mkdir(parents=True, exist_ok=True) |
122 | 162 | dest = dest_dir / path.name |
123 | 163 | if dest.exists(): # avoid clobbering on name reuse |
124 | 164 | dest = dest_dir / f"{path.stem}.{hashlib.sha256(path.name.encode()).hexdigest()[:8]}{path.suffix}" |
125 | 165 | os.replace(path, dest) |
126 | 166 |
|
127 | | - for path in sorted(input_dir.glob("*.json")) if input_dir.exists() else []: |
| 167 | + def ack(self, message_id: str) -> None: |
| 168 | + self._drain(message_id, "_processed") |
| 169 | + |
| 170 | + def reject(self, message_id: str, reason: str) -> None: |
| 171 | + self.output_dir.mkdir(parents=True, exist_ok=True) |
| 172 | + with (self.output_dir / "_rejected.log").open("a", encoding="utf-8") as fh: |
| 173 | + fh.write(f"{message_id}: {reason}\n") |
| 174 | + self._drain(message_id, "_rejected") |
| 175 | + |
| 176 | + def publish_finding(self, finding: dict, *, dedup_key: str) -> "tuple[str, bool]": |
| 177 | + self.output_dir.mkdir(parents=True, exist_ok=True) |
| 178 | + out = self.output_dir / f"finding-{dedup_key}.json" |
| 179 | + created = not out.exists() |
| 180 | + if created: |
| 181 | + out.write_text(json.dumps(finding, indent=2) + "\n", encoding="utf-8") |
| 182 | + return out.name, created |
| 183 | + |
| 184 | + |
| 185 | +class InMemoryBus: |
| 186 | + """A thread-safe, dependency-free in-process ``MeshTransport`` — the agentplane |
| 187 | + live-wire seam. agentplane's MeshRush adapter calls ``publish_telemetry``; GDI |
| 188 | + drains it via ``consume``. The same contract fronts a networked broker later.""" |
| 189 | + |
| 190 | + def __init__(self) -> None: |
| 191 | + self._lock = threading.Lock() |
| 192 | + self._inbox: "OrderedDict[str, bytes]" = OrderedDict() |
| 193 | + self._findings: "OrderedDict[str, dict]" = OrderedDict() |
| 194 | + self._rejected: "list[tuple[str, str]]" = [] |
| 195 | + self._seq = 0 |
| 196 | + |
| 197 | + def publish_telemetry(self, raw: "bytes | str") -> str: |
| 198 | + """Publish a telemetry envelope onto the bus (the producer/agentplane side).""" |
| 199 | + data = raw if isinstance(raw, bytes) else raw.encode("utf-8") |
| 200 | + with self._lock: |
| 201 | + message_id = f"msg-{self._seq}" |
| 202 | + self._seq += 1 |
| 203 | + self._inbox[message_id] = data |
| 204 | + return message_id |
| 205 | + |
| 206 | + def poll(self) -> "list[tuple[str, bytes]]": |
| 207 | + with self._lock: |
| 208 | + return list(self._inbox.items()) |
| 209 | + |
| 210 | + def ack(self, message_id: str) -> None: |
| 211 | + with self._lock: |
| 212 | + self._inbox.pop(message_id, None) |
| 213 | + |
| 214 | + def reject(self, message_id: str, reason: str) -> None: |
| 215 | + with self._lock: |
| 216 | + self._inbox.pop(message_id, None) |
| 217 | + self._rejected.append((message_id, reason)) |
| 218 | + |
| 219 | + def publish_finding(self, finding: dict, *, dedup_key: str) -> "tuple[str, bool]": |
| 220 | + with self._lock: |
| 221 | + created = dedup_key not in self._findings |
| 222 | + if created: |
| 223 | + self._findings[dedup_key] = finding |
| 224 | + return dedup_key, created |
| 225 | + |
| 226 | + def findings(self) -> "list[dict]": |
| 227 | + """All findings published so far (deduped), in first-seen order.""" |
| 228 | + with self._lock: |
| 229 | + return list(self._findings.values()) |
| 230 | + |
| 231 | + def rejected(self) -> "list[tuple[str, str]]": |
| 232 | + """All ``(message_id, reason)`` rejections so far.""" |
| 233 | + with self._lock: |
| 234 | + return list(self._rejected) |
| 235 | + |
| 236 | + |
| 237 | +def consume(transport: MeshTransport, schema: dict, *, clock=_now_fields) -> dict: |
| 238 | + """Drain ``transport``'s pending telemetry into findings, fail-closed + idempotent. |
| 239 | +
|
| 240 | + Returns stats ``{consumed, produced, rejected}``. A malformed or non-conforming |
| 241 | + envelope is rejected (no finding); a finding is published idempotently by the |
| 242 | + source event's content, so re-draining never duplicates. |
| 243 | + """ |
| 244 | + stats = {"consumed": 0, "produced": 0, "rejected": 0} |
| 245 | + for message_id, raw in transport.poll(): |
128 | 246 | stats["consumed"] += 1 |
129 | | - raw = path.read_bytes() |
130 | 247 | try: |
131 | 248 | event = json.loads(raw) |
132 | | - except (json.JSONDecodeError, OSError) as exc: |
| 249 | + except (json.JSONDecodeError, ValueError) as exc: |
133 | 250 | stats["rejected"] += 1 |
134 | | - rejected_log.append(f"{path.name}: unreadable/invalid JSON ({exc})") |
135 | | - _drain(path, rejected_dir) |
| 251 | + transport.reject(message_id, f"unreadable/invalid JSON ({exc})") |
136 | 252 | continue |
137 | 253 | errs = envelope_errors(event, schema) |
138 | 254 | if errs: |
139 | 255 | stats["rejected"] += 1 |
140 | | - rejected_log.append(f"{path.name}: {'; '.join(errs)}") |
141 | | - _drain(path, rejected_dir) |
| 256 | + transport.reject(message_id, "; ".join(errs)) |
142 | 257 | continue |
143 | 258 | ts_ms, utc = clock() |
144 | 259 | finding = _finding_for(event, ts_ms, utc) |
145 | | - # Idempotent: derive the finding filename from the source event content. |
146 | | - out = output_dir / f"finding-{hashlib.sha256(raw).hexdigest()[:16]}.json" |
147 | | - if not out.exists(): |
148 | | - out.write_text(json.dumps(finding, indent=2) + "\n", encoding="utf-8") |
| 260 | + _, created = transport.publish_finding(finding, dedup_key=_dedup_key(raw)) |
| 261 | + if created: |
149 | 262 | stats["produced"] += 1 |
150 | | - _drain(path, processed_dir) |
151 | | - |
152 | | - if rejected_log: |
153 | | - with (output_dir / "_rejected.log").open("a", encoding="utf-8") as fh: |
154 | | - fh.write("\n".join(rejected_log) + "\n") |
| 263 | + transport.ack(message_id) |
155 | 264 | return stats |
156 | 265 |
|
157 | 266 |
|
| 267 | +def consume_once(input_dir: Path, output_dir: Path, schema: dict, *, clock=_now_fields) -> dict: |
| 268 | + """Consume every *.json envelope in ``input_dir``; write findings to ``output_dir``. |
| 269 | +
|
| 270 | + Back-compat wrapper over ``consume`` with a ``FilesystemMailbox`` transport. |
| 271 | + """ |
| 272 | + return consume(FilesystemMailbox(input_dir, output_dir), schema, clock=clock) |
| 273 | + |
| 274 | + |
158 | 275 | def main() -> int: |
159 | 276 | schema = json.loads(SCHEMA.read_text(encoding="utf-8")) |
160 | 277 | inbox = Path(os.environ.get("MESH_INPUT_DIR", str(ROOT / "mesh" / "telemetry"))) |
|
0 commit comments