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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol

### Current Focus: Phase 4A

**Phase 5.3 (browser + in-app center) is complete** — Async notifications: `useNotifications` hook with workspace-scoped `localStorage` persistence and browser Notification dispatch (only when tab hidden + permission granted); `NotificationProvider` in root layout; `NotificationCenter` (bell icon + dropdown) mounts in sidebar footer. `BatchExecutionMonitor` dispatches `batch.completed` on terminal status transitions (distinguishing COMPLETED/FAILED/CANCELLED in both the in-app message and the success icon) and `blocker.created` on per-task BLOCKED transitions. `/execution` requests browser permission once on mount when permission is `'default'`. `/proof` dispatches `gate.run.failed` per failed gate when a proof run completes with `passed === false`. Known limitation: notifications only fire while `BatchExecutionMonitor` is mounted (cross-page background poller is out of scope; tracked for future work). Webhook (#560) deferred. Issue #559.
**Phase 5.3 is complete** — Async notifications cover both surfaces:
- **Browser + in-app center (#559)**: `useNotifications` hook with workspace-scoped `localStorage` persistence and browser Notification dispatch (only when tab hidden + permission granted); `NotificationProvider` in root layout; `NotificationCenter` (bell icon + dropdown) mounts in sidebar footer. `BatchExecutionMonitor` dispatches `batch.completed` on terminal status transitions (distinguishing COMPLETED/FAILED/CANCELLED in both the in-app message and the success icon) and `blocker.created` on per-task BLOCKED transitions. `/execution` requests browser permission once on mount when permission is `'default'`. `/proof` dispatches `gate.run.failed` per failed gate when a proof run completes with `passed === false`. Known limitation: notifications only fire while `BatchExecutionMonitor` is mounted (cross-page background poller is out of scope; tracked for future work).
- **Outbound webhook (#560)**: Settings → Notifications tab takes a single URL + enabled toggle, persisted to `.codeframe/notifications_config.json` via `atomic_write_json`. `GET/PUT /api/v2/settings/notifications` and `POST /api/v2/settings/notifications/test` (test fires a sample payload and surfaces status code). `WebhookNotificationService.send_event` is the generic backend; dispatched fire-and-forget (5s timeout) from `core/conductor.py` on `BATCH_COMPLETED` only (not PARTIAL/FAILED/CANCELLED), `core/blockers.py:create()` after `BLOCKER_CREATED`, and `ui/routers/pr_v2.py:merge_pull_request` after successful merge. Failures are logged but never break the triggering operation.

**Phase 5.2 is complete** — Costs page now ships per-task and per-agent breakdowns (#558) on top of the spend summary (#557). Backend: `GET /api/v2/costs/tasks?days=N&limit=M` (top-N tasks with titles, agent, tokens, cost) and `GET /api/v2/costs/by-agent?days=N` (per-agent rollup + total input/output tokens), both via `TokenRepository.get_top_tasks_by_cost` and `get_costs_by_agent`. Task board cards show an inline `MoneyBag02Icon` cost badge with token-breakdown tooltip when cost data exists. Fixed a v2 data-loss bug where `react_agent` int-cast UUID task IDs and stored NULL in `token_usage`.

Expand Down
29 changes: 29 additions & 0 deletions codeframe/core/blockers.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,38 @@ def create(
print_event=True,
)

# Outbound webhook dispatch (issue #560) — fire-and-forget, never blocks
# blocker creation. A misconfigured webhook must not break the workflow.
_dispatch_blocker_webhook(workspace, blocker_id, task_id)

return blocker


def _dispatch_blocker_webhook(
workspace: Workspace, blocker_id: str, task_id: Optional[str]
) -> None:
"""Best-effort outbound webhook for ``blocker.created``."""
try:
from codeframe.core.notifications_config import is_webhook_active
from codeframe.notifications.webhook import (
WebhookNotificationService,
format_blocker_payload,
)

url = is_webhook_active(workspace)
if url is None:
return
svc = WebhookNotificationService(webhook_url=url, timeout=5)
svc.send_event_background(format_blocker_payload(blocker_id, task_id))
except Exception:
import logging

logging.getLogger(__name__).warning(
"Failed to dispatch blocker.created webhook for %s", blocker_id,
exc_info=True,
)


def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]:
"""Get a blocker by ID.

Expand Down
39 changes: 39 additions & 0 deletions codeframe/core/conductor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,41 @@

logger = logging.getLogger(__name__)


def _dispatch_batch_completed_webhook(
workspace: Workspace,
event_type: "events.EventType",
batch_id: str,
task_count: int,
) -> None:
"""Best-effort outbound webhook for ``batch.completed`` (issue #560).

Only fires when the batch reached the full COMPLETED state — PARTIAL,
FAILED, and CANCELLED are intentionally out of scope for v1. Failures
never break the batch.
"""
if event_type != events.EventType.BATCH_COMPLETED:
return
try:
from codeframe.core.notifications_config import is_webhook_active
from codeframe.notifications.webhook import (
WebhookNotificationService,
format_batch_payload,
)

url = is_webhook_active(workspace)
if url is None:
return
svc = WebhookNotificationService(webhook_url=url, timeout=5)
svc.send_event_background(format_batch_payload(batch_id, task_count))
except Exception:
logger.warning(
"Failed to dispatch batch.completed webhook for %s",
batch_id,
exc_info=True,
)


# Tactical patterns that the supervisor should auto-resolve
SUPERVISOR_TACTICAL_PATTERNS = [
# Virtual environment / package management
Expand Down Expand Up @@ -1125,6 +1160,7 @@ def _execute_serial_resume(
},
print_event=True,
)
_dispatch_batch_completed_webhook(workspace, event_type, batch.id, final_completed)

# Print summary
print(f"\nBatch resume {batch.status.value.lower()}: {final_completed}/{total} tasks completed")
Expand Down Expand Up @@ -1282,6 +1318,7 @@ def _execute_retries(
},
print_event=True,
)
_dispatch_batch_completed_webhook(workspace, event_type, batch.id, final_completed)

# Print retry summary
if final_failed == 0:
Expand Down Expand Up @@ -1544,6 +1581,7 @@ def _execute_serial(
},
print_event=True,
)
_dispatch_batch_completed_webhook(workspace, event_type, batch.id, completed_count)

# Stop reconciliation thread
reconcile_stop.set()
Expand Down Expand Up @@ -1733,6 +1771,7 @@ def _execute_parallel(
},
print_event=True,
)
_dispatch_batch_completed_webhook(workspace, event_type, batch.id, completed_count)

# Stop reconciliation thread
_reconcile_stop_p.set()
Expand Down
183 changes: 183 additions & 0 deletions codeframe/core/notifications_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Per-workspace outbound webhook notification config (issue #560).

Stored alongside other workspace UI settings under ``.codeframe/`` as a JSON
file. Headless — no FastAPI or HTTP imports.

Schema (``.codeframe/notifications_config.json``):

{
"webhook_url": "https://hooks.example.com/...", // or null
"webhook_enabled": true
}

Defense-in-depth: ``is_webhook_active`` re-validates the URL scheme/host
because the JSON file can be hand-edited or migrated from an older
deployment that didn't enforce validation at write time. The router PUT
endpoint validates too — never trust just one layer.
"""

from __future__ import annotations

import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Optional, TypedDict

from codeframe.core.workspace import Workspace

logger = logging.getLogger(__name__)

NOTIFICATIONS_CONFIG_FILENAME = "notifications_config.json"

# Intentionally duplicated with codeframe/ui/routers/settings_v2.py's
# ``_ALLOWED_WEBHOOK_SCHEMES``: core cannot import from the UI layer
# (architecture rule #1 — core must be headless). Keep both values in
# sync if extended.
_ALLOWED_SCHEMES = frozenset({"http", "https"})


class NotificationsConfig(TypedDict):
webhook_url: Optional[str]
webhook_enabled: bool


_DEFAULT: NotificationsConfig = {"webhook_url": None, "webhook_enabled": False}


def _config_path(workspace: Workspace) -> Path:
return workspace.state_dir / NOTIFICATIONS_CONFIG_FILENAME


def _is_safe_webhook_url(url: str) -> bool:
"""Defence-in-depth: scheme/host check on a stored URL before we POST.

The router PUT validates too, but a hand-edited or pre-migration JSON
file could carry a ``file://`` or schemeless URL. Used by
``is_webhook_active`` to fail-safe to ``None``.

TODO: This does NOT block RFC-1918 (10/8, 172.16/12, 192.168/16) or
loopback (127/8) addresses. For a self-hosted single-user tool that is
intentional — users want to point at local receivers. If CodeFRAME ever
runs as a shared / multi-tenant service, add a socket-level check here
that resolves the host and rejects private/loopback ranges.
"""
from urllib.parse import urlparse

try:
parsed = urlparse(url)
except (TypeError, ValueError):
return False
return parsed.scheme.lower() in _ALLOWED_SCHEMES and bool(parsed.netloc)


def load_notifications_config(workspace: Workspace) -> NotificationsConfig:
"""Read notifications config, returning defaults on missing/corrupt file.

Never raises — a broken config should not break the triggering operation.
Non-object JSON (``[]``, ``null``, a bare integer) is treated as corrupt
and falls back to defaults rather than raising ``AttributeError`` from
a downstream ``.get()`` call.
"""
path = _config_path(workspace)
if not path.exists():
return dict(_DEFAULT) # type: ignore[return-value]
try:
data = json.loads(path.read_text())
if not isinstance(data, dict):
raise ValueError(
f"Expected JSON object, got {type(data).__name__}"
)
return {
"webhook_url": data.get("webhook_url") or None,
"webhook_enabled": bool(data.get("webhook_enabled", False)),
}
Comment thread
frankbria marked this conversation as resolved.
except (OSError, json.JSONDecodeError, ValueError) as e:
logger.warning(
"Invalid notifications_config.json — falling back to defaults: %s", e
)
return dict(_DEFAULT) # type: ignore[return-value]


def save_notifications_config(
workspace: Workspace, config: NotificationsConfig
) -> None:
"""Atomically persist notifications config to disk."""
path = _config_path(workspace)
payload = {
"webhook_url": config.get("webhook_url") or None,
"webhook_enabled": bool(config.get("webhook_enabled", False)),
}
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
try:
with os.fdopen(fd, "w") as f:
f.write(json.dumps(payload, indent=2))
os.replace(tmp_name, path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise


def is_webhook_active(workspace: Workspace) -> Optional[str]:
"""Return the webhook URL if URL is set, enabled flag is on, AND the URL
passes basic safety checks (``http(s)`` scheme + non-empty host).

Returns ``None`` otherwise — callers should short-circuit on ``None`` to
avoid instantiating the webhook service for nothing. The safety check is
intentionally redundant with the PUT-endpoint validation: it protects
against hand-edited config files and pre-migration data.
"""
cfg = load_notifications_config(workspace)
url = (cfg["webhook_url"] or "").strip()
if not url or not cfg["webhook_enabled"]:
return None
if not _is_safe_webhook_url(url):
logger.warning(
"Refusing to dispatch webhook to unsafe URL: %s",
_redact_url_for_log(url),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
Comment thread
frankbria marked this conversation as resolved.
return None
return url


def _redact_url_for_log(url: str) -> str:
"""Return a logging-safe representation of a webhook URL.

Slack/Discord/GitHub-style webhook URLs commonly embed secrets in:

* the path or query (Slack's ``T*/B*/...`` token, signed Zapier hooks)
* basic-auth credentials in ``user:password@host`` form

``parsed.netloc`` preserves the userinfo segment, so we use
``parsed.hostname`` (which strips auth and port) and re-attach the
port explicitly. Returns ``scheme://host[:port]`` when parsable, else
``"<unparseable>"``.
"""
from urllib.parse import urlparse

try:
parsed = urlparse(url)
except (TypeError, ValueError):
return "<unparseable>"
if not parsed.scheme or not parsed.hostname:
return "<unparseable>"
host = parsed.hostname
# ``parsed.port`` raises ValueError for malformed ports (e.g.
# ``file://host:abc/x``). Without this guard the "fail-safe" branch
# in ``is_webhook_active`` could end up raising instead of returning
# None, which would bubble through every dispatch site's broad
# ``except Exception``.
try:
port = parsed.port
except ValueError:
return "<unparseable>"
if port is not None:
host = f"{host}:{port}"
return f"{parsed.scheme}://{host}"
Comment thread
frankbria marked this conversation as resolved.
Loading
Loading