--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>
521 lines
20 KiB
Python
521 lines
20 KiB
Python
"""The deterministic status machine behind every case (plan Steps 17-20).
|
|
|
|
Status is derived from `logg.jsonl` and from nothing else. `sak.md`
|
|
frontmatter is a cache of that derivation, and where the two disagree the log
|
|
wins and the divergence is reported rather than quietly repaired (risk H3).
|
|
A cache nothing refreshes is only permanent divergence with better manners,
|
|
so this module also owns the write-back: :func:`oppdater` rewrites exactly
|
|
four frontmatter keys -- `status`, `ventende_part`, `sist_aktivitet`,
|
|
`neste_frist` -- and touches no other key and no body line. It is the ONLY
|
|
writer of those four keys, and every other path through this module is a pure
|
|
reader.
|
|
|
|
Three decisions this step had to take, stated here rather than left implicit.
|
|
|
|
**Two rows of the transition table are not events.** The plan writes "Decision
|
|
`ja`" and "Operator close" without the word *event*, and neither is in the
|
|
build-brief 5.3 enum, which is closed at eleven and stays closed. They reach
|
|
the case log as the decision-log records build-brief 5.7 already mirrors
|
|
there: a `type: beslutning` line carrying `beslutning: ja`, and a
|
|
`type: utfall` line carrying `utfall: avsluttet`. So the case log is a stream
|
|
of *triggers* with two spellings -- `hendelse` for the eleven events, `type`
|
|
for the two mirrored records -- and :func:`trigger_of` is the single place
|
|
that decides which. A `beslutning: nei` in a case log is not a trigger: a no
|
|
creates no case and advances none.
|
|
|
|
**`neste_frist` is the date this case goes silent.** Nothing in the brief
|
|
derives it, and `--oppdater` is required to write it from derived truth, so it
|
|
has to mean something the machine can compute. It is the earliest threshold
|
|
date among the silence rules that apply to the case right now, and `null`
|
|
where no rule applies.
|
|
|
|
**A derivation of `None` does not overwrite the cache.** `neste_frist` in
|
|
`vurderer` and `soker` is the application deadline, which is the operator's
|
|
own data and which no machine here can recompute. A refresh that emptied the
|
|
one field the operator typed would be a defect wearing a refresh's clothes,
|
|
so :func:`oppdater` writes a key only when the derivation has a value for it,
|
|
and :func:`divergenser` likewise compares only those. This is a deliberate
|
|
deviation from a literal reading of "rewrites the four keys"; it is the only
|
|
one in this module.
|
|
|
|
Determinism is a property of the interface. The clock is injected as a date,
|
|
never read off the wall, and nothing that reaches stdout is iterated out of a
|
|
dict or a set -- two runs are byte-identical, including under different hash
|
|
seeds.
|
|
"""
|
|
|
|
import datetime
|
|
import os
|
|
|
|
from jobbsok_lib import frontmatter as frontmatter_lib
|
|
from jobbsok_lib import jsonl, paths
|
|
|
|
#: The closed event vocabulary of build-brief 5.3. An event outside it is an
|
|
#: error naming this whole set, never a silent skip.
|
|
HENDELSER = (
|
|
"opprettet",
|
|
"soknad_sendt",
|
|
"bekreftelse_mottatt",
|
|
"henvendelse_mottatt",
|
|
"svar_sendt",
|
|
"intervju_avtalt",
|
|
"intervju_gjennomfort",
|
|
"tilbud_mottatt",
|
|
"avslag",
|
|
"trukket",
|
|
"stille",
|
|
)
|
|
|
|
#: The decision mirrored into the case log, and the operator's close. Not
|
|
#: events; see the module docstring.
|
|
BESLUTNING_JA = "beslutning_ja"
|
|
AVSLUTTET = "avsluttet"
|
|
|
|
#: Everything the table can be keyed by.
|
|
TRIGGERE = HENDELSER + (BESLUTNING_JA, AVSLUTTET)
|
|
|
|
#: build-brief 6: the main chain, then the two terminal side-states.
|
|
TILSTANDER = (
|
|
"vurderer", "soker", "sendt", "dialog", "intervju", "tilbud", "avsluttet",
|
|
"avslag", "trukket",
|
|
)
|
|
|
|
#: Nothing leaves these. An event after one of them is an error, not a
|
|
#: reopening -- a case that starts again is a new case with a new sak-id.
|
|
TERMINALE = ("avsluttet", "avslag", "trukket")
|
|
|
|
#: The state and waiting party each trigger lands on. `stille` maps to None:
|
|
#: it is operator-appended, carries no transition, and this module never
|
|
#: appends it (there is no autonomous scheduling).
|
|
OVERGANGER = {
|
|
"opprettet": ("vurderer", "meg"),
|
|
BESLUTNING_JA: ("soker", "meg"),
|
|
"soknad_sendt": ("sendt", "dem"),
|
|
"bekreftelse_mottatt": ("sendt", "dem"),
|
|
"henvendelse_mottatt": ("dialog", "meg"),
|
|
"svar_sendt": ("dialog", "dem"),
|
|
"intervju_avtalt": ("intervju", "ingen"),
|
|
"intervju_gjennomfort": ("intervju", "dem"),
|
|
"tilbud_mottatt": ("tilbud", "meg"),
|
|
AVSLUTTET: ("avsluttet", "ingen"),
|
|
"avslag": ("avslag", "ingen"),
|
|
"trukket": ("trukket", "ingen"),
|
|
"stille": None,
|
|
}
|
|
|
|
#: Which triggers are legal from which state. A trigger whose target is the
|
|
#: state itself is listed, so a duplicate event is idempotent rather than an
|
|
#: error -- an operator who logs the same send twice made a bookkeeping slip,
|
|
#: not a state error. `avslag`, `trukket`, `avsluttet` and `stille` are legal
|
|
#: from every non-terminal state: a case can be rejected, withdrawn, closed or
|
|
#: noted as quiet at any point.
|
|
_ALLTID = ("avslag", "trukket", AVSLUTTET, "stille")
|
|
LOVLIGE = {
|
|
"vurderer": ("opprettet", BESLUTNING_JA) + _ALLTID,
|
|
"soker": (BESLUTNING_JA, "soknad_sendt") + _ALLTID,
|
|
"sendt": (
|
|
"soknad_sendt", "bekreftelse_mottatt", "henvendelse_mottatt",
|
|
"intervju_avtalt", "tilbud_mottatt",
|
|
) + _ALLTID,
|
|
"dialog": (
|
|
"henvendelse_mottatt", "svar_sendt", "intervju_avtalt", "tilbud_mottatt",
|
|
) + _ALLTID,
|
|
"intervju": (
|
|
"intervju_avtalt", "intervju_gjennomfort", "henvendelse_mottatt",
|
|
"tilbud_mottatt",
|
|
) + _ALLTID,
|
|
"tilbud": ("tilbud_mottatt",) + _ALLTID,
|
|
"avsluttet": (),
|
|
"avslag": (),
|
|
"trukket": (),
|
|
}
|
|
|
|
#: Events that mean the other party actually made contact. An automated
|
|
#: receipt is not contact, which is why `bekreftelse_mottatt` is absent: the
|
|
#: plan says it must not reset the silence clock, and leaving it out here is
|
|
#: what makes that true rather than commented.
|
|
INNKOMMENDE = ("henvendelse_mottatt", "intervju_avtalt", "tilbud_mottatt")
|
|
|
|
#: Flag names in the order they are emitted. Fixed, so output ordering never
|
|
#: depends on which rule happened to fire first.
|
|
FLAGG = ("sendt_14", "dialog_7", "intervju_10")
|
|
|
|
#: build-brief 6's three silence thresholds, in days.
|
|
TERSKLER = {"sendt_14": 14, "dialog_7": 7, "intervju_10": 10}
|
|
|
|
#: The frontmatter keys this module caches, and the only ones it ever writes.
|
|
CACHE_KEYS = ("status", "ventende_part", "sist_aktivitet", "neste_frist")
|
|
|
|
#: The one non-value a frontmatter scalar can carry. `null` reads back as the
|
|
#: string "null", so the mapping is done here rather than in the parser, which
|
|
#: must keep returning the operator's bytes untouched.
|
|
NULL = "null"
|
|
|
|
|
|
class StatusError(Exception):
|
|
"""A refusal from the status machine, naming what would have been legal."""
|
|
|
|
|
|
def trigger_of(record):
|
|
"""Map a case-log record to a trigger name, or None when it carries none.
|
|
|
|
Raises :class:`StatusError` for a record that names an event outside the
|
|
closed enum, and for one this module cannot read at all. Returning None is
|
|
reserved for records that are legitimately not transitions -- a
|
|
`beslutning: nei`, a `korrigering`, an `utfall` that is not the close.
|
|
"""
|
|
if not isinstance(record, dict):
|
|
raise StatusError("a case-log record must be an object, got %s"
|
|
% type(record).__name__)
|
|
|
|
if "hendelse" in record:
|
|
hendelse = record["hendelse"]
|
|
if hendelse not in HENDELSER:
|
|
raise StatusError(
|
|
"%r is not a case event. The vocabulary is closed: %s"
|
|
% (hendelse, ", ".join(HENDELSER))
|
|
)
|
|
return hendelse
|
|
|
|
kind = record.get("type")
|
|
if kind == "beslutning":
|
|
return BESLUTNING_JA if record.get("beslutning") == "ja" else None
|
|
if kind == "utfall":
|
|
return AVSLUTTET if record.get("utfall") == AVSLUTTET else None
|
|
if kind == "korrigering":
|
|
return None
|
|
|
|
raise StatusError(
|
|
"a case-log record must carry 'hendelse' from the 5.3 enum or 'type' "
|
|
"from %r; got %r" % (list(jsonl.TYPES), sorted(record))
|
|
)
|
|
|
|
|
|
def avgjor(records, today):
|
|
"""Derive status, waiting party, dates and silence flags from a case log.
|
|
|
|
``today`` is injected, always: a machine that only behaves deterministically
|
|
because a test patched its clock is a machine its callers cannot make
|
|
behave deterministically at all.
|
|
"""
|
|
dagen = _dato(today)
|
|
ordered = jsonl.by_time(records)
|
|
|
|
status, ventende = "vurderer", "meg"
|
|
sist_aktivitet = None
|
|
sist_innkommende = None
|
|
sendt_fra = None
|
|
intervju_holdt = None
|
|
utfall_etter_intervju = False
|
|
|
|
for record in ordered:
|
|
trigger = trigger_of(record)
|
|
if trigger is None:
|
|
continue
|
|
if status in TERMINALE:
|
|
raise StatusError(
|
|
"%r comes after the terminal state %r; a case that starts "
|
|
"again is a new case with a new sak-id" % (trigger, status)
|
|
)
|
|
if trigger not in LOVLIGE[status]:
|
|
raise StatusError(
|
|
"%r is not legal from %r. Legal here: %s"
|
|
% (trigger, status, ", ".join(sorted(LOVLIGE[status])))
|
|
)
|
|
|
|
maal = OVERGANGER[trigger]
|
|
if maal is not None:
|
|
status, ventende = maal
|
|
|
|
naa = jsonl.instant(record).date()
|
|
sist_aktivitet = naa if sist_aktivitet is None or naa > sist_aktivitet else sist_aktivitet
|
|
if trigger in INNKOMMENDE:
|
|
sist_innkommende = naa
|
|
if trigger == "soknad_sendt":
|
|
sendt_fra = naa
|
|
if trigger == "intervju_gjennomfort":
|
|
intervju_holdt = naa
|
|
utfall_etter_intervju = False
|
|
if intervju_holdt is not None and trigger in ("tilbud_mottatt",) + TERMINALE:
|
|
utfall_etter_intervju = True
|
|
|
|
frister = _frister(status, ventende, sist_innkommende, sendt_fra,
|
|
intervju_holdt, utfall_etter_intervju)
|
|
flagg = [navn for navn in FLAGG if navn in frister and frister[navn] <= dagen]
|
|
neste_frist = min(frister.values()) if frister else None
|
|
|
|
return {
|
|
"status": status,
|
|
"ventende_part": ventende,
|
|
"sist_aktivitet": _iso(sist_aktivitet),
|
|
"neste_frist": _iso(neste_frist),
|
|
"flagg": flagg,
|
|
"innkommende": _innkommende(sist_innkommende),
|
|
}
|
|
|
|
|
|
def _frister(status, ventende, sist_innkommende, sendt_fra, intervju_holdt,
|
|
utfall_etter_intervju):
|
|
"""The date each applicable silence rule trips, keyed by flag name.
|
|
|
|
Terminal states are never in here, and `vurderer` and `soker` never are
|
|
either: nobody is waiting on the other party yet, so there is nothing for
|
|
silence to mean.
|
|
"""
|
|
frister = {}
|
|
if status in TERMINALE:
|
|
return frister
|
|
|
|
if status == "sendt":
|
|
basis = sist_innkommende or sendt_fra
|
|
if basis is not None:
|
|
frister["sendt_14"] = basis + datetime.timedelta(days=TERSKLER["sendt_14"])
|
|
if status == "dialog" and ventende == "dem" and sist_innkommende is not None:
|
|
frister["dialog_7"] = sist_innkommende + datetime.timedelta(days=TERSKLER["dialog_7"])
|
|
if intervju_holdt is not None and not utfall_etter_intervju:
|
|
frister["intervju_10"] = intervju_holdt + datetime.timedelta(days=TERSKLER["intervju_10"])
|
|
return frister
|
|
|
|
|
|
def _innkommende(sist):
|
|
"""What is known about inbound contact -- and what is only *not recorded*.
|
|
|
|
With the mail server deferred, "no inbound" is a fact about this log and
|
|
not about the world (risk H9). Saying so is the difference between a daily
|
|
view worth reading and one that flags every case as gone quiet.
|
|
"""
|
|
if sist is not None:
|
|
return {"registrert": True, "sist": _iso(sist), "merknad": None}
|
|
return {
|
|
"registrert": False,
|
|
"sist": None,
|
|
"merknad": (
|
|
"ingen innkommende hendelse er registrert. Med e-postserveren "
|
|
"utsatt betyr det ikke at ingen finnes."
|
|
),
|
|
}
|
|
|
|
|
|
def alle_saker(root):
|
|
"""Every sak-id under ``root/saker``, sorted. Sorted, not os.listdir order."""
|
|
saker = paths.safe_join(root, "saker")
|
|
if not os.path.isdir(saker):
|
|
return []
|
|
funnet = []
|
|
for navn in sorted(os.listdir(saker)):
|
|
if not os.path.isdir(os.path.join(saker, navn)):
|
|
continue
|
|
funnet.append(paths.validate_sak_id(navn))
|
|
return funnet
|
|
|
|
|
|
def les_sak(root, sak_id):
|
|
"""Return ``(metadata, body, records)`` for one case. Pure reader."""
|
|
paths.validate_sak_id(sak_id)
|
|
katalog = paths.safe_join(root, "saker", sak_id)
|
|
with open(os.path.join(katalog, "sak.md"), "r", encoding="utf-8") as handle:
|
|
meta, body = frontmatter_lib.parse(handle.read())
|
|
return meta, body, jsonl.read_lines(os.path.join(katalog, "logg.jsonl"))
|
|
|
|
|
|
def status_for_sak(root, sak_id, today):
|
|
"""The derived truth for one case, plus how the cache disagrees with it."""
|
|
meta, _body, records = les_sak(root, sak_id)
|
|
resultat = avgjor(records, today)
|
|
resultat["sak_id"] = sak_id
|
|
resultat["arbeidsgiver"] = meta.get("arbeidsgiver")
|
|
resultat["rolle"] = meta.get("rolle")
|
|
resultat["divergens"] = _divergens(meta, resultat)
|
|
return resultat
|
|
|
|
|
|
def rapport(root, today, sak=None):
|
|
"""The whole workspace as one ordered structure. Writes nothing."""
|
|
saker = [paths.validate_sak_id(sak)] if sak else alle_saker(root)
|
|
return {
|
|
"dato": _iso(_dato(today)),
|
|
"saker": [status_for_sak(root, sak_id, today) for sak_id in saker],
|
|
}
|
|
|
|
|
|
def divergenser(root, today, sak=None):
|
|
"""Every case whose cached frontmatter disagrees with its log."""
|
|
ut = []
|
|
for resultat in rapport(root, today, sak)["saker"]:
|
|
if resultat["divergens"]:
|
|
ut.append({"sak_id": resultat["sak_id"], "divergens": resultat["divergens"]})
|
|
return ut
|
|
|
|
|
|
def oppdater(root, sak_id, resultat):
|
|
"""Rewrite the four cached keys in ``sak.md``. The only writer here.
|
|
|
|
Returns the keys that actually changed. A key whose derivation is None is
|
|
left alone -- see the module docstring.
|
|
"""
|
|
sti = paths.safe_join(root, "saker", sak_id, "sak.md")
|
|
with open(sti, "r", encoding="utf-8") as handle:
|
|
meta, body = frontmatter_lib.parse(handle.read())
|
|
|
|
endret = []
|
|
for nokkel in CACHE_KEYS:
|
|
utledet = resultat.get(nokkel)
|
|
if utledet is None:
|
|
continue
|
|
if _hurtigbuffer(meta, nokkel) != utledet:
|
|
meta[nokkel] = utledet
|
|
endret.append(nokkel)
|
|
if endret:
|
|
with open(sti, "w", encoding="utf-8") as handle:
|
|
handle.write(frontmatter_lib.render(meta, body))
|
|
return endret
|
|
|
|
|
|
def _divergens(meta, resultat):
|
|
avvik = []
|
|
for nokkel in CACHE_KEYS:
|
|
utledet = resultat.get(nokkel)
|
|
if utledet is None:
|
|
continue
|
|
hurtigbuffer = _hurtigbuffer(meta, nokkel)
|
|
if hurtigbuffer != utledet:
|
|
avvik.append({
|
|
"nokkel": nokkel,
|
|
"hurtigbuffer": hurtigbuffer,
|
|
"utledet": utledet,
|
|
})
|
|
return avvik
|
|
|
|
|
|
def _hurtigbuffer(meta, nokkel):
|
|
verdi = meta.get(nokkel)
|
|
return None if verdi == NULL else verdi
|
|
|
|
|
|
def _dato(value):
|
|
if isinstance(value, datetime.date) and not isinstance(value, datetime.datetime):
|
|
return value
|
|
try:
|
|
return datetime.date.fromisoformat(str(value))
|
|
except ValueError:
|
|
raise StatusError("--today must be YYYY-MM-DD, got %r" % (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())
|