scripts.ast_daemon_publish
Reference › scripts › ast_daemon_publish
The snapshot publisher of the resident AST daemon (ADR-0097). It is a library module, not a command; ast_daemon builds a SnapshotPublisher and exposes it through its publish route when publishing is enabled.
SnapshotPublisher is constructed with the toolkit object, the repo root, a snapshot name, the source roots and an output path (for example plans/ast-ultimate.json). It runs a worker thread (start, stop). Callers ask for a publish with request, which returns a ticket, and may block on wait(ticket, timeout); status reports its state. watch_paths lists the files the daemon should watch for it. The worker loop _run calls _publish_once, which analyses the roots through the warm tool, merges runtime call-graph edges when available (_runtime_edges, _callgraph_module), assembles the snapshot document in _build and writes it to the output file. declared_roots reads the roots a repository declares, and _sha1_file hashes files to detect change.
At a glance
| Source | scripts/ast_daemon_publish.py |
| Lines of code | 296 |
| Classes | 1 |
| Functions | 2 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | none |
Class diagram
Dependency diagram
No internal imports and no importers.
API
Classes
SnapshotPublisher
Line 68
Publishes output for the checkout at repo_root from a warm daemon.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(tool: Any, repo_root: Path, *, name: str, roots: list[str], output: str) -> None |
|
output_path property |
output_path() -> Path |
|
watch_paths |
watch_paths() -> list[str] |
Absolute snapshot roots that exist (the watcher covers them all). |
start |
start() -> None |
|
stop |
stop() -> None |
|
status |
status() -> dict[str, Any] |
|
request |
request(since: float | None = None) -> int | None |
Ask for a publish; returns a ticket for :meth:wait, or None when the last publish already read the tree after since (the edited file's mtime) and nothing is pending -- nothing to do. |
wait |
wait(ticket: int | None, timeout: float) -> bool |
Block until the publish that covers ticket finished. |
_run |
_run() -> None |
|
_runtime_edges |
_runtime_edges() -> list[tuple[str, str]] | None |
|
_callgraph_module |
_callgraph_module() -> Any |
|
_publish_once |
_publish_once() -> None |
|
_build |
_build(result: Any, stats: dict[str, Any]) -> dict[str, Any] |
Mirror _cmd_serialize's assembly step, then re-apply runtime edges. |
Functions
| Function | Signature | Summary |
|---|---|---|
declared_roots |
declared_roots(repo_root: Path) -> list[str] |
The snapshot's declared root set: $ORCHESTRATA_AST_ROOTS, else the value in <repo>/env/ast-roots.env (the file .envrc loads; a systemd unit has no direnv), else [] (publishing stays… |
_sha1_file |
_sha1_file(path: Path) -> str | None |
Source
"""The resident AST daemon's snapshot publisher (ADR-0097).
``scripts/ast_daemon.py`` holds the parsed tree warm and serves queries; this
module is the half that PUBLISHES the on-disk snapshot (``plans/ast-<name>.json``)
from that process, so the post-edit hooks stop re-serializing the whole tree on
every write.
SINGLE WRITER. Exactly one thread (``_run``) ever writes the artifact, so a
burst of edits, ``/publish`` requests and watcher events coalesce into the
fewest possible writes: while one publish runs, any number of new requests
collapse into ONE follow-up publish. The write itself is the serializer's own
``_atomic_write_json`` (PID-unique temp + ``os.replace``), the same writer
``serialize_codebase_ast.py serialize`` uses, over the same
``load_or_analyze`` / ``serialize`` / ``compute_file_meta`` calls
``_cmd_serialize`` makes -- so the artifact is byte-compatible with a CLI
``serialize --incremental`` of the same tree.
RUNTIME CALLS. A bare serialize drops every function's ``runtime_calls`` (the
key is written by ``build_runtime_callgraph.py``, not the AST serializer). When
``plans/runtime-callgraph.json`` exists, this publisher re-applies its edges to
the document BEFORE the atomic write -- exactly what ``gates.py``'s reaugment
step does afterwards -- so a daemon publish never zeroes runtime_calls. With no
capture file the key is not written, matching the CLI byte for byte.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
import sys
import threading
import time
from pathlib import Path
from typing import Any
_HERE = Path(__file__).resolve().parent
_ROOTS_ENV = "ORCHESTRATA_AST_ROOTS"
_ROOTS_FILE = "env/ast-roots.env"
_RUNTIME_EDGES = "plans/runtime-callgraph.json"
def declared_roots(repo_root: Path) -> list[str]:
"""The snapshot's declared root set: ``$ORCHESTRATA_AST_ROOTS``, else the
value in ``<repo>/env/ast-roots.env`` (the file ``.envrc`` loads; a systemd
unit has no direnv), else ``[]`` (publishing stays off)."""
raw = os.environ.get(_ROOTS_ENV, "")
if not raw.strip():
try:
text = (repo_root / _ROOTS_FILE).read_text(encoding="utf-8")
except OSError:
text = ""
for line in text.splitlines():
key, sep, value = line.strip().partition("=")
if sep and key.strip() == _ROOTS_ENV:
raw = value.strip().strip("'\"")
return [tok for tok in raw.replace(":", " ").split() if tok]
def _sha1_file(path: Path) -> str | None:
try:
return hashlib.sha1(path.read_bytes()).hexdigest()
except OSError:
return None
class SnapshotPublisher:
"""Publishes ``output`` for the checkout at ``repo_root`` from a warm daemon."""
def __init__(
self,
tool: Any,
repo_root: Path,
*,
name: str,
roots: list[str],
output: str,
) -> None:
self._tool = tool
self._repo_root = repo_root
self._name = name
self._roots = list(roots)
self._output = output
self._cond = threading.Condition()
self._stop = threading.Event()
self._thread = threading.Thread(
target=self._run, daemon=True, name="ast-daemon-publish"
)
self._requested = 0
self._done = 0
self._started_at: float | None = None
self._status: dict[str, Any] = {
"enabled": True,
"output": str(self.output_path),
"published_at": None,
"finished_at": None,
"content_hash": None,
"publish_count": 0,
"writes": 0,
"last_ms": None,
"last_error": None,
}
self._edges_cache: tuple[int, list[tuple[str, str]]] | None = None
self._callgraph: Any = None
@property
def output_path(self) -> Path:
out = Path(self._output)
return out if out.is_absolute() else self._repo_root / out
def watch_paths(self) -> list[str]:
"""Absolute snapshot roots that exist (the watcher covers them all)."""
paths = (
Path(r) if Path(r).is_absolute() else self._repo_root / r
for r in self._roots
)
return [str(p) for p in paths if p.exists()]
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
with self._cond:
self._cond.notify_all()
if self._thread.ident is not None:
self._thread.join(timeout=5.0)
def status(self) -> dict[str, Any]:
with self._cond:
return {**self._status, "pending": self._requested > self._done}
def request(self, since: float | None = None) -> int | None:
"""Ask for a publish; returns a ticket for :meth:`wait`, or ``None``
when the last publish already read the tree after ``since`` (the edited
file's mtime) and nothing is pending -- nothing to do."""
with self._cond:
if (
since is not None
and self._started_at is not None
and self._started_at >= since
and self._requested == self._done
):
return None
self._requested += 1
self._cond.notify_all()
return self._requested
def wait(self, ticket: int | None, timeout: float) -> bool:
"""Block until the publish that covers ``ticket`` finished."""
if ticket is None:
return True
with self._cond:
return self._cond.wait_for(lambda: self._done >= ticket, timeout=timeout)
# -- the single writer ---------------------------------------------------
def _run(self) -> None:
seed = _sha1_file(self.output_path)
with self._cond:
self._status["content_hash"] = seed
while not self._stop.is_set():
with self._cond:
self._cond.wait_for(
lambda: self._requested > self._done or self._stop.is_set(),
timeout=1.0,
)
if self._stop.is_set():
return
if self._requested == self._done:
continue
target = self._requested
error: str | None = None
try:
self._publish_once()
except Exception as exc: # recorded, never fatal: the daemon keeps serving
error = f"{type(exc).__name__}: {exc}"
print(f"ast-daemon: publish failed: {error}", file=sys.stderr)
with self._cond:
self._status["last_error"] = error
self._done = target
self._cond.notify_all()
def _runtime_edges(self) -> list[tuple[str, str]] | None:
path = self._repo_root / _RUNTIME_EDGES
try:
mtime = path.stat().st_mtime_ns
except OSError:
return None
if self._edges_cache is not None and self._edges_cache[0] == mtime:
return self._edges_cache[1]
summary = json.loads(path.read_text(encoding="utf-8"))
edges = [(pair[0], pair[1]) for pair in summary["edges"]]
self._edges_cache = (mtime, edges)
return edges
def _callgraph_module(self) -> Any:
if self._callgraph is None:
spec = importlib.util.spec_from_file_location(
"ast_daemon_runtime_callgraph", _HERE / "build_runtime_callgraph.py"
)
if spec is None or spec.loader is None:
raise ImportError("cannot load build_runtime_callgraph")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self._callgraph = module
return self._callgraph
def _publish_once(self) -> None:
tool = self._tool
started = time.time()
t0 = time.monotonic()
output = self.output_path
wrote = False
# Never touch the process working directory: it is process-global,
# and a publish that moves it overlapping any other thread moving it
# leaves the whole daemon in the wrong directory for good —
# verify_deployment_identity.py then failed the instance mid-publish
# and install-orchestrata.sh rolled back (review findings D9/Q10,
# observed 2026-10-13). load_or_analyze takes the repo root
# explicitly and anchors every path against it, so the artifact
# stays byte-identical to a CLI run from the repository root.
sources = [
Path(root) if Path(root).is_absolute() else self._repo_root / root
for root in self._roots
]
source, extra_roots = sources[0], tuple(sources[1:])
result, stats = tool.load_or_analyze(
self._name,
source,
output,
monorepo=False,
extra_roots=extra_roots,
root=self._repo_root,
)
unchanged = (
not stats["fallback"]
and not stats["reparsed"]
and not stats["removed"]
and output.exists()
)
if unchanged:
os.utime(output) # content identical; the mtime is the freshness pin
else:
serialized = self._build(result, stats)
output.parent.mkdir(parents=True, exist_ok=True)
tool._atomic_write_json(output, serialized, default=str)
wrote = True
digest = _sha1_file(output) if wrote else self._status["content_hash"]
if digest is None:
digest = _sha1_file(output)
with self._cond:
self._started_at = started
self._status.update(
published_at=started,
finished_at=time.time(),
content_hash=digest,
last_ms=round((time.monotonic() - t0) * 1000),
publish_count=self._status["publish_count"] + 1,
writes=self._status["writes"] + (1 if wrote else 0),
)
def _build(self, result: Any, stats: dict[str, Any]) -> dict[str, Any]:
"""Mirror ``_cmd_serialize``'s assembly step, then re-apply runtime edges."""
tool = self._tool
serialized: dict[str, Any]
trees = stats.get("trees")
if not stats["fallback"]:
base = tool.serialize(result)
cached_modules = stats["cached_modules"]
reparsed = set(stats["reparsed"])
out_modules = []
for mod_dict in base["modules"]:
mpath = mod_dict["path"]
if mpath not in reparsed and mpath in cached_modules:
out_modules.append(cached_modules[mpath])
else:
single: dict[str, Any] = {"modules": [mod_dict]}
tool._augment_end_line_and_complexity(single, trees)
out_modules.append(single["modules"][0])
base["modules"] = out_modules
serialized = base
else:
serialized = tool._augment_end_line_and_complexity(
tool.serialize(result), trees
)
serialized["file_meta"] = tool.compute_file_meta(
result.modules, root=self._repo_root
)
edges = self._runtime_edges()
if edges is not None:
cg = self._callgraph_module()
_, func_index = cg.collect_ast_nodes(serialized)
cg.augment_ast(func_index, edges)
return serialized