From b1eb5e6a55bf2174669fca2f4c9d071bd1aeb6f8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 17 Jul 2026 17:07:00 +0200 Subject: [PATCH] feat(resolvers): binky_rhythm_status query in state-hub resolver Due-items for the three Binky operating-rhythm definitions (BINKY-WP-0004-T02). Dueness derives from hub progress events recorded by the executing session (binky_daily_brief / binky_mail_intake / binky_weekly_review, detail.repo scoped); weekly_review carries milestone_moved from event_type=milestone events in the last 7 days (RISK-005 signal). Definitions' resolver comments updated; definitions stay enabled:false until cutover (BINKY-WP-0004-T06). 7 new tests; resolver test file 31/31 green. Pre-existing failures in test_railiance_ops_inventory_wiring/test_schedule_health are untouched. Co-Authored-By: Claude Fable 5 --- activity-definitions/binky-daily-rhythm.md | 9 +- .../binky-weekly-mail-intake.md | 8 +- .../binky-weekly-review-prep.md | 7 +- .../context_resolvers/state_hub.py | 112 +++++++++++++ tests/test_state_hub_context_resolver.py | 157 ++++++++++++++++++ 5 files changed, 282 insertions(+), 11 deletions(-) diff --git a/activity-definitions/binky-daily-rhythm.md b/activity-definitions/binky-daily-rhythm.md index 4d972e5..1198847 100644 --- a/activity-definitions/binky-daily-rhythm.md +++ b/activity-definitions/binky-daily-rhythm.md @@ -16,10 +16,11 @@ context_sources: params: repo: binky-control bind_to: context.rhythm -# Proposed resolver (not yet implemented): returns -# {items: [{kind: "daily_brief", due: bool, date: "YYYY-MM-DD"}]} — due is -# true when no brief exists for today in binky-control/briefs/. Implementing -# it is part of the BINKY-WP-0003-T06 follow-up workplan proposal. +# Resolver implemented (BINKY-WP-0004-T02): returns +# {items: [{kind: "daily_brief", due: bool, date: "YYYY-MM-DD", +# last_run_at}]} — due is true when no binky_daily_brief progress event with +# detail.repo=binky-control exists for today (Europe/Berlin). The executing +# session must record that event on completion (idempotence guard). --- # Binky Daily Operating Rhythm diff --git a/activity-definitions/binky-weekly-mail-intake.md b/activity-definitions/binky-weekly-mail-intake.md index e1180d8..593943e 100644 --- a/activity-definitions/binky-weekly-mail-intake.md +++ b/activity-definitions/binky-weekly-mail-intake.md @@ -17,10 +17,10 @@ context_sources: repo: binky-control kind: mail_intake bind_to: context.rhythm -# Same proposed binky_rhythm_status resolver as binky-daily-rhythm; for -# kind=mail_intake, due is true when the founder's weekly scan drop -# (AWQ-008 pipeline, binky-control) has unprocessed items or no intake -# ran in the last 7 days. +# Resolver implemented (BINKY-WP-0004-T02); for kind=mail_intake, due is +# true when no binky_mail_intake progress event was recorded in the last +# 7 days. (Unprocessed-drop detection stays with the executing session — +# the AWQ-008 drop dir is outside hub visibility.) --- # Binky Weekly Paper-Mail Intake Check diff --git a/activity-definitions/binky-weekly-review-prep.md b/activity-definitions/binky-weekly-review-prep.md index c1311e1..f0a9349 100644 --- a/activity-definitions/binky-weekly-review-prep.md +++ b/activity-definitions/binky-weekly-review-prep.md @@ -17,9 +17,10 @@ context_sources: repo: binky-control kind: weekly_review bind_to: context.rhythm -# Same proposed binky_rhythm_status resolver; for kind=weekly_review, due is -# true every Friday, and the item carries milestone_moved (bool) derived from -# the week's progress events so the emitted task can flag RISK-005. +# Resolver implemented (BINKY-WP-0004-T02); for kind=weekly_review, due is +# true when no binky_weekly_review progress event exists for today, and the +# item carries milestone_moved (bool): any event_type=milestone progress +# event for the repo in the last 7 days — the RISK-005 escalation signal. --- # Binky Weekly Founder Review Prep diff --git a/src/activity_core/context_resolvers/state_hub.py b/src/activity_core/context_resolvers/state_hub.py index e3f8180..9fed76c 100644 --- a/src/activity_core/context_resolvers/state_hub.py +++ b/src/activity_core/context_resolvers/state_hub.py @@ -17,6 +17,8 @@ Supported queries: - consistency_sweep_remote_all: POST {STATE_HUB_URL}/consistency/sweep/remote-all - phase5_stabilization_check: hub-visible Phase 5 stabilization gates - legacy_meter_weekly_review: GET {STATE_HUB_URL}/legacy-meter/weekly-review + - binky_rhythm_status: due-items for the Binky operating-rhythm definitions, + derived from /progress/ events (see _binky_rhythm_status) When STATE_HUB_URL points at the state-hub edge relay, allowlisted GET reads may be served from a stale local cache during upstream outages (`X-StateHub-Edge-Cache: @@ -154,6 +156,8 @@ class StateHubContextResolver(ContextResolver): return _phase5_stabilization_check(params) if query == "legacy_meter_weekly_review": return _legacy_meter_weekly_review(params) + if query == "binky_rhythm_status": + return _binky_rhythm_status(params) return {} @@ -358,6 +362,114 @@ def _phase5_stabilization_check(params: dict[str, Any]) -> dict[str, Any]: CONTEXT_RESOLVER_REGISTRY["state-hub"] = StateHubContextResolver +# Rhythm kinds understood by binky_rhythm_status, mapped to the progress +# event_type an executing session records on completion. A run is only +# "seen" by the idempotence guard if the session logs that event with +# detail.repo set to the target repo. +_BINKY_RHYTHM_EVENT_TYPES = { + "daily_brief": "binky_daily_brief", + "mail_intake": "binky_mail_intake", + "weekly_review": "binky_weekly_review", +} +_BINKY_MAIL_INTAKE_WINDOW_DAYS = 7 +_BINKY_MILESTONE_WINDOW_DAYS = 7 + + +def _binky_rhythm_status(params: dict[str, Any]) -> dict[str, Any]: + """Due-items for the Binky operating-rhythm definitions. + + Params: repo (default "binky-control"), kind (optional: one of + _BINKY_RHYTHM_EVENT_TYPES; absent = daily_brief), timezone (IANA name, + default Europe/Berlin — "today" is evaluated in this zone). + + Dueness is derived from hub progress events, which the executing session + records at completion (event_type per _BINKY_RHYTHM_EVENT_TYPES, with + detail.repo naming the target repo): + - daily_brief: due when no completion event exists for today + - mail_intake: due when no completion event exists in the last 7 days + - weekly_review: due when no completion event exists for today; the item + additionally carries milestone_moved — whether an event_type=milestone + event for the repo was recorded in the last 7 days (RISK-005 signal) + + Events whose detail.repo names a different repo are ignored; events with + no detail.repo are accepted (the binky-prefixed event types are already + repo-specific in practice). + """ + repo = str(params.get("repo") or "binky-control") + kind = str(params.get("kind") or "daily_brief") + if kind not in _BINKY_RHYTHM_EVENT_TYPES: + return {"items": [], "repo": repo, "error": f"unknown kind: {kind}"} + + tz = _binky_timezone(params.get("timezone")) + now_local = _utc_now().astimezone(tz) + today = now_local.date() + + event_type = _BINKY_RHYTHM_EVENT_TYPES[kind] + events = _binky_progress_events(event_type, repo) + # _parse_progress_timestamp returns a datetime.min sentinel for missing or + # unparsable created_at; those events cannot prove a run happened. + timestamps = [ + ts + for ts in ( + _parse_progress_timestamp(item.get("created_at")) for item in events + ) + if ts > datetime.min.replace(tzinfo=timezone.utc) + ] + last_run = max(timestamps, default=None) + + if kind == "mail_intake": + due = last_run is None or ( + (_utc_now() - last_run).days >= _BINKY_MAIL_INTAKE_WINDOW_DAYS + ) + else: + due = last_run is None or last_run.astimezone(tz).date() != today + + item: dict[str, Any] = { + "kind": kind, + "due": due, + "date": today.isoformat(), + "last_run_at": last_run.isoformat() if last_run else None, + } + if kind == "weekly_review": + milestone_events = _binky_progress_events("milestone", repo) + cutoff = _utc_now().timestamp() - _BINKY_MILESTONE_WINDOW_DAYS * 86400 + item["milestone_moved"] = any( + _parse_progress_timestamp(event.get("created_at")).timestamp() > cutoff + for event in milestone_events + ) + + return { + "items": [item], + "repo": repo, + "generated_at": _utc_now().isoformat(), + } + + +def _binky_timezone(raw: Any) -> Any: + from zoneinfo import ZoneInfo + + name = str(raw or "Europe/Berlin") + try: + return ZoneInfo(name) + except Exception: + return timezone.utc + + +def _binky_progress_events(event_type: str, repo: str) -> list[dict[str, Any]]: + items = _fetch_json("/progress/", {"event_type": event_type, "limit": 50}) + if not isinstance(items, list): + return [] + matched: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict) or item.get("event_type") != event_type: + continue + detail_repo = _progress_detail(item).get("repo") + if detail_repo and str(detail_repo) != repo: + continue + matched.append(item) + return matched + + def _repo_sbom_status(params: dict[str, Any]) -> dict[str, Any]: """Resolve SBOM staleness against the State Hub. diff --git a/tests/test_state_hub_context_resolver.py b/tests/test_state_hub_context_resolver.py index e6557e3..4a88be2 100644 --- a/tests/test_state_hub_context_resolver.py +++ b/tests/test_state_hub_context_resolver.py @@ -841,3 +841,160 @@ def test_phase5_stabilization_check_passes(monkeypatch) -> None: assert result["checks"]["totals"]["pass"] is True assert result["checks"]["totals"]["workplans"] == 640 assert result["checks"]["totals"]["workstreams"] == 640 + + +def _binky_resolver_env(monkeypatch, progress_by_type): + def fake_get(url: str, **kwargs) -> DummyResponse: + params = kwargs.get("params") or {} + if url.endswith("/progress/"): + return DummyResponse(progress_by_type.get(params.get("event_type"), [])) + return DummyResponse({}) + + from datetime import datetime, timezone + + fixed_now = datetime(2026, 7, 17, 10, 0, tzinfo=timezone.utc) + monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test") + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr( + "activity_core.context_resolvers.state_hub._utc_now", + lambda: fixed_now, + ) + + +def test_binky_rhythm_daily_brief_due_when_no_events(monkeypatch) -> None: + _binky_resolver_env(monkeypatch, {"binky_daily_brief": []}) + + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control"} + ) + + assert result["repo"] == "binky-control" + assert result["items"] == [ + { + "kind": "daily_brief", + "due": True, + "date": "2026-07-17", + "last_run_at": None, + } + ] + + +def test_binky_rhythm_daily_brief_not_due_after_todays_run(monkeypatch) -> None: + _binky_resolver_env( + monkeypatch, + { + "binky_daily_brief": [ + { + "event_type": "binky_daily_brief", + "created_at": "2026-07-17T06:30:00+00:00", + "detail": {"repo": "binky-control"}, + } + ] + }, + ) + + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control"} + ) + + item = result["items"][0] + assert item["due"] is False + assert item["last_run_at"] == "2026-07-17T06:30:00+00:00" + + +def test_binky_rhythm_daily_brief_ignores_other_repo_events(monkeypatch) -> None: + _binky_resolver_env( + monkeypatch, + { + "binky_daily_brief": [ + { + "event_type": "binky_daily_brief", + "created_at": "2026-07-17T06:30:00+00:00", + "detail": {"repo": "some-other-repo"}, + } + ] + }, + ) + + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control"} + ) + + assert result["items"][0]["due"] is True + + +def test_binky_rhythm_mail_intake_window(monkeypatch) -> None: + recent = { + "event_type": "binky_mail_intake", + "created_at": "2026-07-14T09:40:00+00:00", + "detail": {"repo": "binky-control"}, + } + _binky_resolver_env(monkeypatch, {"binky_mail_intake": [recent]}) + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control", "kind": "mail_intake"} + ) + assert result["items"][0]["due"] is False + + stale = dict(recent, created_at="2026-07-09T09:40:00+00:00") + _binky_resolver_env(monkeypatch, {"binky_mail_intake": [stale]}) + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control", "kind": "mail_intake"} + ) + assert result["items"][0]["due"] is True + + +def test_binky_rhythm_weekly_review_carries_milestone_moved(monkeypatch) -> None: + _binky_resolver_env( + monkeypatch, + { + "binky_weekly_review": [], + "milestone": [ + { + "event_type": "milestone", + "created_at": "2026-07-15T12:00:00+00:00", + "detail": {"repo": "binky-control"}, + } + ], + }, + ) + + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control", "kind": "weekly_review"} + ) + + item = result["items"][0] + assert item["due"] is True + assert item["milestone_moved"] is True + + +def test_binky_rhythm_weekly_review_milestone_outside_window(monkeypatch) -> None: + _binky_resolver_env( + monkeypatch, + { + "binky_weekly_review": [], + "milestone": [ + { + "event_type": "milestone", + "created_at": "2026-07-01T12:00:00+00:00", + "detail": {"repo": "binky-control"}, + } + ], + }, + ) + + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control", "kind": "weekly_review"} + ) + + assert result["items"][0]["milestone_moved"] is False + + +def test_binky_rhythm_unknown_kind_returns_error_shape(monkeypatch) -> None: + _binky_resolver_env(monkeypatch, {}) + + result = StateHubContextResolver().resolve( + "binky_rhythm_status", None, {"repo": "binky-control", "kind": "nope"} + ) + + assert result["items"] == [] + assert "unknown kind" in result["error"]