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 <noreply@anthropic.com>
This commit is contained in:
parent
a2c53508ad
commit
b1eb5e6a55
5 changed files with 282 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue