Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/app/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";

// Bake the git revision into the bundle so the running SPA knows which source it
// was built from and can flag drift against the companion's /api/version (the
// stale-dist failure mode behind #132). Best-effort: "unknown" if git isn't
// available at build time.
// available at build time. The same string tallyman_core.version.checkout_revision
// computes: a dirty tree also names its edits with a short hash of `git diff HEAD`.
function gitRevision(): string {
try {
return execSync("git describe --always --dirty --abbrev=7", { encoding: "utf8" }).trim() || "unknown";
const rev = execSync("git describe --always --dirty --abbrev=7", { encoding: "utf8" }).trim();
if (!rev) return "unknown";
if (!rev.endsWith("-dirty")) return rev;
const diff = execSync("git diff --no-ext-diff --no-color --binary HEAD", {
encoding: "utf8",
maxBuffer: 1 << 30,
}).trim();
return `${rev}.${createHash("sha1").update(diff).digest("hex").slice(0, 6)}`;
} catch {
return "unknown";
}
Expand Down
17 changes: 14 additions & 3 deletions scripts/rebuild_nfl_salaries_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,18 @@ class StockMainStyling(DefaultMainStyling):
{"field": "player", "type": "nominal", "title": "Player"},
{"field": "year_signed", "type": "quantitative", "title": "Signing year", "format": "d"},
{"field": "team", "type": "nominal", "title": "Team"},
{"field": "epa_per_value_million", "type": "quantitative", "title": "EPA/$1M (true value)", "format": ".1f"},
{"field": "total_epa_during_contract", "type": "quantitative", "title": "Total EPA", "format": ".1f"},
{
"field": "epa_per_value_million",
"type": "quantitative",
"title": "EPA/$1M (true value)",
"format": ".1f",
},
{
"field": "total_epa_during_contract",
"type": "quantitative",
"title": "Total EPA",
"format": ".1f",
},
{"field": "value", "type": "quantitative", "title": "Contract value ($)", "format": "$,.0f"},
{"field": "contract_years", "type": "quantitative", "title": "Contract years"},
],
Expand Down Expand Up @@ -196,7 +206,8 @@ class Step:
" .filter(ibis._.rn == 0)\n"
")\n"
"\n"
"total_epa = ibis.coalesce(most_recent_contract.passing_epa, 0) + ibis.coalesce(most_recent_contract.rushing_epa, 0)\n"
"total_epa = ibis.coalesce(most_recent_contract.passing_epa, 0)"
" + ibis.coalesce(most_recent_contract.rushing_epa, 0)\n"
"expr = most_recent_contract.select(\n"
" player_display_name=most_recent_contract.player_display_name,\n"
" season=most_recent_contract.season,\n"
Expand Down
6 changes: 4 additions & 2 deletions src/tallyman_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,14 @@ def _notify_companion_reset(project: str) -> None:
import httpx

from tallyman_core.server_lock import companion_url, resolved_home
from tallyman_core.version import git_revision

url = companion_url()
if url is None:
return # no server on this data dir, so no browsers or Buckaroo sessions to reload
# `home` names this data dir, so a companion serving another one refuses the notify (#183).
payload = {"kind": "project_reset", "project": project, "home": str(resolved_home())}
# `home` names this data dir, so a companion serving another one refuses the notify (#183); `revision` names this
# process's source, so a companion running other source refuses it too.
payload = {"kind": "project_reset", "project": project, "home": str(resolved_home()), "revision": git_revision()}
try:
resp = httpx.post(f"{url}/internal/notify", json=payload, timeout=2.0)
if resp.status_code == 409:
Expand Down
27 changes: 25 additions & 2 deletions src/tallyman_companion/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
from tallyman_core.paths import entries_dir, project_dir, validate_project_name
from tallyman_core.server_lock import is_this_data_dir, resolved_home
from tallyman_core.telemetry import read_spans, record_span
from tallyman_core.version import git_revision, version_info
from tallyman_core.version import REPO_ROOT, describe_source, git_revision, is_this_revision, version_info
from tallyman_xorq import (
full_diff,
list_entries,
Expand Down Expand Up @@ -72,6 +72,9 @@ class NotifyPayload(BaseModel):
# The sender's data dir (TALLYMAN_HOME). A notify from a client of another data dir is refused (#183); absent → not
# checked.
home: str | None = None
# The sender's git revision. A notify from a client running other source is refused, and so is one without it: a
# client that sends none was started from source older than the check.
revision: str | None = None


def _refuse_another_data_dir(what: str, home: str | None) -> None:
Expand All @@ -90,6 +93,25 @@ def _refuse_another_data_dir(what: str, home: str | None) -> None:
)


def _refuse_other_source(what: str, revision: str | None) -> None:
"""409 when a client runs other source than this companion (``tallyman_core.version``).

An MCP server or CLI on another revision has already written to the catalogs by the time it notifies, and one
started from source older than the check has no check of its own, so this refusal and its log line are how it is
noticed. It must not reload or publish anything here either.
"""
if is_this_revision(revision):
return
message = (
f"{what} refused: the client runs {revision or 'no revision (source older than the revision check)'} but this "
f"companion runs {describe_source(git_revision(), str(REPO_ROOT))}. Restart whichever is stale: an MCP server "
"with /mcp, then tallyman, then Reconnect; the companion with restart-tallyman. A `tallyman` CLI command runs "
"the source of the checkout it is run from, so run it from the companion's."
)
log.error(message)
raise HTTPException(409, message)


def _broadcaster() -> tuple[asyncio.Queue, list]:
return asyncio.Queue(), []

Expand Down Expand Up @@ -1856,6 +1878,7 @@ async def api_recalc(project: str, payload: dict | None = None):
@app.post("/internal/notify")
async def notify(payload: NotifyPayload):
_refuse_another_data_dir("notify", payload.home)
_refuse_other_source("notify", payload.revision)
# The payload may name its project (CLI `reset-to --project` can target
# a non-active project); only fall back to the active one when it doesn't.
project_name = payload.project or _require_project()
Expand Down Expand Up @@ -1895,7 +1918,7 @@ async def notify(payload: NotifyPayload):
extra = payload.extra or {}
event = _recalc_sse_event(extra.get("remap", {}), extra.get("step"))
else:
event = payload.model_dump(exclude={"home"})
event = payload.model_dump(exclude={"home", "revision"})
await publish(event)
return {"ok": True, "subscribers": len(subscribers), "project": project_name}

Expand Down
21 changes: 21 additions & 0 deletions src/tallyman_companion/buckaroo_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ def start(self) -> None:
r = self._client.get(f"{self.base_url}/health", timeout=1.0)
if r.status_code == 200:
health = r.json()
self._refuse_other_buckaroo(health.get("version"))
started_at = health.get("started")
log.info("buckaroo ready on %s (started=%s)", self.base_url, started_at)
# If this Buckaroo started fresh (different start time from
Expand All @@ -260,6 +261,26 @@ def start(self) -> None:
self.stop()
raise BuckarooUnavailable("buckaroo did not respond to /health in time")

def _refuse_other_buckaroo(self, version: str | None) -> None:
"""Stop the server and raise ``BuckarooUnavailable`` when it runs another buckaroo than this companion.

The companion runs buckaroo in-process too (diffs, klass validation), and the server is spawned from the same
venv, so they agree at startup. The server is spawned again whenever it dies, though, and a venv synced to
another buckaroo in between (a pull that bumped the pin) gives the new server a version the companion, and the
SPA bundle built with it, were not built against.
"""
import buckaroo # noqa: PLC0415

if version == buckaroo.__version__:
return
self.stop()
message = (
f"the Buckaroo server runs buckaroo {version} but this companion runs buckaroo {buckaroo.__version__}: the "
"venv changed under the running companion. Restart the companion (restart-tallyman)."
)
log.error(message)
raise BuckarooUnavailable(message)

def _drain_stdout(self) -> None:
if self.proc is None or self.proc.stdout is None:
return
Expand Down
11 changes: 8 additions & 3 deletions src/tallyman_core/server_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
serves. The kernel drops the lock when the process exits, however it exits (SIGKILL included), so there is no stale
lock to clean up. ``flock`` rather than ``fcntl.lockf``: a POSIX record lock is dropped when the process closes *any*
descriptor of the file, which ``read_owner`` does in the server's own process. The file also holds an owner record
(JSON: pid, host, port, bind host, start time, data dir, argv). It names the holder in a refusal, and it tells a
client of this data dir which port its companion serves on (``companion_url``). The record is believed only while the
lock is held; a dead server's record stays in the file and ``read_owner`` returns None for it.
(JSON: pid, host, port, bind host, start time, data dir, argv, and the git revision and checkout it runs from). It
names the holder in a refusal, it tells a client of this data dir which port its companion serves on
(``companion_url``), and it tells an MCP server whether the companion runs the same source (``tallyman_core.version``).
The record is believed only while the lock is held; a dead server's record stays in the file and ``read_owner``
returns None for it.

The port is decided once, by ``tallyman run --port``, and this record is how it reaches the clients of the data dir
(the MCP server, ``reset-to``), which are separate processes started independently of the server. There is no
Expand All @@ -36,6 +38,7 @@

from tallyman_core.net import client_host
from tallyman_core.paths import tallyman_home
from tallyman_core.version import REPO_ROOT, git_revision

LOCK_FILENAME = "server.lock"

