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())

View file

@ -0,0 +1,150 @@
{
"dato": "2026-09-15",
"saker": [
{
"arbeidsgiver": "Aurland Maritim AS",
"divergens": [],
"flagg": [],
"innkommende": {
"merknad": "ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.",
"registrert": false,
"sist": null
},
"neste_frist": null,
"rolle": "Prosjektleder",
"sak_id": "2026-09-aurland-maritim-as-prosjektleder",
"sist_aktivitet": "2026-09-05",
"status": "trukket",
"ventende_part": "ingen"
},
{
"arbeidsgiver": "Bratthaug Industri AS",
"divergens": [],
"flagg": [
"intervju_10"
],
"innkommende": {
"merknad": null,
"registrert": true,
"sist": "2026-08-16"
},
"neste_frist": "2026-09-05",
"rolle": "Systemarkitekt",
"sak_id": "2026-09-bratthaug-industri-as-systemarkitekt",
"sist_aktivitet": "2026-08-26",
"status": "intervju",
"ventende_part": "dem"
},
{
"arbeidsgiver": "Havbris Energi AS",
"divergens": [
{
"hurtigbuffer": "dialog",
"nokkel": "status",
"utledet": "sendt"
},
{
"hurtigbuffer": "meg",
"nokkel": "ventende_part",
"utledet": "dem"
}
],
"flagg": [
"sendt_14"
],
"innkommende": {
"merknad": "ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.",
"registrert": false,
"sist": null
},
"neste_frist": "2026-09-09",
"rolle": "Løsningsarkitekt",
"sak_id": "2026-09-havbris-energi-as-losningsarkitekt",
"sist_aktivitet": "2026-08-27",
"status": "sendt",
"ventende_part": "dem"
},
{
"arbeidsgiver": "Myrvang Teknologi AS",
"divergens": [],
"flagg": [],
"innkommende": {
"merknad": "ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.",
"registrert": false,
"sist": null
},
"neste_frist": null,
"rolle": "Utvikler",
"sak_id": "2026-09-myrvang-teknologi-as-utvikler",
"sist_aktivitet": "2026-08-06",
"status": "avslag",
"ventende_part": "ingen"
},
{
"arbeidsgiver": "Nordlys Data AS",
"divergens": [],
"flagg": [],
"innkommende": {
"merknad": "ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.",
"registrert": false,
"sist": null
},
"neste_frist": null,
"rolle": "AI-rådgiver",
"sak_id": "2026-09-nordlys-data-as-ai-radgiver",
"sist_aktivitet": "2026-09-13",
"status": "vurderer",
"ventende_part": "meg"
},
{
"arbeidsgiver": "Solvang Helse AS",
"divergens": [],
"flagg": [],
"innkommende": {
"merknad": null,
"registrert": true,
"sist": "2026-09-12"
},
"neste_frist": null,
"rolle": "Teknisk rådgiver",
"sak_id": "2026-09-solvang-helse-as-teknisk-radgiver",
"sist_aktivitet": "2026-09-12",
"status": "tilbud",
"ventende_part": "meg"
},
{
"arbeidsgiver": "Storelva kommune",
"divergens": [],
"flagg": [
"dialog_7"
],
"innkommende": {
"merknad": null,
"registrert": true,
"sist": "2026-08-21"
},
"neste_frist": "2026-08-28",
"rolle": "Fagleder digitalisering",
"sak_id": "2026-09-storelva-kommune-fagleder-digitalisering",
"sist_aktivitet": "2026-08-22",
"status": "dialog",
"ventende_part": "dem"
},
{
"arbeidsgiver": "Værøy Sjømat AS",
"divergens": [],
"flagg": [],
"innkommende": {
"merknad": "ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.",
"registrert": false,
"sist": null
},
"neste_frist": null,
"rolle": "Dataingeniør",
"sak_id": "2026-09-vaeroy-sjomat-as-dataingenior",
"sist_aktivitet": "2026-09-10",
"status": "soker",
"ventende_part": "meg"
}
]
}

View file

