1
0
This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/integrations/paperclip/client.py
Alexander Whitestone 9d78eb31d1 ruff (#169)
* polish: streamline nav, extract inline styles, improve tablet UX

- Restructure desktop nav from 8+ flat links + overflow dropdown into
  5 grouped dropdowns (Core, Agents, Intel, System, More) matching
  the mobile menu structure to reduce decision fatigue
- Extract all inline styles from mission_control.html and base.html
  notification elements into mission-control.css with semantic classes
- Replace JS-built innerHTML with secure DOM construction in
  notification loader and chat history
- Add CONNECTING state to connection indicator (amber) instead of
  showing OFFLINE before WebSocket connects
- Add tablet breakpoint (1024px) with larger touch targets for
  Apple Pencil / stylus use and safe-area padding for iPad toolbar
- Add active-link highlighting in desktop dropdown menus
- Rename "Mission Control" page title to "System Overview" to
  disambiguate from the chat home page
- Add "Home — Timmy Time" page title to index.html

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

* fix(security): move auth-gate credentials to environment variables

Hardcoded username, password, and HMAC secret in auth-gate.py replaced
with os.environ lookups. Startup now refuses to run if any variable is
unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example.

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

* refactor(tooling): migrate from black+isort+bandit to ruff

Replace three separate linting/formatting tools with a single ruff
invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs),
.pre-commit-config.yaml, and CI workflow. Fixes all ruff errors
including unused imports, missing raise-from, and undefined names.
Ruff config maps existing bandit skips to equivalent S-rules.

https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 12:23:35 -04:00

307 lines
12 KiB
Python

