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,231 @@
1
+ """Body-free workspace hook collector for Kimi Code and Antigravity CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import tempfile
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+ from typing import Iterator
13
+
14
+ from syncmill.agents import normalize_tool_key
15
+ from syncmill.models import ToolCallRecord, ToolOutcome, ToolTelemetry
16
+
17
+
18
+ @contextmanager
19
+ def _locked(path: Path) -> Iterator[None]:
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ with path.open("a", encoding="utf-8") as handle:
22
+ os.chmod(path, 0o600)
23
+ fcntl.flock(handle, fcntl.LOCK_EX)
24
+ try:
25
+ yield
26
+ finally:
27
+ fcntl.flock(handle, fcntl.LOCK_UN)
28
+
29
+
30
+ def _mark_incomplete(spool: Path, code: str) -> None:
31
+ marker = Path(f"{spool}.incomplete")
32
+ marker.write_text(code, encoding="utf-8")
33
+ os.chmod(marker, 0o600)
34
+
35
+
36
+ def _read_records(spool: Path) -> tuple[list[dict], int]:
37
+ """Parsed records plus the count of unreadable (torn / non-dict) lines —
38
+ the reader must surface a torn write as incompleteness, not silence."""
39
+ if not spool.exists():
40
+ return [], 0
41
+ records: list[dict] = []
42
+ unreadable = 0
43
+ for line in spool.read_text(encoding="utf-8").splitlines():
44
+ try:
45
+ item = json.loads(line)
46
+ except json.JSONDecodeError:
47
+ unreadable += 1
48
+ continue
49
+ if isinstance(item, dict):
50
+ records.append(item)
51
+ else:
52
+ unreadable += 1
53
+ return records, unreadable
54
+
55
+
56
+ def _next_sequence(spool: Path) -> int:
57
+ """The next sequence number, tracked in a ``{spool}.seq`` sidecar.
58
+
59
+ Deriving it from a record COUNT was the bug: a torn line is skipped by the
60
+ reader, so the count under-reports and every later record reuses a live
61
+ sequence number (which the reader then sorts by). The sidecar is one int
62
+ read per event (also killing the O(n²) full-spool rescan); a missing or
63
+ unreadable sidecar (first event, or a legacy spool) seeds from the highest
64
+ sequence actually on disk — max, not count, so a torn line can only leave
65
+ a harmless gap, never a collision with a readable record.
66
+ """
67
+ seq_path = Path(f"{spool}.seq")
68
+ try:
69
+ value = int(seq_path.read_text(encoding="utf-8"))
70
+ except (OSError, ValueError):
71
+ records, _ = _read_records(spool)
72
+ value = max(
73
+ (r["sequence"] for r in records if isinstance(r.get("sequence"), int)),
74
+ default=0,
75
+ )
76
+ return value + 1
77
+
78
+
79
+ def _append(spool: Path, *, provider: str, tool_key: str, outcome: str) -> None:
80
+ sequence = _next_sequence(spool)
81
+ record = {
82
+ "source": provider,
83
+ "sequence": sequence,
84
+ "tool_key": tool_key,
85
+ "outcome": outcome,
86
+ }
87
+ spool.parent.mkdir(parents=True, exist_ok=True)
88
+ # Reserve the sequence BEFORE appending the record: a crash in between
89
+ # skips a number (harmless gap); the reverse order could reuse one. The
90
+ # write must be ATOMIC (unique temp + fsync + replace): a plain write_text
91
+ # truncates first and a crash mid-write can leave a valid PREFIX of the
92
+ # old value ("12" → "1"), which _next_sequence would trust — re-minting
93
+ # already-used numbers, the exact bug the sidecar exists to prevent.
94
+ seq_path = Path(f"{spool}.seq")
95
+ fd, tmp = tempfile.mkstemp(dir=str(spool.parent), prefix=f".{seq_path.name}.")
96
+ try:
97
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
98
+ handle.write(str(sequence))
99
+ handle.flush()
100
+ os.fsync(handle.fileno())
101
+ os.chmod(tmp, 0o600)
102
+ os.replace(tmp, seq_path)
103
+ # fsync the DIRECTORY so the rename is durable before the record write
104
+ # below begins: without it a filesystem crash could keep the appended
105
+ # record while reverting the sidecar to an older value — whose next
106
+ # trusted read would re-mint an existing sequence number.
107
+ dir_fd = os.open(str(spool.parent), os.O_RDONLY)
108
+ try:
109
+ os.fsync(dir_fd)
110
+ finally:
111
+ os.close(dir_fd)
112
+ except BaseException:
113
+ with contextlib.suppress(OSError):
114
+ os.unlink(tmp)
115
+ raise
116
+ with spool.open("a", encoding="utf-8") as handle:
117
+ handle.write(json.dumps(record, separators=(",", ":")) + "\n")
118
+ handle.flush()
119
+ # Durable record after a durable reserve: with both ordered this way, a
120
+ # record that survives a crash implies its reservation did too, so the
121
+ # sidecar can never lag behind the spool's highest sequence.
122
+ os.fsync(handle.fileno())
123
+ os.chmod(spool, 0o600)
124
+
125
+
126
+ def collect_hook_event(
127
+ *,
128
+ provider: str,
129
+ event: str,
130
+ spool: Path,
131
+ payload: dict,
132
+ tool_map: dict[str, str] | None = None,
133
+ ) -> None:
134
+ """Project a provider hook payload without ever persisting its body fields."""
135
+ lock = Path(f"{spool}.lock")
136
+ state_path = Path(f"{spool}.state")
137
+ with _locked(lock):
138
+ if provider == "kimi-code":
139
+ raw_name = payload.get("tool_name")
140
+ if not isinstance(raw_name, str):
141
+ _mark_incomplete(spool, "missing_tool_name")
142
+ return
143
+ tool_key = normalize_tool_key(raw_name, tool_map)
144
+ if tool_key is None:
145
+ _mark_incomplete(spool, "unqualified_tool_name")
146
+ return
147
+ outcome = "failed" if event == "failure" else "succeeded"
148
+ _append(spool, provider=provider, tool_key=tool_key, outcome=outcome)
149
+ return
150
+
151
+ if provider != "antigravity":
152
+ _mark_incomplete(spool, "unknown_provider")
153
+ return
154
+
155
+ conversation = payload.get("conversationId")
156
+ step = payload.get("stepIdx")
157
+ if not isinstance(conversation, str) or not isinstance(step, int):
158
+ _mark_incomplete(spool, "missing_correlation")
159
+ return
160
+ correlation = f"{conversation}:{step}"
161
+ state = {}
162
+ if state_path.exists():
163
+ try:
164
+ state = json.loads(state_path.read_text(encoding="utf-8"))
165
+ except (json.JSONDecodeError, OSError):
166
+ _mark_incomplete(spool, "invalid_state")
167
+ return
168
+ if event == "pre":
169
+ tool_call = payload.get("toolCall")
170
+ raw_name = tool_call.get("name") if isinstance(tool_call, dict) else None
171
+ tool_key = normalize_tool_key(raw_name, tool_map) if isinstance(raw_name, str) else None
172
+ if tool_key is None:
173
+ _mark_incomplete(spool, "unqualified_tool_name")
174
+ return
175
+ state[correlation] = tool_key
176
+ state_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8")
177
+ os.chmod(state_path, 0o600)
178
+ return
179
+ tool_key = state.pop(correlation, None)
180
+ if not isinstance(tool_key, str):
181
+ _mark_incomplete(spool, "missing_pre_event")
182
+ return
183
+ state_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8")
184
+ os.chmod(state_path, 0o600)
185
+ outcome = "failed" if event == "failure" or bool(payload.get("error")) else "succeeded"
186
+ _append(spool, provider=provider, tool_key=tool_key, outcome=outcome)
187
+
188
+
189
+ def load_hook_telemetry(spool: Path, *, source: str) -> ToolTelemetry:
190
+ calls: list[ToolCallRecord] = []
191
+ records, malformed = _read_records(spool)
192
+ for item in records:
193
+ try:
194
+ outcome = ToolOutcome(item["outcome"])
195
+ calls.append(
196
+ ToolCallRecord(
197
+ sequence=int(item["sequence"]),
198
+ tool_key=str(item["tool_key"]),
199
+ outcome=outcome,
200
+ )
201
+ )
202
+ except (KeyError, TypeError, ValueError):
203
+ malformed += 1
204
+ incomplete = Path(f"{spool}.incomplete").exists()
205
+ # A crash BETWEEN the sequence reserve and the spool append loses an event
206
+ # without a torn line or an .incomplete marker — the only evidence is the
207
+ # numbering itself: an internal gap in the recorded sequences, or a sidecar
208
+ # reservation newer than any record. Either must flip `complete`, or the
209
+ # consumer would trust a spool that silently dropped an event.
210
+ # Sequences are minted from 1, so completeness means EXACTLY 1..max — a
211
+ # min-to-max contiguity check would miss a lost PREFIX (event 1 reserved
212
+ # and lost, event 2 recorded → singleton [2] looked contiguous).
213
+ sequences = sorted({call.sequence for call in calls})
214
+ lost = bool(sequences) and (
215
+ sequences[0] != 1 or len(sequences) != sequences[-1]
216
+ )
217
+ try:
218
+ reserved = int(Path(f"{spool}.seq").read_text(encoding="utf-8"))
219
+ except (OSError, ValueError):
220
+ reserved = None
221
+ if reserved is not None:
222
+ if sequences:
223
+ lost = lost or reserved > sequences[-1]
224
+ else:
225
+ lost = True # a reservation exists but no record survived
226
+ return ToolTelemetry(
227
+ source=source,
228
+ complete=not incomplete and malformed == 0 and not lost,
229
+ calls=tuple(sorted(calls, key=lambda call: call.sequence)),
230
+ malformed_count=malformed,
231
+ )
syncmill/terminal.py ADDED
@@ -0,0 +1,245 @@
1
+ """The one place syncmill writes to the terminal.
2
+
3
+ Everything the CLI prints may quote a stored record, an agent's output, a
4
+ provider's error, a configured path or an exception, and any of them can carry
5
+ characters a terminal interprets (C1 CSI, OSC sequences, bidi overrides, CR).
6
+ ``echo``/``secho`` pass every message through ``printable_text`` before click
7
+ styles and writes it, so colour still works and line breaks survive while
8
+ nothing else non-printable reaches the terminal. ``echo_patch`` is the one
9
+ writer that can be byte-exact, and only off a terminal; a test pins where it may
10
+ be used and that nothing else in the package writes to the terminal directly.
11
+
12
+ ``install_console_guards`` covers the interpreter's report of an uncaught
13
+ exception, in any thread, and syncmill's own log records; ``EscapingArgumentParser``
14
+ covers argparse's own output.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import io
21
+ import logging
22
+ import os
23
+ import shlex
24
+ import sys
25
+ import threading
26
+ import traceback
27
+ from collections.abc import Sequence
28
+ from typing import Any
29
+
30
+ import click
31
+
32
+ from syncmill.artifacts import printable_name, printable_text
33
+
34
+
35
+ def _escaped(message: Any) -> Any:
36
+ """The display form of anything echo may be handed. ``None`` stays (an
37
+ empty line); bytes are decoded the way a stored patch is, so an invalid
38
+ byte shows as ``\\xNN``; any other object is shown as ``str()`` -- click
39
+ would stringify it anyway, after this point, unescaped."""
40
+ if message is None:
41
+ return None
42
+ if isinstance(message, (bytes, bytearray)):
43
+ message = bytes(message).decode("utf-8", errors="surrogateescape")
44
+ return printable_text(str(message))
45
+
46
+
47
+ def echo(message: Any = None, **kwargs: Any) -> None:
48
+ click.echo(_escaped(message), **kwargs)
49
+
50
+
51
+ def secho(message: Any = None, **kwargs: Any) -> None:
52
+ click.secho(_escaped(message), **kwargs)
53
+
54
+
55
+ NO_COMMAND = "a value has characters that cannot be shown, so no command is printed"
56
+
57
+
58
+ def shell_arg(value: str) -> str | None:
59
+ """``value`` quoted for a command the user copies, or ``None`` when the
60
+ terminal cannot show it unchanged: an escaped argument in a printed command
61
+ names a different file, run or session, so such a command is not printed."""
62
+ return shlex.quote(value) if printable_name(value) == value else None
63
+
64
+
65
+ def printable_command(
66
+ *words: object, args: Sequence[object] = (), options: Sequence[object] = ()
67
+ ) -> str:
68
+ """One copy-paste command line: the fixed ``words``, the positional
69
+ ``args``, then ``options``, each quoted; or a short notice when any of them
70
+ cannot be shown unchanged (see ``shell_arg``).
71
+
72
+ A positional value that starts with ``-`` would be parsed as an option
73
+ (``--discard=x`` selects a different, destructive action), so then the
74
+ options come first and ``--`` ends option parsing before the values."""
75
+ positional = [str(a) for a in args]
76
+ if any(a.startswith("-") for a in positional):
77
+ argv = [*map(str, words), *map(str, options), "--", *positional]
78
+ else:
79
+ argv = [*map(str, words), *positional, *map(str, options)]
80
+ quoted = [shell_arg(word) for word in argv]
81
+ if any(word is None for word in quoted):
82
+ return f"({NO_COMMAND})"
83
+ return " ".join(quoted)
84
+
85
+
86
+ def _stdout_is_terminal() -> bool:
87
+ return sys.stdout is not None and sys.stdout.isatty()
88
+
89
+
90
+ def echo_patch(data: bytes) -> None:
91
+ """Print a stored patch: escaped at a terminal, as ``echo`` shows any
92
+ bytes; otherwise the stored bytes unchanged, because redirected output is
93
+ data (``> fix.patch``, ``| git apply``). Bytes reach click's binary
94
+ writer, which neither decodes them nor strips ANSI sequences off a
95
+ terminal, as it does for text."""
96
+ if not isinstance(data, (bytes, bytearray)):
97
+ raise TypeError(f"a patch is bytes, not {type(data).__name__}")
98
+ if _stdout_is_terminal():
99
+ echo(data, nl=False)
100
+ else:
101
+ click.echo(bytes(data), nl=False)
102
+
103
+
104
+ class EscapedStderr(io.TextIOBase):
105
+ """A text stream that writes to whatever ``sys.stderr`` is, escaped, for a
106
+ library that prints through a stream of its own (the TUI's error console)."""
107
+
108
+ def writable(self) -> bool:
109
+ return True
110
+
111
+ def write(self, text: str) -> int:
112
+ stream = sys.stderr
113
+ if stream is not None:
114
+ stream.write(printable_text(text))
115
+ return len(text)
116
+
117
+ def flush(self) -> None:
118
+ stream = sys.stderr
119
+ if stream is not None:
120
+ stream.flush()
121
+
122
+
123
+ class EscapingArgumentParser(argparse.ArgumentParser):
124
+ """argparse writes usage, help and errors itself (then exits), outside
125
+ click; subparsers inherit the class, so they are covered too."""
126
+
127
+ def _print_message(self, message: str, file=None) -> None:
128
+ super()._print_message(printable_text(message) if message else message, file)
129
+
130
+
131
+ def _escaping_excepthook(exc_type, exc, tb) -> None:
132
+ text = "".join(traceback.format_exception(exc_type, exc, tb))
133
+ sys.stderr.write(printable_text(text))
134
+
135
+
136
+ def _escaping_thread_excepthook(args: threading.ExceptHookArgs) -> None:
137
+ """``threading.excepthook``'s report, escaped, thread name included."""
138
+ if args.exc_type is SystemExit:
139
+ return
140
+ stream = sys.stderr
141
+ if stream is None: # interpreter shutdown, as the default hook allows
142
+ return
143
+ name = args.thread.name if args.thread is not None else threading.get_ident()
144
+ text = "".join(
145
+ traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)
146
+ )
147
+ stream.write(printable_text(f"Exception in thread {name}:\n{text}"))
148
+
149
+
150
+ class _EscapingFormatter(logging.Formatter):
151
+ """lastResort's format (the message, then any traceback or stack), escaped.
152
+
153
+ The message is one line: ``printable_name`` shows an LF in an id, a path or
154
+ an exception string as ``\\x0a``, so an argument cannot forge a log line of
155
+ its own (#198). The traceback and stack keep their lines: the finished
156
+ string is escaped with ``printable_text``, which also covers a traceback
157
+ another formatter cached on the record first. ``msg`` and ``args`` are left
158
+ as they are."""
159
+
160
+ def formatMessage(self, record: logging.LogRecord) -> str:
161
+ return printable_name(super().formatMessage(record))
162
+
163
+ def format(self, record: logging.LogRecord) -> str:
164
+ return printable_text(super().format(record))
165
+
166
+
167
+ class _ConsoleLogHandler(logging.StreamHandler):
168
+ """Where syncmill's own log records are printed: like ``logging.lastResort``
169
+ it writes to whatever ``sys.stderr`` is when the record arrives (CliRunner,
170
+ pytest and Textual swap it), but everything it writes is escaped, including
171
+ the report of a record that failed to format."""
172
+
173
+ def __init__(self) -> None:
174
+ logging.Handler.__init__(self)
175
+ self.setFormatter(_EscapingFormatter())
176
+
177
+ @property
178
+ def stream(self):
179
+ return sys.stderr
180
+
181
+ def handleError(self, record: logging.LogRecord) -> None:
182
+ """The stdlib report (``logging.Handler.handleError``), built in a
183
+ buffer and escaped before it is written: the stdlib version writes the
184
+ failing exception and the record's ``msg``/``args`` to ``sys.stderr``
185
+ unescaped."""
186
+ stream = sys.stderr
187
+ if not (logging.raiseExceptions and stream):
188
+ return
189
+ t, v, tb = sys.exc_info()
190
+ try:
191
+ buf = io.StringIO()
192
+ buf.write("--- Logging error ---\n")
193
+ traceback.print_exception(t, v, tb, None, buf)
194
+ buf.write("Call stack:\n")
195
+ # The first frame outside the logging package is the caller.
196
+ frame = tb.tb_frame if tb is not None else None
197
+ while frame and os.path.dirname(frame.f_code.co_filename) == _LOGGING_DIR:
198
+ frame = frame.f_back
199
+ if frame:
200
+ traceback.print_stack(frame, file=buf)
201
+ else:
202
+ buf.write(f"Logged from file {record.filename}, line {record.lineno}\n")
203
+ try:
204
+ buf.write("Message: %r\nArguments: %s\n" % (record.msg, record.args))
205
+ except RecursionError:
206
+ raise
207
+ except Exception:
208
+ buf.write(
209
+ "Unable to print the message and arguments - possible formatting "
210
+ "error.\nUse the traceback above to help find the error.\n"
211
+ )
212
+ stream.write(printable_text(buf.getvalue()))
213
+ except OSError:
214
+ pass
215
+ finally:
216
+ del t, v, tb
217
+
218
+
219
+ _LOGGING_DIR = os.path.dirname(logging.__file__)
220
+
221
+
222
+ def _install_log_handler() -> None:
223
+ """Print syncmill's records through ``_ConsoleLogHandler`` only. With
224
+ ``propagate`` off they never reach a handler someone else installed on the
225
+ root (``serve``'s RichHandler), so no other handler, formatter or filter
226
+ ever sees, or has to be guessed about, a syncmill record, and nothing
227
+ outside the ``syncmill`` hierarchy is touched. The handler has no level of
228
+ its own: a record prints at its logger's effective level, as it did through
229
+ the root's handlers, rather than at lastResort's fixed WARNING."""
230
+ logger = logging.getLogger("syncmill")
231
+ if not any(isinstance(h, _ConsoleLogHandler) for h in logger.handlers):
232
+ logger.addHandler(_ConsoleLogHandler())
233
+ logger.propagate = False
234
+
235
+
236
+ def install_console_guards() -> None:
237
+ """Escape the interpreter's report of an uncaught exception, in the main
238
+ thread or any other, and every record logged under ``syncmill``. Idempotent.
239
+ Other libraries' log records (memtomem, mcp, uvicorn, asyncio) are not
240
+ covered: escaping inside stdlib logging means guessing what each handler,
241
+ formatter and filter reads, and attempts broke third-party formatters
242
+ (#202). Click's own help and usage text is not covered either."""
243
+ sys.excepthook = _escaping_excepthook
244
+ threading.excepthook = _escaping_thread_excepthook
245
+ _install_log_handler()