Files
optimclaw/src/history/store.rs
T
8f8cb7f7b1 feat: DB-backed user management, admin secrets provisioning, and multi-tenant isolation (#1626)
* feat: complete multi-tenant isolation — per-user budgets, model selection, heartbeat cycling

Finishes the remaining isolation work from phases 2–4 of #59:

Phase 2 (DB scoping): Fix /status and /list commands to use _for_user
DB variants instead of global queries that leaked cross-user job data.

Phase 3 (Runtime isolation): Per-user workspace in routine engine's
spawn_fire so lightweight routines run in the correct user context.
Per-user daily cost tracking in CostGuard with configurable budget via
MAX_COST_PER_USER_PER_DAY_CENTS. Multi-user heartbeat that cycles
through all users with routines, auto-detected from GATEWAY_USER_TOKENS.

Phase 4 (Provider/tools): Per-user model selection via preferred_model
setting — looked up from SettingsStore on first iteration, threaded
through ReasoningContext.model_override to CompletionRequest. Works
with providers that support per-request model overrides (NearAI).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: use selected_model setting key to match /model command persistence

The dispatcher was reading "preferred_model" but the /model command
(merged from staging) persists to "selected_model". Since set_setting
is already per-user scoped, using the same key makes /model work as
the per-user model override in multi-tenant mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override

Three follow-up fixes for multi-tenant isolation:

1. Multi-user heartbeat now runs memory hygiene per user before each
   heartbeat check, matching single-user heartbeat behavior.

2. /model command in multi-tenant mode only persists to per-user
   settings (selected_model) without calling set_model() on the shared
   LlmProvider. The per-request model_override in the dispatcher reads
   from the same setting. Added multi_tenant flag to AgentConfig
   (auto-detected from GATEWAY_USER_TOKENS).

3. RigAdapter now supports per-request model overrides by injecting the
   model name into rig-core's additional_params. OpenAI/Anthropic/Ollama
   API servers use last-key-wins for duplicate JSON keys, so the override
   takes effect via serde's flatten serialization order.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review — cost model attribution, heartbeat concurrency, pruning

Fixes from review comments on #1614:

- Cost tracking now uses the override model name (not active_model_name)
  when a per-user model override is active, for accurate attribution.
- Multi-user heartbeat runs per-user checks concurrently via JoinSet
  instead of sequentially, preventing one slow user from blocking others.
- Per-user failure counts tracked independently; users exceeding
  max_failures are skipped (matching single-user semantics).
- per_user_daily_cost HashMap pruned on day rollover to prevent
  unbounded growth in long-lived deployments.
- Doc comment fixed: says "routines" not "active routines".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: /status ownership, model persistence scoping, heartbeat robustness

Addresses second round of PR review on #1614:

- /status <job_id> DB path now validates job.user_id == requesting user
  before returning data (was missing ownership check, security fix).

- persist_selected_model takes user_id param instead of owner_id, and
  skips .env/TOML writes in multi-tenant mode (these are shared global
  files). handle_system_command now receives user_id from caller.

- JoinSet collection handles Err(JoinError) explicitly instead of
  silently dropping panicked tasks.

- Notification forwarder extracts owner_id from response metadata in
  multi-tenant mode for per-user routing instead of broadcasting to
  the agent owner.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: cost pricing, fire_manual workspace, heartbeat concurrency cap

Round 3 review fixes:

- Cost tracking passes None for cost_per_token when model override is
  active, letting CostGuard look up pricing by model name instead of
  using the default provider's rates (serrrfirat).

- fire_manual() now uses per-user workspace, matching spawn_fire()
  pattern (serrrfirat).

- Removed MULTI_TENANT env var — multi-tenant mode is auto-detected
  solely from GATEWAY_USER_TOKENS presence (serrrfirat + Copilot).

- Multi-user heartbeat capped at 8 concurrent tasks to avoid flooding
  the LLM provider (serrrfirat + Copilot).

- Fixed inject_model_override doc comment accuracy (Copilot).

- Added comment explaining multi-tenant notification routing priority
  (Copilot).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: user-scoped webhook endpoint for multi-tenant isolation

Adds POST /api/webhooks/u/{user_id}/{path} — a user-scoped webhook
endpoint that filters the routine lookup by user_id, preventing
cross-user webhook triggering when paths collide.

The existing /api/webhooks/{path} endpoint remains unchanged for
backward compatibility in single-user deployments.

Changes:
- get_webhook_routine_by_path gains user_id: Option<&str> param
- Both postgres and libsql implementations add AND user_id = ? filter
  when user_id is provided
- New webhook_trigger_user_scoped_handler extracts (user_id, path)
  from URL and passes to shared fire_webhook_inner logic
- Route registered on public router (webhooks are called by external
  services that can't send bearer tokens)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(db): add UserStore trait with users, api_tokens, invitations tables

Foundation for DB-backed user management (#1605):

- UserRecord, ApiTokenRecord, InvitationRecord types in db/mod.rs
- UserStore sub-trait (17 methods) added to Database supertrait
- PostgreSQL migration V14__users.sql (users, api_tokens, invitations)
- libSQL schema + incremental migration V14
- Full implementations for both PgBackend (via Store delegation) and
  LibSqlBackend (direct SQL in libsql/users.rs)
- authenticate_token JOINs api_tokens+users with active/non-revoked
  checks; has_any_users for bootstrap detection

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(web): DB-backed auth, user/token/invitation API handlers

Adds the web gateway layer for DB-backed user management (#1605):

Auth refactor:
- CombinedAuthState wraps env-var tokens (MultiAuthState) + optional
  DbAuthenticator for DB-backed token lookup with LRU cache (60s TTL,
  1024 max entries)
- auth_middleware tries env-var tokens first, then DB fallback
- From<MultiAuthState> impl for backward compatibility
- main.rs wires with_db_auth when database is available

API handlers (12 new endpoints):
- /api/admin/users — CRUD: create, list, detail, update, suspend, activate
- /api/tokens — create (returns plaintext once), list, revoke
- /api/invitations — create, list, accept (creates user + first token)

Token creation: 32 random bytes → hex plaintext, SHA-256 hash stored.
Invitation accept: validates hash + pending + not expired, creates
user record and first API token atomically.

All test files updated for CombinedAuthState type change.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: startup env-var user migration + UserStore integration tests

Completes the DB-backed user management feature (#1605):

- Startup migration: when GATEWAY_USER_TOKENS is set and the users
  table is empty, inserts env-var users + hashed tokens into DB.
  Logs deprecation notice when DB already has users.
- hash_token made pub for reuse in migration code.
- 10 integration tests for UserStore (libsql file-backed):
  - has_any_users bootstrap detection
  - create/get/get_by_email/list/update user lifecycle
  - token create → authenticate → revoke → reject cycle
  - suspended user tokens rejected
  - wrong-user token revoke returns false
  - invitation create → accept → user created
  - record_login and record_token_usage timestamps
- libSQL migration: removed FK constraints from V14 (incompatible
  with execute_batch inside transactions). Tables in both base SCHEMA
  and incremental migration for fresh and existing databases.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: remove GATEWAY_USER_TOKENS, fix review feedback

GATEWAY_USER_TOKENS never went to production — replaced entirely by
DB-backed user management via /api/admin/users and /api/tokens.

Removed:
- UserTokenConfig struct and GATEWAY_USER_TOKENS env var parsing
- user_tokens field from GatewayConfig
- GatewayChannel::new_multi_auth() constructor
- Env-var user migration block in main.rs (~90 lines)
- multi_tenant auto-detection from GATEWAY_USER_TOKENS (now runtime
  via db.has_any_users() in app.rs)

Review fixes (zmanian):
- User ID generation: UUID instead of display-name derivation (#1)
- Invitation accept moved to public router (no auth needed) (#3)
- libSQL get_invitation_by_hash aligned with postgres: filters
  status='pending' AND expires_at > now (#4)
- UUID parse: returns DatabaseError::Serialization instead of
  unwrap_or_default (#7)
- PostgreSQL SELECT * replaced with explicit column lists (#8)
- Sort order aligned (both backends use DESC) (#6)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add role-based access control (admin/member)

Adds a `role` field (admin|member) to user management:

Schema:
- `role TEXT NOT NULL DEFAULT 'member'` added to users table in both
  PostgreSQL V14 migration and libSQL schema/incremental migration
- UserRecord gains `role: String` field
- UserIdentity gains `role: String` field, populated from DB in
  DbAuthenticator and defaulting to "admin" for single-user mode

Access control:
- AdminUser extractor: returns 403 Forbidden if role != "admin"
- /api/admin/users/* handlers: require AdminUser (create, list,
  detail, update, suspend, activate)
- POST /api/invitations: requires AdminUser (only admins can invite)
- User creation accepts optional "role" param (defaults to "member")
- Invitation acceptance creates users with "member" role

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(web): add Users admin tab to web UI

Adds a Users tab to the web gateway UI for managing users, tokens,
and roles without needing direct API calls.

Features:
- User list table with ID, name, email, role, status, created date
- Create user form with display name, email, role selector
- Suspend/activate actions per user
- Create API token for any user (shows plaintext once with copy button)
- Role badges (admin highlighted, member muted)
- Non-admin users see "Admin access required" message
- Keyboard shortcut: Cmd/Ctrl+5 switches to Users tab

CSS:
- Reuses routines-table styles for the user list
- Badge, token-display, btn-small, btn-danger, btn-primary components

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: move Users to Settings subtab, bootstrap admin user on first run

- Moved Users from top-level tab to Settings sidebar subtab (under
  Skills, before Theme toggle)
- On first startup with empty users table, automatically creates an
  admin user from GATEWAY_USER_ID config with a corresponding API
  token from GATEWAY_AUTH_TOKEN. This ensures the owner appears in
  the Users panel immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: user creation shows token, + Token works, no password save popup

Three UI/UX fixes:

1. Create user now generates an initial API token and shows it in a
   copy-able banner instead of triggering the browser's password save
   dialog. Uses autocomplete="off" and type="text" for email field.

2. "+ Token" button works: exposed createTokenForUser/suspendUser/
   activateUser on window for inline onclick handlers in dynamically
   generated table rows. Token creation uses showTokenBanner helper.

3. Admin token creation: POST /api/tokens now accepts optional
   "user_id" field when the requesting user is admin, allowing
   token creation for other users from the Users panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: use event delegation for user action buttons (CSP compliance)

Inline onclick handlers are blocked by the Content-Security-Policy
(script-src 'self' without 'unsafe-inline'). Switched to data-action
attributes with a delegated click listener on the users table.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: add i18n for Users subtab, show login link on user creation

- Added 'settings.users' i18n key for English and Chinese
- Token banner now shows a full login link (domain/?token=xxx)
  with a Copy Link button, plus the raw token below
- Login link works automatically via existing ?token= auto-auth

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: token hash mismatch — hash hex string, not raw bytes

Critical auth bug: token creation hashed the raw 32 bytes
(hasher.update(token_bytes)) but authentication hashed the hex-encoded
string (hash_token(candidate) where candidate is the hex string the
user sends). This meant newly created tokens could never authenticate.

Fixed all 4 token creation sites (users, tokens, invitations create,
invitations accept) to use hash_token(&plaintext_token) which hashes
the hex string consistently with the auth lookup path.

Removed now-unused sha2::Digest imports from handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: remove invitation system

The invitation flow is redundant — admin create user already generates
a token and shows a login link. Invitations add complexity without
value until email integration exists.

Removed:
- InvitationRecord struct and 4 UserStore trait methods
- invitations table from V14 migration (postgres + both libsql schemas)
- PostgreSQL Store methods (create/get/accept/list invitations)
- libSQL UserStore invitation methods + row_to_invitation helper
- invitations.rs handler file (212 lines)
- /api/invitations routes (create, list, accept)
- test_invitation_lifecycle test

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: user deletion, self-service profile, per-user job limits, usage API

Four multi-tenancy improvements:

1. User deletion cascade (DELETE /api/admin/users/{id}):
   Deletes user and all data across 11 user-scoped tables (settings,
   secrets, routines, memory, jobs, conversations, etc.). Admin only.

2. Self-service profile (GET/PATCH /api/profile):
   Users can read and update their own display_name and metadata
   without admin privileges.

3. Per-user job concurrency (MAX_JOBS_PER_USER env var):
   Scheduler checks active_jobs_for(user_id) before dispatch.
   Prevents one user from exhausting all job slots.

4. Usage reporting (GET /api/admin/usage?user_id=X&period=day|week|month):
   Aggregates LLM costs from llm_calls via agent_jobs.user_id.
   Returns per-user, per-model breakdown of calls, tokens, and cost.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add TenantCtx for compile-time tenant isolation

Implements zmanian's architectural proposal from #1614 review:
two-tier scoped database access (TenantScope/AdminScope) so handler
code cannot accidentally bypass tenant scoping.

TenantScope (default): wraps user_id + Arc<dyn Database>, auto-binds
user_id on every operation. ID-based lookups return None for cross-
tenant resources. No escape hatch — forgetting to scope is a compile
error.

AdminScope (explicit opt-in): cross-tenant access for system-level
components (heartbeat, routine engine, self-repair, scheduler, worker).

TenantCtx bundles TenantScope + workspace + cost guard + per-user
rate limiting. Constructed once per request in handle_message, threaded
through all command handlers and ChatDelegate.

Key changes:
- New src/tenant.rs (~920 lines): TenantScope, AdminScope, TenantCtx,
  TenantRateState, TenantRateRegistry
- All command handlers: user_id: &str → ctx: &TenantCtx
- ChatDelegate: cost check/record/settings via self.tenant
- System components: store field changed to AdminScope
- Config: TENANT_MAX_LLM_CONCURRENT, TENANT_MAX_JOBS_CONCURRENT env vars
- Fixes bug: /status <job_id> cross-tenant leak (now auto-filtered)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR #1626 review feedback — bounded LRU cache, admin auth, FK cleanup

- Replace HashMap with lru::LruCache in DbAuthenticator so the token
  cache is hard-bounded at 1024 entries (evicts LRU, not just expired)
- Gate admin user endpoints (list/detail/update/suspend/activate) with
  AdminUser extractor so members get 403 instead of full access
- Add api_tokens to libSQL delete_user cleanup list to prevent orphaned
  tokens (libSQL has no FK cascade)
- Add regression tests for all three fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: update CA certificates in runtime Docker image

Ensures the root certificate bundle is current so TLS handshakes
to services like Supabase succeed on Railway.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: resolve CI failures — formatting, no-panics check

- Run cargo fmt on test code
- Replace .expect() with const NonZeroUsize in DbAuthenticator
- Add // safety: comments for test-only code in multi_tenant.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: switch PostgreSQL TLS from rustls to native-tls

rustls with rustls-native-certs fails TLS handshake on Railway's
slim container (empty or stale root cert store). native-tls delegates
to OpenSSL on Linux which handles system certs more reliably.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Adding user management api

* feat: admin secrets provisioning API + API documentation

- Add PUT/GET/DELETE /api/admin/users/{id}/secrets/{name} endpoints for
  application backends to provision per-user secrets (AES-256-GCM encrypted)
- Add secrets_store field to GatewayState with builder wiring
- Create docs/USER_MANAGEMENT_API.md with full API spec covering users,
  secrets, tokens, profile, and usage endpoints
- Update web gateway CLAUDE.md route table

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: add CatchPanicLayer to capture handler panics

Without this, panics in async handlers silently drop the connection
and the edge proxy returns a generic 503. Now panics are caught,
logged, and returned as 500 with the panic message.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address second-round review — transactional delete, overflow, error logging

- C1: Wrap PostgreSQL delete_user() in a transaction so partial cleanup
  can't leave users in a half-deleted state
- M2: Add job_events to delete cleanup (both backends) — FK to
  agent_jobs without CASCADE would cause FK violation
- H1/M4: Cap expires_in_days to 36500 before i64 cast (tokens + secrets)
- H2: Validate target user exists before creating admin token to prevent
  orphan tokens on libSQL
- H3: Log DB errors in DbAuthenticator::authenticate() instead of
  silently swallowing them as 401

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: revert to rustls with webpki-roots fallback for PostgreSQL TLS

native-tls/OpenSSL caused silent crashes (segfaults in C code) during
DB writes on Railway containers. Switch back to rustls but add
webpki-roots as a fallback when system certs are missing, which was
the original TLS handshake failure on slim container images.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: update Cargo.lock for rustls + webpki-roots

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* debug: add /api/debug/db-write endpoint to diagnose user insert failure

Temporary diagnostic endpoint that tests DB INSERT to users table
with full error logging. No auth required. Will be removed after
debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* perf: use cargo-chef in Dockerfile for dependency caching

Splits the build into planner/deps/builder stages. Dependencies are
only recompiled when Cargo.toml or Cargo.lock change. Source-only
changes skip straight to the final build stage.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* debug: add tracing to users_create_handler

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: guard created_by FK in user creation handler

The auth identity user_id (from owner_id scope) may not match any
user row in the DB, causing a FK violation on the created_by column.
Check that the referenced user exists before setting created_by.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: collapse GATEWAY_USER_ID into IRONCLAW_OWNER_ID

Remove the separate GATEWAY_USER_ID config. The gateway now uses
IRONCLAW_OWNER_ID (config.owner_id) directly for auth identity,
bootstrap user creation, and workspace scoping.

Previously, with_owner_scope() rebinds the auth identity to owner_id
while keeping default_sender_id as the gateway user_id. This caused
a FK constraint violation when creating users because the auth
identity ("default") didn't match any user in the DB ("nearai").

Changes:
- Remove GATEWAY_USER_ID env var and gateway_user_id from settings
- Remove user_id field from GatewayConfig
- Add owner_id parameter to GatewayChannel::new()
- Remove with_owner_scope() method
- Remove default_sender_id from GatewayState
- Remove sender override logic in chat/approval handlers
- Remove debug endpoint and tracing from prior debugging
- Update all tests and E2E fixtures

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: hide Users tab for non-admins, remove auth hint text

- Fetch /api/profile after login and hide the Users settings tab
  when the user's role is not admin
- Remove the "Enter the GATEWAY_AUTH_TOKEN" hint from the login page
  since tokens are now managed via the admin panel, not .env files

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review feedback (auth 503, token expiry, CORS PATCH)

- DB auth errors now return 503 instead of 401 so outages are
  distinguishable from invalid tokens (serrrfirat H3)
- Cap expires_in_days to 36500 before i64 cast to prevent negative
  duration from u64 overflow (serrrfirat H1)
- Add PATCH to CORS allowed methods for profile/user update
  endpoints (Copilot)
- Stop leaking panic details in CatchPanicLayer response body

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: harden multi-tenant isolation — review fixes from #1614

- Add conversation ownership checks in TenantScope: add_conversation_message,
  touch_conversation, list_conversation_messages (+ paginated),
  update_conversation_metadata_field, get_conversation_metadata now return
  NotFound for conversations not owned by the tenant (cross-tenant data leak)
- Fix multi-user heartbeat: clear notify_user_id per runner so notifications
  persist to the correct user, not the shared config target
- Move hygiene tasks into bounded JoinSet instead of unbounded tokio::spawn
- Revert send_notification to private visibility (only used within module)
- Use effective_model_name() for cost attribution in dispatcher so providers
  that ignore per-request model overrides report the actual model used
- Fix inject_model_override doc comment; add 3 unit tests
- Fix heartbeat doc comment ("routines" not "active routines")

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add Jobs, Cost, Last Active columns to admin Users table

Add UserSummaryStats struct and user_summary_stats() batch query to the
UserStore trait (both PostgreSQL and libSQL backends). The admin users
list endpoint now fetches per-user aggregates (job count, total LLM
spend, most recent activity) in a single query and includes them inline
in the response. The frontend Users table displays three new columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review comments and CI formatting failures

CI fixes:
- cargo fmt fixes in cli/mod.rs and db/tls.rs

Security/correctness (from Copilot + serrrfirat + pranavraja99 reviews):
- Token create: reject expires_in_days > 36500 with 400 instead of silent clamp
- Token create: return 404 when admin targets non-existent user
- User create: map duplicate email constraint violations to 409 Conflict
- User create: remove unnecessary DB roundtrip for created_by (use AdminUser directly)
- DB auth: log warn on DB lookup failures instead of silently swallowing errors
- libSQL: add FK constraints on users.created_by and api_tokens.user_id

Config fixes:
- agent.multi_tenant: resolve from AGENT_MULTI_TENANT env var instead of hardcoding false
- heartbeat.multi_tenant: fix doc comment to match actual env-var-based behavior

UI fix:
- showTokenBanner: pass correct title ("Token created!" vs "User created!")

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address remaining review comments (round 2)

- Secrets handlers: normalize name to lowercase before store operations,
  validate target user_id exists (returns 404 if not found)
- libSQL: propagate cost parsing errors instead of unwrap_or_default()
  in both user_usage_stats and user_summary_stats
- users_list_handler: propagate user_summary_stats DB errors (was
  silently swallowed with unwrap_or_default)
- loadUsers: distinguish 401/403 (admin required) from other errors
- Docs: fix users.id type (TEXT not UUID), remove "invitation flow"
  from V14 migration comment

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: i18n for Users tab, atomic user+token creation, transactional delete_user

i18n:
- Add 31 translation keys for all Users tab strings (en + zh-CN)
- Wire data-i18n attributes on HTML elements (headings, buttons, inputs,
  table headers, empty state)
- Replace all hard-coded strings in app.js with I18n.t() calls

Atomic user+token creation:
- Add create_user_with_token() to UserStore trait
- PostgreSQL: wraps both INSERTs in conn.transaction() with auto-rollback
- libSQL: wraps in explicit BEGIN/COMMIT with ROLLBACK on error
- Handler uses single atomic call instead of two separate operations

Transactional delete_user for libSQL:
- Wrap multi-table DELETE cascade in BEGIN/COMMIT transaction
- ROLLBACK on any error to prevent partial cleanup / inconsistent state
- Matches the PostgreSQL implementation which already used transactions

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: revert V14 migration to match deployed checksum [skip-regression-check]

Refinery checksums applied migrations — editing V14__users.sql after
it was already applied causes deployment failures. Revert the cosmetic
comment changes (added in df40b22f) to restore the original checksum.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: bootstrap onboarding flow for multi-tenant users

The bootstrap greeting and workspace seeding only ran for the owner
workspace at startup, so new users created via the admin API never
received the welcome message or identity files (BOOTSTRAP.md, SOUL.md,
AGENTS.md, USER.md, etc.).

Three fixes:
- tenant_ctx(): seed per-user workspace on first creation via
  seed_if_empty(), which writes identity files and sets
  bootstrap_pending when the workspace is truly fresh
- handle_message(): check take_bootstrap_pending() on the tenant
  workspace (not the owner workspace) and persist the greeting to
  the user's own assistant conversation + broadcast via SSE
- WorkspacePool: seed new per-user workspaces in the web gateway
  so memory tools also see identity files immediately

The existing single-user bootstrap in Agent::run() is preserved for
non-multi-tenant deployments.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address remaining PR review comments (round 3)

- Docs: fix metadata description from "merge patch" to "full replacement"
- Secrets: reject expires_in_days > 36500 with 400 (was silently clamped)
- libSQL: CAST(SUM(cost) AS TEXT) in user_usage_stats and user_summary_stats
  to prevent SQLite numeric coercion from crashing get_text() — this was
  the root cause of the Copilot "SUM returns numeric type" comments
- Add 3 regression tests: user_summary_stats (empty + with data) and
  user_usage_stats (multi-model aggregation)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: add role change support for users (admin/member toggle)

- Add update_user_role() to UserStore trait + both backends (PostgreSQL
  and libSQL)
- Extend PATCH /api/admin/users/{id} to accept optional "role" field
  with validation (must be "admin" or "member")
- Add "Make Admin" / "Make Member" toggle button in Users table actions
- Add i18n keys for role change (en + zh-CN)
- Update API docs to document the role field on PATCH
- Fix test helpers to use fmt_ts() for timestamps (was using SQLite
  datetime('now') which produces incompatible format for string comparison)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: show live LLM spend in Users table instead of only DB-recorded costs [skip-regression-check]

Chat turns record LLM cost in CostGuard (in-memory) but don't create
agent_jobs/llm_calls DB rows — those are only written for background
jobs. The Users table was querying only from DB, so it showed $0.00
for users who only chatted.

Now supplements DB stats with CostGuard.daily_spend_for_user() —
the same source displayed in the status bar token counter. Shows
whichever is larger (DB historical total vs live daily spend).

Also falls back to last_login_at for "Last Active" when no DB job
activity exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: persist chat LLM calls to DB and fix usage stats query

Two root causes for zero usage stats:

1. ChatDelegate only recorded LLM costs to CostGuard (in-memory) —
   never to the llm_calls DB table. Added DB persistence via
   TenantScope.record_llm_call() after each chat LLM call, with
   job_id=NULL and conversation_id=thread_id.

2. user_summary_stats query only joined agent_jobs→llm_calls, missing
   chat calls (which have job_id=NULL). Redesigned query to start from
   llm_calls and resolve user_id via COALESCE(agent_jobs.user_id,
   conversations.user_id) — covers both job and chat LLM calls.

Both PostgreSQL and libSQL queries updated. TenantScope gets
record_llm_call() method. Tests updated for new query semantics.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review comments — input validation, cost semantics, panic safety [skip-regression-check]

- Validate display_name: trim whitespace, reject empty strings (create + update)
- Validate metadata: must be a JSON object, return 400 if not (admin + profile)
- secrets_list_handler: verify target user_id exists before listing
- Cost display: use DB total directly (chat calls now persist to DB),
  remove confusing max(db,live) CostGuard fallback
- CatchPanicLayer: truncate panic payload to 200 chars in log to limit
  potential sensitive data exposure

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address Copilot round 5 — docs, secrets consistency, token name, provider field [skip-regression-check]

- Docs: users.id note updated to "typically UUID v4 strings (bootstrap
  admin may use a custom ID)"
- secrets_list_handler: return 503 when DB store is None (was falling
  through to list secrets without user validation)
- tokens_create: trim + reject empty token name (matching display_name
  pattern)
- LlmCallRecord.provider: use llm_backend ("nearai","openai") instead
  of model_name() which returns the model identifier
- user_summary_stats zero-LLM users: acceptable — handler already falls
  back to 0 cost and last_login_at for missing entries

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: DB auth returns 503 on outage, scheduler counts only blocking jobs

From serrrfirat review:
- DB auth: return Err(()) on database errors so middleware returns 503
  instead of silently returning Ok(None) → 401 (auth miss)
- Scheduler: add parallel_blocking_count_for() that uses
  is_parallel_blocking() (Pending/InProgress/Stuck) instead of
  is_active() for per-user concurrency — Completed/Submitted jobs
  no longer count against MAX_JOBS_PER_USER

From Copilot:
- CLAUDE.md: fix secrets route paths from {id} to {user_id}
- token_hash: use .as_slice() instead of .to_vec() to avoid
  heap allocation on every token auth/creation call

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: immediate auth cache invalidation on security-critical actions (zmanian review #6)

Add DbAuthenticator::invalidate_user() that evicts all cached entries
for a user. Called after:
- Suspend user (immediate lockout, was 60s delay)
- Activate user (immediate access restoration)
- Role change (admin↔member takes effect immediately)
- Token revocation (revoked token can't be reused from cache)

The DbAuthenticator is shared (via Clone, which Arc-clones the cache)
between the auth middleware and GatewayState, so handlers can evict
entries from the same cache the middleware reads.

Also from zmanian's review:
- Items 1-5, 7-11 were already resolved in prior commits
- Item 12 (String→enum for status/role) is deferred as a broader refactor

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: last-admin protection, usage stats for chat calls, UTF-8 safe panic truncation

Last-admin protection:
- Suspend, delete, and role-demotion of the last active admin now
  return 409 Conflict instead of succeeding and locking out the admin API
- Helper is_last_admin() checks active admin count before destructive ops

Usage stats:
- user_usage_stats() now includes chat LLM calls (job_id=NULL) by
  joining via conversations.user_id, matching user_summary_stats()
- Both PostgreSQL and libSQL queries updated

Panic handler:
- Use floor_char_boundary(200) instead of byte-index [..200] to
  prevent panic on multi-byte UTF-8 characters in panic messages

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: workspace seed race, bootstrap atomicity, email trim, secrets upsert response [skip-regression-check]

- WorkspacePool: await seed_if_empty() synchronously after inserting
  into cache (drop lock first to avoid blocking), so callers see
  identity files immediately instead of racing a background task
- Bootstrap admin: use create_user_with_token() for atomic user+token
  creation, matching the admin create endpoint
- Email: trim whitespace, treat empty as None to prevent " " being
  stored and breaking uniqueness
- Secrets PUT: report "updated" vs "created" based on prior existence
- Last token_hash.to_vec() → .as_slice() in authenticate_token

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: disable unscoped webhook endpoint in multi-tenant mode [skip-regression-check]

The original /api/webhooks/{path} endpoint looks up routines across all
users. In multi-tenant mode, anyone who knows the webhook path + secret
could trigger another user's routine. Now returns 410 Gone with a
message pointing to the scoped endpoint /api/webhooks/u/{user_id}/{path}.

Detection uses state.db_auth.is_some() — present only when DB-backed
auth is enabled (multi-tenant). Single-user deployments are unaffected.

From: standardtoaster review comment

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: webhook multi-tenant check, secrets error propagation, stale doc comment [skip-regression-check]

- Webhook: use workspace_pool.is_some() instead of db_auth.is_some()
  for multi-tenant detection — db_auth is set for any DB deployment,
  workspace_pool is only set when has_any_users() was true at startup
- Secrets: propagate exists() errors instead of unwrap_or(false) so
  backend outages surface as 500 rather than incorrect "created" status
- Config: fix stale workspace_read_scopes comment referencing user_id

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 00:19:17 -07:00

2890 lines
96 KiB
Rust

//! PostgreSQL store for persisting agent data.
#[cfg(feature = "postgres")]
use std::collections::HashMap;
use chrono::{DateTime, Utc};
#[cfg(feature = "postgres")]
use deadpool_postgres::{Config, Pool};
use rust_decimal::Decimal;
use uuid::Uuid;
#[cfg(feature = "postgres")]
use crate::config::DatabaseConfig;
#[cfg(feature = "postgres")]
use crate::context::{ActionRecord, JobContext, JobState};
#[cfg(feature = "postgres")]
use crate::error::DatabaseError;
/// Record for an LLM call to be persisted.
#[derive(Debug, Clone)]
pub struct LlmCallRecord<'a> {
pub job_id: Option<Uuid>,
pub conversation_id: Option<Uuid>,
pub provider: &'a str,
pub model: &'a str,
pub input_tokens: u32,
pub output_tokens: u32,
pub cost: Decimal,
pub purpose: Option<&'a str>,
}
/// Database store for the agent.
#[cfg(feature = "postgres")]
pub struct Store {
pool: Pool,
}
#[cfg(feature = "postgres")]
impl Store {
/// Wrap an existing pool (useful when the caller already has a connection).
pub fn from_pool(pool: Pool) -> Self {
Self { pool }
}
/// Create a new store and connect to the database.
pub async fn new(config: &DatabaseConfig) -> Result<Self, DatabaseError> {
let mut cfg = Config::new();
cfg.url = Some(config.url().to_string());
cfg.pool = Some(deadpool_postgres::PoolConfig {
max_size: config.pool_size,
..Default::default()
});
let pool = crate::db::tls::create_pool(&cfg, config.ssl_mode)
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
// Test connection
let _ = pool.get().await?;
Ok(Self { pool })
}
/// Run database migrations (embedded via refinery).
pub async fn run_migrations(&self) -> Result<(), DatabaseError> {
use refinery::embed_migrations;
embed_migrations!("migrations");
let mut client = self.pool.get().await?;
migrations::runner()
.run_async(&mut **client)
.await
.map_err(|e| DatabaseError::Migration(e.to_string()))?;
Ok(())
}
/// Get a connection from the pool.
pub async fn conn(&self) -> Result<deadpool_postgres::Object, DatabaseError> {
Ok(self.pool.get().await?)
}
/// Get a clone of the database pool.
///
/// Useful for sharing the pool with other components like Workspace.
pub fn pool(&self) -> Pool {
self.pool.clone()
}
// ==================== Conversations ====================
/// Create a new conversation.
pub async fn create_conversation(
&self,
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
conn.execute(
"INSERT INTO conversations (id, channel, user_id, thread_id) VALUES ($1, $2, $3, $4)",
&[&id, &channel, &user_id, &thread_id],
)
.await?;
Ok(id)
}
/// Update conversation last activity.
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE conversations SET last_activity = NOW() WHERE id = $1",
&[&id],
)
.await?;
Ok(())
}
/// Add a message to a conversation.
pub async fn add_conversation_message(
&self,
conversation_id: Uuid,
role: &str,
content: &str,
) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
conn.execute(
"INSERT INTO conversation_messages (id, conversation_id, role, content) VALUES ($1, $2, $3, $4)",
&[&id, &conversation_id, &role, &content],
)
.await?;
// Update conversation activity
self.touch_conversation(conversation_id).await?;
Ok(id)
}
// ==================== Jobs ====================
/// Save a job context to the database.
pub async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let status = ctx.state.to_string();
let estimated_time_secs = ctx.estimated_duration.map(|d| d.as_secs() as i32);
conn.execute(
r#"
INSERT INTO agent_jobs (
id, conversation_id, title, description, category, status, source,
user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, max_tokens, total_tokens_used,
created_at, started_at, completed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
category = EXCLUDED.category,
status = EXCLUDED.status,
user_id = EXCLUDED.user_id,
estimated_cost = EXCLUDED.estimated_cost,
estimated_time_secs = EXCLUDED.estimated_time_secs,
actual_cost = EXCLUDED.actual_cost,
repair_attempts = EXCLUDED.repair_attempts,
max_tokens = EXCLUDED.max_tokens,
total_tokens_used = EXCLUDED.total_tokens_used,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at
"#,
&[
&ctx.job_id,
&ctx.conversation_id,
&ctx.title,
&ctx.description,
&ctx.category,
&status,
&"direct", // source
&ctx.user_id,
&ctx.budget,
&ctx.budget_token,
&ctx.bid_amount,
&ctx.estimated_cost,
&estimated_time_secs,
&ctx.actual_cost,
&(ctx.repair_attempts as i32),
&(ctx.max_tokens as i64),
&(ctx.total_tokens_used as i64),
&ctx.created_at,
&ctx.started_at,
&ctx.completed_at,
],
)
.await?;
Ok(())
}
/// Get a job by ID.
pub async fn get_job(&self, id: Uuid) -> Result<Option<JobContext>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT id, conversation_id, title, description, category, status, user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, max_tokens, total_tokens_used,
created_at, started_at, completed_at
FROM agent_jobs WHERE id = $1
"#,
&[&id],
)
.await?;
match row {
Some(row) => {
let status_str: String = row.get("status");
let state = parse_job_state(&status_str);
let estimated_time_secs: Option<i32> = row.get("estimated_time_secs");
Ok(Some(JobContext {
job_id: row.get("id"),
state,
user_id: row.get::<_, String>("user_id"),
requester_id: None,
conversation_id: row.get("conversation_id"),
title: row.get("title"),
description: row.get("description"),
category: row.get("category"),
budget: row.get("budget_amount"),
budget_token: row.get("budget_token"),
bid_amount: row.get("bid_amount"),
estimated_cost: row.get("estimated_cost"),
estimated_duration: estimated_time_secs
.map(|s| std::time::Duration::from_secs(s as u64)),
actual_cost: row
.get::<_, Option<Decimal>>("actual_cost")
.unwrap_or_default(),
repair_attempts: row.get::<_, i32>("repair_attempts") as u32,
created_at: row.get("created_at"),
started_at: row.get("started_at"),
completed_at: row.get("completed_at"),
transitions: Vec::new(), // Not loaded from DB for now
metadata: serde_json::Value::Null,
max_tokens: row.get::<_, Option<i64>>("max_tokens").unwrap_or(0) as u64,
total_tokens_used: row.get::<_, Option<i64>>("total_tokens_used").unwrap_or(0)
as u64,
extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
http_interceptor: None,
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(),
)),
// TODO(#661): persist user_timezone in agent_jobs table so
// background/routine jobs retain the session's timezone context.
user_timezone: "UTC".to_string(),
}))
}
None => Ok(None),
}
}
/// Update job status.
pub async fn update_job_status(
&self,
id: Uuid,
status: JobState,
failure_reason: Option<&str>,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let status_str = status.to_string();
conn.execute(
"UPDATE agent_jobs SET status = $2, failure_reason = $3 WHERE id = $1",
&[&id, &status_str, &failure_reason],
)
.await?;
Ok(())
}
/// Mark job as stuck.
pub async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE agent_jobs SET status = 'stuck', stuck_since = NOW() WHERE id = $1",
&[&id],
)
.await?;
Ok(())
}
/// Get stuck jobs.
pub async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query("SELECT id FROM agent_jobs WHERE status = 'stuck'", &[])
.await?;
Ok(rows.iter().map(|r| r.get("id")).collect())
}
// ==================== Actions ====================
/// Save a job action.
pub async fn save_action(
&self,
job_id: Uuid,
action: &ActionRecord,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let duration_ms = action.duration.as_millis() as i32;
let warnings_json = serde_json::to_value(&action.sanitization_warnings)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
conn.execute(
r#"
INSERT INTO job_actions (
id, job_id, sequence_num, tool_name, input, output_raw, output_sanitized,
sanitization_warnings, cost, duration_ms, success, error_message, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
"#,
&[
&action.id,
&job_id,
&(action.sequence as i32),
&action.tool_name,
&action.input,
&action.output_raw,
&action.output_sanitized,
&warnings_json,
&action.cost,
&duration_ms,
&action.success,
&action.error,
&action.executed_at,
],
)
.await?;
Ok(())
}
/// Get actions for a job.
pub async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, sequence_num, tool_name, input, output_raw, output_sanitized,
sanitization_warnings, cost, duration_ms, success, error_message, created_at
FROM job_actions WHERE job_id = $1 ORDER BY sequence_num
"#,
&[&job_id],
)
.await?;
let mut actions = Vec::new();
for row in rows {
let duration_ms: i32 = row.get("duration_ms");
let warnings_json: serde_json::Value = row.get("sanitization_warnings");
let warnings: Vec<String> = serde_json::from_value(warnings_json).unwrap_or_default();
actions.push(ActionRecord {
id: row.get("id"),
sequence: row.get::<_, i32>("sequence_num") as u32,
tool_name: row.get("tool_name"),
input: row.get("input"),
output_raw: row.get("output_raw"),
output_sanitized: row.get("output_sanitized"),
sanitization_warnings: warnings,
cost: row.get("cost"),
duration: std::time::Duration::from_millis(duration_ms as u64),
success: row.get("success"),
error: row.get("error_message"),
executed_at: row.get("created_at"),
});
}
Ok(actions)
}
// ==================== LLM Calls ====================
/// Record an LLM call.
pub async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
conn.execute(
r#"
INSERT INTO llm_calls (id, job_id, conversation_id, provider, model, input_tokens, output_tokens, cost, purpose)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
&[
&id,
&record.job_id,
&record.conversation_id,
&record.provider,
&record.model,
&(record.input_tokens as i32),
&(record.output_tokens as i32),
&record.cost,
&record.purpose,
],
)
.await?;
Ok(id)
}
// ==================== Estimation Snapshots ====================
/// Save an estimation snapshot for learning.
pub async fn save_estimation_snapshot(
&self,
job_id: Uuid,
category: &str,
tool_names: &[String],
estimated_cost: Decimal,
estimated_time_secs: i32,
estimated_value: Decimal,
) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
conn.execute(
r#"
INSERT INTO estimation_snapshots (id, job_id, category, tool_names, estimated_cost, estimated_time_secs, estimated_value)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&job_id,
&category,
&tool_names,
&estimated_cost,
&estimated_time_secs,
&estimated_value,
],
)
.await?;
Ok(id)
}
/// Update estimation snapshot with actual values.
pub async fn update_estimation_actuals(
&self,
id: Uuid,
actual_cost: Decimal,
actual_time_secs: i32,
actual_value: Option<Decimal>,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE estimation_snapshots SET actual_cost = $2, actual_time_secs = $3, actual_value = $4 WHERE id = $1",
&[&id, &actual_cost, &actual_time_secs, &actual_value],
)
.await?;
Ok(())
}
}
// ==================== Sandbox Jobs ====================
/// Record for a sandbox container job, persisted in the `agent_jobs` table
/// with `source = 'sandbox'`.
#[derive(Debug, Clone)]
pub struct SandboxJobRecord {
pub id: Uuid,
pub task: String,
pub status: String,
pub user_id: String,
pub project_dir: String,
pub success: Option<bool>,
pub failure_reason: Option<String>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
/// Serialized JSON of `Vec<CredentialGrant>` for restart support.
/// Stored in the `description` column of `agent_jobs` (unused for sandbox jobs).
pub credential_grants_json: String,
}
/// Summary of sandbox job counts grouped by status.
#[derive(Debug, Clone, Default)]
pub struct SandboxJobSummary {
pub total: usize,
pub creating: usize,
pub running: usize,
pub completed: usize,
pub failed: usize,
pub interrupted: usize,
}
/// Lightweight record for agent (non-sandbox) jobs, used by the web Jobs tab.
#[derive(Debug, Clone)]
pub struct AgentJobRecord {
pub id: Uuid,
pub title: String,
pub status: String,
pub user_id: String,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub failure_reason: Option<String>,
}
/// Summary counts for agent (non-sandbox) jobs.
#[derive(Debug, Clone, Default)]
pub struct AgentJobSummary {
pub total: usize,
pub pending: usize,
pub in_progress: usize,
pub completed: usize,
pub failed: usize,
pub stuck: usize,
}
impl AgentJobSummary {
/// Accumulate a status/count pair into the summary buckets.
pub fn add_count(&mut self, status: &str, count: usize) {
self.total += count;
match status {
"pending" => self.pending += count,
"in_progress" => self.in_progress += count,
"completed" | "submitted" | "accepted" => self.completed += count,
"failed" | "cancelled" => self.failed += count,
"stuck" => self.stuck += count,
_ => {}
}
}
}
#[cfg(feature = "postgres")]
impl Store {
/// Insert a new sandbox job into `agent_jobs`.
pub async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO agent_jobs (
id, title, description, status, source, user_id, project_dir,
success, failure_reason, created_at, started_at, completed_at
) VALUES ($1, $2, $3, $4, 'sandbox', $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
success = EXCLUDED.success,
failure_reason = EXCLUDED.failure_reason,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at
"#,
&[
&job.id,
&job.task,
&job.credential_grants_json,
&job.status,
&job.user_id,
&job.project_dir,
&job.success,
&job.failure_reason,
&job.created_at,
&job.started_at,
&job.completed_at,
],
)
.await?;
Ok(())
}
/// Get a sandbox job by ID.
pub async fn get_sandbox_job(
&self,
id: Uuid,
) -> Result<Option<SandboxJobRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT id, title, description, status, user_id, project_dir,
success, failure_reason, created_at, started_at, completed_at
FROM agent_jobs WHERE id = $1 AND source = 'sandbox'
"#,
&[&id],
)
.await?;
Ok(row.map(|r| SandboxJobRecord {
id: r.get("id"),
task: r.get("title"),
status: r.get("status"),
user_id: r.get("user_id"),
project_dir: r
.get::<_, Option<String>>("project_dir")
.unwrap_or_default(),
success: r.get("success"),
failure_reason: r.get("failure_reason"),
created_at: r.get("created_at"),
started_at: r.get("started_at"),
completed_at: r.get("completed_at"),
credential_grants_json: r.get::<_, String>("description"),
}))
}
/// List all sandbox jobs, most recent first.
pub async fn list_sandbox_jobs(&self) -> Result<Vec<SandboxJobRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, title, description, status, user_id, project_dir,
success, failure_reason, created_at, started_at, completed_at
FROM agent_jobs WHERE source = 'sandbox'
ORDER BY created_at DESC
"#,
&[],
)
.await?;
Ok(rows
.iter()
.map(|r| SandboxJobRecord {
id: r.get("id"),
task: r.get("title"),
status: r.get("status"),
user_id: r.get("user_id"),
project_dir: r
.get::<_, Option<String>>("project_dir")
.unwrap_or_default(),
success: r.get("success"),
failure_reason: r.get("failure_reason"),
created_at: r.get("created_at"),
started_at: r.get("started_at"),
completed_at: r.get("completed_at"),
credential_grants_json: r.get::<_, String>("description"),
})
.collect())
}
/// List sandbox jobs for a specific user, most recent first.
pub async fn list_sandbox_jobs_for_user(
&self,
user_id: &str,
) -> Result<Vec<SandboxJobRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, title, description, status, user_id, project_dir,
success, failure_reason, created_at, started_at, completed_at
FROM agent_jobs WHERE source = 'sandbox' AND user_id = $1
ORDER BY created_at DESC
"#,
&[&user_id],
)
.await?;
Ok(rows
.iter()
.map(|r| SandboxJobRecord {
id: r.get("id"),
task: r.get("title"),
status: r.get("status"),
user_id: r.get("user_id"),
project_dir: r
.get::<_, Option<String>>("project_dir")
.unwrap_or_default(),
success: r.get("success"),
failure_reason: r.get("failure_reason"),
created_at: r.get("created_at"),
started_at: r.get("started_at"),
completed_at: r.get("completed_at"),
credential_grants_json: r.get::<_, String>("description"),
})
.collect())
}
/// Get a summary of sandbox job counts by status for a specific user.
pub async fn sandbox_job_summary_for_user(
&self,
user_id: &str,
) -> Result<SandboxJobSummary, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'sandbox' AND user_id = $1 GROUP BY status",
&[&user_id],
)
.await?;
let mut summary = SandboxJobSummary::default();
for row in &rows {
let status: String = row.get("status");
let count: i64 = row.get("cnt");
let c = count as usize;
summary.total += c;
match status.as_str() {
"creating" => summary.creating += c,
"running" => summary.running += c,
"completed" => summary.completed += c,
"failed" => summary.failed += c,
"interrupted" => summary.interrupted += c,
_ => {}
}
}
Ok(summary)
}
/// Check if a sandbox job belongs to a specific user.
pub async fn sandbox_job_belongs_to_user(
&self,
job_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT 1 FROM agent_jobs WHERE id = $1 AND user_id = $2 AND source = 'sandbox'",
&[&job_id, &user_id],
)
.await?;
Ok(row.is_some())
}
/// Update sandbox job status and optional timestamps/result.
pub async fn update_sandbox_job_status(
&self,
id: Uuid,
status: &str,
success: Option<bool>,
message: Option<&str>,
started_at: Option<DateTime<Utc>>,
completed_at: Option<DateTime<Utc>>,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
UPDATE agent_jobs SET
status = $2,
success = COALESCE($3, success),
failure_reason = COALESCE($4, failure_reason),
started_at = COALESCE($5, started_at),
completed_at = COALESCE($6, completed_at)
WHERE id = $1 AND source = 'sandbox'
"#,
&[&id, &status, &success, &message, &started_at, &completed_at],
)
.await?;
Ok(())
}
/// Mark any sandbox jobs left in "running" or "creating" as "interrupted".
///
/// Called on startup to handle jobs that were running when the process died.
pub async fn cleanup_stale_sandbox_jobs(&self) -> Result<u64, DatabaseError> {
let conn = self.conn().await?;
let count = conn
.execute(
r#"
UPDATE agent_jobs SET
status = 'interrupted',
failure_reason = 'Process restarted',
completed_at = NOW()
WHERE source = 'sandbox' AND status IN ('running', 'creating')
"#,
&[],
)
.await?;
if count > 0 {
tracing::info!("Marked {} stale sandbox jobs as interrupted", count);
}
Ok(count)
}
/// Get a summary of sandbox job counts by status.
pub async fn sandbox_job_summary(&self) -> Result<SandboxJobSummary, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'sandbox' GROUP BY status",
&[],
)
.await?;
let mut summary = SandboxJobSummary::default();
for row in &rows {
let status: String = row.get("status");
let count: i64 = row.get("cnt");
let c = count as usize;
summary.total += c;
match status.as_str() {
"creating" => summary.creating += c,
"running" => summary.running += c,
"completed" => summary.completed += c,
"failed" => summary.failed += c,
"interrupted" => summary.interrupted += c,
_ => {}
}
}
Ok(summary)
}
/// List all agent (non-sandbox) jobs, most recent first.
pub async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, title, status, user_id, failure_reason,
created_at, started_at, completed_at
FROM agent_jobs WHERE source = 'direct'
ORDER BY created_at DESC
"#,
&[],
)
.await?;
Ok(rows
.iter()
.map(|r| AgentJobRecord {
id: r.get("id"),
title: r.get("title"),
status: r.get("status"),
user_id: r.get::<_, Option<String>>("user_id").unwrap_or_default(),
created_at: r.get("created_at"),
started_at: r.get("started_at"),
completed_at: r.get("completed_at"),
failure_reason: r.get("failure_reason"),
})
.collect())
}
pub async fn list_agent_jobs_for_user(
&self,
user_id: &str,
) -> Result<Vec<AgentJobRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, title, status, user_id, failure_reason,
created_at, started_at, completed_at
FROM agent_jobs WHERE source = 'direct' AND user_id = $1
ORDER BY created_at DESC
"#,
&[&user_id],
)
.await?;
Ok(rows
.iter()
.map(|r| AgentJobRecord {
id: r.get("id"),
title: r.get("title"),
status: r.get("status"),
user_id: r.get::<_, Option<String>>("user_id").unwrap_or_default(),
created_at: r.get("created_at"),
started_at: r.get("started_at"),
completed_at: r.get("completed_at"),
failure_reason: r.get("failure_reason"),
})
.collect())
}
/// Get the failure reason for a single agent job.
pub async fn get_agent_job_failure_reason(
&self,
id: Uuid,
) -> Result<Option<String>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT failure_reason FROM agent_jobs WHERE id = $1",
&[&id],
)
.await?;
Ok(row.and_then(|r| r.get::<_, Option<String>>("failure_reason")))
}
/// Summary counts for agent (non-sandbox) jobs.
pub async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'direct' GROUP BY status",
&[],
)
.await?;
let mut summary = AgentJobSummary::default();
for row in &rows {
let status: String = row.get("status");
let count: i64 = row.get("cnt");
summary.add_count(&status, count as usize);
}
Ok(summary)
}
pub async fn agent_job_summary_for_user(
&self,
user_id: &str,
) -> Result<AgentJobSummary, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'direct' AND user_id = $1 GROUP BY status",
&[&user_id],
)
.await?;
let mut summary = AgentJobSummary::default();
for row in &rows {
let status: String = row.get("status");
let count: i64 = row.get("cnt");
summary.add_count(&status, count as usize);
}
Ok(summary)
}
}
// ==================== Job Events ====================
/// A persisted job streaming event (from worker or Claude Code bridge).
#[derive(Debug, Clone)]
pub struct JobEventRecord {
pub id: i64,
pub job_id: Uuid,
pub event_type: String,
pub data: serde_json::Value,
pub created_at: DateTime<Utc>,
}
#[cfg(feature = "postgres")]
impl Store {
/// Persist a job event (fire-and-forget from orchestrator handler).
pub async fn save_job_event(
&self,
job_id: Uuid,
event_type: &str,
data: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO job_events (job_id, event_type, data)
VALUES ($1, $2, $3)
"#,
&[&job_id, &event_type, data],
)
.await?;
Ok(())
}
/// Load job events for a job, ordered by id.
///
/// When `limit` is `Some(n)`, returns the **most recent** `n` events
/// (ordered ascending by id). When `None`, returns all events.
pub async fn list_job_events(
&self,
job_id: Uuid,
limit: Option<i64>,
) -> Result<Vec<JobEventRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(n) = limit {
// Sub-select the last N rows by id DESC, then re-sort ASC.
conn.query(
r#"
SELECT id, job_id, event_type, data, created_at
FROM (
SELECT id, job_id, event_type, data, created_at
FROM job_events
WHERE job_id = $1
ORDER BY id DESC
LIMIT $2
) sub
ORDER BY id ASC
"#,
&[&job_id, &n],
)
.await?
} else {
conn.query(
r#"
SELECT id, job_id, event_type, data, created_at
FROM job_events
WHERE job_id = $1
ORDER BY id ASC
"#,
&[&job_id],
)
.await?
};
Ok(rows
.iter()
.map(|r| JobEventRecord {
id: r.get("id"),
job_id: r.get("job_id"),
event_type: r.get("event_type"),
data: r.get("data"),
created_at: r.get("created_at"),
})
.collect())
}
/// Update the job_mode column for a sandbox job.
pub async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE agent_jobs SET job_mode = $2 WHERE id = $1",
&[&id, &mode],
)
.await?;
Ok(())
}
/// Get the job_mode for a sandbox job.
pub async fn get_sandbox_job_mode(&self, id: Uuid) -> Result<Option<String>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT job_mode FROM agent_jobs WHERE id = $1", &[&id])
.await?;
Ok(row.map(|r| r.get("job_mode")))
}
}
// ==================== Routines ====================
#[cfg(feature = "postgres")]
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
#[cfg(feature = "postgres")]
impl Store {
/// Create a new routine.
pub async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let trigger_type = routine.trigger.type_tag();
let trigger_config = routine.trigger.to_config_json();
let action_type = routine.action.type_tag();
let action_config = routine.action.to_config_json();
let cooldown_secs = routine.guardrails.cooldown.as_secs() as i32;
let max_concurrent = routine.guardrails.max_concurrent as i32;
let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i32);
conn.execute(
r#"
INSERT INTO routines (
id, name, description, user_id, enabled,
trigger_type, trigger_config, action_type, action_config,
cooldown_secs, max_concurrent, dedup_window_secs,
notify_channel, notify_user, notify_on_success, notify_on_failure, notify_on_attention,
state, next_fire_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12,
$13, $14, $15, $16, $17,
$18, $19, $20, $21
)
"#,
&[
&routine.id,
&routine.name,
&routine.description,
&routine.user_id,
&routine.enabled,
&trigger_type,
&trigger_config,
&action_type,
&action_config,
&cooldown_secs,
&max_concurrent,
&dedup_window_secs,
&routine.notify.channel,
&routine.notify.user,
&routine.notify.on_success,
&routine.notify.on_failure,
&routine.notify.on_attention,
&routine.state,
&routine.next_fire_at,
&routine.created_at,
&routine.updated_at,
],
)
.await?;
Ok(())
}
/// Get a routine by ID.
pub async fn get_routine(&self, id: Uuid) -> Result<Option<Routine>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT * FROM routines WHERE id = $1", &[&id])
.await?;
row.map(|r| row_to_routine(&r)).transpose()
}
/// Get a routine by user_id and name.
pub async fn get_routine_by_name(
&self,
user_id: &str,
name: &str,
) -> Result<Option<Routine>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT * FROM routines WHERE user_id = $1 AND name = $2",
&[&user_id, &name],
)
.await?;
row.map(|r| row_to_routine(&r)).transpose()
}
/// List routines for a user.
pub async fn list_routines(&self, user_id: &str) -> Result<Vec<Routine>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT * FROM routines WHERE user_id = $1 ORDER BY name",
&[&user_id],
)
.await?;
rows.iter().map(row_to_routine).collect()
}
/// List all routines across all users.
pub async fn list_all_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query("SELECT * FROM routines ORDER BY name", &[])
.await?;
rows.iter().map(row_to_routine).collect()
}
/// List all enabled routines with event triggers (for event matching).
pub async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT * FROM routines WHERE enabled AND trigger_type IN ('event', 'system_event')",
&[],
)
.await?;
rows.iter().map(row_to_routine).collect()
}
/// Find an enabled webhook routine by its configured path (or fallback to ID).
pub async fn get_webhook_routine_by_path(
&self,
path: &str,
user_id: Option<&str>,
) -> Result<Option<Routine>, DatabaseError> {
let conn = self.conn().await?;
let row = if let Some(uid) = user_id {
conn.query_opt(
"SELECT * FROM routines WHERE enabled AND trigger_type = 'webhook' \
AND user_id = $2 \
AND (trigger_config->>'path' = $1 OR (trigger_config->>'path' IS NULL AND id::text = $1))",
&[&path, &uid],
)
.await?
} else {
conn.query_opt(
"SELECT * FROM routines WHERE enabled AND trigger_type = 'webhook' \
AND (trigger_config->>'path' = $1 OR (trigger_config->>'path' IS NULL AND id::text = $1))",
&[&path],
)
.await?
};
row.as_ref().map(row_to_routine).transpose()
}
/// List all enabled cron routines whose next_fire_at <= now.
pub async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
let conn = self.conn().await?;
let now = Utc::now();
let rows = conn
.query(
r#"
SELECT * FROM routines
WHERE enabled
AND trigger_type = 'cron'
AND next_fire_at IS NOT NULL
AND next_fire_at <= $1
"#,
&[&now],
)
.await?;
rows.iter().map(row_to_routine).collect()
}
/// Update a routine (full replacement of mutable fields).
pub async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let trigger_type = routine.trigger.type_tag();
let trigger_config = routine.trigger.to_config_json();
let action_type = routine.action.type_tag();
let action_config = routine.action.to_config_json();
let cooldown_secs = routine.guardrails.cooldown.as_secs() as i32;
let max_concurrent = routine.guardrails.max_concurrent as i32;
let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i32);
conn.execute(
r#"
UPDATE routines SET
name = $2, description = $3, enabled = $4,
trigger_type = $5, trigger_config = $6,
action_type = $7, action_config = $8,
cooldown_secs = $9, max_concurrent = $10, dedup_window_secs = $11,
notify_channel = $12, notify_user = $13,
notify_on_success = $14, notify_on_failure = $15, notify_on_attention = $16,
state = $17, next_fire_at = $18,
updated_at = now()
WHERE id = $1
"#,
&[
&routine.id,
&routine.name,
&routine.description,
&routine.enabled,
&trigger_type,
&trigger_config,
&action_type,
&action_config,
&cooldown_secs,
&max_concurrent,
&dedup_window_secs,
&routine.notify.channel,
&routine.notify.user,
&routine.notify.on_success,
&routine.notify.on_failure,
&routine.notify.on_attention,
&routine.state,
&routine.next_fire_at,
],
)
.await?;
Ok(())
}
/// Update runtime state after a routine fires.
pub async fn update_routine_runtime(
&self,
id: Uuid,
last_run_at: DateTime<Utc>,
next_fire_at: Option<DateTime<Utc>>,
run_count: u64,
consecutive_failures: u32,
state: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
UPDATE routines SET
last_run_at = $2, next_fire_at = $3,
run_count = $4, consecutive_failures = $5,
state = $6, updated_at = now()
WHERE id = $1
"#,
&[
&id,
&last_run_at,
&next_fire_at,
&(run_count as i64),
&(consecutive_failures as i32),
state,
],
)
.await?;
Ok(())
}
/// Delete a routine.
pub async fn delete_routine(&self, id: Uuid) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let count = conn
.execute("DELETE FROM routines WHERE id = $1", &[&id])
.await?;
Ok(count > 0)
}
// ==================== Routine Runs ====================
/// Record a routine run starting.
pub async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let status = run.status.to_string();
conn.execute(
r#"
INSERT INTO routine_runs (
id, routine_id, trigger_type, trigger_detail,
started_at, status, job_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&run.id,
&run.routine_id,
&run.trigger_type,
&run.trigger_detail,
&run.started_at,
&status,
&run.job_id,
],
)
.await?;
Ok(())
}
/// Complete a routine run.
pub async fn complete_routine_run(
&self,
id: Uuid,
status: RunStatus,
result_summary: Option<&str>,
tokens_used: Option<i32>,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let status_str = status.to_string();
let now = Utc::now();
conn.execute(
r#"
UPDATE routine_runs SET
completed_at = $2, status = $3,
result_summary = $4, tokens_used = $5
WHERE id = $1
"#,
&[&id, &now, &status_str, &result_summary, &tokens_used],
)
.await?;
Ok(())
}
/// List recent runs for a routine.
pub async fn list_routine_runs(
&self,
routine_id: Uuid,
limit: i64,
) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT * FROM routine_runs
WHERE routine_id = $1
ORDER BY started_at DESC
LIMIT $2
"#,
&[&routine_id, &limit],
)
.await?;
rows.iter().map(row_to_routine_run).collect()
}
/// Count currently running runs for a routine.
pub async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result<i64, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_one(
"SELECT COUNT(*) as cnt FROM routine_runs WHERE routine_id = $1 AND status = 'running'",
&[&routine_id],
)
.await?;
Ok(row.get("cnt"))
}
/// Batch-load concurrent run counts for multiple routines in a single query.
/// Returns a map where missing routine IDs default to 0.
#[cfg(feature = "postgres")]
pub async fn count_running_routine_runs_batch(
&self,
routine_ids: &[Uuid],
) -> Result<HashMap<Uuid, i64>, DatabaseError> {
if routine_ids.is_empty() {
return Ok(HashMap::new());
}
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT routine_id, COUNT(*) as cnt FROM routine_runs
WHERE routine_id = ANY($1) AND status = 'running'
GROUP BY routine_id",
&[&routine_ids],
)
.await?;
let mut counts = HashMap::new();
for row in rows {
let id: Uuid = row.get("routine_id");
let cnt: i64 = row.get("cnt");
counts.insert(id, cnt);
}
// Ensure all requested IDs are in the map (defaults to 0 for no running runs)
for id in routine_ids {
counts.entry(*id).or_insert(0);
}
Ok(counts)
}
/// Batch-load the most recent run status for multiple routines in a single query.
/// Uses a window function to pick only the latest run per routine.
#[cfg(feature = "postgres")]
pub async fn batch_get_last_run_status(
&self,
routine_ids: &[Uuid],
) -> Result<HashMap<Uuid, RunStatus>, DatabaseError> {
if routine_ids.is_empty() {
return Ok(HashMap::new());
}
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT DISTINCT ON (routine_id) routine_id, status
FROM routine_runs
WHERE routine_id = ANY($1)
ORDER BY routine_id, started_at DESC",
&[&routine_ids],
)
.await?;
let mut statuses = HashMap::new();
for row in rows {
let id: Uuid = row.get("routine_id");
let status_str: String = row.get("status");
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
statuses.insert(id, status);
}
}
Ok(statuses)
}
/// Link a routine run to a dispatched job.
pub async fn link_routine_run_to_job(
&self,
run_id: Uuid,
job_id: Uuid,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE routine_runs SET job_id = $1 WHERE id = $2",
&[&job_id, &run_id],
)
.await?;
Ok(())
}
/// List routine runs dispatched as full_job that have not yet been finalized.
pub async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT * FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
&[],
)
.await?;
rows.iter().map(row_to_routine_run).collect()
}
}
#[cfg(feature = "postgres")]
fn row_to_routine(row: &tokio_postgres::Row) -> Result<Routine, DatabaseError> {
let trigger_type: String = row.get("trigger_type");
let trigger_config: serde_json::Value = row.get("trigger_config");
let action_type: String = row.get("action_type");
let action_config: serde_json::Value = row.get("action_config");
let cooldown_secs: i32 = row.get("cooldown_secs");
let max_concurrent: i32 = row.get("max_concurrent");
let dedup_window_secs: Option<i32> = row.get("dedup_window_secs");
let trigger = Trigger::from_db(&trigger_type, trigger_config)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
let action = RoutineAction::from_db(&action_type, action_config)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
Ok(Routine {
id: row.get("id"),
name: row.get("name"),
description: row.get("description"),
user_id: row.get("user_id"),
enabled: row.get("enabled"),
trigger,
action,
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(cooldown_secs as u64),
max_concurrent: max_concurrent as u32,
dedup_window: dedup_window_secs.map(|s| std::time::Duration::from_secs(s as u64)),
},
notify: NotifyConfig {
channel: row.get("notify_channel"),
user: row.get("notify_user"),
on_attention: row.get("notify_on_attention"),
on_failure: row.get("notify_on_failure"),
on_success: row.get("notify_on_success"),
},
last_run_at: row.get("last_run_at"),
next_fire_at: row.get("next_fire_at"),
run_count: row.get::<_, i64>("run_count") as u64,
consecutive_failures: row.get::<_, i32>("consecutive_failures") as u32,
state: row.get("state"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
#[cfg(feature = "postgres")]
fn row_to_routine_run(row: &tokio_postgres::Row) -> Result<RoutineRun, DatabaseError> {
let status_str: String = row.get("status");
let status: RunStatus = status_str
.parse()
.map_err(|e: crate::error::RoutineError| DatabaseError::Serialization(e.to_string()))?;
Ok(RoutineRun {
id: row.get("id"),
routine_id: row.get("routine_id"),
trigger_type: row.get("trigger_type"),
trigger_detail: row.get("trigger_detail"),
started_at: row.get("started_at"),
completed_at: row.get("completed_at"),
status,
result_summary: row.get("result_summary"),
tokens_used: row.get("tokens_used"),
job_id: row.get("job_id"),
created_at: row.get("created_at"),
})
}
// ==================== Conversation Persistence ====================
/// Summary of a conversation for the thread list.
#[derive(Debug, Clone)]
pub struct ConversationSummary {
pub id: Uuid,
/// First user message, truncated to 100 chars.
pub title: Option<String>,
pub message_count: i64,
pub started_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
/// Thread type extracted from metadata (e.g. "assistant", "thread").
pub thread_type: Option<String>,
/// Channel that owns this conversation (e.g. "gateway", "telegram", "routine").
pub channel: String,
}
/// A single message in a conversation.
#[derive(Debug, Clone)]
pub struct ConversationMessage {
pub id: Uuid,
pub role: String,
pub content: String,
pub created_at: DateTime<Utc>,
}
#[cfg(feature = "postgres")]
impl Store {
/// Ensure a conversation row exists for a given UUID.
///
/// Returns `true` when the row is inserted or refreshed for the same
/// `(channel, user_id)`. Returns `false` when the UUID already exists but
/// belongs to a different owner/channel.
pub async fn ensure_conversation(
&self,
id: Uuid,
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let affected = conn
.execute(
r#"
INSERT INTO conversations (id, channel, user_id, thread_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET last_activity = NOW()
WHERE conversations.user_id = EXCLUDED.user_id
AND conversations.channel = EXCLUDED.channel
"#,
&[&id, &channel, &user_id, &thread_id],
)
.await?;
Ok(affected > 0)
}
/// List conversations with a title derived from the first user message.
pub async fn list_conversations_with_preview(
&self,
user_id: &str,
channel: &str,
limit: i64,
) -> Result<Vec<ConversationSummary>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT
c.id,
c.started_at,
c.last_activity,
c.metadata,
c.channel,
(SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id AND m.role = 'user') AS message_count,
(SELECT LEFT(m2.content, 100)
FROM conversation_messages m2
WHERE m2.conversation_id = c.id AND m2.role = 'user'
ORDER BY m2.created_at ASC
LIMIT 1
) AS title
FROM conversations c
WHERE c.user_id = $1 AND c.channel = $2
ORDER BY c.last_activity DESC
LIMIT $3
"#,
&[&user_id, &channel, &limit],
)
.await?;
Ok(rows
.iter()
.map(|r| {
let metadata: serde_json::Value = r.get("metadata");
let thread_type = metadata
.get("thread_type")
.and_then(|v| v.as_str())
.map(String::from);
let sql_title: Option<String> = r.get("title");
let title = sql_title.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
ConversationSummary {
id: r.get("id"),
title,
message_count: r.get("message_count"),
started_at: r.get("started_at"),
last_activity: r.get("last_activity"),
thread_type,
channel: r.get("channel"),
}
})
.collect())
}
/// List conversations across all channels with a title derived from the first user message.
pub async fn list_conversations_all_channels(
&self,
user_id: &str,
limit: i64,
) -> Result<Vec<ConversationSummary>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT
c.id,
c.started_at,
c.last_activity,
c.metadata,
c.channel,
(SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id AND m.role = 'user') AS message_count,
(SELECT LEFT(m2.content, 100)
FROM conversation_messages m2
WHERE m2.conversation_id = c.id AND m2.role = 'user'
ORDER BY m2.created_at ASC
LIMIT 1
) AS title
FROM conversations c
WHERE c.user_id = $1
ORDER BY c.last_activity DESC
LIMIT $2
"#,
&[&user_id, &limit],
)
.await?;
Ok(rows
.iter()
.map(|r| {
let metadata: serde_json::Value = r.get("metadata");
let thread_type = metadata
.get("thread_type")
.and_then(|v| v.as_str())
.map(String::from);
// For routine/heartbeat threads, derive title from metadata
// since they may have no user messages.
let sql_title: Option<String> = r.get("title");
let title = sql_title.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
ConversationSummary {
id: r.get("id"),
title,
message_count: r.get("message_count"),
started_at: r.get("started_at"),
last_activity: r.get("last_activity"),
thread_type,
channel: r.get("channel"),
}
})
.collect())
}
/// Get or create a persistent conversation for a routine.
///
/// Looks for a conversation where `metadata->>'routine_id' = routine_id`.
/// Creates one if it doesn't exist. Uses INSERT ON CONFLICT to avoid
/// TOCTOU races under concurrent routine executions.
pub async fn get_or_create_routine_conversation(
&self,
routine_id: Uuid,
routine_name: &str,
user_id: &str,
) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
let rid = routine_id.to_string();
// Attempt insert first; the partial unique index
// uq_conv_routine(user_id, (metadata->>'routine_id')) prevents duplicates.
let new_id = Uuid::new_v4();
let metadata = serde_json::json!({
"thread_type": "routine",
"routine_id": routine_id.to_string(),
"routine_name": routine_name,
});
conn.execute(
r#"
INSERT INTO conversations (id, channel, user_id, metadata)
VALUES ($1, 'routine', $2, $3)
ON CONFLICT (user_id, (metadata->>'routine_id'))
WHERE metadata->>'routine_id' IS NOT NULL
DO NOTHING
"#,
&[&new_id, &user_id, &metadata],
)
.await?;
// Select back — always returns the winner.
let row = conn
.query_one(
r#"
SELECT id FROM conversations
WHERE user_id = $1 AND metadata->>'routine_id' = $2
LIMIT 1
"#,
&[&user_id, &rid],
)
.await?;
Ok(row.get("id"))
}
/// Get or create the singleton heartbeat conversation for a user.
///
/// Looks for a conversation where `metadata->>'thread_type' = 'heartbeat'`.
/// Creates one if it doesn't exist. Uses INSERT ON CONFLICT to avoid
/// TOCTOU races under concurrent heartbeat sends.
pub async fn get_or_create_heartbeat_conversation(
&self,
user_id: &str,
) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
// Attempt insert; the partial unique index
// uq_conv_heartbeat(user_id) prevents duplicates.
let new_id = Uuid::new_v4();
let metadata = serde_json::json!({
"thread_type": "heartbeat",
});
conn.execute(
r#"
INSERT INTO conversations (id, channel, user_id, metadata)
VALUES ($1, 'heartbeat', $2, $3)
ON CONFLICT (user_id)
WHERE metadata->>'thread_type' = 'heartbeat'
DO NOTHING
"#,
&[&new_id, &user_id, &metadata],
)
.await?;
// Select back — always returns the winner.
let row = conn
.query_one(
r#"
SELECT id FROM conversations
WHERE user_id = $1 AND metadata->>'thread_type' = 'heartbeat'
LIMIT 1
"#,
&[&user_id],
)
.await?;
Ok(row.get("id"))
}
/// Get or create the singleton "assistant" conversation for a user+channel.
///
/// Looks for a conversation where `metadata->>'thread_type' = 'assistant'`.
/// Creates one if it doesn't exist.
pub async fn get_or_create_assistant_conversation(
&self,
user_id: &str,
channel: &str,
) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
// Try to find existing assistant conversation
let row = conn
.query_opt(
r#"
SELECT id FROM conversations
WHERE user_id = $1 AND channel = $2 AND metadata->>'thread_type' = 'assistant'
LIMIT 1
"#,
&[&user_id, &channel],
)
.await?;
if let Some(row) = row {
return Ok(row.get("id"));
}
// Create a new assistant conversation
let id = Uuid::new_v4();
let metadata = serde_json::json!({"thread_type": "assistant", "title": "Assistant"});
conn.execute(
r#"
INSERT INTO conversations (id, channel, user_id, metadata)
VALUES ($1, $2, $3, $4)
"#,
&[&id, &channel, &user_id, &metadata],
)
.await?;
Ok(id)
}
/// Create a conversation with specific metadata.
pub async fn create_conversation_with_metadata(
&self,
channel: &str,
user_id: &str,
metadata: &serde_json::Value,
) -> Result<Uuid, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
conn.execute(
"INSERT INTO conversations (id, channel, user_id, metadata) VALUES ($1, $2, $3, $4)",
&[&id, &channel, &user_id, metadata],
)
.await?;
Ok(id)
}
/// Check whether a conversation belongs to the given user.
pub async fn conversation_belongs_to_user(
&self,
conversation_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT 1 FROM conversations WHERE id = $1 AND user_id = $2",
&[&conversation_id, &user_id],
)
.await?;
Ok(row.is_some())
}
/// Load messages for a conversation with cursor-based pagination.
///
/// Returns `(messages_oldest_first, has_more)`.
/// Pass `before` as a cursor to load older messages.
pub async fn list_conversation_messages_paginated(
&self,
conversation_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
let conn = self.conn().await?;
let fetch_limit = limit + 1; // Fetch one extra to determine has_more
let rows = if let Some(before_ts) = before {
conn.query(
r#"
SELECT id, role, content, created_at
FROM conversation_messages
WHERE conversation_id = $1 AND created_at < $2
ORDER BY created_at DESC
LIMIT $3
"#,
&[&conversation_id, &before_ts, &fetch_limit],
)
.await?
} else {
conn.query(
r#"
SELECT id, role, content, created_at
FROM conversation_messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT $2
"#,
&[&conversation_id, &fetch_limit],
)
.await?
};
let has_more = rows.len() as i64 > limit;
let take_count = (rows.len() as i64).min(limit) as usize;
// Rows come newest-first from DB; reverse so caller gets oldest-first
let mut messages: Vec<ConversationMessage> = rows
.iter()
.take(take_count)
.map(|r| ConversationMessage {
id: r.get("id"),
role: r.get("role"),
content: r.get("content"),
created_at: r.get("created_at"),
})
.collect();
messages.reverse();
Ok((messages, has_more))
}
/// Merge a single key into a conversation's metadata JSONB.
pub async fn update_conversation_metadata_field(
&self,
id: Uuid,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
let patch = serde_json::json!({ key: value });
conn.execute(
"UPDATE conversations SET metadata = metadata || $2 WHERE id = $1",
&[&id, &patch],
)
.await?;
Ok(())
}
/// Read the metadata JSONB for a conversation.
pub async fn get_conversation_metadata(
&self,
id: Uuid,
) -> Result<Option<serde_json::Value>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT metadata FROM conversations WHERE id = $1", &[&id])
.await?;
Ok(row.map(|r| r.get::<_, serde_json::Value>(0)))
}
/// Load all messages for a conversation, ordered chronologically.
pub async fn list_conversation_messages(
&self,
conversation_id: Uuid,
) -> Result<Vec<ConversationMessage>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, role, content, created_at
FROM conversation_messages
WHERE conversation_id = $1
ORDER BY created_at ASC
"#,
&[&conversation_id],
)
.await?;
Ok(rows
.iter()
.map(|r| ConversationMessage {
id: r.get("id"),
role: r.get("role"),
content: r.get("content"),
created_at: r.get("created_at"),
})
.collect())
}
}
#[cfg(feature = "postgres")]
fn parse_job_state(s: &str) -> JobState {
match s {
"pending" => JobState::Pending,
"in_progress" => JobState::InProgress,
"completed" => JobState::Completed,
"submitted" => JobState::Submitted,
"accepted" => JobState::Accepted,
"failed" => JobState::Failed,
"stuck" => JobState::Stuck,
"cancelled" => JobState::Cancelled,
_ => JobState::Pending,
}
}
// ==================== Tool Failures ====================
#[cfg(feature = "postgres")]
use crate::agent::BrokenTool;
#[cfg(feature = "postgres")]
impl Store {
/// Record a tool failure (upsert: increment count if exists).
pub async fn record_tool_failure(
&self,
tool_name: &str,
error_message: &str,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO tool_failures (tool_name, error_message, error_count, last_failure)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (tool_name) DO UPDATE SET
error_message = $2,
error_count = tool_failures.error_count + 1,
last_failure = NOW()
"#,
&[&tool_name, &error_message],
)
.await?;
Ok(())
}
/// Get tools that have failed more than `threshold` times and haven't been repaired.
pub async fn get_broken_tools(&self, threshold: i32) -> Result<Vec<BrokenTool>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT tool_name, error_message, error_count, first_failure, last_failure,
last_build_result, repair_attempts
FROM tool_failures
WHERE error_count >= $1 AND repaired_at IS NULL
ORDER BY error_count DESC
"#,
&[&threshold],
)
.await?;
Ok(rows
.iter()
.map(|row| BrokenTool {
name: row.get("tool_name"),
last_error: row.get("error_message"),
failure_count: row.get::<_, i32>("error_count") as u32,
first_failure: row.get("first_failure"),
last_failure: row.get("last_failure"),
last_build_result: row.get("last_build_result"),
repair_attempts: row.get::<_, i32>("repair_attempts") as u32,
})
.collect())
}
/// Mark a tool as repaired.
pub async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE tool_failures SET repaired_at = NOW(), error_count = 0 WHERE tool_name = $1",
&[&tool_name],
)
.await?;
Ok(())
}
/// Increment repair attempts for a tool.
pub async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE tool_failures SET repair_attempts = repair_attempts + 1 WHERE tool_name = $1",
&[&tool_name],
)
.await?;
Ok(())
}
}
// ==================== Settings ====================
/// A single setting row from the database.
#[derive(Debug, Clone)]
pub struct SettingRow {
pub key: String,
pub value: serde_json::Value,
pub updated_at: DateTime<Utc>,
}
#[cfg(feature = "postgres")]
impl Store {
/// Get a single setting by key.
pub async fn get_setting(
&self,
user_id: &str,
key: &str,
) -> Result<Option<serde_json::Value>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT value FROM settings WHERE user_id = $1 AND key = $2",
&[&user_id, &key],
)
.await?;
Ok(row.map(|r| r.get("value")))
}
/// Get a single setting with full metadata.
pub async fn get_setting_full(
&self,
user_id: &str,
key: &str,
) -> Result<Option<SettingRow>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT key, value, updated_at FROM settings WHERE user_id = $1 AND key = $2",
&[&user_id, &key],
)
.await?;
Ok(row.map(|r| SettingRow {
key: r.get("key"),
value: r.get("value"),
updated_at: r.get("updated_at"),
}))
}
/// Set a single setting (upsert).
pub async fn set_setting(
&self,
user_id: &str,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO settings (user_id, key, value, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, key) DO UPDATE SET
value = EXCLUDED.value,
updated_at = NOW()
"#,
&[&user_id, &key, value],
)
.await?;
Ok(())
}
/// Delete a single setting (reset to default).
pub async fn delete_setting(&self, user_id: &str, key: &str) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let count = conn
.execute(
"DELETE FROM settings WHERE user_id = $1 AND key = $2",
&[&user_id, &key],
)
.await?;
Ok(count > 0)
}
/// List all settings for a user (with metadata).
pub async fn list_settings(&self, user_id: &str) -> Result<Vec<SettingRow>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT key, value, updated_at FROM settings WHERE user_id = $1 ORDER BY key",
&[&user_id],
)
.await?;
Ok(rows
.iter()
.map(|r| SettingRow {
key: r.get("key"),
value: r.get("value"),
updated_at: r.get("updated_at"),
})
.collect())
}
/// Get all settings as a flat key-value map.
pub async fn get_all_settings(
&self,
user_id: &str,
) -> Result<std::collections::HashMap<String, serde_json::Value>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT key, value FROM settings WHERE user_id = $1",
&[&user_id],
)
.await?;
Ok(rows
.iter()
.map(|r| {
let key: String = r.get("key");
let value: serde_json::Value = r.get("value");
(key, value)
})
.collect())
}
/// Bulk-write settings (used for migration/import).
///
/// Each entry is upserted individually within a single transaction.
pub async fn set_all_settings(
&self,
user_id: &str,
settings: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<(), DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn.transaction().await?;
for (key, value) in settings {
tx.execute(
r#"
INSERT INTO settings (user_id, key, value, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, key) DO UPDATE SET
value = EXCLUDED.value,
updated_at = NOW()
"#,
&[&user_id, &key, value],
)
.await?;
}
tx.commit().await?;
Ok(())
}
/// Check if the settings table has any rows for a user.
pub async fn has_settings(&self, user_id: &str) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_one(
"SELECT COUNT(*) as cnt FROM settings WHERE user_id = $1",
&[&user_id],
)
.await?;
let count: i64 = row.get("cnt");
Ok(count > 0)
}
}
// ==================== Users / API Tokens / Invitations ====================
#[cfg(feature = "postgres")]
use crate::db::{ApiTokenRecord, UserRecord};
#[cfg(feature = "postgres")]
impl Store {
/// Create a new user record.
pub async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
&[
&user.id,
&user.email,
&user.display_name,
&user.status,
&user.role,
&user.created_at,
&user.updated_at,
&user.last_login_at,
&user.created_by,
&user.metadata,
],
)
.await?;
Ok(())
}
/// Get a user by their string id.
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// Get a user by email address.
pub async fn get_user_by_email(
&self,
email: &str,
) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// List users, optionally filtered by status.
pub async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = match status {
Some(s) => {
conn.query(
"SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC",
&[&s],
)
.await?
}
None => {
conn.query("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[])
.await?
}
};
Ok(rows.iter().map(row_to_user).collect())
}
/// Update a user's status.
pub async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
&[&status, &id],
)
.await?;
Ok(())
}
/// Update a user's role (admin/member).
pub async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2",
&[&role, &id],
)
.await?;
Ok(())
}
/// Update a user's display name and metadata.
pub async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET display_name = $1, metadata = $2, updated_at = NOW() WHERE id = $3",
&[&display_name, metadata, &id],
)
.await?;
Ok(())
}
/// Record a login timestamp for a user.
pub async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1",
&[&id],
)
.await?;
Ok(())
}
/// Create a new API token.
pub async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
let now = Utc::now();
conn.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&user_id,
&token_hash.as_slice(),
&token_prefix,
&name,
&expires_at,
&now,
],
)
.await?;
Ok(ApiTokenRecord {
id,
user_id: user_id.to_string(),
name: name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
/// Create a user and their initial API token atomically in a single transaction.
pub async fn create_user_with_token(
&self,
user: &UserRecord,
token_name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn.transaction().await?;
tx.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
&[
&user.id,
&user.email,
&user.display_name,
&user.status,
&user.role,
&user.created_at,
&user.updated_at,
&user.last_login_at,
&user.created_by,
&user.metadata,
],
)
.await?;
let id = Uuid::new_v4();
let now = Utc::now();
tx.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&user.id,
&token_hash.as_slice(),
&token_prefix,
&token_name,
&expires_at,
&now,
],
)
.await?;
tx.commit().await?;
Ok(ApiTokenRecord {
id,
user_id: user.id.clone(),
name: token_name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
/// List tokens for a user.
pub async fn list_api_tokens(
&self,
user_id: &str,
) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
FROM api_tokens
WHERE user_id = $1
ORDER BY created_at DESC
"#,
&[&user_id],
)
.await?;
Ok(rows.iter().map(row_to_api_token).collect())
}
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
pub async fn revoke_api_token(
&self,
token_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let count = conn
.execute(
"UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL",
&[&token_id, &user_id],
)
.await?;
Ok(count > 0)
}
/// Authenticate a token by hash. Returns the token record and its owning user
/// if the token is active (non-revoked, non-expired) and the user is active.
pub async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at,
u.id as u_id, u.email, u.display_name, u.status, u.role, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata
FROM api_tokens t
JOIN users u ON t.user_id = u.id
WHERE t.token_hash = $1
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > NOW())
AND u.status = 'active'
"#,
&[&token_hash.as_slice()],
)
.await?;
Ok(row.map(|r| {
let token = ApiTokenRecord {
id: r.get("id"),
user_id: r.get("user_id"),
name: r.get("name"),
token_prefix: r.get("token_prefix"),
expires_at: r.get("expires_at"),
last_used_at: r.get("last_used_at"),
created_at: r.get("created_at"),
revoked_at: r.get("revoked_at"),
};
let user = UserRecord {
id: r.get("u_id"),
email: r.get("email"),
display_name: r.get("display_name"),
status: r.get("status"),
role: r.get("role"),
created_at: r.get("u_created_at"),
updated_at: r.get("updated_at"),
last_login_at: r.get("last_login_at"),
created_by: r.get("created_by"),
metadata: r.get("metadata"),
};
(token, user)
}))
}
/// Update `last_used_at` for a token.
pub async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1",
&[&token_id],
)
.await?;
Ok(())
}
/// Check whether any user records exist.
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_one(
"SELECT EXISTS(SELECT 1 FROM users LIMIT 1) as has_users",
&[],
)
.await?;
Ok(row.get("has_users"))
}
/// Delete a user and all their data across all user-scoped tables.
/// Returns false if the user doesn't exist.
pub async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn
.transaction()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Delete from child tables first to avoid FK violations.
// job_events must come before agent_jobs (FK without CASCADE).
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots.
// conversations cascades to conversation_messages.
// memory_documents cascades to memory_chunks.
// routines cascades to routine_runs.
// api_tokens cascade automatically via FK on users.
for table in &[
"settings",
"heartbeat_state",
"tool_rate_limit_state",
"secret_usage_log",
"leak_detection_events",
"secrets",
"wasm_tools",
"routines",
"memory_documents",
"conversations",
] {
tx.execute(&format!("DELETE FROM {table} WHERE user_id = $1"), &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
}
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
tx.execute(
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = $1)",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.execute("DELETE FROM agent_jobs WHERE user_id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Nullify self-referencing created_by before deleting the user
tx.execute(
"UPDATE users SET created_by = NULL WHERE created_by = $1",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// api_tokens cascade automatically via FK
let result = tx
.execute("DELETE FROM users WHERE id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.commit()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(result > 0)
}
/// Get per-user LLM usage stats for a time period.
/// Aggregates from llm_calls via agent_jobs.user_id.
pub async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT COALESCE(j.user_id, c.user_id) as user_id,
l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE l.created_at >= $1
AND COALESCE(j.user_id, c.user_id) = $2
GROUP BY COALESCE(j.user_id, c.user_id), l.model
ORDER BY total_cost DESC
"#,
&[&since, &uid],
)
.await?
} else {
conn.query(
r#"
SELECT COALESCE(j.user_id, c.user_id) as user_id,
l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE l.created_at >= $1
GROUP BY COALESCE(j.user_id, c.user_id), l.model
ORDER BY total_cost DESC
"#,
&[&since],
)
.await?
};
let mut stats = Vec::with_capacity(rows.len());
for row in &rows {
stats.push(crate::db::UserUsageStats {
user_id: row.get("user_id"),
model: row.get("model"),
call_count: row.get("call_count"),
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
total_cost: row.get("total_cost"),
});
}
Ok(stats)
}
/// Lightweight per-user summary stats (job count, total cost, last active).
///
/// Aggregates from `llm_calls`, resolving user_id via either `agent_jobs`
/// (for background job calls) or `conversations` (for chat calls where
/// `job_id` is NULL).
pub async fn user_summary_stats(
&self,
user_id: Option<&str>,
) -> Result<Vec<crate::db::UserSummaryStats>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT
COALESCE(j.user_id, c.user_id) AS user_id,
COUNT(DISTINCT j.id) AS job_count,
COALESCE(SUM(l.cost), 0) AS total_cost,
MAX(l.created_at) AS last_active_at
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE COALESCE(j.user_id, c.user_id) = $1
GROUP BY COALESCE(j.user_id, c.user_id)
"#,
&[&uid],
)
.await?
} else {
conn.query(
r#"
SELECT
COALESCE(j.user_id, c.user_id) AS user_id,
COUNT(DISTINCT j.id) AS job_count,
COALESCE(SUM(l.cost), 0) AS total_cost,
MAX(l.created_at) AS last_active_at
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
GROUP BY COALESCE(j.user_id, c.user_id)
"#,
&[],
)
.await?
};
let mut stats = Vec::with_capacity(rows.len());
for row in &rows {
stats.push(crate::db::UserSummaryStats {
user_id: row.get("user_id"),
job_count: row.get("job_count"),
total_cost: row.get("total_cost"),
last_active_at: row.get("last_active_at"),
});
}
Ok(stats)
}
}
#[cfg(feature = "postgres")]
fn row_to_user(row: &tokio_postgres::Row) -> UserRecord {
UserRecord {
id: row.get("id"),
email: row.get("email"),
display_name: row.get("display_name"),
status: row.get("status"),
role: row.get("role"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
last_login_at: row.get("last_login_at"),
created_by: row.get("created_by"),
metadata: row.get("metadata"),
}
}
#[cfg(feature = "postgres")]
fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
ApiTokenRecord {
id: row.get("id"),
user_id: row.get("user_id"),
name: row.get("name"),
token_prefix: row.get("token_prefix"),
expires_at: row.get("expires_at"),
last_used_at: row.get("last_used_at"),
created_at: row.get("created_at"),
revoked_at: row.get("revoked_at"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_conversation_summary_has_channel_field() {
// Regression: ConversationSummary must include a `channel` field
// so the gateway can distinguish thread origins.
let summary = ConversationSummary {
id: Uuid::nil(),
title: Some("Hello".to_string()),
message_count: 1,
started_at: Utc::now(),
last_activity: Utc::now(),
thread_type: Some("thread".to_string()),
channel: "telegram".to_string(),
};
assert_eq!(summary.channel, "telegram");
}
#[test]
fn test_conversation_summary_channel_various_values() {
for ch in ["gateway", "routine", "heartbeat", "telegram", "signal"] {
let summary = ConversationSummary {
id: Uuid::nil(),
title: None,
message_count: 0,
started_at: Utc::now(),
last_activity: Utc::now(),
thread_type: None,
channel: ch.to_string(),
};
assert_eq!(summary.channel, ch);
}
}
/// Regression test: save_job must persist user_id and get_job must return it.
/// Requires a running PostgreSQL instance (integration tier).
#[cfg(feature = "postgres")]
#[tokio::test]
#[ignore]
async fn test_save_job_persists_user_id() {
use crate::config::Config;
use crate::context::JobContext;
let _ = dotenvy::dotenv();
let config = Config::from_env().await.expect("Failed to load config");
let store = Store::new(&config.database)
.await
.expect("Failed to connect to database");
store
.run_migrations()
.await
.expect("Failed to run migrations");
let ctx = JobContext::with_user("test-user-42", "PG user_id test", "regression test");
store.save_job(&ctx).await.unwrap();
let loaded = store.get_job(ctx.job_id).await.unwrap().unwrap();
assert_eq!(loaded.user_id, "test-user-42");
// Clean up
let conn = store.conn().await.unwrap();
conn.execute("DELETE FROM agent_jobs WHERE id = $1", &[&ctx.job_id])
.await
.unwrap();
}
}