@ -0,0 +1,154 @@
"""The sak_status command line, and what it is allowed to write (plan Step 20).
Two properties are asserted here that no unit test can reach, because both are
about the process rather than the function.
**Byte-stability.** The golden file is only evidence if the same workspace and
the same `--today` produce the same bytes every time, including under a
different `PYTHONHASHSEED`. Python randomises string hashing per process, so a
dictionary or a set that reached stdout would reorder between runs and the
golden would fail intermittently -- the worst way for a determinism bug to
present itself. Running the CLI twice under two seeds and comparing bytes is
what turns "no dict iteration order reaches stdout" from an intention into a
measurement.
**The write boundary.** The read paths write nothing, and `--oppdater` is the
one exception -- not a caveat, a named mode. So there are two digest tests,
not one: a run without the flag leaves every byte in the workspace where it
was, and a run with it changes exactly the four cached frontmatter keys and
leaves `logg.jsonl`, the `sak.md` body and every other file byte-identical.
Saying "the script writes nothing" without that second test is what let the
write-back go unnoticed in the first place.
Style note: this file follows tests/test_sak_status_silence.py.
"""
import hashlib
import json
import os
import subprocess
import sys
import pytest
import sak_status
from jobbsok_lib import frontmatter as frontmatter_lib
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CLI = os.path.join(REPO, "scripts", "sak_status.py")
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
GOLDEN = os.path.join(REPO, "tests", "golden", "sak-status-2026-09-15.json")
TODAY = "2026-09-15"
#: The case whose cached frontmatter is deliberately wrong (Step 19).
UENIG = "2026-09-havbris-energi-as-losningsarkitekt"
def kjor(*argv, **kwargs):
"""Run the CLI as a process and return (exit code, stdout, stderr) bytes."""
env = dict(os.environ)
env.pop("JOBBSOK_WORKSPACE", None)
env.pop("CLAUDE_PLUGIN_DATA", None)
env.update(kwargs.get("env", {}))
proc = subprocess.run(
[sys.executable, CLI] + list(argv),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env,
)
return proc.returncode, proc.stdout, proc.stderr
def digest(root):
"""Every file under ``root`` mapped to the sha256 of its bytes."""
ut = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for navn in sorted(filenames):
full = os.path.join(dirpath, navn)
with open(full, "rb") as handle:
ut[os.path.relpath(full, root)] = hashlib.sha256(handle.read()).hexdigest()
return ut
def test_the_json_output_matches_the_golden_file(golden):
kode, ut, feil = kjor("--workspace", KORPUS, "--today", TODAY, "--format", "json")
assert kode == 0, feil.decode("utf-8")
golden(GOLDEN, ut.decode("utf-8"))
def test_two_consecutive_runs_are_byte_identical():
_kode, forste, _ = kjor("--workspace", KORPUS, "--today", TODAY, "--format", "json")
_kode, andre, _ = kjor("--workspace", KORPUS, "--today", TODAY, "--format", "json")
assert forste == andre
def test_two_different_hash_seeds_produce_the_same_bytes():
kwargs = ("--workspace", KORPUS, "--today", TODAY, "--format", "json")
_kode, null, _ = kjor(*kwargs, env={"PYTHONHASHSEED": "0"})
_kode, mange, _ = kjor(*kwargs, env={"PYTHONHASHSEED": "1048573"})
assert null == mange, (
"output moved with PYTHONHASHSEED, so a dict or a set reached stdout"
)
# And the text form too, which is the one the operator actually reads.
tekst = ("--workspace", KORPUS, "--today", TODAY)
_kode, a, _ = kjor(*tekst, env={"PYTHONHASHSEED": "0"})
_kode, b, _ = kjor(*tekst, env={"PYTHONHASHSEED": "1048573"})
assert a == b
def test_omitting_workspace_is_an_error_and_never_a_guess_at_home():
kode, ut, feil = kjor("--today", TODAY)
assert kode != 0
assert b"--workspace" in feil
assert os.path.expanduser("~").encode("utf-8") not in ut + feil, (
"the refusal leaked a home directory; there is no default workspace"
)
def test_a_traversing_case_identifier_is_refused():
kode, _ut, feil = kjor(
"--workspace", KORPUS, "--today", TODAY, "--sak", "../../etc/passwd"
)
assert kode != 0
assert b"../../etc/passwd" in feil
def test_a_run_without_oppdater_leaves_every_byte_where_it_was(workspace):
forst = digest(workspace)
kode, _ut, feil = kjor("--workspace", workspace, "--today", TODAY, "--format", "json")
assert kode == 0, feil.decode("utf-8")
assert digest(workspace) == forst, "the read path wrote something"
def test_a_run_with_oppdater_changes_only_the_four_cached_keys(workspace):
sti = os.path.join(workspace, "saker", UENIG, "sak.md")
with open(sti, "r", encoding="utf-8") as handle:
for_meta, for_body = frontmatter_lib.parse(handle.read())
forst = digest(workspace)
kode, _ut, feil = kjor("--workspace", workspace, "--today", TODAY, "--oppdater")
assert kode == 0, feil.decode("utf-8")
etter = digest(workspace)
endrede = sorted(navn for navn in forst if forst[navn] != etter[navn])
assert endrede == [os.path.join("saker", UENIG, "sak.md")], (
"--oppdater touched %r; only the diverging case's sak.md may move"
% (endrede,)
)
assert sorted(etter) == sorted(forst), "--oppdater added or removed a file"
with open(sti, "r", encoding="utf-8") as handle:
etter_meta, etter_body = frontmatter_lib.parse(handle.read())
assert etter_body == for_body
flyttet = sorted(k for k in for_meta if for_meta[k] != etter_meta.get(k))
assert flyttet == ["status", "ventende_part"]
assert set(flyttet) <= set(sak_status.CACHE_KEYS)
def test_check_exits_non_zero_on_the_deliberately_divergent_fixture(workspace):
kode, ut, _feil = kjor("--workspace", KORPUS, "--today", TODAY, "--check")
assert kode != 0
assert UENIG.encode("utf-8") in ut, "the check must name the case that diverged"
kjor("--workspace", workspace, "--today", TODAY, "--oppdater")
kode, _ut, _feil = kjor("--workspace", workspace, "--today", TODAY, "--check")
assert kode == 0, "a workspace that has just been refreshed must check clean"