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
|
|
@ -24,7 +24,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
from portfolio_optimiser_claude.artifacts import (
|
||||
_dump_json,
|
||||
|
|
@ -47,6 +47,16 @@ from portfolio_optimiser_claude.ir import SavingsProposal, load_validator_input
|
|||
from portfolio_optimiser_claude.okf import bundle_context, navigate_bundle
|
||||
from portfolio_optimiser_claude.provenance import Citation, Provenance
|
||||
from portfolio_optimiser_claude.loop import ModelClient, run_project
|
||||
from portfolio_optimiser_claude.notify import (
|
||||
EgressNotPermitted,
|
||||
Notification,
|
||||
Notifier,
|
||||
Transport,
|
||||
add_notify_args,
|
||||
build_notifiers,
|
||||
emit,
|
||||
notify_config_from_args,
|
||||
)
|
||||
from portfolio_optimiser_claude.validator import Rejection
|
||||
|
||||
_PROPOSER_ROLE = "proposer"
|
||||
|
|
@ -135,6 +145,7 @@ def execute_run(
|
|||
max_attempts: int,
|
||||
outbox_dir: Path | None = None,
|
||||
run_id: str | None = None,
|
||||
notifiers: Sequence[Notifier] = (),
|
||||
) -> int:
|
||||
"""Drive the loop under the §8 meter; persist artifacts on BOTH outcomes.
|
||||
|
||||
|
|
@ -146,7 +157,12 @@ def execute_run(
|
|||
proposal/outcome pair to the outbox (S2.1) — the system's own output layer,
|
||||
read by K8 (live capture) and K9 (pending tracking). A budget stop has no
|
||||
proposal, so it writes no outbox pair.
|
||||
|
||||
``notifiers`` (K10) receive a structured event on BOTH outcomes — a budget
|
||||
stop is a run outcome, not an absence of one, so it notifies too. The list
|
||||
is empty unless the operator configured a sink; delivery never gates the run.
|
||||
"""
|
||||
run_label = run_id or out_dir.name
|
||||
meter = BudgetMeter(contracts.termination)
|
||||
try:
|
||||
result = run_project(
|
||||
|
|
@ -169,6 +185,14 @@ def execute_run(
|
|||
)
|
||||
for name, path in sorted(stop_paths.items()):
|
||||
print(f"artifact: {name} -> {path}")
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="run.stopped",
|
||||
summary=f"run {run_label} stopped by budget: {stop.kind}",
|
||||
fields={"kind": stop.kind, "observed": stop.observed, "limit": stop.limit},
|
||||
),
|
||||
)
|
||||
return 3
|
||||
|
||||
provenance = Provenance(
|
||||
|
|
@ -200,6 +224,19 @@ def execute_run(
|
|||
)
|
||||
for name, path in sorted(outbox_paths.items()):
|
||||
print(f"outbox: {name} -> {path}")
|
||||
emit(
|
||||
notifiers,
|
||||
Notification(
|
||||
event="run.completed",
|
||||
summary=f"run {run_label}: {outcome_kind}",
|
||||
fields={
|
||||
"validator_decision": result.validator_decision,
|
||||
"checker_decision": result.checker_decision,
|
||||
"attempts": result.attempts,
|
||||
"outcome": outcome_kind,
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -294,8 +331,18 @@ def execute_dry_run(
|
|||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None = None) -> int:
|
||||
"""The thin CLI: contracts fail-fast (§10) → compose (§5) → execute (§3, §8)."""
|
||||
def main(
|
||||
argv: list[str] | None = None,
|
||||
*,
|
||||
client_factory: ClientFactory | None = None,
|
||||
notifier_transport: Transport | None = None,
|
||||
) -> int:
|
||||
"""The thin CLI: contracts fail-fast (§10) → compose (§5) → execute (§3, §8).
|
||||
|
||||
``notifier_transport`` is the injected webhook seam (K10): ``None`` uses the
|
||||
real ``default_webhook_transport`` on the CLI path; the suite injects a
|
||||
canned transport so no socket is opened.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one project through the loop (merge inbox → seed → fold → run)."
|
||||
)
|
||||
|
|
@ -316,6 +363,7 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
|
|||
help="build all, capture run-config + preflight to the outbox, STOP before "
|
||||
"the first model call (K8 live-run drill; no spend, no model call).",
|
||||
)
|
||||
add_notify_args(parser)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# fail-fast (§10 spirit): a run persisted to the outbox MUST carry an explicit
|
||||
|
|
@ -331,6 +379,17 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
|
|||
elif args.outbox is not None and not (args.run_id or "").strip():
|
||||
parser.error("--outbox requires --run-id (no wall-clock default)")
|
||||
|
||||
# K10 (§8): build the notify sinks BEFORE any spend — a --notify-webhook
|
||||
# without --allow-webhook-egress is refused fail-fast here, so no run rides
|
||||
# on a misconfigured egress. The opt-in is a run argument, never a config
|
||||
# field (the config cannot grant itself network access).
|
||||
try:
|
||||
notifiers = build_notifiers(
|
||||
notify_config_from_args(args), webhook_transport=notifier_transport
|
||||
)
|
||||
except EgressNotPermitted as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
# §10: ALL startup contracts schema-validated BEFORE any model client exists.
|
||||
contracts = load_contracts(
|
||||
data_source={"docs_dir": str(args.bundle), "top_k": args.top_k},
|
||||
|
|
@ -392,6 +451,7 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
|
|||
max_attempts=args.max_attempts,
|
||||
outbox_dir=args.outbox,
|
||||
run_id=args.run_id,
|
||||
notifiers=notifiers,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue