189 lines
7.4 KiB
Python
189 lines
7.4 KiB
Python
"""Immutable protocol records with one atomic latest-generation publication."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from src.meeting_lab.models.meeting_context import MeetingContext, write_meeting_context
|
|
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
|
|
|
|
_DIAGNOSTICS = (
|
|
"exact_prompt.txt",
|
|
"transcript_input.txt",
|
|
"raw_response.json",
|
|
"runtime_metadata.json",
|
|
)
|
|
|
|
|
|
def _git_provenance(repository: Path) -> dict[str, Any]:
|
|
provenance: dict[str, Any] = {"repository": str(repository), "sha": None, "dirty": None}
|
|
try:
|
|
provenance["sha"] = subprocess.check_output(
|
|
["git", "rev-parse", "HEAD"], cwd=repository, text=True, stderr=subprocess.DEVNULL
|
|
).strip()
|
|
provenance["dirty"] = bool(
|
|
subprocess.check_output(
|
|
["git", "status", "--porcelain"],
|
|
cwd=repository,
|
|
text=True,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return provenance
|
|
|
|
|
|
def _write_json(path: Path, value: Any) -> None:
|
|
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _replace_link(path: Path, target: str) -> None:
|
|
"""Replace a link/file atomically; never write through an existing symlink."""
|
|
temporary = path.with_name(f".{path.name}-{uuid4().hex}")
|
|
try:
|
|
temporary.symlink_to(target)
|
|
os.replace(temporary, path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def _sync_record(directory: Path) -> None:
|
|
for path in directory.iterdir():
|
|
with path.open("rb") as stream:
|
|
os.fsync(stream.fileno())
|
|
descriptor = os.open(directory, os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def _legacy_generation(run_dir: Path, protocol_dir: Path, generations: Path) -> None:
|
|
"""Bootstrap old regular files before switching any compatibility paths."""
|
|
if (protocol_dir / "current").is_symlink() or not (run_dir / "protocol.md").is_file():
|
|
return
|
|
# Preserve the latest regular-file state even for runs created by an older
|
|
# history implementation. Never reuse or overwrite an existing record.
|
|
index = max(
|
|
(int(p.name) for p in generations.iterdir() if p.is_dir() and p.name.isdigit()),
|
|
default=0,
|
|
) + 1
|
|
legacy = generations / f"{index:03d}"
|
|
pending = Path(tempfile.mkdtemp(prefix=".legacy-", dir=generations))
|
|
try:
|
|
shutil.copyfile(run_dir / "protocol.md", pending / "protocol.md")
|
|
for name in _DIAGNOSTICS:
|
|
source = protocol_dir / name
|
|
if source.is_file():
|
|
shutil.copyfile(source, pending / name)
|
|
context = run_dir / "context/meeting_context.yaml"
|
|
if context.is_file():
|
|
shutil.copyfile(context, pending / "meeting_context.yaml")
|
|
_sync_record(pending)
|
|
pending.rename(legacy)
|
|
finally:
|
|
shutil.rmtree(pending, ignore_errors=True)
|
|
_replace_link(protocol_dir / "current", f"generations/{legacy.name}")
|
|
|
|
|
|
def _compatibility_links(run_dir: Path, protocol_dir: Path, pending: Path) -> None:
|
|
"""Install indirections while they still resolve to the previous generation."""
|
|
links = [(run_dir / "protocol.md", "protocol/current/protocol.md")]
|
|
links.extend((protocol_dir / name, f"current/{name}") for name in _DIAGNOSTICS)
|
|
if (pending / "meeting_context.yaml").is_file():
|
|
context_dir = run_dir / "context"
|
|
context_dir.mkdir(exist_ok=True)
|
|
links.append(
|
|
(context_dir / "meeting_context.yaml", "../protocol/current/meeting_context.yaml")
|
|
)
|
|
for path, target in links:
|
|
if not path.is_symlink() or os.readlink(path) != target:
|
|
_replace_link(path, target)
|
|
|
|
|
|
def persist_protocol_generation(
|
|
run_dir: Path,
|
|
result: DirectProtocolResult,
|
|
*,
|
|
context: MeetingContext | None,
|
|
) -> Path:
|
|
"""Publish a complete record; current is the sole successful-generation pointer.
|
|
|
|
Readers needing a multi-file snapshot should resolve current once. Relative
|
|
symlinks keep complete run directories movable. Writes are serialized locally.
|
|
"""
|
|
protocol_dir = run_dir / "protocol"
|
|
protocol_dir.mkdir(exist_ok=True)
|
|
generations = protocol_dir / "generations"
|
|
generations.mkdir(exist_ok=True)
|
|
with (protocol_dir / ".publication.lock").open("a") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
_legacy_generation(run_dir, protocol_dir, generations)
|
|
index = (
|
|
max(
|
|
(int(p.name) for p in generations.iterdir() if p.is_dir() and p.name.isdigit()),
|
|
default=0,
|
|
)
|
|
+ 1
|
|
)
|
|
destination = generations / f"{index:03d}"
|
|
pending = Path(tempfile.mkdtemp(prefix=".pending-", dir=generations))
|
|
published = False
|
|
try:
|
|
metadata = dict(result.runtime_metadata)
|
|
metadata.update(
|
|
{
|
|
"generation_index": index,
|
|
"generation_timestamp": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
"source_run_id": run_dir.name,
|
|
"speaker_mapping": dict(sorted(context.speaker_mappings.items()))
|
|
if context
|
|
else {},
|
|
"speaker_mapping_names": {
|
|
label: context.participant_for_speaker(label).get("display_name", "")
|
|
for label in sorted(context.speaker_mappings)
|
|
if context.participant_for_speaker(label) is not None
|
|
}
|
|
if context
|
|
else {},
|
|
"git": {"meeting_lab": _git_provenance(Path(__file__).resolve().parents[3])},
|
|
}
|
|
)
|
|
(pending / "protocol.md").write_text(result.protocol_text, encoding="utf-8")
|
|
(pending / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
|
|
if result.transcript_input is not None:
|
|
(pending / "transcript_input.txt").write_text(
|
|
result.transcript_input, encoding="utf-8"
|
|
)
|
|
_write_json(pending / "raw_response.json", result.raw_response)
|
|
_write_json(pending / "runtime_metadata.json", metadata)
|
|
_write_json(pending / "model_metadata.json", result.model_metadata)
|
|
if context is not None:
|
|
write_meeting_context(context, pending / "meeting_context.yaml")
|
|
_sync_record(pending)
|
|
_compatibility_links(run_dir, protocol_dir, pending)
|
|
pending.rename(destination)
|
|
_replace_link(protocol_dir / "current", f"generations/{destination.name}")
|
|
published = True
|
|
finally:
|
|
shutil.rmtree(pending, ignore_errors=True)
|
|
# An interrupted process may leave an unreferenced complete record;
|
|
# never delete the record selected by current, even after interruption.
|
|
if (
|
|
not published
|
|
and destination.exists()
|
|
and (protocol_dir / "current").resolve() != destination
|
|
):
|
|
shutil.rmtree(destination, ignore_errors=True)
|
|
return run_dir / "protocol.md"
|