547 lines
30 KiB
Markdown
547 lines
30 KiB
Markdown
# Database
|
||
|
||
## Connection and Init
|
||
|
||
`db::init_pool(path)` opens a SQLite connection pool with `create_if_missing=true`, then calls `create_tables()`. All `CREATE TABLE IF NOT EXISTS` statements are idempotent — safe to run on every startup.
|
||
|
||
The pool is opened in **WAL journal mode** (`synchronous=NORMAL`) with a **5 s `busy_timeout`**. The DB is written concurrently (chat loop, cron, and plugins such as the mobile-connector persisting its E2E `send_counter` before each send). Without `busy_timeout`, a contended write returns `SQLITE_BUSY` ("database is locked") immediately and the operation is lost — which silently dropped outbound mobile messages (the `inbox_update` never reached the device). WAL lets readers run alongside the single writer; `busy_timeout` makes a writer wait for the lock instead of failing.
|
||
|
||
---
|
||
|
||
## Table Schemas
|
||
|
||
### chat_sessions
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `title` | TEXT | nullable |
|
||
| `source` | TEXT | NOT NULL DEFAULT `'web'` |
|
||
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` (added via ALTER) |
|
||
| `is_interactive` | INTEGER | NOT NULL DEFAULT `1` (added via ALTER) |
|
||
| `is_ephemeral` | INTEGER | NOT NULL DEFAULT `0` (added via ALTER) |
|
||
| `run_context` | TEXT | nullable — `RunContext` JSON blob (e.g. `{"security_group":"admin"}`); formerly `run_context_id` (renamed in migration 10) |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
**`is_interactive`** — `1` when a real user is actively participating in the session (web, Telegram). `0` for fully automated sessions where all user-role messages are generated by the application (cron, tic).
|
||
|
||
**`is_ephemeral`** — `1` for short-lived task sessions (cron, tic) with no long-term conversational value. Memory sinks (e.g. Honcho) should skip ephemeral sessions.
|
||
|
||
Index: `idx_stack_session ON chat_sessions_stack(session_id)`
|
||
|
||
---
|
||
|
||
### chat_sessions_stack
|
||
|
||
Represents one agent call frame. Root frames have `depth=0`; sub-agent frames increment depth.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `session_id` | INTEGER | NOT NULL REFERENCES `chat_sessions(id)` |
|
||
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` |
|
||
| `agent_prompt` | TEXT | nullable (the `call_agent` prompt) |
|
||
| `depth` | INTEGER | NOT NULL DEFAULT 0 |
|
||
| `parent_tool_call_id` | INTEGER | nullable (links to `chat_llm_tools.id`) |
|
||
| `terminated_at` | TEXT | nullable; set when `dispatch_sub_agent` finishes |
|
||
| `created_at` | TEXT | NOT NULL |
|
||
|
||
---
|
||
|
||
### chat_history
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `session_stack_id` | INTEGER | NOT NULL REFERENCES `chat_sessions_stack(id)` |
|
||
| `role` | TEXT | NOT NULL CHECK(`user` \| `assistant` \| `agent`) |
|
||
| `content` | TEXT | NOT NULL DEFAULT `''` |
|
||
| `status` | TEXT | NOT NULL DEFAULT `ok` CHECK(`ok` \| `failed`) |
|
||
| `input_tokens` | INTEGER | nullable |
|
||
| `output_tokens` | INTEGER | nullable |
|
||
| `duration_ms` | INTEGER | nullable |
|
||
| `model_db_id` | INTEGER | nullable REFERENCES `llm_models(id)` |
|
||
| `is_synthetic` | INTEGER | NOT NULL DEFAULT `0` — `1` when the message was system-generated |
|
||
| `reasoning_content` | TEXT | nullable — chain-of-thought from reasoning models |
|
||
| `cost` | REAL | nullable — turn cost in USD, when the provider reports it (OpenRouter `usage.cost`) |
|
||
| `metadata` | TEXT | nullable — generic JSON metadata bag (added v17); currently `{ "attachments": [...] }` |
|
||
| `created_at` | TEXT | NOT NULL |
|
||
|
||
- `role = 'agent'` is a sub-agent prompt message; sent as `user` to the LLM but hidden in the UI
|
||
- `status = 'failed'` rows are excluded from `for_stack()` (not sent to LLM)
|
||
- `model_db_id` is set only on `role = 'assistant'` rows; `null` for user/agent messages and for rows created before this column was added
|
||
- `is_synthetic = 1` marks messages injected by the system (ChatHub notification assistant messages with tool-call injection, or legacy synthetic user turns). They are included in the LLM context but excluded from the UI history (not shown on page reload). Currently used for `role = 'assistant'` rows that carry the synthetic `read_notification` tool call and reasoning trace.
|
||
- `reasoning_content` is populated only for `role = 'assistant'` rows from providers that return chain-of-thought (currently DeepSeek thinking mode); echoed back in the assistant message on subsequent turns
|
||
- `cost` is set on `role = 'assistant'` rows only when the provider returns a per-request price (OpenRouter exposes it under `usage.cost`); `null` for providers that don't bill per-call. Extracted via the `ChatbotClient::extract_cost` trait method (see [llm-clients.md](llm-clients.md))
|
||
- `metadata` is a generic JSON bag (type `MessageMetadata` in `core-api`), set on `role = 'user'` rows that carry file attachments. The raw `[SYSTEM INFO]` block the LLM sees is **never** stored here — it is generated on the fly from `metadata.attachments` by the message builder, while the UI renders the same list as chips. `content` always stays the clean typed text. See [frontend.md](frontend.md) (attachments) and [session.md](session.md).
|
||
|
||
Index: `idx_history_stack ON chat_history(session_stack_id)`
|
||
|
||
---
|
||
|
||
### chat_summaries
|
||
|
||
Persisted conversation summaries generated by the **context compactor** (see
|
||
[compaction.md](compaction.md)). Each row covers all `chat_history` messages up
|
||
to and including `covers_up_to_message_id`. `build_openai_messages` loads only
|
||
messages *after* this boundary and injects the summary as context.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `stack_id` | INTEGER | NOT NULL REFERENCES `chat_sessions_stack(id)` |
|
||
| `content` | TEXT | NOT NULL — the summary text |
|
||
| `covers_up_to_message_id` | INTEGER | NOT NULL — boundary: messages with `id > this` are loaded raw |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Index: `idx_chat_summaries_stack ON chat_summaries(stack_id)`
|
||
|
||
Multiple rows can exist per stack (one per compaction cycle). Only the most
|
||
recent row is used; older rows are retained for historical reference.
|
||
|
||
---
|
||
|
||
### chat_llm_tools
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `message_id` | INTEGER | NOT NULL REFERENCES `chat_history(id)` |
|
||
| `name` | TEXT | NOT NULL |
|
||
| `arguments` | TEXT | nullable (JSON string) |
|
||
| `result` | TEXT | nullable |
|
||
| `status` | TEXT | NOT NULL DEFAULT `running` CHECK(`running` \| `pending` \| `done` \| `failed` \| `cancelled` \| `rejected`) |
|
||
| `created_at` | TEXT | NOT NULL |
|
||
|
||
- `running` → tool is executing (or app crashed mid-execution — re-run on resume)
|
||
- `pending` → blocked on a human approval / clarification (re-gate / re-ask on resume)
|
||
- `done` → result is in `result` column
|
||
- `failed` → tool/runtime error message is in `result` column
|
||
- `cancelled` → stopped by the user via `/stop` (terminal, **not** re-run on resume) — distinct from `failed`
|
||
- `rejected` → denied by an approval policy or a human (terminal) — distinct from `failed`
|
||
|
||
The richer in-memory `ToolExecutionState` maps onto these strings (see [tools.md](tools.md#tool-execution-lifecycle)). The table is created directly with the full 6-value `CHECK` constraint; `cancelled`/`rejected` (distinct from `failed`) were historically added by a table rebuild in schema versions 15–16, now folded into the baseline (see [Migration Pattern](#migration-pattern)).
|
||
|
||
Index: `idx_tools_message ON chat_llm_tools(message_id)`
|
||
|
||
---
|
||
|
||
### mcp_servers
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `name` | TEXT | NOT NULL UNIQUE |
|
||
| `transport` | TEXT | NOT NULL DEFAULT `'stdio'` |
|
||
| `command` | TEXT | nullable |
|
||
| `args_json` | TEXT | nullable (JSON array) |
|
||
| `env_json` | TEXT | nullable (JSON object) |
|
||
| `url` | TEXT | nullable |
|
||
| `api_key` | TEXT | nullable |
|
||
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
|
||
| `created_at` | TEXT | NOT NULL |
|
||
|
||
---
|
||
|
||
### llm_providers
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `name` | TEXT | NOT NULL UNIQUE |
|
||
| `type` | TEXT | NOT NULL (`lm_studio`, `ollama`, `open_ai`, `anthropic`) |
|
||
| `api_key` | TEXT | nullable |
|
||
| `base_url` | TEXT | nullable |
|
||
| `description` | TEXT | nullable |
|
||
| `removed_at` | TEXT | nullable; set on soft-delete (api_key is also cleared) |
|
||
| `created_at` | TEXT | NOT NULL |
|
||
|
||
Providers are **never hard-deleted**. `DELETE /api/llm/providers/{id}` sets `removed_at` and clears `api_key`, and cascades soft-delete to all models belonging to that provider. Rows with `removed_at IS NOT NULL` are excluded from all queries.
|
||
|
||
---
|
||
|
||
### llm_models
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` ON DELETE CASCADE |
|
||
| `model_id` | TEXT | NOT NULL (provider's internal model name) |
|
||
| `name` | TEXT | NOT NULL UNIQUE (display name, used as client key) |
|
||
| `strength` | TEXT | nullable (`very_low` \| `low` \| `average` \| `high` \| `very_high`) |
|
||
| `scope` | TEXT | NOT NULL DEFAULT `'[]'` (JSON array of scope strings) |
|
||
| `is_default` | INTEGER | NOT NULL DEFAULT 0 |
|
||
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first by AUTO) |
|
||
| `extra_params` | TEXT | nullable (JSON object, merged into request body at call time) |
|
||
| `context_length` / `max_output_tokens` / `knowledge_cutoff` / `capabilities` | INTEGER / INTEGER / TEXT / TEXT | nullable catalog metadata (see `docs/llm-clients.md`) |
|
||
| `reasoning` | TEXT | nullable; selected reasoning value (JSON string for a `ValueSet` mode, JSON number for a `Range` mode; NULL = off) — see *Reasoning* in `docs/llm-clients.md` |
|
||
| `removed_at` | TEXT | nullable; set on soft-delete |
|
||
| `created_at` | TEXT | NOT NULL |
|
||
|
||
The only uniqueness constraint is `name` (also the resolution key). There is **no** `UNIQUE(provider_id, model_id)` — the same underlying model may be registered several times under one provider with different aliases/reasoning (e.g. `glm-4.6` vs `glm-4.6-thinking`). Migration **v20** dropped the historical `(provider_id, model_id)` constraint by rebuilding the table (preserving `id` values, which are FK targets in `llm_requests.model_db_id` / `chat_history.model_db_id`) and added the `reasoning` column.
|
||
|
||
Models are **never hard-deleted**. `DELETE /api/llm/models/{id}` sets `removed_at`. Rows with `removed_at IS NOT NULL` are excluded from all queries, including the AUTO selector. The `id` column remains a valid FK target for `chat_history.model_db_id` even after removal. Re-adding a model with the same `name` **revives** the soft-deleted row (`insert_model` upserts on `name`).
|
||
|
||
---
|
||
|
||
### transcribe_models
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` |
|
||
| `model_id` | TEXT | NOT NULL (provider's internal model name, e.g. `openai/whisper-1`) |
|
||
| `name` | TEXT | NOT NULL UNIQUE (display name) |
|
||
| `language` | TEXT | nullable; BCP-47 code (e.g. `"it"`, `"en"`) or NULL for auto-detect |
|
||
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first by `TranscribeManager`) |
|
||
| `removed_at` | TEXT | nullable; soft-delete |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Only providers that declare `ServiceType::Transcribe` in `ApiProvider::supported_types()` should have rows here (OpenAI, OpenRouter). See [providers/transcribe.md](providers/transcribe.md).
|
||
|
||
---
|
||
|
||
### image_generate_models
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` |
|
||
| `model_id` | TEXT | NOT NULL (provider's internal model name, e.g. `x-ai/grok-2-vision`) |
|
||
| `name` | TEXT | NOT NULL UNIQUE (display name, also used as `provider_id` in the `image_generate` LLM tool) |
|
||
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first) |
|
||
| `removed_at` | TEXT | nullable; soft-delete |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Only providers that declare `ServiceType::ImageGenerate` in `ApiProvider::supported_types()` should have rows here (OpenRouter, OpenAI). See [providers/image.md](providers/image.md).
|
||
|
||
---
|
||
|
||
### secrets
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `key` | TEXT | PRIMARY KEY (uppercase by convention, e.g. `HUGGINGFACE_TOKEN`) |
|
||
| `value` | TEXT | NOT NULL — stored in plain text, never log |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` — updated on upsert |
|
||
|
||
Managed via `SecretsStore` (`src/core/secrets.rs`). See [secrets.md](secrets.md).
|
||
|
||
---
|
||
|
||
### tts_models
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` |
|
||
| `model_id` | TEXT | NOT NULL (generation model, e.g. `tts-1-hd`, `eleven_multilingual_v2`) |
|
||
| `voice_id` | TEXT | nullable; speaker voice ID — required for ElevenLabs, NULL for OpenAI (added in schema v2) |
|
||
| `name` | TEXT | NOT NULL UNIQUE (display name, also used as the synthesiser `id()`) |
|
||
| `description` | TEXT | nullable; human-readable description shown in UI |
|
||
| `instructions` | TEXT | nullable; default voice style / tone / speed (overridable per call) |
|
||
| `response_format` | TEXT | nullable; audio format `mp3`/`opus`/`aac`/`flac`/`wav`/`pcm` sent to the provider — NULL ⇒ `mp3` (added in schema v18) |
|
||
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first by `TtsManager`) |
|
||
| `removed_at` | TEXT | nullable; soft-delete |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
See [providers/tts.md](providers/tts.md).
|
||
|
||
---
|
||
|
||
### scheduled_jobs
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `title` | TEXT | NOT NULL |
|
||
| `description` | TEXT | NOT NULL DEFAULT `''` |
|
||
| `cron` | TEXT | NOT NULL (7-field format) |
|
||
| `prompt` | TEXT | NOT NULL |
|
||
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` (column default; in practice always set explicitly — `agent_id` is required at task creation) |
|
||
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
|
||
| `last_run_at` | TEXT | nullable (RFC3339 datetime) |
|
||
| `next_run_at` | TEXT | nullable (RFC3339); pre-computed next fire time; used by `list_due()` (added via ALTER) |
|
||
| `single_run` | INTEGER | NOT NULL DEFAULT 0; if 1 the job is disabled after its first execution (added via ALTER) |
|
||
| `running_session_id` | INTEGER | nullable; non-null while a run is in-flight; cleared by `finish_run()`; used by `list_interrupted()` for restart recovery (added via ALTER) |
|
||
| `kind` | TEXT | NOT NULL DEFAULT `'cron'` — `'cron'` scheduled, `'sync'` run-now-blocking, `'async'` run-now fire-and-forget |
|
||
| `parent_session_id` | INTEGER | nullable FK → `chat_sessions(id)`; set for `kind='async'` tasks to route the result back to the calling session |
|
||
| `run_context` | TEXT | nullable; `RunContext` JSON blob inherited from the creating session (or overridden via the Tasks UI); applied to the ephemeral child session at run time. Formerly `run_context_id` (renamed in migration 10). |
|
||
| `origin_ref` | TEXT | nullable; free-form reference to the entity that originated this job (e.g. `"PROJECT_TASK:42"`); used by `TaskManager` for post-completion callbacks (added in migration 11) |
|
||
| `created_at` | TEXT | NOT NULL |
|
||
|
||
**`next_run_at`** is set at job creation (first upcoming fire time after now), advanced after each successful run, cleared on disable, and recalculated by `toggle_item` (kind=cron) when re-enabling. `list_due(pool, now)` queries `WHERE kind='cron' AND enabled=1 AND next_run_at <= now AND running_session_id IS NULL`. Sync and async tasks run immediately on creation and are never picked up by the scheduler tick.
|
||
|
||
**`origin_ref`** is set only for jobs created by `ProjectTicketManager.start()`. When the job completes, `run_job()` checks this field and dispatches `ticket_manager.on_job_completed()` to update the ticket's status and result.
|
||
|
||
**`running_session_id`** is set by `set_running()` before `handle_message()` starts and cleared by `finish_run()` after the run completes. At startup, `recover_interrupted()` queries `list_interrupted()` and re-spawns any jobs still in-flight from a crash.
|
||
|
||
---
|
||
|
||
### job_runs
|
||
|
||
Audit trail of every cron job and immediate task execution. One row per run, written after each execution by `TaskManager::run_job()`.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `job_id` | INTEGER | NOT NULL REFERENCES `scheduled_jobs(id)` |
|
||
| `session_id` | INTEGER | nullable — the ephemeral session that ran the job |
|
||
| `started_at` | TEXT | NOT NULL (RFC3339) |
|
||
| `completed_at` | TEXT | nullable (RFC3339) |
|
||
| `duration_ms` | INTEGER | nullable |
|
||
| `status` | TEXT | NOT NULL (`success` \| `failed`) |
|
||
| `final_response` | TEXT | nullable — last assistant message from the session |
|
||
| `error` | TEXT | nullable — error message on failure |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Index: `idx_job_runs_job_id ON job_runs(job_id)`
|
||
|
||
DAO in `src/core/db/job_runs.rs`: `insert(pool, ...)`, `list_for_job(pool, job_id, limit)`.
|
||
|
||
---
|
||
|
||
### sources
|
||
|
||
One row per source (e.g. `"web"`, `"telegram"`). Tracks the active session for each source.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | TEXT | PRIMARY KEY |
|
||
| `active_session_id` | INTEGER | nullable REFERENCES `chat_sessions(id)` |
|
||
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
`ChatHub::get_or_create_session()` reads this table; `sources::upsert()` writes it.
|
||
|
||
---
|
||
|
||
### relay_clients
|
||
|
||
Created and owned by the `mobile-connector` plugin (`crates/plugin-mobile-connector/src/db.rs`), not the main crate. One row per paired mobile device. Counters MUST persist across restarts to prevent AES-GCM nonce reuse / replay (see [mobile-connector.md](mobile-connector.md)).
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `ed25519_pub` | BLOB | PRIMARY KEY (32 B) |
|
||
| `x25519_pub` | BLOB | NOT NULL (32 B, for ECDH) |
|
||
| `state` | TEXT | NOT NULL (`pending` \| `authorized`) |
|
||
| `platform` | TEXT | nullable (`ios` \| `android`) |
|
||
| `device_info` | TEXT | nullable JSON (from the E2E `hello`) |
|
||
| `send_counter` | INTEGER | NOT NULL DEFAULT 0 (dir agent→client) |
|
||
| `recv_counter` | INTEGER | NOT NULL DEFAULT 0 (last seen, dir client→agent) |
|
||
| `authorized_at` | INTEGER | nullable unix ms |
|
||
| `last_seen` | INTEGER | nullable unix ms |
|
||
|
||
---
|
||
|
||
### config
|
||
|
||
Global key/value store for runtime configuration.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `key` | TEXT | PRIMARY KEY |
|
||
| `value` | TEXT | NOT NULL |
|
||
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Currently used keys:
|
||
|
||
| Key | Owner | Description |
|
||
| --- | --- | --- |
|
||
| `source_home` | `ChatHub` | Source ID that receives background agent notifications. Default: `"web"` |
|
||
| `tic.run_context` | `TicManager` | Run context ID applied to each TIC session tick. Empty = no run context. |
|
||
| `tic.interval_minutes` | `TicManager` | Override tick interval in minutes. Empty = use `tic.interval_secs` from `config.yml`. |
|
||
|
||
Keys are managed via `GET /api/config` + `PUT /api/config/:key`. Registered properties are declared by each subsystem via `core_api::ConfigProperty` and collected in `Skald::config_properties`.
|
||
|
||
---
|
||
|
||
### mcp_events
|
||
|
||
Persistent store for push notifications received from MCP servers via JSON-RPC notifications (stdout, no `id` field). Written by `McpManager::notification_consumer`; consumed by `TicManager` at a configurable interval (`tic.interval_secs` in `config.yml`, default 900 s).
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `source` | TEXT | NOT NULL — MCP server name (e.g. `"whatsapp"`, `"gmail"`, `"gcal"`) |
|
||
| `method` | TEXT | NOT NULL — notification method (e.g. `"event/new_email"`) |
|
||
| `payload` | TEXT | NOT NULL — JSON string of the notification `params` field |
|
||
| `processed` | INTEGER | NOT NULL DEFAULT 0 — set to 1 by `TicManager` before running the agent |
|
||
| `processed_at` | TEXT | nullable — timestamp set when `processed` is flipped |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Index: `idx_mcp_events_pending ON mcp_events(id) WHERE processed = 0` — partial index for efficient pending-event queries.
|
||
|
||
DAO in `src/core/db/mcp_events.rs`: `insert`, `pending_limited(limit)`, `pending`, `mark_processed(ids)`, `all_recent(limit)`.
|
||
|
||
---
|
||
|
||
### session_mcp_grants
|
||
|
||
**Root-agent** record of which tool groups have been activated via `activate_tools` — MCP server names and/or the reserved keyword `config` (which unlocks the built-in `Config`-category tools). Grants survive restarts and persist for the whole session lifetime. Tools for granted groups are included in the LLM's tool list on every subsequent turn via the dynamic `all_tool_defs()` call.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `session_id` | INTEGER | NOT NULL (FK to `chat_sessions.id`) |
|
||
| `mcp_name` | TEXT | NOT NULL — MCP server name (e.g. `"gmail"`) |
|
||
| `granted_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Unique constraint: `(session_id, mcp_name)` — granting the same server twice is a no-op (INSERT OR IGNORE).
|
||
|
||
DAO in `src/core/db/session_mcp_grants.rs`: `grant(pool, session_id, mcp_name)`, `list_for_session(pool, session_id)`.
|
||
|
||
> **Sub-agents do not write here.** They use `stack_mcp_grants` instead.
|
||
|
||
---
|
||
|
||
### stack_mcp_grants
|
||
|
||
**Sub-agent** record of which MCP servers have been activated by a specific stack frame. Grants are:
|
||
|
||
- **Isolated** from the parent session — they do not affect `session_mcp_grants`.
|
||
- **Persistent** across restarts — if a sub-agent is interrupted, `dispatch_sub_agent` re-loads these grants from DB when execution resumes.
|
||
- **Cleaned up** automatically when the stack frame terminates: `dispatch_sub_agent` calls `delete_for_stack(pool, stack_id)` before returning.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `stack_id` | INTEGER | NOT NULL (FK to `chat_sessions_stack.id`) |
|
||
| `mcp_name` | TEXT | NOT NULL — MCP server name |
|
||
| `granted_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Unique constraint: `(stack_id, mcp_name)` — granting the same server twice is a no-op (INSERT OR IGNORE).
|
||
|
||
DAO in `src/core/db/stack_mcp_grants.rs`: `grant(pool, stack_id, mcp_name)`, `list_for_stack(pool, stack_id)`, `delete_for_stack(pool, stack_id)`.
|
||
|
||
---
|
||
|
||
### llm_requests
|
||
|
||
Debug log of every `chat_with_tools` call. Populated by [`LoggingChatbotClient`](../src/chatbot/logging.rs) (fire-and-forget, invisible to the LLM loop). Enabled via `config.yml` — see `llm.request_log`.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `session_id` | INTEGER | nullable — `chat_sessions.id` of the calling session |
|
||
| `stack_id` | INTEGER | nullable — `chat_sessions_stack.id` of the calling frame |
|
||
| `model_name` | TEXT | NOT NULL — display name of the model (e.g. `"claude"`) |
|
||
| `request_json` | TEXT | NOT NULL — full HTTP request body sent to the provider (compact JSON) |
|
||
| `request_headers` | TEXT | nullable — HTTP request headers as JSON object (api-key redacted) |
|
||
| `response_json` | TEXT | nullable — full HTTP response body (compact JSON); NULL on error |
|
||
| `response_headers` | TEXT | nullable — HTTP response headers as JSON object |
|
||
| `error_text` | TEXT | nullable — error message when the HTTP call failed |
|
||
| `input_tokens` | INTEGER | nullable |
|
||
| `output_tokens` | INTEGER | nullable |
|
||
| `duration_ms` | INTEGER | NOT NULL DEFAULT 0 — wall-clock round-trip time |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
|
||
Index: `idx_llm_requests_created ON llm_requests(created_at)` — used by the retention cleanup.
|
||
|
||
Rows are purged automatically by a background task (runs at boot + every hour) via `db::llm_requests::cleanup(pool, retention_days)`. Default retention: 14 days.
|
||
|
||
DAO in `src/core/db/llm_requests.rs`: `insert(pool, LlmRequestRow)`, `cleanup(pool, retention_days)`.
|
||
|
||
**Config** (`config.yml`):
|
||
|
||
```yaml
|
||
llm:
|
||
request_log:
|
||
enabled: true # default false
|
||
retention_days: 14 # default 14
|
||
```
|
||
|
||
---
|
||
|
||
### session_scratchpad
|
||
|
||
Per-session key-value store used by the `update_scratchpad` tool. Shared by all agents in the same chat session (including async sub-tasks, which are transparently redirected to the parent session's scratchpad at runtime); not persisted across sessions (tied to `chat_sessions.id`).
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `session_id` | INTEGER | NOT NULL REFERENCES `chat_sessions(id)` |
|
||
| `key` | TEXT | NOT NULL |
|
||
| `value` | TEXT | NOT NULL |
|
||
|
||
Primary key: `(session_id, key)`.
|
||
|
||
`upsert()` uses `INSERT ... ON CONFLICT DO UPDATE` — writing the same key twice overwrites the previous value. Contents are injected into every agent's system context via `build_openai_messages`.
|
||
|
||
---
|
||
|
||
## Schema Creation
|
||
|
||
The database has no migration system. `create_tables()` builds every table directly at its final shape on first boot (`CREATE TABLE IF NOT EXISTS`). There is no `schema_version` tracking, no incremental migration logic — the schema is a single flat baseline.
|
||
|
||
To change the schema, edit the relevant `CREATE TABLE` in `create_tables()` (`src/core/db/mod.rs`). Since the system has no existing users, schema changes simply start from a fresh DB (delete the old `data/skald.db` if needed).
|
||
|
||
---
|
||
|
||
### projects
|
||
|
||
Filesystem-linked projects, each with an independent Kanban board of tickets. `updated_at` is refreshed on every project or ticket operation (via `db::projects::touch`) so the UI can order by recency.
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `name` | TEXT | NOT NULL |
|
||
| `path` | TEXT | NOT NULL — absolute filesystem path for the project folder |
|
||
| `description` | TEXT | NOT NULL DEFAULT `''` |
|
||
| `run_context` | TEXT | nullable — `RunContext` JSON blob applied to tickets when they have no own run_context |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` — refreshed on every project/ticket operation |
|
||
|
||
DAO in `src/core/db/projects.rs`: `list(pool)` (ORDER BY updated_at DESC), `get(pool, id)`, `create(...)`, `update(...)`, `touch(pool, id)`, `delete(pool, id)` (CASCADE deletes tickets).
|
||
|
||
---
|
||
|
||
### project_tickets
|
||
|
||
Individual work items belonging to a project. Each ticket can be executed by a background agent.
|
||
|
||
**Lifecycle**: `todo` → `pending` (start() called) → `in_progress` (job starts) → `done` / `failed`
|
||
|
||
| Column | Type | Constraints |
|
||
| --- | --- | --- |
|
||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||
| `project_id` | INTEGER | NOT NULL REFERENCES `projects(id)` ON DELETE CASCADE |
|
||
| `title` | TEXT | NOT NULL |
|
||
| `description` | TEXT | NOT NULL DEFAULT `''` |
|
||
| `status` | TEXT | NOT NULL DEFAULT `'todo'` CHECK(`todo` \| `pending` \| `in_progress` \| `done` \| `failed`) |
|
||
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` |
|
||
| `run_context` | TEXT | nullable — `RunContext` JSON blob; stores only static config set at creation time (e.g. `security_group`). Runtime fields (`working_directory`, `allow_fs_writes`, `system_prompt`) are computed by `ProjectTicketManager.start()` and never persisted here. Falls back to `projects.run_context` if NULL. |
|
||
| `job_id` | INTEGER | nullable FK → `scheduled_jobs(id)` (no `ON DELETE` action); set when `start()` creates the job. Callers that delete a `scheduled_jobs` row (`scheduled_jobs::delete()`, cron's `cleanup_expired_single_runs`) must null this first, or the delete fails with a FK constraint error. |
|
||
| `result` | TEXT | nullable — final agent output (populated when status = `done`) |
|
||
| `error` | TEXT | nullable — error message (populated when status = `failed`) |
|
||
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
|
||
| `started_at` | TEXT | nullable — set when the job begins execution |
|
||
| `completed_at` | TEXT | nullable — set when the job finishes |
|
||
|
||
DAO in `src/core/db/project_tickets.rs`: `list_for_project(pool, project_id)`, `get(pool, id)`, `create(...)`, `delete(pool, id)`, `set_status(pool, id, status)`, `start(pool, id, job_id)`, `complete(pool, id, result, error)`, `reset(pool, id)`.
|
||
|
||
---
|
||
|
||
## Soft-Failure Pattern
|
||
|
||
On `handle_message` start, two recovery operations run:
|
||
|
||
1. **Orphaned user message check** — if the last `chat_history` row for the stack has `role='user'` or `role='agent'` (no following assistant message), it is marked `status='failed'`. This prevents sending a user→user sequence to strict LLM APIs (e.g. OpenRouter).
|
||
|
||
2. **`resume_pending_tools(stack_id, config, tx)`** — finds all `chat_llm_tools` rows still in `status='pending'` for the stack. For each one:
|
||
- Re-runs it through the `ApprovalManager` gate (rules may have changed since the interruption).
|
||
- `Deny` → marks the tool call `failed` immediately.
|
||
- `Require` → emits an `ApprovalRequired`/`PendingWrite` event and waits for human input, just like a live turn. If the WS is disconnected, returns early leaving the calls still pending.
|
||
- `Allow` → executes the tool directly; marks it `done` or `failed` depending on outcome.
|
||
|
||
After all pending calls are resolved, `run_agent_turn` runs normally and the LLM sees a complete history.
|
||
|
||
> **Note:** `fail_pending_for_stack()` exists in `db/chat_llm_tools.rs` but is not called from the session handler. Pending tool calls are re-executed (not hard-failed) so the LLM receives real results rather than synthetic errors.
|
||
|
||
---
|
||
|
||
## When to Update This File
|
||
|
||
- Any table schema changes (new column, new table, removed table)
|
||
- Migration approach changes
|
||
- A new soft-failure recovery step is added to `handle_message`
|