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
syncmill/room_viewer.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Read-only tmux viewer for a comparison-room round (#0.1.2 Phase 2).
|
|
2
|
+
|
|
3
|
+
A room round already tees each member's output to a private bounded log
|
|
4
|
+
(:class:`syncmill.rooms.RoundLogSink`). This module surfaces those logs live:
|
|
5
|
+
``room attach`` (and the TUI Rooms ``t`` key) open a **read-only** tmux session
|
|
6
|
+
with one ``tail -F`` pane per member. It is observation-only — the panes run
|
|
7
|
+
``tail``, never a provider — so there is no steering here (interactive steering
|
|
8
|
+
stays in ``session``).
|
|
9
|
+
|
|
10
|
+
It is deliberately a flat top-level module (the gateway/monitor/session/board
|
|
11
|
+
precedent) that **reuses monitor's tmux primitives** (``_tmux``/``_tmux_attach``/
|
|
12
|
+
``tmux_available``) rather than reimplementing them, and follows session.py's
|
|
13
|
+
ownership discipline: a deterministic name is the reattach key, and lifecycle
|
|
14
|
+
operations target EXACT names (``=name``) so they can never touch a foreign
|
|
15
|
+
session. Two invariants carry over from session.py: it is **not** an agent and
|
|
16
|
+
**not** a strategy (it touches neither agents nor memory — only tmux and the
|
|
17
|
+
room store's already-written logs), and — unlike the round itself, whose
|
|
18
|
+
execution never touches tmux — an explicit attach with no tmux available is a
|
|
19
|
+
hard error (this IS the tmux feature, so there is no fallback).
|
|
20
|
+
|
|
21
|
+
Ownership is **project-scoped**. One tmux server is shared across every project
|
|
22
|
+
on a host, so the session name carries the project id: reclaiming viewers for
|
|
23
|
+
project B must never touch project A's live sessions. A sweep only ever
|
|
24
|
+
considers names under *this* project's prefix, and a per-room kill scopes to
|
|
25
|
+
*this* room's prefix — the project id + uuid4 room id together are the
|
|
26
|
+
independent ownership record, not a bare pattern match.
|
|
27
|
+
|
|
28
|
+
Round execution is untouched: the viewer only ever runs on an explicit user
|
|
29
|
+
attach, reading logs the round already wrote, so a tmux failure here can never
|
|
30
|
+
affect a running round.
|
|
31
|
+
|
|
32
|
+
Two attach state machines, deliberately separate because their lifecycles
|
|
33
|
+
differ:
|
|
34
|
+
|
|
35
|
+
* **External** (not inside tmux) — a FOREGROUND ``attach-session`` that blocks
|
|
36
|
+
until the user detaches (``Ctrl-b d``); the caller (the CLI, or the TUI under
|
|
37
|
+
``App.suspend``) regains control on return.
|
|
38
|
+
* **Inside tmux** (``$TMUX`` set) — a non-blocking ``switch-client -E -t
|
|
39
|
+
=<session>``; nesting an attach is refused by tmux, and switch does not block,
|
|
40
|
+
so there is no suspend/return dance — the user returns with ``Prefix + L``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
from hashlib import sha256
|
|
48
|
+
from typing import TYPE_CHECKING
|
|
49
|
+
from uuid import uuid4
|
|
50
|
+
|
|
51
|
+
from syncmill.command_errors import command_error
|
|
52
|
+
from syncmill.monitor import _tmux, _tmux_attach, tmux_available
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING: # a type hint only — importing at runtime would make rooms.py,
|
|
55
|
+
# which drives teardown from the room lifecycle, import a module that imports
|
|
56
|
+
# it back. Keeping it type-only makes the runtime graph rooms → room_viewer.
|
|
57
|
+
from syncmill.rooms import RoomStore
|
|
58
|
+
|
|
59
|
+
_VIEWER_PREFIX = "syncmill-room-"
|
|
60
|
+
|
|
61
|
+
#: A tmux user option stamped on every viewer we create, carrying the project
|
|
62
|
+
#: key. Verified before ANY kill so a coincidental same-named user session (or a
|
|
63
|
+
#: session from a project whose key collides) is never reclaimed — the name is
|
|
64
|
+
#: the scope, this marker is the ownership PROOF.
|
|
65
|
+
_OWNER_OPTION = "@syncmill_room_viewer"
|
|
66
|
+
|
|
67
|
+
#: Terminal room states whose viewers a sweep may reclaim / a create must refuse.
|
|
68
|
+
_TERMINAL_ROOM_STATES = {"applied", "closed", "stale"}
|
|
69
|
+
|
|
70
|
+
#: A tmux session id is ``$`` followed by digits. We validate the id parsed from
|
|
71
|
+
#: ``new-session -F`` against this in full — a bare ``startswith("$")`` would let a
|
|
72
|
+
#: degenerate value like ``$`` or ``$x`` through into set-option/kill targets.
|
|
73
|
+
_SESSION_ID_RE = re.compile(r"\$\d+\Z")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RoomViewerError(RuntimeError):
|
|
77
|
+
"""A room viewer could not be opened. Like ``SessionLaunchError``, this has no
|
|
78
|
+
fallback — the viewer IS the tmux feature — so the CLI surfaces it directly."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _project_digest(project_id: str) -> str:
|
|
82
|
+
"""The full SHA-256 of the raw project id, stored in the ownership marker so
|
|
83
|
+
the ownership check does not lean on the truncated name key."""
|
|
84
|
+
return sha256(project_id.encode("utf-8", errors="surrogateescape")).hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _project_key(project_id: str) -> str:
|
|
88
|
+
"""A tmux-name-safe, collision-resistant key for a project. A lossy character
|
|
89
|
+
substitution collides (``team/a`` and ``team:a`` map to one name, so a sweep
|
|
90
|
+
of one would kill the other's viewers), so this is a digest of the RAW id.
|
|
91
|
+
The name uses a short prefix of the digest; the full digest lives in the
|
|
92
|
+
ownership marker (:func:`_project_digest`) as the authoritative check."""
|
|
93
|
+
return _project_digest(project_id)[:16]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _exact(name: str) -> str:
|
|
97
|
+
"""A tmux ``target-session`` spec forcing EXACT matching (session.py's rule):
|
|
98
|
+
tmux resolves a raw target exact-THEN-prefix, so ``=name`` is what keeps a
|
|
99
|
+
lookup from matching a longer foreign session when the exact one is absent.
|
|
100
|
+
Used for SESSION targets (``has-session``/``kill-session``/``attach``/``switch``)."""
|
|
101
|
+
return f"={name}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _project_prefix(project_id: str) -> str:
|
|
105
|
+
"""The prefix every viewer session of one project shares — the project-scope
|
|
106
|
+
ownership boundary a sweep must never cross."""
|
|
107
|
+
return f"{_VIEWER_PREFIX}{_project_key(project_id)}-"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def viewer_session_name(project_id: str, room_id: str, round_number: int) -> str:
|
|
111
|
+
"""The deterministic tmux session name for a room round — the reattach key.
|
|
112
|
+
|
|
113
|
+
Carries a collision-resistant project key so one host's shared tmux server
|
|
114
|
+
can host viewers for many projects without a sweep in one reclaiming
|
|
115
|
+
another's live sessions. Room ids are uuid4 (already tmux-safe).
|
|
116
|
+
"""
|
|
117
|
+
return f"{_project_prefix(project_id)}{room_id}-r{round_number}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _room_prefix(project_id: str, room_id: str) -> str:
|
|
121
|
+
"""The prefix scoping a per-room kill to exactly this room's viewers. The
|
|
122
|
+
trailing ``-r`` (round marker) prevents matching a room whose id is a prefix
|
|
123
|
+
of another — room ids are uuid4, so this is exact to the room."""
|
|
124
|
+
return f"{_project_prefix(project_id)}{room_id}-r"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _session_exists(name: str) -> bool:
|
|
128
|
+
return _tmux("has-session", "-t", _exact(name)).returncode == 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _session_id(name: str) -> str | None:
|
|
132
|
+
"""The tmux ``session_id`` (``$N``) of the session currently named ``name``,
|
|
133
|
+
or ``None`` if absent. This binds an inspect-then-kill to ONE session
|
|
134
|
+
instance: a stale rebuild that kills by id can never hit a fresh replacement
|
|
135
|
+
that took the name in between (killing by name would). ``list-sessions``
|
|
136
|
+
(unlike ``display-message``) needs no attached client."""
|
|
137
|
+
res = _tmux("list-sessions", "-F", "#{session_name} #{session_id}")
|
|
138
|
+
if res.returncode != 0:
|
|
139
|
+
return None
|
|
140
|
+
for line in res.stdout.splitlines():
|
|
141
|
+
parts = line.split()
|
|
142
|
+
if len(parts) == 2 and parts[0] == name:
|
|
143
|
+
return parts[1]
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _inspect_instance(session_id: str, project_id: str) -> tuple[bool, int] | None:
|
|
148
|
+
"""``(owned, pane_count)`` for a specific session INSTANCE (by id), or ``None``
|
|
149
|
+
if that instance has DISAPPEARED between resolution and inspection.
|
|
150
|
+
|
|
151
|
+
Distinguishing "gone" from "exists but unowned" is load-bearing: a session
|
|
152
|
+
that vanishes mid-inspection must be retried/recreated, not reported as a
|
|
153
|
+
foreign-name collision. The ``-q`` flag is essential — WITHOUT it tmux also
|
|
154
|
+
returns non-zero for an UNSET option, so a stable unowned session would look
|
|
155
|
+
"gone" and (mis)trip the churn path. With ``-q`` an unset option is rc 0 +
|
|
156
|
+
empty, and only a missing session is non-zero — the true gone signal."""
|
|
157
|
+
owner = _tmux("show-options", "-t", f"{session_id}:", "-q", "-v", _OWNER_OPTION)
|
|
158
|
+
if owner.returncode != 0:
|
|
159
|
+
return None # instance disappeared
|
|
160
|
+
owned = owner.stdout.strip() == _project_digest(project_id)
|
|
161
|
+
panes = _tmux("list-panes", "-t", session_id, "-F", "#{pane_id}")
|
|
162
|
+
return owned, (len(panes.stdout.split()) if panes.returncode == 0 else 0)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _kill_owned_instance(name: str, project_id: str) -> bool:
|
|
166
|
+
"""Resolve ``name`` to its session id, verify OUR ownership of THAT instance,
|
|
167
|
+
and kill the SAME id. Binding the check and the kill to one id closes the
|
|
168
|
+
TOCTOU where a checked-owned session is replaced by a foreign session under
|
|
169
|
+
the same name before a name-based kill lands. Returns whether it killed."""
|
|
170
|
+
sid = _session_id(name)
|
|
171
|
+
if sid is None:
|
|
172
|
+
return False
|
|
173
|
+
# -q so an unset marker is rc 0 + empty (not-ours), not a false "gone".
|
|
174
|
+
owner = _tmux("show-options", "-t", f"{sid}:", "-q", "-v", _OWNER_OPTION)
|
|
175
|
+
if owner.returncode != 0 or owner.stdout.strip() != _project_digest(project_id):
|
|
176
|
+
return False # gone, or not ours
|
|
177
|
+
return _tmux("kill-session", "-t", sid).returncode == 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_round(store: RoomStore, room_id: str, round_number: int | None) -> dict:
|
|
181
|
+
"""The requested round (or the latest), validated to exist."""
|
|
182
|
+
rounds = store.list_rounds(room_id)
|
|
183
|
+
if not rounds:
|
|
184
|
+
raise RoomViewerError(f"room {room_id} has no rounds yet")
|
|
185
|
+
if round_number is None:
|
|
186
|
+
return rounds[-1]
|
|
187
|
+
for row in rounds:
|
|
188
|
+
if int(row["number"]) == round_number:
|
|
189
|
+
return row
|
|
190
|
+
raise RoomViewerError(f"room {room_id} has no round {round_number}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def ensure_viewer(
|
|
194
|
+
store: RoomStore,
|
|
195
|
+
project_id: str,
|
|
196
|
+
room_id: str,
|
|
197
|
+
round_number: int | None = None,
|
|
198
|
+
) -> str:
|
|
199
|
+
"""Ensure a read-only viewer session exists for a round and return its name.
|
|
200
|
+
|
|
201
|
+
Idempotent: an existing session is returned as-is (the reattach path). A
|
|
202
|
+
round with no per-member monitor rows (one that predates the progress table)
|
|
203
|
+
is "terminal unavailable" — there is nothing live to follow — and raises
|
|
204
|
+
rather than opening an empty session.
|
|
205
|
+
"""
|
|
206
|
+
if not tmux_available():
|
|
207
|
+
raise RoomViewerError("tmux is not available; the room viewer requires tmux")
|
|
208
|
+
room = store.get_room(room_id) # raises RoomError for an unknown room
|
|
209
|
+
if room["state"] in _TERMINAL_ROOM_STATES:
|
|
210
|
+
# A terminal room's viewer has nothing live to follow; refuse to recreate
|
|
211
|
+
# one (else a sweep-then-attach would resurrect it every time).
|
|
212
|
+
raise RoomViewerError(f"room is {room['state']}; nothing to view")
|
|
213
|
+
round_row = _resolve_round(store, room_id, round_number)
|
|
214
|
+
members = store.list_round_members(round_row["id"])
|
|
215
|
+
if not members:
|
|
216
|
+
raise RoomViewerError(
|
|
217
|
+
f"round {round_row['number']} has no monitor data "
|
|
218
|
+
"(it predates per-member progress); terminal unavailable"
|
|
219
|
+
)
|
|
220
|
+
name = viewer_session_name(project_id, room_id, int(round_row["number"]))
|
|
221
|
+
# Resolve every member log up front — for BOTH paths. The reattach
|
|
222
|
+
# early-return below must not bypass _create_viewer's fail-closed log
|
|
223
|
+
# validation: a log that no longer resolves (DB drift, a symlink swapped
|
|
224
|
+
# in) invalidates any existing viewer. Valid logs are also hardened here
|
|
225
|
+
# (0700/0600) so a log seeded permissive by an older syncmill is
|
|
226
|
+
# tightened on reattach, not only on rebuild.
|
|
227
|
+
resolved_logs = [
|
|
228
|
+
_resolve_member_log(store, room_id, round_row["id"], member)
|
|
229
|
+
for member in members
|
|
230
|
+
]
|
|
231
|
+
logs_valid = all(path is not None for path in resolved_logs)
|
|
232
|
+
_harden_member_logs(resolved_logs)
|
|
233
|
+
# Bind the ownership/completeness checks AND the rebuild-kill to ONE instance
|
|
234
|
+
# (by session id): a concurrent caller that already replaced the name has a
|
|
235
|
+
# different id, so our stale kill targets the gone instance, never its valid
|
|
236
|
+
# replacement (killing by name would clobber it). A session that vanishes
|
|
237
|
+
# mid-inspection is re-resolved under a bounded retry rather than mistaken
|
|
238
|
+
# for a foreign-name collision.
|
|
239
|
+
for _ in range(4):
|
|
240
|
+
sid = _session_id(name)
|
|
241
|
+
if sid is None:
|
|
242
|
+
break # no session under the name → create
|
|
243
|
+
inspected = _inspect_instance(sid, project_id)
|
|
244
|
+
if inspected is None:
|
|
245
|
+
continue # the instance vanished mid-inspection → re-resolve
|
|
246
|
+
owned, panes = inspected
|
|
247
|
+
if owned and panes == len(members) and logs_valid:
|
|
248
|
+
# Ours, complete, and every log still resolves → reattach.
|
|
249
|
+
return name
|
|
250
|
+
if owned:
|
|
251
|
+
# Ours but incomplete (a pane exited, a prior build was killed
|
|
252
|
+
# mid-flight) OR its logs no longer resolve (a stale viewer
|
|
253
|
+
# tailing invalid paths): kill THIS instance — never by name —
|
|
254
|
+
# and fall through to _create_viewer, whose fail-closed log
|
|
255
|
+
# validation rejects the invalid case.
|
|
256
|
+
_tmux("kill-session", "-t", sid)
|
|
257
|
+
break
|
|
258
|
+
# Not ours. Only raise if the SAME unowned instance still holds the name;
|
|
259
|
+
# if the name was taken over by a different instance in between (e.g. a
|
|
260
|
+
# valid owned creator just published), re-inspect the replacement rather
|
|
261
|
+
# than report a stale collision.
|
|
262
|
+
if _session_id(name) == sid:
|
|
263
|
+
raise RoomViewerError(
|
|
264
|
+
f"a session named {name!r} exists but is not a syncmill viewer; refusing"
|
|
265
|
+
)
|
|
266
|
+
continue
|
|
267
|
+
else:
|
|
268
|
+
# A persistently-churning unowned name (never the same instance twice):
|
|
269
|
+
# do not attach or clobber it — refuse.
|
|
270
|
+
raise RoomViewerError(
|
|
271
|
+
f"a session named {name!r} keeps being replaced by non-viewer sessions; refusing"
|
|
272
|
+
)
|
|
273
|
+
_create_viewer(store, project_id, name, room_id, round_row, members)
|
|
274
|
+
return name
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _harden_member_logs(paths: list[str | None]) -> None:
|
|
278
|
+
"""Best-effort 0700/0600 tightening of existing resolved member logs.
|
|
279
|
+
|
|
280
|
+
``RoundLogSink._open``'s ``O_CREAT`` mode is ignored for a pre-existing
|
|
281
|
+
file, so a log/dir created permissive by an older version stays that way
|
|
282
|
+
until explicitly chmodded. ``None`` entries (invalid paths) are skipped —
|
|
283
|
+
the caller separately fail-closes on them; a missing file is fine — the
|
|
284
|
+
sink or the seeding loop creates it privately later.
|
|
285
|
+
"""
|
|
286
|
+
for path in paths:
|
|
287
|
+
if path is None:
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
os.chmod(os.path.dirname(path), 0o700)
|
|
291
|
+
except OSError:
|
|
292
|
+
pass
|
|
293
|
+
try:
|
|
294
|
+
os.chmod(path, 0o600)
|
|
295
|
+
except OSError:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _resolve_member_log(
|
|
300
|
+
store: RoomStore, room_id: str, round_id: str, member: dict
|
|
301
|
+
) -> str | None:
|
|
302
|
+
"""Fail-closed absolute log path for a member, or ``None`` if invalid.
|
|
303
|
+
|
|
304
|
+
The path is RECOMPUTED from authoritative room/round/member ids (never the
|
|
305
|
+
stored string is trusted), required to equal the stored ``log_path``, and
|
|
306
|
+
bound to the exact round directory with NO symlink indirection — so an
|
|
307
|
+
absolute/``..``/symlinked ``log_path`` in the DB, or an in-root symlink that
|
|
308
|
+
would rebind the member to another file, can never make the viewer tail
|
|
309
|
+
outside the round's own tree.
|
|
310
|
+
"""
|
|
311
|
+
stored = member.get("log_path")
|
|
312
|
+
canonical = store.round_log_relpath(room_id, round_id, member["member_id"])
|
|
313
|
+
if stored != canonical:
|
|
314
|
+
return None # drift from the authoritative path → do not follow it
|
|
315
|
+
root = store.candidates.root.resolve()
|
|
316
|
+
expected = root / canonical
|
|
317
|
+
# Reject the leaf being a symlink, and any symlink in the chain (realpath
|
|
318
|
+
# resolves every component, so a divergence means a symlink was traversed).
|
|
319
|
+
if expected.is_symlink() or os.path.realpath(expected) != str(expected):
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
expected.relative_to(root)
|
|
323
|
+
except ValueError:
|
|
324
|
+
return None
|
|
325
|
+
if expected.exists() and not expected.is_file():
|
|
326
|
+
return None
|
|
327
|
+
return str(expected)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _terminal_room_state(store: RoomStore, room_id: str) -> str | None:
|
|
331
|
+
"""The room's state if it is terminal, else ``None``; never raises.
|
|
332
|
+
|
|
333
|
+
Used to re-read the authoritative state when a build step fails: a terminal
|
|
334
|
+
transition (close/apply/prune) sweeps our temp session mid-build, and the
|
|
335
|
+
resulting raw tmux error ("name in use", a split failure) describes the
|
|
336
|
+
symptom rather than the cause. A store failure here yields ``None`` so the
|
|
337
|
+
original tmux error still surfaces.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
state = store.get_room(room_id)["state"]
|
|
341
|
+
except Exception:
|
|
342
|
+
return None
|
|
343
|
+
return state if state in _TERMINAL_ROOM_STATES else None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _create_viewer(
|
|
347
|
+
store: RoomStore,
|
|
348
|
+
project_id: str,
|
|
349
|
+
name: str,
|
|
350
|
+
room_id: str,
|
|
351
|
+
round_row: dict,
|
|
352
|
+
members: list[dict],
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Build the read-only session ATOMICALLY under a temp name, then rename it to
|
|
355
|
+
the final name only once complete.
|
|
356
|
+
|
|
357
|
+
Two properties this guarantees: (a) the final name never names a partial
|
|
358
|
+
session — a concurrent reattach that sees it can safely attach; (b) if ANY
|
|
359
|
+
member's log is invalid or ANY pane fails, the whole build is torn down and
|
|
360
|
+
the create raises — a viewer never silently drops a member.
|
|
361
|
+
"""
|
|
362
|
+
logs: list[tuple[str, str]] = []
|
|
363
|
+
for member in members:
|
|
364
|
+
path = _resolve_member_log(store, room_id, round_row["id"], member)
|
|
365
|
+
if path is None:
|
|
366
|
+
# Fail the whole viewer, not just this member — a partial viewer that
|
|
367
|
+
# silently omits a member reads as complete and misleads the reviewer.
|
|
368
|
+
raise RoomViewerError(
|
|
369
|
+
f"member {member['member_id']} has no valid log; refusing a partial viewer"
|
|
370
|
+
)
|
|
371
|
+
logs.append((member["member_id"], path))
|
|
372
|
+
# Seed each log so `tail -F` opens cleanly even before the first write.
|
|
373
|
+
# Use RoundLogSink._open's modes (0700 dir, 0600 file): O_CREAT's mode is
|
|
374
|
+
# ignored for a pre-existing file, so a umask-default seed would leave the
|
|
375
|
+
# member's private provider-output log group/world-readable for its whole
|
|
376
|
+
# life once the sink later opens it.
|
|
377
|
+
for _, path in logs:
|
|
378
|
+
try:
|
|
379
|
+
log_dir = os.path.dirname(path)
|
|
380
|
+
os.makedirs(log_dir, exist_ok=True, mode=0o700)
|
|
381
|
+
os.close(os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600))
|
|
382
|
+
# mkdir/open modes only apply to newly created paths — tighten
|
|
383
|
+
# pre-existing ones (e.g. seeded by an older syncmill at 0755/0644)
|
|
384
|
+
# so the private log never stays group/world-readable.
|
|
385
|
+
os.chmod(log_dir, 0o700)
|
|
386
|
+
os.chmod(path, 0o600)
|
|
387
|
+
except OSError:
|
|
388
|
+
pass # best-effort; tail -F tolerates a not-yet-existent file
|
|
389
|
+
temp = f"{name}-mk{uuid4().hex[:8]}"
|
|
390
|
+
first_member, first_log = logs[0]
|
|
391
|
+
# Capture OUR session id up front: every teardown of the session we build
|
|
392
|
+
# (build-failure, terminal-publication) targets THIS id, never the name, so a
|
|
393
|
+
# foreign session that later claims the deterministic name is never killed by
|
|
394
|
+
# us. The temp name is random, but the rename preserves the id, so the id is
|
|
395
|
+
# the one stable handle across the whole build.
|
|
396
|
+
created = _tmux(
|
|
397
|
+
"new-session", "-d", "-s", temp, "-P", "-F", "#{session_id} #{pane_id}",
|
|
398
|
+
"tail", "-n", "+1", "-F", first_log,
|
|
399
|
+
)
|
|
400
|
+
if created.returncode != 0:
|
|
401
|
+
raise command_error(
|
|
402
|
+
RoomViewerError, "tmux new-session failed for the room viewer", created.stderr
|
|
403
|
+
)
|
|
404
|
+
parts = created.stdout.split()
|
|
405
|
+
our_sid = parts[0] if parts else _session_id(temp)
|
|
406
|
+
if not our_sid or not _SESSION_ID_RE.match(our_sid):
|
|
407
|
+
# -F should always print the session id ($N); if it somehow did not and
|
|
408
|
+
# the temp is already gone, we have no stable handle — tear down by the
|
|
409
|
+
# (unique) temp name and fail cleanly rather than pass None into tmux.
|
|
410
|
+
_tmux("kill-session", "-t", _exact(temp))
|
|
411
|
+
raise RoomViewerError("could not resolve the new viewer session id")
|
|
412
|
+
first_pane = parts[1] if len(parts) > 1 else ""
|
|
413
|
+
try:
|
|
414
|
+
# Stamp ownership FIRST and require it to succeed: an unmarked session
|
|
415
|
+
# would be unreclaimable by close/sweep and unrecognizable as ours. The
|
|
416
|
+
# marker (the full project digest) is addressed via the session id since
|
|
417
|
+
# set-option rejects a bare =session; it survives the rename.
|
|
418
|
+
stamped = _tmux("set-option", "-t", f"{our_sid}:", _OWNER_OPTION, _project_digest(project_id))
|
|
419
|
+
if stamped.returncode != 0:
|
|
420
|
+
raise command_error(
|
|
421
|
+
RoomViewerError, "failed to stamp viewer ownership", stamped.stderr
|
|
422
|
+
)
|
|
423
|
+
_tmux("set-option", "-t", f"{our_sid}:", "pane-border-status", "top")
|
|
424
|
+
_tmux("set-option", "-t", f"{our_sid}:", "pane-border-format", " #{pane_title} ")
|
|
425
|
+
if first_pane:
|
|
426
|
+
_tmux("select-pane", "-t", first_pane, "-T", f"{first_member} (round {round_row['number']})")
|
|
427
|
+
for member_id, path in logs[1:]:
|
|
428
|
+
res = _tmux(
|
|
429
|
+
"split-window", "-t", f"{our_sid}:", "-d", "-P", "-F", "#{pane_id}",
|
|
430
|
+
"tail", "-n", "+1", "-F", path,
|
|
431
|
+
)
|
|
432
|
+
if res.returncode != 0:
|
|
433
|
+
raise command_error(
|
|
434
|
+
RoomViewerError, f"failed to add a viewer pane for {member_id}", res.stderr
|
|
435
|
+
)
|
|
436
|
+
pane_id = res.stdout.strip()
|
|
437
|
+
if pane_id:
|
|
438
|
+
_tmux("select-pane", "-t", pane_id, "-T", member_id)
|
|
439
|
+
_tmux("select-layout", "-t", f"{our_sid}:", "tiled")
|
|
440
|
+
except Exception:
|
|
441
|
+
_tmux("kill-session", "-t", our_sid) # our instance, by id
|
|
442
|
+
# A terminal transition that swept our temp mid-build is the CAUSE of the
|
|
443
|
+
# tmux failure we are holding; report it as such (same wording as the
|
|
444
|
+
# pre-check) rather than the raw split/pane error it manifested as.
|
|
445
|
+
terminal = _terminal_room_state(store, room_id)
|
|
446
|
+
if terminal is not None:
|
|
447
|
+
raise RoomViewerError(f"room is {terminal}; nothing to view") from None
|
|
448
|
+
raise
|
|
449
|
+
except BaseException:
|
|
450
|
+
_tmux("kill-session", "-t", our_sid) # our instance, by id
|
|
451
|
+
raise
|
|
452
|
+
# Publish atomically: the final name appears only now, fully built.
|
|
453
|
+
renamed = _tmux("rename-session", "-t", our_sid, name)
|
|
454
|
+
if renamed.returncode != 0:
|
|
455
|
+
# A concurrent creator won the race and already published the final name;
|
|
456
|
+
# drop our complete-but-duplicate instance (by id) and accept the winner
|
|
457
|
+
# only after validating IT — resolved once to a sid — is a complete, owned
|
|
458
|
+
# viewer, never a squatting foreign name (which two name-based lookups
|
|
459
|
+
# could straddle a replacement).
|
|
460
|
+
_tmux("kill-session", "-t", our_sid)
|
|
461
|
+
winner_sid = _session_id(name)
|
|
462
|
+
inspected = _inspect_instance(winner_sid, project_id) if winner_sid else None
|
|
463
|
+
if inspected is None or not (inspected[0] and inspected[1] == len(members)):
|
|
464
|
+
terminal = _terminal_room_state(store, room_id)
|
|
465
|
+
if terminal is not None:
|
|
466
|
+
# Not a lost race — a terminal transition swept our temp session
|
|
467
|
+
# before the rename. Say so, matching ensure_viewer's pre-check.
|
|
468
|
+
raise RoomViewerError(f"room is {terminal}; nothing to view")
|
|
469
|
+
raise command_error(
|
|
470
|
+
RoomViewerError, "failed to publish the viewer (name in use)", renamed.stderr
|
|
471
|
+
)
|
|
472
|
+
return
|
|
473
|
+
# A terminal transition (close/apply/prune) can race publication: it may have
|
|
474
|
+
# swept the temp just before the rename, then found nothing. Re-read the
|
|
475
|
+
# authoritative state and reclaim OUR just-published instance (by id, so a
|
|
476
|
+
# foreign session that took the name is never killed) if the room is terminal.
|
|
477
|
+
try:
|
|
478
|
+
if store.get_room(room_id)["state"] in _TERMINAL_ROOM_STATES:
|
|
479
|
+
_tmux("kill-session", "-t", our_sid)
|
|
480
|
+
except Exception:
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def attach_viewer(name: str) -> int:
|
|
485
|
+
"""Attach to a viewer session, choosing the state machine by context.
|
|
486
|
+
|
|
487
|
+
Returns a tmux exit code (0 on a clean external detach or a successful
|
|
488
|
+
in-tmux switch). Not wrapped in any Textual suspend — the TUI caller wraps
|
|
489
|
+
THIS in ``App.suspend`` for the external path; the CLI needs no wrapper.
|
|
490
|
+
"""
|
|
491
|
+
if in_tmux():
|
|
492
|
+
# Inside tmux: nesting an attach is refused, and switch does not block,
|
|
493
|
+
# so there is no suspend/return dance. -E resets to the pane env.
|
|
494
|
+
return _tmux("switch-client", "-E", "-t", _exact(name)).returncode
|
|
495
|
+
# External: foreground attach that blocks until the user detaches.
|
|
496
|
+
return _tmux_attach(_exact(name))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def in_tmux() -> bool:
|
|
500
|
+
"""Whether this process is running inside a tmux client (selects the state
|
|
501
|
+
machine and the correct return hint to print)."""
|
|
502
|
+
return bool(os.environ.get("TMUX"))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def close_room_viewers(project_id: str, room_id: str) -> int:
|
|
506
|
+
"""Kill every viewer session for a room's rounds. Best-effort, returns the
|
|
507
|
+
count killed. Called from the room lifecycle (close/apply/prune); a missing
|
|
508
|
+
tmux server is a no-op.
|
|
509
|
+
|
|
510
|
+
Scoped to ``{project}-{room}-r``: project id + uuid4 room id are the
|
|
511
|
+
ownership record, so this can only ever match this project's viewers of this
|
|
512
|
+
exact room, never a foreign or cross-project session. No store is needed —
|
|
513
|
+
unlike :func:`sweep_orphan_viewers`, the caller has already decided the room
|
|
514
|
+
is terminal; the session names alone carry the scope.
|
|
515
|
+
"""
|
|
516
|
+
if not tmux_available():
|
|
517
|
+
return 0
|
|
518
|
+
prefix = _room_prefix(project_id, room_id)
|
|
519
|
+
killed = 0
|
|
520
|
+
for session in _list_project_viewers(project_id):
|
|
521
|
+
# Resolve name → sid, check OUR ownership of that instance, kill the same
|
|
522
|
+
# id — so a checked-owned session replaced by a foreign one under the same
|
|
523
|
+
# name before the kill is never clobbered (the TOCTOU fixed everywhere).
|
|
524
|
+
if session.startswith(prefix) and _kill_owned_instance(session, project_id):
|
|
525
|
+
killed += 1
|
|
526
|
+
return killed
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def sweep_orphan_viewers(store: RoomStore, project_id: str) -> int:
|
|
530
|
+
"""Kill THIS project's viewer sessions whose room no longer exists or is
|
|
531
|
+
terminal. Best-effort; returns the count reclaimed.
|
|
532
|
+
|
|
533
|
+
Only sessions under this project's prefix are ever considered — the
|
|
534
|
+
cross-project safety boundary — so a sweep in project B can never reclaim
|
|
535
|
+
project A's live viewers, and never touches a foreign/user/monitor session.
|
|
536
|
+
"""
|
|
537
|
+
if not tmux_available():
|
|
538
|
+
return 0
|
|
539
|
+
live = {room["id"]: room["state"] for room in store.list_rooms()}
|
|
540
|
+
sessions = _list_project_viewers(project_id)
|
|
541
|
+
present = set(sessions)
|
|
542
|
+
killed = 0
|
|
543
|
+
for session in sessions:
|
|
544
|
+
reclaim = False
|
|
545
|
+
final = _final_of_temp(session)
|
|
546
|
+
if final is not None and final in present:
|
|
547
|
+
# An abandoned build temp whose final session already published — a
|
|
548
|
+
# SIGKILL/crash mid-build leaves persistent tail processes.
|
|
549
|
+
reclaim = True
|
|
550
|
+
else:
|
|
551
|
+
room_id = _room_id_from_name(session, project_id)
|
|
552
|
+
if room_id is None:
|
|
553
|
+
continue # unparseable under our own prefix → leave it alone
|
|
554
|
+
state = live.get(room_id)
|
|
555
|
+
reclaim = state is None or state in _TERMINAL_ROOM_STATES
|
|
556
|
+
# Same sid-bound check-and-kill as close: never kill by name.
|
|
557
|
+
if reclaim and _kill_owned_instance(session, project_id):
|
|
558
|
+
killed += 1
|
|
559
|
+
return killed
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _final_of_temp(name: str) -> str | None:
|
|
563
|
+
"""If ``name`` is a build temp (``…-mk{hex}``), the final name it was building
|
|
564
|
+
toward; else ``None``. Used to reclaim a temp abandoned by a crashed build
|
|
565
|
+
once its final session exists."""
|
|
566
|
+
mk = name.rfind("-mk")
|
|
567
|
+
if mk <= 0:
|
|
568
|
+
return None
|
|
569
|
+
suffix = name[mk + 3:]
|
|
570
|
+
if suffix and all(c in "0123456789abcdef" for c in suffix):
|
|
571
|
+
return name[:mk]
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _list_project_viewers(project_id: str) -> list[str]:
|
|
576
|
+
"""Live tmux session names under THIS project's viewer prefix (``[]`` with no
|
|
577
|
+
server). The project prefix is the scope boundary every reclaim respects."""
|
|
578
|
+
res = _tmux("list-sessions", "-F", "#{session_name}")
|
|
579
|
+
if res.returncode != 0:
|
|
580
|
+
return []
|
|
581
|
+
prefix = _project_prefix(project_id)
|
|
582
|
+
return [line.strip() for line in res.stdout.splitlines() if line.strip().startswith(prefix)]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _room_id_from_name(name: str, project_id: str) -> str | None:
|
|
586
|
+
"""Recover the room_id from a viewer session name of THIS project, or ``None``.
|
|
587
|
+
|
|
588
|
+
The name is ``{project_prefix}{room_id}-r{n}``, or an in-flight temp
|
|
589
|
+
``…-r{n}-mk{hex}``. The project prefix is KNOWN (passed in) and stripped
|
|
590
|
+
exactly, so hyphens in a project id are never ambiguous. A ``-mk{hex}`` build
|
|
591
|
+
suffix is stripped first so an abandoned temp (a SIGKILL mid-build) is still
|
|
592
|
+
attributed to its room and reclaimable — then the trailing ``-r{digits}``
|
|
593
|
+
round marker is stripped (uuid4s carry no such tail)."""
|
|
594
|
+
prefix = _project_prefix(project_id)
|
|
595
|
+
if not name.startswith(prefix):
|
|
596
|
+
return None
|
|
597
|
+
body = name[len(prefix):]
|
|
598
|
+
mk = body.rfind("-mk")
|
|
599
|
+
if mk > 0 and body[mk + 3:] and all(c in "0123456789abcdef" for c in body[mk + 3:]):
|
|
600
|
+
body = body[:mk] # an abandoned build temp → attribute to its room
|
|
601
|
+
marker = body.rfind("-r")
|
|
602
|
+
if marker <= 0 or not body[marker + 2:].isdigit():
|
|
603
|
+
return None
|
|
604
|
+
return body[:marker]
|