syncmill 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,339 @@
1
+ """Optional Phoenix OTLP/HTTP streaming sink.
2
+
3
+ This module is imported lazily only for ``integration.trace.exporter=otlp-http``.
4
+ It deliberately owns a private OpenTelemetry provider: SyncMill exports only its
5
+ body-free :class:`~syncmill.tracer.SpanRecord` values and never auto-instruments
6
+ ambient libraries.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import queue
13
+ import threading
14
+ import time
15
+ from collections.abc import Callable, Mapping, Sequence
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from opentelemetry.sdk.resources import Resource
20
+ from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
21
+ from opentelemetry.sdk.trace.export import (
22
+ SpanExportResult,
23
+ SpanExporter,
24
+ SpanProcessor,
25
+ )
26
+ from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator
27
+ from opentelemetry.trace import Link, Status, StatusCode, set_span_in_context
28
+ from phoenix.otel import HTTPSpanExporter
29
+ from syncmill import __version__
30
+ from syncmill.tracer import _MAX_STATUS_MESSAGE
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _MAX_QUEUE_SIZE = 2048
35
+ _MAX_EXPORT_BATCH_SIZE = 512
36
+ _STOP = object()
37
+
38
+
39
+ @dataclass
40
+ class _Barrier:
41
+ event: threading.Event
42
+ reached: bool = False
43
+
44
+
45
+ class _FixedTraceIdGenerator(IdGenerator):
46
+ """Use the FileTracer trace id while retaining random span ids."""
47
+
48
+ def __init__(self, trace_id: str) -> None:
49
+ self._trace_id = int(trace_id, 16)
50
+ self._random = RandomIdGenerator()
51
+
52
+ def generate_span_id(self) -> int:
53
+ return self._random.generate_span_id()
54
+
55
+ def generate_trace_id(self) -> int:
56
+ return self._trace_id
57
+
58
+
59
+ class _BoundedBatchSpanProcessor(SpanProcessor):
60
+ """Small background batcher with a real caller-side flush deadline.
61
+
62
+ OpenTelemetry's exporter still owns HTTP retry behavior. This processor owns
63
+ only queueing and the maximum time SyncMill waits in its finalizer.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ exporter: SpanExporter,
69
+ *,
70
+ batch_delay_seconds: float,
71
+ ) -> None:
72
+ self._exporter = exporter
73
+ self._delay = batch_delay_seconds
74
+ self._queue: queue.Queue[Any] = queue.Queue(maxsize=_MAX_QUEUE_SIZE)
75
+ self._lock = threading.Lock()
76
+ self._accepting = True
77
+ self._stop_requested = threading.Event()
78
+ self._submitted = 0
79
+ self._exported = 0
80
+ self._dropped = 0
81
+ self._failed = False
82
+ self._worker = threading.Thread(
83
+ target=self._run,
84
+ name="syncmill-phoenix-exporter",
85
+ daemon=True,
86
+ )
87
+ self._worker.start()
88
+
89
+ def on_start(self, span: Any, parent_context: Any | None = None) -> None:
90
+ return None
91
+
92
+ def on_end(self, span: ReadableSpan) -> None:
93
+ with self._lock:
94
+ self._submitted += 1
95
+ accepting = self._accepting
96
+ if not accepting:
97
+ self._mark_dropped(1)
98
+ return
99
+ try:
100
+ self._queue.put_nowait(span)
101
+ except queue.Full:
102
+ self._mark_dropped(1)
103
+ logger.warning("Phoenix trace queue is full; dropping a completed span")
104
+
105
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
106
+ timeout = max(timeout_millis / 1000.0, 0.0)
107
+ deadline = time.monotonic() + timeout
108
+ barrier = _Barrier(threading.Event())
109
+ try:
110
+ self._queue.put(barrier, timeout=max(deadline - time.monotonic(), 0.0))
111
+ except queue.Full:
112
+ self._mark_failed()
113
+ return False
114
+ if not barrier.event.wait(max(deadline - time.monotonic(), 0.0)):
115
+ self._mark_failed()
116
+ return False
117
+ return barrier.reached
118
+
119
+ def shutdown(self, timeout_seconds: float = 5.0) -> bool: # type: ignore[override]
120
+ deadline = time.monotonic() + max(timeout_seconds, 0.0)
121
+ with self._lock:
122
+ self._accepting = False
123
+ flushed = self.force_flush(
124
+ timeout_millis=max(int((deadline - time.monotonic()) * 1000), 0)
125
+ )
126
+ self._stop_requested.set()
127
+ try:
128
+ self._queue.put_nowait(_STOP)
129
+ except queue.Full:
130
+ pass
131
+ self._worker.join(max(deadline - time.monotonic(), 0.0))
132
+ clean = flushed and not self._worker.is_alive()
133
+ if not clean:
134
+ self._mark_failed()
135
+ return clean
136
+
137
+ def receipt(self, *, extra_dropped: int = 0, extra_failed: bool = False) -> dict:
138
+ with self._lock:
139
+ submitted = self._submitted + extra_dropped
140
+ exported = self._exported
141
+ dropped = self._dropped + extra_dropped
142
+ failed = self._failed or extra_failed
143
+ if failed:
144
+ # Anything not acknowledged is honestly reported as dropped/unconfirmed.
145
+ dropped = max(dropped, submitted - exported)
146
+ return {
147
+ "backend": "phoenix",
148
+ "protocol": "otlp-http/protobuf",
149
+ "status": "error" if failed or dropped else "ok",
150
+ "exported_span_count": exported,
151
+ "dropped_span_count": dropped,
152
+ }
153
+
154
+ def _run(self) -> None:
155
+ try:
156
+ while True:
157
+ item = self._queue.get()
158
+ if item is _STOP:
159
+ return
160
+ if isinstance(item, _Barrier):
161
+ item.reached = True
162
+ item.event.set()
163
+ continue
164
+
165
+ batch: list[ReadableSpan] = [item]
166
+ control: object | _Barrier | None = None
167
+ deadline = time.monotonic() + self._delay
168
+ while len(batch) < _MAX_EXPORT_BATCH_SIZE:
169
+ remaining = deadline - time.monotonic()
170
+ if remaining <= 0:
171
+ break
172
+ try:
173
+ next_item = self._queue.get(timeout=remaining)
174
+ except queue.Empty:
175
+ break
176
+ if next_item is _STOP or isinstance(next_item, _Barrier):
177
+ control = next_item
178
+ break
179
+ batch.append(next_item)
180
+
181
+ self._export(batch)
182
+ if isinstance(control, _Barrier):
183
+ control.reached = True
184
+ control.event.set()
185
+ elif control is _STOP:
186
+ return
187
+ if self._stop_requested.is_set():
188
+ self._discard_remaining()
189
+ return
190
+ finally:
191
+ try:
192
+ self._exporter.shutdown()
193
+ except Exception:
194
+ logger.warning("Phoenix exporter shutdown failed", exc_info=True)
195
+
196
+ def _export(self, batch: Sequence[ReadableSpan]) -> None:
197
+ try:
198
+ outcome = self._exporter.export(batch)
199
+ except Exception:
200
+ logger.warning("Phoenix span export failed", exc_info=True)
201
+ outcome = SpanExportResult.FAILURE
202
+ with self._lock:
203
+ if outcome is SpanExportResult.SUCCESS:
204
+ self._exported += len(batch)
205
+ else:
206
+ self._dropped += len(batch)
207
+ self._failed = True
208
+
209
+ def _discard_remaining(self) -> None:
210
+ discarded = 0
211
+ while True:
212
+ try:
213
+ item = self._queue.get_nowait()
214
+ except queue.Empty:
215
+ break
216
+ if isinstance(item, _Barrier):
217
+ item.event.set()
218
+ elif item is not _STOP:
219
+ discarded += 1
220
+ if discarded:
221
+ self._mark_dropped(discarded)
222
+
223
+ def _mark_dropped(self, count: int) -> None:
224
+ with self._lock:
225
+ self._dropped += count
226
+ self._failed = True
227
+
228
+ def _mark_failed(self) -> None:
229
+ with self._lock:
230
+ self._failed = True
231
+
232
+
233
+ class PhoenixSpanSink:
234
+ """Convert completed SyncMill records into private OTel spans."""
235
+
236
+ def __init__(
237
+ self,
238
+ *,
239
+ run_id: str,
240
+ trace_id: str,
241
+ run_start: float,
242
+ endpoint: str,
243
+ project_name: str,
244
+ batch_delay_seconds: float,
245
+ timeout_seconds: int,
246
+ attribute_factory: Callable[[Any], Mapping[str, str | int | bool]],
247
+ ) -> None:
248
+ resource = Resource.create(
249
+ {
250
+ "service.name": "syncmill",
251
+ "openinference.project.name": project_name,
252
+ }
253
+ )
254
+ self._provider = TracerProvider(
255
+ resource=resource,
256
+ shutdown_on_exit=False,
257
+ id_generator=_FixedTraceIdGenerator(trace_id),
258
+ )
259
+ exporter = HTTPSpanExporter(endpoint=endpoint, timeout=timeout_seconds)
260
+ self._processor = _BoundedBatchSpanProcessor(
261
+ exporter,
262
+ batch_delay_seconds=batch_delay_seconds,
263
+ )
264
+ self._provider.add_span_processor(self._processor)
265
+ self._tracer = self._provider.get_tracer("syncmill", __version__)
266
+ self._root = self._tracer.start_span(
267
+ "syncmill.run",
268
+ start_time=int(run_start * 1e9),
269
+ )
270
+ self._contexts: dict[str, Any] = {"run": self._root.get_span_context()}
271
+ self._recorded: set[str] = set()
272
+ self._attribute_factory = attribute_factory
273
+ self._timeout_seconds = timeout_seconds
274
+ self._validation_dropped = 0
275
+ self._validation_failed = False
276
+ self._closed = False
277
+ self._receipt: dict | None = None
278
+
279
+ def record(self, record: Any) -> None:
280
+ try:
281
+ self._record(record)
282
+ except Exception:
283
+ self._validation_dropped += 1
284
+ self._validation_failed = True
285
+ logger.warning("Phoenix span conversion failed", exc_info=True)
286
+
287
+ def flush(self) -> dict:
288
+ if self._receipt is not None:
289
+ return dict(self._receipt)
290
+ if not self._closed:
291
+ if "run" not in self._recorded:
292
+ self._validation_failed = True
293
+ # The private root exists and is exported, but its final
294
+ # producer-authored run record never arrived. Mark the receipt
295
+ # erroneous without also claiming that an emitted span dropped.
296
+ self._root.set_status(Status(StatusCode.ERROR, "missing run span"))
297
+ self._root.end()
298
+ self._closed = True
299
+ clean = self._processor.shutdown(self._timeout_seconds)
300
+ self._receipt = self._processor.receipt(
301
+ extra_dropped=self._validation_dropped,
302
+ extra_failed=self._validation_failed or not clean,
303
+ )
304
+ return dict(self._receipt)
305
+
306
+ def _record(self, record: Any) -> None:
307
+ if self._closed:
308
+ raise RuntimeError("Phoenix stream is already closed")
309
+ if record.node_id in self._recorded:
310
+ raise ValueError(f"duplicate span node_id: {record.node_id!r}")
311
+ if record.node_parent is not None and record.node_parent not in self._contexts:
312
+ raise ValueError(
313
+ f"span {record.node_id!r} has unresolved parent {record.node_parent!r}"
314
+ )
315
+ missing_links = [target for target in record.links if target not in self._contexts]
316
+ if missing_links:
317
+ raise ValueError(
318
+ f"span {record.node_id!r} links to unrecorded node {missing_links[0]!r}"
319
+ )
320
+
321
+ attributes = dict(self._attribute_factory(record))
322
+ if record.node_id == "run":
323
+ span = self._root
324
+ else:
325
+ links = [Link(self._contexts[target]) for target in record.links]
326
+ span = self._tracer.start_span(
327
+ record.name,
328
+ context=set_span_in_context(self._root),
329
+ links=links,
330
+ start_time=int(record.start * 1e9),
331
+ )
332
+ for key, value in attributes.items():
333
+ span.set_attribute(key, value)
334
+ if record.status in {"failed", "timeout", "cancelled"}:
335
+ message = (record.error or record.status)[:_MAX_STATUS_MESSAGE]
336
+ span.set_status(Status(StatusCode.ERROR, message))
337
+ self._contexts[record.node_id] = span.get_span_context()
338
+ self._recorded.add(record.node_id)
339
+ span.end(end_time=int(record.end * 1e9))