34 Commits

Author SHA1 Message Date
e80ead528d fix(calendar): /agent/status idempotent + 409 on bad transition
Same-state transition was 500 (transition_to_busy asserts current=IDLE).
Now: short-circuit identical target → 200 no_change=true. Any other
state-machine violation surfaces as 409 with the actual error message
instead of generic 500.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:46:30 +01:00
f1aafb86df feat(calendar): GET /agent/status — read-only status query for plugin gate
Previously only POST /agent/status existed (for state transitions).
Fabric.OpenclawPlugin's triage on-call gate needs to check whether
the on-duty agent is currently on_call without flipping their state —
so the wake decision is read-only. GET returns {agent_id, status},
404 if unknown.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:34:10 +01:00
65905e4831 Merge pull request 'feat(schedule_type): minute-precision windows + variable maintenance length' (#21) from feat/schedule-type-minutes into main 2026-05-22 19:19:21 +00:00
345e0f3a04 feat(schedule_type): minute-precision windows + variable maintenance length
Lifts the two hard restrictions in PR #18:
  * window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439)
  * maintenance window was exactly 1 hour → now any duration in [1, 180] minutes
    ((maint_to - maint_from) mod 1440)

## Schema migration (additive)

`_migrate_schema()` detects legacy "hours" rows (any row where MAX of the
6 window columns is ≤ 23) and multiplies each column by 60 to convert
to minutes. Idempotent — post-conversion values are well above 23 so
the guard never fires twice.

## Touched surfaces

- `models/schedule_type.py` — column comments updated; new
  `compute_maintenance_duration()` helper (returns 1-1440 min, treats
  from==to as 1440 which is then rejected by validator)
