This commit is contained in:
null 2026-05-26 15:41:24 -05:00
parent d65e888ade
commit 077a07b233
11 changed files with 819 additions and 0 deletions

173
backend/app/api/bot.py Normal file
View File

@ -0,0 +1,173 @@
"""Bot API key management and project report endpoints."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlmodel import col, desc, select
from app.api.deps import require_org_admin, require_org_member
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
from app.core.time import utcnow
from app.db.session import get_session
from app.models.bot import BotApiKey, BotReport
from app.schemas.bot import (
BotApiKeyCreate,
BotApiKeyCreated,
BotApiKeyRead,
BotReportCreate,
BotReportRead,
)
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/bot", tags=["bot"])
SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member)
ORG_ADMIN_DEP = Depends(require_org_admin)
async def _get_bot_key_auth(
x_bot_key: str | None = Header(default=None, alias="X-Bot-Key"),
session: AsyncSession = SESSION_DEP,
) -> BotApiKey:
"""Validate X-Bot-Key header against stored hashes. Returns the matching key row."""
if not x_bot_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="X-Bot-Key header required.")
keys = list(await session.exec(select(BotApiKey)))
for key in keys:
if verify_agent_token(x_bot_key, key.key_hash):
key.last_used_at = utcnow()
session.add(key)
await session.commit()
return key
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bot key.")
BOT_KEY_DEP = Depends(_get_bot_key_auth)
# ---------------------------------------------------------------------------
# Key management (user auth)
# ---------------------------------------------------------------------------
@router.post("/keys", response_model=BotApiKeyCreated, status_code=status.HTTP_201_CREATED)
async def create_bot_key(
data: BotApiKeyCreate,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> BotApiKeyCreated:
"""Generate a new bot API key. The plaintext key is returned once — store it immediately."""
plaintext = generate_agent_token()
key = BotApiKey(
organization_id=ctx.organization.id,
name=data.name,
key_hash=hash_agent_token(plaintext),
key_last_four=plaintext[-4:],
)
session.add(key)
await session.commit()
await session.refresh(key)
return BotApiKeyCreated(
id=key.id,
name=key.name,
key_last_four=key.key_last_four,
created_at=key.created_at,
last_used_at=key.last_used_at,
key=plaintext,
)
@router.get("/keys", response_model=list[BotApiKeyRead])
async def list_bot_keys(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[BotApiKeyRead]:
"""List all bot API keys for this organization."""
keys = list(await session.exec(
select(BotApiKey)
.where(col(BotApiKey.organization_id) == ctx.organization.id)
.order_by(desc(col(BotApiKey.created_at)))
))
return [BotApiKeyRead.model_validate(k, from_attributes=True) for k in keys]
@router.delete("/keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bot_key(
key_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> None:
"""Revoke a bot API key."""
key = await session.get(BotApiKey, key_id)
if key is None or key.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await session.delete(key)
await session.commit()
# ---------------------------------------------------------------------------
# Reporting (bot key auth)
# ---------------------------------------------------------------------------
@router.post("/report", response_model=BotReportRead, status_code=status.HTTP_201_CREATED)
async def create_bot_report(
data: BotReportCreate,
session: AsyncSession = SESSION_DEP,
key: BotApiKey = BOT_KEY_DEP,
) -> BotReportRead:
"""Record what the bot is currently working on."""
report = BotReport(
organization_id=key.organization_id,
key_id=key.id,
project=data.project,
task=data.task,
status=data.status,
detail=data.detail,
)
session.add(report)
await session.commit()
await session.refresh(report)
return BotReportRead.model_validate(report, from_attributes=True)
# ---------------------------------------------------------------------------
# Report reading (user auth)
# ---------------------------------------------------------------------------
@router.get("/reports/latest", response_model=BotReportRead)
async def get_latest_bot_report(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> BotReportRead:
"""Return the most recent bot report for this organization."""
report = (await session.exec(
select(BotReport)
.where(col(BotReport.organization_id) == ctx.organization.id)
.order_by(desc(col(BotReport.reported_at)))
.limit(1)
)).first()
if report is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return BotReportRead.model_validate(report, from_attributes=True)
@router.get("/reports", response_model=list[BotReportRead])
async def list_bot_reports(
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[BotReportRead]:
"""List bot reports newest-first."""
reports = list(await session.exec(
select(BotReport)
.where(col(BotReport.organization_id) == ctx.organization.id)
.order_by(desc(col(BotReport.reported_at)))
.limit(limit)
))
return [BotReportRead.model_validate(r, from_attributes=True) for r in reports]

View File

@ -12,6 +12,7 @@ from fastapi.openapi.utils import get_openapi
from fastapi_pagination import add_pagination from fastapi_pagination import add_pagination
from app.api.activity import router as activity_router from app.api.activity import router as activity_router
from app.api.bot import router as bot_router
from app.api.agent import router as agent_router from app.api.agent import router as agent_router
from app.api.agent_forgejo import router as agent_forgejo_router from app.api.agent_forgejo import router as agent_forgejo_router
from app.api.agent_sessions import router as agent_sessions_router from app.api.agent_sessions import router as agent_sessions_router
@ -607,6 +608,7 @@ def readyz() -> HealthStatusResponse:
api_v1 = APIRouter(prefix="/api/v1") api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(auth_router) api_v1.include_router(auth_router)
api_v1.include_router(bot_router)
api_v1.include_router(agent_router) api_v1.include_router(agent_router)
api_v1.include_router(agents_router) api_v1.include_router(agents_router)
api_v1.include_router(activity_router) api_v1.include_router(activity_router)

38
backend/app/models/bot.py Normal file
View File

@ -0,0 +1,38 @@
"""Models for bot API keys and project reports."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field
from app.core.time import utcnow
from app.models.base import QueryModel
class BotApiKey(QueryModel, table=True):
__tablename__ = "bot_api_keys" # pyright: ignore[reportAssignmentType]
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str = Field(index=True)
key_hash: str = Field(index=True)
key_last_four: str
created_at: datetime = Field(default_factory=utcnow)
last_used_at: datetime | None = Field(default=None)
class BotReport(QueryModel, table=True):
__tablename__ = "bot_reports" # pyright: ignore[reportAssignmentType]
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
key_id: UUID = Field(foreign_key="bot_api_keys.id", index=True)
project: str | None = Field(default=None)
task: str | None = Field(default=None)
status: str | None = Field(default=None)
detail: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
reported_at: datetime = Field(default_factory=utcnow, index=True)

