Compare commits

...

11 Commits

11 changed files with 424 additions and 66 deletions

View File

@@ -10,17 +10,20 @@ BE-CAL-API-006: Plan edit / plan cancel endpoints.
BE-CAL-API-007: Date-list endpoint. 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 typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.core.config import get_db 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.models import User
from app.models.agent import Agent, AgentStatus, ExhaustReason
from app.schemas.calendar import ( from app.schemas.calendar import (
AgentHeartbeatResponse,
AgentStatusUpdateRequest,
CalendarDayResponse, CalendarDayResponse,
CalendarSlotItem, CalendarSlotItem,
DateListResponse, DateListResponse,
@@ -32,7 +35,9 @@ from app.schemas.calendar import (
SchedulePlanEdit, SchedulePlanEdit,
SchedulePlanListResponse, SchedulePlanListResponse,
SchedulePlanResponse, SchedulePlanResponse,
SlotStatusEnum,
SlotConflictItem, SlotConflictItem,
SlotAgentUpdate,
TimeSlotCancelResponse, TimeSlotCancelResponse,
TimeSlotCreate, TimeSlotCreate,
TimeSlotCreateResponse, TimeSlotCreateResponse,
@@ -40,6 +45,15 @@ from app.schemas.calendar import (
TimeSlotEditResponse, TimeSlotEditResponse,
TimeSlotResponse, 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 ( from app.services.minimum_workload import (
get_workload_config, get_workload_config,
get_workload_warnings_for_date, get_workload_warnings_for_date,
@@ -62,10 +76,52 @@ from app.services.slot_immutability import (
guard_plan_cancel_no_past_retroaction, guard_plan_cancel_no_past_retroaction,
guard_plan_edit_no_past_retroaction, guard_plan_edit_no_past_retroaction,
) )
from app.models.role_permission import Permission, RolePermission
router = APIRouter(prefix="/calendar", tags=["Calendar"]) 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) # TimeSlot creation (BE-CAL-API-001)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -101,7 +157,7 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
def create_slot( def create_slot(
payload: TimeSlotCreate, payload: TimeSlotCreate,
db: Session = Depends(get_db), 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. """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( @router.get(
"/day", "/day",
response_model=CalendarDayResponse, response_model=CalendarDayResponse,
@@ -230,7 +443,7 @@ def _virtual_slot_to_item(vs: dict) -> CalendarSlotItem:
def get_calendar_day( def get_calendar_day(
date: Optional[date_type] = Query(None, description="Target date (defaults to today)"), date: Optional[date_type] = Query(None, description="Target date (defaults to today)"),
db: Session = Depends(get_db), 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. """Return all calendar slots for the authenticated user on the given date.
@@ -301,7 +514,7 @@ def edit_real_slot(
slot_id: int, slot_id: int,
payload: TimeSlotEdit, payload: TimeSlotEdit,
db: Session = Depends(get_db), 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. """Edit an existing real (materialized) slot.
@@ -380,7 +593,7 @@ def edit_virtual_slot(
virtual_id: str, virtual_id: str,
payload: TimeSlotEdit, payload: TimeSlotEdit,
db: Session = Depends(get_db), 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. """Edit a virtual (plan-generated) slot.
@@ -469,7 +682,7 @@ def edit_virtual_slot(
def cancel_real_slot( def cancel_real_slot(
slot_id: int, slot_id: int,
db: Session = Depends(get_db), 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. """Cancel an existing real (materialized) slot.
@@ -516,7 +729,7 @@ def cancel_real_slot(
def cancel_virtual_slot( def cancel_virtual_slot(
virtual_id: str, virtual_id: str,
db: Session = Depends(get_db), 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. """Cancel a virtual (plan-generated) slot.
@@ -596,7 +809,7 @@ def _plan_to_response(plan: SchedulePlan) -> SchedulePlanResponse:
def create_plan( def create_plan(
payload: SchedulePlanCreate, payload: SchedulePlanCreate,
db: Session = Depends(get_db), 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. """Create a new recurring schedule plan.
@@ -632,7 +845,7 @@ def create_plan(
def list_plans( def list_plans(
include_inactive: bool = Query(False, description="Include cancelled/inactive plans"), include_inactive: bool = Query(False, description="Include cancelled/inactive plans"),
db: Session = Depends(get_db), 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. """Return all schedule plans for the authenticated user.
@@ -658,7 +871,7 @@ def list_plans(
def get_plan( def get_plan(
plan_id: int, plan_id: int,
db: Session = Depends(get_db), 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.""" """Return a single schedule plan owned by the authenticated user."""
plan = ( plan = (
@@ -705,7 +918,7 @@ def edit_plan(
plan_id: int, plan_id: int,
payload: SchedulePlanEdit, payload: SchedulePlanEdit,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_calendar_write),
): ):
"""Edit an existing schedule plan. """Edit an existing schedule plan.
@@ -792,7 +1005,7 @@ def edit_plan(
def cancel_plan( def cancel_plan(
plan_id: int, plan_id: int,
db: Session = Depends(get_db), 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. """Cancel (soft-delete) a schedule plan.
@@ -859,7 +1072,7 @@ _DATE_LIST_EXCLUDED_STATUSES = {SlotStatus.SKIPPED.value, SlotStatus.ABORTED.val
) )
def list_dates( def list_dates(
db: Session = Depends(get_db), 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 """Return a sorted list of future dates that have at least one
materialized (real) slot. materialized (real) slot.
@@ -897,7 +1110,7 @@ def list_dates(
) )
def get_my_workload_config( def get_my_workload_config(
db: Session = Depends(get_db), 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. """Return the workload thresholds for the authenticated user.
@@ -916,7 +1129,7 @@ def get_my_workload_config(
def put_my_workload_config( def put_my_workload_config(
payload: MinimumWorkloadConfig, payload: MinimumWorkloadConfig,
db: Session = Depends(get_db), 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.""" """Full replacement of the workload configuration."""
row = replace_workload_config(db, current_user.id, payload) row = replace_workload_config(db, current_user.id, payload)
@@ -933,7 +1146,7 @@ def put_my_workload_config(
def patch_my_workload_config( def patch_my_workload_config(
payload: MinimumWorkloadUpdate, payload: MinimumWorkloadUpdate,
db: Session = Depends(get_db), 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.""" """Partial update — only the provided periods are overwritten."""
row = upsert_workload_config(db, current_user.id, payload) row = upsert_workload_config(db, current_user.id, payload)

View File

@@ -22,6 +22,7 @@ from app.services.monitoring import (
get_server_states_view, get_server_states_view,
test_provider_connection, test_provider_connection,
) )
from app.services.discord_wakeup import create_private_wakeup_channel
router = APIRouter(prefix='/monitor', tags=['Monitor']) router = APIRouter(prefix='/monitor', tags=['Monitor'])
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'} SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
@@ -42,6 +43,12 @@ class MonitoredServerCreate(BaseModel):
display_name: str | None = None 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)): def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
if not current_user.is_admin: if not current_user.is_admin:
raise HTTPException(status_code=403, detail='Admin required') 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 return None
class ServerHeartbeat(BaseModel): @router.post('/admin/discord-wakeup/test')
identifier: str def discord_wakeup_test(payload: DiscordWakeupTestRequest, _: models.User = Depends(require_admin)):
openclaw_version: str | None = None return create_private_wakeup_channel(payload.discord_user_id, payload.title, payload.message)
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('/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): class TelemetryPayload(BaseModel):
identifier: str identifier: str
openclaw_version: str | None = None openclaw_version: str | None = None
@@ -227,13 +202,13 @@ class TelemetryPayload(BaseModel):
uptime_seconds: int | None = None uptime_seconds: int | None = None
@router.post('/server/heartbeat-v2') @router.post('/server/heartbeat')
def server_heartbeat_v2( def server_heartbeat(
payload: TelemetryPayload, payload: TelemetryPayload,
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'), x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
db: Session = Depends(get_db) 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( server = db.query(MonitoredServer).filter(
MonitoredServer.api_key == x_api_key, MonitoredServer.api_key == x_api_key,
MonitoredServer.is_enabled == True MonitoredServer.is_enabled == True
@@ -256,4 +231,3 @@ def server_heartbeat_v2(
st.last_seen_at = datetime.now(timezone.utc) st.last_seen_at = datetime.now(timezone.utc)
db.commit() db.commit()
return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at} return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at}

View File

@@ -30,6 +30,7 @@ def _user_response(user: models.User) -> dict:
"role_id": user.role_id, "role_id": user.role_id,
"role_name": user.role_name, "role_name": user.role_name,
"agent_id": user.agent.agent_id if user.agent else None, "agent_id": user.agent.agent_id if user.agent else None,
"discord_user_id": user.discord_user_id,
"created_at": user.created_at, "created_at": user.created_at,
} }
return data return data
@@ -114,6 +115,7 @@ def create_user(
username=user.username, username=user.username,
email=user.email, email=user.email,
full_name=user.full_name, full_name=user.full_name,
discord_user_id=user.discord_user_id,
hashed_password=hashed_password, hashed_password=hashed_password,
is_admin=False, is_admin=False,
is_active=True, is_active=True,
@@ -202,6 +204,9 @@ def update_user(
raise HTTPException(status_code=400, detail="You cannot deactivate your own account") raise HTTPException(status_code=400, detail="You cannot deactivate your own account")
user.is_active = payload.is_active 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.commit()
db.refresh(user) db.refresh(user)
return _user_response(user) return _user_response(user)

View File

@@ -132,6 +132,10 @@ DEFAULT_PERMISSIONS = [
# Monitor # Monitor
("monitor.read", "View monitor", "monitor"), ("monitor.read", "View monitor", "monitor"),
("monitor.manage", "Manage 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
("webhook.manage", "Manage webhooks", "admin"), ("webhook.manage", "Manage webhooks", "admin"),
] ]
@@ -168,6 +172,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen", "propose.accept", "propose.reject", "propose.reopen",
"monitor.read", "monitor.read",
"calendar.read", "calendar.write", "calendar.manage",
"user.reset-self-apikey", "user.reset-self-apikey",
} }
@@ -178,6 +183,7 @@ _DEV_PERMISSIONS = {
"milestone.read", "milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read", "monitor.read",
"calendar.read", "calendar.write",
"user.reset-self-apikey", "user.reset-self-apikey",
} }

View File

@@ -42,6 +42,7 @@ def config_status():
return { return {
"initialized": cfg.get("initialized", False), "initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"), "backend_url": cfg.get("backend_url"),
"discord": cfg.get("discord") or {},
} }
except Exception: except Exception:
return {"initialized": False} return {"initialized": False}
@@ -96,6 +97,25 @@ def _migrate_schema():
{"column_name": column_name}, {"column_name": column_name},
).fetchone() is not None ).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): def _drop_fk_constraints(db, table_name: str, referenced_table: str):
rows = db.execute(text( rows = db.execute(text(
""" """
@@ -139,9 +159,7 @@ def _migrate_schema():
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")) result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
if not result.fetchone(): if not result.fetchone():
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL")) 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)")) _ensure_unique_index(db, "projects", "idx_projects_project_code", "project_code")
else:
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_project_code ON projects (project_code)"))
# projects.owner_name # projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE '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")) 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") _ensure_fk(db, "tasks", "created_by_id", "users", "id", "fk_tasks_created_by_id")
if _has_column(db, "tasks", "task_code"): 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 # milestones creator field
result = db.execute(text("SHOW COLUMNS FROM milestones LIKE 'created_by_id'")) 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) --- # --- Milestone status enum migration (old -> new) ---
if _has_table(db, "milestones"): if _has_table(db, "milestones"):
if _has_column(db, "milestones", "milestone_code"): 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 # Alter enum column to accept new values
db.execute(text( db.execute(text(
"ALTER TABLE milestones MODIFY COLUMN status " "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")) db.execute(text("ALTER TABLE users ADD COLUMN role_id INTEGER NULL"))
_ensure_fk(db, "users", "role_id", "roles", "id", "fk_users_role_id") _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 --- # --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"): 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")) 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")) 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"): 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"): 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"): 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"): 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 --- # --- server_states nginx telemetry for generic monitor client ---
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_installed"): 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 ) 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() db.commit()
except Exception as e: except Exception as e:
db.rollback() db.rollback()

View File

@@ -165,6 +165,12 @@ class TimeSlot(Base):
comment="Lifecycle status of this slot", 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( plan_id = Column(
Integer, Integer,
ForeignKey("schedule_plans.id"), ForeignKey("schedule_plans.id"),

View File

@@ -72,6 +72,7 @@ class User(Base):
email = Column(String(100), unique=True, nullable=False) email = Column(String(100), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=True) hashed_password = Column(String(255), nullable=True)
full_name = Column(String(100), nullable=True) full_name = Column(String(100), nullable=True)
discord_user_id = Column(String(32), nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True) role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)

View File

@@ -407,3 +407,30 @@ class DateListResponse(BaseModel):
default_factory=list, default_factory=list,
description="Sorted list of future dates with materialized slots", 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

View File

@@ -171,6 +171,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: Optional[str] = None password: Optional[str] = None
role_id: Optional[int] = None role_id: Optional[int] = None
discord_user_id: Optional[str] = None
# Agent binding (both must be provided or both omitted) # Agent binding (both must be provided or both omitted)
agent_id: Optional[str] = None agent_id: Optional[str] = None
claw_identifier: Optional[str] = None claw_identifier: Optional[str] = None
@@ -182,6 +183,7 @@ class UserUpdate(BaseModel):
password: Optional[str] = None password: Optional[str] = None
role_id: Optional[int] = None role_id: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
discord_user_id: Optional[str] = None
class UserResponse(UserBase): class UserResponse(UserBase):
@@ -191,6 +193,7 @@ class UserResponse(UserBase):
role_id: Optional[int] = None role_id: Optional[int] = None
role_name: Optional[str] = None role_name: Optional[str] = None
agent_id: Optional[str] = None agent_id: Optional[str] = None
discord_user_id: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:

View 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"),
}

View 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"),
}