Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ describe("dashboard routing and API query helpers", () => {
});

it("renders the webhook status exported by the backend", () => {
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3, totals: { hooks: 143, deliveries: 912 }, coverage: { both: 3, imap_only: 1, webhook_only: 2, imap_eligible: 4, ratio: 0.75, decision_mismatches: 1, incomplete_decision_traces: 1, split_identities: 1, promotion_state: "blocked", promotion_ready: false, observation_complete: true, window_start: "2026-10-01T00:00:00Z", window_end: "2026-10-08T00:00:00Z", grace_seconds: 600 } }} timeseries={[]} section="overview" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore={false} deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "", enqueue_status: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={vi.fn()} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3, totals: { hooks: 143, deliveries: 912 }, coverage: { both: 3, imap_only: 1, webhook_only: 2, imap_eligible: 4, ratio: 0.75, decision_mismatches: 1, incomplete_decision_traces: 1, split_identities: 1, promotion_state: "blocked", promotion_ready: false, observation_complete: true, observation_started_at: "2026-10-01T00:00:00Z", window_start: "2026-10-01T00:00:00Z", window_end: "2026-10-08T00:00:00Z", grace_seconds: 600, scope_repositories: ["gisce/github-agent-bridge"], outside_scope_imap_eligible: 5, outside_scope_webhook_eligible: 2 } }} timeseries={[]} section="overview" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore={false} deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "", enqueue_status: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={vi.fn()} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);

expect(screen.getByRole("heading", { name: "GitHub webhooks" })).toBeInTheDocument();
expect(screen.getByText("shadow")).toBeInTheDocument();
Expand All @@ -250,9 +250,17 @@ describe("dashboard routing and API query helpers", () => {
expect(screen.getByText("Decision mismatches")).toBeInTheDocument();
expect(screen.getByText("Incomplete decision traces")).toBeInTheDocument();
expect(screen.getByText("Split identities")).toBeInTheDocument();
expect(screen.getByText("Outside scope IMAP")).toBeInTheDocument();
expect(screen.getByText(/Shadow rollout cohort: 1 configured repositories/)).toBeInTheDocument();
expect(screen.getByText("blocked")).toBeInTheDocument();
});

it("labels the primary promotion scope as global eligibility", () => {
render(<WebhookPage summary={{ mode: "primary", configured: true, receipts: {}, duplicate_deliveries: 0, cross_source_matches: 1, totals: { hooks: 1, deliveries: 1 }, coverage: { both: 1, imap_only: 0, webhook_only: 0, imap_eligible: 1, ratio: 1, promotion_state: "ready", promotion_ready: true, window_start: "2026-10-01T00:00:00Z", window_end: "2026-10-08T00:00:00Z", scope_repositories: null } }} timeseries={[]} section="overview" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore={false} deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "", enqueue_status: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={vi.fn()} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);

expect(screen.getByText(/Primary eligibility scope: all repositories/)).toBeInTheDocument();
});

it("navigates webhook hook inventory and delivery details", async () => {
const user = userEvent.setup();
const onSectionChange = vi.fn();
Expand Down
13 changes: 12 additions & 1 deletion dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ type WebhookSummary = {
incomplete_decision_traces?: number;
split_identities?: number;
observation_complete?: boolean;
observation_started_at?: string | null;
promotion_state?: "insufficient_data" | "blocked" | "ready";
promotion_ready?: boolean;
imap_eligible: number;
Expand All @@ -213,6 +214,9 @@ type WebhookSummary = {
window_end?: string;
observation_days?: number;
grace_seconds?: number;
scope_repositories?: string[] | null;
outside_scope_imap_eligible?: number;
outside_scope_webhook_eligible?: number;
};
};

Expand Down Expand Up @@ -2094,6 +2098,11 @@ function WebhookPage({
const promotionState = summary?.coverage?.promotion_state
?? (summary?.coverage?.promotion_ready ? "ready" : "blocked");
const promotionLabel = promotionState === "insufficient_data" ? "insufficient data" : promotionState;
const promotionScopeLabel = summary?.mode === "primary"
? "Primary eligibility scope"
: summary?.mode === "canary"
? "Canary rollout cohort"
: "Shadow rollout cohort";
const sections = [
{ id: "overview" as const, label: "Overview" },
{ id: "hooks" as const, label: "Hooks", count: summary?.totals?.hooks },
Expand Down Expand Up @@ -2129,8 +2138,10 @@ function WebhookPage({
<MiniStat label="Incomplete decision traces" value={summary?.coverage?.incomplete_decision_traces ?? 0} />
<MiniStat label="Split identities" value={summary?.coverage?.split_identities ?? 0} />
<MiniStat label="Promotion gate" value={promotionLabel} />
<MiniStat label="Outside scope IMAP" value={summary?.coverage?.outside_scope_imap_eligible ?? 0} />
<MiniStat label="Outside scope webhook" value={summary?.coverage?.outside_scope_webhook_eligible ?? 0} />
</div>
{summary?.coverage?.window_start && summary.coverage.window_end ? <p className="mt-3 font-mono text-xs text-muted">Actionable IMAP events and comparable webhook events from <TimeText value={summary.coverage.window_start} /> to <TimeText value={summary.coverage.window_end} />; {summary.coverage.observation_days ?? 7} observation days required and newest {summary.coverage.grace_seconds ?? 0}s excluded.</p> : null}
{summary?.coverage?.window_start && summary.coverage.window_end ? <p className="mt-3 font-mono text-xs text-muted">Actionable IMAP events and comparable webhook events from <TimeText value={summary.coverage.window_start} /> to <TimeText value={summary.coverage.window_end} />; {summary.coverage.observation_days ?? 7} observation days required and newest {summary.coverage.grace_seconds ?? 0}s excluded. {promotionScopeLabel}: {summary.coverage.scope_repositories == null ? "all repositories" : `${summary.coverage.scope_repositories.length} configured repositories`}{summary.coverage.observation_started_at ? <>; observation epoch <TimeText value={summary.coverage.observation_started_at} /></> : null}.</p> : null}
</Panel>
{Object.keys(summary?.enqueue ?? {}).length ? <Panel title="Canary ingestion decisions"><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{Object.entries(summary?.enqueue ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([state, count]) => <MiniStat key={state} label={state} value={count} />)}</div></Panel> : null}
<Panel title="Exceptions requiring review">
Expand Down
14 changes: 13 additions & 1 deletion docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ duration and `GITHUB_AGENT_BRIDGE_WEBHOOK_COVERAGE_GRACE_SECONDS` to change the
default ten-minute grace period. Receipt retention must cover the full window
plus its grace period or the gate remains `insufficient_data`.

When corrected code or configuration starts a new rollout epoch, set
`GITHUB_AGENT_BRIDGE_WEBHOOK_OBSERVATION_STARTED_AT` to that deployment's
ISO-8601 UTC timestamp. The gate then ignores earlier evidence and remains
`insufficient_data` until a complete new window has elapsed. In shadow and
canary modes, coverage and exceptions are limited to the intended rollout
cohort in `webhookCanaryRepos`; actionable events outside that scope are
reported separately and cannot block the cohort gate. In primary mode, the
same queries use the global `enabledRepos` scope. An empty `enabledRepos` is
unrestricted, so primary coverage and exceptions include every repository.

Every durable ingestion receipt also records the normalized repository, work
key, event descriptor, bridge action, policy decision and work intent. The
dashboard compares those receipt-scoped decisions when both transports share a
Expand Down Expand Up @@ -287,7 +297,9 @@ both `GITHUB_AGENT_BRIDGE_WEBHOOK_MODE=primary` and
operator acknowledgement; `primary` fails closed without both it and
`GITHUB_AGENT_BRIDGE_WEBHOOK_POLICY`. The configured policy remains authoritative
for trust, action, routing and the global `enabledRepos` guardrail. Primary mode
does not use the narrower `webhookCanaryRepos` allowlist.
does not use the narrower `webhookCanaryRepos` allowlist. Its second observation
window and exception queue cover `enabledRepos`, or all repositories when that
global allowlist is empty.

Primary changes which source is expected to win, not the idempotency model.
Keep the IMAP reader enabled during the stable fallback phase. If webhook
Expand Down
9 changes: 9 additions & 0 deletions docs/shadow-canary.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ Set `GITHUB_AGENT_BRIDGE_WEBHOOK_MODE=canary`, point
canonical event identity, the first receipt creates the job and the second is
recorded as a duplicate of it.

Set `GITHUB_AGENT_BRIDGE_WEBHOOK_OBSERVATION_STARTED_AT` to the ISO-8601 UTC
deployment instant for the code and configuration being validated. Promotion
metrics and exceptions use only `webhookCanaryRepos`; out-of-scope actionable
events remain visible as separate counters but do not block this cohort.
Shadow mode uses the same intended rollout cohort for observational coverage.

Inspect webhook summary, exceptions and deliveries in the administrator
dashboard. Do not widen the canary while there are unexplained actionable
IMAP-only events, split jobs, or action/intent mismatches.
Expand Down Expand Up @@ -114,6 +120,9 @@ GITHUB_AGENT_BRIDGE_WEBHOOK_PRIMARY_ACK=true
`primary` fails closed without the explicit acknowledgement and policy path.
It stops applying the narrower `webhookCanaryRepos` list, but the global
`enabledRepos`, trust, action and routing policy still apply.
Promotion metrics and exceptions therefore cover `enabledRepos` during this
second window. If `enabledRepos` is empty, policy semantics are unrestricted
and the gate covers every repository.

Keep the IMAP reader active during a second stable observation window. Primary
changes which source is expected to win; it does not remove the idempotency or
Expand Down
76 changes: 70 additions & 6 deletions src/github_agent_bridge/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,17 @@ def _webhook_coverage_window(
retention_days: int,
observation_days: int,
grace_seconds: int,
) -> tuple[str, str, bool]:
configured_observation_start: str | None = None,
) -> tuple[str, str, bool, str | None]:
now = datetime.now(UTC)
end = now - timedelta(seconds=grace_seconds)
requested_start = end - timedelta(days=observation_days)
retained_start = now - timedelta(days=retention_days)
start = max(requested_start, retained_start)
observation_complete = False
observation_started_at = repository.observation_started_at()
observation_started_at = (
configured_observation_start or repository.observation_started_at()
)
if observation_started_at:
first_observed = _parse_webhook_datetime(
str(observation_started_at), "webhook_observation_started_at"
Expand All @@ -150,9 +153,19 @@ def _webhook_coverage_window(
_webhook_datetime_value(start),
_webhook_datetime_value(end),
observation_complete,
observation_started_at,
)


def _webhook_promotion_scope(config: DashboardConfig) -> set[str] | None:
if config.webhook_policy is None:
return None
policy = Policy.from_file(config.webhook_policy)
if config.webhook_mode == "primary":
return policy.enabled_repos or None
return policy.webhook_canary_repos


def _encode_webhook_delivery_cursor(created_at: str, delivery_id: str) -> str:
payload = json.dumps([created_at, delivery_id], separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
Expand Down Expand Up @@ -311,6 +324,7 @@ def __init__(
webhook_retention_days: int | None = None,
webhook_promotion_observation_days: int | None = None,
webhook_coverage_grace_seconds: int | None = None,
webhook_observation_started_at: str | None = None,
webhook_mode: str | None = None,
webhook_policy: str | Path | None = None,
webhook_primary_ack: bool | None = None,
Expand Down Expand Up @@ -353,6 +367,27 @@ def __init__(
else int(os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_COVERAGE_GRACE_SECONDS", "600"))
),
)
observation_start = (
webhook_observation_started_at
if webhook_observation_started_at is not None
else os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_OBSERVATION_STARTED_AT", "")
)
if observation_start:
try:
parsed_observation_start = datetime.fromisoformat(
observation_start.replace("Z", "+00:00")
)
except ValueError as exc:
raise ValueError(
"GITHUB_AGENT_BRIDGE_WEBHOOK_OBSERVATION_STARTED_AT must be an ISO-8601 datetime"
) from exc
if parsed_observation_start.tzinfo is None:
parsed_observation_start = parsed_observation_start.replace(tzinfo=UTC)
self.webhook_observation_started_at = _webhook_datetime_value(
parsed_observation_start.astimezone(UTC)
)
else:
self.webhook_observation_started_at = None
self.webhook_mode = (webhook_mode or os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_MODE", "shadow")).lower()
if self.webhook_mode not in {"shadow", "canary", "primary"}:
raise ValueError("GITHUB_AGENT_BRIDGE_WEBHOOK_MODE must be shadow, canary, or primary")
Expand Down Expand Up @@ -1000,13 +1035,25 @@ async def github_webhook_shadow(request: Request) -> dict[str, Any]:
@app.get("/api/webhooks/github/summary")
def github_webhook_shadow_summary(_: dict[str, Any] = Depends(current_admin_profile)) -> dict[str, Any]:
ensure_webhook_schema()
window_start, window_end, observation_complete = _webhook_coverage_window(
(
window_start,
window_end,
observation_complete,
observation_started_at,
) = _webhook_coverage_window(
webhook_repository,
retention_days=config.webhook_retention_days,
observation_days=config.webhook_promotion_observation_days,
grace_seconds=config.webhook_coverage_grace_seconds,
configured_observation_start=config.webhook_observation_started_at,
)
scope_repositories = _webhook_promotion_scope(config)
summary = webhook_repository.summary(
window_start, window_end, scope_repositories
)
outside_scope = webhook_repository.outside_scope_counts(
window_start, window_end, scope_repositories
)
summary = webhook_repository.summary(window_start, window_end)
has_comparable_events = (
summary["both_events"] > 0
and summary["incomplete_decision_traces"] == 0
Expand Down Expand Up @@ -1051,6 +1098,7 @@ def github_webhook_shadow_summary(_: dict[str, Any] = Depends(current_admin_prof
"incomplete_decision_traces": summary["incomplete_decision_traces"],
"split_identities": summary["split_identities"],
"observation_complete": observation_complete,
"observation_started_at": observation_started_at,
"promotion_state": promotion_state,
"promotion_ready": promotion_state == "ready",
"imap_eligible": summary["imap_events"],
Expand All @@ -1066,6 +1114,13 @@ def github_webhook_shadow_summary(_: dict[str, Any] = Depends(current_admin_prof
"window_end": window_end,
"observation_days": config.webhook_promotion_observation_days,
"grace_seconds": config.webhook_coverage_grace_seconds,
"scope_repositories": (
sorted(scope_repositories)
if scope_repositories is not None
else None
),
"outside_scope_imap_eligible": outside_scope["imap_events"],
"outside_scope_webhook_eligible": outside_scope["webhook_events"],
},
}

Expand All @@ -1076,18 +1131,27 @@ def github_webhook_shadow_exceptions(
) -> dict[str, Any]:
"""Return bounded, actionable shadow divergences without loading all receipts."""
ensure_webhook_schema()
window_start, window_end, _ = _webhook_coverage_window(
window_start, window_end, _, _ = _webhook_coverage_window(
webhook_repository,
retention_days=config.webhook_retention_days,
observation_days=config.webhook_promotion_observation_days,
grace_seconds=config.webhook_coverage_grace_seconds,
configured_observation_start=config.webhook_observation_started_at,
)
scope_repositories = _webhook_promotion_scope(config)
rows = webhook_repository.exceptions(
window_start, window_end, limit, scope_repositories
)
rows = webhook_repository.exceptions(window_start, window_end, limit)
return {
"exceptions": rows,
"window_start": window_start,
"window_end": window_end,
"grace_seconds": config.webhook_coverage_grace_seconds,
"scope_repositories": (
sorted(scope_repositories)
if scope_repositories is not None
else None
),
}

@app.get("/api/webhooks/github/timeseries")
Expand Down
Loading
Loading