View File

@ -0,0 +1,43 @@
"""Schemas for bot API keys and project reports."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from sqlmodel import Field, SQLModel
class BotApiKeyCreate(SQLModel):
name: str
class BotApiKeyRead(SQLModel):
id: UUID
name: str
key_last_four: str
created_at: datetime
last_used_at: datetime | None
class BotApiKeyCreated(BotApiKeyRead):
"""Returned once on creation — includes the plaintext key, never stored."""
key: str
class BotReportCreate(SQLModel):
project: str | None = None
task: str | None = None
status: str | None = "busy"
detail: dict[str, Any] | None = None
class BotReportRead(SQLModel):
id: UUID
key_id: UUID
project: str | None
task: str | None
status: str | None
detail: dict[str, Any] | None
reported_at: datetime

View File

@ -0,0 +1,63 @@
"""add bot api keys and reports
Revision ID: d5e6f7a8b9c0
Revises: c6d7e8f9a0b1
Create Date: 2026-05-26 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "d5e6f7a8b9c0"
down_revision = "c6d7e8f9a0b1"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = inspect(op.get_bind())
if not inspector.has_table("bot_api_keys"):
op.create_table(
"bot_api_keys",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("key_hash", sa.String(), nullable=False),
sa.Column("key_last_four", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_bot_api_keys_organization_id", "bot_api_keys", ["organization_id"])
op.create_index("ix_bot_api_keys_key_hash", "bot_api_keys", ["key_hash"])
op.create_index("ix_bot_api_keys_name", "bot_api_keys", ["name"])
if not inspector.has_table("bot_reports"):
op.create_table(
"bot_reports",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("key_id", sa.Uuid(), nullable=False),
sa.Column("project", sa.String(), nullable=True),
sa.Column("task", sa.String(), nullable=True),
sa.Column("status", sa.String(), nullable=True),
sa.Column("detail", sa.JSON(), nullable=True),
sa.Column("reported_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.ForeignKeyConstraint(["key_id"], ["bot_api_keys.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_bot_reports_organization_id", "bot_reports", ["organization_id"])
op.create_index("ix_bot_reports_key_id", "bot_reports", ["key_id"])
op.create_index("ix_bot_reports_reported_at", "bot_reports", ["reported_at"])
def downgrade() -> None:
op.drop_table("bot_reports")
op.drop_table("bot_api_keys")

View File

@ -63,6 +63,7 @@ import {
} from "@/components/dashboard/RuntimeUsageSection"; } from "@/components/dashboard/RuntimeUsageSection";
import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel"; import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel";
import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel"; import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel";
import { BotStatusSection } from "@/components/dashboard/BotStatusSection";
import { import {
type listAgentsApiV1AgentsGetResponse, type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet, useListAgentsApiV1AgentsGet,
@ -1462,6 +1463,10 @@ export default function DashboardPage() {
</div> </div>
)} )}
<div className="mt-4">
<BotStatusSection />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<SessionsSection <SessionsSection
sessions={sessionSummaries} sessions={sessionSummaries}

View File

@ -0,0 +1,243 @@
"use client";
export const dynamic = "force-dynamic";
import { useCallback, useState } from "react";
import { Bot, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
listBotKeys,
createBotKey,
deleteBotKey,
type BotApiKeyRead,
type BotApiKeyCreated,
} from "@/lib/api/bot";
// ---------------------------------------------------------------------------
// One-time key reveal dialog
// ---------------------------------------------------------------------------
function KeyRevealDialog({
created,
onClose,
}: {
created: BotApiKeyCreated | null;
onClose: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
if (!created) return;
void navigator.clipboard.writeText(created.key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [created]);
return (
<Dialog open={!!created} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>API Key Generated</DialogTitle>
<DialogDescription>
Copy this key now it will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2 rounded-lg border border-[color:rgba(251,191,36,0.35)] bg-[color:rgba(251,191,36,0.08)] p-3">
<code className="flex-1 break-all font-mono text-sm text-strong">
{created?.key}
</code>
<Button size="sm" variant="ghost" onClick={handleCopy} className="shrink-0 px-2">
{copied ? <Check className="h-4 w-4 text-[color:var(--success)]" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted">
Add this to Ripley&apos;s <code className="font-mono">TOOLS.md</code> as{" "}
<code className="font-mono">PIPELINE_BOT_KEY={created?.key}</code>
</p>
</div>
<div className="flex justify-end pt-2">
<Button onClick={onClose}>Done</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Key row
// ---------------------------------------------------------------------------
function KeyRow({
apiKey,
onDelete,
}: {
apiKey: BotApiKeyRead;
onDelete: (id: string) => void;
}) {
const [confirmOpen, setConfirmOpen] = useState(false);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: () => deleteBotKey(apiKey.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["bot-keys"] });
setConfirmOpen(false);
},
});
const createdAt = new Date(apiKey.created_at).toLocaleDateString();
const lastUsed = apiKey.last_used_at
? new Date(apiKey.last_used_at).toLocaleString()
: "Never";
return (
<>
<div className="flex items-center justify-between gap-4 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<KeyRound className="h-4 w-4 shrink-0 text-muted" />
<div className="min-w-0">
<p className="truncate font-medium text-strong">{apiKey.name}</p>
<p className="text-xs text-muted">
ending {apiKey.key_last_four} · created {createdAt} · last used {lastUsed}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="shrink-0 px-2 text-muted hover:text-[color:var(--danger)]"
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<ConfirmActionDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Revoke API key?"
description={
<>
This will permanently revoke <strong>{apiKey.name}</strong>{" "}
(ending {apiKey.key_last_four}). Any scripts using this key will stop working immediately.
</>
}
onConfirm={() => deleteMutation.mutate()}
isConfirming={deleteMutation.isPending}
confirmLabel="Revoke"
confirmVariant="primary"
/>
</>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function BotApiKeysPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [createdKey, setCreatedKey] = useState<BotApiKeyCreated | null>(null);
const keysQuery = useQuery({
queryKey: ["bot-keys"],
queryFn: listBotKeys,
});
const createMutation = useMutation({
mutationFn: () => createBotKey(name.trim()),
onSuccess: (data) => {
setCreatedKey(data);
setName("");
void queryClient.invalidateQueries({ queryKey: ["bot-keys"] });
},
});
const handleGenerate = useCallback(() => {
if (!name.trim()) return;
createMutation.mutate();
}, [name, createMutation]);
const keys = keysQuery.data ?? [];
return (
<DashboardPageLayout
title="Bot API Keys"
description="Generate API keys so the OpenClaw agent (Ripley) can report its current project to the dashboard."
signedOut={{
message: "Sign in to manage bot API keys.",
forceRedirectUrl: "/settings/bot-api-keys",
signUpForceRedirectUrl: "/settings/bot-api-keys",
}}
>
<div className="space-y-8">
{/* Generate section */}
<section className="rounded-xl border border-[color:var(--border)] p-6 space-y-4">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-[color:var(--warning)]" />
<h2 className="text-base font-semibold text-strong">Generate a new key</h2>
</div>
<p className="text-sm text-muted">
Give the key a name (e.g. &quot;Ripley&quot;), then click Generate. The plaintext key
is shown once copy it immediately.
</p>
<div className="flex gap-2">
<Input
placeholder="Key name, e.g. Ripley"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleGenerate(); }}
className="max-w-xs"
/>
<Button
onClick={handleGenerate}
disabled={!name.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate
</Button>
</div>
{createMutation.isError && (
<p className="text-sm text-[color:var(--danger)]">Failed to generate key. Try again.</p>
)}
</section>
{/* Key list */}
<section className="space-y-3">
<h2 className="text-base font-semibold text-strong">Active keys</h2>
{keysQuery.isLoading && (
<div className="flex items-center gap-2 text-muted text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
)}
{!keysQuery.isLoading && keys.length === 0 && (
<p className="text-sm text-muted">No keys yet. Generate one above.</p>
)}
{keys.map((k) => (
<KeyRow key={k.id} apiKey={k} onDelete={() => void queryClient.invalidateQueries({ queryKey: ["bot-keys"] })} />
))}
</section>
</div>
<KeyRevealDialog created={createdKey} onClose={() => setCreatedKey(null)} />
</DashboardPageLayout>
);
}

View File

@ -510,6 +510,13 @@ export default function SettingsPage() {
icon: GitBranch, icon: GitBranch,
tone: "warning", tone: "warning",
}, },
{
href: "/settings/bot-api-keys",
label: "Bot API keys",
description: "Generate and manage API keys for the OpenClaw agent (Ripley).",
icon: Bot,
tone: "neutral",
},
]; ];
const operationItems: SettingsLink[] = isAdmin const operationItems: SettingsLink[] = isAdmin