- `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440;
  `_validate_maintenance_window` accepts 1-180min duration; response
  includes derived `maintenance_duration_minutes`
- `schemas/schedule_type_special_slot.py` — `minute_in_window` max
  59→179; `estimated_duration` max 60→180
- `routers/schedule_type.py` — PATCH re-validates merged maintenance
  pair (partial updates can put the row into an invalid combo the
  pydantic single-field validator can't catch); `_attach_derived`
  populates the new response field
- `routers/schedule_type_special_slot.py` — `_validate_fits_window`
  now takes the parent's maintenance duration instead of hard-coded 60
- `routers/calendar.py` — `_scheduled_inside_window` arg renamed
  hour→min; the maintenance-window guard error message formats
  HH:MM not HH:00
- `services/special_slot_materialiser.py` — materialised
  `scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)`
  with hour/minute split

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:18:21 +01:00
e5e81d418d Merge pull request 'feat(users): PATCH /users/{id}/bind-agent to backfill agents row' (#20) from feat/user-bind-agent into main 2026-05-22 18:58:25 +00:00
6400f7f612 feat(users): PATCH /users/{id}/bind-agent to backfill agents row
Companion endpoint for the cli's upcoming `hf user bind-agent` subcommand.
Lets admin retroactively bind an existing user to (agent_id,
claw_identifier) when that user was created before `hf user create`
supported the binding flags (i.e. all of zhi/lyn/mirror/sherlock/orion/
nav on prod today — agents table has 0 rows even though their user rows
exist).

Schema:
  PATCH /users/{identifier}/bind-agent
  body: {agent_id: str, claw_identifier: str}  // both required
  perm: account.create (admin auto)            // same as POST /users

Behaviour:
  * idempotent: re-bind to the same (agent_id, claw_identifier) → 200
    no-op, no extra row
  * 409 if user is already bound to a different pair
  * 409 if requested agent_id is already in use by another user
  * creates the agents row inline; subsequent /schedule-types/agent/
    {agent_id}/assign etc. then work normally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:58:06 +01:00
5b59806e38 Merge pull request 'fix(schedule-type): accept X-API-Key for CRUD' (#19) from feat/schedule-type-apikey-auth into main 2026-05-22 18:36:20 +00:00
23632aa073 fix(schedule-type): accept X-API-Key for CRUD
The /schedule-types/ router was the last surface still gated on
get_current_user (JWT-only). The companion special-slot router
(PR #18) used get_current_user_or_apikey, so the admin flow was:

  * create a schedule_type → DB direct insert (cli can't reach it)
  * add special slot via API → works

Swaps all 5 CRUD endpoints (list / create / patch / delete /
assign-agent) to get_current_user_or_apikey so the same hzhang
admin api_key that works for special-slot creation now works for
schedule_type creation too. /schedule-types/agent/me already uses
X-Agent-ID headers (not user auth), so no change there.

Existing JWT callers are unaffected — get_current_user_or_apikey
tries api_key first then falls back to JWT.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:35:56 +01:00
7017d3483e Merge pull request 'feat(calendar): maintenance window + schedule_type special slots' (#18) from feat/maintenance-window-and-special-slots into main 2026-05-22 18:19:06 +00:00
dcaaa4259a feat(calendar): maintenance window + schedule_type special slots
## What this adds

1. **Maintenance window on ScheduleType**
   - New columns: maintenance_from / maintenance_to (UTC hours, 0-23)
   - Invariant: window is exactly 1 hour (validated in pydantic;
     maintenance_to must equal (maintenance_from + 1) % 24)
   - Default applied via additive migration: 8:00-9:00 UTC for existing
     rows so deployments don't crash on first boot

2. **ScheduleTypeSpecialSlot** — admin-managed slot template
   - New table schedule_type_special_slots
   - Admin (schedule_type.manage) CRUD via
     /schedule-types/{id}/special-slots
   - Fields: name, description, minute_in_window (0-59 inside the
     parent maintenance window), estimated_duration, priority,
     event_data (JSON merged into materialised slot), is_active
   - Unique constraint (schedule_type_id, name) — name is the stable
     human-readable identifier per cohort

3. **Per-agent materialisation**
   - New service app/services/special_slot_materialiser.py
   - GET /calendar/sync calls materialise_special_slots_for_claw
     (idempotent, one row per agent per template per date)
   - GET /calendar/day calls materialise_special_slots_for_user
   - Materialised rows are slot_type=system, event_type=system_event,
     is_admin_locked=true, special_slot_id pointing back to template
   - Plugin's runSync picks them up like any other due slot via the
     normal real-slots query path

4. **Admin-locked enforcement**
   - New TimeSlot columns: is_admin_locked, special_slot_id (FK to
     schedule_type_special_slots, ON DELETE SET NULL)
   - PATCH /calendar/slots/{id}: refuses any edit on admin-locked
     slots (423)
   - POST /calendar/slots/{id}/cancel: refuses cancel on admin-locked
     (423)
   - PATCH /calendar/slots/{id}/agent-update: admin-locked accept only
     ongoing/paused/finished/aborted statuses (423 on other transitions)

5. **Maintenance-window guard on slot creation**
   - POST /calendar/slots: rejects slot_type=system outright (only
     materialiser may create system slots) and rejects any non-system
     slot whose [scheduled_at, +duration] intersects the calling
     user's schedule_type maintenance window (422). Handles 23->0 wrap

6. **Schema response**
   - TimeSlotResponse / CalendarSlotItem now include is_admin_locked
     and special_slot_id so clients can render the lock indicator and
     trace back to the template

## Migration

Additive only — no destructive changes. Lives in _migrate_schema()
in app/main.py; the new schedule_type_special_slots table is created
by Base.metadata.create_all() on first boot.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:18:42 +01:00
c6d2ecbf95 Merge pull request 'feature/oidc-login' (#17) from feature/oidc-login into main
Reviewed-on: #17
2026-05-17 21:27:39 +00:00
5a5e3fa2eb fix(security): block OIDC-binding privilege escalation
The oidc-binding PUT/DELETE endpoints allowed any account.create holder
(non-admin role 'account-manager') to bind an attacker-controlled OIDC
identity to the admin account (or unbind admin, reopening the OIDC-only
bootstrap window) — full admin takeover.

Non-admin callers may now only manage bindings of non-privileged
accounts: requests targeting an is_admin user, the built-in
acc-mgr/deleted-user, or any holder of account.create / user.reset-apikey
are rejected with 403. Global admins remain unrestricted, so the
intended "account-manager binds normal users" capability is preserved.

Found by post-feature security audit. Verified locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:07:43 +01:00
1c91cb32fc feat(auth): OIDC-only admin-role bootstrap auto-connect
In OIDC-only mode, before any admin is linked, an IdP user whose token
carries the configured admin role (default "admin"; OIDC_ADMIN_ROLE /
oidc_settings.admin_role) auto-connects to the unbound hf admin on
first OIDC sign-in, then the window self-closes once any admin is
bound. Roles are scanned across userinfo + the (unverified) access
token: realm_access.roles, resource_access.*.roles, roles/role/groups.
Adds admin_role to settings model/env/effective/API and to the wizard
bootstrap config. Replaces the manual admin-subject approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:05:39 +01:00
f64e2a24f8 feat(init): bootstrap OIDC from wizard config
init_wizard applies config['oidc'] on first init: creates the
oidc_settings row and, when admin_subject is given, binds the
bootstrap admin so OIDC-only deployments are reachable. Idempotent —
an existing row / admin binding is preserved (later admin edits via
the API survive restarts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:59 +01:00
ece2b550fc docs: OIDC feature test plan / test points
Test points for OIDC login, user binding, HARBORFORGE_OIDC_ONLY mode,
and the admin OIDC settings page, with local verification status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:40:26 +01:00
f8126d0cbc feat(auth): admin-configurable OIDC provider (oidc_settings)
Persist OIDC config in a single-row oidc_settings table; non-empty DB
fields override the OIDC_* env vars (env = bootstrap default). The
Authlib client is rebuilt when config changes.

- GET/PUT /auth/oidc/settings — admin only, via JWT OR API key. The
  API-key path is the recovery channel when OIDC-only mode is on and
  OIDC is misconfigured (avoids total lockout).
- client_secret is write-only: never returned (has_client_secret bool),
  preserved when the field is left blank on update.
- /auth/config, login/link/callback now use the effective (DB|env)
  config so enabling OIDC needs no redeploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:29:15 +01:00
54b6103880 feat(auth): OIDC login + identity binding + HARBORFORGE_OIDC_ONLY
- Generic OIDC (Authlib discovery) Authorization Code flow; backend
  issues the existing HS256 JWT on success. Unbound identities are
  rejected (no auto-provisioning).
- User.oidc_issuer/oidc_subject (unique together) + startup migration.
- PUT/DELETE /users/{id}/oidc-binding (admin or account-manager;
  JWT or API key; 409 on conflict). Self-link /auth/oidc/link
  (non-OIDC_ONLY only). Public GET /auth/config.
- HARBORFORGE_OIDC_ONLY: /auth/token rejected, create/update ignore
  password (passwordless users; API keys + OIDC still work).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY; authlib+itsdangerous deps;
  SessionMiddleware for OIDC state. Fixed _user_response to expose
  the new binding fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:22:04 +01:00
d2fafdfe9c Merge security/critical-auth-fixes into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:59 +01:00
f03bfe9093 docs: README accuracy pass + Security section
Document the auth/RBAC/SSRF hardening in this branch: mandatory strong
SECRET_KEY (server refuses weak/default), admin-only + masked /api-keys,
admin-only /webhooks with SSRF guard, project role hierarchy, and auth
added to previously-open endpoints. Fixed stale Issues→tasks model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:50:25 +01:00
801a63f8bb fix(security): close critical auth/SSRF/RBAC holes
Verified locally end-to-end (before: exploitable, after: blocked).

- config: refuse to start on weak/default/short SECRET_KEY (was
  trivially forgeable JWT -> full admin)
- deps: add reusable require_admin dependency (JWT or API key)
- api-keys: require admin to mint/list/revoke; mask key on list
  (was unauthenticated -> instant admin API key)
- webhooks: whole router now admin-only (was fully unauthenticated
  CRUD + readable logs)
- webhook delivery: validate URL scheme + reject hosts resolving to
  private/loopback/link-local/reserved IPs; disable redirects
  (was a readable SSRF primitive)
- rbac: implement a real project-role hierarchy in check_project_role
  (was a no-op: any member, even guest, passed admin/mgr gates)
- misc: auth on delete_milestone (+ensure_can_edit_milestone),
  worklog create/delete (force caller user_id, owner-only delete),
  /activity and /export/tasks (were unauthenticated data exposure)
- tasks: auth + ensure_can_edit_task on assign_task and batch_assign

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:53:14 +01:00
b7ae20e43f Merge pull request 'zhi-2026-04-18' (#16) from zhi-2026-04-18 into main
Reviewed-on: #16
2026-05-01 07:24:35 +00:00
69c4e17d0f Merge branch 'main' into zhi-2026-04-18 2026-05-01 07:24:28 +00:00
zhi
8ab9cae474 feat: schedule type system for work/entertainment periods
- New model: ScheduleType (name, work_from/to, entertainment_from/to)
- Agent.schedule_type_id FK to schedule_types
- CRUD API: GET/POST/PATCH/DELETE /schedule-types/
- Agent assignment: PUT /schedule-types/agent/{agent_id}/assign
- Agent self-query: GET /schedule-types/agent/me
- Permissions: schedule_type.read, schedule_type.manage
- Migration: adds schedule_type_id column to agents table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:20:51 +00:00
zhi
5b7169a3cf feat: add /calendar/sync endpoint for multi-agent schedule sync
Returns today's slots for all agents on a claw instance, keyed by
agent_id. Used by HF Plugin to maintain a local schedule cache
instead of per-agent heartbeat.

Also records heartbeat for all agents on the instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 09:30:57 +00:00
630c215e62 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
00846f92df 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
04fa209f22 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
76c741a7ba 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
d92f8c76b2 Merge branch 'main' into multi-stage 2026-04-16 21:22:54 +00:00
779854d69f 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
61fcca8aff 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
5696a068e6 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
a3be8380c9 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
beb95f7bbe 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
10 changed files with 290 additions and 107 deletions

View File

@@ -52,6 +52,7 @@ from app.schemas.calendar import (
) )
from app.services.agent_heartbeat import get_pending_slots_for_agent from app.services.agent_heartbeat import get_pending_slots_for_agent
from app.services.agent_status import ( from app.services.agent_status import (
AgentStatusError,
record_heartbeat, record_heartbeat,
transition_to_busy, transition_to_busy,
transition_to_idle, transition_to_idle,
@@ -205,12 +206,14 @@ def create_slot(
st.maintenance_from, st.maintenance_from,
st.maintenance_to, st.maintenance_to,
): ):
mf_h, mf_m = divmod(st.maintenance_from, 60)
mt_h, mt_m = divmod(st.maintenance_to, 60)
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=( detail=(
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min " f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
f"intersects the maintenance window " f"intersects the maintenance window "
f"{st.maintenance_from:02d}:00-{st.maintenance_to:02d}:00 UTC of " f"{mf_h:02d}:{mf_m:02d}-{mt_h:02d}:{mt_m:02d} UTC of "
f"schedule_type '{st.name}' — that window is admin-reserved" f"schedule_type '{st.name}' — that window is admin-reserved"
), ),
) )
@@ -341,22 +344,21 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
def _scheduled_inside_window( def _scheduled_inside_window(
scheduled_at, scheduled_at,
estimated_duration_minutes: int, estimated_duration_minutes: int,
window_from_hour: int, window_from_min: int,
window_to_hour: int, window_to_min: int,
) -> bool: ) -> bool:
"""True if [scheduled_at, scheduled_at+duration] intersects [from:00, to:00]. """True if [scheduled_at, scheduled_at+duration] intersects [from, to).
Handles the 23→0 wrap case (window straddles UTC midnight). Window bounds are minutes-since-UTC-midnight (0-1439). Handles the
case where the window crosses UTC midnight (e.g. 23:30→01:00).
""" """
start_min = scheduled_at.hour * 60 + scheduled_at.minute start_min = scheduled_at.hour * 60 + scheduled_at.minute
end_min = start_min + max(estimated_duration_minutes, 1) end_min = start_min + max(estimated_duration_minutes, 1)
win_start_min = window_from_hour * 60 if window_to_min > window_from_min:
win_end_min = window_to_hour * 60
if win_end_min > win_start_min:
# normal same-day window # normal same-day window
return start_min < win_end_min and end_min > win_start_min return start_min < window_to_min and end_min > window_from_min
# wrap-around: window = [from..24:00) [00:00..to) # wrap-around: window = [from..1440) [0..to)
return (start_min < 24 * 60 and end_min > win_start_min) or end_min > win_end_min return (start_min < 1440 and end_min > window_from_min) or end_min > window_to_min
# Admin-locked special slots accept only these agent-driven status # Admin-locked special slots accept only these agent-driven status
@@ -560,6 +562,29 @@ def agent_update_virtual_slot(
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[]) return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
@router.get(
"/agent/status",
summary="Read an agent's current runtime status (no side effects)",
)
def get_agent_status(
agent_id: str = Query(..., description="Target agent_id"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return `{agent_id, status}` so callers (Fabric.OpenclawPlugin's
triage on-call gate, etc.) can decide whether the agent is currently
eligible without flipping their state.
No-op for unknown agents — returns 404 with `{detail: 'Agent not
found'}` so the caller can decide whether to fail-open or fail-closed.
"""
agent = _require_agent(db, agent_id, x_claw_identifier)
return {
"agent_id": agent.agent_id,
"status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
}
@router.post( @router.post(
"/agent/status", "/agent/status",
summary="Update agent runtime status from plugin", summary="Update agent runtime status from plugin",
@@ -570,6 +595,13 @@ def update_agent_status(
): ):
agent = _require_agent(db, payload.agent_id, payload.claw_identifier) agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
target = (payload.status or '').lower().strip() target = (payload.status or '').lower().strip()
# Idempotent same-state transition: a 'busy → busy' request is a
# no-op rather than a 500. Lets plugin status gates / cli `--set`
# be safe to fire-and-forget without first reading current state.
current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status)
if current == target:
return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True}
try:
if target == AgentStatus.IDLE.value: if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent) transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value: elif target == AgentStatus.BUSY.value:
@@ -583,6 +615,11 @@ def update_agent_status(
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at) transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else: else:
raise HTTPException(status_code=400, detail="Unsupported agent status") raise HTTPException(status_code=400, detail="Unsupported agent status")
except AgentStatusError as e:
# State-machine violation (e.g. busy → busy via wrong precondition)
# → 409 with the rejected transition explained, instead of a 500.
db.rollback()
raise HTTPException(status_code=409, detail=str(e))
db.commit() db.commit()
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)} return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}

View File

@@ -57,6 +57,18 @@ def _require_schedule_manage(db: Session, user: User) -> User:
return user return user
def _attach_derived(st: ScheduleType) -> ScheduleType:
"""Attach derived fields (maintenance_duration_minutes) so the
pydantic ScheduleTypeResponse picks them up via from_attributes.
Pydantic with from_attributes reads attributes off the ORM object;
setting a transient attr here avoids having to convert through dict.
"""
if st is not None:
st.maintenance_duration_minutes = st.compute_maintenance_duration()
return st
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schedule Type CRUD # Schedule Type CRUD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -71,7 +83,7 @@ def list_schedule_types(
current_user: User = Depends(get_current_user_or_apikey), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_read(db, current_user) _require_schedule_read(db, current_user)
return db.query(ScheduleType).all() return [_attach_derived(st) for st in db.query(ScheduleType).all()]
@router.post( @router.post(
@@ -96,11 +108,13 @@ def create_schedule_type(
work_to=payload.work_to, work_to=payload.work_to,
entertainment_from=payload.entertainment_from, entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to, entertainment_to=payload.entertainment_to,
maintenance_from=payload.maintenance_from,
maintenance_to=payload.maintenance_to,
) )
db.add(st) db.add(st)
db.commit() db.commit()
db.refresh(st) db.refresh(st)
return st return _attach_derived(st)
@router.patch( @router.patch(
@@ -120,12 +134,23 @@ def update_schedule_type(
if not st: if not st:
raise HTTPException(404, "Schedule type not found") raise HTTPException(404, "Schedule type not found")
for field, value in payload.model_dump(exclude_unset=True).items(): update_fields = payload.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(st, field, value) setattr(st, field, value)
# Re-validate maintenance after merge (partial updates can put the row
# into an invalid window combo that the pydantic schema couldn't catch
# because it only saw one field).
from app.schemas.schedule_type import _validate_maintenance_window
try:
_validate_maintenance_window(st.maintenance_from, st.maintenance_to)
except ValueError as e:
db.rollback()
raise HTTPException(422, str(e))
db.commit() db.commit()
db.refresh(st) db.refresh(st)
return st return _attach_derived(st)
@router.delete( @router.delete(
@@ -181,7 +206,8 @@ def get_my_schedule_type(
if not agent.schedule_type_id: if not agent.schedule_type_id:
return None return None
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first() st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
return _attach_derived(st) if st else None
@router.put( @router.put(

View File

@@ -75,15 +75,17 @@ def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType:
def _validate_fits_window( def _validate_fits_window(
minute_in_window: int, minute_in_window: int,
estimated_duration: int, estimated_duration: int,
maintenance_duration_minutes: int,
) -> None: ) -> None:
"""Reject special slots that wouldn't fit inside the 1-hour window.""" """Reject special slots that wouldn't fit inside the parent's maintenance window."""
if minute_in_window + estimated_duration > 60: if minute_in_window + estimated_duration > maintenance_duration_minutes:
raise HTTPException( raise HTTPException(
422, 422,
( (
f"special slot does not fit in maintenance window: " f"special slot does not fit in maintenance window: "
f"minute_in_window={minute_in_window} + " f"minute_in_window={minute_in_window} + "
f"estimated_duration={estimated_duration} > 60" f"estimated_duration={estimated_duration} > "
f"maintenance window {maintenance_duration_minutes}min"
), ),
) )
@@ -127,8 +129,8 @@ def create_special_slot(
current_user: User = Depends(get_current_user_or_apikey), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
_fetch_schedule_type(db, schedule_type_id) st = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(payload.minute_in_window, payload.estimated_duration) _validate_fits_window(payload.minute_in_window, payload.estimated_duration, st.compute_maintenance_duration())
dup = ( dup = (
db.query(ScheduleTypeSpecialSlot) db.query(ScheduleTypeSpecialSlot)
@@ -188,7 +190,8 @@ def update_special_slot(
update_fields = payload.model_dump(exclude_unset=True) update_fields = payload.model_dump(exclude_unset=True)
next_min = update_fields.get("minute_in_window", slot.minute_in_window) next_min = update_fields.get("minute_in_window", slot.minute_in_window)
next_dur = update_fields.get("estimated_duration", slot.estimated_duration) next_dur = update_fields.get("estimated_duration", slot.estimated_duration)
_validate_fits_window(next_min, next_dur) parent = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(next_min, next_dur, parent.compute_maintenance_duration())
for field, value in update_fields.items(): for field, value in update_fields.items():
setattr(slot, field, value) setattr(slot, field, value)

View File

@@ -221,6 +221,71 @@ def update_user(
return _user_response(user) return _user_response(user)
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
def bind_agent(
identifier: str,
payload: schemas.UserBindAgentRequest,
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
"""Bind an existing user to (agent_id, claw_identifier).
Backfill path for users that were created via `hf user create` before
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
the `agents` row that should have been written at user-create time.
Idempotent: if the user is already bound to the same
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
Rejects (409) if:
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
- the requested agent_id is already in use by another user
Permission: account.create (admin auto-grants) — same gate as
POST /users so the surface stays symmetric.
"""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
if existing_agent_for_user:
if (
existing_agent_for_user.agent_id == payload.agent_id
and existing_agent_for_user.claw_identifier == payload.claw_identifier
):
# idempotent re-bind
return _user_response(user)
raise HTTPException(
status_code=409,
detail=(
f"User '{user.username}' is already bound to agent "
f"'{existing_agent_for_user.agent_id}' on claw "
f"'{existing_agent_for_user.claw_identifier}'"
),
)
existing_for_agent_id = (
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
)
if existing_for_agent_id:
raise HTTPException(
status_code=409,
detail=f"agent_id '{payload.agent_id}' already in use by another user",
)
db.add(
Agent(
user_id=user.id,
agent_id=payload.agent_id,
claw_identifier=payload.claw_identifier,
)
)
db.commit()
db.refresh(user)
return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME} _BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}

View File

@@ -400,9 +400,9 @@ def _migrate_schema():
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL")) db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to --- # --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the 1-hour-window # Default 8:009:00 UTC for existing rows; the maintenance
# invariant is enforced at the schema level for any NEW rows by # duration invariant (1-180min) is enforced at the schema
# the pydantic ScheduleTypeCreate validator. # level for any NEW rows by ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"): if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"): if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text( db.execute(text(
@@ -413,6 +413,29 @@ def _migrate_schema():
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9" "ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
)) ))
# --- minutes-since-midnight migration (PR #21+) ---
# The 6 schedule_type window columns used to hold *hours*
# (0-23). PR #21 changed semantics to *minutes since UTC
# midnight* (0-1439). Detect the legacy regime by checking
# if ANY row has all 6 values ≤ 23 — if so, multiply each
# by 60 to convert. Idempotent: post-conversion values are
# all ≥ 0 and usually well above 23, so guard never fires
# twice.
row = db.execute(text(
"SELECT MAX(GREATEST(work_from, work_to, entertainment_from, entertainment_to, maintenance_from, maintenance_to)) AS m "
"FROM schedule_types"
)).fetchone()
if row is not None and row.m is not None and row.m <= 23:
db.execute(text(
"UPDATE schedule_types SET "
" work_from = work_from * 60, "
" work_to = work_to * 60, "
" entertainment_from = entertainment_from * 60, "
" entertainment_to = entertainment_to * 60, "
" maintenance_from = maintenance_from * 60, "
" maintenance_to = maintenance_to * 60"
))
# --- time_slots: admin-locked + special_slot pointer --- # --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"): if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"): if not _has_column(db, "time_slots", "is_admin_locked"):

View File

@@ -1,9 +1,17 @@
"""ScheduleType model — defines work/entertainment/maintenance time periods. """ScheduleType model — defines work/entertainment/maintenance time periods.
Each ScheduleType defines the daily work, entertainment, and maintenance Each ScheduleType defines the daily work, entertainment, and maintenance
windows. Agents reference a schedule_type to know when they should be windows for agents who reference this type. All bounds are stored as
working, when they can engage in entertainment, and when the system **minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
requires them to surrender control for admin-scheduled special slots. sub-hour boundaries are exact.
Maintenance window length is variable (1-180 minutes) and admin-owned;
agent slots cannot intersect it (see `app/api/routers/calendar.py`).
Historical note: pre-PR #21 the columns held *hours* (0-23) and the
maintenance window was hard-fixed at exactly 1 hour. The additive
migration in `_migrate_schema()` multiplies legacy values by 60 so
existing rows convert transparently.
""" """
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy import Column, Integer, String, DateTime
@@ -26,52 +34,26 @@ class ScheduleType(Base):
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')", comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
) )
work_from = Column( # Minutes since UTC midnight, 0-1439 inclusive.
Integer, work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
nullable=False, work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)")
comment="Work period start hour (0-23, UTC)",
)
work_to = Column( entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)")
Integer, entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)")
nullable=False,
comment="Work period end hour (0-23, UTC)",
)
entertainment_from = Column( # Maintenance window — admin-owned, variable length (1-180 min).
Integer, # Default 8:009:00 UTC = 480540 minutes for existing rows.
nullable=False,
comment="Entertainment period start hour (0-23, UTC)",
)
entertainment_to = Column(
Integer,
nullable=False,
comment="Entertainment period end hour (0-23, UTC)",
)
# -----------------------------------------------------------------
# Maintenance window — every agent on this schedule_type must
# surrender work/entertainment slots during this hour. Admin-created
# special slots tied to this schedule_type can only be scheduled
# inside this window. The window is always exactly 1 hour.
#
# Default (when columns are added via additive migration to existing
# rows) is 8:009:00 UTC so deployments stay sane until an operator
# picks proper hours per schedule_type.
# -----------------------------------------------------------------
maintenance_from = Column( maintenance_from = Column(
Integer, Integer,
nullable=False, nullable=False,
server_default="8", server_default="480",
comment="Maintenance window start hour (0-23, UTC). Window is exactly 1h.", comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
) )
maintenance_to = Column( maintenance_to = Column(
Integer, Integer,
nullable=False, nullable=False,
server_default="9", server_default="540",
comment="Maintenance window end hour (0-23, UTC). Must equal (maintenance_from + 1) % 24.", comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
) )
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -83,3 +65,19 @@ class ScheduleType(Base):
back_populates="schedule_type", back_populates="schedule_type",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# ---------------------------------------------------------------
# Convenience methods used by the API layer + materialiser.
# ---------------------------------------------------------------
def compute_maintenance_duration(self) -> int:
"""Maintenance window length in minutes (handles 23→0 wrap)."""
return (self.maintenance_to - self.maintenance_from) % 1440 or 1440
def window_contains(self, start_min: int, end_min: int, win_from: int, win_to: int) -> bool:
"""True if [start_min, end_min) intersects [win_from, win_to) (handles wrap)."""
# Normalise into [0, 1440) — same logic as the helper in calendar.py.
if win_to > win_from:
return start_min < win_to and end_min > win_from
# wrap window crosses midnight: [win_from..1440) [0..win_to)
return start_min < win_to or end_min > win_from