Expand Down Expand Up @@ -95,6 +98,8 @@ def claim_data_dir(*, port: int, bind_host: str, home: Path | str | None = None)
"started_at": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
"data_dir": str(data_dir),
"argv": sys.argv,
"revision": git_revision(),
"source": str(REPO_ROOT),
}
os.ftruncate(fd, 0)
os.pwrite(fd, json.dumps(record).encode(), 0)
Expand Down
78 changes: 59 additions & 19 deletions src/tallyman_core/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Git-revision stamping so each running component reports the source it's on.
"""Git-revision stamping, and the check that every process on a data dir runs the same source.

tallyman has no releases yet, so the version *is* the git revision. Every
long-lived process (the companion, the MCP server) and the built JS bundle
Expand All @@ -7,44 +7,84 @@
(the class of bug behind #132). The companion exposes its revision at
``GET /api/version`` and on an ``X-Tallyman-Revision`` response header; the SPA
bakes its build-time revision and warns when the two disagree.

The MCP server (one per Claude Code session), the companion and the CLI are started independently, so one of them can
run code from before a pull, a checkout or an edit while another runs the new code, against the same catalogs. They
refuse to work together across revisions: the companion writes its revision into the owner record of the data dir it
claims (``server_lock``), an MCP tool refuses to run against a companion on another one, and the companion refuses a
notify from a client on another one.
"""

from __future__ import annotations

import functools
import hashlib
from pathlib import Path

from tallyman_core.git_util import run_git

# version.py lives at <repo>/src/tallyman_core/version.py.
_REPO_ROOT = Path(__file__).resolve().parents[2]
REPO_ROOT = Path(__file__).resolve().parents[2]


def checkout_revision(root: Path | str) -> str:
"""The revision of the checkout at *root*, as it is on disk now.

``git describe --always --dirty`` yields the abbreviated commit, suffixed ``-dirty`` when there are *tracked*
working-tree changes (untracked files are ignored, so a stray scratch file doesn't flag drift). The suffix is the
same for every set of changes, so a dirty checkout also names its changes with a short hash of ``git diff HEAD``:
``<sha>-dirty.<diff hash>``. Two processes started from one commit with different edits then report different
revisions. ``packages/app/vite.config.ts`` computes the same string for the SPA bundle.

Routed through ``git_util.run_git`` (fork-safe ``posix_spawn``, the sanctioned primitive — no bare ``subprocess``
git in src/). Returns ``"unknown"`` when git or the repo isn't available — versioning must never break a start.
"""
try:
rc, out, _ = run_git(["describe", "--always", "--dirty", "--abbrev=7"], cwd=root, timeout=2.0)
if rc != 0 or not out:
return "unknown"
if not out.endswith("-dirty"):
return out
rc, diff, _ = run_git(["diff", "--no-ext-diff", "--no-color", "--binary", "HEAD"], cwd=root, timeout=5.0)
except OSError:
return "unknown"
if rc != 0:
return "unknown"
return f"{out}.{hashlib.sha1(diff.encode()).hexdigest()[:6]}"


@functools.lru_cache(maxsize=1)
def git_revision() -> str:
"""Short git revision of the checkout this process was launched from.
"""Revision of the checkout this process was launched from (``checkout_revision``).

Pinned on first call (``lru_cache``) so it reflects the code the process is
actually running, not wherever ``HEAD`` moves later in the process's life.
``git describe --always --dirty`` yields the abbreviated commit, suffixed
``-dirty`` when there are *tracked* working-tree changes (untracked files are
ignored, so a stray scratch file doesn't flag drift). Routed through
``git_util.run_git`` (fork-safe ``posix_spawn``, the sanctioned primitive —
no bare ``subprocess`` git in src/). Returns ``"unknown"`` when git or the
repo isn't available — versioning must never break a start.
"""
try:
rc, out, _ = run_git(
["describe", "--always", "--dirty", "--abbrev=7"],
cwd=_REPO_ROOT,
timeout=2.0,
)
except OSError:
return "unknown"
return out if (rc == 0 and out) else "unknown"
return checkout_revision(REPO_ROOT)


def version_info() -> dict:
"""Structured version payload for ``/api/version``, headers, and logs."""
rev = git_revision()
return {"revision": rev, "dirty": rev.endswith("-dirty")}
return {"revision": rev, "dirty": "-dirty" in rev, "source": str(REPO_ROOT)}


def is_this_revision(revision: str | None) -> bool:
"""Whether another process reporting *revision* runs the source this one does. An unknown revision never does."""
return revision == git_revision() and revision != "unknown"


def describe_source(revision: str | None, source: str | None) -> str:
"""``<revision> from <checkout>`` for a refusal message, noting when that checkout has since moved on.

The note says which side is stale: a process whose checkout is no longer at the revision it started from is
running old code.
"""
if not revision:
return "no revision (it was started from source older than the revision check)"
text = f"{revision} from {source or 'an unknown checkout'}"
if source and Path(source).is_dir():
now = checkout_revision(source)
if now not in (revision, "unknown"):
text += f" (stale: that checkout is now at {now})"
return text
Loading
Loading