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/teamfile.py
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Team-profile persistence — the tomlkit ``syncmill.toml`` write path (P3 TEAM pane).
|
|
2
|
+
|
|
3
|
+
The first (and only) toml-*writing* module: `team save` / `team use` and the
|
|
4
|
+
dashboard TEAM pane all funnel through these helpers, while reads stay on
|
|
5
|
+
pydantic-settings' ``TomlConfigSettingsSource``. A flat top-level module
|
|
6
|
+
(gateway/monitor/session/board/dashboard precedent): two consumers (cli +
|
|
7
|
+
dashboard) share one genuinely new concern, and putting the writers in
|
|
8
|
+
``config.py`` would mix declarative reads with imperative writes and force a
|
|
9
|
+
``config → agents`` import cycle (the unknown-agent check needs
|
|
10
|
+
``_AGENT_CONSTRUCTORS``). Import graph stays acyclic:
|
|
11
|
+
``teamfile → config, agents``; ``cli → teamfile``; ``dashboard → teamfile``.
|
|
12
|
+
|
|
13
|
+
Design rules this module owns:
|
|
14
|
+
|
|
15
|
+
- **Comment-preserving round-trip.** Every write parses the existing document
|
|
16
|
+
with tomlkit, mutates the smallest span, and dumps — comments/whitespace
|
|
17
|
+
everywhere else are preserved byte-for-byte. Replacing ``[teams.<name>]`` is
|
|
18
|
+
deliberately *wholesale* (flags define the full profile; a partial merge
|
|
19
|
+
could not express "clear goals"), so only that table's own comments are lost.
|
|
20
|
+
- **Validate against the RESOLVED config, then write toml only.** A save must
|
|
21
|
+
never produce a file that makes the next ``SyncmillConfig()`` construction
|
|
22
|
+
die (which would brick every command): ``validate_team_save`` runs the shared
|
|
23
|
+
``check_team_goals`` over ``{**config.teams, name: team}`` — the env+toml
|
|
24
|
+
merged view with the candidate substituted. For every other name the merged
|
|
25
|
+
view is unchanged by the write; for the saved name either env doesn't define
|
|
26
|
+
it (merged == the validated candidate) or env wins the deep-merge (already
|
|
27
|
+
valid, since the resolved config loaded before the save).
|
|
28
|
+
- **Atomic writes** — temp file + ``os.replace`` (the breaker/board recipe).
|
|
29
|
+
- **Serialized read-modify-write.** Atomicity alone only guarantees no reader
|
|
30
|
+
ever sees a half-written file; it does NOT stop two writers from each parsing
|
|
31
|
+
the same document and the second replace discarding the first's table (a lost
|
|
32
|
+
update — `team save` racing the dashboard/TUI TEAM pane, or `init --force`).
|
|
33
|
+
Every write therefore runs inside :func:`config_write_lock`, a BLOCKING
|
|
34
|
+
cross-process lock kept in syncmill's own state root, keyed by a digest of
|
|
35
|
+
the config's real path — DERIVED, never probed, so every writer picks the
|
|
36
|
+
same file, and never in the user's working tree (see :func:`_lock_path_for`
|
|
37
|
+
for what both of those cost to learn). Blocking, not
|
|
38
|
+
fail-fast: the critical section is a sub-millisecond parse-mutate-dump, so a
|
|
39
|
+
waiter serializes invisibly, where a busy error would turn a benign micro-race
|
|
40
|
+
into a user-visible failure. The lock is re-entrant per thread, so a caller
|
|
41
|
+
that spans validate→save (closing the stale-validation window) costs the
|
|
42
|
+
inner acquisitions nothing.
|
|
43
|
+
- ``TeamFileError`` is the single error type at the seam (``BoardError``
|
|
44
|
+
precedent): the CLI converts to ``BadParameter``/``ClickException``, the
|
|
45
|
+
dashboard mirrors it into ``last_team_error`` — this module never imports
|
|
46
|
+
click.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import hashlib
|
|
52
|
+
import os
|
|
53
|
+
import stat
|
|
54
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
55
|
+
from contextlib import ExitStack, contextmanager
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from uuid import uuid4
|
|
58
|
+
|
|
59
|
+
import tomlkit
|
|
60
|
+
from pydantic import ValidationError
|
|
61
|
+
from pydantic_settings import (
|
|
62
|
+
BaseSettings,
|
|
63
|
+
PydanticBaseSettingsSource,
|
|
64
|
+
TomlConfigSettingsSource,
|
|
65
|
+
)
|
|
66
|
+
from tomlkit.items import Table
|
|
67
|
+
|
|
68
|
+
from syncmill.agents import _AGENT_CONSTRUCTORS
|
|
69
|
+
from syncmill.artifacts import ArtifactError, _cross_process_lock, printable_name
|
|
70
|
+
from syncmill.config import SyncmillConfig, TeamConfig, check_team_goals
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TeamFileError(Exception):
|
|
74
|
+
"""A team-profile write/validation failure (parse error, invalid profile,
|
|
75
|
+
unwritable file, unobtainable lock). The single error type at this seam."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
#: Where config write locks live — syncmill's own state root, like the board db
|
|
79
|
+
#: and the breaker's state file. NOT in the repository: see :func:`_lock_path_for`.
|
|
80
|
+
_LOCK_DIR = "~/.syncmill/locks"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _lock_path_for(path: Path) -> Path:
|
|
84
|
+
"""Where ``path``'s write lock lives: syncmill's state root, keyed by config.
|
|
85
|
+
|
|
86
|
+
Two properties this has to have, which cost two earlier revisions to learn.
|
|
87
|
+
|
|
88
|
+
**Derived, never probed.** The first revision asked
|
|
89
|
+
``git rev-parse --git-common-dir`` so the lock would sit in ``.git``. That
|
|
90
|
+
made the lock's identity depend on a subprocess: a writer that cannot run
|
|
91
|
+
``git``, or a repository whose common directory is read-only, picks a
|
|
92
|
+
different file or fails outright — and two writers holding two different
|
|
93
|
+
locks serialize nothing at all. A lock whose whole purpose is that everyone
|
|
94
|
+
picks the SAME file cannot be chosen by something that can answer
|
|
95
|
+
differently per process. The key here is a digest of the config's real path
|
|
96
|
+
(``realpath``, so two routes to one file — a symlinked checkout, a bind
|
|
97
|
+
mount — share one lock).
|
|
98
|
+
|
|
99
|
+
**Out of the working tree.** The second revision put the lock beside the
|
|
100
|
+
config and had ``syncmill init`` add it to ``.git/info/exclude``. But that
|
|
101
|
+
file is machine-local: anyone cloning a repository initialized elsewhere,
|
|
102
|
+
or upgrading a project without re-running ``init``, has no such rule, and
|
|
103
|
+
their first ``team save`` drops an untracked file into ``git status`` — and
|
|
104
|
+
into every agent worktree, since snapshots capture untracked files. Nothing
|
|
105
|
+
the tool writes for its own bookkeeping belongs in the user's tree.
|
|
106
|
+
|
|
107
|
+
The tradeoff taken knowingly: this is a per-USER path, so two different OS
|
|
108
|
+
users editing one shared config are not serialized against each other. That
|
|
109
|
+
is the rare case, it degrades to the behavior that shipped before any lock
|
|
110
|
+
existed, and it buys universal correctness for the case that actually
|
|
111
|
+
happens — one person's CLI racing their own dashboard or TUI.
|
|
112
|
+
|
|
113
|
+
Raises ``TeamFileError`` when the state root cannot be resolved or created
|
|
114
|
+
(an unresolvable ``~``, an unwritable home) — the ``Board`` precedent for
|
|
115
|
+
the same state root.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
target = os.path.realpath(path)
|
|
119
|
+
root = Path(_LOCK_DIR).expanduser()
|
|
120
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
121
|
+
except (OSError, RuntimeError) as exc:
|
|
122
|
+
raise TeamFileError(f"cannot prepare the config lock directory: {exc}")
|
|
123
|
+
digest = hashlib.sha256(target.encode("utf-8")).hexdigest()
|
|
124
|
+
return root / f"{digest}.lock"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@contextmanager
|
|
128
|
+
def config_write_lock(path: Path) -> Iterator[None]:
|
|
129
|
+
"""Serialize read-modify-write cycles on ``path`` across processes.
|
|
130
|
+
|
|
131
|
+
The lock file (see :func:`_lock_path_for`) is never unlinked: removing it
|
|
132
|
+
would let a later acquirer create a *different* inode and hold a lock nobody
|
|
133
|
+
else contends on (the ``JobStore`` lock-file rule).
|
|
134
|
+
|
|
135
|
+
Acquisition BLOCKS — every writer holds it for one parse-mutate-dump, so a
|
|
136
|
+
waiter serializes invisibly, and failing fast would report contention as an
|
|
137
|
+
error the user can do nothing about. Re-entrant per thread (the underlying
|
|
138
|
+
:func:`syncmill.artifacts._cross_process_lock`), so a caller may wrap a
|
|
139
|
+
whole validate→save sequence and the writers' own acquisitions are free.
|
|
140
|
+
|
|
141
|
+
Only the ACQUISITION is wrapped into ``TeamFileError`` — the body's own
|
|
142
|
+
exceptions propagate unchanged, so a write failure is never relabelled as a
|
|
143
|
+
locking one (every existing converter, CLI → ``ClickException``, dashboard →
|
|
144
|
+
``last_team_error``, TUI → notify, ``project.py`` → ``ProjectError``, keeps
|
|
145
|
+
working because the new failure is the same error type).
|
|
146
|
+
"""
|
|
147
|
+
stack = ExitStack()
|
|
148
|
+
try:
|
|
149
|
+
stack.enter_context(_cross_process_lock(_lock_path_for(path)))
|
|
150
|
+
except (ArtifactError, OSError) as exc:
|
|
151
|
+
raise TeamFileError(f"cannot lock {path}: {exc}") from exc
|
|
152
|
+
with stack:
|
|
153
|
+
yield
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def toml_path() -> Path:
|
|
157
|
+
"""The file every helper reads and writes — derived from
|
|
158
|
+
``SyncmillConfig.model_config["toml_file"]`` so the write path is
|
|
159
|
+
read/write-symmetric with discovery by construction (``syncmill.toml``
|
|
160
|
+
discovered cwd-relative in production; the hermetic suite's autouse
|
|
161
|
+
``isolate_toml_discovery`` repoints it into ``tmp_path``, so a save test
|
|
162
|
+
can never touch a real repo-root file even if it forgets to repoint).
|
|
163
|
+
|
|
164
|
+
ABSOLUTIZED against the cwd at call time: consumers hold the returned path
|
|
165
|
+
across their lifetime (the dashboard/TUI anchor ``_toml_path.parent`` for
|
|
166
|
+
project roots and worker launches), and a relative ``Path("syncmill.toml")``
|
|
167
|
+
would silently re-resolve against whatever the process cwd has drifted to
|
|
168
|
+
by then — the "resolved once at launch" claim those comments make is only
|
|
169
|
+
true because this pins an absolute path. Deliberately NOT ``resolve()``:
|
|
170
|
+
a ``syncmill.toml`` that is a symlink to a shared config elsewhere must
|
|
171
|
+
keep the LINK's directory as the anchor — dereferencing it would point
|
|
172
|
+
``_toml_path.parent`` (project root, jobs, room workers) at the symlink
|
|
173
|
+
target's directory instead of the checkout holding the link.
|
|
174
|
+
``Path.cwd()`` is already symlink-free on POSIX (getcwd resolves)."""
|
|
175
|
+
path = Path(
|
|
176
|
+
SyncmillConfig.model_config.get("toml_file") or "syncmill.toml"
|
|
177
|
+
).expanduser()
|
|
178
|
+
return path if path.is_absolute() else Path.cwd() / path
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load_document(path: Path) -> tomlkit.TOMLDocument:
|
|
182
|
+
"""Parse ``path`` into a tomlkit document; a missing file is an empty one.
|
|
183
|
+
|
|
184
|
+
Raises ``TeamFileError`` on unreadable/unparseable content or an existing
|
|
185
|
+
top-level ``teams`` key that isn't a table (writing under it would corrupt
|
|
186
|
+
the file).
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
text = path.read_text(encoding="utf-8")
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
return tomlkit.document()
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
raise TeamFileError(f"cannot read {path}: {exc}")
|
|
194
|
+
try:
|
|
195
|
+
doc = tomlkit.parse(text)
|
|
196
|
+
except Exception as exc: # tomlkit raises its own ParseError hierarchy
|
|
197
|
+
raise TeamFileError(f"cannot parse {path}: {exc}")
|
|
198
|
+
teams = doc.get("teams")
|
|
199
|
+
if teams is not None and not isinstance(teams, (Table, dict)):
|
|
200
|
+
raise TeamFileError(
|
|
201
|
+
f"{path} has a top-level 'teams' key that is not a table — "
|
|
202
|
+
f"fix the file before saving a team profile"
|
|
203
|
+
)
|
|
204
|
+
return doc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_team(
|
|
208
|
+
*,
|
|
209
|
+
agents: Sequence[str],
|
|
210
|
+
strategy: str | None = None,
|
|
211
|
+
goals: Sequence[str] = (),
|
|
212
|
+
capabilities: Mapping[str, Sequence[str]] | None = None,
|
|
213
|
+
) -> TeamConfig:
|
|
214
|
+
"""Construct a validated ``TeamConfig``, converting the pydantic
|
|
215
|
+
``ValidationError`` into a one-line ``TeamFileError`` (the CLI
|
|
216
|
+
``_load_config()`` conversion pattern)."""
|
|
217
|
+
try:
|
|
218
|
+
return TeamConfig(
|
|
219
|
+
agents=list(agents),
|
|
220
|
+
strategy=strategy,
|
|
221
|
+
goals=list(goals),
|
|
222
|
+
capabilities={k: list(v) for k, v in (capabilities or {}).items()},
|
|
223
|
+
)
|
|
224
|
+
except ValidationError as exc:
|
|
225
|
+
raise TeamFileError(f"invalid team profile:\n{exc}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def validate_team_save(
|
|
229
|
+
config: SyncmillConfig, name: str, team: TeamConfig, *, path: Path | None = None
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Pre-write validation for ``save_team`` — raises ``TeamFileError``.
|
|
232
|
+
|
|
233
|
+
Checks the candidate against TWO views (codex design-review Major — each
|
|
234
|
+
can mask a conflict the other sees):
|
|
235
|
+
|
|
236
|
+
- the RESOLVED (env+toml) config with the new team substituted under its
|
|
237
|
+
name — guarantees the next config construction under the current env
|
|
238
|
+
can't die (see the module docstring);
|
|
239
|
+
- when ``path`` is given, the FILE-only teams with the same substitution —
|
|
240
|
+
an env override (env > file, per-field deep-merge) can hide a dup-goal
|
|
241
|
+
that would remain in the file once that env is removed.
|
|
242
|
+
|
|
243
|
+
Plus the same unknown-agent-id check ``cli._apply_team`` applies at
|
|
244
|
+
selection time — catching the typo at save time instead of first use.
|
|
245
|
+
Free-form capability *tokens* are never validated (the matcher's
|
|
246
|
+
graceful-fallback contract).
|
|
247
|
+
"""
|
|
248
|
+
if not name:
|
|
249
|
+
raise TeamFileError("team name must be a non-empty string")
|
|
250
|
+
unknown = [a for a in team.agents if a not in _AGENT_CONSTRUCTORS]
|
|
251
|
+
if unknown:
|
|
252
|
+
raise TeamFileError(
|
|
253
|
+
f"team {name!r} names unknown agent(s): {', '.join(unknown)}. "
|
|
254
|
+
f"Valid agents: {', '.join(sorted(_AGENT_CONSTRUCTORS))}"
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
check_team_goals({**config.teams, name: team})
|
|
258
|
+
except ValueError as exc:
|
|
259
|
+
raise TeamFileError(str(exc))
|
|
260
|
+
if path is not None:
|
|
261
|
+
_validate_file_standalone(path, name, team)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _validate_file_standalone(path: Path, name: str, team: TeamConfig) -> None:
|
|
265
|
+
"""Reject a save that would leave ``path`` unloadable once env overrides are
|
|
266
|
+
dropped (codex post-code review Major). The resolved-view check above can be
|
|
267
|
+
satisfied by an env override that MASKS a broken file team — so validate the
|
|
268
|
+
file STANDING ALONE: build a real ``SyncmillConfig`` from **only** the toml
|
|
269
|
+
file (env/dotenv sources dropped), which validates every file team's
|
|
270
|
+
structure AND its dup-goals **exactly** as ``_load_config()`` would minus
|
|
271
|
+
env (crucially env-free — a standalone ``TeamConfig(**table)`` would instead
|
|
272
|
+
read bare ``AGENTS``/``GOALS`` env for any field the table omits, silently
|
|
273
|
+
re-introducing the very mask this guards against). Then run the shared
|
|
274
|
+
dup-goal rule over the file teams plus the candidate. Any malformed file
|
|
275
|
+
team surfaces as ``TeamFileError`` — a save can't paper over a file that
|
|
276
|
+
won't load without env.
|
|
277
|
+
|
|
278
|
+
(A bad top-level toml key can't reach here: it isn't env-maskable, so the
|
|
279
|
+
caller's own ``_load_config()`` would already have failed the command.)
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
class _FileOnly(SyncmillConfig):
|
|
283
|
+
model_config = {**SyncmillConfig.model_config, "toml_file": str(path)}
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def settings_customise_sources(
|
|
287
|
+
cls,
|
|
288
|
+
settings_cls: type[BaseSettings],
|
|
289
|
+
init_settings: PydanticBaseSettingsSource,
|
|
290
|
+
env_settings: PydanticBaseSettingsSource,
|
|
291
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
292
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
293
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
294
|
+
# Deliberately DROP env_settings/dotenv_settings — the file alone.
|
|
295
|
+
return (
|
|
296
|
+
init_settings,
|
|
297
|
+
TomlConfigSettingsSource(settings_cls),
|
|
298
|
+
file_secret_settings,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
file_cfg = _FileOnly()
|
|
303
|
+
except ValidationError as exc:
|
|
304
|
+
raise TeamFileError(
|
|
305
|
+
f"{path} has a team profile that is invalid standing alone (an "
|
|
306
|
+
f"environment override currently masks it):\n{exc}"
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
check_team_goals({**file_cfg.teams, name: team})
|
|
310
|
+
except ValueError as exc:
|
|
311
|
+
raise TeamFileError(
|
|
312
|
+
f"{exc} (the conflict is inside {path} standing alone — an "
|
|
313
|
+
f"environment override currently masks it)"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def save_team(path: Path, name: str, team: TeamConfig) -> None:
|
|
318
|
+
"""Create/replace ``[teams.<name>]`` in ``path``, preserving everything else.
|
|
319
|
+
|
|
320
|
+
The named table is replaced WHOLESALE (flags define the full profile —
|
|
321
|
+
partial merge can't express "clear goals"); ``strategy`` is emitted only
|
|
322
|
+
when set, ``goals`` and the ``[teams.<name>.capabilities]`` sub-table only
|
|
323
|
+
when non-empty, so the file mirrors what ``team show`` reports as unset.
|
|
324
|
+
The table name and capability keys are verbatim (tomlkit quotes as needed).
|
|
325
|
+
Atomic (temp + ``os.replace``) and serialized against every other writer by
|
|
326
|
+
:func:`config_write_lock`, so a concurrent save of a DIFFERENT team can no
|
|
327
|
+
longer be discarded by this one's replace. Raises ``TeamFileError``.
|
|
328
|
+
"""
|
|
329
|
+
with config_write_lock(path):
|
|
330
|
+
doc = load_document(path)
|
|
331
|
+
teams = doc.get("teams")
|
|
332
|
+
if teams is None:
|
|
333
|
+
# Super-table: renders as bare `[teams.<name>]` headers, no `[teams]`.
|
|
334
|
+
teams = tomlkit.table(is_super_table=True)
|
|
335
|
+
doc["teams"] = teams
|
|
336
|
+
entry = tomlkit.table()
|
|
337
|
+
entry["agents"] = list(team.agents)
|
|
338
|
+
if team.strategy is not None:
|
|
339
|
+
entry["strategy"] = team.strategy
|
|
340
|
+
if team.goals:
|
|
341
|
+
entry["goals"] = list(team.goals)
|
|
342
|
+
if team.capabilities:
|
|
343
|
+
caps = tomlkit.table()
|
|
344
|
+
for agent_id, tokens in team.capabilities.items():
|
|
345
|
+
caps[agent_id] = list(tokens)
|
|
346
|
+
entry["capabilities"] = caps
|
|
347
|
+
teams[name] = entry
|
|
348
|
+
_atomic_write(path, tomlkit.dumps(doc))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def set_default_team(path: Path, name: str | None) -> None:
|
|
352
|
+
"""Upsert (or, with ``None``, remove) the top-level ``default_team`` key.
|
|
353
|
+
|
|
354
|
+
Clearing an absent key is a no-op and never creates a missing file (so
|
|
355
|
+
``team use --clear`` stays a safe fix path). tomlkit places a new top-level
|
|
356
|
+
scalar BEFORE the first table (regression-tested — a value appended after
|
|
357
|
+
``[teams.x]`` would re-parse as that table's member and corrupt the file).
|
|
358
|
+
Serialized by :func:`config_write_lock`. The "no file to clear" check stays
|
|
359
|
+
OUTSIDE the lock: taking it would create the lock file (and, on a missing
|
|
360
|
+
parent directory, fail) for a call that is defined to touch nothing.
|
|
361
|
+
Raises ``TeamFileError``.
|
|
362
|
+
"""
|
|
363
|
+
if name is None and not path.exists():
|
|
364
|
+
return
|
|
365
|
+
with config_write_lock(path):
|
|
366
|
+
if name is None:
|
|
367
|
+
doc = load_document(path)
|
|
368
|
+
if "default_team" not in doc:
|
|
369
|
+
return
|
|
370
|
+
del doc["default_team"]
|
|
371
|
+
else:
|
|
372
|
+
doc = load_document(path)
|
|
373
|
+
doc["default_team"] = name
|
|
374
|
+
_atomic_write(path, tomlkit.dumps(doc))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _fsync_dir(directory: Path) -> None:
|
|
378
|
+
"""Best-effort fsync of a directory, so the rename itself is durable.
|
|
379
|
+
|
|
380
|
+
Not every platform lets you open a directory for this; where it fails, the file
|
|
381
|
+
contents are still fsynced and the rename is still atomic — only the ordering
|
|
382
|
+
guarantee across a power loss is weaker."""
|
|
383
|
+
try:
|
|
384
|
+
fd = os.open(str(directory), os.O_RDONLY)
|
|
385
|
+
except OSError:
|
|
386
|
+
return
|
|
387
|
+
try:
|
|
388
|
+
os.fsync(fd)
|
|
389
|
+
except OSError:
|
|
390
|
+
pass
|
|
391
|
+
finally:
|
|
392
|
+
os.close(fd)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _restore_mode(fd: int, tmp: Path, mode: int) -> None:
|
|
396
|
+
"""Put back the bits ``os.open``'s umask masking cleared.
|
|
397
|
+
|
|
398
|
+
``os.fchmod`` is Unix-only, so on Windows it would raise ``AttributeError`` —
|
|
399
|
+
outside the ``OSError`` boundary this module converts — and fail every write to
|
|
400
|
+
an existing config. Fall back to chmod-by-path there; the temp is ours and
|
|
401
|
+
freshly created, and Windows only honours the read-only bit anyway.
|
|
402
|
+
|
|
403
|
+
The fallback's pathname is not a meaningful race: it runs only where
|
|
404
|
+
``fchmod`` is absent (Windows), and there a file this process holds open
|
|
405
|
+
cannot be renamed or deleted by another process unless it was opened with
|
|
406
|
+
delete sharing, which CPython does not request."""
|
|
407
|
+
fchmod = getattr(os, "fchmod", None)
|
|
408
|
+
if fchmod is not None:
|
|
409
|
+
fchmod(fd, mode)
|
|
410
|
+
else: # pragma: no cover - exercised on Windows
|
|
411
|
+
os.chmod(tmp, mode)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
415
|
+
"""Temp file + ``os.replace`` in ``path.parent`` (the breaker/board recipe),
|
|
416
|
+
so a failed write can never leave a truncated ``syncmill.toml``.
|
|
417
|
+
|
|
418
|
+
The destination's mode is PRESERVED. ``NamedTemporaryFile`` creates at 0600 and
|
|
419
|
+
``os.replace`` carries that onto the destination, so writing a checked-in 0644
|
|
420
|
+
project config silently narrowed it — a spurious mode change in git and a break
|
|
421
|
+
for group-readable checkouts.
|
|
422
|
+
|
|
423
|
+
A file that does not exist yet is created with ``O_CREAT|O_EXCL`` and mode 0666,
|
|
424
|
+
so the KERNEL applies the process umask — exactly what a plain ``open()`` would
|
|
425
|
+
produce. Reading the umask in userspace means setting it to 0 and restoring it,
|
|
426
|
+
which is process-global: any thread (the dashboard runs Textual) or fork inside
|
|
427
|
+
that window would create its files world-writable.
|
|
428
|
+
|
|
429
|
+
The temp is created with the DESTINATION's mode, not widened-then-narrowed: a
|
|
430
|
+
private 0600 config written into a 0644 temp is world-readable for as long as it
|
|
431
|
+
takes to write it. ``os.open``'s mode is umask-masked, so the following
|
|
432
|
+
``fchmod`` restores any bit the umask cleared — by then the file has only ever
|
|
433
|
+
been at most as permissive as its destination.
|
|
434
|
+
|
|
435
|
+
Durability order is fchmod → fsync → replace → fsync(parent): the mode is set
|
|
436
|
+
before the fsync that has to cover it, and the parent sync is what makes the
|
|
437
|
+
rename survive a power loss rather than leaving a zero-length config.
|
|
438
|
+
"""
|
|
439
|
+
try:
|
|
440
|
+
mode: int | None = stat.S_IMODE(path.stat().st_mode)
|
|
441
|
+
except FileNotFoundError:
|
|
442
|
+
mode = None # genuinely new: let the umask decide, via O_CREAT below
|
|
443
|
+
except OSError as exc:
|
|
444
|
+
# Only "the file is not there" means "new file". Treating any stat failure
|
|
445
|
+
# that way makes a transient inspection error (EIO, a stale NFS handle)
|
|
446
|
+
# silently republish a PRIVATE config at the umask default. Refuse before
|
|
447
|
+
# creating anything.
|
|
448
|
+
raise TeamFileError(f"cannot inspect {path}: {exc}")
|
|
449
|
+
tmp = path.parent / f".{path.name}.{uuid4().hex}.tmp"
|
|
450
|
+
created = False
|
|
451
|
+
try:
|
|
452
|
+
fd = os.open(
|
|
453
|
+
str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666 if mode is None else mode
|
|
454
|
+
)
|
|
455
|
+
created = True # only now do we own this path, and may remove it
|
|
456
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
457
|
+
fh.write(text)
|
|
458
|
+
if mode is not None:
|
|
459
|
+
_restore_mode(fh.fileno(), tmp, mode)
|
|
460
|
+
fh.flush()
|
|
461
|
+
os.fsync(fh.fileno())
|
|
462
|
+
os.replace(tmp, path)
|
|
463
|
+
created = False # renamed away; nothing of ours is left at `tmp`
|
|
464
|
+
_fsync_dir(path.parent)
|
|
465
|
+
except OSError as exc:
|
|
466
|
+
raise TeamFileError(f"cannot write {path}: {exc}")
|
|
467
|
+
finally:
|
|
468
|
+
# Every exit path, not just OSError: a KeyboardInterrupt or an encoding
|
|
469
|
+
# failure mid-write would otherwise strand a partial temp. Guarded by
|
|
470
|
+
# `created` so an O_EXCL collision never unlinks a file we did not make.
|
|
471
|
+
if created:
|
|
472
|
+
try:
|
|
473
|
+
tmp.unlink(missing_ok=True)
|
|
474
|
+
except OSError:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def write_document(path: Path, doc: tomlkit.TOMLDocument) -> None:
|
|
479
|
+
"""Dump ``doc`` over ``path`` atomically, preserving mode and comments.
|
|
480
|
+
|
|
481
|
+
Public so ``project.py`` can refresh a project config through the one module
|
|
482
|
+
that owns toml writing, rather than hand-serializing a second time — which
|
|
483
|
+
also puts `init`'s writes under the same :func:`config_write_lock` as the
|
|
484
|
+
team writers, so an `init --force` repair and a `team save` cannot interleave
|
|
485
|
+
into a lost update. The caller read ``doc`` before this call, so it should
|
|
486
|
+
hold the lock across that read whenever the two must be one transaction."""
|
|
487
|
+
with config_write_lock(path):
|
|
488
|
+
_atomic_write(path, tomlkit.dumps(doc))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def file_team_names(path: Path) -> set[str]:
|
|
492
|
+
"""The team names defined in ``path`` ITSELF (not the resolved view) —
|
|
493
|
+
`team use` warns when the chosen name resolves only via ``SYNCMILL_TEAMS``,
|
|
494
|
+
since the persisted default would dangle without that env (codex
|
|
495
|
+
design-review Major). Raises ``TeamFileError`` like ``load_document``."""
|
|
496
|
+
return set(load_document(path).get("teams") or {})
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def load_config_at(path: Path) -> SyncmillConfig:
|
|
500
|
+
"""Construct the resolved config with the toml source pointed at ``path``.
|
|
501
|
+
|
|
502
|
+
The dashboard's post-write reload seam (codex design-review Major): a plain
|
|
503
|
+
``SyncmillConfig()`` reads ``model_config["toml_file"]`` relative to the
|
|
504
|
+
*current* cwd, which need not be the file the TEAM pane just wrote — this
|
|
505
|
+
pins the toml layer to the exact injected path while preserving the normal
|
|
506
|
+
env > file > defaults precedence (a one-off subclass, so the global
|
|
507
|
+
``SyncmillConfig.model_config`` is never mutated). Raises pydantic's
|
|
508
|
+
``ValidationError`` like any construction; callers degrade best-effort.
|
|
509
|
+
"""
|
|
510
|
+
|
|
511
|
+
class _ConfigAt(SyncmillConfig):
|
|
512
|
+
model_config = {**SyncmillConfig.model_config, "toml_file": str(path)}
|
|
513
|
+
|
|
514
|
+
return _ConfigAt()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def parse_capability_specs(specs: Sequence[str]) -> dict[str, list[str]]:
|
|
518
|
+
"""Parse ``"agent=tok1,tok2"`` specs (the ``--capability`` flag / TEAM-pane
|
|
519
|
+
field shape) into a capabilities map. Raises ``TeamFileError`` on a missing
|
|
520
|
+
``=``, an empty agent id, no non-empty tokens, or a duplicate agent key."""
|
|
521
|
+
caps: dict[str, list[str]] = {}
|
|
522
|
+
for spec in specs:
|
|
523
|
+
agent_id, sep, raw = spec.partition("=")
|
|
524
|
+
agent_id = agent_id.strip()
|
|
525
|
+
if not sep or not agent_id:
|
|
526
|
+
raise TeamFileError(
|
|
527
|
+
f"invalid capability spec {spec!r} — expected 'agent=tok1,tok2'"
|
|
528
|
+
)
|
|
529
|
+
tokens = [t.strip() for t in raw.split(",") if t.strip()]
|
|
530
|
+
if not tokens:
|
|
531
|
+
raise TeamFileError(
|
|
532
|
+
f"capability spec {spec!r} declares no tokens — "
|
|
533
|
+
f"expected 'agent=tok1,tok2'"
|
|
534
|
+
)
|
|
535
|
+
if agent_id in caps:
|
|
536
|
+
raise TeamFileError(f"duplicate capability spec for agent {agent_id!r}")
|
|
537
|
+
caps[agent_id] = tokens
|
|
538
|
+
return caps
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def render_team(name: str, team: TeamConfig, config: SyncmillConfig) -> str:
|
|
542
|
+
"""One team profile as plain text — the shared renderer `team show` echoes
|
|
543
|
+
and the dashboard TEAM pane displays verbatim, so the two surfaces stay
|
|
544
|
+
byte-identical (the ``render_item``/``render_board_stats`` precedent).
|
|
545
|
+
|
|
546
|
+
Names, agent ids, capabilities, strategies and goals come from configuration
|
|
547
|
+
and an unknown agent id is shown as such, so each is displayed through
|
|
548
|
+
``printable_name``."""
|
|
549
|
+
p = printable_name
|
|
550
|
+
lines = [f"Team: {p(name)}", "", "Agents (team priority order):", ""]
|
|
551
|
+
for i, agent_id in enumerate(team.agents, 1):
|
|
552
|
+
if agent_id not in _AGENT_CONSTRUCTORS:
|
|
553
|
+
status = "unknown agent"
|
|
554
|
+
elif config.agent_config(agent_id).enabled:
|
|
555
|
+
status = "enabled"
|
|
556
|
+
else:
|
|
557
|
+
status = "disabled"
|
|
558
|
+
line = f" {i}. {p(agent_id)} [{status}]"
|
|
559
|
+
caps = team.capabilities.get(agent_id)
|
|
560
|
+
if caps:
|
|
561
|
+
line += f" capabilities: {', '.join(p(c) for c in caps)}"
|
|
562
|
+
lines.append(line)
|
|
563
|
+
strategy = (
|
|
564
|
+
p(team.strategy)
|
|
565
|
+
if team.strategy
|
|
566
|
+
else f"(inherits default_strategy: {p(str(config.default_strategy))})"
|
|
567
|
+
)
|
|
568
|
+
lines.append("")
|
|
569
|
+
lines.append(f"strategy: {strategy}")
|
|
570
|
+
lines.append(f"goals: {', '.join(p(g) for g in team.goals) if team.goals else '—'}")
|
|
571
|
+
return "\n".join(lines)
|