diff --git a/AGENTS.md b/AGENTS.md index f0d26749..80dab882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,9 @@ For app-surface changes, inspect the proof app at registration in the frontend build. Keep standalone `dist/` delivery separate. - Keep generic agent, harness, workspace, sandbox, event, gate, webhook, and settings plumbing in Druks. Keep domain-specific policy in the app. +- One Druks installation serves one organization. Execution defaults and agent + overrides are shared. Accounts own credentials, personal preferences, and run + attribution. Do not add personal execution defaults. - Grow the author surface by parameter, not by namespace. If the SDK lacks a capability, widen the primitive that owns it. Add a keyword argument or a method to the class that holds the data. Do not add a namespace, facade, diff --git a/backend/druks/accounts/models.py b/backend/druks/accounts/models.py index e53c47f1..b7c5536b 100644 --- a/backend/druks/accounts/models.py +++ b/backend/druks/accounts/models.py @@ -21,6 +21,8 @@ from druks.core.models import Uuid7Pk from druks.database import db_session from druks.models import Base +from druks.settings import load_settings +from druks.user_settings.models import InstallationSettings class Account(Base, Uuid7Pk): @@ -36,6 +38,10 @@ class Account(Base, Uuid7Pk): # stored as the provider gave it and matched regardless of case. username: Mapped[str] = mapped_column(CITEXT, unique=True) is_default: Mapped[bool] = mapped_column(default=False, server_default=text("false")) + timezone: Mapped[str] = mapped_column(String, default="UTC") + gate_park_destination_id: Mapped[str | None] = mapped_column( + ForeignKey("notification_destinations.id", ondelete="SET NULL"), default=None + ) created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) @classmethod @@ -65,14 +71,25 @@ async def get_or_create(cls, username: str) -> "Account": account = await cls.get_for_username(username) if account: return account + installation = await InstallationSettings.get() session = db_session() await session.execute( insert(cls) - .values(username=username, is_default=~select(cls.id).exists()) + .values( + username=username, + is_default=~select(cls.id).exists(), + timezone=load_settings().timezone, + gate_park_destination_id=installation.gate_park_destination_id, + ) .on_conflict_do_nothing(index_elements=["username"]) ) return (await session.scalars(select(cls).where(cls.username == username))).one() + async def update_preferences(self, **fields: object) -> None: + for field, value in fields.items(): + setattr(self, field, value) + await db_session().flush() + @classmethod async def list_all(cls) -> list["Account"]: stmt = select(cls).order_by(cls.created_at, cls.id) diff --git a/backend/druks/agents.py b/backend/druks/agents.py index 5200978d..0a8da36b 100644 --- a/backend/druks/agents.py +++ b/backend/druks/agents.py @@ -16,12 +16,12 @@ from druks.durable.exceptions import WorkflowError from druks.durable.models import AgentCall, Artifact from druks.files.datastructures import File +from druks.harnesses.config import AgentConfig, get_config from druks.harnesses.exceptions import ( HarnessError, HarnessInvalidOutputError, Retry, ) -from druks.harnesses.profiles import Profile, get_profile from druks.prompts import render_prompt from druks.sandbox import gate as sandbox_gate from druks.sandbox.client import provisioning_key, sandbox_client @@ -44,14 +44,14 @@ @contextlib.asynccontextmanager async def _runner( - workflow: "Workflow", host_id: str | None, workflow_id: str, step: str, profile: Profile + workflow: "Workflow", host_id: str | None, workflow_id: str, step: str, config: AgentConfig ) -> AsyncIterator["Workspace"]: # The agent always runs in a Workspace. A warm run attaches the run's held VM; the # rest get a fresh ephemeral VM. Either way workflow.get_workspace() turns the VM into # the runner — fresh per call, so nothing (connection or credential) is held across steps. if host_id: vm = sandbox_client.attach(host_id=host_id) - elif (refs := [*profile.secret_refs, *await workflow.get_secret_refs()]) and ( + elif (refs := [*config.secret_refs, *await workflow.get_secret_refs()]) and ( identity := await SandboxIdentity.lookup(workflow_id, step, refs) ): # A crashed attempt left its box behind. Its identity finds it again. @@ -63,7 +63,7 @@ async def _runner( await set_run_phase("provisioning_vm") # A box that fetches gets its own identity, and the key names it. A # replay finds the box through the identity, above. - identity, entries, key = None, {}, profile.secrets_id + identity, entries, key = None, {}, config.secrets_id if refs: identity, entries = await SandboxIdentity.create( run_id=workflow_id, scoped_to=step, secret_refs=refs @@ -71,7 +71,7 @@ async def _runner( key = identity.id vm = sandbox_client.ephemeral( idempotency_key=provisioning_key(workflow_id, step, key), - secrets={**profile.secrets, **entries}, + secrets={**config.secrets, **entries}, template=template, identity=identity, ) @@ -140,12 +140,12 @@ def __set_name__(self, owner: type, attr: str) -> None: object.__setattr__(self, "app", owner.name) agents.register(self) - async def get_profile(self) -> Profile: + async def get_config(self) -> AgentConfig: """How this agent runs for the current run's actor, read from settings now.""" workflow = current_workflow.get(None) if not workflow: - raise WorkflowError(f"agent {self.id!r} reads its profile only inside a workflow") - return await get_profile(self.id, workflow.account_id) + raise WorkflowError(f"agent {self.id!r} reads its config only inside a workflow") + return await get_config(self.id, workflow.account_id) async def __call__( self, *, contract: type[AgentOutput] | None = None, **context: object @@ -202,9 +202,9 @@ async def _quota_retry_wait() -> float: # recorded wait instead of re-reading the scrape. async with step_session(): # The scrape belongs to the subscription account. - profile = await get_profile(self.id, workflow.account_id) - provider_id = profile.model.partition("/")[0] - scrape = await UsageScrape.latest_for(provider_id, profile.charged_account_id) + config = await get_config(self.id, workflow.account_id) + provider_id = config.model.partition("/")[0] + scrape = await UsageScrape.latest_for(provider_id, config.charged_account_id) if scrape: now = datetime.now(UTC) reset = scrape.soonest_reset_after(now) @@ -276,10 +276,10 @@ async def _run( workflow = current_workflow.get() # Refusing an unservable call here beats provisioning a VM and # 401ing mid-run. - profile = await get_profile(self.id, workflow.account_id) - model = profile.model - subscription_id = profile.subscription.id if profile.subscription else None - api_key_id = profile.api_key.id if profile.api_key else None + config = await get_config(self.id, workflow.account_id) + model = config.model + subscription_id = config.subscription.id if config.subscription else None + api_key_id = config.api_key.id if config.api_key else None # An agent call is a durability boundary — its effects don't roll back — # so commit here rather than hold the step's connection idle through the # minutes of provisioning and the run. @@ -288,7 +288,7 @@ async def _run( artifact_dir = settings.artifacts_dir / f"run-{workflow_id}" engine = _step_engine() - call_id = profile.harness_class.mint_run_id(None) + call_id = config.harness_class.mint_run_id(None) # Registered for provisioning through execution — the subscription's # rotation defers around it. A key never rotates. @@ -299,14 +299,14 @@ async def _run( ) async with gate: await set_run_phase("provisioning_vm") - host_id = await workflow._lease_host(profile) + host_id = await workflow._lease_host(config) # Record the call RUNNING once it has a host to run on (its id names # the on-disk transcript dir) so the live step shows while the agent # works, then finish it — or fail it if the run raised after # starting. A provisioning failure happens before this and records # no call. - async with _runner(workflow, host_id, workflow_id, self.id, profile) as runner: + async with _runner(workflow, host_id, workflow_id, self.id, config) as runner: context = await runner.prepare_context(context, agent_call_id=call_id) # Templates read the live workflow + the workspace the agent runs in, # alongside whatever the workflow's get_prompt_context composes. @@ -328,7 +328,7 @@ async def _run( try: result = await runner.run_agent( account_id=workflow.account_id, - profile=profile, + config=config, agent=self.id, prompt=prompt, schema=contract.model_json_schema(), diff --git a/backend/druks/api/dashboard.py b/backend/druks/api/dashboard.py index 86b265a7..563a6221 100644 --- a/backend/druks/api/dashboard.py +++ b/backend/druks/api/dashboard.py @@ -6,7 +6,7 @@ from druks.database import db_session from druks.durable.enums import OPEN_STATES, RunState from druks.durable.models import Artifact, Run -from druks.user_settings.models import SettingsProfile +from druks.settings import load_settings PAGE_SIZE = 200 router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) @@ -59,7 +59,7 @@ async def list_current_work(response: Response) -> DashboardWork: async def list_current_schedules(response: Response) -> DashboardSchedules: """Configured cadence, not scheduler health.""" response.headers["Cache-Control"] = "no-store" - timezone = (await SettingsProfile.get()).timezone + timezone = load_settings().timezone return DashboardSchedules( rows=[ DashboardSchedule( diff --git a/backend/druks/api/server.py b/backend/druks/api/server.py index 07fb908a..e7e5c019 100644 --- a/backend/druks/api/server.py +++ b/backend/druks/api/server.py @@ -34,7 +34,7 @@ from druks.durable.exceptions import AgentCallNotFound from druks.events.routes import router as events_router from druks.files.routes import router as files_router -from druks.harnesses.exceptions import CatalogError, ProfileSettingsError +from druks.harnesses.exceptions import AgentConfigError, CatalogError from druks.harnesses.routes import router as providers_router from druks.mcp.catalog import load_mcp_catalog from druks.mcp.gateway import exceptions as gate_errors @@ -220,8 +220,8 @@ async def _catalog_error_handler(request: Request, exc: CatalogError) -> JSONRes return JSONResponse(status_code=503, content={"error": "HTTP_503", "detail": detail}) -@app.exception_handler(ProfileSettingsError) -async def _profile_settings_handler(request: Request, exc: ProfileSettingsError) -> JSONResponse: +@app.exception_handler(AgentConfigError) +async def _profile_settings_handler(request: Request, exc: AgentConfigError) -> JSONResponse: return JSONResponse(status_code=422, content={"error": "HTTP_422", "detail": str(exc)}) diff --git a/backend/druks/core/utils/time.py b/backend/druks/core/utils/time.py index a9d7b9ed..14556aef 100644 --- a/backend/druks/core/utils/time.py +++ b/backend/druks/core/utils/time.py @@ -6,6 +6,14 @@ def ensure_utc(value: datetime) -> datetime: return value if value.tzinfo is not None else value.replace(tzinfo=UTC) +def validate_timezone(value: str) -> str: + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError) as exc: + raise ValueError(f"Unknown IANA timezone: {value!r}") from exc + return value + + def operator_local_day(timezone_name: str, now: datetime) -> tuple[ZoneInfo, datetime]: try: timezone = ZoneInfo(timezone_name) diff --git a/backend/druks/durable/engine.py b/backend/druks/durable/engine.py index 891f4193..46d36c38 100644 --- a/backend/druks/durable/engine.py +++ b/backend/druks/durable/engine.py @@ -10,7 +10,7 @@ from druks.database import create_async_engine_from_url, db_session, get_session, session_scope from druks.durable.dbos_state import DBOS_SYSTEM_SCHEMA from druks.settings import load_settings -from druks.user_settings.models import SettingsProfile +from druks.user_settings.models import InstallationSettings if TYPE_CHECKING: from druks.workflows import Workflow @@ -81,7 +81,7 @@ async def apply_schedules() -> None: await DBOS.delete_schedule_async(existing["schedule_name"]) # Evaluate in the installation timezone so daily cadence follows DST. # Personal display preferences must not move a shared schedule. - timezone = (await SettingsProfile.get()).timezone + timezone = load_settings().timezone for cls, fn in _scheduled: await DBOS.delete_schedule_async(cls.kind) cron = await cls.get_schedule() @@ -96,6 +96,8 @@ async def launch() -> None: # loop and async steps share it. DBOS.launch() async with session_scope(_step_engine()): + # Commit the singleton before concurrent settings requests can create it. + await InstallationSettings.get() await apply_schedules() diff --git a/backend/druks/harnesses/base.py b/backend/druks/harnesses/base.py index 62421e6a..2af26f6e 100644 --- a/backend/druks/harnesses/base.py +++ b/backend/druks/harnesses/base.py @@ -113,7 +113,7 @@ def get_secrets(cls, provider: str, key: str) -> dict[str, Secret]: refuses: no raw key enters a box.""" if is_registered(provider): return {provider: get_provider(provider).get_secret(key)} - raise exceptions.ProfileSettingsError( + raise exceptions.AgentConfigError( f"Druks has no proven API-key transport for provider {provider!r}. " "Use an Anthropic or OpenAI key." ) diff --git a/backend/druks/harnesses/profiles.py b/backend/druks/harnesses/config.py similarity index 77% rename from backend/druks/harnesses/profiles.py rename to backend/druks/harnesses/config.py index edadae48..00fe36af 100644 --- a/backend/druks/harnesses/profiles.py +++ b/backend/druks/harnesses/config.py @@ -8,19 +8,18 @@ from druks.secrets.datastructures import Audience from druks.secrets.enums import SecretKind from druks.secrets.models import VaultSecret -from druks.user_settings.models import SettingsOverride, SettingsProfile +from druks.user_settings.models import InstallationSettings, SettingsOverride from .base import Harness -from .exceptions import HarnessNotConnectedError, ProfileSettingsError +from .exceptions import AgentConfigError, HarnessNotConnectedError from .models import ProviderCatalog from .providers import get_provider, is_registered, provider_label from .registry import get_harness @dataclass(frozen=True) -class Profile: - """How an agent runs for one account: harness, model, effort, billing, read from - Settings → Agents at call time.""" +class AgentConfig: + """Shared execution settings with the account's selected credential.""" harness_class: type[Harness] model: str @@ -47,7 +46,7 @@ def model_id(self) -> str: @property def secrets_id(self) -> str: - """What a box created for this profile holds: the pasted key, or the + """What a box created for this config holds: the pasted key, or the subscriptions it fetches.""" if self.secrets: return f"{self.api_key.audience_name}.{self.api_key.updated_at:%Y%m%dT%H%M%S}" @@ -58,35 +57,33 @@ def charged_account_id(self) -> str | None: return self.subscription.account_id if self.subscription else None -async def check_profile(harness_name: str, model: str, billing: str) -> type[Harness]: +async def check_config(harness_name: str, model: str, billing: str) -> type[Harness]: """The harness that runs the triple; a triple no harness runs raises.""" harness = get_harness(harness_name) if not harness: - raise ProfileSettingsError(f"no installed harness is named {harness_name!r}.") + raise AgentConfigError(f"no installed harness is named {harness_name!r}.") provider_id = model.partition("/")[0] if is_registered(provider_id): provider = get_provider(provider_id) if not harness.has_provider(provider): - raise ProfileSettingsError(f"{harness_name} does not run {provider.label} models.") + raise AgentConfigError(f"{harness_name} does not run {provider.label} models.") else: catalog = await ProviderCatalog.get(provider_id) if not catalog: - raise ProfileSettingsError( + raise AgentConfigError( f"model {model!r} names no provider; add one in Settings → Providers." ) if harness.provider: - raise ProfileSettingsError(f"{harness_name} does not run {catalog.label} models.") + raise AgentConfigError(f"{harness_name} does not run {catalog.label} models.") if model not in {entry["id"] for entry in catalog.models}: - raise ProfileSettingsError(f"{catalog.label} lists no model {model!r}.") + raise AgentConfigError(f"{catalog.label} lists no model {model!r}.") if billing not in harness.billing_options: - raise ProfileSettingsError( - f"{harness_name} runs on an API key only; set billing to api_key." - ) + raise AgentConfigError(f"{harness_name} runs on an API key only; set billing to api_key.") return harness -async def get_profile(agent_name: str, account_id: str | None) -> Profile: - """Resolve an agent's profile for the supplied or default account. +async def get_config(agent_name: str, account_id: str | None) -> AgentConfig: + """Resolve shared execution settings and the supplied or default account's credential. A missing credential raises.""" from druks.apps.registry import agents # cycle: apps → agents → this module @@ -96,11 +93,11 @@ async def get_profile(agent_name: str, account_id: str | None) -> Profile: if not account_id: account = await Account.get_default() account_id = account.id if account else None - settings = await SettingsProfile.get(account_id) + settings = await InstallationSettings.get() harness_name = (await SettingsOverride.agent_harness(agent_name, settings=settings)).value model = (await SettingsOverride.agent_model(agent_name, settings=settings)).value billing = (await SettingsOverride.agent_billing(agent_name, settings=settings)).value - harness_class = await check_profile(harness_name, model, billing) + harness_class = await check_config(harness_name, model, billing) provider_id = model.partition("/")[0] subscription = None provider_key = None @@ -121,7 +118,7 @@ async def get_profile(agent_name: str, account_id: str | None) -> Profile: timeout = ( await SettingsOverride.agent_timeout(agent_name, agent.timeout, settings=settings) ).value - return Profile( + return AgentConfig( harness_class=harness_class, model=model, subscription=subscription, diff --git a/backend/druks/harnesses/exceptions.py b/backend/druks/harnesses/exceptions.py index 97df044f..5388766a 100644 --- a/backend/druks/harnesses/exceptions.py +++ b/backend/druks/harnesses/exceptions.py @@ -142,5 +142,5 @@ def __init__(self, tag: str) -> None: self.tag = tag -class ProfileSettingsError(HarnessError): +class AgentConfigError(HarnessError): """A (harness, model, billing) triple no installed harness runs; a 422.""" diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index a8133c78..cf1023d7 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -17,11 +17,11 @@ from druks.secrets.datastructures import Audience from druks.secrets.enums import SecretKind from druks.secrets.models import VaultSecret +from druks.settings import load_settings from druks.usage.models import UsageScrape from druks.usage.reads import list_finished_calls from druks.usage.schemas import UsageHistoryPoint from druks.usage.trends import FIVE_HOUR_RANGE, WEEK_RANGE, downsample -from druks.user_settings.models import SettingsProfile _TRANSCRIPT_TAIL_BYTES = 8 * 1024 _STDERR_TAIL_BYTES = 4 * 1024 @@ -107,7 +107,7 @@ async def _artifact_content(artifact: Artifact | None) -> schemas.ArtifactConten async def get_usage(account: Account) -> schemas.AgentUsageResponse: now = datetime.now(UTC) - timezone, local_start = operator_local_day((await SettingsProfile.get()).timezone, now) + timezone, local_start = operator_local_day(load_settings().timezone, now) rows = await list_finished_calls( account.id, since=local_start, until=local_start + timedelta(days=1) ) diff --git a/backend/druks/sandbox/host.py b/backend/druks/sandbox/host.py index b8f3e032..74a12847 100644 --- a/backend/druks/sandbox/host.py +++ b/backend/druks/sandbox/host.py @@ -38,7 +38,7 @@ if TYPE_CHECKING: from druks.harnesses.base import Harness - from druks.harnesses.profiles import Profile + from druks.harnesses.config import AgentConfig from .runner import Exec @@ -203,7 +203,7 @@ async def run_agent( self, *, agent: str, - profile: "Profile", + config: "AgentConfig", prompt: str, schema: dict[str, Any], artifact_dir: Path, @@ -214,7 +214,7 @@ async def run_agent( extra_env: dict[str, Any] | None = None, mcp_servers: tuple[McpServer, ...] = (), ) -> AgentResult: - """Run ``agent`` with ``profile`` and return a pure ``AgentResult`` — + """Run ``agent`` with ``config`` and return a pure ``AgentResult`` — no database write. A failure is carried on the result's ``error``, not raised, so the call still records what it cost before the agent call re-raises it. @@ -224,11 +224,11 @@ async def run_agent( ``include_plugins=False`` (Claude only) skips uploading the operator's plugin state — for prompts that hit no MCP server; a no-op for codex. """ - model, timeout = profile.model, profile.timeout - harness = profile.harness_class( + model, timeout = config.model, config.timeout + harness = config.harness_class( model=model, - fast_mode=profile.fast_mode, - effort=profile.effort, + fast_mode=config.fast_mode, + effort=config.effort, sandbox=SandboxSettings.maybe_from_settings(load_settings()), ) @@ -252,7 +252,7 @@ async def run_agent( extra_env=extra_env, mcp_servers=mcp_servers, call_id=run_id, - identity=profile.identity, + identity=config.identity, ) except HarnessError as exc: error = exc diff --git a/backend/druks/settings.py b/backend/druks/settings.py index 2b5b2bbf..97a5609d 100644 --- a/backend/druks/settings.py +++ b/backend/druks/settings.py @@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal import asyncssh -from pydantic import BaseModel, BeforeValidator, Field, model_validator +from pydantic import AfterValidator, BaseModel, BeforeValidator, Field, model_validator from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -13,6 +13,8 @@ ) from sqlalchemy_encrypted_field import validate_keys +from druks.core.utils.time import validate_timezone + DEFAULT_DATA_DIR = Path("/var/lib/druks") # The MCP default-server catalog Druks ships: an explicit empty ``mcpServers`` @@ -177,6 +179,7 @@ class Settings(BaseSettings): hide_input_in_errors=True, ) + timezone: Annotated[str, AfterValidator(validate_timezone)] = "UTC" identity: Identity = Identity() urls: Urls = Urls() secrets: Secrets diff --git a/backend/druks/setup_env.py b/backend/druks/setup_env.py index f74aa99a..a06ba2bc 100644 --- a/backend/druks/setup_env.py +++ b/backend/druks/setup_env.py @@ -11,6 +11,8 @@ import tomlkit +from druks.core.utils.time import validate_timezone + GAPS_EXIT_CODE = 3 _COMPOSE_ENV_KEYS = ( @@ -58,6 +60,7 @@ "DRUKS_SECRETS_PROXY_BIND_HOST", } ) +_KNOWN_TOP_LEVEL_KEYS = frozenset({"timezone"}) _KNOWN_TOML_KEYS = { "identity": ( "mode", @@ -162,6 +165,9 @@ def run_setup( # to render and apply it. `druks setup` alone re-renders .env but does # not restart services. +# Schedule timezone and initial timezone for new accounts. +timezone = "UTC" + # Browser identity: "header", "jwt", or "none". [identity] mode = "" @@ -287,6 +293,10 @@ def _canonical_config(raw: dict[str, Any]) -> dict[str, Any]: additions are welcome as flat scalars, one table deep; anything more structured is refused with its key named.""" config = copy.deepcopy(raw) + timezone = config.setdefault("timezone", "UTC") + if not isinstance(timezone, str): + raise ValueError("druks.toml: timezone must be a string") + validate_timezone(timezone) for table_name, keys in _KNOWN_TOML_KEYS.items(): table = config.setdefault(table_name, {}) if not isinstance(table, dict): @@ -361,7 +371,11 @@ def _set_value(target: MutableMapping[str, Any], path: tuple[str, ...], value: s def _parse_assignment(assignment: str) -> tuple[tuple[str, ...], str]: path_text, separator, value = assignment.partition("=") path = tuple(path_text.split(".")) - if not separator or len(path) < 2 or any(not part for part in path): + if ( + not separator + or (len(path) < 2 and path[0] not in _KNOWN_TOP_LEVEL_KEYS) + or any(not part for part in path) + ): raise ValueError(f"invalid --set {assignment!r}; expected key.path=value") return path, value diff --git a/backend/druks/usage/routes.py b/backend/druks/usage/routes.py index 230e729d..a9f17d7a 100644 --- a/backend/druks/usage/routes.py +++ b/backend/druks/usage/routes.py @@ -10,6 +10,7 @@ from druks.secrets.datastructures import Audience from druks.secrets.enums import SecretKind from druks.secrets.models import VaultSecret +from druks.settings import load_settings from druks.usage.models import UsageScrape from druks.usage.reads import list_finished_calls from druks.usage.schemas import ( @@ -24,7 +25,6 @@ UsageWindowHistory, ) from druks.usage.trends import FIVE_HOUR_RANGE, WEEK_RANGE, downsample -from druks.user_settings.models import SettingsProfile router = APIRouter() @@ -102,9 +102,7 @@ async def get_usage_history(account: Account = Depends(current_account)) -> Usag async def get_usage_today(account: Account = Depends(current_account)) -> UsageTodayResponse: # Deriving the operator-local-day window here (the query just takes it) keeps # this total identical to the sys-strip's and the agent surface's figures. - timezone, local_start = operator_local_day( - (await SettingsProfile.get()).timezone, datetime.now(UTC) - ) + timezone, local_start = operator_local_day(load_settings().timezone, datetime.now(UTC)) rows = await list_finished_calls( account.id, since=local_start, until=local_start + timedelta(days=1) ) diff --git a/backend/druks/user_settings/models.py b/backend/druks/user_settings/models.py index 2b54b55c..1a37be70 100644 --- a/backend/druks/user_settings/models.py +++ b/backend/druks/user_settings/models.py @@ -1,7 +1,7 @@ from datetime import datetime -from typing import Any, ClassVar +from typing import Any -from sqlalchemy import ForeignKey, UniqueConstraint, select +from sqlalchemy import CheckConstraint, ForeignKey, select from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Mapped, mapped_column @@ -21,84 +21,42 @@ from .datastructures import ResolvedChoice, ResolvedTimeout -class SettingsProfile(Base): - """Installation defaults or one account's complete personal profile.""" +class InstallationSettings(Base): + """Shared execution settings and the notification default for new accounts.""" __tablename__ = "settings" - __table_args__ = (UniqueConstraint("account_id", postgresql_nulls_not_distinct=True),) + __table_args__ = (CheckConstraint("id = 1", name="settings_singleton"),) - id: Mapped[int] = mapped_column(primary_key=True) - account_id: Mapped[str | None] = mapped_column( - ForeignKey("accounts.id", ondelete="CASCADE"), default=None - ) - timezone: Mapped[str] = mapped_column(String, default="UTC") + id: Mapped[int] = mapped_column(primary_key=True, default=1) default_harness: Mapped[str] = mapped_column(String, default=DEFAULT_HARNESS) default_model: Mapped[str] = mapped_column(String, default=DEFAULT_MODEL) default_billing: Mapped[str] = mapped_column(String, default=DEFAULT_BILLING) default_effort: Mapped[str] = mapped_column(String, default=DEFAULT_EFFORT) fast_mode: Mapped[bool] = mapped_column(default=False) default_timeout: Mapped[int] = mapped_column(default=DEFAULT_TIMEOUT) - # The designated gate-park notification destination; unset — or the - # destination deleted (SET NULL) — turns gate-park notifications off. gate_park_destination_id: Mapped[str | None] = mapped_column( ForeignKey("notification_destinations.id", ondelete="SET NULL"), default=None ) updated_at: Mapped[datetime] = mapped_column(default=Base.utc_now) - PROFILE_FIELDS: ClassVar[tuple[str, ...]] = ( - "timezone", - "default_harness", - "default_model", - "default_billing", - "default_effort", - "fast_mode", - "default_timeout", - "gate_park_destination_id", - ) - @classmethod - async def get(cls, account_id: str | None = None) -> "SettingsProfile": - """Read an account's profile, or the installation defaults until its first edit.""" + async def get(cls) -> "InstallationSettings": + """Read or create the installation settings.""" session = db_session() - query = select(cls).where(cls.account_id == account_id) + query = select(cls).where(cls.id == 1) if row := await session.scalar(query): return row - if account_id: - return await cls.get() await session.execute( - pg_insert(cls) - .values(account_id=None) - .on_conflict_do_nothing(index_elements=["account_id"]) + pg_insert(cls).values(id=1).on_conflict_do_nothing(index_elements=["id"]) ) return (await session.scalars(query)).one() - async def copy_for_account(self, account_id: str) -> "SettingsProfile": - """Create the first personal profile without replacing a concurrent edit.""" - values = {field: getattr(self, field) for field in self.PROFILE_FIELDS} - session = db_session() - await session.execute( - pg_insert(SettingsProfile) - .values(account_id=account_id, **values) - .on_conflict_do_nothing(index_elements=["account_id"]) - ) - return ( - await session.scalars( - select(SettingsProfile).where(SettingsProfile.account_id == account_id) - ) - ).one() - - async def update_profile(self, **fields: object) -> None: + async def update(self, **fields: object) -> None: for field, value in fields.items(): setattr(self, field, value) self.updated_at = Base.utc_now() await db_session().flush() - async def set_gate_park_destination(self, destination_id: str | None) -> None: - # None is the off-switch, so this is a set-or-clear, not a skip-on-None. - self.gate_park_destination_id = destination_id - self.updated_at = Base.utc_now() - await db_session().flush() - class SettingsOverride(Base): __tablename__ = "settings_overrides" @@ -126,7 +84,7 @@ async def write(cls, key: str, value: Any) -> None: await session.flush() @classmethod - async def agent_harness(cls, name: str, *, settings: SettingsProfile) -> ResolvedChoice: + async def agent_harness(cls, name: str, *, settings: InstallationSettings) -> ResolvedChoice: override = await cls.read(f"agent_harness:{name}") if override: return ResolvedChoice(override, "agent") @@ -137,7 +95,7 @@ async def set_agent_harness(cls, name: str, harness: str | None) -> None: await cls.write(f"agent_harness:{name}", harness) @classmethod - async def agent_model(cls, name: str, *, settings: SettingsProfile) -> ResolvedChoice: + async def agent_model(cls, name: str, *, settings: InstallationSettings) -> ResolvedChoice: override = await cls.read(f"agent_model:{name}") if override: return ResolvedChoice(override, "agent") @@ -148,7 +106,7 @@ async def set_agent_model(cls, name: str, model: str | None) -> None: await cls.write(f"agent_model:{name}", model) @classmethod - async def agent_billing(cls, name: str, *, settings: SettingsProfile) -> ResolvedChoice: + async def agent_billing(cls, name: str, *, settings: InstallationSettings) -> ResolvedChoice: override = await cls.read(f"agent_billing:{name}") if override: return ResolvedChoice(override, "agent") @@ -159,7 +117,7 @@ async def set_agent_billing(cls, name: str, billing: str | None) -> None: await cls.write(f"agent_billing:{name}", billing) @classmethod - async def agent_effort(cls, name: str, *, settings: SettingsProfile) -> ResolvedChoice: + async def agent_effort(cls, name: str, *, settings: InstallationSettings) -> ResolvedChoice: override = await cls.read(f"agent_effort:{name}") if override: return ResolvedChoice(override, "agent") @@ -171,7 +129,7 @@ async def set_agent_effort(cls, name: str, value: str | None) -> None: @classmethod async def agent_timeout( - cls, name: str, declared: int | None, *, settings: SettingsProfile + cls, name: str, declared: int | None, *, settings: InstallationSettings ) -> ResolvedTimeout: override = await cls.read(f"agent_timeout:{name}") if override: diff --git a/backend/druks/user_settings/reads.py b/backend/druks/user_settings/reads.py index fb7b0d32..0859419c 100644 --- a/backend/druks/user_settings/reads.py +++ b/backend/druks/user_settings/reads.py @@ -7,7 +7,7 @@ from druks.apps.settings import field_kind from druks.database import db_session -from .models import SettingsOverride, SettingsProfile +from .models import InstallationSettings, SettingsOverride from .schemas import ( AgentSettingResponse, AppSettingsResponse, @@ -21,7 +21,9 @@ from druks.workflows import Workflow -async def get_agent_setting(agent: "Agent", *, settings: SettingsProfile) -> AgentSettingResponse: +async def get_agent_setting( + agent: "Agent", *, settings: InstallationSettings +) -> AgentSettingResponse: harness = await SettingsOverride.agent_harness(agent.id, settings=settings) model = await SettingsOverride.agent_model(agent.id, settings=settings) billing = await SettingsOverride.agent_billing(agent.id, settings=settings) @@ -102,7 +104,9 @@ async def get_workflow_settings(workflow: "type[Workflow]") -> WorkflowSettingsR return WorkflowSettingsResponse(kind=kind, fields=fields) -async def get_app_settings(app: "type[App]", *, settings: SettingsProfile) -> AppSettingsResponse: +async def get_app_settings( + app: "type[App]", *, settings: InstallationSettings +) -> AppSettingsResponse: model = app.settings_model return AppSettingsResponse( name=app.name, diff --git a/backend/druks/user_settings/routes.py b/backend/druks/user_settings/routes.py index c6adffb0..a0f55407 100644 --- a/backend/druks/user_settings/routes.py +++ b/backend/druks/user_settings/routes.py @@ -1,37 +1,34 @@ -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select from druks.accounts.dependencies import current_account, current_session_account from druks.accounts.models import Account from druks.apps.loader import get_app, iter_apps from druks.apps.registry import agents, workflows -from druks.database import db_session from druks.durable.engine import apply_schedules from druks.harnesses.base import Harness -from druks.harnesses.exceptions import ProfileSettingsError -from druks.harnesses.profiles import check_profile +from druks.harnesses.config import check_config from druks.harnesses.registry import get_harnesses from druks.notifications.models import Destination from . import reads from .datastructures import ALLOWED_EFFORTS -from .models import SettingsOverride, SettingsProfile +from .models import InstallationSettings, SettingsOverride from .schemas import ( AgentsAppResponse, AgentsResponse, AppsSettingsResponse, AppsSettingsUpdate, HarnessResponse, + PersonalSettingsResponse, SettingsResponse, + UpdatePersonalSettingsRequest, UpdateSettingsRequest, ) router = APIRouter(prefix="/api/settings", tags=["settings"]) agents_router = APIRouter(prefix="/api/agents", tags=["settings"]) -_PROFILE_DEFAULTS = ( +_EXECUTION_DEFAULTS = ( "default_harness", "default_model", "default_billing", @@ -41,25 +38,14 @@ ) -def _validate_timezone(value: str) -> str: - try: - ZoneInfo(value) - except (ZoneInfoNotFoundError, ValueError) as exc: - raise HTTPException( - status_code=422, - detail=f"Unknown IANA timezone: {value!r}", - ) from exc - return value - - @router.get("/harnesses", response_model=list[HarnessResponse], response_model_by_alias=True) async def list_harnesses() -> tuple[type[Harness], ...]: return get_harnesses() @agents_router.get("", response_model=AgentsResponse, response_model_by_alias=True) -async def list_agents(account: Account = Depends(current_account)) -> AgentsResponse: - settings = await SettingsProfile.get(account.id) +async def list_agents() -> AgentsResponse: + settings = await InstallationSettings.get() projected = [ AgentsAppResponse( name=app.name, @@ -73,64 +59,63 @@ async def list_agents(account: Account = Depends(current_account)) -> AgentsResp @router.get("", response_model=SettingsResponse, response_model_by_alias=True) -async def get_settings() -> SettingsProfile: - return await SettingsProfile.get() +async def get_settings() -> InstallationSettings: + return await InstallationSettings.get() -@router.get("/personal", response_model=SettingsResponse, response_model_by_alias=True) -async def get_personal_settings(account: Account = Depends(current_account)) -> SettingsProfile: - return await SettingsProfile.get(account.id) +@router.get("/personal", response_model=PersonalSettingsResponse, response_model_by_alias=True) +async def get_personal_settings( + account: Account = Depends(current_account), +) -> Account: + return account -async def check_agent_profiles(settings: SettingsProfile) -> None: - await check_profile(settings.default_harness, settings.default_model, settings.default_billing) +async def check_agent_configs(settings: InstallationSettings) -> None: + await check_config(settings.default_harness, settings.default_model, settings.default_billing) for agent in agents.all(): - await check_profile( + await check_config( (await SettingsOverride.agent_harness(agent.id, settings=settings)).value, (await SettingsOverride.agent_model(agent.id, settings=settings)).value, (await SettingsOverride.agent_billing(agent.id, settings=settings)).value, ) -async def save_settings( - body: UpdateSettingsRequest, account_id: str | None = None -) -> SettingsProfile: +async def _settings_changes( + body: UpdateSettingsRequest | UpdatePersonalSettingsRequest, +) -> dict[str, object]: fields = body.model_dump(exclude_unset=True, exclude_none=True) - if "timezone" in fields: - fields["timezone"] = _validate_timezone(fields["timezone"]) if "gate_park_destination_id" in body.model_fields_set: destination_id = body.gate_park_destination_id if destination_id and not await Destination.get(destination_id): raise HTTPException(status_code=422, detail=f"Unknown destination {destination_id!r}") fields["gate_park_destination_id"] = destination_id - row = await SettingsProfile.get(account_id) - if fields: - if account_id and not row.account_id: - row = await row.copy_for_account(account_id) - timezone_changed = "timezone" in fields and fields["timezone"] != row.timezone - await row.update_profile(**fields) - if any(field in fields for field in _PROFILE_DEFAULTS): - await check_agent_profiles(row) - if not account_id and timezone_changed: - await apply_schedules() - return row + return fields @router.patch("", response_model=SettingsResponse, response_model_by_alias=True) -async def update_settings(body: UpdateSettingsRequest) -> SettingsProfile: - return await save_settings(body) +async def update_settings(body: UpdateSettingsRequest) -> InstallationSettings: + fields = await _settings_changes(body) + settings = await InstallationSettings.get() + if fields: + await settings.update(**fields) + if any(field in fields for field in _EXECUTION_DEFAULTS): + await check_agent_configs(settings) + return settings -@router.patch("/personal", response_model=SettingsResponse, response_model_by_alias=True) +@router.patch("/personal", response_model=PersonalSettingsResponse, response_model_by_alias=True) async def update_personal_settings( - body: UpdateSettingsRequest, account: Account = Depends(current_account) -) -> SettingsProfile: - return await save_settings(body, account.id) + body: UpdatePersonalSettingsRequest, account: Account = Depends(current_account) +) -> Account: + fields = await _settings_changes(body) + if fields: + await account.update_preferences(**fields) + return account @router.get("/apps", response_model=AppsSettingsResponse, response_model_by_alias=True) -async def get_app_settings(account: Account = Depends(current_account)) -> AppsSettingsResponse: - settings = await SettingsProfile.get(account.id) +async def get_app_settings() -> AppsSettingsResponse: + settings = await InstallationSettings.get() projected = [await reads.get_app_settings(m, settings=settings) for m in iter_apps()] return AppsSettingsResponse( allowed_efforts=list(ALLOWED_EFFORTS), @@ -142,10 +127,9 @@ async def get_app_settings(account: Account = Depends(current_account)) -> AppsS "/apps", response_model=AppsSettingsResponse, response_model_by_alias=True, + dependencies=[Depends(current_session_account)], ) -async def update_app_settings( - body: AppsSettingsUpdate, account: Account = Depends(current_session_account) -) -> AppsSettingsResponse: +async def update_app_settings(body: AppsSettingsUpdate) -> AppsSettingsResponse: for name, harness in body.agent_harnesses.items(): await SettingsOverride.set_agent_harness(name, harness) for name, model in body.agent_models.items(): @@ -158,16 +142,8 @@ async def update_app_settings( if name not in agents: raise HTTPException(status_code=422, detail=f"Unknown agent {name!r}") if body.agent_harnesses or body.agent_models or body.agent_billings: - installation = await SettingsProfile.get() - await check_agent_profiles(installation) - profiles = await db_session().execute( - select(SettingsProfile, Account.username).join(Account) - ) - for settings, username in profiles: - try: - await check_agent_profiles(settings) - except ProfileSettingsError as error: - raise ProfileSettingsError(f"Personal profile for {username}: {error}") from error + installation = await InstallationSettings.get() + await check_agent_configs(installation) for name, effort in body.agent_efforts.items(): await SettingsOverride.set_agent_effort(name, effort) @@ -213,4 +189,4 @@ async def update_app_settings( # the just-written overrides off this request's session. await apply_schedules() - return await get_app_settings(account) + return await get_app_settings() diff --git a/backend/druks/user_settings/schemas.py b/backend/druks/user_settings/schemas.py index c285cc4c..a029b4fc 100644 --- a/backend/druks/user_settings/schemas.py +++ b/backend/druks/user_settings/schemas.py @@ -1,7 +1,7 @@ from datetime import datetime -from typing import Any, Literal +from typing import Annotated, Any, Literal -from pydantic import BaseModel, ConfigDict, Field, PositiveInt +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, PositiveInt from pydantic.fields import FieldInfo from druks.apps.settings import ( @@ -12,6 +12,7 @@ field_visibility, validate_field_choice_details, ) +from druks.core.utils.time import validate_timezone from druks.harnesses.datastructures import Billing from druks.harnesses.schemas import SortedNames from druks.schemas import Schema @@ -27,35 +28,48 @@ class HarnessResponse(Schema): billing_options: SortedNames -class SettingsResponse(Schema): +class PersonalSettingsResponse(Schema): model_config = ConfigDict(from_attributes=True) - account_id: str | None timezone: str + gate_park_destination_id: str | None + + +class SettingsResponse(Schema): + model_config = ConfigDict(from_attributes=True) + + gate_park_destination_id: str | None + updated_at: datetime default_harness: str default_model: str default_billing: str default_effort: str fast_mode: bool default_timeout: int - gate_park_destination_id: str | None - updated_at: datetime + + +class UpdatePersonalSettingsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + timezone: Annotated[str, AfterValidator(validate_timezone)] | None = None + # Absent leaves the destination unchanged. Null turns notifications off. + gate_park_destination_id: str | None = Field( + default=None, validation_alias="gateParkDestinationId" + ) class UpdateSettingsRequest(BaseModel): model_config = ConfigDict(extra="forbid") - timezone: str | None = None + gate_park_destination_id: str | None = Field( + default=None, validation_alias="gateParkDestinationId" + ) default_harness: str | None = Field(default=None, validation_alias="defaultHarness") default_model: str | None = Field(default=None, validation_alias="defaultModel") default_billing: Billing | None = Field(default=None, validation_alias="defaultBilling") default_effort: Effort | None = Field(default=None, validation_alias="defaultEffort") fast_mode: bool | None = Field(default=None, validation_alias="fastMode") default_timeout: PositiveInt | None = Field(default=None, validation_alias="defaultTimeout") - # Tri-state: absent = unchanged, null = clear (off), value = designate. - gate_park_destination_id: str | None = Field( - default=None, validation_alias="gateParkDestinationId" - ) Source = Literal["agent", "default"] diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index cd680433..122edeff 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -58,7 +58,7 @@ from druks.sandbox.models import SandboxIdentity, SecretRef from druks.sandbox.templates import get_template_id from druks.signals import publish -from druks.user_settings.models import SettingsOverride, SettingsProfile +from druks.user_settings.models import SettingsOverride from druks.workspaces import Workspace # druks.workflows is the author door for workflow authoring: Workflow, Gate, @@ -86,7 +86,7 @@ ] if TYPE_CHECKING: - from druks.harnesses.profiles import Profile + from druks.harnesses.config import AgentConfig from druks.sandbox.host import Host # A human gate can park for days; a long recv TTL still caps zombie parks. @@ -373,7 +373,8 @@ async def _notify_designated_destination(workflow_id: str, subject: dict[str, An async def _create() -> str | None: async with step_session(): run = await Run.get(workflow_id) - destination_id = (await SettingsProfile.get(run.account_id)).gate_park_destination_id + account = await Account.get_for_run(run.account_id) + destination_id = account.gate_park_destination_id if destination_id: return await run.create_park_notification(destination_id, subject) @@ -834,18 +835,18 @@ async def get_workspace(self, host: "Host") -> Workspace: return self.workspace_class(**await self.get_workspace_kwargs(host)) async def get_secret_refs(self) -> list[SecretRef]: - # The secrets a box of this run fetches beyond its profile's: the + # The secrets a box of this run fetches beyond its config's: the # workspace's and its MCP servers', read before the box exists. subject = await self.subject _, mcp = await self.workspace_class.get_mcp_delivery(subject, self.account_id) return [*await self.workspace_class.get_secret_refs(subject), *mcp] - async def _lease_host(self, profile: "Profile") -> str | None: + async def _lease_host(self, config: "AgentConfig") -> str | None: # The warm VM, provisioned once per segment; state is carried in git, so # only the host-id matters across steps — held-across-steps never fights replay. if not self.steps_reuse_sandbox: return - refs = [*profile.secret_refs, *await self.get_secret_refs()] + refs = [*config.secret_refs, *await self.get_secret_refs()] # A crashed process left its box behind. Its identity finds it again. if ( not self._host @@ -853,7 +854,7 @@ async def _lease_host(self, profile: "Profile") -> str | None: and (identity := await SandboxIdentity.lookup(self._workflow_id, "workflow", refs)) ): self._host = await sandbox_client.reattach(host_id=identity.host_id) - self._host_secrets_id = profile.secrets_id + self._host_secrets_id = config.secrets_id if self._host and self._host.expires_at: remaining = (self._host.expires_at - datetime.now(UTC)).total_seconds() if remaining < SANDBOX_HOST_ROTATE_BEFORE_SECONDS: @@ -861,7 +862,7 @@ async def _lease_host(self, profile: "Profile") -> str | None: # host. Safe because each call rebuilds its workspace on whatever # host it lands on (state lives in git), so a bare VM is fine. await self._reap_run() - if self._host and self._host_secrets_id != profile.secrets_id: + if self._host and self._host_secrets_id != config.secrets_id: # Drukbox binds entries at creation. await self._reap_run() if not self._host: @@ -872,7 +873,7 @@ async def _lease_host(self, profile: "Profile") -> str | None: # The key names the pasted key the VM holds, so a replay finds its VM. # A box that fetches gets its own identity, and the key names that # instead. A replay finds the box through the identity, above. - identity, entries, key = None, {}, profile.secrets_id + identity, entries, key = None, {}, config.secrets_id if refs: identity, entries = await SandboxIdentity.create( run_id=self._workflow_id, scoped_to="workflow", secret_refs=refs @@ -880,11 +881,11 @@ async def _lease_host(self, profile: "Profile") -> str | None: key = identity.id self._host = await sandbox_client.provision( idempotency_key=provisioning_key(self._workflow_id, "workflow", key), - secrets={**profile.secrets, **entries}, + secrets={**config.secrets, **entries}, template=template, identity=identity, ) - self._host_secrets_id = profile.secrets_id + self._host_secrets_id = config.secrets_id return self._host.id async def _reap_run(self) -> None: diff --git a/backend/migrations/versions/74b53981122c_add_account_preferences.py b/backend/migrations/versions/74b53981122c_add_account_preferences.py new file mode 100644 index 00000000..68eb4d44 --- /dev/null +++ b/backend/migrations/versions/74b53981122c_add_account_preferences.py @@ -0,0 +1,30 @@ +"""Add account preference columns. + +Revision ID: 74b53981122c +Revises: a3c9e7f1b5d2 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "74b53981122c" +down_revision = "a3c9e7f1b5d2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("accounts", sa.Column("timezone", sa.String(), nullable=True)) + op.add_column("accounts", sa.Column("gate_park_destination_id", sa.String(), nullable=True)) + op.create_foreign_key( + "accounts_gate_park_destination_id_fkey", + "accounts", + "notification_destinations", + ["gate_park_destination_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + raise NotImplementedError("Account preference storage is forward-only.") diff --git a/backend/migrations/versions/7dc609a2d51a_move_preferences_to_accounts.py b/backend/migrations/versions/7dc609a2d51a_move_preferences_to_accounts.py new file mode 100644 index 00000000..c8bff9fc --- /dev/null +++ b/backend/migrations/versions/7dc609a2d51a_move_preferences_to_accounts.py @@ -0,0 +1,34 @@ +"""Move personal preferences out of execution settings. + +Revision ID: 7dc609a2d51a +Revises: 74b53981122c +""" + +from alembic import op + +revision = "7dc609a2d51a" +down_revision = "74b53981122c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + UPDATE accounts AS account + SET timezone = COALESCE( + (SELECT timezone FROM settings WHERE account_id = account.id), + (SELECT timezone FROM settings WHERE account_id IS NULL), + 'UTC' + ), + gate_park_destination_id = CASE + WHEN EXISTS (SELECT 1 FROM settings WHERE account_id = account.id) + THEN (SELECT gate_park_destination_id FROM settings WHERE account_id = account.id) + ELSE (SELECT gate_park_destination_id FROM settings WHERE account_id IS NULL) + END + """) + op.execute("DELETE FROM settings WHERE account_id IS NOT NULL") + op.execute("UPDATE settings SET id = 1") + + +def downgrade() -> None: + raise NotImplementedError("Personal execution defaults cannot be restored.") diff --git a/backend/migrations/versions/b43924bf37db_enforce_installation_settings_singleton.py b/backend/migrations/versions/b43924bf37db_enforce_installation_settings_singleton.py new file mode 100644 index 00000000..de0b69b7 --- /dev/null +++ b/backend/migrations/versions/b43924bf37db_enforce_installation_settings_singleton.py @@ -0,0 +1,24 @@ +"""Enforce one settings row for the installation. + +Revision ID: b43924bf37db +Revises: 7dc609a2d51a +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b43924bf37db" +down_revision = "7dc609a2d51a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("accounts", "timezone", existing_type=sa.String(), nullable=False) + op.drop_column("settings", "account_id") + op.drop_column("settings", "timezone") + op.create_check_constraint("settings_singleton", "settings", "id = 1") + + +def downgrade() -> None: + raise NotImplementedError("Installation settings separation is forward-only.") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 97a3bfc7..5adc7309 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -33,11 +33,11 @@ IDENTITY_HEADER = "X-ExeDev-Email" -class ProfileOutput(agents.AgentOutput): +class ConfigOutput(agents.AgentOutput): ok: bool -PROFILE_PROBE = agents.Agent(id="profile_probe", prompt="probe.md", contract=ProfileOutput) +CONFIG_PROBE = agents.Agent(id="config_probe", prompt="probe.md", contract=ConfigOutput) def settings_client(tmp_path: Path) -> TestClient: diff --git a/backend/tests/test_profiles.py b/backend/tests/test_agent_config.py similarity index 67% rename from backend/tests/test_profiles.py rename to backend/tests/test_agent_config.py index 12c3daf8..7c014079 100644 --- a/backend/tests/test_profiles.py +++ b/backend/tests/test_agent_config.py @@ -2,8 +2,8 @@ import pytest from conftest import ( - PROFILE_PROBE, - ProfileOutput, + CONFIG_PROBE, + ConfigOutput, connect_anthropic_subscription, connect_provider, make_jwt, @@ -17,10 +17,10 @@ from druks.durable.models import AgentCall from druks.harnesses.claude import ClaudeHarness from druks.harnesses.codex import CodexHarness -from druks.harnesses.exceptions import HarnessNotConnectedError, ProfileSettingsError +from druks.harnesses.config import check_config, get_config +from druks.harnesses.exceptions import AgentConfigError, HarnessNotConnectedError from druks.harnesses.models import ProviderCatalog from druks.harnesses.opencode import OpenCodeHarness -from druks.harnesses.profiles import check_profile, get_profile from druks.harnesses.providers import OpenAiProvider from druks.sandbox.constants import MAX_AGENT_TIMEOUT_SECONDS from druks.secrets.datastructures import Audience @@ -28,38 +28,34 @@ from druks.secrets.models import VaultSecret from druks.testing import seed_call, seed_run from druks.user_settings import reads -from druks.user_settings.models import SettingsOverride, SettingsProfile +from druks.user_settings.models import InstallationSettings, SettingsOverride from druks.workflows import WorkflowError, current_workflow from druks_field_notes.workflows import Summarize from sqlalchemy.exc import IntegrityError -DECLARED = agents.Agent( - id="profile_declared", prompt="probe.md", contract=ProfileOutput, timeout=900 -) +DECLARED = agents.Agent(id="config_declared", prompt="probe.md", contract=ConfigOutput, timeout=900) OVERSIZED = agents.Agent( - id="profile_oversized", + id="config_oversized", prompt="probe.md", - contract=ProfileOutput, + contract=ConfigOutput, timeout=MAX_AGENT_TIMEOUT_SECONDS * 2, ) async def test_check_judges_the_triple_together(druks_db): assert ( - await check_profile("claude", "anthropic/claude-opus-4-7", "subscription") is ClaudeHarness - ) - assert await check_profile("claude", "anthropic/claude-opus-4-7", "api_key") is ClaudeHarness - assert ( - await check_profile("opencode", "anthropic/claude-opus-4-7", "api_key") is OpenCodeHarness + await check_config("claude", "anthropic/claude-opus-4-7", "subscription") is ClaudeHarness ) - with pytest.raises(ProfileSettingsError, match="claude does not run OpenAI models"): - await check_profile("claude", "openai/gpt-5.5", "subscription") - with pytest.raises(ProfileSettingsError, match="opencode runs on an API key only"): - await check_profile("opencode", "anthropic/claude-opus-4-7", "subscription") - with pytest.raises(ProfileSettingsError, match="no installed harness is named 'grok'"): - await check_profile("grok", "anthropic/claude-opus-4-7", "subscription") - with pytest.raises(ProfileSettingsError, match="names no provider"): - await check_profile("claude", "claude-opus-4-7", "subscription") + assert await check_config("claude", "anthropic/claude-opus-4-7", "api_key") is ClaudeHarness + assert await check_config("opencode", "anthropic/claude-opus-4-7", "api_key") is OpenCodeHarness + with pytest.raises(AgentConfigError, match="claude does not run OpenAI models"): + await check_config("claude", "openai/gpt-5.5", "subscription") + with pytest.raises(AgentConfigError, match="opencode runs on an API key only"): + await check_config("opencode", "anthropic/claude-opus-4-7", "subscription") + with pytest.raises(AgentConfigError, match="no installed harness is named 'grok'"): + await check_config("grok", "anthropic/claude-opus-4-7", "subscription") + with pytest.raises(AgentConfigError, match="names no provider"): + await check_config("claude", "claude-opus-4-7", "subscription") async def _key() -> VaultSecret: @@ -87,7 +83,7 @@ async def test_call_keeps_its_billing_reference_after_disconnect(druks_db, billi call = await seed_call( druks_db, run, - PROFILE_PROBE.id, + CONFIG_PROBE.id, subscription_id=subscription.id if billing == "subscription" else None, api_key_id=key.id if billing == "api_key" else None, ) @@ -122,7 +118,7 @@ async def test_call_requires_exactly_one_billing_reference(druks_db, both): druks_db.add( AgentCall( run_id=run.id, - agent=PROFILE_PROBE.id, + agent=CONFIG_PROBE.id, model="anthropic/claude-opus-4-7", sandbox_host_id="test-host", subscription_id=subscription.id if both else None, @@ -136,8 +132,8 @@ async def test_a_subscription_agent_runs_as_its_actor_or_the_default_account(dru default_subscription = await connect_anthropic_subscription("a@example.com") actor = await connect_anthropic_subscription("b@example.com") - as_actor = await get_profile(PROFILE_PROBE.id, actor.account_id) - unattended = await get_profile(PROFILE_PROBE.id, None) + as_actor = await get_config(CONFIG_PROBE.id, actor.account_id) + unattended = await get_config(CONFIG_PROBE.id, None) assert as_actor.subscription.id == actor.id assert as_actor.charged_account_id == actor.account_id @@ -151,7 +147,7 @@ async def test_a_subscription_agent_runs_as_its_actor_or_the_default_account(dru assert (as_actor.effort, as_actor.timeout, as_actor.fast_mode) == ("high", 1800, False) -async def test_a_codex_subscription_profile_carries_its_login_facts_and_its_ref(druks_db): +async def test_a_codex_subscription_config_carries_its_login_facts_and_its_ref(druks_db): id_token = make_jwt( { "https://api.openai.com/auth": { @@ -174,18 +170,18 @@ async def test_a_codex_subscription_profile_carries_its_login_facts_and_its_ref( }, provider_email="a@example.com", ) - await SettingsOverride.set_agent_harness(PROFILE_PROBE.id, "codex") - await SettingsOverride.set_agent_model(PROFILE_PROBE.id, "openai/gpt-5.5") + await SettingsOverride.set_agent_harness(CONFIG_PROBE.id, "codex") + await SettingsOverride.set_agent_model(CONFIG_PROBE.id, "openai/gpt-5.5") - profile = await get_profile(PROFILE_PROBE.id, subscription.account_id) + config = await get_config(CONFIG_PROBE.id, subscription.account_id) - assert profile.harness_class is CodexHarness + assert config.harness_class is CodexHarness # The facts the box's login names come from the row and its id token; the # tokens stay on the server. - assert profile.identity == {"email": "a@example.com", "account_id": "acc-1", "plan": "pro"} - [ref] = profile.secret_refs + assert config.identity == {"email": "a@example.com", "account_id": "acc-1", "plan": "pro"} + [ref] = config.secret_refs assert ref.key == ("codex_subscription_token", subscription.id, "", "chatgpt.com") - assert profile.secrets_id == subscription.id + assert config.secrets_id == subscription.id async def test_a_subscription_agent_refuses_without_the_actors_own_subscription(druks_db): @@ -195,16 +191,16 @@ async def test_a_subscription_agent_refuses_without_the_actors_own_subscription( # A missing personal subscription cannot borrow another credential. with pytest.raises(HarnessNotConnectedError, match="connect your Anthropic subscription"): - await get_profile(PROFILE_PROBE.id, stranger.id) + await get_config(CONFIG_PROBE.id, stranger.id) async def test_a_key_agent_runs_on_the_installations_key_for_anyone(druks_db): actor = await connect_anthropic_subscription("a@example.com") pasted = await _key() - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "api_key") + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "api_key") - as_actor = await get_profile(PROFILE_PROBE.id, actor.account_id) - unattended = await get_profile(PROFILE_PROBE.id, None) + as_actor = await get_config(CONFIG_PROBE.id, actor.account_id) + unattended = await get_config(CONFIG_PROBE.id, None) # Claude reads the key from a placeholder in the VM, never from its invocation. assert (as_actor.secrets, as_actor.subscription) == ({"anthropic": _SHARED_ENTRY}, None) @@ -218,10 +214,10 @@ async def test_a_key_agent_runs_on_the_installations_key_for_anyone(druks_db): async def test_a_key_agent_refuses_without_the_key(druks_db): actor = await connect_anthropic_subscription("a@example.com") - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "api_key") + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "api_key") with pytest.raises(HarnessNotConnectedError, match="add the Anthropic API key"): - await get_profile(PROFILE_PROBE.id, actor.account_id) + await get_config(CONFIG_PROBE.id, actor.account_id) async def test_an_added_provider_refuses_until_its_transport_is_proven(druks_db): @@ -236,67 +232,67 @@ async def test_an_added_provider_refuses_until_its_transport_is_proven(druks_db) "sk-openrouter", pasted_by=await Account.get_or_create("ops@example.com"), ) - await SettingsOverride.set_agent_harness(PROFILE_PROBE.id, "opencode") - await SettingsOverride.set_agent_model(PROFILE_PROBE.id, "openrouter/anthropic/claude-sonnet-4") - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "api_key") + await SettingsOverride.set_agent_harness(CONFIG_PROBE.id, "opencode") + await SettingsOverride.set_agent_model(CONFIG_PROBE.id, "openrouter/anthropic/claude-sonnet-4") + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "api_key") - with pytest.raises(ProfileSettingsError, match="'openrouter'"): - await get_profile(PROFILE_PROBE.id, None) + with pytest.raises(AgentConfigError, match="'openrouter'"): + await get_config(CONFIG_PROBE.id, None) async def test_an_added_provider_without_a_key_names_it(druks_db): await Account.get_or_create("ops@example.com") await ProviderCatalog.create("groq", [{"id": "groq/llama-4", "label": "Llama 4"}], label="Groq") - await SettingsOverride.set_agent_harness(PROFILE_PROBE.id, "opencode") - await SettingsOverride.set_agent_model(PROFILE_PROBE.id, "groq/llama-4") - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "api_key") + await SettingsOverride.set_agent_harness(CONFIG_PROBE.id, "opencode") + await SettingsOverride.set_agent_model(CONFIG_PROBE.id, "groq/llama-4") + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "api_key") with pytest.raises(HarnessNotConnectedError, match="add the Groq API key in Settings"): - await get_profile(PROFILE_PROBE.id, None) + await get_config(CONFIG_PROBE.id, None) async def test_an_added_provider_runs_only_on_an_unbound_cli_and_its_own_models(druks_db): await Account.get_or_create("ops@example.com") await ProviderCatalog.create("groq", [{"id": "groq/llama-4", "label": "Llama 4"}], label="Groq") - with pytest.raises(ProfileSettingsError, match="claude does not run Groq models"): - await check_profile("claude", "groq/llama-4", "api_key") - with pytest.raises(ProfileSettingsError, match="Groq lists no model 'groq/llama-9'"): - await check_profile("opencode", "groq/llama-9", "api_key") - with pytest.raises(ProfileSettingsError, match="names no provider; add one"): - await check_profile("opencode", "nobody/model", "api_key") - assert await check_profile("opencode", "groq/llama-4", "api_key") is OpenCodeHarness + with pytest.raises(AgentConfigError, match="claude does not run Groq models"): + await check_config("claude", "groq/llama-4", "api_key") + with pytest.raises(AgentConfigError, match="Groq lists no model 'groq/llama-9'"): + await check_config("opencode", "groq/llama-9", "api_key") + with pytest.raises(AgentConfigError, match="names no provider; add one"): + await check_config("opencode", "nobody/model", "api_key") + assert await check_config("opencode", "groq/llama-4", "api_key") is OpenCodeHarness async def test_a_key_only_harness_bills_the_key(druks_db): await connect_anthropic_subscription("a@example.com") await _key() - await SettingsOverride.set_agent_harness(PROFILE_PROBE.id, "opencode") - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "api_key") + await SettingsOverride.set_agent_harness(CONFIG_PROBE.id, "opencode") + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "api_key") - profile = await get_profile(PROFILE_PROBE.id, None) + config = await get_config(CONFIG_PROBE.id, None) - assert profile.harness_class is OpenCodeHarness - assert profile.secrets == {"anthropic": _SHARED_ENTRY} + assert config.harness_class is OpenCodeHarness + assert config.secrets == {"anthropic": _SHARED_ENTRY} async def test_a_stored_triple_no_harness_runs_refuses(druks_db): await connect_anthropic_subscription("a@example.com") - await SettingsOverride.set_agent_harness(PROFILE_PROBE.id, "opencode") + await SettingsOverride.set_agent_harness(CONFIG_PROBE.id, "opencode") - with pytest.raises(ProfileSettingsError, match="opencode runs on an API key only"): - await get_profile(PROFILE_PROBE.id, None) + with pytest.raises(AgentConfigError, match="opencode runs on an API key only"): + await get_config(CONFIG_PROBE.id, None) async def test_effort_timeout_and_fast_mode_follow_the_defaults_and_overrides(druks_db): await connect_anthropic_subscription("a@example.com") - settings = await SettingsProfile.get() - await settings.update_profile(default_effort="low", default_timeout=600, fast_mode=True) + settings = await InstallationSettings.get() + await settings.update(default_effort="low", default_timeout=600, fast_mode=True) await SettingsOverride.set_agent_effort(DECLARED.id, "medium") - probe = await get_profile(PROFILE_PROBE.id, None) - declared = await get_profile(DECLARED.id, None) - oversized = await get_profile(OVERSIZED.id, None) + probe = await get_config(CONFIG_PROBE.id, None) + declared = await get_config(DECLARED.id, None) + oversized = await get_config(OVERSIZED.id, None) assert (probe.effort, probe.timeout, probe.fast_mode) == ("low", 600, True) assert (declared.effort, declared.timeout) == ("medium", 900) @@ -304,17 +300,17 @@ async def test_effort_timeout_and_fast_mode_follow_the_defaults_and_overrides(dr assert oversized.timeout == MAX_AGENT_TIMEOUT_SECONDS -async def test_an_agent_reads_its_own_profile(druks_db): +async def test_an_agent_reads_its_own_config(druks_db): await connect_anthropic_subscription("a@example.com") await _key() token = current_workflow.set(SimpleNamespace(account_id=None)) - subscribed = await PROFILE_PROBE.get_profile() - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "api_key") - keyed = await PROFILE_PROBE.get_profile() + subscribed = await CONFIG_PROBE.get_config() + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "api_key") + keyed = await CONFIG_PROBE.get_config() current_workflow.reset(token) with pytest.raises(WorkflowError, match="only inside a workflow"): - await PROFILE_PROBE.get_profile() + await CONFIG_PROBE.get_config() assert (subscribed.harness, subscribed.model_id) == ("claude", "claude-opus-4-7") assert (subscribed.billing, subscribed.secrets) == ("subscription", {}) @@ -323,28 +319,28 @@ async def test_an_agent_reads_its_own_profile(druks_db): async def test_an_unregistered_agent_is_named(druks_db): with pytest.raises(KeyError, match="no agent is registered as 'ghost'"): - await get_profile("ghost", None) + await get_config("ghost", None) async def test_two_apps_declare_the_same_agent_name(druks_db): class Ticketing(App): name = "ticketing" - file_tickets = agents.Agent(prompt="probe.md", contract=ProfileOutput) + file_tickets = agents.Agent(prompt="probe.md", contract=ConfigOutput) class BugHunter(App): name = "bug_hunter" file_tickets = agents.Agent( - name="File tickets", prompt="probe.md", contract=ProfileOutput, timeout=300 + name="File tickets", prompt="probe.md", contract=ConfigOutput, timeout=300 ) try: await connect_anthropic_subscription("a@example.com") await SettingsOverride.set_agent_effort(BugHunter.file_tickets.id, "low") declared = (Ticketing.agents(), BugHunter.agents()) - ticketing = await get_profile(Ticketing.file_tickets.id, None) - bug_hunter = await get_profile(BugHunter.file_tickets.id, None) + ticketing = await get_config(Ticketing.file_tickets.id, None) + bug_hunter = await get_config(BugHunter.file_tickets.id, None) settings = [ - await reads.get_agent_setting(agent, settings=await SettingsProfile.get()) + await reads.get_agent_setting(agent, settings=await InstallationSettings.get()) for agent in (Ticketing.file_tickets, BugHunter.file_tickets) ] finally: diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index 2db550fd..379d252a 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -172,7 +172,7 @@ async def test_declaration_drives_run_agent_call(druks_db, tmp_path, monkeypatch assert result == DummyOutput(ok=True) kwargs = sandbox.run_agent.await_args.kwargs assert kwargs["agent"] == "dummy" - assert kwargs["profile"].subscription.account_id == current_run.account_id + assert kwargs["config"].subscription.account_id == current_run.account_id assert kwargs["schema"] == DummyOutput.model_json_schema() assert kwargs["prompt"] == "PROMPT:dummy/agent.md:repo=acme/widget" assert kwargs["artifact_dir"] == tmp_path / "run-wf-9" @@ -255,10 +255,10 @@ async def test_running_call_visible_then_finished( await SettingsOverride.set_agent_billing(DUMMY_AGENT.id, billing) during: dict[str, object] = {} - async def _run_agent(*, call_id, profile, **_kwargs): + async def _run_agent(*, call_id, config, **_kwargs): row = await AgentCall.get(call_id) - assert row.subscription_id == (profile.subscription.id if profile.subscription else None) - assert row.api_key_id == (profile.api_key.id if profile.api_key else None) + assert row.subscription_id == (config.subscription.id if config.subscription else None) + assert row.api_key_id == (config.api_key.id if config.api_key else None) during["status"] = row.status during["host"] = row.sandbox_host_id return make_agent_result({"ok": True}, agent="dummy") @@ -816,10 +816,10 @@ async def fake_provision(self, *, idempotency_key=None, **_kwargs): monkeypatch.setattr("druks.sandbox.client.Client.provision", fake_provision) - profile = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="") + config = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="") with pytest.raises(HarnessSandboxProvisioningError): - await current_run._lease_host(profile) - host_id = await current_run._lease_host(profile) + await current_run._lease_host(config) + host_id = await current_run._lease_host(config) assert host_id == "warm-host" assert keys == ["wf-9:workflow", "wf-9:workflow"] @@ -857,7 +857,7 @@ async def test_api_key_billing_hands_claude_a_placeholder( druks_db, tmp_path, monkeypatch, current_run ): """Under api_key billing the VM is created with the key as a Drukbox entry. The - profile the sandbox runs carries no key, and the durable call records none.""" + config the sandbox runs carries no key, and the durable call records none.""" import json from drukbox_sdk import Secret @@ -892,8 +892,8 @@ async def fake_ephemeral(self, *, idempotency_key, secrets, **_kwargs): ] # The VM's key names the pasted key, never its value. assert keys == [f"wf-9:dummy:anthropic.{pasted.updated_at:%Y%m%dT%H%M%S}"] - profile = sandbox.run_agent.await_args.kwargs["profile"] - assert (profile.billing, profile.subscription) == ("api_key", None) + config = sandbox.run_agent.await_args.kwargs["config"] + assert (config.billing, config.subscription) == ("api_key", None) [call] = await AgentCall.list_for_run("wf-9") assert (call.subscription_id, call.api_key.audience_name) == (None, "anthropic") row = {column.key: getattr(call, column.key) for column in AgentCall.__table__.columns} diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py index 7d7ec80b..323c1be5 100644 --- a/backend/tests/test_api_settings.py +++ b/backend/tests/test_api_settings.py @@ -10,13 +10,13 @@ from sqlalchemy import text -def test_get_settings_returns_default_utc_when_no_row_exists(tmp_path: Path): +def test_get_settings_returns_shared_defaults(tmp_path: Path): with settings_client(tmp_path) as client: response = client.get("/api/settings") assert response.status_code == 200 body = response.json() - assert body["timezone"] == "UTC" + assert "timezone" not in body assert "updatedAt" in body @@ -44,7 +44,7 @@ def test_get_settings_carries_the_execution_defaults(tmp_path: Path): False, 1800, ) - assert body["accountId"] is None + assert "accountId" not in body def test_patch_settings_judges_the_default_triple_together(tmp_path: Path): @@ -80,47 +80,12 @@ async def test_accounts_report_the_default_without_a_fallback_setting(tmp_path: ) -def test_patch_settings_persists_valid_iana_zone(tmp_path: Path, monkeypatch): - async def _noop_schedules(): - return - - monkeypatch.setattr("druks.user_settings.routes.apply_schedules", _noop_schedules) - with settings_client(tmp_path) as client: - patch = client.patch("/api/settings", json={"timezone": "Europe/Madrid"}) - assert patch.status_code == 200 - assert patch.json()["timezone"] == "Europe/Madrid" - - get = client.get("/api/settings") - assert get.status_code == 200 - assert get.json()["timezone"] == "Europe/Madrid" - - -def test_patch_settings_rejects_invalid_timezone(tmp_path: Path): - with settings_client(tmp_path) as client: - response = client.patch("/api/settings", json={"timezone": "Not/A/Zone"}) - - assert response.status_code == 422 - body = response.json() - assert "Not/A/Zone" in body["detail"] - - -def test_timezone_change_reconciles_schedules(tmp_path: Path, monkeypatch): - """Crons are evaluated in the operator's timezone, so changing it repoints - the DBOS schedules now; re-asserting the same zone doesn't churn them.""" - reconciled = [] - - async def record(): - reconciled.append(True) - - monkeypatch.setattr("druks.user_settings.routes.apply_schedules", record) +def test_installation_timezone_cannot_be_changed_through_settings(tmp_path: Path): with settings_client(tmp_path) as client: - patch = client.patch("/api/settings", json={"timezone": "Europe/Madrid"}) - assert patch.status_code == 200 - assert len(reconciled) == 1 - - patch = client.patch("/api/settings", json={"timezone": "Europe/Madrid"}) - assert patch.status_code == 200 - assert len(reconciled) == 1 + assert client.patch("/api/settings", json={"timezone": "Europe/Madrid"}).status_code == 422 + response = client.patch("/api/settings/personal", json={"timezone": "Europe/Madrid"}) + assert response.status_code == 200 + assert client.get("/api/settings/personal").json()["timezone"] == "Europe/Madrid" def test_patch_settings_updates_the_defaults_every_agent_inherits(tmp_path: Path): diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index a3797316..892a1b3a 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -6,7 +6,7 @@ from druks.durable.dbos_state import workflow_status from druks.durable.models import Artifact, Run from druks.testing import configure_app_for_test, make_settings, seed_call, seed_run -from druks.user_settings.models import SettingsOverride, SettingsProfile +from druks.user_settings.models import SettingsOverride from druks_field_notes.models import Note from druks_field_notes.workflows import Summarize from fastapi.testclient import TestClient @@ -120,7 +120,10 @@ async def test_schedules_resolve_paused_override_and_operator_timezone( monkeypatch.setattr(Summarize, "every", "0 9 * * *") await SettingsOverride.set_workflow_setting(Summarize.kind, "schedule", "15 10 * * 1") await SettingsOverride.set_workflow_setting(Summarize.kind, "schedule_enabled", False) - await (await SettingsProfile.get()).update_profile(timezone="Europe/Madrid") + from druks.api import dashboard + + settings = dashboard.load_settings().model_copy(update={"timezone": "Europe/Madrid"}) + monkeypatch.setattr(dashboard, "load_settings", lambda: settings) response = client.get("/api/dashboard/schedules") diff --git a/backend/tests/test_declared_sandboxes.py b/backend/tests/test_declared_sandboxes.py index 41cc292c..b48e05d5 100644 --- a/backend/tests/test_declared_sandboxes.py +++ b/backend/tests/test_declared_sandboxes.py @@ -279,8 +279,8 @@ async def ephemeral(**kwargs): ) monkeypatch.setattr(agent_module, "get_template_id", resolve) - profile = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="") - async with agent_module._runner(workflow, None, "run-1", "summarize", profile) as runner: + config = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="") + async with agent_module._runner(workflow, None, "run-1", "summarize", config) as runner: assert runner == "workspace" resolve.assert_awaited_once_with(sandbox) diff --git a/backend/tests/test_durable_sdk.py b/backend/tests/test_durable_sdk.py index 7ae926ef..b9798cee 100644 --- a/backend/tests/test_durable_sdk.py +++ b/backend/tests/test_durable_sdk.py @@ -13,6 +13,7 @@ from druks.durable.engine import configure_engine, init_dbos, launch, shutdown from druks.models import StoredSubject from druks.testing import init_db +from druks.user_settings.models import InstallationSettings from druks.workflows import Gate, Subject, Workflow, step, task from pydantic import BaseModel from sqlalchemy import NullPool, create_engine, select @@ -269,7 +270,6 @@ async def rt(): from druks.secrets.datastructures import Audience from druks.secrets.enums import SecretKind from druks.secrets.models import VaultSecret - from druks.user_settings.models import SettingsProfile session = get_session(engine) try: @@ -289,7 +289,6 @@ async def rt(): secrets={"claudeAiOauth": {"accessToken": "t"}}, ) ) - session.add(SettingsProfile()) await session.commit() finally: await session.close() @@ -397,6 +396,14 @@ async def _account_id(engine, email: str) -> str: await session.close() +async def test_launch_commits_installation_settings_before_serving(rt): + # This session must see the row committed by launch(), before any settings request. + async with get_session(rt.engine) as session: + settings = await session.get(InstallationSettings, 1) + assert settings is not None + assert settings.default_harness == "claude" + + async def test_attribution_rides_the_run_and_survives_resume(rt): """start(account_id=…) lands on the durable_runs row and the reserved input key; attributes stay subject-only; a resume keeps the trigger account.""" @@ -946,31 +953,11 @@ async def test_session_scope_commits_writes(rt): await session.close() -async def test_launch_commits_the_user_settings_seed(rt): - # launch()'s reconcile touches the settings singleton (apply_schedules - # reads its timezone), and the row must land committed before the app - # serves: two requests racing the first-touch insert wait on its key lock - # synchronously on the event loop and deadlock the whole process. - from druks.user_settings.models import SettingsProfile - - session = get_session(rt.engine) - try: - assert ( - await session.scalar( - select(SettingsProfile).where(SettingsProfile.account_id.is_(None)) - ) - is not None - ) - finally: - await session.close() - - -async def test_apply_schedules_evaluates_cron_in_operator_timezone(rt): +async def test_apply_schedules_evaluates_cron_in_installation_timezone(rt, monkeypatch): # The cron is stored verbatim and evaluated in the operator's timezone, so # "daily at midnight" is their midnight and stays honest across DST. from dbos import DBOS from druks.durable.engine import apply_schedules - from druks.user_settings.models import SettingsProfile def sweep_timezone(): rows = {s["schedule_name"]: s["cron_timezone"] for s in DBOS.list_schedules()} @@ -979,12 +966,10 @@ def sweep_timezone(): await apply_schedules() assert sweep_timezone() == "UTC" # the settings default - # Commit the write — a bare test-task session stays idle-in-transaction and - # its row lock deadlocks any later test that touches user_settings. - from druks.database import session_scope + from druks.durable import engine - async with session_scope(rt.engine): - await (await SettingsProfile.get()).update_profile(timezone="Europe/Madrid") + settings = engine.load_settings().model_copy(update={"timezone": "Europe/Madrid"}) + monkeypatch.setattr(engine, "load_settings", lambda: settings) await apply_schedules() assert sweep_timezone() == "Europe/Madrid" @@ -993,15 +978,15 @@ async def test_user_settings_get_recreates_the_singleton(rt): # get() is the first-touch creator; its ON CONFLICT insert lets two # processes booting one fresh database both call it safely. from druks.database import db_session, session_scope - from druks.user_settings.models import SettingsProfile + from druks.user_settings.models import InstallationSettings from sqlalchemy import delete async with session_scope(rt.engine): - await db_session().execute(delete(SettingsProfile)) + await db_session().execute(delete(InstallationSettings)) async with session_scope(rt.engine): - assert (await SettingsProfile.get()).timezone == "UTC" + assert (await InstallationSettings.get()).default_harness == "claude" async with session_scope(rt.engine): - assert (await SettingsProfile.get()).account_id is None + assert (await InstallationSettings.get()).id == 1 async def test_a_run_hydrates_the_subject_row_it_was_started_for(rt): diff --git a/backend/tests/test_harness_auth.py b/backend/tests/test_harness_auth.py index 68d62732..1b27e210 100644 --- a/backend/tests/test_harness_auth.py +++ b/backend/tests/test_harness_auth.py @@ -12,7 +12,7 @@ from druks.harnesses.claude import ClaudeHarness, _get_credentials from druks.harnesses.codex import CodexHarness from druks.harnesses.datastructures import SandboxSettings -from druks.harnesses.exceptions import HarnessNotConnectedError, ProfileSettingsError +from druks.harnesses.exceptions import AgentConfigError, HarnessNotConnectedError from druks.harnesses.opencode import OpenCodeHarness from druks.harnesses.pi import PiHarness from druks.harnesses.providers import AnthropicProvider, OpenAiProvider, jwt_claims @@ -309,7 +309,7 @@ def test_key_entries_follow_the_proven_transport_of_each_harness(): def test_an_unproven_provider_key_refuses_instead_of_entering_the_box(): - with pytest.raises(ProfileSettingsError, match="'openrouter'"): + with pytest.raises(AgentConfigError, match="'openrouter'"): OpenCodeHarness.get_secrets("openrouter", "sk-1") diff --git a/backend/tests/test_migration_agents_resolve_on_user_settings.py b/backend/tests/test_migration_agents_resolve_on_user_settings.py index 5afdb35f..f4d73da0 100644 --- a/backend/tests/test_migration_agents_resolve_on_user_settings.py +++ b/backend/tests/test_migration_agents_resolve_on_user_settings.py @@ -26,6 +26,7 @@ def _upgrade(connection) -> None: async def test_the_defaults_come_from_the_first_harness_row(druks_db): for statement in ( "ALTER TABLE settings RENAME TO user_settings", + "ALTER TABLE user_settings ADD COLUMN timezone varchar NOT NULL DEFAULT 'UTC'", "ALTER TABLE user_settings DROP COLUMN default_harness, DROP COLUMN default_billing, " "DROP COLUMN default_effort, DROP COLUMN fast_mode, DROP COLUMN default_timeout", "CREATE TABLE harnesses (name varchar PRIMARY KEY, fast_mode boolean NOT NULL, " diff --git a/backend/tests/test_notifications_durable.py b/backend/tests/test_notifications_durable.py index 5618ace6..3fd59566 100644 --- a/backend/tests/test_notifications_durable.py +++ b/backend/tests/test_notifications_durable.py @@ -17,7 +17,6 @@ from druks.notifications.outbox import notifications_queue, send_notification from druks.notifications.services import respond_to_notification from druks.testing import configure_app_for_test, init_db, make_settings -from druks.user_settings.models import SettingsProfile from druks.workflows import Gate, OperatorReply, Run, Workflow from fastapi.testclient import TestClient from pydantic import BaseModel, Field @@ -398,7 +397,8 @@ async def _set_gate_park_pointer(rt, destination_id): session = get_session(rt.engine) db_session.registry.set(session) try: - await (await SettingsProfile.get()).set_gate_park_destination(destination_id) + account = await Account.get_default() + await account.update_preferences(gate_park_destination_id=destination_id) await session.commit() finally: await db_session.remove() @@ -531,9 +531,7 @@ async def delete_destination(): # ON DELETE SET NULL cleared the pointer itself. session = get_session(rt.engine) try: - settings = await session.scalar( - select(SettingsProfile).where(SettingsProfile.account_id.is_(None)) - ) + settings = await session.scalar(select(Account).where(Account.is_default)) assert settings.gate_park_destination_id is None finally: await session.close() @@ -732,22 +730,22 @@ async def test_concurrent_responds_resolve_to_one_answer(rt, deliver_spy): @pytest.mark.parametrize("unattended", [True, False]) -async def test_gate_notifications_use_the_selected_personal_profile(rt, deliver_spy, unattended): +async def test_gate_notifications_use_the_selected_personal_preferences( + rt, deliver_spy, unattended +): destination = await _seed_destination(rt, f"personal-{unattended}") - async def configure_profile(): + async def configure_preferences(): await Account.get_or_create("default@example.com") default = await Account.get_default() explicit = await Account.get_or_create("explicit@example.com") account = default if unattended else explicit - installation = await SettingsProfile.get() - personal = await installation.copy_for_account(account.id) - await personal.set_gate_park_destination(destination.id) + await account.update_preferences(gate_park_destination_id=destination.id) subject = NotificationProbe(id=9020 if unattended else 9021) db_session.add(subject) return account.id, subject - account_id, subject = await _seed(rt, configure_profile) + account_id, subject = await _seed(rt, configure_preferences) workflow_id = await rt.ExternalFlow.start( subject=subject, account_id=None if unattended else account_id ) diff --git a/backend/tests/test_personal_settings.py b/backend/tests/test_personal_settings.py index 83d046bd..f13081a5 100644 --- a/backend/tests/test_personal_settings.py +++ b/backend/tests/test_personal_settings.py @@ -1,7 +1,12 @@ +import importlib.util +from pathlib import Path + import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations from conftest import ( + CONFIG_PROBE, IDENTITY_HEADER, - PROFILE_PROBE, connect_anthropic_subscription, header_client, settings_client, @@ -10,60 +15,162 @@ from druks.accounts.models import Account from druks.database import db_session from druks.durable.models import Run +from druks.harnesses.config import get_config from druks.harnesses.exceptions import HarnessNotConnectedError -from druks.harnesses.profiles import get_profile +from druks.notifications.models import Destination from druks.secrets.datastructures import Audience from druks.secrets.models import VaultSecret -from druks.user_settings.models import SettingsOverride, SettingsProfile +from druks.testing import make_settings +from druks.user_settings.models import InstallationSettings, SettingsOverride from druks.workflows import _run_instance from druks_field_notes.models import Note from druks_field_notes.workflows import Summarize -from sqlalchemy import func, select +from sqlalchemy import func, select, text from sqlalchemy.exc import IntegrityError -async def test_personal_reads_inherit_without_creating_a_row(druks_db): - account = await Account.get_or_create("alice@example.com") - installation = await SettingsProfile.get() - await installation.update_profile(timezone="Europe/Madrid", default_effort="low") - - personal = await SettingsProfile.get(account.id) - - assert personal.account_id is None - assert personal.timezone == "Europe/Madrid" - assert await druks_db.scalar(select(func.count()).select_from(SettingsProfile)) == 1 +async def test_account_creation_copies_defaults_once(tmp_path, druks_db, monkeypatch): + destination = await Destination.create( + name="New account gates", kind="slack_webhook", url="https://example.invalid/hook" + ) + installation = await InstallationSettings.get() + await installation.update(gate_park_destination_id=destination.id) + monkeypatch.setattr( + "druks.accounts.models.load_settings", + lambda: make_settings(tmp_path, timezone="Europe/Madrid"), + ) + alice = await Account.get_or_create("alice@example.com") + assert (alice.timezone, alice.gate_park_destination_id) == ("Europe/Madrid", destination.id) + await alice.update_preferences(timezone="America/New_York", gate_park_destination_id=None) + await installation.update(default_effort="low") + existing = await Account.get_or_create("alice@example.com") + assert existing.id == alice.id + assert (existing.timezone, existing.gate_park_destination_id) == ("America/New_York", None) + with header_client(tmp_path) as client: + assert client.get( + "/api/settings/personal", headers={IDENTITY_HEADER: alice.username} + ).json() == { + "timezone": "America/New_York", + "gateParkDestinationId": None, + } + assert await druks_db.scalar(select(func.count()).select_from(InstallationSettings)) == 1 + + +async def test_personal_notifications_can_be_saved_and_cleared(tmp_path, druks_db): + destination = await Destination.create( + name="Personal gates", kind="slack_webhook", url="https://example.invalid/hook" + ) + with settings_client(tmp_path) as client: + saved = client.patch( + "/api/settings/personal", json={"gateParkDestinationId": destination.id} + ) + assert saved.status_code == 200 + assert saved.json()["gateParkDestinationId"] == destination.id + assert client.get("/api/settings").json()["gateParkDestinationId"] is None + cleared = client.patch("/api/settings/personal", json={"gateParkDestinationId": None}) + assert cleared.status_code == 200 + assert cleared.json()["gateParkDestinationId"] is None + rejected = client.patch("/api/settings/personal", json={"gateParkDestinationId": "missing"}) + assert rejected.status_code == 422 -async def test_first_edit_copies_the_profile_and_later_defaults_do_not_change_it(druks_db): +@pytest.mark.parametrize("has_settings", [False, True]) +async def test_migrations_preserve_preferences_and_installation_execution(druks_db, has_settings): alice = await Account.get_or_create("alice@example.com") bob = await Account.get_or_create("bob@example.com") - installation = await SettingsProfile.get() - await installation.update_profile( - timezone="Europe/Madrid", default_effort="low", fast_mode=True + charlie = await Account.get_or_create("charlie@example.com") + account_ids = (alice.id, bob.id, charlie.id) + destination = await Destination.create( + name="Personal gates", kind="slack_webhook", url="https://example.invalid/hook" ) - personal = await installation.copy_for_account(alice.id) - await personal.update_profile(timezone="America/New_York") - await installation.update_profile(default_effort="high", fast_mode=False) - - assert (await SettingsProfile.get(alice.id)).default_effort == "low" - assert personal.fast_mode - assert personal.timezone == "America/New_York" - assert (await SettingsProfile.get(bob.id)).default_effort == "high" - assert await druks_db.scalar(select(func.count()).select_from(SettingsProfile)) == 2 - existing_profile = await installation.copy_for_account(alice.id) - assert existing_profile.id == personal.id - assert existing_profile.default_effort == "low" - - -async def test_the_database_refuses_duplicate_installation_or_personal_profiles(druks_db): - account = await Account.get_or_create("alice@example.com") - installation = await SettingsProfile.get() - await installation.copy_for_account(account.id) - for account_id in (None, account.id): - with pytest.raises(IntegrityError): - async with druks_db.begin_nested(): - druks_db.add(SettingsProfile(account_id=account_id)) - await druks_db.flush() + for statement in ( + "DELETE FROM settings", + "ALTER TABLE accounts DROP COLUMN timezone", + "ALTER TABLE accounts DROP COLUMN gate_park_destination_id", + "ALTER TABLE settings DROP CONSTRAINT settings_singleton", + "ALTER TABLE settings ADD COLUMN timezone varchar NOT NULL DEFAULT 'UTC'", + "ALTER TABLE settings ADD COLUMN account_id varchar REFERENCES accounts(id) " + "ON DELETE CASCADE", + "ALTER TABLE settings ADD CONSTRAINT settings_account_id_key " + "UNIQUE NULLS NOT DISTINCT (account_id)", + ): + await druks_db.execute(text(statement)) + if has_settings: + for row_id, account_id, timezone, destination_id in ( + (7, None, "Europe/Madrid", destination.id), + (1, alice.id, "America/New_York", destination.id), + (2, bob.id, "Asia/Tokyo", None), + ): + await druks_db.execute( + text( + "INSERT INTO settings (id, account_id, timezone, gate_park_destination_id, " + "default_harness, default_model, default_billing, default_effort, fast_mode, " + "default_timeout, updated_at) VALUES (:id, :account_id, :timezone, " + ":destination_id, 'codex', 'openai/gpt-5.5', 'api_key', " + "'high', true, 600, '2026-09-01T12:00:00Z')" + ), + dict( + id=row_id, + account_id=account_id, + timezone=timezone, + destination_id=destination_id, + ), + ) + await druks_db.execute( + text("UPDATE settings SET default_effort = 'low' WHERE account_id IS NOT NULL") + ) + + def upgrade(connection): + for filename in ( + "74b53981122c_add_account_preferences.py", + "7dc609a2d51a_move_preferences_to_accounts.py", + "b43924bf37db_enforce_installation_settings_singleton.py", + ): + path = Path(__file__).resolve().parent.parent / "migrations" / "versions" / filename + spec = importlib.util.spec_from_file_location("settings_migration", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with Operations.context(MigrationContext.configure(connection)): + module.upgrade() + + await (await druks_db.connection()).run_sync(upgrade) + preferences = ( + await druks_db.execute( + text("SELECT id, timezone, gate_park_destination_id FROM accounts ORDER BY id") + ) + ).all() + expected = ( + [ + (alice.id, "America/New_York", destination.id), + (bob.id, "Asia/Tokyo", None), + (charlie.id, "Europe/Madrid", destination.id), + ] + if has_settings + else [(account_id, "UTC", None) for account_id in account_ids] + ) + assert preferences == sorted(expected) + execution = ( + await druks_db.execute( + text( + "SELECT id, default_harness, default_model, default_billing, default_effort, " + "fast_mode, default_timeout FROM settings" + ) + ) + ).all() + assert execution == ( + [(1, "codex", "openai/gpt-5.5", "api_key", "high", True, 600)] if has_settings else [] + ) + assert await druks_db.scalar(text("SELECT to_regclass('personal_settings')")) is None + druks_db.expunge_all() + assert (await InstallationSettings.get()).id == 1 + + +async def test_the_database_refuses_duplicate_installation_settings(druks_db): + await InstallationSettings.get() + with pytest.raises(IntegrityError): + async with druks_db.begin_nested(): + druks_db.add(InstallationSettings(id=2)) + await druks_db.flush() async def test_the_database_refuses_a_second_default_account(druks_db): @@ -95,39 +202,46 @@ async def test_unattended_subscription_requires_a_connection(druks_db): with pytest.raises( HarnessNotConnectedError, match="connect your Anthropic subscription" ) as error: - await get_profile(PROFILE_PROBE.id, None) + await get_config(CONFIG_PROBE.id, None) assert error.value.code == "not_connected" -async def test_unattended_api_key_uses_installation_profile_without_a_default_account(druks_db): +async def test_unattended_api_key_uses_installation_config_without_a_default_account(druks_db): account = Account(username="alice@example.com") session = db_session() session.add(account) await session.flush() await VaultSecret.paste(Audience.provider("anthropic"), "test-api-key", pasted_by=account) - installation = await SettingsProfile.get() - await installation.update_profile(default_billing="api_key", default_effort="low") + installation = await InstallationSettings.get() + await installation.update(default_billing="api_key", default_effort="low") assert await Account.get_default() is None - profile = await get_profile(PROFILE_PROBE.id, None) - - assert profile.api_key.secrets["value"] == "test-api-key" - assert profile.subscription is None - assert profile.effort == "low" + config = await get_config(CONFIG_PROBE.id, None) + assert config.api_key.secrets["value"] == "test-api-key" + assert config.subscription is None + assert config.effort == "low" -async def test_unattended_execution_uses_the_default_personal_profile_and_agent_overrides(druks_db): - subscription = await connect_anthropic_subscription("alice@example.com") - installation = await SettingsProfile.get() - personal = await installation.copy_for_account(subscription.account_id) - await personal.update_profile(default_effort="low", default_timeout=600, fast_mode=True) - profile = await get_profile(PROFILE_PROBE.id, None) +async def test_accounts_share_execution_defaults_and_keep_their_own_subscriptions(druks_db): + alice = await connect_anthropic_subscription("alice@example.com") + bob = await connect_anthropic_subscription("bob@example.com") + installation = await InstallationSettings.get() + personal = await Account.get(bob.account_id) + await personal.update_preferences(timezone="Europe/Madrid") + await installation.update(default_effort="low", default_timeout=600, fast_mode=True) - assert profile.subscription.id == subscription.id - assert (profile.effort, profile.timeout, profile.fast_mode) == ("low", 600, True) - await SettingsOverride.set_agent_effort(PROFILE_PROBE.id, "high") - assert (await get_profile(PROFILE_PROBE.id, None)).effort == "high" + for account_id, subscription in ( + (None, alice), + (alice.account_id, alice), + (bob.account_id, bob), + ): + config = await get_config(CONFIG_PROBE.id, account_id) + assert config.subscription.id == subscription.id + assert (config.effort, config.timeout, config.fast_mode) == ("low", 600, True) + await SettingsOverride.set_agent_effort(CONFIG_PROBE.id, "high") + for account_id in (None, alice.account_id, bob.account_id): + assert (await get_config(CONFIG_PROBE.id, account_id)).effort == "high" async def test_personal_api_is_scoped_and_does_not_retime_schedules( @@ -142,24 +256,17 @@ async def apply_schedules(): with header_client(tmp_path) as client: alice = {IDENTITY_HEADER: "alice@example.com"} bob = {IDENTITY_HEADER: "bob@example.com"} - assert ( - client.patch( - "/api/settings", headers=alice, json={"timezone": "Europe/Madrid"} - ).status_code - == 200 - ) inherited = client.get("/api/settings/personal", headers=alice).json() - assert inherited["accountId"] is None + assert set(inherited) == {"timezone", "gateParkDestinationId"} saved = client.patch( "/api/settings/personal", headers=alice, json={"timezone": "America/New_York"} ) assert saved.status_code == 200 - assert saved.json()["accountId"] == (await Account.get_for_username("alice@example.com")).id - assert ( - client.get("/api/settings/personal", headers=bob).json()["timezone"] == "Europe/Madrid" - ) - assert client.get("/api/settings", headers=alice).json()["timezone"] == "Europe/Madrid" - assert calls == ["retimed"] + account = await Account.get_for_username("alice@example.com") + assert account.timezone == "America/New_York" + assert client.get("/api/settings/personal", headers=bob).json()["timezone"] == "UTC" + assert "timezone" not in client.get("/api/settings", headers=alice).json() + assert calls == [] assert ( client.patch( "/api/settings/personal", headers=alice, json={"accountId": "someone-else"} @@ -168,27 +275,78 @@ async def apply_schedules(): ) -async def test_invalid_first_edit_leaves_no_personal_row(tmp_path, druks_db): +@pytest.mark.parametrize( + ("field", "value"), + [ + ("defaultHarness", "codex"), + ("defaultModel", "openai/gpt-5.5"), + ("defaultBilling", "api_key"), + ("defaultEffort", "low"), + ("fastMode", True), + ("defaultTimeout", 600), + ("timezone", "not-a-timezone"), + ], +) +async def test_invalid_edit_keeps_account_preferences(tmp_path, druks_db, field, value): with settings_client(tmp_path) as client: - response = client.patch("/api/settings/personal", json={"defaultModel": "openai/gpt-5.5"}) + response = client.patch("/api/settings/personal", json={field: value}) assert response.status_code == 422 - assert client.get("/api/settings/personal").json()["accountId"] is None - assert client.patch("/api/settings/personal", json={}).json()["accountId"] is None + assert client.patch("/api/settings/personal", json={}).status_code == 200 + assert (await Account.get_default()).timezone == "UTC" -async def test_shared_agent_overrides_must_fit_other_accounts_profiles(tmp_path, druks_db): +async def test_shared_agent_overrides_use_installation_settings_after_a_personal_edit( + tmp_path, druks_db +): account = await Account.get_or_create("bob@example.com") - installation = await SettingsProfile.get() - personal = await installation.copy_for_account(account.id) - await personal.update_profile(default_harness="codex", default_model="openai/gpt-5.5") + await account.update_preferences(timezone="Europe/Madrid") with settings_client(tmp_path) as client: response = client.patch( - "/api/settings/apps", json={"agentHarnesses": {PROFILE_PROBE.id: "claude"}} + "/api/settings/apps", json={"agentHarnesses": {CONFIG_PROBE.id: "claude"}} ) - assert response.status_code == 422 - assert response.json()["detail"] == ( - "Personal profile for bob@example.com: claude does not run OpenAI models." + assert response.status_code == 200 + assert await SettingsOverride.read(f"agent_harness:{CONFIG_PROBE.id}") == "claude" + + +async def test_notification_default_only_seeds_new_accounts(tmp_path, druks_db): + destination = await Destination.create( + name="Default gates", kind="slack_webhook", url="https://example.invalid/hook" ) - assert await SettingsOverride.read(f"agent_harness:{PROFILE_PROBE.id}") is None + with header_client(tmp_path) as client: + alice = {IDENTITY_HEADER: "alice@example.com"} + bob = {IDENTITY_HEADER: "bob@example.com"} + charlie = {IDENTITY_HEADER: "charlie@example.com"} + assert ( + client.get("/api/settings/personal", headers=alice).json()["gateParkDestinationId"] + is None + ) + assert ( + client.patch( + "/api/settings", headers=alice, json={"gateParkDestinationId": destination.id} + ).status_code + == 200 + ) + assert ( + client.get("/api/settings/personal", headers=alice).json()["gateParkDestinationId"] + is None + ) + assert ( + client.get("/api/settings/personal", headers=bob).json()["gateParkDestinationId"] + == destination.id + ) + assert ( + client.patch( + "/api/settings", headers=alice, json={"gateParkDestinationId": None} + ).status_code + == 200 + ) + assert ( + client.get("/api/settings/personal", headers=bob).json()["gateParkDestinationId"] + == destination.id + ) + assert ( + client.get("/api/settings/personal", headers=charlie).json()["gateParkDestinationId"] + is None + ) diff --git a/backend/tests/test_run_state.py b/backend/tests/test_run_state.py index 75a79a43..d39507a1 100644 --- a/backend/tests/test_run_state.py +++ b/backend/tests/test_run_state.py @@ -2,7 +2,7 @@ from unittest import mock import pytest -from conftest import PROFILE_PROBE +from conftest import CONFIG_PROBE from dbos._error import DBOSWorkflowCancelledError from druks.accounts.models import Account from druks.database import db_session as ambient_session @@ -10,8 +10,8 @@ from druks.durable.enums import RunState from druks.durable.models import Run from druks.events.models import Event +from druks.harnesses.config import get_config from druks.harnesses.exceptions import HarnessNotConnectedError -from druks.harnesses.profiles import get_profile from druks.models import Base from druks.signals import subscribe from druks.testing import seed_run @@ -294,7 +294,7 @@ async def test_unattended_execution_without_subscription_records_not_connected( item, run = await _item_and_run(druks_db, "running") async def body() -> None: - await get_profile(PROFILE_PROBE.id, None) + await get_config(CONFIG_PROBE.id, None) with pytest.raises(HarnessNotConnectedError, match="connect your Anthropic subscription"): await _execute_run(run.id, run.kind, {"type": "note", "id": item.id}, run.account_id, body) diff --git a/backend/tests/test_sandboxed_harness.py b/backend/tests/test_sandboxed_harness.py index 3d3090a5..9102fbfe 100644 --- a/backend/tests/test_sandboxed_harness.py +++ b/backend/tests/test_sandboxed_harness.py @@ -9,11 +9,12 @@ from typing import Any import pytest -from conftest import PROFILE_PROBE, connect_provider, installation_key, make_jwt +from conftest import CONFIG_PROBE, connect_provider, installation_key, make_jwt from druks.durable.enums import AgentCallStatus from druks.harnesses.base import Harness from druks.harnesses.claude import ClaudeHarness from druks.harnesses.codex import CodexHarness +from druks.harnesses.config import AgentConfig, get_config from druks.harnesses.exceptions import ( HarnessAuthError, HarnessError, @@ -25,7 +26,6 @@ HarnessUsageLimitError, Retry, ) -from druks.harnesses.profiles import Profile, get_profile from druks.harnesses.providers import AnthropicProvider, OpenAiProvider from druks.sandbox.datastructures import ( AgentInvocation, @@ -526,8 +526,8 @@ def test_agent_result_names_the_agent_in_its_failure(): @pytest.fixture -def agent_profile(): - return Profile( +def agent_config(): + return AgentConfig( harness_class=ClaudeHarness, model="anthropic/claude-opus-4-7", subscription=SimpleNamespace(id="subscription-1", account_id="acc"), @@ -543,7 +543,7 @@ def agent_profile(): async def test_run_agent_carries_foreign_failures_as_harness_errors( - ctx: SimpleNamespace, agent_profile + ctx: SimpleNamespace, agent_config ): """The result's error is always from the taxonomy: a foreign failure is wrapped unclassified, keeps its traceback via the chain, and — unlike an @@ -560,7 +560,7 @@ async def run_prompt(harness: Any, **_kwargs: Any) -> Any: result = await Host.run_agent( sandbox, agent="evaluate", - profile=agent_profile, + config=agent_config, prompt="p", schema={"type": "object"}, artifact_dir=ctx.artifact_dir, @@ -575,7 +575,7 @@ async def run_prompt(harness: Any, **_kwargs: Any) -> Any: assert type(revived) is HarnessError and revived.__cause__ is None -async def test_run_agent_carries_a_taxonomy_failure_as_itself(ctx: SimpleNamespace, agent_profile): +async def test_run_agent_carries_a_taxonomy_failure_as_itself(ctx: SimpleNamespace, agent_config): sandbox = SimpleNamespace(id="host-abc", ssh_username="root") timeout = HarnessTimeoutError("claude timed out after 60s.") @@ -586,7 +586,7 @@ async def run_prompt(harness: Any, **_kwargs: Any) -> Any: result = await Host.run_agent( sandbox, agent="evaluate", - profile=agent_profile, + config=agent_config, prompt="p", schema={"type": "object"}, artifact_dir=ctx.artifact_dir, @@ -601,8 +601,8 @@ async def test_claude_api_key_stays_on_the_server( """Under api_key billing the VM is created with the key as a Drukbox entry and holds a placeholder. The key reaches no invocation, VM file, artifact, or result.""" key = (await installation_key()).secrets["value"] - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "api_key") - profile = await get_profile(PROFILE_PROBE.id, None) + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "api_key") + config = await get_config(CONFIG_PROBE.id, None) result_event = { "type": "result", "subtype": "success", @@ -623,7 +623,7 @@ async def test_claude_api_key_stays_on_the_server( result = await Host.run_agent( sandbox, agent="evaluate", - profile=profile, + config=config, prompt="p", schema={"type": "object"}, artifact_dir=ctx.artifact_dir, @@ -640,7 +640,7 @@ async def test_claude_api_key_stays_on_the_server( assert not any(type(entry) is HomeFile for entry in bundle.home) for artifact in (ctx.artifact_dir / "call-9").iterdir(): assert key not in artifact.read_text() - assert key not in repr(result) and key not in repr(profile) + assert key not in repr(result) and key not in repr(config) async def test_claude_subscription_token_stays_on_the_server( @@ -659,8 +659,8 @@ async def test_claude_subscription_token_stays_on_the_server( } }, ) - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "subscription") - profile = await get_profile(PROFILE_PROBE.id, None) + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "subscription") + config = await get_config(CONFIG_PROBE.id, None) result_event = { "type": "result", "subtype": "success", @@ -681,7 +681,7 @@ async def test_claude_subscription_token_stays_on_the_server( result = await Host.run_agent( sandbox, agent="evaluate", - profile=profile, + config=config, prompt="p", schema={"type": "object"}, artifact_dir=ctx.artifact_dir, @@ -689,8 +689,8 @@ async def test_claude_subscription_token_stays_on_the_server( ) assert result.status is AgentCallStatus.SUCCEEDED - [secret] = profile.secret_refs - assert secret.key == ("anthropic", profile.subscription.id, "", "") + [secret] = config.secret_refs + assert secret.key == ("anthropic", config.subscription.id, "", "") [start] = sandbox.calls assert not start.kwargs["extra_env"] bundle = start.kwargs["credentials_bundle"] @@ -715,10 +715,10 @@ async def test_codex_subscription_token_stays_on_the_server( "account_id": "acc-1", } await connect_provider(OpenAiProvider, {"OPENAI_API_KEY": None, "tokens": tokens}) - await SettingsOverride.set_agent_harness(PROFILE_PROBE.id, "codex") - await SettingsOverride.set_agent_model(PROFILE_PROBE.id, "openai/gpt-5.5") - await SettingsOverride.set_agent_billing(PROFILE_PROBE.id, "subscription") - profile = await get_profile(PROFILE_PROBE.id, None) + await SettingsOverride.set_agent_harness(CONFIG_PROBE.id, "codex") + await SettingsOverride.set_agent_model(CONFIG_PROBE.id, "openai/gpt-5.5") + await SettingsOverride.set_agent_billing(CONFIG_PROBE.id, "subscription") + config = await get_config(CONFIG_PROBE.id, None) # Codex leaves its result in the box; the fake download pulls nothing, so # the file is in place before the run. (ctx.artifact_dir / "call-9").mkdir() @@ -737,7 +737,7 @@ async def test_codex_subscription_token_stays_on_the_server( result = await Host.run_agent( sandbox, agent="evaluate", - profile=profile, + config=config, prompt="p", schema={"type": "object"}, artifact_dir=ctx.artifact_dir, @@ -746,8 +746,8 @@ async def test_codex_subscription_token_stays_on_the_server( assert result.status is AgentCallStatus.SUCCEEDED assert result.output == {"ok": True} - [secret] = profile.secret_refs - assert secret.key == ("codex_subscription_token", profile.subscription.id, "", "chatgpt.com") + [secret] = config.secret_refs + assert secret.key == ("codex_subscription_token", config.subscription.id, "", "chatgpt.com") [start] = sandbox.calls assert not start.kwargs["extra_env"] bundle = start.kwargs["credentials_bundle"] @@ -758,4 +758,4 @@ async def test_codex_subscription_token_stays_on_the_server( assert secret not in start.kwargs["stdin_data"].decode() for artifact in (ctx.artifact_dir / "call-9").iterdir(): assert secret not in artifact.read_text() - assert secret not in repr(result) and secret not in repr(profile) + assert secret not in repr(result) and secret not in repr(config) diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 0a87b0e6..9b06480b 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -59,7 +59,8 @@ def test_the_pat_slot_cannot_be_the_identity_header(tmp_path, mode): def test_toml_populates_authored_submodels(tmp_path, monkeypatch): config_path = tmp_path / "druks.toml" config_path.write_text( - f""" + f''' +timezone = "Europe/Madrid" [identity] mode = "header" header = "X-Edge-Email" @@ -76,7 +77,7 @@ def test_toml_populates_authored_submodels(tmp_path, monkeypatch): service_token = "sandbox-token" image = "sandbox:latest" timeout = 180 -""".strip() +'''.strip() + "\n" ) monkeypatch.setenv("DRUKS_CONFIG", str(config_path)) @@ -92,6 +93,7 @@ def test_toml_populates_authored_submodels(tmp_path, monkeypatch): assert settings.sandbox.service_url == "https://sandbox.example.com" assert settings.sandbox.image == "sandbox:latest" assert settings.sandbox.timeout == 180.0 + assert settings.timezone == "Europe/Madrid" def test_only_an_explicit_issuer_url_changes_the_mint_base(tmp_path): @@ -164,3 +166,22 @@ def test_ensure_data_dirs_provisions_skills_dir(tmp_path): ensure_data_dirs(settings) assert settings.skills_dir.is_dir() assert settings.files_dir.is_dir() + + +def test_installation_timezone_defaults_to_utc(tmp_path): + assert make_settings(tmp_path).timezone == "UTC" + + +@pytest.mark.parametrize("timezone", ["Not/A/Zone", "/etc/passwd", "../UTC"]) +def test_installation_timezone_rejects_invalid_zones(tmp_path, timezone): + with pytest.raises(ValidationError, match="Unknown IANA timezone"): + make_settings(tmp_path, timezone=timezone) + + +def test_development_example_pins_the_installation_timezone(tmp_path, monkeypatch): + example = Path(__file__).resolve().parents[2] / "druks.toml.example" + config = tmp_path / "druks.toml" + config.write_text(example.read_text()) + monkeypatch.setenv("DRUKS_CONFIG", str(config)) + monkeypatch.setenv("TIMEZONE", "Asia/Tokyo") + assert Settings(secrets={"secrets_key": _SECRETS_KEY}).timezone == "UTC" diff --git a/backend/tests/test_setup_env.py b/backend/tests/test_setup_env.py index 4df168c9..d524d0b5 100644 --- a/backend/tests/test_setup_env.py +++ b/backend/tests/test_setup_env.py @@ -598,3 +598,20 @@ def test_exe_template_registry_uses_existing_provider_contract(tmp_path, reposit assert "registry-token" not in "\n".join(printed) assert stat.S_IMODE(env_path.stat().st_mode) == 0o600 assert stat.S_IMODE((tmp_path / "druks.toml").stat().st_mode) == 0o600 + + +def test_setup_writes_and_preserves_installation_timezone(tmp_path, monkeypatch): + env_path = tmp_path / ".env" + _run(env_path) + assert _read_toml(tmp_path / "druks.toml")["timezone"] == "UTC" + _run(env_path, set_values=("timezone=Europe/Madrid",)) + _run(env_path) + assert _read_toml(tmp_path / "druks.toml")["timezone"] == "Europe/Madrid" + monkeypatch.setenv("DRUKS_CONFIG", str(tmp_path / "druks.toml")) + assert Settings().timezone == "Europe/Madrid" + + +@pytest.mark.parametrize("timezone", ["Not/A/Zone", "/etc/passwd", ""]) +def test_setup_rejects_invalid_installation_timezone(tmp_path, timezone): + with pytest.raises(ValueError, match="Unknown IANA timezone"): + _run(tmp_path / ".env", set_values=(f"timezone={timezone}",)) diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index b5b478a6..718857cb 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -8,7 +8,7 @@ validate_setting_override, validate_settings_declaration, ) -from druks.user_settings.models import SettingsProfile +from druks.user_settings.models import InstallationSettings from druks.user_settings.schemas import SettingsFieldResponse from druks.workflows import Workflow from pydantic import BaseModel, Field, SecretStr, field_validator @@ -21,9 +21,8 @@ def session(druks_db): async def test_get_lazy_creates_row_with_the_shipped_defaults(session): - row = await SettingsProfile.get() + row = await InstallationSettings.get() await session.commit() - assert row.timezone == "UTC" assert (row.default_harness, row.default_model, row.default_billing) == ( "claude", "anthropic/claude-opus-4-7", @@ -32,11 +31,11 @@ async def test_get_lazy_creates_row_with_the_shipped_defaults(session): assert (row.default_effort, row.fast_mode, row.default_timeout) == ("high", False, 1800) -async def test_update_profile_persists_the_defaults(session): - row = await SettingsProfile.get() - await row.update_profile(default_harness="codex", fast_mode=True) +async def test_update_persists_the_defaults(session): + row = await InstallationSettings.get() + await row.update(default_harness="codex", fast_mode=True) await session.commit() - row = await SettingsProfile.get() + row = await InstallationSettings.get() assert (row.default_harness, row.fast_mode) == ("codex", True) diff --git a/backend/tests/test_warm_host_rotation.py b/backend/tests/test_warm_host_rotation.py index 4f8e05f1..fa333ca7 100644 --- a/backend/tests/test_warm_host_rotation.py +++ b/backend/tests/test_warm_host_rotation.py @@ -9,7 +9,7 @@ from druks.workflows import Workflow -def _profile(secrets: dict[str, Secret], secrets_id: str = "") -> SimpleNamespace: +def _config(secrets: dict[str, Secret], secrets_id: str = "") -> SimpleNamespace: return SimpleNamespace(secrets=secrets, secret_refs=[], secrets_id=secrets_id) @@ -20,8 +20,8 @@ def _profile(secrets: dict[str, Secret], secrets_id: str = "") -> SimpleNamespac auth_header="x-api-key", auth_prefix="", ) -_NONE = _profile({}) -_ANTHROPIC = _profile({"anthropic": _ENTRY}, "anthropic.20260907T110000") +_NONE = _config({}) +_ANTHROPIC = _config({"anthropic": _ENTRY}, "anthropic.20260907T110000") @dataclass @@ -113,7 +113,7 @@ async def test_warm_host_keeps_its_entries_across_calls(monkeypatch): flow = _warm_workflow() first = await flow._lease_host(_ANTHROPIC) - second = await flow._lease_host(_profile({"anthropic": _ENTRY}, _ANTHROPIC.secrets_id)) + second = await flow._lease_host(_config({"anthropic": _ENTRY}, _ANTHROPIC.secrets_id)) assert first == second == "host-1" assert fake.provisions == ["wf-1:workflow:anthropic.20260907T110000"] @@ -145,7 +145,7 @@ async def test_provisioning_key_names_the_pasted_key(monkeypatch): The same pasted key finds the host. A replaced key asks for a fresh host.""" fake = _FakeSandboxClient(lease=timedelta(hours=2)) monkeypatch.setattr(sdk, "sandbox_client", fake) - replaced = _profile({"anthropic": _ENTRY}, "anthropic.20260907T120000") + replaced = _config({"anthropic": _ENTRY}, "anthropic.20260907T120000") await _warm_workflow()._lease_host(_ANTHROPIC) await _warm_workflow()._lease_host(_ANTHROPIC) @@ -192,9 +192,9 @@ async def test_a_replay_finds_the_warm_box_through_its_identity( client = _FakeSandboxClient(lease=timedelta(hours=2)) monkeypatch.setattr(sdk, "sandbox_client", client) flow = _warm_workflow() - profile = SimpleNamespace(secrets={}, secret_refs=secrets, secrets_id=subscription.id) + config = SimpleNamespace(secrets={}, secret_refs=secrets, secrets_id=subscription.id) - assert await flow._lease_host(profile) == "host-crashed" + assert await flow._lease_host(config) == "host-crashed" assert client.reattached == ["host-crashed"] assert client.provisions == [] diff --git a/docs/concepts.md b/docs/concepts.md index ce51a2af..5ad0ea2f 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -234,10 +234,10 @@ app or integration owns the provider payload and domain reaction. Configuration has two planes: - **Deployment:** `druks.toml` configures the deployment and creates the process environment. -- Postgres settings configure installation defaults, personal profiles, +- Postgres settings configure installation defaults, personal preferences, app and workflow settings, agent overrides, notifications, MCP servers, and skills. See [personal and installation settings](configuration.md#personal-and-installation-settings) - for profile resolution and timezone rules. + for execution defaults and timezone rules. Druks keeps every secret in the vault, encrypted at rest: pasted keys, MCP tokens, OAuth grants, GitHub App keys, and provider subscriptions. It decrypts diff --git a/docs/configuration.md b/docs/configuration.md index 9fbfaa53..0612edf8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,8 +10,8 @@ without replacing the process. | Plane | Examples | Stored in | | --- | --- | --- | -| Deployment | identity, ingress, Drukbox, encryption key | `~/druks/druks.toml` | -| Dashboard | timezone, the GitHub connection, harness and tracker credentials, workflow and agent overrides, MCP servers, skills | Postgres | +| Deployment | installation timezone, identity, ingress, Drukbox, encryption key | `~/druks/druks.toml` | +| Dashboard | personal timezone, the GitHub connection, harness and tracker credentials, workflow and agent overrides, MCP servers, skills | Postgres | The installer creates the deployment `.env` from `druks.toml`. Compose, Druks, and Drukbox consume this build artifact. Do not edit `.env`. Edit `druks.toml`, @@ -64,28 +64,47 @@ recover an installation, preserve `[secrets]`. Use repeatable ## Personal and installation settings -**Settings → Preferences** edits your personal profile. **Settings → General** -and **Settings → Agents** edit installation defaults. Each page saves its -own draft. +One Druks installation serves one organization. Separate organizations use +separate installations. **Settings → Agents** owns the shared harness, model, +billing, effort, fast mode, and timeout defaults. Every account uses these +defaults. Shared agent overrides take priority. A declared agent timeout also +takes priority over the installation default. -Your account uses installation defaults until the first personal edit. That edit -copies the complete profile. Later installation changes do not change your saved -profile. Shared agent overrides take priority. A declared agent timeout also takes -priority over the profile default. +**Settings → Preferences** edits the timezone on your `Account`. The personal +settings API also edits your account's gate notification destination. Druks +copies the installation timezone and notification default when it creates an +account. Later installation changes do not change existing account preferences. +Each page saves its own draft. + +`PATCH /api/settings` accepts `gateParkDestinationId` as the notification default +for **new accounts only**. Set it to a destination ID, or to `null` to start new +accounts with gate notifications off. This default has no dashboard control. +Use `PATCH /api/settings/personal` to change an existing account's destination. +Clearing or replacing the installation default does not change existing accounts. + +Set the installation timezone at the top level of `druks.toml`, before any table: + +```toml +timezone = "Europe/Madrid" +``` + +Use an IANA timezone. The default is `UTC`. Restart Druks after a change to +apply it to all schedules. The first account becomes the default account, including in header and JWT modes. -Unattended calls use its profile and subscriptions. The flag grants no extra +Unattended calls use its subscriptions. The flag grants no extra permissions. Calls with an explicit account use that account's subscriptions. Missing subscriptions fail the call. API keys belong to the installation. The installation timezone controls schedules and operational day boundaries. Your personal timezone controls timestamp display. Gate notifications use the -run account's profile. Unattended runs record the default account. +run account's preferences. Unattended runs record the default account. Druks refuses to start a run before account setup. The API exposes installation settings at `GET/PATCH /api/settings` and your -profile at `GET/PATCH /api/settings/personal`. The personal route uses the -authenticated account. Its `accountId` is NULL while it inherits defaults. +preferences at `GET/PATCH /api/settings/personal`. The personal route uses the +authenticated account. It returns `timezone` and `gateParkDestinationId`. It +rejects execution settings. The installation API rejects timezone changes. ## Core process settings @@ -148,7 +167,7 @@ order: The `exp`, `iss`, and `aud` claims must match the configuration. Druks maps `identity.jwt_identity_claim` to an account. A validation error returns a 401 with the error class, not the token. Druks uses a fixed RS256 - profile and does not negotiate it. + configuration and does not negotiate it. 4. **No-authentication mode (`none`).** This mode has no authentication or identity edge. Druks resolves the only account. Zero accounts is the setup state. The first completed provider connection creates the operator account from the @@ -401,9 +420,10 @@ are sandbox entries. Provider credentials do not belong in this root. OpenCode and Pi do not read it. The default harness, model, billing, effort, and timeout live in **Settings → Agents**. Each agent can override any of them on its app's page. -**Unattended runs use** names the default account. Its profile selects the -subscription or installation API key. A call refuses before provisioning a VM if its selected -credential is missing. +**Unattended runs use** names the default account. Shared execution settings +select subscription or API key billing. Subscription billing uses the run +account's subscription. API key billing uses the installation key. A call refuses +before provisioning a VM if its selected credential is missing. ## Sandboxes diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index c32a7300..2cb0da24 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -193,14 +193,15 @@ For example, a webhook can resolve the ticket assignee. An unattended start records the default account. Druks refuses to start a run before an account is available. A parked run keeps its account after resume. -Each agent call uses that account's profile. Agent overrides take priority. +Each agent call uses the installation execution defaults. Agent overrides take +priority. Subscription billing uses the run account's subscription. The call records exactly one billing reference: `subscription_id` or `api_key_provider`. Druks uses that selected credential for execution. Missing credentials refuse the call. A workflow can use different providers across its agent calls. Disconnect clears the credential secret and retains its billing identity for call history. See [personal and installation settings](configuration.md#personal-and-installation-settings) -for profile creation and timezone rules. +for execution defaults, personal preferences, and timezone rules. ### The journal @@ -392,19 +393,20 @@ operator configures it in the app's **Settings → Agents**. Shared defaults are in **Settings → Agents**: ```python -profile = await NightWatch.auditor.get_profile() -profile.harness # "claude" | "codex" | "opencode" | "pi" -profile.model_id # the model as that CLI names it, provider prefix stripped -profile.model # "provider/model" -profile.effort -profile.billing # "subscription" | "api_key" -profile.secrets # the Drukbox entries that put the key in the VM as a placeholder -``` - -`get_profile()` runs inside a workflow and reads the settings at call time for -the run's own actor, the same read Druks makes for the calling agent. A -missing login or key raises before any sandbox work. Under subscription -billing there is no key. The VM home holds the login of the calling agent's +config = await NightWatch.auditor.get_config() +config.harness # "claude" | "codex" | "opencode" | "pi" +config.model_id # the model as that CLI names it, provider prefix stripped +config.model # "provider/model" +config.effort +config.billing # "subscription" | "api_key" +config.secrets # the Drukbox entries that put the key in the VM as a placeholder +``` + +`get_config()` returns an `AgentConfig` inside a workflow. This temporary value +contains shared execution settings and the run account's selected credential. +Druks resolves it at call time, as it does for an agent call. It stores no +personal preferences and has no database table. A missing login or key raises +before any sandbox work. Under subscription billing there is no key. The VM home holds the login of the calling agent's subscription only, so a nested CLI on another provider needs `api_key` billing. Under `api_key` billing, the VM holds the key as a placeholder in the variable the entry names: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `CODEX_API_KEY` for diff --git a/druks.toml.example b/druks.toml.example index bacabbe6..8dc1980c 100644 --- a/druks.toml.example +++ b/druks.toml.example @@ -1,6 +1,9 @@ # Host-run development configuration. Copy to `druks.toml`. # Reference: docs/configuration.md. +# Schedule timezone and initial timezone for new accounts. +timezone = "UTC" + [identity] mode = "none" header = "" diff --git a/frontend/README.md b/frontend/README.md index 3c2ff7db..f230648b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -41,10 +41,12 @@ navigation button opens a modal drawer. Escape closes the drawer and returns focus to the button. Settings use `/settings/
` routes. `/settings/personal` edits the current -account through `/api/settings/personal`; `/settings/general` and -`/settings/agents` edit installation defaults through `/api/settings`. The -preferences provider uses the personal endpoint for timestamp display. Search -matches section names and app field labels. Preferences, General, and Agents +account's preferences through `/api/settings/personal`. `/settings/agents` edits +shared execution defaults through `/api/settings`. The +preferences provider uses the personal endpoint for timestamp display. Preferences +contains only timezone and does not depend on execution catalogs. All accounts +use the shared execution defaults in Agents and the app agent overrides. Search +matches section names and app field labels. Preferences and Agents retain separate drafts across settings pages. Save changes applies only the current page. Leaving Settings offers Save, Discard, and Stay. Save applies each dirty page; a failed request keeps the operator on that page with its draft. Resource diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a921765f..7b486fa3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -13,9 +13,11 @@ import type { Harness, Identity, Pat, + PersonalSettings, SubjectResponse, SubjectSummary, UpdateAppsSettingsRequest, + UpdatePersonalSettingsRequest, UpdateSettingsRequest, UsageHistoryResponse, UsageResponse, @@ -33,7 +35,7 @@ import type { ProviderSubscription, Skill, SkillCollection, - SettingsProfile, + InstallationSettings, DashboardSchedules, DashboardWork, } from './types' @@ -264,12 +266,12 @@ export const api = { const qs = query.toString() return getJSON(`/api/events${qs ? `?${qs}` : ''}`) }, - getSettings: () => getJSON('/api/settings'), + getSettings: () => getJSON('/api/settings'), updateSettings: (body: UpdateSettingsRequest) => - patchJSON('/api/settings', body), - getPersonalSettings: () => getJSON('/api/settings/personal'), - updatePersonalSettings: (body: UpdateSettingsRequest) => - patchJSON('/api/settings/personal', body), + patchJSON('/api/settings', body), + getPersonalSettings: () => getJSON('/api/settings/personal'), + updatePersonalSettings: (body: UpdatePersonalSettingsRequest) => + patchJSON('/api/settings/personal', body), harnesses: () => getJSON('/api/settings/harnesses'), agents: () => getJSON('/api/agents'), accounts: () => getJSON('/api/auth/accounts'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 0907b643..d5f18e07 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -611,28 +611,35 @@ export interface Service { export type Billing = 'subscription' | 'api_key' -export interface SettingsProfile { - accountId: string | null +export interface PersonalSettings { timezone: string + gateParkDestinationId: string | null +} + +export interface InstallationSettings { + gateParkDestinationId: string | null + updatedAt: string defaultHarness: string defaultModel: string defaultBilling: Billing defaultEffort: string fastMode: boolean defaultTimeout: number - gateParkDestinationId: string | null - updatedAt: string } -export interface UpdateSettingsRequest { +export interface UpdatePersonalSettingsRequest { timezone?: string + gateParkDestinationId?: string | null +} + +export interface UpdateSettingsRequest { + gateParkDestinationId?: string | null defaultHarness?: string defaultModel?: string defaultBilling?: Billing defaultEffort?: string fastMode?: boolean defaultTimeout?: number - gateParkDestinationId?: string | null } export type BrowserSessionStatus = 'needs_login' | 'ready' | 'stale' | 'anonymous' diff --git a/frontend/src/components/SettingsPages.test.tsx b/frontend/src/components/SettingsPages.test.tsx index 66afd6c2..52d225b9 100644 --- a/frontend/src/components/SettingsPages.test.tsx +++ b/frontend/src/components/SettingsPages.test.tsx @@ -7,9 +7,9 @@ import { api } from '../api/client' import { SETTINGS_FIELDS } from './settings' import type { AgentSetting, + PersonalSettings, AppsSettingsResponse, UpdateAppsSettingsRequest, - SettingsProfile, } from '../api/types' vi.mock('../apps', () => ({})) @@ -33,14 +33,12 @@ const harnesses = [ ] const userSettings = { - timezone: 'UTC', defaultHarness: 'claude', defaultModel: 'anthropic/claude-opus-4-7', defaultBilling: 'subscription', defaultEffort: 'high', fastMode: false, defaultTimeout: 1800, - accountId: null, gateParkDestinationId: null, updatedAt: '2026-08-01T00:00:00Z', } @@ -283,7 +281,7 @@ function stubFetch( initialApps: AppsSettingsResponse = appSettings, ) { let savedSettings = { ...userSettings } - let savedPersonal: typeof userSettings | null = null + let savedPersonal: PersonalSettings | null = null const savedApps = structuredClone(initialApps) vi.stubGlobal( 'fetch', @@ -348,12 +346,16 @@ function stubFetch( }) } if (path === '/api/settings/personal') { + const preferences = savedPersonal ?? { + timezone: 'UTC', + gateParkDestinationId: savedSettings.gateParkDestinationId, + } if (init?.method === 'PATCH') { const changes = JSON.parse(String(init.body)) personalPatched.push(changes) - savedPersonal = { ...(savedPersonal ?? savedSettings), ...changes, accountId: 'operator' } + savedPersonal = { ...preferences, ...changes } } - return new Response(JSON.stringify(savedPersonal ?? savedSettings), { status: 200 }) + return new Response(JSON.stringify(savedPersonal ?? preferences), { status: 200 }) } if (path === '/api/settings' && init?.method === 'PATCH') { patched.push(JSON.parse(String(init.body))) @@ -427,7 +429,7 @@ function stubFetch( } function renderSettings(workPath?: string) { - window.history.replaceState(null, '', workPath ?? '/settings/general') + window.history.replaceState(null, '', workPath ?? '/settings/personal') vi.stubGlobal( 'matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })), @@ -606,7 +608,7 @@ describe('SettingsPages agents', () => { } }) - it('offers Agents separately from General preferences', async () => { + it('offers Agents separately from Preferences', async () => { stubFetch() renderSettings() @@ -625,7 +627,7 @@ describe('SettingsPages agents', () => { expect((screen.getByLabelText('Billing') as HTMLSelectElement).value).toBe('subscription') expect((screen.getByLabelText('Unattended runs use') as HTMLInputElement).value).toBe('paulo@example.com') expect( - screen.getByText('The default account supplies unattended preferences and subscriptions.'), + screen.getByText('The default account supplies subscriptions for unattended runs.'), ).toBeTruthy() expect(await screen.findByText('coder')).toBeTruthy() expect(screen.getByText('critic')).toBeTruthy() @@ -782,10 +784,10 @@ describe('settings drafts and navigation', () => { }) fireEvent.click(screen.getByRole('link', { name: 'Agents' })) fireEvent.change(await screen.findByLabelText('Effort'), { target: { value: 'low' } }) - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) expect((screen.getByLabelText('Timezone') as HTMLSelectElement).value).toBe('Europe/Madrid') fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) - await waitFor(() => expect(patched).toEqual([{ timezone: 'Europe/Madrid' }])) + await waitFor(() => expect(personalPatched).toEqual([{ timezone: 'Europe/Madrid' }])) fireEvent.click(screen.getByRole('link', { name: 'Agents' })) expect((screen.getByLabelText('Effort') as HTMLSelectElement).value).toBe('low') expect( @@ -801,14 +803,14 @@ describe('settings drafts and navigation', () => { const main = screen.getByRole('main') main.scrollTop = 250 fireEvent.click(screen.getByRole('link', { name: 'Settings' })) - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) fireEvent.change(await screen.findByRole('combobox', { name: 'Timezone' }), { target: { value: 'Europe/Madrid' }, }) fireEvent.click(screen.getByRole('link', { name: 'Back to Druks' })) let dialog = screen.getByRole('dialog', { name: 'Save your changes?' }) fireEvent.click(within(dialog).getByRole('button', { name: 'Stay' })) - expect(window.location.pathname).toBe('/settings/general') + expect(window.location.pathname).toBe('/settings/personal') expect((screen.getByRole('combobox', { name: 'Timezone' }) as HTMLSelectElement).value).toBe( 'Europe/Madrid', ) @@ -830,11 +832,10 @@ describe('settings drafts and navigation', () => { stubFetch(false) const update = vi .spyOn(api, 'updateSettings') - .mockResolvedValueOnce({ ...userSettings, timezone: 'Europe/Madrid' } as SettingsProfile) .mockRejectedValueOnce(new Error('Could not save the agent defaults.')) renderSettings('/events') fireEvent.click(await screen.findByRole('link', { name: 'Settings' })) - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) fireEvent.change(await screen.findByLabelText('Timezone'), { target: { value: 'Europe/Madrid' }, }) @@ -849,12 +850,10 @@ describe('settings drafts and navigation', () => { await screen.findByText('Could not save the agent defaults.') expect(window.location.pathname).toBe('/settings/agents') expect((screen.getByLabelText('Effort') as HTMLSelectElement).value).toBe('low') - expect(update.mock.calls.map(([body]) => body)).toEqual([ - { timezone: 'Europe/Madrid' }, - { defaultEffort: 'low' }, - ]) + expect(personalPatched).toEqual([{ timezone: 'Europe/Madrid' }]) + expect(update.mock.calls.map(([body]) => body)).toEqual([{ defaultEffort: 'low' }]) expect(screen.queryByRole('dialog', { name: 'Save your changes?' })).toBeNull() - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) expect((screen.getByLabelText('Timezone') as HTMLSelectElement).value).toBe('Europe/Madrid') expect( (screen.getByRole('button', { name: 'Save changes' }) as HTMLButtonElement).disabled, @@ -869,7 +868,7 @@ describe('settings drafts and navigation', () => { }) await waitFor(() => expect(window.history.state?.druksPosition).toBe(1)) fireEvent.click(await screen.findByRole('link', { name: 'Settings' })) - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) fireEvent.change(await screen.findByRole('combobox', { name: 'Timezone' }), { target: { value: 'Europe/Madrid' }, }) @@ -877,13 +876,13 @@ describe('settings drafts and navigation', () => { window.history.go(-3) }) await screen.findByRole('dialog', { name: 'Save your changes?' }) - await waitFor(() => expect(window.location.pathname).toBe('/settings/general')) + await waitFor(() => expect(window.location.pathname).toBe('/settings/personal')) fireEvent.click(screen.getByRole('button', { name: 'Stay' })) await act(async () => { window.history.go(-3) }) const dialog = await screen.findByRole('dialog', { name: 'Save your changes?' }) - await waitFor(() => expect(window.location.pathname).toBe('/settings/general')) + await waitFor(() => expect(window.location.pathname).toBe('/settings/personal')) fireEvent.click(within(dialog).getByRole('button', { name: 'Discard' })) await screen.findByRole('heading', { name: 'Current work' }) expect(window.location.pathname + window.location.search + window.location.hash).toBe( @@ -899,14 +898,14 @@ describe('settings resource and keyboard behavior', () => { fireEvent.click(screen.getByRole('link', { name: 'API tokens' })) const name = await screen.findByPlaceholderText(/What will hold it/) fireEvent.change(name, { target: { value: 'local client' } }) - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) fireEvent.click(screen.getByRole('link', { name: 'API tokens' })) expect((screen.getByPlaceholderText(/What will hold it/) as HTMLInputElement).value).toBe( 'local client', ) fireEvent.click(screen.getByRole('button', { name: 'mint' })) const secret = await screen.findByLabelText('personal access token') - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) fireEvent.click(screen.getByRole('link', { name: 'API tokens' })) expect(screen.getByLabelText('personal access token')).toBe(secret) expect((secret as HTMLInputElement).value).toBe('test-copy-once-token') @@ -915,8 +914,8 @@ describe('settings resource and keyboard behavior', () => { it('blocks duplicate shortcut saves while a request is pending', async () => { stubFetch(false) - let finish!: (settings: SettingsProfile) => void - const update = vi.spyOn(api, 'updateSettings').mockReturnValue( + let finish!: (settings: PersonalSettings) => void + const update = vi.spyOn(api, 'updatePersonalSettings').mockReturnValue( new Promise((resolve) => { finish = resolve }), @@ -929,7 +928,7 @@ describe('settings resource and keyboard behavior', () => { expect(update).toHaveBeenCalledTimes(1) expect(update).toHaveBeenCalledWith({ timezone: 'Europe/Madrid' }) await act(async () => { - finish({ ...userSettings, timezone: 'Europe/Madrid' } as SettingsProfile) + finish({ timezone: 'Europe/Madrid', gateParkDestinationId: null } as PersonalSettings) }) expect( (screen.getByRole('button', { name: 'Save changes' }) as HTMLButtonElement).disabled, @@ -965,7 +964,7 @@ it('restores the work return URL when a settings fragment route reloads', async stubFetch(false) renderSettings('/events?app=field_notes#recent') fireEvent.click(await screen.findByRole('link', { name: 'Settings' })) - fireEvent.click(screen.getByRole('link', { name: 'General' })) + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) await act(async () => { window.location.hash = 'settings-content' }) @@ -979,7 +978,7 @@ it('restores the work return URL when a settings fragment route reloads', async , ) await screen.findByRole('combobox', { name: 'Timezone' }) - expect(window.location.pathname + window.location.hash).toBe('/settings/general#settings-content') + expect(window.location.pathname + window.location.hash).toBe('/settings/personal#settings-content') expect(screen.getByRole('link', { name: 'Back to Druks' }).getAttribute('href')).toBe( '/events?app=field_notes#recent', ) @@ -1192,7 +1191,7 @@ describe('canonical app settings', () => { const destination = await screen.findByRole('link', { name: 'Field Notes App settings · Section' }) expect(destination.getAttribute('href')).toBe('/apps/field_notes/settings') fireEvent.click(destination) - expect(window.location.pathname).toBe('/settings/general') + expect(window.location.pathname).toBe('/settings/personal') fireEvent.click( within(screen.getByRole('dialog', { name: 'Save your changes?' })).getByRole('button', { name: 'Save', @@ -1200,7 +1199,7 @@ describe('canonical app settings', () => { ) await screen.findByLabelText('Notebook') expect(window.location.pathname).toBe('/apps/field_notes/settings') - expect(patched).toEqual([{ timezone: 'Europe/Madrid' }]) + expect(personalPatched).toEqual([{ timezone: 'Europe/Madrid' }]) expect(screen.queryByRole('navigation', { name: 'App settings sections' })).toBeNull() }) @@ -1224,6 +1223,60 @@ describe('canonical app settings', () => { }) describe('settings resource read failures', () => { + it.each(['failed', 'pending'] as const)( + 'saves Preferences while execution reads are %s', + async (state) => { + stubFetch(false) + for (const method of [ + 'getSettings', 'getAppSettings', 'providerCatalogs', 'providerSubscriptions', + 'providerKeys', 'providers', 'harnesses', 'accounts', 'agents', + ] as const) { + const request = vi.spyOn(api, method) + if (state === 'failed') request.mockRejectedValue(new Error('Offline')) + else request.mockImplementation(() => new Promise(() => {})) + } + renderSettings('/settings/personal') + fireEvent.change(await screen.findByRole('combobox', { name: 'Timezone' }), { + target: { value: 'Europe/Madrid' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(personalPatched).toEqual([{ timezone: 'Europe/Madrid' }])) + expect(await screen.findByText('Saved')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(patched).toEqual([]) + }, + ) + + it('retries a failed Preferences read without blocking Agents', async () => { + stubFetch(false) + const original = api.getPersonalSettings + const request = vi.spyOn(api, 'getPersonalSettings').mockRejectedValue(new Error('Offline')) + renderSettings('/settings/personal') + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('Could not load preferences.') + expect(screen.queryByRole('combobox', { name: 'Timezone' })).toBeNull() + fireEvent.click(screen.getByRole('link', { name: 'Agents' })) + expect(await screen.findByRole('heading', { name: 'Default execution' })).toBeTruthy() + fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) + request.mockImplementation(original) + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Try again' })) + expect(await screen.findByRole('combobox', { name: 'Timezone' })).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('saves shared execution defaults while Preferences is pending', async () => { + stubFetch(false) + vi.spyOn(api, 'getPersonalSettings').mockImplementation(() => new Promise(() => {})) + renderSettings('/settings/agents') + fireEvent.change(await screen.findByRole('combobox', { name: 'Effort' }), { + target: { value: 'low' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + expect(await screen.findByText('Saved')).toBeTruthy() + expect(patched).toEqual([{ defaultEffort: 'low' }]) + expect(personalPatched).toEqual([]) + }) + it('uses a newly saved directory catalog in Agents without remounting Settings', async () => { stubFetch(false) const originalFetch = fetch @@ -1399,7 +1452,7 @@ function mockScroll() { it('finds model defaults, app overrides, and timezone with working field focus', async () => { stubFetch(false) const scroll = mockScroll() - renderSettings('/settings/general') + renderSettings('/settings/personal') const search = await screen.findByLabelText('Search settings') fireEvent.change(search, { target: { value: 'model' } }) const results = screen.getByLabelText('Settings search results') @@ -1408,8 +1461,8 @@ it('finds model defaults, app overrides, and timezone with working field focus', fireEvent.click(defaultModel) await waitFor(() => expect(document.activeElement?.getAttribute('aria-label')).toMatch(/^Model:/)) fireEvent.change(screen.getByLabelText('Search settings'), { target: { value: 'timezone' } }) - fireEvent.click(screen.getByRole('link', { name: 'Timezone General · Field' })) - await waitFor(() => expect(document.activeElement?.id).toBe('settings-timezone')) + fireEvent.click(screen.getByRole('link', { name: 'Timezone Preferences · Field' })) + await waitFor(() => expect(document.activeElement?.id).toBe('personal-timezone')) expect(scroll).toHaveBeenCalled() }) @@ -1433,9 +1486,9 @@ it('closes an agent menu on surrounding scroll or resize, but keeps internal scr it.each(Object.values(SETTINGS_FIELDS))('opens the shared $label field from search', async (field) => { stubFetch(false) mockScroll() - renderSettings('/settings/general') + renderSettings('/settings/personal') fireEvent.change(await screen.findByLabelText('Search settings'), { target: { value: field.label } }) - const owner = field.section === 'general' ? 'General' : 'Agents' + const owner = field.section === 'personal' ? 'Preferences' : 'Agents' fireEvent.click(await screen.findByRole('link', { name: `${field.label} ${owner} · Field` })) await waitFor(() => expect(document.activeElement?.closest('[data-setting]')?.getAttribute('data-setting')).toBe(field.field)) }) @@ -1459,15 +1512,15 @@ it('keeps focus on another field after saving defaults reached through search', it('focuses the same search result on each click', async () => { stubFetch(false) const scroll = mockScroll() - renderSettings('/settings/general') + renderSettings('/settings/personal') const search = await screen.findByLabelText('Search settings') for (let click = 0; click < 2; click++) { search.focus() fireEvent.change(search, { target: { value: 'timezone' } }) expect(document.activeElement).toBe(search) scroll.mockClear() - fireEvent.click(screen.getByRole('link', { name: 'Timezone General · Field' })) - await waitFor(() => expect(document.activeElement?.id).toBe('settings-timezone')) + fireEvent.click(screen.getByRole('link', { name: 'Timezone Preferences · Field' })) + await waitFor(() => expect(document.activeElement?.id).toBe('personal-timezone')) expect(scroll).toHaveBeenCalledTimes(1) } }) @@ -1490,37 +1543,44 @@ it('keeps focus on another agent field after saving an app reached through searc }) -it('saves a personal profile without changing installation settings', async () => { +it('saves a personal timezone and keeps execution controls in Agents', async () => { stubFetch() renderSettings('/settings/personal') const timezone = await screen.findByRole('combobox', { name: 'Timezone' }) - expect(screen.getByText(/Your first save creates a personal profile/)).toBeTruthy() + expect(screen.queryByRole('heading', { name: 'Default execution' })).toBeNull() + expect(screen.queryByRole('combobox', { name: 'Effort' })).toBeNull() fireEvent.change(timezone, { target: { value: 'Europe/Madrid' } }) - fireEvent.change(screen.getByRole('combobox', { name: 'Effort' }), { target: { value: 'low' } }) fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) - await waitFor(() => expect(personalPatched).toEqual([{ timezone: 'Europe/Madrid', defaultEffort: 'low' }])) + await waitFor(() => expect(personalPatched).toEqual([{ timezone: 'Europe/Madrid' }])) expect(patched).toEqual([]) - await screen.findByText(/Your saved profile applies/) - fireEvent.click(screen.getByRole('link', { name: 'General' })) - expect((await screen.findByRole('combobox', { name: 'Timezone' }) as HTMLSelectElement).value).toBe('UTC') - fireEvent.change(screen.getByRole('combobox', { name: 'Timezone' }), { target: { value: 'Asia/Tokyo' } }) + fireEvent.click(screen.getByRole('link', { name: 'Agents' })) + expect(await screen.findByRole('heading', { name: 'Default execution' })).toBeTruthy() + expect((screen.getByRole('combobox', { name: 'Effort' }) as HTMLSelectElement).value).toBe('high') + expect(screen.queryByRole('link', { name: 'General' })).toBeNull() + fireEvent.change(screen.getByRole('combobox', { name: 'Effort' }), { target: { value: 'low' } }) fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) - await waitFor(() => expect(patched).toEqual([{ timezone: 'Asia/Tokyo' }])) + await waitFor(() => expect(patched).toEqual([{ defaultEffort: 'low' }])) fireEvent.click(screen.getByRole('link', { name: 'Preferences' })) expect((await screen.findByRole('combobox', { name: 'Timezone' }) as HTMLSelectElement).value).toBe('Europe/Madrid') }) -it('keeps personal and installation drafts separate when saving one page', async () => { - stubFetch() - renderSettings('/settings/personal') - fireEvent.change(await screen.findByRole('combobox', { name: 'Timezone' }), { target: { value: 'Europe/Madrid' } }) - fireEvent.click(screen.getByRole('link', { name: 'General' })) - fireEvent.change(await screen.findByRole('combobox', { name: 'Timezone' }), { target: { value: 'Asia/Tokyo' } }) - fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) - await waitFor(() => expect(patched).toEqual([{ timezone: 'Asia/Tokyo' }])) - expect(personalPatched).toEqual([]) - fireEvent.click(screen.getByRole('link', { name: /Preferences/ })) - expect((await screen.findByRole('combobox', { name: 'Timezone' }) as HTMLSelectElement).value).toBe('Europe/Madrid') - fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) - await waitFor(() => expect(personalPatched).toEqual([{ timezone: 'Europe/Madrid' }])) + +it('focuses a personal field after preferences finish loading', async () => { + stubFetch(false) + mockScroll() + let finish!: (settings: PersonalSettings) => void + vi.spyOn(api, 'getPersonalSettings').mockReturnValue(new Promise((resolve) => { finish = resolve })) + renderSettings('/settings/personal?field=timezone') + await screen.findByRole('heading', { name: 'Preferences' }) + expect(screen.queryByRole('combobox', { name: 'Timezone' })).toBeNull() + await act(async () => { finish({ timezone: 'UTC', gateParkDestinationId: null }) }) + await waitFor(() => expect(document.activeElement?.id).toBe('personal-timezone')) +}) + +it('redirects the removed General page to Preferences', async () => { + stubFetch(false) + renderSettings('/settings/general') + await screen.findByRole('heading', { name: 'Preferences' }) + expect(window.location.pathname).toBe('/settings/personal') + expect(await screen.findByRole('combobox', { name: 'Timezone' })).toBeTruthy() }) diff --git a/frontend/src/components/SettingsPages.tsx b/frontend/src/components/SettingsPages.tsx index 4fa7c7ca..51da6f34 100644 --- a/frontend/src/components/SettingsPages.tsx +++ b/frontend/src/components/SettingsPages.tsx @@ -8,6 +8,7 @@ import type { Account, AppSettingsProblems, UpdateAppsSettingsRequest, + UpdatePersonalSettingsRequest, UpdateSettingsRequest, } from '../api/types' import { appLabel } from '../apps/registry' @@ -22,7 +23,7 @@ import { AgentsPane, AppPane, ConnectionsPane, - GeneralPane, + PreferencesPane, McpServersPane, ProvidersPane, ServicesPane, @@ -44,7 +45,6 @@ const SECTIONS = [ { id: 'connections', label: 'Connections', group: 'Tools & access' }, { id: 'mcp', label: 'MCP servers', group: 'Tools & access' }, { id: 'skills', label: 'Skills', group: 'Tools & access' }, - { id: 'general', label: 'General', group: 'Installation' }, { id: 'personal', label: 'Preferences', group: 'Personal' }, { id: 'api-tokens', label: 'API tokens', group: 'Personal' }, ] @@ -104,7 +104,6 @@ export function SettingsPages({ const catalogsQuery = useQuery({ queryKey: ['providerCatalogs'], queryFn: api.providerCatalogs }) const executionQueries = [ settingsQuery, - personalQuery, appsQuery, harnessesQuery, providersQuery, @@ -129,9 +128,7 @@ export function SettingsPages({ const harnessByName = Object.fromEntries(harnesses.map((harness) => [harness.name, harness])) const harnessColor = harnessColors(harnesses.map((harness) => harness.name)) const savedDefaults = settingsQuery.data ? defaultsOf(settingsQuery.data) : null - const savedPersonal = personalQuery.data ? defaultsOf(personalQuery.data) : null - const [personalEdits, setPersonalEdits] = useState({}) - const [timezone, setTimezone] = useState(null) + const [personalEdits, setPersonalEdits] = useState({}) const [defaults, setDefaults] = useState(null) const [appEdits, setAppEdits] = useState>({}) const [appProblems, setAppProblems] = useState({}) @@ -147,25 +144,20 @@ export function SettingsPages({ const leaving = useRef(false) const heading = useRef(null) const errorNotice = useRef(null) - const effectiveTimezone = timezone ?? settingsQuery.data?.timezone ?? 'UTC' const effectiveDefaults = defaults ?? savedDefaults - const personalDefaults = personalQuery.data ? defaultsOf({ ...personalQuery.data, ...personalEdits }) : null const personalTimezone = personalEdits.timezone ?? personalQuery.data?.timezone ?? 'UTC' const personalChanges = Object.fromEntries( Object.entries(personalEdits).filter(([field, value]) => - value !== personalQuery.data?.[field as keyof UpdateSettingsRequest], + value !== personalQuery.data?.[field as keyof UpdatePersonalSettingsRequest], ), ) const timezones = useMemo(() => ['UTC', ...Intl.supportedValuesOf('timeZone')], []) const clock = useMemo(() => { void tick - return absTime(new Date().toISOString(), effectiveTimezone) - }, [tick, effectiveTimezone]) + return absTime(new Date().toISOString(), personalTimezone) + }, [tick, personalTimezone]) const dirtyPages = [ ...(Object.keys(personalChanges).length > 0 ? ['personal'] : []), - ...(timezone !== null && settingsQuery.data && timezone !== settingsQuery.data.timezone - ? ['general'] - : []), ...(defaults && savedDefaults && JSON.stringify(defaults) !== JSON.stringify(savedDefaults) ? ['agents'] : []), @@ -193,11 +185,11 @@ export function SettingsPages({ app && (app.settings.length || app.workflows.some((workflow) => workflow.fields.length)), ) const paneSection = app?.agents.length && !hasOptions ? 'agents' : appTab - const executionPage = section === 'agents' || section === 'personal' || Boolean(appName && paneSection === 'agents') + const executionPage = section === 'agents' || Boolean(appName && paneSection === 'agents') const Content = appName ? 'section' : 'main' const title = SECTIONS.find((entry) => entry.id === section)?.label ?? (app ? appLabel(app.name) : 'Settings') - const formPage = section === 'general' || section === 'agents' || section === 'personal' || Boolean(app && validAppPage) + const formPage = section === 'agents' || section === 'personal' || Boolean(app && validAppPage) const executionChanged = defaults && savedDefaults && @@ -212,19 +204,9 @@ export function SettingsPages({ .some((model) => model.id === defaults.defaultModel && model.enabled), ) - const personalExecutionChanged = personalDefaults && savedPersonal && ( - personalDefaults.defaultHarness !== savedPersonal.defaultHarness || - personalDefaults.defaultModel !== savedPersonal.defaultModel || - personalDefaults.defaultBilling !== savedPersonal.defaultBilling - ) - const personalExecutionInvalid = Boolean( - personalExecutionChanged && personalDefaults && !catalog - .modelsOf(personalDefaults.defaultHarness, personalDefaults.defaultBilling) - .some((model) => model.id === personalDefaults.defaultModel && model.enabled), - ) - useEffect(() => { if (location === '/settings') navigate('/settings/providers', { replace: true }) + if (location === '/settings/general') navigate('/settings/personal', { replace: true }) const movedApp = /^\/settings\/apps\/([^/]+)/.exec(location)?.[1] if (movedApp) navigate(`/apps/${movedApp}/settings`, { replace: true }) }, [location, navigate]) @@ -262,8 +244,7 @@ export function SettingsPages({ }, [dirtyPages.length]) function discard(page: string) { - if (page === 'general') setTimezone(null) - else if (page === 'personal') setPersonalEdits({}) + if (page === 'personal') setPersonalEdits({}) else if (page === 'agents') setDefaults(null) else setAppEdits((current) => { @@ -291,7 +272,7 @@ export function SettingsPages({ } async function save(pages: string[], proceed?: () => void) { - if (saving || !settingsQuery.data) return + if (saving || (pages.some((page) => page !== 'personal') && !settingsQuery.data)) return setSaving(true) let page = pages[0] ?? section try { @@ -303,16 +284,8 @@ export function SettingsPages({ return next }) if (page === 'personal') { - if (personalExecutionInvalid) - throw new Error('Choose a model with a connected credential before you save.') const saved = await api.updatePersonalSettings(personalChanges) queryClient.setQueryData(['personalSettings'], saved) - await queryClient.invalidateQueries({ queryKey: ['agents'] }) - await queryClient.invalidateQueries({ queryKey: ['appSettings'] }) - } else if (page === 'general') { - const saved = await api.updateSettings({ timezone: effectiveTimezone }) - queryClient.setQueryData(['settings'], saved) - await queryClient.invalidateQueries({ queryKey: ['personalSettings'] }) } else if (page === 'agents' && defaults && savedDefaults) { if (executionInvalid) throw new Error('Choose a model with a connected credential before you save.') @@ -323,7 +296,6 @@ export function SettingsPages({ } const saved = await api.updateSettings(body) queryClient.setQueryData(['settings'], saved) - await queryClient.invalidateQueries({ queryKey: ['personalSettings'] }) await queryClient.invalidateQueries({ queryKey: ['agents'] }) await queryClient.invalidateQueries({ queryKey: ['appSettings'] }) } else { @@ -445,7 +417,7 @@ export function SettingsPages({ // Drop the query after the focus, so a data refresh does not focus again. navigate(location, { replace: true }) } - }, [active, fieldTarget, location, navigate, executionReady, appsQuery.data, settingsQuery.data]) + }, [active, fieldTarget, location, navigate, executionReady, appsQuery.data, settingsQuery.data, personalQuery.data]) return (

Settings