Compare commits
11 Commits
ae353afbed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2ab541b73 | |||
| 755c418391 | |||
| 57681c674f | |||
| 79c6c32a78 | |||
| 5e98d1c8f2 | |||
| 5a2b64df70 | |||
| 578493edc1 | |||
| 41bebc862b | |||
| e9529e3cb0 | |||
| 848f5d7596 | |||
| 0448cde765 |
@@ -10,17 +10,20 @@ BE-CAL-API-006: Plan edit / plan cancel endpoints.
|
||||
BE-CAL-API-007: Date-list endpoint.
|
||||
"""
|
||||
|
||||
from datetime import date as date_type
|
||||
from datetime import date as date_type, datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import get_db
|
||||
from app.models.calendar import SchedulePlan, SlotStatus, TimeSlot
|
||||
from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
|
||||
from app.models.models import User
|
||||
from app.models.agent import Agent, AgentStatus, ExhaustReason
|
||||
from app.schemas.calendar import (
|
||||
AgentHeartbeatResponse,
|
||||
AgentStatusUpdateRequest,
|
||||
CalendarDayResponse,
|
||||
CalendarSlotItem,
|
||||
DateListResponse,
|
||||
@@ -32,7 +35,9 @@ from app.schemas.calendar import (
|
||||
SchedulePlanEdit,
|
||||
SchedulePlanListResponse,
|
||||
SchedulePlanResponse,
|
||||
SlotStatusEnum,
|
||||
SlotConflictItem,
|
||||
SlotAgentUpdate,
|
||||
TimeSlotCancelResponse,
|
||||
TimeSlotCreate,
|
||||
TimeSlotCreateResponse,
|
||||
@@ -40,6 +45,15 @@ from app.schemas.calendar import (
|
||||
TimeSlotEditResponse,
|
||||
TimeSlotResponse,
|
||||
)
|
||||
from app.services.agent_heartbeat import get_pending_slots_for_agent
|
||||
from app.services.agent_status import (
|
||||
record_heartbeat,
|
||||
transition_to_busy,
|
||||
transition_to_idle,
|
||||
transition_to_offline,
|
||||
transition_to_exhausted,
|
||||
)
|
||||
from app.services.discord_wakeup import create_private_wakeup_channel
|
||||
from app.services.minimum_workload import (
|
||||
get_workload_config,
|
||||
get_workload_warnings_for_date,
|
||||
@@ -62,10 +76,52 @@ from app.services.slot_immutability import (
|
||||
guard_plan_cancel_no_past_retroaction,
|
||||
guard_plan_edit_no_past_retroaction,
|
||||
)
|
||||
from app.models.role_permission import Permission, RolePermission
|
||||
|
||||
router = APIRouter(prefix="/calendar", tags=["Calendar"])
|
||||
|
||||
|
||||
def _has_global_permission(db: Session, user: User, permission_name: str) -> bool:
|
||||
if user.is_admin:
|
||||
return True
|
||||
if not user.role_id:
|
||||
return False
|
||||
perm = db.query(Permission).filter(Permission.name == permission_name).first()
|
||||
if not perm:
|
||||
return False
|
||||
return db.query(RolePermission).filter(
|
||||
RolePermission.role_id == user.role_id,
|
||||
RolePermission.permission_id == perm.id,
|
||||
).first() is not None
|
||||
|
||||
|
||||
def _require_calendar_permission(db: Session, user: User, permission_name: str) -> User:
|
||||
if _has_global_permission(db, user, permission_name):
|
||||
return user
|
||||
raise HTTPException(status_code=403, detail=f"Calendar permission '{permission_name}' required")
|
||||
|
||||
|
||||
def require_calendar_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _require_calendar_permission(db, current_user, "calendar.read")
|
||||
|
||||
|
||||
def require_calendar_write(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _require_calendar_permission(db, current_user, "calendar.write")
|
||||
|
||||
|
||||
def require_calendar_manage(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _require_calendar_permission(db, current_user, "calendar.manage")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TimeSlot creation (BE-CAL-API-001)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -101,7 +157,7 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
|
||||
def create_slot(
|
||||
payload: TimeSlotCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Create a one-off calendar slot.
|
||||
|
||||
@@ -222,6 +278,163 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
|
||||
)
|
||||
|
||||
|
||||
def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
|
||||
agent = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.agent_id == agent_id, Agent.claw_identifier == claw_identifier)
|
||||
.first()
|
||||
)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return agent
|
||||
|
||||
|
||||
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
|
||||
slot.status = payload.status.value
|
||||
if payload.started_at is not None:
|
||||
slot.started_at = payload.started_at
|
||||
slot.attended = True
|
||||
if payload.actual_duration is not None:
|
||||
slot.actual_duration = payload.actual_duration
|
||||
if payload.status == SlotStatusEnum.ONGOING:
|
||||
slot.attended = True
|
||||
|
||||
|
||||
def _maybe_trigger_discord_wakeup(db: Session, slot: TimeSlot) -> dict | None:
|
||||
"""Trigger Discord wakeup if slot became ONGOING and not already sent."""
|
||||
# Only trigger for ONGOING status and if not already sent
|
||||
if slot.status != SlotStatus.ONGOING or slot.wakeup_sent_at is not None:
|
||||
return None
|
||||
|
||||
# Get user and check for discord_user_id
|
||||
user = db.query(User).filter(User.id == slot.user_id).first()
|
||||
if not user or not user.discord_user_id:
|
||||
return None
|
||||
|
||||
# Get agent for this user
|
||||
agent = db.query(Agent).filter(Agent.user_id == user.id).first()
|
||||
agent_id_str = agent.agent_id if agent else "unknown"
|
||||
|
||||
# Build wakeup message
|
||||
title = f"HarborForge Slot: {slot.event_type.value if slot.event_type else 'work'}"
|
||||
message = (
|
||||
f"🎯 **Slot started**\n"
|
||||
f"Agent: `{agent_id_str}`\n"
|
||||
f"Type: {slot.slot_type.value}\n"
|
||||
f"Duration: {slot.estimated_duration}min\n"
|
||||
f"Priority: {slot.priority}\n"
|
||||
f"Use `hf calendar slot {slot.id}` for details."
|
||||
)
|
||||
|
||||
try:
|
||||
result = create_private_wakeup_channel(
|
||||
discord_user_id=user.discord_user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
)
|
||||
slot.wakeup_sent_at = datetime.now(timezone.utc)
|
||||
return {"ok": True, "channel_id": result.get("channel_id")}
|
||||
except Exception as e:
|
||||
# Log but don't fail the slot update
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/agent/heartbeat",
|
||||
methods=["GET", "POST"],
|
||||
response_model=AgentHeartbeatResponse,
|
||||
summary="Get all due slots for the calling agent",
|
||||
)
|
||||
def agent_heartbeat(
|
||||
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, x_agent_id, x_claw_identifier)
|
||||
record_heartbeat(db, agent)
|
||||
slots = get_pending_slots_for_agent(db, agent.user_id, now=datetime.now(timezone.utc))
|
||||
db.commit()
|
||||
return AgentHeartbeatResponse(
|
||||
slots=[_real_slot_to_item(slot) for slot in slots],
|
||||
agent_status=agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
|
||||
message=f"{len(slots)} due slot(s)",
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/slots/{slot_id}/agent-update",
|
||||
response_model=TimeSlotEditResponse,
|
||||
summary="Agent updates a real slot status",
|
||||
)
|
||||
def agent_update_real_slot(
|
||||
slot_id: int,
|
||||
payload: SlotAgentUpdate,
|
||||
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, x_agent_id, x_claw_identifier)
|
||||
slot = db.query(TimeSlot).filter(TimeSlot.id == slot_id, TimeSlot.user_id == agent.user_id).first()
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
_apply_agent_slot_update(slot, payload)
|
||||
_maybe_trigger_discord_wakeup(db, slot)
|
||||
db.commit()
|
||||
db.refresh(slot)
|
||||
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/slots/virtual/{virtual_id}/agent-update",
|
||||
response_model=TimeSlotEditResponse,
|
||||
summary="Agent materializes and updates a virtual slot status",
|
||||
)
|
||||
def agent_update_virtual_slot(
|
||||
virtual_id: str,
|
||||
payload: SlotAgentUpdate,
|
||||
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, x_agent_id, x_claw_identifier)
|
||||
slot = materialize_from_virtual_id(db, virtual_id)
|
||||
if slot.user_id != agent.user_id:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
_apply_agent_slot_update(slot, payload)
|
||||
_maybe_trigger_discord_wakeup(db, slot)
|
||||
db.commit()
|
||||
db.refresh(slot)
|
||||
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/status",
|
||||
summary="Update agent runtime status from plugin",
|
||||
)
|
||||
def update_agent_status(
|
||||
payload: AgentStatusUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
|
||||
target = (payload.status or '').lower().strip()
|
||||
if target == AgentStatus.IDLE.value:
|
||||
transition_to_idle(db, agent)
|
||||
elif target == AgentStatus.BUSY.value:
|
||||
transition_to_busy(db, agent, slot_type=SlotType.WORK)
|
||||
elif target == AgentStatus.ON_CALL.value:
|
||||
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
|
||||
elif target == AgentStatus.OFFLINE.value:
|
||||
transition_to_offline(db, agent)
|
||||
elif target == AgentStatus.EXHAUSTED.value:
|
||||
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
|
||||
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported agent status")
|
||||
db.commit()
|
||||
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/day",
|
||||
response_model=CalendarDayResponse,
|
||||
@@ -230,7 +443,7 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
|
||||
def get_calendar_day(
|
||||
date: Optional[date_type] = Query(None, description="Target date (defaults to today)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return all calendar slots for the authenticated user on the given date.
|
||||
|
||||
@@ -301,7 +514,7 @@ def edit_real_slot(
|
||||
slot_id: int,
|
||||
payload: TimeSlotEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Edit an existing real (materialized) slot.
|
||||
|
||||
@@ -380,7 +593,7 @@ def edit_virtual_slot(
|
||||
virtual_id: str,
|
||||
payload: TimeSlotEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Edit a virtual (plan-generated) slot.
|
||||
|
||||
@@ -469,7 +682,7 @@ def edit_virtual_slot(
|
||||
def cancel_real_slot(
|
||||
slot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Cancel an existing real (materialized) slot.
|
||||
|
||||
@@ -516,7 +729,7 @@ def cancel_real_slot(
|
||||
def cancel_virtual_slot(
|
||||
virtual_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Cancel a virtual (plan-generated) slot.
|
||||
|
||||
@@ -596,7 +809,7 @@ def _plan_to_response(plan: SchedulePlan) -> SchedulePlanResponse:
|
||||
def create_plan(
|
||||
payload: SchedulePlanCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Create a new recurring schedule plan.
|
||||
|
||||
@@ -632,7 +845,7 @@ def create_plan(
|
||||
def list_plans(
|
||||
include_inactive: bool = Query(False, description="Include cancelled/inactive plans"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return all schedule plans for the authenticated user.
|
||||
|
||||
@@ -658,7 +871,7 @@ def list_plans(
|
||||
def get_plan(
|
||||
plan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return a single schedule plan owned by the authenticated user."""
|
||||
plan = (
|
||||
@@ -705,7 +918,7 @@ def edit_plan(
|
||||
plan_id: int,
|
||||
payload: SchedulePlanEdit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Edit an existing schedule plan.
|
||||
|
||||
@@ -792,7 +1005,7 @@ def edit_plan(
|
||||
def cancel_plan(
|
||||
plan_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_write),
|
||||
):
|
||||
"""Cancel (soft-delete) a schedule plan.
|
||||
|
||||
@@ -859,7 +1072,7 @@ _DATE_LIST_EXCLUDED_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.val
|
||||
)
|
||||
def list_dates(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_read),
|
||||
):
|
||||
"""Return a sorted list of future dates that have at least one
|
||||
materialized (real) slot.
|
||||
@@ -897,7 +1110,7 @@ def list_dates(
|
||||
)
|
||||
def get_my_workload_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_manage),
|
||||
):
|
||||
"""Return the workload thresholds for the authenticated user.
|
||||
|
||||
@@ -916,7 +1129,7 @@ def get_my_workload_config(
|
||||
def put_my_workload_config(
|
||||
payload: MinimumWorkloadConfig,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_manage),
|
||||
):
|
||||
"""Full replacement of the workload configuration."""
|
||||
row = replace_workload_config(db, current_user.id, payload)
|
||||
@@ -933,7 +1146,7 @@ def put_my_workload_config(
|
||||
def patch_my_workload_config(
|
||||
payload: MinimumWorkloadUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_calendar_manage),
|
||||
):
|
||||
"""Partial update — only the provided periods are overwritten."""
|
||||
row = upsert_workload_config(db, current_user.id, payload)
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.services.monitoring import (
|
||||
get_server_states_view,
|
||||
test_provider_connection,
|
||||
)
|
||||
from app.services.discord_wakeup import create_private_wakeup_channel
|
||||
router = APIRouter(prefix='/monitor', tags=['Monitor'])
|
||||
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
|
||||
|
||||
@@ -42,6 +43,12 @@ class MonitoredServerCreate(BaseModel):
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class DiscordWakeupTestRequest(BaseModel):
|
||||
discord_user_id: str
|
||||
title: str = "HarborForge Wakeup"
|
||||
message: str = "A HarborForge slot is ready to start."
|
||||
|
||||
|
||||
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail='Admin required')
|
||||
@@ -175,43 +182,11 @@ def revoke_api_key(server_id: int, db: Session = Depends(get_db), _: models.User
|
||||
return None
|
||||
|
||||
|
||||
class ServerHeartbeat(BaseModel):
|
||||
identifier: str
|
||||
openclaw_version: str | None = None
|
||||
plugin_version: str | None = None
|
||||
agents: List[dict] = []
|
||||
nginx_installed: bool | None = None
|
||||
nginx_sites: List[str] = []
|
||||
cpu_pct: float | None = None
|
||||
mem_pct: float | None = None
|
||||
disk_pct: float | None = None
|
||||
swap_pct: float | None = None
|
||||
@router.post('/admin/discord-wakeup/test')
|
||||
def discord_wakeup_test(payload: DiscordWakeupTestRequest, _: models.User = Depends(require_admin)):
|
||||
return create_private_wakeup_channel(payload.discord_user_id, payload.title, payload.message)
|
||||
|
||||
|
||||
@router.post('/server/heartbeat')
|
||||
def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)):
|
||||
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == payload.identifier, MonitoredServer.is_enabled == True).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail='unknown server identifier')
|
||||
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
|
||||
if not st:
|
||||
st = ServerState(server_id=server.id)
|
||||
db.add(st)
|
||||
st.openclaw_version = payload.openclaw_version
|
||||
st.plugin_version = payload.plugin_version
|
||||
st.agents_json = json.dumps(payload.agents, ensure_ascii=False)
|
||||
st.nginx_installed = payload.nginx_installed
|
||||
st.nginx_sites_json = json.dumps(payload.nginx_sites, ensure_ascii=False)
|
||||
st.cpu_pct = payload.cpu_pct
|
||||
st.mem_pct = payload.mem_pct
|
||||
st.disk_pct = payload.disk_pct
|
||||
st.swap_pct = payload.swap_pct
|
||||
st.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}
|
||||
|
||||
|
||||
# Heartbeat v2 with API Key authentication
|
||||
class TelemetryPayload(BaseModel):
|
||||
identifier: str
|
||||
openclaw_version: str | None = None
|
||||
@@ -227,13 +202,13 @@ class TelemetryPayload(BaseModel):
|
||||
uptime_seconds: int | None = None
|
||||
|
||||
|
||||
@router.post('/server/heartbeat-v2')
|
||||
def server_heartbeat_v2(
|
||||
@router.post('/server/heartbeat')
|
||||
def server_heartbeat(
|
||||
payload: TelemetryPayload,
|
||||
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Server heartbeat using API Key authentication (no challenge_uuid required)"""
|
||||
"""Server heartbeat using API Key authentication."""
|
||||
server = db.query(MonitoredServer).filter(
|
||||
MonitoredServer.api_key == x_api_key,
|
||||
MonitoredServer.is_enabled == True
|
||||
@@ -256,4 +231,3 @@ def server_heartbeat_v2(
|
||||
st.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ def _user_response(user: models.User) -> dict:
|
||||
"role_id": user.role_id,
|
||||
"role_name": user.role_name,
|
||||
"agent_id": user.agent.agent_id if user.agent else None,
|
||||
"discord_user_id": user.discord_user_id,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
return data
|
||||
@@ -114,6 +115,7 @@ def create_user(
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
discord_user_id=user.discord_user_id,
|
||||
hashed_password=hashed_password,
|
||||
is_admin=False,
|
||||
is_active=True,
|
||||
@@ -202,6 +204,9 @@ def update_user(
|
||||
raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
|
||||
user.is_active = payload.is_active
|
||||
|
||||
if payload.discord_user_id is not None:
|
||||
user.discord_user_id = payload.discord_user_id or None
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return _user_response(user)
|
||||
|
||||
@@ -132,6 +132,10 @@ DEFAULT_PERMISSIONS = [
|
||||
# Monitor
|
||||
("monitor.read", "View monitor", "monitor"),
|
||||
("monitor.manage", "Manage monitor", "monitor"),
|
||||
# Calendar
|
||||
("calendar.read", "View calendar slots and plans", "calendar"),
|
||||
("calendar.write", "Create and edit calendar slots and plans", "calendar"),
|
||||
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
|
||||
# Webhook
|
||||
("webhook.manage", "Manage webhooks", "admin"),
|
||||
]
|
||||
@@ -168,6 +172,7 @@ _MGR_PERMISSIONS = {
|
||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||
"propose.accept", "propose.reject", "propose.reopen",
|
||||
"monitor.read",
|
||||
"calendar.read", "calendar.write", "calendar.manage",
|
||||
"user.reset-self-apikey",
|
||||
}
|
||||
|
||||
@@ -178,6 +183,7 @@ _DEV_PERMISSIONS = {
|
||||
"milestone.read",
|
||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||
"monitor.read",
|
||||
"calendar.read", "calendar.write",
|
||||
"user.reset-self-apikey",
|
||||
}
|
||||
|
||||
|
||||
43
app/main.py
43
app/main.py
@@ -42,6 +42,7 @@ def config_status():
|
||||
return {
|
||||
"initialized": cfg.get("initialized", False),
|
||||
"backend_url": cfg.get("backend_url"),
|
||||
"discord": cfg.get("discord") or {},
|
||||
}
|
||||
except Exception:
|
||||
return {"initialized": False}
|
||||
@@ -96,6 +97,25 @@ def _migrate_schema():
|
||||
{"column_name": column_name},
|
||||
).fetchone() is not None
|
||||
|
||||
def _has_index(db, table_name: str, index_name: str) -> bool:
|
||||
return db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table_name
|
||||
AND INDEX_NAME = :index_name
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "index_name": index_name},
|
||||
).fetchone() is not None
|
||||
|
||||
def _ensure_unique_index(db, table_name: str, index_name: str, columns_sql: str):
|
||||
if not _has_index(db, table_name, index_name):
|
||||
db.execute(text(f"CREATE UNIQUE INDEX {index_name} ON {table_name} ({columns_sql})"))
|
||||
|
||||
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
|
||||
rows = db.execute(text(
|
||||
"""
|
||||
@@ -139,9 +159,7 @@ def _migrate_schema():
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
|
||||
if not result.fetchone():
|
||||
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
||||
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
|
||||
else:
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_project_code ON projects (project_code)"))
|
||||
_ensure_unique_index(db, "projects", "idx_projects_project_code", "project_code")
|
||||
|
||||
# projects.owner_name
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
|
||||
@@ -176,7 +194,7 @@ def _migrate_schema():
|
||||
db.execute(text("ALTER TABLE tasks ADD COLUMN created_by_id INTEGER NULL"))
|
||||
_ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
|
||||
if _has_column(db, "tasks", "task_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_task_code ON tasks (task_code)"))
|
||||
_ensure_unique_index(db, "tasks", "idx_tasks_task_code", "task_code")
|
||||
|
||||
# milestones creator field
|
||||
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'"))
|
||||
@@ -207,7 +225,7 @@ def _migrate_schema():
|
||||
# --- Milestone status enum migration (old -> new) ---
|
||||
if _has_table(db, "milestones"):
|
||||
if _has_column(db, "milestones", "milestone_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_milestones_milestone_code ON milestones (milestone_code)"))
|
||||
_ensure_unique_index(db, "milestones", "idx_milestones_milestone_code", "milestone_code")
|
||||
# Alter enum column to accept new values
|
||||
db.execute(text(
|
||||
"ALTER TABLE milestones MODIFY COLUMN status "
|
||||
@@ -254,6 +272,9 @@ def _migrate_schema():
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN role_id INTEGER NULL"))
|
||||
_ensure_fk(db, "users", "role_id", "roles", "id", "fk_users_role_id")
|
||||
|
||||
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
|
||||
|
||||
# --- monitored_servers.api_key for heartbeat v2 ---
|
||||
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
|
||||
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
|
||||
@@ -264,16 +285,16 @@ def _migrate_schema():
|
||||
db.execute(text("ALTER TABLE server_states ADD COLUMN plugin_version VARCHAR(64) NULL"))
|
||||
|
||||
if _has_table(db, "meetings") and _has_column(db, "meetings", "meeting_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_meetings_meeting_code ON meetings (meeting_code)"))
|
||||
_ensure_unique_index(db, "meetings", "idx_meetings_meeting_code", "meeting_code")
|
||||
|
||||
if _has_table(db, "supports") and _has_column(db, "supports", "support_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_supports_support_code ON supports (support_code)"))
|
||||
_ensure_unique_index(db, "supports", "idx_supports_support_code", "support_code")
|
||||
|
||||
if _has_table(db, "proposes") and _has_column(db, "proposes", "propose_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_proposes_propose_code ON proposes (propose_code)"))
|
||||
_ensure_unique_index(db, "proposes", "idx_proposes_propose_code", "propose_code")
|
||||
|
||||
if _has_table(db, "essentials") and _has_column(db, "essentials", "essential_code"):
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_essentials_essential_code ON essentials (essential_code)"))
|
||||
_ensure_unique_index(db, "essentials", "idx_essentials_essential_code", "essential_code")
|
||||
|
||||
# --- server_states nginx telemetry for generic monitor client ---
|
||||
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"):
|
||||
@@ -338,6 +359,10 @@ def _migrate_schema():
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""))
|
||||
|
||||
# --- time_slots: add wakeup_sent_at for Discord wakeup tracking ---
|
||||
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
|
||||
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
|
||||
@@ -165,6 +165,12 @@ class TimeSlot(Base):
|
||||
comment="Lifecycle status of this slot",
|
||||
)
|
||||
|
||||
wakeup_sent_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="When Discord wakeup was sent for this slot",
|
||||
)
|
||||
|
||||
plan_id = Column(
|
||||
Integer,
|
||||
ForeignKey("schedule_plans.id"),
|
||||
|
||||
@@ -72,6 +72,7 @@ class User(Base):
|
||||
email = Column(String(100), unique=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=True)
|
||||
full_name = Column(String(100), nullable=True)
|
||||
discord_user_id = Column(String(32), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
||||
|
||||
@@ -407,3 +407,30 @@ class DateListResponse(BaseModel):
|
||||
default_factory=list,
|
||||
description="Sorted list of future dates with materialized slots",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent heartbeat / agent-driven slot updates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentHeartbeatResponse(BaseModel):
|
||||
"""Slots that are due for a specific agent plus its current runtime status."""
|
||||
slots: list[CalendarSlotItem] = Field(default_factory=list)
|
||||
agent_status: str
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class SlotAgentUpdate(BaseModel):
|
||||
"""Plugin-driven slot status update payload."""
|
||||
status: SlotStatusEnum
|
||||
started_at: Optional[dt_time] = None
|
||||
actual_duration: Optional[int] = Field(None, ge=0, le=65535)
|
||||
|
||||
|
||||
class AgentStatusUpdateRequest(BaseModel):
|
||||
"""Plugin-driven agent status report."""
|
||||
agent_id: str
|
||||
claw_identifier: str
|
||||
status: str
|
||||
recovery_at: Optional[dt_datetime] = None
|
||||
exhaust_reason: Optional[str] = None
|
||||
|
||||
@@ -171,6 +171,7 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
password: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
discord_user_id: Optional[str] = None
|
||||
# Agent binding (both must be provided or both omitted)
|
||||
agent_id: Optional[str] = None
|
||||
claw_identifier: Optional[str] = None
|
||||
@@ -182,6 +183,7 @@ class UserUpdate(BaseModel):
|
||||
password: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
discord_user_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
@@ -191,6 +193,7 @@ class UserResponse(UserBase):
|
||||
role_id: Optional[int] = None
|
||||
role_name: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
discord_user_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
72
app/services/discord_wakeup.py
Normal file
72
app/services/discord_wakeup.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.harborforge_config import get_discord_wakeup_config
|
||||
|
||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
|
||||
|
||||
|
||||
def _headers(bot_token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bot {bot_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_category(guild_id: str, bot_token: str) -> str | None:
|
||||
resp = requests.get(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), timeout=15)
|
||||
if not resp.ok:
|
||||
raise HTTPException(status_code=502, detail=f"Discord list channels failed: {resp.text}")
|
||||
for ch in resp.json():
|
||||
if ch.get("type") == 4 and ch.get("name") == WAKEUP_CATEGORY_NAME:
|
||||
return ch.get("id")
|
||||
payload = {"name": WAKEUP_CATEGORY_NAME, "type": 4}
|
||||
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
|
||||
if not created.ok:
|
||||
raise HTTPException(status_code=502, detail=f"Discord create category failed: {created.text}")
|
||||
return created.json().get("id")
|
||||
|
||||
|
||||
def create_private_wakeup_channel(discord_user_id: str, title: str, message: str) -> dict[str, Any]:
|
||||
cfg = get_discord_wakeup_config()
|
||||
guild_id = cfg.get("guild_id")
|
||||
bot_token = cfg.get("bot_token")
|
||||
if not guild_id or not bot_token:
|
||||
raise HTTPException(status_code=400, detail="Discord wakeup config is incomplete")
|
||||
|
||||
category_id = _ensure_category(guild_id, bot_token)
|
||||
channel_name = f"wake-{discord_user_id[-6:]}-{int(datetime.now(timezone.utc).timestamp())}"
|
||||
payload = {
|
||||
"name": channel_name,
|
||||
"type": 0,
|
||||
"parent_id": category_id,
|
||||
"permission_overwrites": [
|
||||
{"id": guild_id, "type": 0, "deny": "1024"},
|
||||
{"id": discord_user_id, "type": 1, "allow": "1024"},
|
||||
],
|
||||
"topic": title,
|
||||
}
|
||||
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
|
||||
if not created.ok:
|
||||
raise HTTPException(status_code=502, detail=f"Discord create channel failed: {created.text}")
|
||||
channel = created.json()
|
||||
sent = requests.post(
|
||||
f"{DISCORD_API_BASE}/channels/{channel['id']}/messages",
|
||||
headers=_headers(bot_token),
|
||||
json={"content": message},
|
||||
timeout=15,
|
||||
)
|
||||
if not sent.ok:
|
||||
raise HTTPException(status_code=502, detail=f"Discord send message failed: {sent.text}")
|
||||
return {
|
||||
"guild_id": guild_id,
|
||||
"channel_id": channel.get("id"),
|
||||
"channel_name": channel.get("name"),
|
||||
"message_id": sent.json().get("id"),
|
||||
}
|
||||
26
app/services/harborforge_config.py
Normal file
26
app/services/harborforge_config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
|
||||
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
|
||||
|
||||
|
||||
def load_runtime_config() -> dict[str, Any]:
|
||||
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
|
||||
if not os.path.exists(config_path):
|
||||
return {}
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_discord_wakeup_config() -> dict[str, str | None]:
|
||||
cfg = load_runtime_config()
|
||||
discord_cfg = cfg.get("discord") or {}
|
||||
return {
|
||||
"guild_id": discord_cfg.get("guild_id"),
|
||||
"bot_token": discord_cfg.get("bot_token"),
|
||||
}
|
||||
Reference in New Issue
Block a user