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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- 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))
|