feat(m2): add deterministic sak_status cli with golden output

--workspace is required with no environment fall-back: this process runs
unsandboxed, and a status run that quietly picked a workspace is one nobody
can audit. --check exits 1 on a stale cache, distinct from 2 for a failed run.

Byte-stability is measured, not asserted: the CLI is run twice under two
PYTHONHASHSEED values and the bytes compared, in both output formats. The
write boundary gets two digest tests rather than one -- without --oppdater
every byte stays put, with it exactly the diverging case's four cached keys
move and logg.jsonl, the body and every other file are byte-identical.

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 21:40:32 +02:00
commit 12125c1932
3 changed files with 420 additions and 0 deletions

View file

@ -403,3 +403,119 @@ def _dato(value):
def _iso(value):
return None if value is None else value.isoformat()
# ---------------------------------------------------------------------------
# Command line (plan Step 20)
#
# Output ordering is fully determined: cases come out sorted by sak-id, keys
# in JSON are sorted, and flags come out in FLAGG order. Nothing here iterates
# a dict or a set into stdout, which is what makes two runs byte-identical
# under different hash seeds rather than only usually.
# ---------------------------------------------------------------------------
#: Exit codes. 1 is reserved for `--check` finding divergence, so a caller can
#: tell "the cache is stale" apart from "the run failed".
EXIT_OK = 0
EXIT_DIVERGENS = 1
EXIT_FEIL = 2
def build_parser():
import argparse
parser = argparse.ArgumentParser(
prog="sak_status.py",
description=(
"Utled status, ventende part og stillhets-flagg for hver sak i "
"arbeidsomraadet. Leser logg.jsonl; sak.md er en hurtigbuffer."
),
)
# Required, and deliberately without the environment fall-backs
# paths.workspace_root offers: this process runs unsandboxed, and a
# status run that quietly picked a workspace would be a status run
# nobody could audit.
parser.add_argument("--workspace", required=True,
help="arbeidsomraadets rot (ingen underforstaatt standard)")
parser.add_argument("--today", default=None, metavar="YYYY-MM-DD",
help="klokka som skal brukes; injisert, aldri veggklokka")
parser.add_argument("--sak", default=None, metavar="SAK-ID",
help="begrens til en enkelt sak")
parser.add_argument("--format", dest="format", default="tekst",
choices=("tekst", "json"))
parser.add_argument("--check", action="store_true",
help="avslutt med kode 1 hvis en hurtigbuffer avviker")
parser.add_argument("--oppdater", action="store_true",
help="skriv de fire hurtigbuffer-noklene tilbake til sak.md")
return parser
def _skriv(strom, tekst):
strom.write(tekst)
strom.flush()
def _som_json(data):
import json as _json
return _json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
def _som_tekst(data):
linjer = ["sak-status %s (%d saker)" % (data["dato"], len(data["saker"])), ""]
for sak in data["saker"]:
linjer.append(sak["sak_id"])
linjer.append(" status: %-9s ventende: %-6s sist aktivitet: %s"
% (sak["status"], sak["ventende_part"],
sak["sist_aktivitet"] or "-"))
linjer.append(" neste frist: %s" % (sak["neste_frist"] or "-"))
if sak["flagg"]:
linjer.append(" flagg: %s" % ", ".join(sak["flagg"]))
if not sak["innkommende"]["registrert"]:
linjer.append(" innkommende: %s" % sak["innkommende"]["merknad"])
for avvik in sak["divergens"]:
linjer.append(" avvik: %s hurtigbuffer=%r utledet=%r"
% (avvik["nokkel"], avvik["hurtigbuffer"], avvik["utledet"]))
linjer.append("")
return "\n".join(linjer)
def main(argv=None, stdout=None, stderr=None):
import sys as _sys
argv = _sys.argv[1:] if argv is None else argv
stdout = _sys.stdout if stdout is None else stdout
stderr = _sys.stderr if stderr is None else stderr
args = build_parser().parse_args(argv)
today = args.today or datetime.date.today().isoformat()
try:
root = paths.workspace_root(args.workspace)
if args.sak is not None:
paths.validate_sak_id(args.sak)
data = rapport(root, today, args.sak)
if args.oppdater:
for sak in data["saker"]:
oppdater(root, sak["sak_id"], sak)
data = rapport(root, today, args.sak)
except (StatusError, paths.WorkspaceError, jsonl.JsonlError,
frontmatter_lib.FrontmatterError, OSError) as feil:
_skriv(stderr, "sak_status: %s\n" % feil)
return EXIT_FEIL
_skriv(stdout, _som_json(data) if args.format == "json" else _som_tekst(data))
if args.check:
avvikende = [sak for sak in data["saker"] if sak["divergens"]]
if avvikende:
_skriv(stdout, "\n%d sak(er) med utdatert hurtigbuffer. Kjor --oppdater.\n"
% len(avvikende))
return EXIT_DIVERGENS
return EXIT_OK
if __name__ == "__main__":
import sys as _sys
_sys.exit(main())