feat(portfolio): K10 — notification/notifier seam, opt-in webhook egress (parity row 23) [skip-docs]
S5.2-analog. New notify.py: Notifier protocol + console/file/webhook sinks. The webhook (the one transport that leaves the machine) fires ONLY behind an explicit per-run opt-in flag (--allow-webhook-egress), mirroring ingest-spec §8 (the flag is a run argument, never a config field). Transport is injected — canned in the suite (NULL socket), real transport behind one seam function default_webhook_transport; an AST grep-guard proves no network path exists outside that seam. run.py (both outcomes — a budget stop notifies too) and hitl.py (read-only preserved) share the same opt-in-gated CLI seam, refusing a webhook-without-opt-in before any spend. Payload shape is stack-local (no shared notification spec; divergence documented). Two new load-bearing test files (18 tests): opt-in gate + payload structure + the grep-guard + run/hitl emit wiring + run-level opt-in threading, each detach-proven RED. 544 -> 562 green, full gate clean (ruff+format+mypy strict, 26 src files). README sync (test count x2 + notify.py module note + load-bearing omtale). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
This commit is contained in:
parent
b9dd479865
commit
a2acfc0f98
6 changed files with 863 additions and 11 deletions
|
|
@ -41,6 +41,16 @@ from pydantic import BaseModel, Field, ValidationError, model_validator
|
|||
|
||||
from portfolio_optimiser_claude.inbox import load_inbox
|
||||
from portfolio_optimiser_claude.ir import SavingsProposal
|
||||
from portfolio_optimiser_claude.notify import (
|
||||
EgressNotPermitted,
|
||||
Notification,
|
||||
Notifier,
|
||||
Transport,
|
||||
add_notify_args,
|
||||
build_notifiers,
|
||||
emit,
|
||||
notify_config_from_args,
|
||||
)
|
||||
from portfolio_optimiser_claude.okf import navigate_bundle
|
||||
|
||||
_OUTCOME_SUFFIX = "-outcome.json"
|
||||
|
|
@ -199,7 +209,7 @@ def _bundle_dirs(values: list[str] | None) -> list[Path]:
|
|||
return [Path(value) for value in (values or [])]
|
||||
|
||||
|
||||
def _cmd_pending(args: argparse.Namespace) -> int:
|
||||
def _cmd_pending(args: argparse.Namespace, notifiers: Sequence[Notifier]) -> int:
|
||||
pending = pending_proposals(
|
||||
Path(args.outbox), Path(args.inbox), bundle_dirs=_bundle_dirs(args.bundle)
|
||||
)
|
||||
|
|
@ -209,10 +219,21 @@ def _cmd_pending(args: argparse.Namespace) -> int:
|
|||
f" {proposal.run_id} verdict_id={proposal.verdict_id} "
|
||||
f"measure={proposal.measure!r} project={proposal.project_id}"
|
||||
)
|
||||
# K10: notification is egress, NOT writing any of the three read layers —
|
||||
# the read-only invariant (§3 Step 7) holds (proven by the seam test's
|
||||
# before/after byte snapshot of outbox+inbox).
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="hitl.pending",
|
||||
summary=f"{len(pending)} proposal(s) awaiting a verdict",
|
||||
fields={"pending": len(pending)},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_route(args: argparse.Namespace) -> int:
|
||||
def _cmd_route(args: argparse.Namespace, notifiers: Sequence[Notifier]) -> int:
|
||||
try:
|
||||
routing = load_routing(json.loads(Path(args.routing).read_text("utf-8")))
|
||||
except (OSError, ValueError, ValidationError) as exc:
|
||||
|
|
@ -229,15 +250,29 @@ def _cmd_route(args: argparse.Namespace) -> int:
|
|||
f" {item.proposal.run_id} measure={item.proposal.measure!r} "
|
||||
f"verdict_id={item.proposal.verdict_id} -> {expert}"
|
||||
)
|
||||
unrouted = sum(1 for item in routed if item.expert is None)
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="hitl.route",
|
||||
summary=f"{len(routed)} pending proposal(s) routed, {unrouted} UNROUTED",
|
||||
fields={"pending": len(routed), "unrouted": unrouted},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
def main(argv: list[str] | None = None, *, notifier_transport: Transport | None = None) -> int:
|
||||
"""The thin CLI: ``pending`` lists outstanding proposals; ``route`` adds experts.
|
||||
|
||||
``pending`` is a pure report (exit 0). ``route`` loads the routing config
|
||||
fail-fast — a malformed/missing config exits non-zero WITHOUT touching any
|
||||
layer (§10). Neither subcommand writes anything.
|
||||
layer (§10). Neither subcommand writes any of the three read layers.
|
||||
|
||||
An optional notify seam (K10) delivers a summary event; a ``--notify-webhook``
|
||||
without ``--allow-webhook-egress`` is refused fail-fast (§8) BEFORE any read.
|
||||
``notifier_transport`` is the injected webhook transport (canned in the suite,
|
||||
``default_webhook_transport`` on the CLI path — no socket in tests).
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
|
|
@ -256,6 +291,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
action="append",
|
||||
help="bundle dir whose promoted verdicts also settle a proposal (repeatable)",
|
||||
)
|
||||
add_notify_args(sub)
|
||||
|
||||
pending_parser = subparsers.add_parser("pending", help="list proposals awaiting a verdict")
|
||||
_add_common(pending_parser)
|
||||
|
|
@ -269,7 +305,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||
route_parser.set_defaults(func=_cmd_route)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
result: int = args.func(args)
|
||||
# K10 (§8): build the notify sinks BEFORE any read — a --notify-webhook
|
||||
# without --allow-webhook-egress is refused fail-fast here (the opt-in is a
|
||||
# run argument, never a config field).
|
||||
try:
|
||||
notifiers = build_notifiers(
|
||||
notify_config_from_args(args), webhook_transport=notifier_transport
|
||||
)
|
||||
except EgressNotPermitted as exc:
|
||||
parser.error(str(exc))
|
||||
result: int = args.func(args, notifiers)
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue