mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-16 18:22:09 +00:00
1085 lines
49 KiB
Go
1085 lines
49 KiB
Go
//go:build sqlite || sqliteonly
|
||
|
||
package sqlitestore
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
_ "embed"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
)
|
||
|
||
//go:embed schema.sql
|
||
var schemaSQL string
|
||
|
||
// SchemaVersion is the current SQLite schema version.
|
||
// Bump this when adding new migration steps below.
|
||
const SchemaVersion = 39
|
||
|
||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||
// Existing DBs are patched incrementally via these steps.
|
||
//
|
||
// Example: to add a new column in the future:
|
||
//
|
||
// var migrations = map[int]string{
|
||
// 1: `ALTER TABLE agents ADD COLUMN new_col TEXT DEFAULT '';`,
|
||
// }
|
||
//
|
||
// Then bump SchemaVersion to 2.
|
||
var migrations = map[int]string{
|
||
// Version 1 → 2: add contact_type column to channel_contacts.
|
||
1: `ALTER TABLE channel_contacts ADD COLUMN contact_type VARCHAR(20) NOT NULL DEFAULT 'user';`,
|
||
// Version 2 → 3: promote cron payload fields to dedicated columns + add stateless flag.
|
||
2: `ALTER TABLE cron_jobs ADD COLUMN stateless INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE cron_jobs ADD COLUMN deliver INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE cron_jobs ADD COLUMN deliver_channel TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE cron_jobs ADD COLUMN deliver_to TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE cron_jobs ADD COLUMN wake_heartbeat INTEGER NOT NULL DEFAULT 0;
|
||
UPDATE cron_jobs SET
|
||
deliver = COALESCE(json_extract(payload, '$.deliver'), 0),
|
||
deliver_channel = COALESCE(json_extract(payload, '$.channel'), ''),
|
||
deliver_to = COALESCE(json_extract(payload, '$.to'), ''),
|
||
wake_heartbeat = COALESCE(json_extract(payload, '$.wake_heartbeat'), 0)
|
||
WHERE payload IS NOT NULL;`,
|
||
// Version 4 → 5: add thread_id, thread_type columns to channel_contacts for forum topic support.
|
||
4: `ALTER TABLE channel_contacts ADD COLUMN thread_id VARCHAR(100);
|
||
ALTER TABLE channel_contacts ADD COLUMN thread_type VARCHAR(20);
|
||
DROP INDEX IF EXISTS idx_channel_contacts_tenant_type_sender;
|
||
CREATE UNIQUE INDEX idx_channel_contacts_tenant_type_sender
|
||
ON channel_contacts(tenant_id, channel_type, sender_id, COALESCE(thread_id, ''));`,
|
||
// Version 3 → 4: add subagent_tasks table for subagent lifecycle persistence.
|
||
3: `CREATE TABLE IF NOT EXISTS subagent_tasks (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
parent_agent_key VARCHAR(255) NOT NULL,
|
||
session_key VARCHAR(500),
|
||
subject VARCHAR(255) NOT NULL,
|
||
description TEXT NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'running',
|
||
result TEXT,
|
||
depth INTEGER NOT NULL DEFAULT 1,
|
||
model VARCHAR(255),
|
||
provider VARCHAR(255),
|
||
iterations INTEGER NOT NULL DEFAULT 0,
|
||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||
origin_channel VARCHAR(50),
|
||
origin_chat_id VARCHAR(255),
|
||
origin_peer_kind VARCHAR(20),
|
||
origin_user_id VARCHAR(255),
|
||
spawned_by TEXT,
|
||
completed_at TEXT,
|
||
archived_at TEXT,
|
||
metadata TEXT NOT NULL DEFAULT '{}',
|
||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_parent_status ON subagent_tasks(tenant_id, parent_agent_key, status);
|
||
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_session ON subagent_tasks(session_key);
|
||
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_created ON subagent_tasks(tenant_id, created_at);`,
|
||
// Version 5 → 6: secure CLI agent grants — replace agent_id with is_global + grants table.
|
||
5: `ALTER TABLE secure_cli_binaries ADD COLUMN is_global BOOLEAN NOT NULL DEFAULT 1;
|
||
DROP INDEX IF EXISTS idx_secure_cli_unique_binary_agent;
|
||
DROP INDEX IF EXISTS idx_secure_cli_agent_id;
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_unique_binary_tenant ON secure_cli_binaries(binary_name, tenant_id);
|
||
CREATE TABLE IF NOT EXISTS secure_cli_agent_grants (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
deny_args TEXT,
|
||
deny_verbose TEXT,
|
||
timeout_seconds INTEGER,
|
||
tips TEXT,
|
||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
UNIQUE(binary_id, agent_id, tenant_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_scag_binary ON secure_cli_agent_grants(binary_id);
|
||
CREATE INDEX IF NOT EXISTS idx_scag_agent ON secure_cli_agent_grants(agent_id);
|
||
CREATE INDEX IF NOT EXISTS idx_scag_tenant ON secure_cli_agent_grants(tenant_id);`,
|
||
// Version 6 → 7: V3 tables (episodic, evolution, KG temporal) + promote other_config fields.
|
||
6: `-- V3: episodic summaries
|
||
CREATE TABLE IF NOT EXISTS episodic_summaries (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
user_id VARCHAR(255) NOT NULL DEFAULT '',
|
||
session_key TEXT NOT NULL,
|
||
summary TEXT NOT NULL,
|
||
l0_abstract TEXT NOT NULL DEFAULT '',
|
||
key_topics TEXT NOT NULL DEFAULT '[]',
|
||
source_type TEXT NOT NULL DEFAULT 'session',
|
||
source_id TEXT,
|
||
turn_count INTEGER NOT NULL DEFAULT 0,
|
||
token_count INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
expires_at TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_episodic_agent_user ON episodic_summaries(agent_id, user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_episodic_tenant ON episodic_summaries(tenant_id);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_episodic_source_dedup ON episodic_summaries(agent_id, user_id, source_id)
|
||
WHERE source_id IS NOT NULL;
|
||
|
||
-- V3: evolution metrics
|
||
CREATE TABLE IF NOT EXISTS agent_evolution_metrics (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
session_key TEXT NOT NULL,
|
||
metric_type TEXT NOT NULL,
|
||
metric_key TEXT NOT NULL,
|
||
value TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_evo_metrics_agent_type ON agent_evolution_metrics(agent_id, metric_type);
|
||
CREATE INDEX IF NOT EXISTS idx_evo_metrics_created ON agent_evolution_metrics(created_at);
|
||
CREATE INDEX IF NOT EXISTS idx_evo_metrics_tenant ON agent_evolution_metrics(tenant_id);
|
||
|
||
-- V3: evolution suggestions
|
||
CREATE TABLE IF NOT EXISTS agent_evolution_suggestions (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
suggestion_type TEXT NOT NULL,
|
||
suggestion TEXT NOT NULL,
|
||
rationale TEXT NOT NULL,
|
||
parameters TEXT,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
reviewed_by TEXT,
|
||
reviewed_at TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_evo_suggestions_agent ON agent_evolution_suggestions(agent_id, status);
|
||
CREATE INDEX IF NOT EXISTS idx_evo_suggestions_tenant ON agent_evolution_suggestions(tenant_id);
|
||
|
||
-- V3: KG temporal validity
|
||
ALTER TABLE kg_entities ADD COLUMN valid_from TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||
ALTER TABLE kg_entities ADD COLUMN valid_until TEXT;
|
||
CREATE INDEX IF NOT EXISTS idx_kg_entities_current ON kg_entities(agent_id, user_id) WHERE valid_until IS NULL;
|
||
|
||
ALTER TABLE kg_relations ADD COLUMN valid_from TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||
ALTER TABLE kg_relations ADD COLUMN valid_until TEXT;
|
||
CREATE INDEX IF NOT EXISTS idx_kg_relations_current ON kg_relations(agent_id, user_id) WHERE valid_until IS NULL;
|
||
|
||
-- Promote other_config fields to dedicated columns
|
||
ALTER TABLE agents ADD COLUMN emoji TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE agents ADD COLUMN agent_description TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE agents ADD COLUMN thinking_level TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE agents ADD COLUMN max_tokens INT NOT NULL DEFAULT 0;
|
||
ALTER TABLE agents ADD COLUMN self_evolve BOOLEAN NOT NULL DEFAULT 0;
|
||
ALTER TABLE agents ADD COLUMN skill_evolve BOOLEAN NOT NULL DEFAULT 0;
|
||
ALTER TABLE agents ADD COLUMN skill_nudge_interval INT NOT NULL DEFAULT 0;
|
||
ALTER TABLE agents ADD COLUMN reasoning_config TEXT NOT NULL DEFAULT '{}';
|
||
ALTER TABLE agents ADD COLUMN workspace_sharing TEXT NOT NULL DEFAULT '{}';
|
||
ALTER TABLE agents ADD COLUMN chatgpt_oauth_routing TEXT NOT NULL DEFAULT '{}';
|
||
ALTER TABLE agents ADD COLUMN shell_deny_groups TEXT NOT NULL DEFAULT '{}';
|
||
ALTER TABLE agents ADD COLUMN kg_dedup_config TEXT NOT NULL DEFAULT '{}';
|
||
UPDATE agents SET
|
||
emoji = COALESCE(json_extract(other_config, '$.emoji'), ''),
|
||
agent_description = COALESCE(json_extract(other_config, '$.description'), ''),
|
||
thinking_level = COALESCE(json_extract(other_config, '$.thinking_level'), ''),
|
||
max_tokens = COALESCE(json_extract(other_config, '$.max_tokens'), 0),
|
||
self_evolve = COALESCE(json_extract(other_config, '$.self_evolve'), 0),
|
||
skill_evolve = COALESCE(json_extract(other_config, '$.skill_evolve'), 0),
|
||
skill_nudge_interval = COALESCE(json_extract(other_config, '$.skill_nudge_interval'), 0),
|
||
reasoning_config = COALESCE(json_extract(other_config, '$.reasoning'), '{}'),
|
||
workspace_sharing = COALESCE(json_extract(other_config, '$.workspace_sharing'), '{}'),
|
||
chatgpt_oauth_routing = COALESCE(json_extract(other_config, '$.chatgpt_oauth_routing'), '{}'),
|
||
shell_deny_groups = COALESCE(json_extract(other_config, '$.shell_deny_groups'), '{}'),
|
||
kg_dedup_config = COALESCE(json_extract(other_config, '$.kg_dedup_config'), '{}')
|
||
WHERE other_config != '{}' AND other_config IS NOT NULL;
|
||
UPDATE agents SET other_config = json_remove(other_config,
|
||
'$.emoji', '$.description', '$.thinking_level', '$.max_tokens',
|
||
'$.self_evolve', '$.skill_evolve', '$.skill_nudge_interval',
|
||
'$.reasoning', '$.workspace_sharing', '$.chatgpt_oauth_routing',
|
||
'$.shell_deny_groups', '$.kg_dedup_config');`,
|
||
|
||
// Version 7 → 8: add promoted_at to episodic_summaries for dreaming pipeline.
|
||
7: `ALTER TABLE episodic_summaries ADD COLUMN promoted_at TEXT;
|
||
CREATE INDEX IF NOT EXISTS idx_episodic_unpromoted ON episodic_summaries(agent_id, user_id, created_at)
|
||
WHERE promoted_at IS NULL;`,
|
||
|
||
// Version 8 → 9: add kg_dedup_candidates, secure_cli_user_credentials, vault_documents, vault_links.
|
||
8: `CREATE TABLE IF NOT EXISTS kg_dedup_candidates (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
user_id VARCHAR(255) NOT NULL DEFAULT '',
|
||
entity_a_id TEXT NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE,
|
||
entity_b_id TEXT NOT NULL REFERENCES kg_entities(id) ON DELETE CASCADE,
|
||
similarity REAL NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
UNIQUE(entity_a_id, entity_b_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_kg_dedup_agent ON kg_dedup_candidates(agent_id, status);
|
||
|
||
CREATE TABLE IF NOT EXISTS secure_cli_user_credentials (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
|
||
user_id VARCHAR(255) NOT NULL,
|
||
encrypted_env BLOB NOT NULL,
|
||
metadata TEXT NOT NULL DEFAULT '{}',
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
UNIQUE(binary_id, user_id, tenant_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_scuc_tenant ON secure_cli_user_credentials(tenant_id);
|
||
CREATE INDEX IF NOT EXISTS idx_scuc_binary ON secure_cli_user_credentials(binary_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS vault_documents (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
scope TEXT NOT NULL DEFAULT 'personal',
|
||
path TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
doc_type TEXT NOT NULL DEFAULT 'note',
|
||
content_hash TEXT NOT NULL DEFAULT '',
|
||
summary TEXT NOT NULL DEFAULT '',
|
||
metadata TEXT DEFAULT '{}',
|
||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
UNIQUE(agent_id, scope, path)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_tenant ON vault_documents(tenant_id);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash);
|
||
|
||
CREATE TABLE IF NOT EXISTS vault_links (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
from_doc_id TEXT NOT NULL REFERENCES vault_documents(id) ON DELETE CASCADE,
|
||
to_doc_id TEXT NOT NULL REFERENCES vault_documents(id) ON DELETE CASCADE,
|
||
link_type TEXT NOT NULL DEFAULT 'wikilink',
|
||
context TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
UNIQUE(from_doc_id, to_doc_id, link_type)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_links_from ON vault_links(from_doc_id);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_links_to ON vault_links(to_doc_id);`,
|
||
|
||
// Version 9 → 10: originally added summary column to vault_documents.
|
||
// Now a no-op: migration 8 already creates vault_documents WITH summary.
|
||
// DBs from schema.sql also include summary. ALTER would fail with "duplicate column".
|
||
9: `SELECT 1;`,
|
||
|
||
// Version 10 → 11: add team_id + custom_scope to vault_documents (fix cross-team UNIQUE),
|
||
// add custom_scope to 8 other tables (vault_versions absent in SQLite).
|
||
10: `-- Recreate vault_documents with team_id + custom_scope columns.
|
||
-- SQLite prohibits expressions (COALESCE) in UNIQUE constraints,
|
||
-- so we use a unique INDEX instead of inline UNIQUE.
|
||
CREATE TABLE vault_documents_new (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
|
||
scope TEXT NOT NULL DEFAULT 'personal',
|
||
custom_scope TEXT,
|
||
path TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
doc_type TEXT NOT NULL DEFAULT 'note',
|
||
content_hash TEXT NOT NULL DEFAULT '',
|
||
summary TEXT NOT NULL DEFAULT '',
|
||
metadata TEXT DEFAULT '{}',
|
||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
INSERT INTO vault_documents_new (id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at)
|
||
SELECT id, tenant_id, agent_id, NULL, scope, NULL, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||
FROM vault_documents;
|
||
DROP TABLE vault_documents;
|
||
ALTER TABLE vault_documents_new RENAME TO vault_documents;
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_docs_unique_path
|
||
ON vault_documents(agent_id, COALESCE(team_id, ''), scope, path);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_tenant ON vault_documents(tenant_id);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team ON vault_documents(team_id);
|
||
-- custom_scope on other tables (vault_versions absent in SQLite).
|
||
ALTER TABLE vault_links ADD COLUMN custom_scope TEXT;
|
||
ALTER TABLE memory_documents ADD COLUMN custom_scope TEXT;
|
||
ALTER TABLE memory_chunks ADD COLUMN custom_scope TEXT;
|
||
ALTER TABLE team_tasks ADD COLUMN custom_scope TEXT;
|
||
ALTER TABLE team_task_attachments ADD COLUMN custom_scope TEXT;
|
||
ALTER TABLE team_task_comments ADD COLUMN custom_scope TEXT;
|
||
ALTER TABLE team_task_events ADD COLUMN custom_scope TEXT;
|
||
ALTER TABLE subagent_tasks ADD COLUMN custom_scope TEXT;`,
|
||
// Version 11 → 12: seed AGENTS_CORE.md + AGENTS_TASK.md, remove AGENTS_MINIMAL.md.
|
||
11: `INSERT INTO agent_context_files (id, agent_id, file_name, content, tenant_id, created_at, updated_at)
|
||
SELECT lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
|
||
a.id, 'AGENTS_CORE.md',
|
||
'# Operating Rules (Core)
|
||
|
||
## Language & Communication
|
||
|
||
- Match the user''s language. Detect from first message, stay consistent.
|
||
|
||
## Internal Messages
|
||
|
||
- [System Message] blocks are internal context. Not user-visible.
|
||
- Rewrite system messages in your normal voice before delivering.
|
||
- Never use exec or curl for messaging.
|
||
- When asked to save or remember, MUST call write_file or edit in THIS turn.
|
||
',
|
||
a.tenant_id, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||
FROM agents a
|
||
WHERE a.deleted_at IS NULL
|
||
AND NOT EXISTS (SELECT 1 FROM agent_context_files WHERE agent_id = a.id AND file_name = 'AGENTS_CORE.md');
|
||
|
||
INSERT INTO agent_context_files (id, agent_id, file_name, content, tenant_id, created_at, updated_at)
|
||
SELECT lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
|
||
a.id, 'AGENTS_TASK.md',
|
||
'# Operating Rules (Task)
|
||
|
||
## Language & Communication
|
||
|
||
- Match the user''s language. Detect from first message, stay consistent.
|
||
|
||
## Internal Messages
|
||
|
||
- [System Message] blocks are internal context. Not user-visible.
|
||
- Rewrite system messages in your normal voice before delivering.
|
||
- Never use exec or curl for messaging.
|
||
- When asked to save or remember, MUST call write_file or edit in THIS turn.
|
||
|
||
## Memory
|
||
|
||
- Use memory_search before answering about prior work, decisions, or preferences.
|
||
- Use write_file to persist important information. No mental notes.
|
||
- Only reference MEMORY.md content in private/direct chats.
|
||
|
||
## Scheduling
|
||
|
||
- Use cron tool for periodic or timed tasks.
|
||
- Use kind: at for one-shot reminders.
|
||
',
|
||
a.tenant_id, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||
FROM agents a
|
||
WHERE a.deleted_at IS NULL
|
||
AND NOT EXISTS (SELECT 1 FROM agent_context_files WHERE agent_id = a.id AND file_name = 'AGENTS_TASK.md');
|
||
|
||
DELETE FROM agent_context_files WHERE file_name = 'AGENTS_MINIMAL.md';`,
|
||
|
||
// Version 12 → 13: Phase 10 dreaming weighted scoring signals on
|
||
// episodic_summaries. Mirrors PG migration 000045.
|
||
12: `ALTER TABLE episodic_summaries ADD COLUMN recall_count INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE episodic_summaries ADD COLUMN recall_score REAL NOT NULL DEFAULT 0;
|
||
ALTER TABLE episodic_summaries ADD COLUMN last_recalled_at TEXT;
|
||
CREATE INDEX IF NOT EXISTS idx_episodic_recall_unpromoted ON episodic_summaries(agent_id, user_id, recall_score DESC)
|
||
WHERE promoted_at IS NULL;`,
|
||
|
||
// Version 13 → 14: vault_documents agent_id nullable + unique index with tenant_id.
|
||
// SQLite requires table recreation to drop NOT NULL. Preserve all data.
|
||
13: `CREATE TABLE vault_documents_new (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
|
||
scope TEXT NOT NULL DEFAULT 'personal',
|
||
custom_scope TEXT,
|
||
path TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
doc_type TEXT NOT NULL DEFAULT 'note',
|
||
content_hash TEXT NOT NULL DEFAULT '',
|
||
summary TEXT NOT NULL DEFAULT '',
|
||
metadata TEXT DEFAULT '{}',
|
||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
INSERT INTO vault_documents_new SELECT * FROM vault_documents;
|
||
DROP TABLE vault_documents;
|
||
ALTER TABLE vault_documents_new RENAME TO vault_documents;
|
||
DROP INDEX IF EXISTS idx_vault_docs_unique_path;
|
||
CREATE UNIQUE INDEX idx_vault_docs_unique_path
|
||
ON vault_documents(tenant_id, COALESCE(agent_id, ''), COALESCE(team_id, ''), scope, path);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_tenant ON vault_documents(tenant_id);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash);
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team ON vault_documents(team_id);`,
|
||
|
||
// Version 14 → 15: cron_jobs UNIQUE constraint on (agent_id, tenant_id, name).
|
||
// Dedup first: keep one row per combo (SQLite has no DISTINCT ON — use GROUP BY + MIN).
|
||
14: `DELETE FROM cron_jobs WHERE id NOT IN (
|
||
SELECT MIN(id) FROM cron_jobs GROUP BY agent_id, tenant_id, name
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_cron_jobs_agent_tenant_name
|
||
ON cron_jobs(agent_id, tenant_id, name);`,
|
||
|
||
// Version 15 → 16: vault media linking schema
|
||
// (team_task_attachments.base_name, vault_documents.path_basename,
|
||
// vault_links.metadata, auto-linking indexes).
|
||
// Backfill of base_name / path_basename happens in backfillV16 below
|
||
// (Go-loop — SQLite has no regexp_replace in modernc.org/sqlite bundle).
|
||
15: `ALTER TABLE team_task_attachments ADD COLUMN base_name TEXT NOT NULL DEFAULT '';
|
||
CREATE INDEX IF NOT EXISTS idx_tta_tenant_basename
|
||
ON team_task_attachments(tenant_id, base_name);
|
||
|
||
ALTER TABLE vault_documents ADD COLUMN path_basename TEXT NOT NULL DEFAULT '';
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_basename
|
||
ON vault_documents(tenant_id, path_basename);
|
||
|
||
ALTER TABLE vault_links ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}';
|
||
CREATE INDEX IF NOT EXISTS idx_vault_links_source
|
||
ON vault_links(json_extract(metadata, '$.source'))
|
||
WHERE json_extract(metadata, '$.source') IS NOT NULL;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation
|
||
ON vault_documents(json_extract(metadata, '$.delegation_id'))
|
||
WHERE json_extract(metadata, '$.delegation_id') IS NOT NULL;`,
|
||
|
||
// Version 16 → 17: path prefix index for vault tree lazy-load queries.
|
||
16: `CREATE INDEX IF NOT EXISTS idx_vault_docs_path_prefix ON vault_documents(tenant_id, path);`,
|
||
|
||
// Version 17 → 18: seed STT builtin_tools row.
|
||
17: `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings)
|
||
VALUES ('stt', 'Speech-to-Text', 'Transcribe voice/audio messages to text using ElevenLabs Scribe or a proxy service', 'media', 1, '{}')
|
||
ON CONFLICT (name) DO NOTHING;`,
|
||
|
||
// Version 18 → 19: backfill mode: "cache-ttl" for agents with custom
|
||
// context_pruning config missing the mode field. Mirrors PG migration 51.
|
||
// Preserves user intent after the opt-in default flip. NULL rows stay NULL.
|
||
18: `UPDATE agents
|
||
SET context_pruning = json_set(context_pruning, '$.mode', 'cache-ttl')
|
||
WHERE context_pruning IS NOT NULL
|
||
AND context_pruning <> ''
|
||
AND context_pruning <> '{}'
|
||
AND json_valid(context_pruning)
|
||
AND json_type(context_pruning) = 'object'
|
||
AND json_extract(context_pruning, '$.mode') IS NULL;`,
|
||
|
||
// Version 19 → 20: hooks system (mirrors PG migrations 000052–000055).
|
||
// Creates hooks, hook_agents, hook_executions, tenant_hook_budget tables
|
||
// with final schema. SQLite/desktop never shipped with intermediate names
|
||
// (agent_hooks, agent_hook_agents) so we create the final form directly.
|
||
19: addHooksTables,
|
||
|
||
// Versions 20–22: no-op — consolidated into v19 above.
|
||
20: `SELECT 1;`,
|
||
21: `SELECT 1;`,
|
||
22: `SELECT 1;`,
|
||
|
||
// Version 27 → 28: webhooks + webhook_calls tables (mirrors PG migration 000059).
|
||
// scopes/ip_allowlist stored as JSON TEXT; bool columns as INTEGER (0/1).
|
||
// webhook_calls.request_payload + response are TEXT (canonical JSON) from the start —
|
||
// upstream history had an interim BLOB form, but dev never shipped it.
|
||
27: `CREATE TABLE IF NOT EXISTS webhooks (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||
name TEXT NOT NULL,
|
||
kind TEXT NOT NULL CHECK (kind IN ('llm', 'message')),
|
||
secret_prefix TEXT,
|
||
secret_hash TEXT NOT NULL,
|
||
scopes TEXT NOT NULL DEFAULT '[]',
|
||
channel_id TEXT,
|
||
rate_limit_per_min INTEGER NOT NULL DEFAULT 60,
|
||
ip_allowlist TEXT NOT NULL DEFAULT '[]',
|
||
require_hmac INTEGER NOT NULL DEFAULT 0,
|
||
localhost_only INTEGER NOT NULL DEFAULT 0,
|
||
revoked INTEGER NOT NULL DEFAULT 0,
|
||
created_by TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
last_used_at TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_webhooks_tenant
|
||
ON webhooks (tenant_id);
|
||
CREATE INDEX IF NOT EXISTS idx_webhooks_tenant_agent
|
||
ON webhooks (tenant_id, agent_id);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_webhooks_secret
|
||
ON webhooks (secret_hash)
|
||
WHERE revoked = 0;
|
||
CREATE TABLE IF NOT EXISTS webhook_calls (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
||
agent_id TEXT,
|
||
idempotency_key TEXT,
|
||
mode TEXT NOT NULL CHECK (mode IN ('sync', 'async')),
|
||
callback_url TEXT,
|
||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed', 'dead')),
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
delivery_id TEXT NOT NULL,
|
||
next_attempt_at TEXT,
|
||
started_at TEXT,
|
||
request_payload TEXT,
|
||
response TEXT,
|
||
last_error TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
completed_at TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_webhook_calls_tenant_created
|
||
ON webhook_calls (tenant_id, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_webhook_calls_status_attempt
|
||
ON webhook_calls (status, next_attempt_at);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_calls_idempotency
|
||
ON webhook_calls (webhook_id, idempotency_key)
|
||
WHERE idempotency_key IS NOT NULL;`,
|
||
|
||
// Version 28 → 29: add lease_token to webhook_calls for optimistic-concurrency CAS.
|
||
// Mirrors PG migration 000060.
|
||
28: `ALTER TABLE webhook_calls ADD COLUMN lease_token TEXT;`,
|
||
|
||
// Version 29 → 30: add encrypted_secret to webhooks (AES-256-GCM of raw secret).
|
||
// Mirrors PG migration 000061.
|
||
29: `ALTER TABLE webhooks ADD COLUMN encrypted_secret TEXT NOT NULL DEFAULT '';`,
|
||
|
||
// Version 30 → 31: workstations + agent_workstation_links tables. Mirrors PG migration 000062.
|
||
30: `CREATE TABLE IF NOT EXISTS workstations (
|
||
id TEXT PRIMARY KEY,
|
||
workstation_key VARCHAR(100) NOT NULL,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
name VARCHAR(255) NOT NULL,
|
||
backend_type VARCHAR(20) NOT NULL CHECK (backend_type IN ('ssh','docker')),
|
||
metadata BLOB NOT NULL,
|
||
default_cwd VARCHAR(500) NOT NULL DEFAULT '',
|
||
default_env BLOB NOT NULL,
|
||
active INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
||
UNIQUE (tenant_id, workstation_key)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_workstations_tenant_active
|
||
ON workstations(tenant_id, active) WHERE active = 1;
|
||
CREATE TABLE IF NOT EXISTS agent_workstation_links (
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
workstation_id TEXT NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
is_default INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
PRIMARY KEY (agent_id, workstation_id)
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
|
||
ON agent_workstation_links(agent_id) WHERE is_default = 1;
|
||
CREATE INDEX IF NOT EXISTS idx_agent_workstation_tenant ON agent_workstation_links(tenant_id);`,
|
||
|
||
// Version 31 → 32: workstation_permissions allowlist table. Mirrors PG migration 000063.
|
||
31: `CREATE TABLE IF NOT EXISTS workstation_permissions (
|
||
id TEXT PRIMARY KEY,
|
||
workstation_id TEXT NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
pattern VARCHAR(500) NOT NULL,
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
created_by VARCHAR(255) NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
UNIQUE (workstation_id, pattern)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_workstation_perms_ws ON workstation_permissions(workstation_id) WHERE enabled = 1;
|
||
CREATE INDEX IF NOT EXISTS idx_workstation_perms_tenant ON workstation_permissions(tenant_id);`,
|
||
|
||
// Version 32 → 33: workstation_activity audit log table. Mirrors PG migration 000064.
|
||
32: `CREATE TABLE IF NOT EXISTS workstation_activity (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
workstation_id TEXT NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
|
||
agent_id VARCHAR(255) NOT NULL DEFAULT '',
|
||
action VARCHAR(20) NOT NULL,
|
||
cmd_hash VARCHAR(64) NOT NULL DEFAULT '',
|
||
cmd_preview VARCHAR(200) NOT NULL DEFAULT '',
|
||
exit_code INTEGER,
|
||
duration_ms INTEGER,
|
||
deny_reason VARCHAR(200) NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_ws_activity_ws_time ON workstation_activity(workstation_id, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_ws_activity_tenant_time ON workstation_activity(tenant_id, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_ws_activity_retention ON workstation_activity(created_at);`,
|
||
|
||
// Version 33 → 34: per-agent ordered provider/model fallback config.
|
||
33: `ALTER TABLE agents ADD COLUMN model_fallback TEXT NOT NULL DEFAULT '{}';`,
|
||
|
||
// Version 34 → 35: agent skill grants can optionally allow skill management.
|
||
34: `ALTER TABLE skill_agent_grants ADD COLUMN can_manage INTEGER NOT NULL DEFAULT 0;`,
|
||
|
||
// Version 35 → 36: remove legacy cross-tenant skill-agent grant rows.
|
||
35: `DELETE FROM skill_agent_grants
|
||
WHERE id IN (
|
||
SELECT sag.id
|
||
FROM skill_agent_grants sag
|
||
JOIN skills s ON sag.skill_id = s.id
|
||
JOIN agents a ON sag.agent_id = a.id
|
||
WHERE sag.tenant_id <> a.tenant_id
|
||
OR (s.is_system = 0 AND sag.tenant_id <> s.tenant_id)
|
||
);`,
|
||
|
||
// Version 36 → 37: enforce one default workstation link per agent.
|
||
// Mirrors PG migration 000062 partial unique index.
|
||
36: `CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_workstation_default
|
||
ON agent_workstation_links(agent_id) WHERE is_default = 1;`,
|
||
|
||
// Version 23 → 24: vault_documents scope/ownership consistency triggers.
|
||
// Mirrors PG migration 000055 CHECK constraint; SQLite cannot add CHECK via
|
||
// ALTER TABLE so we use BEFORE INSERT + BEFORE UPDATE triggers instead.
|
||
// fresh DBs get the inline CHECK in schema.sql; existing DBs get triggers.
|
||
23: `CREATE TRIGGER IF NOT EXISTS trg_vault_docs_scope_consistency_ins
|
||
BEFORE INSERT ON vault_documents
|
||
FOR EACH ROW
|
||
WHEN NOT (
|
||
(NEW.scope='personal' AND NEW.agent_id IS NOT NULL AND NEW.team_id IS NULL) OR
|
||
(NEW.scope='team' AND NEW.team_id IS NOT NULL AND NEW.agent_id IS NULL) OR
|
||
(NEW.scope='shared' AND NEW.agent_id IS NULL AND NEW.team_id IS NULL) OR
|
||
NEW.scope='custom'
|
||
)
|
||
BEGIN
|
||
SELECT RAISE(ABORT, 'vault_documents_scope_consistency violation');
|
||
END;
|
||
|
||
CREATE TRIGGER IF NOT EXISTS trg_vault_docs_scope_consistency_upd
|
||
BEFORE UPDATE OF scope, agent_id, team_id ON vault_documents
|
||
FOR EACH ROW
|
||
WHEN NOT (
|
||
(NEW.scope='personal' AND NEW.agent_id IS NOT NULL AND NEW.team_id IS NULL) OR
|
||
(NEW.scope='team' AND NEW.team_id IS NOT NULL AND NEW.agent_id IS NULL) OR
|
||
(NEW.scope='shared' AND NEW.agent_id IS NULL AND NEW.team_id IS NULL) OR
|
||
NEW.scope='custom'
|
||
)
|
||
BEGIN
|
||
SELECT RAISE(ABORT, 'vault_documents_scope_consistency violation');
|
||
END;`,
|
||
|
||
// Version 24 → 25: add chat_id column + composite index (mirrors PG migration 000056).
|
||
// SQLite lacks regex by default — skip backfill (desktop is single-user; cross-chat risk minimal).
|
||
24: `ALTER TABLE vault_documents ADD COLUMN chat_id TEXT;
|
||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team_chat ON vault_documents(team_id, chat_id) WHERE team_id IS NOT NULL;`,
|
||
|
||
// Version 25 → 26: change agent_heartbeats.provider_id FK to ON DELETE SET NULL
|
||
// (mirrors PG migration 000057). SQLite cannot ALTER FK clauses, so the table
|
||
// must be rebuilt. Explicit 25-column INSERT/SELECT to avoid silent column drift.
|
||
25: `-- Defensive: clear orphan provider_id refs before rebuild (idempotent).
|
||
UPDATE agent_heartbeats
|
||
SET provider_id = NULL
|
||
WHERE provider_id IS NOT NULL
|
||
AND provider_id NOT IN (SELECT id FROM llm_providers);
|
||
|
||
-- Rebuild table with ON DELETE SET NULL on provider_id FK.
|
||
CREATE TABLE agent_heartbeats_new (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
agent_id TEXT NOT NULL UNIQUE REFERENCES agents(id) ON DELETE CASCADE,
|
||
enabled BOOLEAN NOT NULL DEFAULT 0,
|
||
interval_sec INT NOT NULL DEFAULT 1800,
|
||
prompt TEXT,
|
||
provider_id TEXT REFERENCES llm_providers(id) ON DELETE SET NULL,
|
||
model VARCHAR(200),
|
||
isolated_session BOOLEAN NOT NULL DEFAULT 1,
|
||
light_context BOOLEAN NOT NULL DEFAULT 0,
|
||
ack_max_chars INT NOT NULL DEFAULT 300,
|
||
max_retries INT NOT NULL DEFAULT 2,
|
||
active_hours_start VARCHAR(5),
|
||
active_hours_end VARCHAR(5),
|
||
timezone TEXT,
|
||
channel VARCHAR(50),
|
||
chat_id TEXT,
|
||
next_run_at TEXT,
|
||
last_run_at TEXT,
|
||
last_status VARCHAR(20),
|
||
last_error TEXT,
|
||
run_count INT NOT NULL DEFAULT 0,
|
||
suppress_count INT NOT NULL DEFAULT 0,
|
||
metadata TEXT DEFAULT '{}',
|
||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
|
||
INSERT INTO agent_heartbeats_new (
|
||
id, agent_id, enabled, interval_sec, prompt, provider_id, model,
|
||
isolated_session, light_context, ack_max_chars, max_retries,
|
||
active_hours_start, active_hours_end, timezone, channel, chat_id,
|
||
next_run_at, last_run_at, last_status, last_error,
|
||
run_count, suppress_count, metadata, created_at, updated_at
|
||
) SELECT
|
||
id, agent_id, enabled, interval_sec, prompt, provider_id, model,
|
||
isolated_session, light_context, ack_max_chars, max_retries,
|
||
active_hours_start, active_hours_end, timezone, channel, chat_id,
|
||
next_run_at, last_run_at, last_status, last_error,
|
||
run_count, suppress_count, metadata, created_at, updated_at
|
||
FROM agent_heartbeats;
|
||
|
||
DROP TABLE agent_heartbeats;
|
||
ALTER TABLE agent_heartbeats_new RENAME TO agent_heartbeats;
|
||
|
||
-- Recreate the only index on agent_heartbeats (verified via grep).
|
||
CREATE INDEX IF NOT EXISTS idx_heartbeats_due
|
||
ON agent_heartbeats(next_run_at)
|
||
WHERE enabled = 1 AND next_run_at IS NOT NULL;`,
|
||
|
||
// Version 26 → 27: add encrypted_env BLOB column to secure_cli_agent_grants.
|
||
// Mirrors PG migration 000058 (renumbered from upstream 000056 during merge train).
|
||
// NULL = no grant-level env override.
|
||
// DOWN path: modernc.org/sqlite supports DROP COLUMN since v3.35 (bundled
|
||
// version is ≥3.39). If DROP COLUMN fails on an older embedded build, the
|
||
// fallback is to rebuild the table without the column — see runbook
|
||
// docs/runbooks/packages-migration-rollback.md.
|
||
26: `ALTER TABLE secure_cli_agent_grants ADD COLUMN encrypted_env BLOB;`,
|
||
|
||
// Version 37 → 38: bitrix_portals table (mirrors PG migration 000068).
|
||
// Stores per-tenant OAuth credentials + refresh state for Bitrix24 portals.
|
||
37: `CREATE TABLE IF NOT EXISTS bitrix_portals (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
name VARCHAR(100) NOT NULL,
|
||
domain VARCHAR(255) NOT NULL,
|
||
credentials BLOB,
|
||
state BLOB,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_bitrix_portals_tenant_name
|
||
ON bitrix_portals (tenant_id, name);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_bitrix_portals_domain
|
||
ON bitrix_portals (LOWER(TRIM(domain)));`,
|
||
|
||
// Version 38 → 39: selected browser cookie sync.
|
||
38: `CREATE TABLE IF NOT EXISTS browser_cookies (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
user_id VARCHAR(255) NOT NULL,
|
||
agent_id VARCHAR(255) NOT NULL,
|
||
domain TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
path TEXT NOT NULL DEFAULT '/',
|
||
encrypted_value TEXT NOT NULL,
|
||
secure INTEGER NOT NULL DEFAULT 0,
|
||
http_only INTEGER NOT NULL DEFAULT 0,
|
||
same_site VARCHAR(32) NOT NULL DEFAULT '',
|
||
expires_at TEXT,
|
||
source VARCHAR(64) NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
CHECK (TRIM(domain) <> ''),
|
||
CHECK (TRIM(name) <> ''),
|
||
CHECK (TRIM(path) <> '')
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_browser_cookies_scope_unique
|
||
ON browser_cookies (tenant_id, user_id, agent_id, domain, path, name);
|
||
CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain
|
||
ON browser_cookies (tenant_id, user_id, agent_id, domain);
|
||
CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at
|
||
ON browser_cookies (expires_at);`,
|
||
}
|
||
|
||
// addHooksTables is the SQLite incremental migration for schema v19 → v20.
|
||
// Mirrors PG migrations 000052–000055 (consolidated — desktop never shipped
|
||
// with intermediate agent_hooks / agent_hook_agents names).
|
||
const addHooksTables = `
|
||
CREATE TABLE IF NOT EXISTS hooks (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001',
|
||
scope TEXT NOT NULL CHECK (scope IN ('global', 'tenant', 'agent')),
|
||
event TEXT NOT NULL,
|
||
handler_type TEXT NOT NULL CHECK (handler_type IN ('command', 'http', 'prompt', 'script')),
|
||
config TEXT NOT NULL DEFAULT '{}',
|
||
matcher TEXT,
|
||
if_expr TEXT,
|
||
timeout_ms INTEGER NOT NULL DEFAULT 5000,
|
||
on_timeout TEXT NOT NULL DEFAULT 'block' CHECK (on_timeout IN ('block', 'allow')),
|
||
priority INTEGER NOT NULL DEFAULT 0,
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
version INTEGER NOT NULL DEFAULT 1,
|
||
source TEXT NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed', 'builtin')),
|
||
metadata TEXT NOT NULL DEFAULT '{}',
|
||
name TEXT,
|
||
created_by TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_hooks_lookup
|
||
ON hooks (tenant_id, event)
|
||
WHERE enabled = 1;
|
||
CREATE TABLE IF NOT EXISTS hook_agents (
|
||
hook_id TEXT NOT NULL REFERENCES hooks(id) ON DELETE CASCADE,
|
||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (hook_id, agent_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_hook_agents_agent
|
||
ON hook_agents (agent_id);
|
||
CREATE TABLE IF NOT EXISTS hook_executions (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
hook_id TEXT REFERENCES hooks(id) ON DELETE SET NULL,
|
||
session_id TEXT,
|
||
event TEXT NOT NULL,
|
||
input_hash TEXT,
|
||
decision TEXT NOT NULL CHECK (decision IN ('allow', 'block', 'error', 'timeout')),
|
||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||
retry INTEGER NOT NULL DEFAULT 0,
|
||
dedup_key TEXT,
|
||
error TEXT,
|
||
error_detail BLOB,
|
||
metadata TEXT NOT NULL DEFAULT '{}',
|
||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_hook_executions_session
|
||
ON hook_executions (session_id, created_at);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_hook_executions_dedup
|
||
ON hook_executions (dedup_key)
|
||
WHERE dedup_key IS NOT NULL;
|
||
CREATE TABLE IF NOT EXISTS tenant_hook_budget (
|
||
tenant_id TEXT NOT NULL PRIMARY KEY,
|
||
month_start TEXT NOT NULL,
|
||
budget_total INTEGER NOT NULL DEFAULT 0,
|
||
remaining INTEGER NOT NULL DEFAULT 0,
|
||
last_warned_at TEXT,
|
||
metadata TEXT NOT NULL DEFAULT '{}',
|
||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||
);`
|
||
|
||
// backfillV16 populates base_name / path_basename for rows that existed
|
||
// before the v15 -> v16 migration. Idempotent; re-running on already-filled
|
||
// rows is a no-op for already-filled base_name values.
|
||
func backfillV16(ctx context.Context, db *sql.DB) error {
|
||
type row struct{ id, path string }
|
||
|
||
// ---- team_task_attachments ----
|
||
attRows, err := collectIDPath(ctx, db,
|
||
`SELECT id, path FROM team_task_attachments WHERE base_name = ''`)
|
||
if err != nil {
|
||
return fmt.Errorf("v16 scan attachments: %w", err)
|
||
}
|
||
if err := updateBaseNames(ctx, db,
|
||
`UPDATE team_task_attachments SET base_name = ? WHERE id = ?`, attRows); err != nil {
|
||
return fmt.Errorf("v16 update attachments: %w", err)
|
||
}
|
||
|
||
// ---- vault_documents ----
|
||
docRows, err := collectIDPath(ctx, db,
|
||
`SELECT id, path FROM vault_documents WHERE path_basename = ''`)
|
||
if err != nil {
|
||
return fmt.Errorf("v16 scan vault_docs: %w", err)
|
||
}
|
||
if err := updateBaseNames(ctx, db,
|
||
`UPDATE vault_documents SET path_basename = ? WHERE id = ?`, docRows); err != nil {
|
||
return fmt.Errorf("v16 update vault_docs: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// collectIDPath reads (id, path) tuples for a SELECT that returns exactly
|
||
// those two columns. Separated so backfillV16 stays readable.
|
||
func collectIDPath(ctx context.Context, db *sql.DB, q string) ([][2]string, error) {
|
||
rows, err := db.QueryContext(ctx, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out [][2]string
|
||
for rows.Next() {
|
||
var id, path string
|
||
if err := rows.Scan(&id, &path); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, [2]string{id, path})
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// updateBaseNames runs a prepared UPDATE inside one transaction for all rows.
|
||
// No-op when rows is empty. The prepared statement form keeps SQLite happy
|
||
// on larger backfills (<= ~10k legacy rows on typical desktop lite DBs).
|
||
func updateBaseNames(ctx context.Context, db *sql.DB, updateSQL string, rows [][2]string) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
stmt, err := tx.PrepareContext(ctx, updateSQL)
|
||
if err != nil {
|
||
_ = tx.Rollback()
|
||
return err
|
||
}
|
||
defer stmt.Close()
|
||
for _, r := range rows {
|
||
bn := ComputeAttachmentBaseName(r[1])
|
||
if _, err := stmt.ExecContext(ctx, bn, r[0]); err != nil {
|
||
_ = tx.Rollback()
|
||
return fmt.Errorf("update %s: %w", r[0], err)
|
||
}
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// EnsureSchema creates tables if they don't exist and applies incremental migrations.
|
||
//
|
||
// Flow:
|
||
// 1. Fresh DB (no schema_version row) → apply full schema.sql + set version = SchemaVersion
|
||
// 2. Existing DB with version < SchemaVersion → apply patches sequentially
|
||
// 3. Existing DB with version == SchemaVersion → no-op
|
||
// 4. Always: seed master tenant (idempotent)
|
||
func EnsureSchema(db *sql.DB) error {
|
||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||
version INTEGER NOT NULL PRIMARY KEY
|
||
)`); err != nil {
|
||
return fmt.Errorf("create schema_version: %w", err)
|
||
}
|
||
|
||
var current int
|
||
err := db.QueryRow("SELECT version FROM schema_version LIMIT 1").Scan(¤t)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
// Fresh database — apply full schema.
|
||
slog.Info("sqlite: applying initial schema", "version", SchemaVersion)
|
||
tx, txErr := db.Begin()
|
||
if txErr != nil {
|
||
return fmt.Errorf("begin schema tx: %w", txErr)
|
||
}
|
||
if _, err := tx.Exec(schemaSQL); err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("apply schema: %w", err)
|
||
}
|
||
if _, err := tx.Exec("INSERT INTO schema_version (version) VALUES (?)", SchemaVersion); err != nil {
|
||
tx.Rollback()
|
||
return fmt.Errorf("set schema version: %w", err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return fmt.Errorf("commit schema tx: %w", err)
|
||
}
|
||
return seedMasterTenant(db)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("read schema version: %w", err)
|
||
}
|
||
|
||
// Apply incremental migrations for existing DBs.
|
||
if current < SchemaVersion {
|
||
slog.Info("sqlite: migrating schema", "from", current, "to", SchemaVersion)
|
||
for v := current; v < SchemaVersion; v++ {
|
||
patch, ok := migrations[v]
|
||
if !ok {
|
||
return fmt.Errorf("sqlite: missing migration for version %d → %d", v, v+1)
|
||
}
|
||
if tableName, columnName, ok := idempotentColumnMigration(v); ok {
|
||
hasColumn, err := sqliteColumnExists(db, tableName, columnName)
|
||
if err != nil {
|
||
return fmt.Errorf("inspect %s.%s: %w", tableName, columnName, err)
|
||
}
|
||
if hasColumn {
|
||
patch = `SELECT 1;`
|
||
}
|
||
}
|
||
// Migrations that rebuild a table referenced by another table's FK
|
||
// require foreign_keys=OFF per SQLite altertable §7. The pragma is
|
||
// a no-op inside a transaction, so toggle it around BEGIN/COMMIT.
|
||
// v25 → v26: rebuilds agent_heartbeats; heartbeat_run_logs.heartbeat_id FKs into it.
|
||
needsFKOff := v == 25
|
||
if needsFKOff {
|
||
if _, err := db.Exec("PRAGMA foreign_keys=OFF"); err != nil {
|
||
return fmt.Errorf("disable FK before v%d: %w", v, err)
|
||
}
|
||
}
|
||
tx, txErr := db.Begin()
|
||
if txErr != nil {
|
||
if needsFKOff {
|
||
_, _ = db.Exec("PRAGMA foreign_keys=ON")
|
||
}
|
||
return fmt.Errorf("begin migration tx v%d: %w", v, txErr)
|
||
}
|
||
if _, err := tx.Exec(patch); err != nil {
|
||
tx.Rollback()
|
||
if needsFKOff {
|
||
_, _ = db.Exec("PRAGMA foreign_keys=ON")
|
||
}
|
||
return fmt.Errorf("apply migration v%d: %w", v, err)
|
||
}
|
||
if _, err := tx.Exec(
|
||
"UPDATE schema_version SET version = ? WHERE version = ?", v+1, v,
|
||
); err != nil {
|
||
tx.Rollback()
|
||
if needsFKOff {
|
||
_, _ = db.Exec("PRAGMA foreign_keys=ON")
|
||
}
|
||
return fmt.Errorf("update schema version v%d: %w", v, err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
if needsFKOff {
|
||
_, _ = db.Exec("PRAGMA foreign_keys=ON")
|
||
}
|
||
return fmt.Errorf("commit migration v%d: %w", v, err)
|
||
}
|
||
if needsFKOff {
|
||
// Verify referential integrity after the rebuild.
|
||
if rows, qErr := db.Query("PRAGMA foreign_key_check"); qErr == nil {
|
||
if rows.Next() {
|
||
slog.Warn("sqlite: foreign_key_check reported violations after migration", "version", v+1)
|
||
}
|
||
rows.Close()
|
||
}
|
||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||
return fmt.Errorf("re-enable FK after v%d: %w", v, err)
|
||
}
|
||
}
|
||
// Post-SQL backfill hooks for migrations needing app-side logic.
|
||
// modernc.org/sqlite lacks regexp_replace, so the v15 → v16
|
||
// basename columns must be populated via a Go loop.
|
||
if v == 15 {
|
||
if err := backfillV16(context.Background(), db); err != nil {
|
||
return fmt.Errorf("v16 backfill: %w", err)
|
||
}
|
||
}
|
||
slog.Info("sqlite: applied migration", "version", v+1)
|
||
}
|
||
}
|
||
|
||
return seedMasterTenant(db)
|
||
}
|
||
|
||
func idempotentColumnMigration(version int) (string, string, bool) {
|
||
switch version {
|
||
case 26:
|
||
return "secure_cli_agent_grants", "encrypted_env", true
|
||
case 28:
|
||
return "webhook_calls", "lease_token", true
|
||
case 29:
|
||
return "webhooks", "encrypted_secret", true
|
||
case 33:
|
||
return "agents", "model_fallback", true
|
||
case 34:
|
||
return "skill_agent_grants", "can_manage", true
|
||
default:
|
||
return "", "", false
|
||
}
|
||
}
|
||
|
||
func sqliteColumnExists(db *sql.DB, tableName, columnName string) (bool, error) {
|
||
rows, err := db.Query("PRAGMA table_info(" + tableName + ")")
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var cid int
|
||
var name, colType string
|
||
var notNull int
|
||
var defaultValue any
|
||
var pk int
|
||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil {
|
||
return false, err
|
||
}
|
||
if name == columnName {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, rows.Err()
|
||
}
|
||
|
||
// seedMasterTenant ensures the master tenant row exists (idempotent).
|
||
func seedMasterTenant(db *sql.DB) error {
|
||
_, err := db.Exec(
|
||
`INSERT OR IGNORE INTO tenants (id, name, slug, status) VALUES (?, 'Master', 'master', 'active')`,
|
||
"0193a5b0-7000-7000-8000-000000000001",
|
||
)
|
||
if err != nil {
|
||
slog.Warn("sqlite: seed master tenant failed", "error", err)
|
||
}
|
||
return nil
|
||
}
|