Compare commits

..

20 Commits

Author SHA1 Message Date
c3199d0cd0 fix: Essential model uses created_by_id not user_id
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:17:32 +01:00
d3f72962c0 fix: correct ActivityLog import name in user deletion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:15:45 +01:00
4643a73c60 feat: add deleted-user builtin and safe user deletion
- Add deleted-user as a built-in account (no permissions, cannot log in)
  created during init_wizard, protected from deletion like acc-mgr
- On user delete, reassign all foreign key references to deleted-user
  then delete the original user, instead of failing on IntegrityError
- API keys, notifications, and project memberships are deleted outright
  since they're meaningless without the real user

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:08:19 +01:00
h z
eae947d9b6 Merge pull request 'feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB' (#15) from multi-stage into main
Reviewed-on: #15
2026-04-16 21:23:04 +00:00
h z
a2f626557e Merge branch 'main' into multi-stage 2026-04-16 21:22:54 +00:00
h z
c5827db872 Merge pull request 'dev-2026-03-29' (#14) from dev-2026-03-29 into main
Reviewed-on: #14
2026-04-16 21:22:03 +00:00
7326cadfec feat: grant user.reset-apikey permission to account-manager role
Allows acc-mgr to reset user API keys, enabling automated
provisioning workflows via the CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:19:13 +00:00
1b10c97099 feat: allow API key auth for reset-apikey endpoint
Change dependency from get_current_user (OAuth2 only) to
get_current_user_or_apikey, enabling account-manager API key
to reset user API keys for provisioning workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:17:13 +00:00
8434a5d226 feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB
Stage 1 (builder): install build deps and pre-download wheels
Stage 2 (runtime): copy only installed packages + runtime deps, no build tools
2026-04-15 01:27:44 +00:00
h z
a2ab541b73 Merge pull request 'HarborForge.Backend: dev-2026-03-29 -> main' (#13) from dev-2026-03-29 into main
Reviewed-on: #13
2026-04-05 22:08:14 +00:00
755c418391 feat: auto-trigger Discord wakeup when slot becomes ONGOING 2026-04-05 09:37:14 +00:00
57681c674f feat: add discord wakeup test endpoint 2026-04-04 21:03:48 +00:00
79c6c32a78 feat: store discord user ids on accounts 2026-04-04 20:16:22 +00:00
5e98d1c8f2 feat: accept post heartbeats for calendar agents 2026-04-04 17:58:57 +00:00
5a2b64df70 fix: use model slot types for agent status updates 2026-04-04 16:49:52 +00:00
578493edc1 feat: expose calendar agent heartbeat api 2026-04-04 16:46:04 +00:00
41bebc862b fix: enforce calendar role permissions 2026-04-04 14:35:42 +00:00
e9529e3cb0 feat: add calendar role permissions 2026-04-04 11:59:21 +00:00
848f5d7596 refactor: replace monitor heartbeat-v2 with heartbeat 2026-04-04 08:05:48 +00:00
0448cde765 fix: make code index migration mysql-compatible 2026-04-03 19:00:45 +00:00
12 changed files with 592 additions and 84 deletions

View File

@@ -1,25 +1,46 @@
FROM python:3.11-slim
# Stage 1: build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
# Install system dependencies
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
curl \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Pre-download wheels to avoid recompiling bcrypt from source
RUN pip install --no-cache-dir --prefix=/install \
'bcrypt==4.0.1' \
'cffi>=2.0' \
'pycparser>=2.0'
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: slim runtime
FROM python:3.11-slim
WORKDIR /app
# Install runtime dependencies only (no build tools)
RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY . .
COPY app/ ./app/
COPY requirements.txt ./
# Make entrypoint
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
# Expose port
EXPOSE 8000
# Wait for wizard config, then start uvicorn
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -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)

View File

@@ -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}

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db
from app.init_wizard import DELETED_USER_USERNAME
from app.models import models
from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission
@@ -30,6 +31,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 +116,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,11 +205,94 @@ 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)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None:
"""Reassign all foreign key references from old_id to new_id, then delete
records that would be meaningless under deleted-user (api_keys, notifications,
project memberships)."""
from app.models.apikey import APIKey
from app.models.notification import Notification
from app.models.activity import ActivityLog as Activity
from app.models.worklog import WorkLog as WorkLogModel
from app.models.meeting import Meeting, MeetingParticipant
from app.models.task import Task
from app.models.support import Support
from app.models.proposal import Proposal
from app.models.milestone import Milestone
from app.models.calendar import TimeSlot, SchedulePlan
from app.models.minimum_workload import MinimumWorkload
from app.models.essential import Essential
# Delete records that are meaningless without the real user
db.query(APIKey).filter(APIKey.user_id == old_id).delete()
db.query(Notification).filter(Notification.user_id == old_id).delete()
db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete()
# Reassign ownership/authorship references
db.query(models.Project).filter(models.Project.owner_id == old_id).update(
{"owner_id": new_id})
db.query(models.Comment).filter(models.Comment.author_id == old_id).update(
{"author_id": new_id})
db.query(Activity).filter(Activity.user_id == old_id).update(
{"user_id": new_id})
db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update(
{"user_id": new_id})
# Tasks
db.query(Task).filter(Task.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Task).filter(Task.assignee_id == old_id).update(
{"assignee_id": new_id})
db.query(Task).filter(Task.created_by_id == old_id).update(
{"created_by_id": new_id})
# Meetings
db.query(Meeting).filter(Meeting.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update(
{"user_id": new_id})
# Support
db.query(Support).filter(Support.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Support).filter(Support.assignee_id == old_id).update(
{"assignee_id": new_id})
# Proposals
db.query(Proposal).filter(Proposal.created_by_id == old_id).update(
{"created_by_id": new_id})
# Milestones
db.query(Milestone).filter(Milestone.created_by_id == old_id).update(
{"created_by_id": new_id})
# Calendar
db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update(
{"user_id": new_id})
db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update(
{"user_id": new_id})
# Minimum workload / Essential
db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update(
{"user_id": new_id})
db.query(Essential).filter(Essential.created_by_id == old_id).update(
{"created_by_id": new_id})
# Agent profile
db.query(Agent).filter(Agent.user_id == old_id).update(
{"user_id": new_id})
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
identifier: str,
@@ -218,17 +304,26 @@ def delete_user(
raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
# Protect built-in accounts from deletion
if user.is_admin:
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
if user.username == "acc-mgr":
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
try:
if user.username in _BUILTIN_USERNAMES:
raise HTTPException(
status_code=400,
detail=f"The {user.username} account is a built-in account and cannot be deleted",
)
deleted_user = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if not deleted_user:
raise HTTPException(
status_code=500,
detail="Built-in deleted-user account not found. Run init_wizard first.",
)
_reassign_user_references(db, user.id, deleted_user.id)
db.delete(user)
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
return None
@@ -236,7 +331,7 @@ def delete_user(
def reset_user_apikey(
identifier: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Reset (regenerate) a user's API key.
@@ -244,6 +339,8 @@ def reset_user_apikey(
- user.reset-apikey: can reset any user's API key
- user.reset-self-apikey: can reset only own API key
- admin: can reset any user's API key
Accepts both OAuth2 Bearer token and X-API-Key authentication.
"""
import secrets
from app.models.apikey import APIKey

View File

@@ -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,11 +183,13 @@ _DEV_PERMISSIONS = {
"milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read",
"calendar.read", "calendar.write",
"user.reset-self-apikey",
}
_ACCOUNT_MANAGER_PERMISSIONS = {
"account.create",
"user.reset-apikey",
}
# Role definitions: (name, description, permission_set)
@@ -288,6 +295,39 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None:
"""Create the built-in deleted-user if not exists.
This user serves as a foreign key sink: when a real user is deleted,
all references are reassigned here instead of cascading deletes.
It has no role (no permissions) and cannot log in.
"""
existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if existing:
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
return existing
user = models.User(
username=DELETED_USER_USERNAME,
email="deleted-user@harborforge.internal",
full_name="Deleted User",
hashed_password=None,
is_admin=False,
is_active=False,
role_id=None,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created deleted-user (id=%d)", user.id)
return user
def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
@@ -312,6 +352,9 @@ def run_init(db: Session) -> None:
# Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:

View File

@@ -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()

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

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