2026-05-19 15:59:12 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import httpx
|
2026-05-22 16:14:10 +02:00
|
|
|
import pytest
|
2026-05-19 15:59:12 +02:00
|
|
|
|
|
|
|
|
from activity_core.context_resolvers.state_hub import StateHubContextResolver
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DummyResponse:
|
|
|
|
|
def __init__(self, payload: Any, status_error: Exception | None = None) -> None:
|
|
|
|
|
self.payload = payload
|
|
|
|
|
self.status_error = status_error
|
|
|
|
|
|
|
|
|
|
def raise_for_status(self) -> None:
|
|
|
|
|
if self.status_error is not None:
|
|
|
|
|
raise self.status_error
|
|
|
|
|
|
|
|
|
|
def json(self) -> Any:
|
|
|
|
|
return self.payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_state_summary_query(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse({"tasks": {"todo": 3}})
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve("state_summary", None, {})
|
|
|
|
|
|
|
|
|
|
assert result == {"tasks": {"todo": 3}}
|
|
|
|
|
assert calls == [
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/state/summary",
|
|
|
|
|
"params": None,
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_triage_queries(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse({"url": url, "params": kwargs.get("params")})
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test/")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
resolver = StateHubContextResolver()
|
|
|
|
|
|
|
|
|
|
resolver.resolve("next_steps", None, {})
|
|
|
|
|
resolver.resolve("workplan_index", None, {"refresh": False})
|
|
|
|
|
resolver.resolve("hub_inbox", None, {"to_agent": "hub", "unread_only": True})
|
|
|
|
|
|
|
|
|
|
assert calls == [
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/state/next_steps",
|
|
|
|
|
"params": None,
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/workstreams/workplan-index",
|
|
|
|
|
"params": {"refresh": False},
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/messages/",
|
|
|
|
|
"params": {"to_agent": "hub", "unread_only": True},
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 15:26:00 +02:00
|
|
|
def test_pending_decisions_query(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse([{"id": "d1", "status": "open", "deadline": "2026-07-31T00:00:00Z"}])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
resolver = StateHubContextResolver()
|
|
|
|
|
|
|
|
|
|
result = resolver.resolve(
|
|
|
|
|
"pending_decisions", None, {"topic_id": "topic-1", "workstream_id": "wp-1"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result == [{"id": "d1", "status": "open", "deadline": "2026-07-31T00:00:00Z"}]
|
|
|
|
|
assert calls == [
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/decisions/",
|
|
|
|
|
"params": {"topic_id": "topic-1", "workstream_id": "wp-1", "status": "open"},
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pending_decisions_query_defaults_status_open(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse([])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
resolver = StateHubContextResolver()
|
|
|
|
|
|
|
|
|
|
resolver.resolve("pending_decisions", None, {})
|
|
|
|
|
|
|
|
|
|
assert calls == [
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/decisions/",
|
|
|
|
|
"params": {"status": "open"},
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 15:59:12 +02:00
|
|
|
def test_existing_queries_still_resolve(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
Fix repo_sbom_status resolver — close ADHOC-2026-06-01-T01
The state-hub resolver was calling GET /sbom/status?repo={slug}, which State
Hub does not expose. Real SBOM routes are /sbom/, /sbom/{slug},
/sbom/snapshots/, /sbom/snapshots/{id}, /sbom/ingest/, /sbom/report/licences/.
The weekly-sbom-staleness ActivityDefinition was passing params {repos: all}
and the resolver was reading params.get("repo_slug", ""), so the URL
collapsed to /sbom/status?repo= and 404'd. _fetch_json swallowed the error,
the rule context.repos.sbom_age_days > 30 evaluated against {} and never
matched, and the weekly SBOM check has been a silent no-op for as long as
the route mismatch has existed.
Resolver now supports two modes selected by params:
- single-repo: {repo_slug: foo} → GET /sbom/{foo}, returns
{repo_slug, last_sbom_at, sbom_age_days, has_sbom}
- bulk: {repos: all} → GET /repos/, computes per-repo age, returns the
worst repo's fields hoisted to the top of the result alongside
stale_count, total_count, worst_* fields, and the full per-repo list
Never-scanned repos get a 99999 sentinel age so threshold rules treat
them as very stale without forcing the rule to special-case None.
Hoisting the worst entry to the top preserves the existing rule
expression context.repos.sbom_age_days > 30 (and target_repo:
context.repos.repo_slug, though that field is a separate interpolation
gap tracked as ADHOC-2026-06-01-T02). The integration tests'
aspirational per-repo iteration model is left intact.
Live validation against State Hub on 2026-06-01:
- single: activity-core → 36 days since 2026-04-26 ingest
- bulk: 48 repos total, 46 stale (>30d), worst is info-tech-canon (never
scanned), rule expression evaluates True
Tests: 120 passed, 1 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 03:31:56 +02:00
|
|
|
if url.endswith("/state/domain/custodian"):
|
|
|
|
|
return DummyResponse({"ok": True})
|
|
|
|
|
if url.endswith("/sbom/activity-core"):
|
|
|
|
|
return DummyResponse({
|
|
|
|
|
"repo_slug": "activity-core",
|
|
|
|
|
"last_sbom_at": "2026-04-26T11:37:56+00:00",
|
|
|
|
|
"entry_count": 38,
|
|
|
|
|
"entries": [],
|
|
|
|
|
})
|
|
|
|
|
raise AssertionError(f"unexpected url {url}")
|
2026-05-19 15:59:12 +02:00
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
resolver = StateHubContextResolver()
|
|
|
|
|
|
|
|
|
|
assert resolver.resolve("domain_summary", None, {"domain": "custodian"}) == {"ok": True}
|
Fix repo_sbom_status resolver — close ADHOC-2026-06-01-T01
The state-hub resolver was calling GET /sbom/status?repo={slug}, which State
Hub does not expose. Real SBOM routes are /sbom/, /sbom/{slug},
/sbom/snapshots/, /sbom/snapshots/{id}, /sbom/ingest/, /sbom/report/licences/.
The weekly-sbom-staleness ActivityDefinition was passing params {repos: all}
and the resolver was reading params.get("repo_slug", ""), so the URL
collapsed to /sbom/status?repo= and 404'd. _fetch_json swallowed the error,
the rule context.repos.sbom_age_days > 30 evaluated against {} and never
matched, and the weekly SBOM check has been a silent no-op for as long as
the route mismatch has existed.
Resolver now supports two modes selected by params:
- single-repo: {repo_slug: foo} → GET /sbom/{foo}, returns
{repo_slug, last_sbom_at, sbom_age_days, has_sbom}
- bulk: {repos: all} → GET /repos/, computes per-repo age, returns the
worst repo's fields hoisted to the top of the result alongside
stale_count, total_count, worst_* fields, and the full per-repo list
Never-scanned repos get a 99999 sentinel age so threshold rules treat
them as very stale without forcing the rule to special-case None.
Hoisting the worst entry to the top preserves the existing rule
expression context.repos.sbom_age_days > 30 (and target_repo:
context.repos.repo_slug, though that field is a separate interpolation
gap tracked as ADHOC-2026-06-01-T02). The integration tests'
aspirational per-repo iteration model is left intact.
Live validation against State Hub on 2026-06-01:
- single: activity-core → 36 days since 2026-04-26 ingest
- bulk: 48 repos total, 46 stale (>30d), worst is info-tech-canon (never
scanned), rule expression evaluates True
Tests: 120 passed, 1 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 03:31:56 +02:00
|
|
|
|
|
|
|
|
sbom = resolver.resolve("repo_sbom_status", None, {"repo_slug": "activity-core"})
|
|
|
|
|
assert sbom["repo_slug"] == "activity-core"
|
|
|
|
|
assert sbom["has_sbom"] is True
|
|
|
|
|
assert sbom["last_sbom_at"] == "2026-04-26T11:37:56+00:00"
|
|
|
|
|
assert isinstance(sbom["sbom_age_days"], int) and sbom["sbom_age_days"] >= 0
|
|
|
|
|
|
|
|
|
|
assert [c["url"] for c in calls] == [
|
|
|
|
|
"http://state-hub.test/state/domain/custodian",
|
|
|
|
|
"http://state-hub.test/sbom/activity-core",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_repo_sbom_status_bulk_returns_worst_repo(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse([
|
|
|
|
|
{"slug": "fresh-repo", "last_sbom_at": "2099-01-01T00:00:00+00:00"},
|
|
|
|
|
{"slug": "stale-repo", "last_sbom_at": "2024-01-01T00:00:00+00:00"},
|
|
|
|
|
{"slug": "never-scanned", "last_sbom_at": None},
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve(
|
|
|
|
|
"repo_sbom_status", None, {"repos": "all"}
|
|
|
|
|
)
|
2026-05-19 15:59:12 +02:00
|
|
|
|
|
|
|
|
assert calls == [
|
Fix repo_sbom_status resolver — close ADHOC-2026-06-01-T01
The state-hub resolver was calling GET /sbom/status?repo={slug}, which State
Hub does not expose. Real SBOM routes are /sbom/, /sbom/{slug},
/sbom/snapshots/, /sbom/snapshots/{id}, /sbom/ingest/, /sbom/report/licences/.
The weekly-sbom-staleness ActivityDefinition was passing params {repos: all}
and the resolver was reading params.get("repo_slug", ""), so the URL
collapsed to /sbom/status?repo= and 404'd. _fetch_json swallowed the error,
the rule context.repos.sbom_age_days > 30 evaluated against {} and never
matched, and the weekly SBOM check has been a silent no-op for as long as
the route mismatch has existed.
Resolver now supports two modes selected by params:
- single-repo: {repo_slug: foo} → GET /sbom/{foo}, returns
{repo_slug, last_sbom_at, sbom_age_days, has_sbom}
- bulk: {repos: all} → GET /repos/, computes per-repo age, returns the
worst repo's fields hoisted to the top of the result alongside
stale_count, total_count, worst_* fields, and the full per-repo list
Never-scanned repos get a 99999 sentinel age so threshold rules treat
them as very stale without forcing the rule to special-case None.
Hoisting the worst entry to the top preserves the existing rule
expression context.repos.sbom_age_days > 30 (and target_repo:
context.repos.repo_slug, though that field is a separate interpolation
gap tracked as ADHOC-2026-06-01-T02). The integration tests'
aspirational per-repo iteration model is left intact.
Live validation against State Hub on 2026-06-01:
- single: activity-core → 36 days since 2026-04-26 ingest
- bulk: 48 repos total, 46 stale (>30d), worst is info-tech-canon (never
scanned), rule expression evaluates True
Tests: 120 passed, 1 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 03:31:56 +02:00
|
|
|
{"url": "http://state-hub.test/repos/", "params": None, "timeout": 10.0},
|
2026-05-19 15:59:12 +02:00
|
|
|
]
|
Fix repo_sbom_status resolver — close ADHOC-2026-06-01-T01
The state-hub resolver was calling GET /sbom/status?repo={slug}, which State
Hub does not expose. Real SBOM routes are /sbom/, /sbom/{slug},
/sbom/snapshots/, /sbom/snapshots/{id}, /sbom/ingest/, /sbom/report/licences/.
The weekly-sbom-staleness ActivityDefinition was passing params {repos: all}
and the resolver was reading params.get("repo_slug", ""), so the URL
collapsed to /sbom/status?repo= and 404'd. _fetch_json swallowed the error,
the rule context.repos.sbom_age_days > 30 evaluated against {} and never
matched, and the weekly SBOM check has been a silent no-op for as long as
the route mismatch has existed.
Resolver now supports two modes selected by params:
- single-repo: {repo_slug: foo} → GET /sbom/{foo}, returns
{repo_slug, last_sbom_at, sbom_age_days, has_sbom}
- bulk: {repos: all} → GET /repos/, computes per-repo age, returns the
worst repo's fields hoisted to the top of the result alongside
stale_count, total_count, worst_* fields, and the full per-repo list
Never-scanned repos get a 99999 sentinel age so threshold rules treat
them as very stale without forcing the rule to special-case None.
Hoisting the worst entry to the top preserves the existing rule
expression context.repos.sbom_age_days > 30 (and target_repo:
context.repos.repo_slug, though that field is a separate interpolation
gap tracked as ADHOC-2026-06-01-T02). The integration tests'
aspirational per-repo iteration model is left intact.
Live validation against State Hub on 2026-06-01:
- single: activity-core → 36 days since 2026-04-26 ingest
- bulk: 48 repos total, 46 stale (>30d), worst is info-tech-canon (never
scanned), rule expression evaluates True
Tests: 120 passed, 1 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 03:31:56 +02:00
|
|
|
assert result["total_count"] == 3
|
|
|
|
|
# both stale-repo and never-scanned exceed the 30-day staleness threshold
|
|
|
|
|
assert result["stale_count"] == 2
|
|
|
|
|
assert result["worst_repo_slug"] == "never-scanned"
|
|
|
|
|
assert result["worst_age_days"] == 99999
|
|
|
|
|
|
|
|
|
|
by_slug = {entry["repo_slug"]: entry for entry in result["repos"]}
|
|
|
|
|
assert by_slug["fresh-repo"]["has_sbom"] is True
|
|
|
|
|
assert by_slug["fresh-repo"]["sbom_age_days"] == 0
|
|
|
|
|
assert by_slug["never-scanned"]["has_sbom"] is False
|
|
|
|
|
assert by_slug["never-scanned"]["last_sbom_at"] is None
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 13:19:06 +02:00
|
|
|
def test_todo_md_staleness_returns_stale_repos(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse({
|
|
|
|
|
"stale_days": 6,
|
|
|
|
|
"repos": [
|
|
|
|
|
{
|
|
|
|
|
"repo_slug": "markitect-main",
|
|
|
|
|
"has_todo": True,
|
|
|
|
|
"age_days": 12,
|
|
|
|
|
"mtime": "2026-06-26T10:00:00+00:00",
|
|
|
|
|
"todo_path": "/home/worsch/markitect-main/TODO.md",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"stale_count": 1,
|
|
|
|
|
"total_with_todo": 1,
|
|
|
|
|
"total_scanned": 62,
|
|
|
|
|
"worst_repo_slug": "markitect-main",
|
|
|
|
|
"worst_age_days": 12,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve(
|
|
|
|
|
"todo_md_staleness", None, {"stale_days": 6}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert calls == [{
|
|
|
|
|
"url": "http://state-hub.test/repos/todo-md-staleness",
|
|
|
|
|
"params": {"stale_days": 6},
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
}]
|
|
|
|
|
assert result["stale_count"] == 1
|
|
|
|
|
assert result["repos"][0]["repo_slug"] == "markitect-main"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_todo_md_staleness_returns_empty_on_failure(monkeypatch) -> None:
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
return DummyResponse(None, status_error=httpx.HTTPError("boom"))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
assert StateHubContextResolver().resolve("todo_md_staleness", None, {}) == {}
|
|
|
|
|
|
|
|
|
|
|
Fix repo_sbom_status resolver — close ADHOC-2026-06-01-T01
The state-hub resolver was calling GET /sbom/status?repo={slug}, which State
Hub does not expose. Real SBOM routes are /sbom/, /sbom/{slug},
/sbom/snapshots/, /sbom/snapshots/{id}, /sbom/ingest/, /sbom/report/licences/.
The weekly-sbom-staleness ActivityDefinition was passing params {repos: all}
and the resolver was reading params.get("repo_slug", ""), so the URL
collapsed to /sbom/status?repo= and 404'd. _fetch_json swallowed the error,
the rule context.repos.sbom_age_days > 30 evaluated against {} and never
matched, and the weekly SBOM check has been a silent no-op for as long as
the route mismatch has existed.
Resolver now supports two modes selected by params:
- single-repo: {repo_slug: foo} → GET /sbom/{foo}, returns
{repo_slug, last_sbom_at, sbom_age_days, has_sbom}
- bulk: {repos: all} → GET /repos/, computes per-repo age, returns the
worst repo's fields hoisted to the top of the result alongside
stale_count, total_count, worst_* fields, and the full per-repo list
Never-scanned repos get a 99999 sentinel age so threshold rules treat
them as very stale without forcing the rule to special-case None.
Hoisting the worst entry to the top preserves the existing rule
expression context.repos.sbom_age_days > 30 (and target_repo:
context.repos.repo_slug, though that field is a separate interpolation
gap tracked as ADHOC-2026-06-01-T02). The integration tests'
aspirational per-repo iteration model is left intact.
Live validation against State Hub on 2026-06-01:
- single: activity-core → 36 days since 2026-04-26 ingest
- bulk: 48 repos total, 46 stale (>30d), worst is info-tech-canon (never
scanned), rule expression evaluates True
Tests: 120 passed, 1 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 03:31:56 +02:00
|
|
|
def test_repo_sbom_status_returns_empty_on_failure(monkeypatch) -> None:
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
return DummyResponse(None, status_error=httpx.HTTPError("boom"))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
resolver = StateHubContextResolver()
|
|
|
|
|
|
|
|
|
|
assert resolver.resolve("repo_sbom_status", None, {"repo_slug": "x"}) == {}
|
|
|
|
|
assert resolver.resolve("repo_sbom_status", None, {"repos": "all"}) == {}
|
2026-05-19 15:59:12 +02:00
|
|
|
|
|
|
|
|
|
2026-06-07 20:58:34 +02:00
|
|
|
def test_coding_retro_returns_latest_progress_suggestions(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse([
|
|
|
|
|
{
|
|
|
|
|
"id": "older-retro",
|
|
|
|
|
"event_type": "coding_retro",
|
|
|
|
|
"summary": "older",
|
|
|
|
|
"created_at": "2026-05-31T17:00:00Z",
|
|
|
|
|
"detail": {
|
|
|
|
|
"generated_at": "2026-05-31T17:00:00Z",
|
|
|
|
|
"suggestions": [
|
|
|
|
|
{
|
|
|
|
|
"repo": "old-repo",
|
|
|
|
|
"title": "Old recommendation",
|
|
|
|
|
"recommendation": "Do the older thing.",
|
|
|
|
|
"priority": "low",
|
|
|
|
|
"score": 1,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "note-1",
|
|
|
|
|
"event_type": "note",
|
|
|
|
|
"summary": "ignore me",
|
|
|
|
|
"created_at": "2026-06-07T17:05:00Z",
|
|
|
|
|
"detail": {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "newer-retro",
|
|
|
|
|
"event_type": "coding_retro",
|
|
|
|
|
"summary": "weekly coding retro ready",
|
|
|
|
|
"created_at": "2026-06-07T17:10:00Z",
|
|
|
|
|
"detail": {
|
|
|
|
|
"generated_at": "2026-06-07T17:09:30Z",
|
|
|
|
|
"window": {
|
|
|
|
|
"since": "2026-05-31T00:00:00Z",
|
|
|
|
|
"until": "2026-06-07T00:00:00Z",
|
|
|
|
|
},
|
|
|
|
|
"suggestions": [
|
|
|
|
|
{
|
|
|
|
|
"target_repo": "activity-core",
|
|
|
|
|
"title": "Harden schedule smoke gates",
|
|
|
|
|
"description": "Add a smoke proof before enablement.",
|
|
|
|
|
"priority": "HIGH",
|
|
|
|
|
"score": "8.5",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"repo_slug": "repo-without-title",
|
|
|
|
|
"recommendation": "missing title should be skipped",
|
|
|
|
|
"score": 9,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-06-18 15:13:08 +02:00
|
|
|
{
|
|
|
|
|
"id": "newer-30-day-retro",
|
|
|
|
|
"event_type": "coding_retro",
|
|
|
|
|
"summary": "monthly coding retro ready",
|
|
|
|
|
"created_at": "2026-06-07T17:15:00Z",
|
|
|
|
|
"detail": {
|
|
|
|
|
"generated_at": "2026-06-07T17:14:30Z",
|
|
|
|
|
"window": {
|
|
|
|
|
"days": 30,
|
|
|
|
|
"since": "2026-05-08T00:00:00Z",
|
|
|
|
|
"until": "2026-06-07T00:00:00Z",
|
|
|
|
|
},
|
|
|
|
|
"suggestions": [
|
|
|
|
|
{
|
|
|
|
|
"repo": "broad-retro-repo",
|
|
|
|
|
"title": "Should not displace the weekly retro",
|
|
|
|
|
"recommendation": "Keep weekly schedule bounded.",
|
|
|
|
|
"priority": "high",
|
|
|
|
|
"score": 99,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-06-07 20:58:34 +02:00
|
|
|
])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test/")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve(
|
|
|
|
|
"coding_retro",
|
|
|
|
|
None,
|
|
|
|
|
{"limit": 20, "window_days": 7},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert calls == [
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/progress/",
|
2026-06-18 15:13:08 +02:00
|
|
|
"params": {"event_type": "coding_retro", "limit": 20},
|
2026-06-07 20:58:34 +02:00
|
|
|
"timeout": 10.0,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
assert result["source_progress_id"] == "newer-retro"
|
|
|
|
|
assert result["generated_at"] == "2026-06-07T17:09:30Z"
|
|
|
|
|
assert result["window"] == {
|
|
|
|
|
"since": "2026-05-31T00:00:00Z",
|
|
|
|
|
"until": "2026-06-07T00:00:00Z",
|
|
|
|
|
}
|
|
|
|
|
assert result["summary"] == "weekly coding retro ready"
|
|
|
|
|
assert result["suggestions"] == [
|
|
|
|
|
{
|
|
|
|
|
"repo": "activity-core",
|
|
|
|
|
"title": "Harden schedule smoke gates",
|
|
|
|
|
"recommendation": "Add a smoke proof before enablement.",
|
|
|
|
|
"priority": "high",
|
|
|
|
|
"score": 8.5,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 15:13:08 +02:00
|
|
|
def test_coding_retro_returns_empty_when_window_does_not_match(monkeypatch) -> None:
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
return DummyResponse([
|
|
|
|
|
{
|
|
|
|
|
"id": "monthly-retro",
|
|
|
|
|
"event_type": "coding_retro",
|
|
|
|
|
"summary": "monthly coding retro ready",
|
|
|
|
|
"created_at": "2026-06-07T17:10:00Z",
|
|
|
|
|
"detail": {
|
|
|
|
|
"window": {"days": 30},
|
|
|
|
|
"suggestions": [
|
|
|
|
|
{
|
|
|
|
|
"repo": "activity-core",
|
|
|
|
|
"title": "Broad retro item",
|
|
|
|
|
"recommendation": "Do not emit from weekly schedule.",
|
|
|
|
|
"priority": "high",
|
|
|
|
|
"score": 10,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve(
|
|
|
|
|
"coding_retro",
|
|
|
|
|
None,
|
|
|
|
|
{"event_type": "coding_retro", "window_days": 7},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result == {
|
|
|
|
|
"suggestions": [],
|
|
|
|
|
"window": None,
|
|
|
|
|
"generated_at": None,
|
|
|
|
|
"source_progress_id": None,
|
|
|
|
|
"event_type": "coding_retro",
|
|
|
|
|
"summary": "",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 20:58:34 +02:00
|
|
|
def test_coding_retro_returns_empty_shape_when_not_published(monkeypatch) -> None:
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
return DummyResponse([
|
|
|
|
|
{
|
|
|
|
|
"id": "note-1",
|
|
|
|
|
"event_type": "note",
|
|
|
|
|
"created_at": "2026-06-07T17:10:00Z",
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve(
|
|
|
|
|
"coding_retro",
|
|
|
|
|
None,
|
|
|
|
|
{"event_type": "coding_retro"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result == {
|
|
|
|
|
"suggestions": [],
|
|
|
|
|
"window": None,
|
|
|
|
|
"generated_at": None,
|
|
|
|
|
"source_progress_id": None,
|
|
|
|
|
"event_type": "coding_retro",
|
|
|
|
|
"summary": "",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 15:59:12 +02:00
|
|
|
def test_resolver_failure_returns_empty(monkeypatch) -> None:
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
raise httpx.ConnectError("offline")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
assert StateHubContextResolver().resolve("state_summary", None, {}) == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_query_returns_empty() -> None:
|
|
|
|
|
assert StateHubContextResolver().resolve("unknown", None, {}) == {}
|
2026-05-19 19:09:21 +02:00
|
|
|
|
|
|
|
|
|
2026-05-22 16:14:10 +02:00
|
|
|
def test_recently_on_scope_hourly_posts_batch(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
2026-05-23 02:51:54 +02:00
|
|
|
return DummyResponse(
|
|
|
|
|
{
|
|
|
|
|
"generated": [{"domain_slug": "custodian"}],
|
|
|
|
|
"skipped": [],
|
|
|
|
|
"failed": [],
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-22 16:14:10 +02:00
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test/")
|
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve(
|
|
|
|
|
"recently_on_scope_hourly",
|
|
|
|
|
None,
|
|
|
|
|
{
|
|
|
|
|
"range": "1h",
|
|
|
|
|
"active_only": True,
|
|
|
|
|
"include_attention": False,
|
|
|
|
|
"required": True,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-23 02:51:54 +02:00
|
|
|
assert result == {
|
|
|
|
|
"generated": [{"domain_slug": "custodian"}],
|
|
|
|
|
"skipped": [],
|
|
|
|
|
"failed": [],
|
|
|
|
|
}
|
2026-05-22 16:14:10 +02:00
|
|
|
assert calls == [
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/recently-on-scope/hourly",
|
|
|
|
|
"json": {"range": "1h", "active_only": True, "include_attention": False},
|
|
|
|
|
"timeout": 10.0,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recently_on_scope_hourly_failure_bubbles(monkeypatch) -> None:
|
|
|
|
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
raise httpx.ConnectError("offline")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(httpx.ConnectError):
|
|
|
|
|
StateHubContextResolver().resolve("recently_on_scope_hourly", None, {"range": "1h"})
|
2026-05-23 02:51:54 +02:00
|
|
|
|
|
|
|
|
|
2026-06-21 20:19:22 +02:00
|
|
|
def test_consistency_sweep_remote_all_posts_batch(monkeypatch) -> None:
|
|
|
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
calls.append({"url": url, **kwargs})
|
|
|
|
|
return DummyResponse(
|
|
|
|
|
{
|
|
|
|
|
"exit_code": 0,
|
|
|
|
|
"lock_skipped": False,
|
|
|
|
|
"repos_processed": [{"repo_slug": "state-hub", "result": "pass"}],
|
|
|
|
|
"skipped_clean": ["quiet-repo"],
|
|
|
|
|
"skipped_missing": [],
|
|
|
|
|
"skipped_budget": [],
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test/")
|
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve(
|
|
|
|
|
"consistency_sweep_remote_all",
|
|
|
|
|
None,
|
2026-06-21 21:46:43 +02:00
|
|
|
{"max_seconds": 300, "source": "activity-core", "required": True},
|
2026-06-21 20:19:22 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["exit_code"] == 0
|
|
|
|
|
assert result["repos_processed"][0]["repo_slug"] == "state-hub"
|
|
|
|
|
assert calls == [
|
|
|
|
|
{
|
|
|
|
|
"url": "http://state-hub.test/consistency/sweep/remote-all",
|
2026-06-21 21:46:43 +02:00
|
|
|
"json": {"max_seconds": 300, "source": "activity-core"},
|
2026-06-21 20:19:22 +02:00
|
|
|
"timeout": 330.0,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_consistency_sweep_remote_all_failure_bubbles(monkeypatch) -> None:
|
|
|
|
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
raise httpx.ConnectError("offline")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(httpx.ConnectError):
|
|
|
|
|
StateHubContextResolver().resolve(
|
|
|
|
|
"consistency_sweep_remote_all",
|
|
|
|
|
None,
|
|
|
|
|
{"max_seconds": 300},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_consistency_sweep_remote_all_rejects_empty_response(monkeypatch) -> None:
|
|
|
|
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
return DummyResponse({})
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="missing required key"):
|
|
|
|
|
StateHubContextResolver().resolve(
|
|
|
|
|
"consistency_sweep_remote_all",
|
|
|
|
|
None,
|
|
|
|
|
{"max_seconds": 300},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 02:51:54 +02:00
|
|
|
def test_recently_on_scope_hourly_rejects_empty_response(monkeypatch) -> None:
|
|
|
|
|
def fake_post(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
return DummyResponse({})
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="missing required key"):
|
|
|
|
|
StateHubContextResolver().resolve("recently_on_scope_hourly", None, {"range": "1h"})
|
2026-05-22 16:14:10 +02:00
|
|
|
|
|
|
|
|
|
2026-05-19 19:09:21 +02:00
|
|
|
def test_daily_triage_digest_is_curated_scalar_json(monkeypatch) -> None:
|
|
|
|
|
payloads = {
|
|
|
|
|
"/state/summary": {
|
|
|
|
|
"generated_at": "2026-05-19T05:20:00Z",
|
2026-06-04 12:15:07 +02:00
|
|
|
"totals": {"tasks": {"todo": 4, "wait": 1}},
|
2026-07-06 10:52:52 +02:00
|
|
|
"ranked_suggestions": [
|
|
|
|
|
{
|
|
|
|
|
"id": "sug-1",
|
|
|
|
|
"title": "Issue-core ingestion API key path",
|
|
|
|
|
"stage": "suggestion",
|
|
|
|
|
"domain_slug": "custodian",
|
|
|
|
|
"origin_ref": "issue-core-ingestion-api-key",
|
|
|
|
|
"relevance": 2,
|
|
|
|
|
"wsjf": 4.7,
|
|
|
|
|
"last_requested_at": "2026-05-19T04:00:00Z",
|
|
|
|
|
}
|
|
|
|
|
],
|
2026-05-19 19:09:21 +02:00
|
|
|
"topics": [
|
|
|
|
|
{
|
|
|
|
|
"slug": "custodian",
|
|
|
|
|
"domain_slug": "custodian",
|
|
|
|
|
"workstreams": [
|
|
|
|
|
{
|
|
|
|
|
"id": "ws-1",
|
|
|
|
|
"slug": "cust-wp-0045",
|
|
|
|
|
"title": "Activity-Core Daily Triage Runner Cutover",
|
|
|
|
|
"status": "ready",
|
|
|
|
|
"owner": "custodian",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "ws-closed",
|
|
|
|
|
"slug": "closed",
|
|
|
|
|
"title": "Closed",
|
|
|
|
|
"status": "finished",
|
|
|
|
|
"owner": "custodian",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
"/workstreams/workplan-index": {
|
|
|
|
|
"workstreams": {
|
|
|
|
|
"ws-1": {
|
|
|
|
|
"repo_slug": "the-custodian",
|
|
|
|
|
"relative_path": "workplans/CUST-WP-0045.md",
|
|
|
|
|
"needs_review": True,
|
|
|
|
|
"health_labels": ["needs_review"],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"/state/next_steps": [
|
|
|
|
|
{
|
|
|
|
|
"type": "resolved_decision",
|
|
|
|
|
"domain": "custodian",
|
|
|
|
|
"workstream_id": "ws-1",
|
|
|
|
|
"workstream_slug": "cust-wp-0045",
|
|
|
|
|
"workstream_title": "Activity-Core Daily Triage Runner Cutover",
|
|
|
|
|
"task_id": "task-1",
|
|
|
|
|
"task_title": "T05 - Update ActivityDefinition",
|
|
|
|
|
"message": "free text should not be included",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"/messages/": [
|
|
|
|
|
{
|
|
|
|
|
"id": "msg-1",
|
|
|
|
|
"from_agent": "hub",
|
|
|
|
|
"subject": "Please review",
|
|
|
|
|
"body": "free text should not be included",
|
|
|
|
|
"created_at": "2026-05-19T05:00:00Z",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"/workstreams/ws-1": {
|
|
|
|
|
"planning_priority": "high",
|
|
|
|
|
"planning_order": 45,
|
|
|
|
|
},
|
|
|
|
|
"/tasks/": [
|
|
|
|
|
{
|
|
|
|
|
"id": "task-1",
|
|
|
|
|
"title": "T05 - Update ActivityDefinition",
|
|
|
|
|
"status": "todo",
|
|
|
|
|
"priority": "high",
|
|
|
|
|
"needs_human": False,
|
|
|
|
|
"description": "free text should not be included",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "task-2",
|
|
|
|
|
"title": "T06 - Canary Cutover",
|
2026-06-04 12:15:07 +02:00
|
|
|
"status": "wait",
|
2026-05-19 19:09:21 +02:00
|
|
|
"priority": "medium",
|
|
|
|
|
"needs_human": True,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
path = url.removeprefix("http://state-hub.test")
|
|
|
|
|
return DummyResponse(payloads[path])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
raw_digest = StateHubContextResolver().resolve(
|
|
|
|
|
"daily_triage_digest",
|
|
|
|
|
None,
|
|
|
|
|
{"max_workstreams": 4, "max_next_steps": 4},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert isinstance(raw_digest, str)
|
|
|
|
|
assert "free text should not be included" not in raw_digest
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
digest = json.loads(raw_digest)
|
2026-06-04 12:15:07 +02:00
|
|
|
assert digest["totals"] == {"tasks": {"todo": 4, "wait": 1}}
|
2026-07-08 16:41:28 +02:00
|
|
|
assert digest["open_workplans"] == digest["open_workstreams"]
|
2026-05-19 19:09:21 +02:00
|
|
|
assert digest["open_workstreams"][0]["slug"] == "cust-wp-0045"
|
|
|
|
|
assert digest["open_workstreams"][0]["planning_priority"] == "high"
|
|
|
|
|
assert digest["open_workstreams"][0]["open_task_counts"] == {
|
2026-06-04 12:15:07 +02:00
|
|
|
"wait": 1,
|
2026-05-19 19:09:21 +02:00
|
|
|
"todo": 1,
|
2026-06-04 12:15:07 +02:00
|
|
|
"progress": 0,
|
2026-05-19 19:09:21 +02:00
|
|
|
"needs_human": 1,
|
|
|
|
|
"open_total": 2,
|
|
|
|
|
}
|
|
|
|
|
assert digest["deterministic_scoring"]["future_mode"] == (
|
|
|
|
|
"code_score_high_gain_high_effort_candidates"
|
|
|
|
|
)
|
2026-07-06 10:52:52 +02:00
|
|
|
assert digest["ranked_suggestions"][0]["origin_ref"] == "issue-core-ingestion-api-key"
|
2026-07-07 00:48:36 +02:00
|
|
|
|
|
|
|
|
|
2026-07-08 20:55:06 +02:00
|
|
|
def test_legacy_meter_weekly_review_summarises_candidates(monkeypatch) -> None:
|
|
|
|
|
payloads = {
|
|
|
|
|
"/legacy-meter/weekly-review": {
|
|
|
|
|
"generated_at": "2026-07-08T08:30:00+00:00",
|
|
|
|
|
"window_start": "2026-07-01T08:30:00+00:00",
|
|
|
|
|
"window_end": "2026-07-08T08:30:00+00:00",
|
|
|
|
|
"activity_core_handoff": {
|
|
|
|
|
"activity_id": "statehub-legacy-interface-review",
|
|
|
|
|
"scheduler_owner": "activity-core",
|
|
|
|
|
},
|
|
|
|
|
"interfaces": [
|
|
|
|
|
{
|
|
|
|
|
"interface": {
|
|
|
|
|
"interface_key": "rest_api:GET /workstreams/",
|
|
|
|
|
"interface_kind": "rest_api",
|
|
|
|
|
"replacement_ref": "GET /workplans/",
|
|
|
|
|
},
|
|
|
|
|
"window": {"calls": 2},
|
|
|
|
|
"retirement_candidate": False,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"interface": {
|
|
|
|
|
"interface_key": "mcp:create_workstream",
|
|
|
|
|
"interface_kind": "mcp_tool",
|
|
|
|
|
"replacement_ref": "create_workplan",
|
|
|
|
|
},
|
|
|
|
|
"window": {"calls": 0},
|
|
|
|
|
"retirement_candidate": True,
|
|
|
|
|
"retirement_reason": "no measured usage in review window",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"retirement_candidates": [
|
|
|
|
|
{
|
|
|
|
|
"interface": {
|
|
|
|
|
"interface_key": "mcp:create_workstream",
|
|
|
|
|
"interface_kind": "mcp_tool",
|
|
|
|
|
"replacement_ref": "create_workplan",
|
|
|
|
|
},
|
|
|
|
|
"window": {"calls": 0},
|
|
|
|
|
"retirement_candidate": True,
|
|
|
|
|
"retirement_reason": "no measured usage in review window",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
path = url.replace("http://state-hub.test", "")
|
|
|
|
|
return DummyResponse(payloads.get(path, {}))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
|
|
|
|
|
monkeypatch.setattr(httpx, "get", fake_get)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve("legacy_meter_weekly_review", None, {"days": 7})
|
|
|
|
|
|
|
|
|
|
assert result["kind"] == "legacy_meter_weekly_review"
|
|
|
|
|
assert result["interface_count"] == 2
|
|
|
|
|
assert result["retirement_candidate_count"] == 1
|
|
|
|
|
assert result["window_legacy_calls"] == 2
|
|
|
|
|
assert result["retirement_candidates"][0]["interface_key"] == "mcp:create_workstream"
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 00:48:36 +02:00
|
|
|
def test_phase5_stabilization_check_passes(monkeypatch) -> None:
|
|
|
|
|
payloads = {
|
|
|
|
|
"/state/health": {"status": "ok", "db": "connected"},
|
|
|
|
|
"/state/summary": {
|
|
|
|
|
"totals": {
|
|
|
|
|
"workstreams": {"total": 640},
|
|
|
|
|
"tasks": {"total": 4002},
|
|
|
|
|
"topics": {"total": 14},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"/progress/": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
|
|
|
|
|
path = url.replace("http://state-hub.test", "")
|
|
|
|
|
params = kwargs.get("params") or {}
|
|
|
|
|
if path == "/progress/":
|
|
|
|
|
if params.get("event_type") == "consistency_sweep_remote_all":
|
|
|
|
|
return DummyResponse([
|
|
|
|
|
{
|
|
|
|
|
"created_at": "2026-07-06T20:00:00+00:00",
|
|
|
|
|
"detail": {"exit_code": 0, "skipped_missing": []},
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
if params.get("event_type") == "daily_triage":
|
|
|
|
|
return DummyResponse([
|
|
|
|
|
{"created_at": "2026-07-06T19:00:00+00:00", "summary": "ok"}
|
|
|
|
|
])
|
|
|
|
|
return DummyResponse(payloads.get(path, {}))
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
fixed_now = datetime(2026, 7, 7, 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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = StateHubContextResolver().resolve("phase5_stabilization_check", None, {})
|
|
|
|
|
|
|
|
|
|
assert result["overall_pass"] is True
|
|
|
|
|
assert result["skipped"] is False
|
|
|
|
|
assert result["checks"]["totals"]["pass"] is True
|
2026-07-08 20:00:35 +02:00
|
|
|
assert result["checks"]["totals"]["workplans"] == 640
|
|
|
|
|
assert result["checks"]["totals"]["workstreams"] == 640
|