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:
parent
b80bd00781
commit
12125c1932
3 changed files with 420 additions and 0 deletions
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue