30 KiB
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 asuserto the LLM but hidden in the UIstatus = 'failed'rows are excluded fromfor_stack()(not sent to LLM)model_db_idis set only onrole = 'assistant'rows;nullfor user/agent messages and for rows created before this column was addedis_synthetic = 1marks 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 forrole = 'assistant'rows that carry the syntheticread_notificationtool call and reasoning trace.reasoning_contentis populated only forrole = 'assistant'rows from providers that return chain-of-thought (currently DeepSeek thinking mode); echoed back in the assistant message on subsequent turnscostis set onrole = 'assistant'rows only when the provider returns a per-request price (OpenRouter exposes it underusage.cost);nullfor providers that don't bill per-call. Extracted via theChatbotClient::extract_costtrait method (see llm-clients.md)metadatais a generic JSON bag (typeMessageMetadataincore-api), set onrole = 'user'rows that carry file attachments. The raw[SYSTEM INFO]block the LLM sees is never stored here — it is generated on the fly frommetadata.attachmentsby the message builder, while the UI renders the same list as chips.contentalways stays the clean typed text. See frontend.md (attachments) and 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). 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 inresultcolumnfailed→ tool/runtime error message is inresultcolumncancelled→ stopped by the user via/stop(terminal, not re-run on resume) — distinct fromfailedrejected→ denied by an approval policy or a human (terminal) — distinct fromfailed
The richer in-memory ToolExecutionState maps onto these strings (see tools.md). 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).
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.
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.
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.
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.
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).
| 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_grantsinstead.
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_agentre-loads these grants from DB when execution resumes. - Cleaned up automatically when the stack frame terminates:
dispatch_sub_agentcallsdelete_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 (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):
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:
-
Orphaned user message check — if the last
chat_historyrow for the stack hasrole='user'orrole='agent'(no following assistant message), it is markedstatus='failed'. This prevents sending a user→user sequence to strict LLM APIs (e.g. OpenRouter). -
resume_pending_tools(stack_id, config, tx)— finds allchat_llm_toolsrows still instatus='pending'for the stack. For each one:- Re-runs it through the
ApprovalManagergate (rules may have changed since the interruption). Deny→ marks the tool callfailedimmediately.Require→ emits anApprovalRequired/PendingWriteevent 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 itdoneorfaileddepending on outcome.
After all pending calls are resolved,
run_agent_turnruns normally and the LLM sees a complete history. - Re-runs it through the
Note:
fail_pending_for_stack()exists indb/chat_llm_tools.rsbut 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