"""Paperclip AI API client.
Async HTTP client for communicating with a remote Paperclip server.
All methods degrade gracefully — log the error, return a fallback, never crash.
Paperclip API is mounted at ``/api`` and uses ``local_trusted`` mode on the
VPS, so the board actor is implicit. When the server sits behind an nginx
auth-gate the client authenticates with Basic-auth on the first request and
re-uses the session cookie thereafter.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from config import settings
from integrations.paperclip.models import (
CreateIssueRequest,
PaperclipAgent,
PaperclipComment,
PaperclipGoal,
PaperclipIssue,
UpdateIssueRequest,
)
logger = logging.getLogger(__name__)
class PaperclipClient:
"""Thin async wrapper around the Paperclip REST API.
All public methods return typed results on success or ``None`` / ``[]``
on failure so callers never need to handle exceptions.
"""
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
timeout: int = 30,
):
self._base_url = (base_url or settings.paperclip_url).rstrip("/")
self._api_key = api_key or settings.paperclip_api_key
self._timeout = timeout or settings.paperclip_timeout
self._client: httpx.AsyncClient | None = None
# ── lifecycle ────────────────────────────────────────────────────────
def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
headers: dict[str, str] = {"Accept": "application/json"}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers=headers,
timeout=self._timeout,
)
return self._client
async def close(self) -> None:
if self._client and not self._client.is_closed:
await self._client.aclose()
# ── helpers ──────────────────────────────────────────────────────────
async def _get(self, path: str, params: dict | None = None) -> Any | None:
try:
resp = await self._get_client().get(path, params=params)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.warning("Paperclip GET %s failed: %s", path, exc)
return None
async def _post(self, path: str, json: dict | None = None) -> Any | None:
try:
resp = await self._get_client().post(path, json=json)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.warning("Paperclip POST %s failed: %s", path, exc)
return None
async def _patch(self, path: str, json: dict | None = None) -> Any | None:
try:
resp = await self._get_client().patch(path, json=json)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.warning("Paperclip PATCH %s failed: %s", path, exc)
return None
async def _delete(self, path: str) -> bool:
try:
resp = await self._get_client().delete(path)
resp.raise_for_status()
return True
except Exception as exc:
logger.warning("Paperclip DELETE %s failed: %s", path, exc)
return False
# ── health ───────────────────────────────────────────────────────────
async def healthy(self) -> bool:
"""Quick connectivity check."""
data = await self._get("/api/health")
return data is not None
# ── companies ────────────────────────────────────────────────────────
async def list_companies(self) -> list[dict[str, Any]]:
data = await self._get("/api/companies")
return data if isinstance(data, list) else []
# ── agents ───────────────────────────────────────────────────────────
async def list_agents(self, company_id: str | None = None) -> list[PaperclipAgent]:
cid = company_id or settings.paperclip_company_id
if not cid:
logger.warning("paperclip_company_id not set — cannot list agents")
return []
data = await self._get(f"/api/companies/{cid}/agents")
if not isinstance(data, list):
return []
return [PaperclipAgent(**a) for a in data]
async def get_agent(self, agent_id: str) -> PaperclipAgent | None:
data = await self._get(f"/api/agents/{agent_id}")
return PaperclipAgent(**data) if data else None
async def wake_agent(
self,
agent_id: str,
issue_id: str | None = None,
message: str | None = None,
) -> dict[str, Any] | None:
"""Trigger a heartbeat wake for an agent."""
body: dict[str, Any] = {}
if issue_id:
body["issueId"] = issue_id
if message:
body["message"] = message
return await self._post(f"/api/agents/{agent_id}/wakeup", json=body)
async def get_org(self, company_id: str | None = None) -> dict[str, Any] | None:
cid = company_id or settings.paperclip_company_id
if not cid:
return None
return await self._get(f"/api/companies/{cid}/org")
# ── issues (tickets) ─────────────────────────────────────────────────
async def list_issues(
self,
company_id: str | None = None,
status: str | None = None,
) -> list[PaperclipIssue]:
cid = company_id or settings.paperclip_company_id
if not cid:
return []
params: dict[str, str] = {}
if status:
params["status"] = status
data = await self._get(f"/api/companies/{cid}/issues", params=params)
if not isinstance(data, list):
return []
return [PaperclipIssue(**i) for i in data]
async def get_issue(self, issue_id: str) -> PaperclipIssue | None:
data = await self._get(f"/api/issues/{issue_id}")
return PaperclipIssue(**data) if data else None
async def create_issue(
self,
req: CreateIssueRequest,
company_id: str | None = None,
) -> PaperclipIssue | None:
cid = company_id or settings.paperclip_company_id
if not cid:
logger.warning("paperclip_company_id not set — cannot create issue")
return None
data = await self._post(
f"/api/companies/{cid}/issues",
json=req.model_dump(exclude_none=True),
)
return PaperclipIssue(**data) if data else None
async def update_issue(
self,
issue_id: str,
req: UpdateIssueRequest,
) -> PaperclipIssue | None:
data = await self._patch(
f"/api/issues/{issue_id}",
json=req.model_dump(exclude_none=True),
)
return PaperclipIssue(**data) if data else None
async def delete_issue(self, issue_id: str) -> bool:
return await self._delete(f"/api/issues/{issue_id}")
# ── issue comments ───────────────────────────────────────────────────
async def list_comments(self, issue_id: str) -> list[PaperclipComment]:
data = await self._get(f"/api/issues/{issue_id}/comments")
if not isinstance(data, list):
return []
return [PaperclipComment(**c) for c in data]
async def add_comment(
self,
issue_id: str,
content: str,
) -> PaperclipComment | None:
data = await self._post(
f"/api/issues/{issue_id}/comments",
json={"content": content},
)
return PaperclipComment(**data) if data else None
# ── issue workflow ───────────────────────────────────────────────────
async def checkout_issue(self, issue_id: str) -> dict[str, Any] | None:
"""Assign an issue to Timmy (checkout)."""
body: dict[str, Any] = {}
if settings.paperclip_agent_id:
body["agentId"] = settings.paperclip_agent_id
return await self._post(f"/api/issues/{issue_id}/checkout", json=body)
async def release_issue(self, issue_id: str) -> dict[str, Any] | None:
"""Release a checked-out issue."""
return await self._post(f"/api/issues/{issue_id}/release")
# ── goals ────────────────────────────────────────────────────────────
async def list_goals(self, company_id: str | None = None) -> list[PaperclipGoal]:
cid = company_id or settings.paperclip_company_id
if not cid:
return []
data = await self._get(f"/api/companies/{cid}/goals")
if not isinstance(data, list):
return []
return [PaperclipGoal(**g) for g in data]
async def create_goal(
self,
title: str,
description: str = "",
company_id: str | None = None,
) -> PaperclipGoal | None:
cid = company_id or settings.paperclip_company_id
if not cid:
return None
data = await self._post(
f"/api/companies/{cid}/goals",
json={"title": title, "description": description},
)
return PaperclipGoal(**data) if data else None
# ── heartbeat runs ───────────────────────────────────────────────────
async def list_heartbeat_runs(
self,
company_id: str | None = None,
) -> list[dict[str, Any]]:
cid = company_id or settings.paperclip_company_id
if not cid:
return []
data = await self._get(f"/api/companies/{cid}/heartbeat-runs")
return data if isinstance(data, list) else []
async def get_run_events(self, run_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/heartbeat-runs/{run_id}/events")
return data if isinstance(data, list) else []
async def cancel_run(self, run_id: str) -> dict[str, Any] | None:
return await self._post(f"/api/heartbeat-runs/{run_id}/cancel")
# ── approvals ────────────────────────────────────────────────────────
async def list_approvals(self, company_id: str | None = None) -> list[dict[str, Any]]:
cid = company_id or settings.paperclip_company_id
if not cid:
return []
data = await self._get(f"/api/companies/{cid}/approvals")
return data if isinstance(data, list) else []
async def approve(self, approval_id: str, comment: str = "") -> dict[str, Any] | None:
body: dict[str, Any] = {}
if comment:
body["comment"] = comment
return await self._post(f"/api/approvals/{approval_id}/approve", json=body)
async def reject(self, approval_id: str, comment: str = "") -> dict[str, Any] | None:
body: dict[str, Any] = {}
if comment:
body["comment"] = comment
return await self._post(f"/api/approvals/{approval_id}/reject", json=body)
# Module-level singleton
paperclip = PaperclipClient()