Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions backend/alembic/versions/v1_0_0_f061_default_tenant_timezone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""F061: Use Beijing as the required default tenant timezone.

Revision ID: f061_default_tenant_timezone
Revises: f060_tenant_id_backfill
Create Date: 2026-08-05 12:00:00

Background:
Agent scheduling inherits its timezone from the Tenant when the Agent has no
override, so new Tenants need a stable platform default.

Scope:
Require the Tenant timezone column and change its server default to
Asia/Shanghai.

Idempotent:
Reapplying the same nullability and server-default metadata is safe.
"""

from __future__ import annotations

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op


revision: str = "f061_default_tenant_timezone"
down_revision: str | None = "f060_tenant_id_backfill"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.alter_column(
"tenants",
"timezone",
existing_type=sa.String(length=50),
nullable=False,
server_default="Asia/Shanghai",
)


def downgrade() -> None:
op.alter_column(
"tenants",
"timezone",
existing_type=sa.String(length=50),
nullable=True,
server_default="UTC",
)
9 changes: 6 additions & 3 deletions backend/app/api/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from app.models.user import User
from app.schemas.schemas import AgentCreate, AgentOut, AgentUpdate
from app.services.storage import get_storage_backend
from app.services.timezone_utils import DEFAULT_TIMEZONE
from app.services.access_relationships import ensure_access_granted_platform_relationships
from app.services.quota_guard import check_agent_creation_quota, QuotaExceeded
from app.models.tenant import Tenant
Expand Down Expand Up @@ -592,13 +593,15 @@ async def get_agent(
creator = await user_dao.get_with_identity(agent.creator_id)
out["creator_username"] = creator.username if creator else None

# Resolve effective timezone (agent → tenant → UTC)
# Resolve effective timezone (agent → tenant → platform default)
effective_tz = agent.timezone
if not effective_tz and agent.tenant_id:
tenant = await tenant_dao.get(agent.tenant_id)
if tenant:
effective_tz = tenant.timezone or "UTC"
out["effective_timezone"] = effective_tz or "UTC"
effective_tz = tenant.timezone
if not effective_tz:
effective_tz = DEFAULT_TIMEZONE
out["effective_timezone"] = effective_tz

return out

Expand Down
14 changes: 11 additions & 3 deletions backend/app/api/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func as sqla_func, select
from sqlalchemy.ext.asyncio import AsyncSession

Expand All @@ -24,6 +24,7 @@
from app.models.tenant import Tenant
from app.models.user import User
from app.services.storage import ensure_local_path, get_storage_backend, normalize_storage_key
from app.services.timezone_utils import validate_timezone_name

router = APIRouter(prefix="/tenants", tags=["tenants"])

Expand All @@ -39,7 +40,7 @@ class TenantOut(BaseModel):
name: str
slug: str
im_provider: str
timezone: str = "UTC"
timezone: str = "Asia/Shanghai"
country_region: str = "001"
is_active: bool
sso_enabled: bool = False
Expand All @@ -62,6 +63,13 @@ class TenantUpdate(BaseModel):
sso_domain: str | None = None
a2a_async_enabled: bool | None = None

@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str | None) -> str:
if value is None:
raise ValueError("Tenant timezone is required")
return validate_timezone_name(value)


def _tenant_logo_key(tenant_id: uuid.UUID) -> str:
return normalize_storage_key(f"_tenant_logos/{tenant_id}.png")
Expand Down Expand Up @@ -265,7 +273,7 @@ async def join_company(
ic_result = await query_dao.execute(db,
select(InvitationCode).where(
InvitationCode.code == data.invitation_code,
InvitationCode.is_active == True,
InvitationCode.is_active.is_(True),
InvitationCode.tenant_id.is_not(None),
)
)
Expand Down
15 changes: 15 additions & 0 deletions backend/app/api/triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import uuid

from croniter import croniter
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
Expand Down Expand Up @@ -91,6 +92,20 @@ async def update_trigger(
raise HTTPException(404, "Trigger not found")

if body.config is not None:
if trigger.type == "cron":
expr = body.config.get("expr")
if not isinstance(expr, str) or not expr.strip():
raise HTTPException(
400,
"cron trigger requires config.expr",
)
try:
croniter(expr)
except Exception as exc:
raise HTTPException(
400,
f"Invalid cron expression: '{expr}'.",
) from exc
trigger.config = body.config
if body.reason is not None:
trigger.reason = body.reason
Expand Down
6 changes: 5 additions & 1 deletion backend/app/models/tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ class Tenant(Base):
min_heartbeat_interval_minutes: Mapped[int] = mapped_column(Integer, default=240)

# Default timezone for all agents in this company (IANA format, e.g. "Asia/Shanghai")
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
timezone: Mapped[str] = mapped_column(
String(50),
default="Asia/Shanghai",
nullable=False,
)
# Company country/region code used to derive default timezone and business calendar.
country_region: Mapped[str] = mapped_column(String(10), default="001")

Expand Down
11 changes: 10 additions & 1 deletion backend/app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import uuid
from datetime import datetime

from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr, Field, field_validator

from app.services.timezone_utils import validate_timezone_name


# ─── Auth ───────────────────────────────────────────────
Expand Down Expand Up @@ -323,6 +325,13 @@ class AgentUpdate(BaseModel):
timezone: str | None = None
expires_at: datetime | None = None # Admin only — extend agent expiry

@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str | None) -> str | None:
if value is None:
return None
return validate_timezone_name(value)


class AgentStatusOut(BaseModel):
"""Agent status from state.json."""
Expand Down
19 changes: 17 additions & 2 deletions backend/app/services/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from typing import Optional, Any, cast
import re

from croniter import croniter
from loguru import logger
from sqlalchemy import select, or_

Expand Down Expand Up @@ -10494,7 +10495,6 @@ async def _handle_set_trigger_outcome(
"invalid_tool_arguments",
)
try:
from croniter import croniter
croniter(expr)
except Exception:
return _typed_failure(
Expand Down Expand Up @@ -10790,7 +10790,22 @@ async def _handle_update_trigger_outcome(
for key, value in new_config.items()
if key != "token" and not key.startswith("_")
}
trigger.config = {**old_config, **user_patch, **protected}
updated_config = {**old_config, **user_patch, **protected}
if trigger.type == "cron":
expr = updated_config.get("expr")
if not isinstance(expr, str) or not expr.strip():
return _typed_failure(
"cron trigger requires config.expr.",
"invalid_tool_arguments",
)
try:
croniter(expr)
except Exception:
return _typed_failure(
f"Invalid cron expression: '{expr}'.",
"invalid_tool_arguments",
)
trigger.config = updated_config
changes.append(f"config fields patched: {sorted(user_patch)}")
if new_reason is not None:
if not isinstance(new_reason, str) or not new_reason.strip():
Expand Down
23 changes: 17 additions & 6 deletions backend/app/services/timezone_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import uuid
from datetime import datetime
from zoneinfo import ZoneInfo
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError


from sqlalchemy import select
Expand Down Expand Up @@ -32,11 +32,22 @@
"Pacific/Auckland",
]

DEFAULT_TIMEZONE = "Asia/Shanghai"


def validate_timezone_name(value: str) -> str:
"""Return a valid IANA timezone name or raise a validation error."""
try:
ZoneInfo(value)
except (ValueError, ZoneInfoNotFoundError) as error:
raise ValueError(f"Invalid IANA timezone: {value}") from error
return value


async def get_agent_timezone(agent_id: uuid.UUID) -> str:
"""Resolve effective timezone for an agent.