View File

@@ -1,27 +1,44 @@
"""Schemas for ScheduleType CRUD.""" """Schemas for ScheduleType CRUD.
All `*_from` / `*_to` values are **minutes since UTC midnight** (0-1439).
A maintenance window of variable length is allowed (1-180 minutes,
handles 23→0 wrap).
"""
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from typing import Optional from typing import Optional
def _validate_maintenance_window(maintenance_from: int, maintenance_to: int) -> None: _MAX_MIN = 1440 # 24 * 60 — exclusive upper bound
"""Maintenance window must be exactly 1 hour (handles 23→0 wrap)."""
expected_to = (maintenance_from + 1) % 24
if maintenance_to != expected_to: def _maintenance_duration(maint_from: int, maint_to: int) -> int:
"""Maintenance window length in minutes; treats from==to as 24h (invalid)."""
return (maint_to - maint_from) % _MAX_MIN or _MAX_MIN
def _validate_maintenance_window(maint_from: int, maint_to: int) -> None:
dur = _maintenance_duration(maint_from, maint_to)
if dur < 1 or dur > 180:
raise ValueError( raise ValueError(
f"maintenance window must be exactly 1 hour: " f"maintenance window duration must be in [1, 180] minutes; "
f"expected maintenance_to={expected_to}, got {maintenance_to}" f"got {dur} (from={maint_from}, to={maint_to})"
) )
def _validate_minute_field(name: str, value: int) -> None:
if value < 0 or value >= _MAX_MIN:
raise ValueError(f"{name} must be in [0, {_MAX_MIN}); got {value}")
class ScheduleTypeCreate(BaseModel): class ScheduleTypeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=64)
work_from: int = Field(..., ge=0, le=23) work_from: int = Field(..., ge=0, lt=_MAX_MIN, description="Work start (minutes since UTC midnight, 0-1439)")
work_to: int = Field(..., ge=0, le=23) work_to: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_from: int = Field(..., ge=0, le=23) entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_to: int = Field(..., ge=0, le=23) entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN)
maintenance_from: int = Field(8, ge=0, le=23, description="Maintenance window start hour UTC (default 8)") maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)")
maintenance_to: int = Field(9, ge=0, le=23, description="Maintenance window end hour UTC; must equal (maintenance_from+1) % 24") maintenance_to: int = Field(540, ge=0, lt=_MAX_MIN, description="Maintenance end; (to-from) mod 1440 in [1,180]")
@model_validator(mode="after") @model_validator(mode="after")
def _check_maintenance(self): def _check_maintenance(self):
@@ -31,12 +48,12 @@ class ScheduleTypeCreate(BaseModel):
class ScheduleTypeUpdate(BaseModel): class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64) name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, le=23) work_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
work_to: Optional[int] = Field(None, ge=0, le=23) work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_from: Optional[int] = Field(None, ge=0, le=23) entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_to: Optional[int] = Field(None, ge=0, le=23) entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_from: Optional[int] = Field(None, ge=0, le=23) maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_to: Optional[int] = Field(None, ge=0, le=23) maintenance_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
@model_validator(mode="after") @model_validator(mode="after")
def _check_maintenance(self): def _check_maintenance(self):
@@ -56,6 +73,7 @@ class ScheduleTypeResponse(BaseModel):
entertainment_to: int entertainment_to: int
maintenance_from: int maintenance_from: int
maintenance_to: int maintenance_to: int
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -9,8 +9,8 @@ from pydantic import BaseModel, Field
class SpecialSlotCreate(BaseModel): class SpecialSlotCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512) description: Optional[str] = Field(None, max_length=512)
minute_in_window: int = Field(0, ge=0, le=59, description="Minute offset (0-59) inside the schedule_type maintenance window") minute_in_window: int = Field(0, ge=0, le=179, description="Minute offset (0-179) inside the schedule_type maintenance window")
estimated_duration: int = Field(15, ge=1, le=60, description="Duration in minutes; must fit inside the 1-hour maintenance window") estimated_duration: int = Field(15, ge=1, le=180, description="Duration in minutes; must fit inside the maintenance window (1-180min)")
priority: int = Field(50, ge=0, le=99) priority: int = Field(50, ge=0, le=99)
event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data") event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data")
is_active: bool = True is_active: bool = True
@@ -19,8 +19,8 @@ class SpecialSlotCreate(BaseModel):
class SpecialSlotUpdate(BaseModel): class SpecialSlotUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64) name: Optional[str] = Field(None, min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512) description: Optional[str] = Field(None, max_length=512)
minute_in_window: Optional[int] = Field(None, ge=0, le=59) minute_in_window: Optional[int] = Field(None, ge=0, le=179)
estimated_duration: Optional[int] = Field(None, ge=1, le=60) estimated_duration: Optional[int] = Field(None, ge=1, le=180)
priority: Optional[int] = Field(None, ge=0, le=99) priority: Optional[int] = Field(None, ge=0, le=99)
event_data: Optional[dict[str, Any]] = None event_data: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime, time from datetime import datetime, time
from enum import Enum from enum import Enum
@@ -186,6 +186,19 @@ class UserUpdate(BaseModel):
discord_user_id: Optional[str] = None discord_user_id: Optional[str] = None
class UserBindAgentRequest(BaseModel):
"""Request body for PATCH /users/{identifier}/bind-agent.
Binds an existing user to (agent_id, claw_identifier) by inserting a
row in the `agents` table. Both fields required (mirrors the
create-time invariant in UserCreate). Idempotent: re-binding the same
user to the same (agent_id, claw_identifier) returns the existing
Agent row instead of 409.
"""
agent_id: str = Field(..., min_length=1, max_length=128)
claw_identifier: str = Field(..., min_length=1, max_length=128)
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
is_active: bool is_active: bool

View File

@@ -145,11 +145,11 @@ def _build_time_slot_from_template(
schedule_type: ScheduleType, schedule_type: ScheduleType,
template: ScheduleTypeSpecialSlot, template: ScheduleTypeSpecialSlot,
) -> TimeSlot: ) -> TimeSlot:
scheduled_at = time_type( # schedule_type.maintenance_from is minutes-since-UTC-midnight; the
hour=schedule_type.maintenance_from, # template's minute_in_window is an offset inside that window. Combined
minute=template.minute_in_window, # offset must fit in [0, 1440) and produce a wall-clock time_type.
second=0, total_min = (schedule_type.maintenance_from + template.minute_in_window) % 1440
) scheduled_at = time_type(hour=total_min // 60, minute=total_min % 60, second=0)
# Merge admin-supplied event_data with bookkeeping pointers so the # Merge admin-supplied event_data with bookkeeping pointers so the
# agent (and ARD) can identify the template at wake time. # agent (and ARD) can identify the template at wake time.
merged_event_data = dict(template.event_data or {}) merged_event_data = dict(template.event_data or {})