feat: add online presence tracking with last_seen column and configurable timeout
Add `last_seen` column to users table with index, implement `set_last_seen` and `get_online_users` database functions, expose presence config env vars (`PRESENCE_TIMEOUT_SECONDS`, `PRESENCE_ONLINE_LIMIT`, `PRESENCE_ONLINE_MARGIN_SECONDS`), include `last_seen` in follow list responses, and update profile docs to mention online indicator.
This commit is contained in:
@@ -76,6 +76,9 @@ Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA
|
||||
| `PLAYWRIGHT_HEADLESS` | `1` in tests | Toggle headed mode |
|
||||
| `DEVPLACE_TEMPLATE_AUTO_RELOAD` | `1` (on) | Jinja template auto-reload. `1` stat-checks every template per render (dev hot-reload); set `0` in production so compiled templates stay cached in memory (the `prod`/`locust` make targets set it). |
|
||||
| `DEVPLACE_WEB_WORKERS` / `--workers` | `nproc` (prod) | Uvicorn worker count; `prod` derives it from the box core count (`make prod WEB_WORKERS=N` to override). |
|
||||
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | How long after a user's last activity they still count as online (the presence window). `config.PRESENCE_WRITE_SECONDS` (half of it) is the per-worker throttle between `last_seen` writes. |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Max avatars in the feed's live "Online now" panel (`config.PRESENCE_ONLINE_LIMIT`). |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin (hysteresis) before an online user is dropped: online at the timeout, offline only after timeout + this. Prevents dot/roster flicker at the boundary (`config.PRESENCE_ONLINE_MARGIN_SECONDS`). |
|
||||
| `DEVPLACE_DATA_DIR` | `<repo>/data` | Single root for ALL runtime/user-generated data OUTSIDE the package and OUTSIDE `/static`: the DB (`data/devplace.db`), `data/uploads/` (attachments + project files), `data/devii_*.db`, `data/keys/` (VAPID), `data/locks/`, `data/bot/`, zip/fork staging, and container workspaces. Defined once in `config.py` (`DATA_PATHS` registry + `ensure_data_dirs()`). Point at a volume in prod. Build contexts are ephemeral and live in the OS temp dir. |
|
||||
|
||||
## Architecture
|
||||
@@ -224,6 +227,9 @@ Every notification type (`NOTIFICATION_TYPES`) is independently toggleable per u
|
||||
### Live view relay (`devplacepy/services/live_view_relay.py`)
|
||||
`LiveViewRelayService` is a second lock-owner pub/sub bridge (default-enabled, 1s interval, registered in `main.py`) that **replaces per-client HTTP polling on the admin live views with server push, computing a snapshot only for topics that currently have subscribers**. Each tick it reads `pubsub.topics()`, matches concrete subscribed topics against the `VIEWS` registry `(regex, async compute, min_interval)`, throttles per topic, and publishes the **same payload shape the matching HTTP endpoint already returns** (so frontend render code is unchanged). Topics + cadence: `container.list` (4s), `project.{slug}.containers` (3s), `container.{uid}.detail` (4s), `container.{uid}.logs` (3s), `fleet.bots` (2s), `admin.services` (5s), `admin.services.{name}` (5s), `admin.ai-usage.{hours}` (15s), `admin.backups` (8s) - all admin-only by pub/sub policy. The frontend monitors (`ContainerInstance`/`ContainerList`/`ContainerManager`/`BotMonitor`/`ServiceMonitor`/`AiUsageMonitor`/`BackupMonitor`) subscribe via `window.app.pubsub` and keep a lengthened (15-30s) HTTP poll as initial-load + fallback. **The `admin.backups` view is published with `can_download=False`** so the primary-admin-only backup `download_url` never traverses the bus (the same withholding every backups endpoint applies); `BackupMonitor` establishes download capability from its authoritative HTTP poll and builds the `/admin/backups/{uid}/download` URL client-side. To add an admin live view: add one `VIEWS` row returning the endpoint payload and subscribe on the frontend. Keep durable/ordered/stateful/raw-I/O channels (messages, Devii, SEO/DeepSearch progress, container PTY) OFF pub/sub. See `pubreport.md` and AGENTS.md -> "Live view relay".
|
||||
|
||||
### Online presence (`devplacepy/services/presence.py`)
|
||||
User online status is a single **`users.last_seen`** (UTC ISO) column - never a new table, never per-load inserts. The write path is the `main.py` `track_presence` middleware: for the resolved current user on every non-asset request it calls `presence.touch(uid)`, which is a **per-worker in-memory throttle** (`_last_write` dict) that issues one in-place `UPDATE users SET last_seen=...` at most once per `config.PRESENCE_WRITE_SECONDS` (= half the timeout) per user per worker - so continuous browsing costs a dict lookup, not a write, and the row never grows. The read path is free: `presence.is_online(user_row)` compares `last_seen` to now against `config.PRESENCE_TIMEOUT_SECONDS` (`DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60), and profile/messages render the initial dot off the user row they already loaded (no extra query). It is the **only** cross-worker-correct approach here because pub/sub is in-process (SQLite is the sole shared medium). Registered as the Jinja global `is_online(user)` (`templating.py`), on `UserOut.last_seen` and `ProfileOut.profile_online`. **Live updates** ride the demand-driven lock-owner relay pattern: `PresenceRelayService` (`services/presence_relay.py`, default-enabled, 2s tick, registered in `main.py`) recomputes ONE global online set (dots + roster from the same set, so they never disagree) with **hysteresis** (`presence.stays_online`: online at `PRESENCE_TIMEOUT_SECONDS`, drops only after `+ PRESENCE_ONLINE_MARGIN_SECONDS` grace - quick on, slow off - to kill boundary flicker), batch-reads via `get_users_by_uids`, and publishes `{online, last_seen}` to `public.presence.{uid}` **only on change** (an `online`-bool transition or a first-seen subscriber) - never on a fixed interval, so an idle page emits nothing. Frontend `PresenceManager` (`static/js/PresenceManager.js`, `app.presence`) scans `[data-presence-uid]` elements, subscribes via `app.pubsub`, and treats each frame's `online` flag as **authoritative** (so a live dot never false-expires from the client clock); the `last_seen` staleness timer is only a fallback for elements that never got a frame (guests). **The old messaging presence was refactored onto this**: the per-worker, WS-connect-based `message_hub.is_online/last_seen` display path was removed (messaging kept only its socket connection tracking for delivery); the messages header presence span now carries `data-presence-uid` and is driven by the same `PresenceManager`, so chat presence is finally cross-worker correct. Reuse `presence.is_online` / the `public.presence.{uid}` topic / `PresenceManager` for any online indicator - never re-implement WS-connect presence. A **sitewide avatar corner dot** (green online / grey offline) reuses all of this via ONE partial `templates/_presence_dot.html` (a `data-presence-uid` span with no label, coloured by `PresenceManager` with no extra JS), included by `templates/_avatar_link.html` and the raw-avatar sites wrapped in `.avatar-badge`. The feed's **Online now** panel is the one place presence is queried by `last_seen` (`presence.online_users`/`online_candidates` -> `database.get_online_users`, indexed by `idx_users_last_seen`, ordered **alphabetically by username** so avatars keep a stable slot): the same relay republishes the shared `public.presence.roster` topic only when the online SET changes (a `frozenset` compare - reordering never republishes), and `static/js/OnlineUsers.js` (`app.onlineUsers`) renders it live. See AGENTS.md -> "Online presence".
|
||||
|
||||
### Devii user-defined tools (`services/devii/virtual_tools/`)
|
||||
Users invent new Devii tools in natural language ("when I say woeii, do Y"); each is stored per-owner and dynamically added to Devii's LLM tool list, and when called its handler **re-prompts Devii itself** (a self-eval sub-agent) with the stored prompt plus the user's single free-form `input`. CRUD is **Devii-only** (`tool_create`/`tool_list`/`tool_get`/`tool_update`/`tool_delete`, `handler="virtual_tool"`). **Dynamic tool list:** the session tool list is normally frozen, but `DeviiSession._refresh_tools()` runs at the top of every `_run_turn` and rewrites `self.tools` **in place** (`self.tools[:] = CATALOG.tool_schemas_for(...) + virtual_store.tool_schemas()`); since `Agent`/`AgenticController`/`react_loop` share that list object, new/edited/deleted tools take effect next turn with no rebuild - never reassign `self.tools`, always mutate in place. **Self-eval engine:** `agentic/controller.py` `_spawn` (shared by `_delegate`, `run_subagent`, and the new `eval` tool) runs `react_loop` under a contextvar **depth guard** (`agentic/state.py`, `MAX_EVAL_DEPTH=2`) so self-calls can't loop. **Store** `VirtualToolStore(db, owner_kind, owner_id)` over `devii_virtual_tools` (persistent for users, `memory_db()` for guests, built in `hub.get_or_create`). **Dispatch:** the dispatcher's unknown-name branch (`if action is None`) resolves a virtual tool via `self._virtual_tools.has/run` before erroring, so virtual tools live only in the store, not the static catalog. Name validation rejects built-in collisions (`set(CATALOG.by_name())`).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user