Priority: agent.timezone → tenant.timezone → 'UTC'
Priority: agent.timezone → tenant.timezone → default timezone.
"""
from app.models.agent import Agent
from app.models.tenant import Tenant
Expand All @@ -51,7 +62,7 @@ async def get_agent_timezone(agent_id: uuid.UUID) -> str:
)
agent = result.scalar_one_or_none()
if not agent:
return "UTC"
return DEFAULT_TIMEZONE

# Agent-level override
if agent.timezone:
Expand All @@ -64,19 +75,19 @@ async def get_agent_timezone(agent_id: uuid.UUID) -> str:
if tenant and tenant.timezone:
return tenant.timezone

return "UTC"
return DEFAULT_TIMEZONE


def get_agent_timezone_sync(agent, tenant=None) -> str:
"""Synchronous version — when agent and tenant objects are already loaded.

Priority: agent.timezone → tenant.timezone → 'UTC'
Priority: agent.timezone → tenant.timezone → default timezone.
"""
if agent.timezone:
return agent.timezone
if tenant and hasattr(tenant, 'timezone') and tenant.timezone:
return tenant.timezone
return "UTC"
return DEFAULT_TIMEZONE


def now_in_timezone(tz_name: str) -> datetime:
Expand Down
7 changes: 4 additions & 3 deletions backend/app/services/trigger_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async def _handle_okr_report_trigger(trigger: AgentTrigger, now: datetime) -> bo
async def _handle_okr_collection_trigger(trigger: AgentTrigger, now: datetime) -> bool:
return await handle_okr_collection_trigger_runtime(trigger, now)

async def _evaluate_trigger(trigger: AgentTrigger, now: datetime) -> bool:
async def _evaluate_trigger(trigger: AgentTrigger, now: datetime) -> datetime | None:
return await evaluate_trigger_runtime(trigger, now)

# ── Main Tick Loop ──────────────────────────────────────────────────
Expand Down Expand Up @@ -139,7 +139,8 @@ async def _tick():
continue

try:
if await _evaluate_trigger(trigger, now):
scheduled_at = await _evaluate_trigger(trigger, now)
if scheduled_at is not None:
handled = await _handle_okr_report_trigger(trigger, now)
if not handled:
handled = await _handle_okr_collection_trigger(trigger, now)
Expand All @@ -166,7 +167,7 @@ async def _tick():
continue
recent.append(now)
_on_msg_fire_log[trigger.agent_id] = recent
await enqueue_due_trigger(trigger, now)
await enqueue_due_trigger(trigger, scheduled_at)
except Exception as e:
logger.warning(f"Error evaluating trigger {trigger.name}: {e}")

Expand Down
28 changes: 20 additions & 8 deletions backend/app/services/trigger_runtime/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from datetime import datetime

from loguru import logger

from app.dao import query_dao
from app.models.trigger import AgentTrigger
from app.services.trigger_runtime.keys import build_scheduled_execution_key
Expand Down Expand Up @@ -31,12 +33,22 @@ def runtime_execution_payload(trigger: AgentTrigger) -> dict:
return payload


async def enqueue_due_trigger(trigger: AgentTrigger, now: datetime) -> None:
async def enqueue_due_trigger(trigger: AgentTrigger, scheduled_at: datetime) -> None:
async with query_dao.session() as db:
await enqueue_trigger_execution(
db,
trigger=trigger,
source=trigger.type,
idempotency_key=build_scheduled_execution_key(trigger, now),
payload_obj=runtime_execution_payload(trigger),
)
try:
await enqueue_trigger_execution(
db,
trigger=trigger,
source=trigger.type,
idempotency_key=build_scheduled_execution_key(trigger, scheduled_at),
scheduled_at=scheduled_at,
payload_obj=runtime_execution_payload(trigger),
)
except Exception as error:
logger.bind(
trigger_id=str(trigger.id),
trigger_name=trigger.name,
trigger_type=trigger.type,
scheduled_at=scheduled_at.isoformat(),
).error("Trigger occurrence registration failed: {}", error)
raise
Loading