View File

@ -0,0 +1,80 @@
"use client";
import { Bot, Clock } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getLatestBotReport, type BotReportRead } from "@/lib/api/bot";
import { DashboardSection } from "./DashboardSection";
function timeAgo(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function StatusBadge({ status }: { status: string | null }) {
if (!status) return null;
const styles: Record<string, string> = {
busy: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
idle: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
error: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
};
const style = styles[status.toLowerCase()] ?? "bg-[color:var(--surface-strong)] text-muted";
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${style}`}>
{status}
</span>
);
}
function ReportContent({ report }: { report: BotReportRead }) {
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-2xl font-semibold text-strong truncate">
{report.project ?? "—"}
</p>
{report.task && (
<p className="mt-1 text-sm text-muted truncate">{report.task}</p>
)}
</div>
<StatusBadge status={report.status} />
</div>
<div className="flex items-center gap-1.5 text-xs text-muted">
<Clock className="h-3 w-3" />
{timeAgo(report.reported_at)}
</div>
</div>
);
}
function EmptyState() {
return (
<div className="flex items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm text-muted">
<Bot className="h-4 w-4 shrink-0" />
Ripley hasn&apos;t reported in yet.
</div>
);
}
export function BotStatusSection() {
const { data: report } = useQuery({
queryKey: ["bot-report-latest"],
queryFn: getLatestBotReport,
refetchInterval: 30_000,
});
return (
<DashboardSection
title="Ripley"
tone="warning"
action={{ label: "Manage keys", href: "/settings/bot-api-keys" }}
infoText="Current project reported by the OpenClaw agent"
>
{report ? <ReportContent report={report} /> : <EmptyState />}
</DashboardSection>
);
}

View File

@ -0,0 +1,76 @@
"use client";
import { customFetch } from "@/api/mutator";
type ApiResponse<T> = {
data: T;
status: number;
headers: Headers;
};
export type BotApiKeyRead = {
id: string;
name: string;
key_last_four: string;
created_at: string;
last_used_at: string | null;
};
export type BotApiKeyCreated = BotApiKeyRead & {
key: string;
};
export type BotReportRead = {
id: string;
key_id: string;
project: string | null;
task: string | null;
status: string | null;
detail: Record<string, unknown> | null;
reported_at: string;
};
export async function listBotKeys(): Promise<BotApiKeyRead[]> {
const response = await customFetch<ApiResponse<BotApiKeyRead[]>>(
"/api/v1/bot/keys",
{ method: "GET" },
);
return response.data;
}
export async function createBotKey(name: string): Promise<BotApiKeyCreated> {
const response = await customFetch<ApiResponse<BotApiKeyCreated>>(
"/api/v1/bot/keys",
{
method: "POST",
body: JSON.stringify({ name }),
},
);
return response.data;
}
export async function deleteBotKey(id: string): Promise<void> {
await customFetch<ApiResponse<void>>(`/api/v1/bot/keys/${id}`, {
method: "DELETE",
});
}
export async function listBotReports(limit = 50): Promise<BotReportRead[]> {
const response = await customFetch<ApiResponse<BotReportRead[]>>(
`/api/v1/bot/reports?limit=${limit}`,
{ method: "GET" },
);
return response.data;
}
export async function getLatestBotReport(): Promise<BotReportRead | null> {
try {
const response = await customFetch<ApiResponse<BotReportRead>>(
"/api/v1/bot/reports/latest",
{ method: "GET" },
);
return response.data;
} catch {
return null;
}
}

89
scripts/report-project.py Normal file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Report the current project/task to the Pipeline dashboard.
Usage:
python report-project.py --project Pipeline --task "fixing bug" --status busy
Required environment variables:
PIPELINE_BOT_KEY API key generated from Settings Bot API keys
PIPELINE_URL Pipeline base URL (default: http://localhost:8001)
Optional environment variables:
PIPELINE_REPORT_TIMEOUT Request timeout in seconds (default: 3)
Exit codes:
0 success or env vars not configured (non-disruptive)
1 HTTP error or unexpected failure
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
def main() -> int:
parser = argparse.ArgumentParser(
description="Report current project/task to Pipeline dashboard."
)
parser.add_argument("--project", default=None, help="Project name, e.g. Pipeline")
parser.add_argument("--task", default=None, help="Current task description")
parser.add_argument(
"--status",
default="busy",
choices=["busy", "idle", "error"],
help="Agent status (default: busy)",
)
parser.add_argument("--detail", default=None, help="Extra JSON detail string")
args = parser.parse_args()
bot_key = os.environ.get("PIPELINE_BOT_KEY", "").strip()
base_url = os.environ.get("PIPELINE_URL", "http://localhost:8001").strip().rstrip("/")
timeout = float(os.environ.get("PIPELINE_REPORT_TIMEOUT", "3"))
if not bot_key:
# Not configured — exit silently so the agent isn't disrupted.
return 0
payload: dict[str, object] = {
"project": args.project,
"task": args.task,
"status": args.status,
}
if args.detail:
try:
payload["detail"] = json.loads(args.detail)
except json.JSONDecodeError:
payload["detail"] = {"raw": args.detail}
body = json.dumps(payload).encode("utf-8")
url = f"{base_url}/api/v1/bot/report"
req = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"X-Bot-Key": bot_key,
"Content-Type": "application/json",
"User-Agent": "PipelineBotReport/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout):
pass
except urllib.error.HTTPError as exc:
print(f"report-project: HTTP {exc.code} from {url}", file=sys.stderr)
return 1
except (urllib.error.URLError, OSError) as exc:
print(f"report-project: connection error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())