1pub const PRIVILEGED_KIND: &str = "system";
11
12pub fn is_privileged_kind(kind: &str) -> bool {
19 kind == PRIVILEGED_KIND
20}
21
22pub const SCHEMA: &str = r#"
24-- NOTE: pragmas (journal_mode=WAL, busy_timeout, foreign_keys) are set by
25-- the connection opener *before* this batch runs — concurrent openers race
26-- if we set them here.
27
28CREATE TABLE IF NOT EXISTS projects (
29 id TEXT PRIMARY KEY,
30 name TEXT NOT NULL
31);
32
33CREATE TABLE IF NOT EXISTS agents (
34 id TEXT PRIMARY KEY, -- "<project>:<agent>"
35 project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
36 role TEXT NOT NULL,
37 runtime TEXT NOT NULL,
38 is_manager INTEGER NOT NULL DEFAULT 0,
39 reports_to TEXT -- short name, resolved within project
40);
41
42CREATE INDEX IF NOT EXISTS agents_project_idx ON agents(project_id);
43
44CREATE TABLE IF NOT EXISTS messages (
45 id INTEGER PRIMARY KEY AUTOINCREMENT,
46 project_id TEXT NOT NULL,
47 sender TEXT NOT NULL, -- "<project>:<agent>" or "user:<handle>" or "cli"
48 recipient TEXT NOT NULL, -- "<project>:<agent>" or "channel:<project>:<name>"
49 text TEXT NOT NULL,
50 thread_id TEXT,
51 sent_at REAL NOT NULL,
52 delivered_at REAL,
53 acked_at REAL
54);
55
56CREATE INDEX IF NOT EXISTS messages_recipient_idx
57 ON messages(recipient, acked_at);
58CREATE INDEX IF NOT EXISTS messages_project_idx
59 ON messages(project_id, sent_at);
60
61-- Channels + subscriptions + per-agent ACLs.
62CREATE TABLE IF NOT EXISTS channels (
63 id TEXT PRIMARY KEY, -- "<project>:<name>"
64 project_id TEXT NOT NULL,
65 name TEXT NOT NULL,
66 wildcard INTEGER NOT NULL DEFAULT 0 -- 1 iff members = "*"
67);
68
69CREATE TABLE IF NOT EXISTS channel_members (
70 channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
71 agent_id TEXT NOT NULL,
72 PRIMARY KEY (channel_id, agent_id)
73);
74
75CREATE INDEX IF NOT EXISTS channel_members_agent_idx
76 ON channel_members(agent_id);
77
78CREATE TABLE IF NOT EXISTS agent_acls (
79 agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
80 can_dm_json TEXT NOT NULL DEFAULT '[]', -- ["dev","critic"]
81 can_bcast_json TEXT NOT NULL DEFAULT '[]' -- ["product","all"]
82);
83
84-- Inter-project manager bridges.
85CREATE TABLE IF NOT EXISTS bridges (
86 id INTEGER PRIMARY KEY AUTOINCREMENT,
87 from_agent TEXT NOT NULL, -- "<project>:<agent>", must be a manager
88 to_agent TEXT NOT NULL, -- "<project>:<agent>", must be a manager
89 topic TEXT NOT NULL,
90 opened_by TEXT NOT NULL, -- "user:<handle>" or "cli"
91 opened_at REAL NOT NULL,
92 expires_at REAL NOT NULL,
93 closed_at REAL
94);
95
96CREATE INDEX IF NOT EXISTS bridges_open_idx
97 ON bridges(expires_at, closed_at);
98
99-- Human-in-the-loop permission fabric.
100CREATE TABLE IF NOT EXISTS approvals (
101 id INTEGER PRIMARY KEY AUTOINCREMENT,
102 project_id TEXT NOT NULL,
103 agent_id TEXT NOT NULL,
104 action TEXT NOT NULL, -- "publish", "deploy", ...
105 scope_tag TEXT, -- optional narrower tag
106 summary TEXT NOT NULL,
107 payload_json TEXT,
108 status TEXT NOT NULL, -- pending | approved | denied | expired | undeliverable
109 requested_at REAL NOT NULL,
110 decided_at REAL,
111 decided_by TEXT,
112 decision_note TEXT,
113 expires_at REAL NOT NULL,
114 delivered_at REAL -- NULL until an interface adapter confirms surfacing to a human
115);
116
117CREATE INDEX IF NOT EXISTS approvals_pending_idx
118 ON approvals(status, expires_at);
119
120-- Budget ledger. Rows are appended by runtime cost parsers (the claude-code
121-- writer is `teamctl budget-record`, fired from the Stop hook). `teamctl
122-- budget` aggregates per project/day.
123CREATE TABLE IF NOT EXISTS budget (
124 id INTEGER PRIMARY KEY AUTOINCREMENT,
125 project_id TEXT NOT NULL,
126 agent_id TEXT,
127 runtime TEXT,
128 usd REAL NOT NULL DEFAULT 0,
129 -- claude-code writer: prompt + both cache buckets (the schema has no cache
130 -- columns; cache tokens are priced into `usd` at their own rates). So this
131 -- is "all non-output tokens", not strictly Anthropic "input tokens".
132 input_tok INTEGER NOT NULL DEFAULT 0,
133 output_tok INTEGER NOT NULL DEFAULT 0,
134 observed_at REAL NOT NULL
135);
136
137CREATE INDEX IF NOT EXISTS budget_project_day_idx
138 ON budget(project_id, observed_at);
139
140-- Rate-limit events. Written by `teamctl rl-watch` whenever a runtime
141-- emits a rate-limit signature. Hooks (notify, webhook, run) run off these
142-- rows; the wrapper loop sleeps until `resets_at` before respawning.
143CREATE TABLE IF NOT EXISTS rate_limits (
144 id INTEGER PRIMARY KEY AUTOINCREMENT,
145 agent_id TEXT NOT NULL,
146 runtime TEXT NOT NULL,
147 hit_at REAL NOT NULL,
148 resets_at REAL, -- nullable: sometimes we can't parse
149 raw_match TEXT NOT NULL,
150 handled_at REAL
151);
152
153CREATE INDEX IF NOT EXISTS rate_limits_agent_idx
154 ON rate_limits(agent_id, hit_at);
155"#;
156
157pub fn ensure(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
161 conn.execute_batch(SCHEMA)?;
162 let migrations: &[&str] = &[
165 "ALTER TABLE approvals ADD COLUMN delivered_at REAL",
166 "ALTER TABLE messages ADD COLUMN kind TEXT",
178 "ALTER TABLE messages ADD COLUMN structured_payload TEXT",
179 "ALTER TABLE messages ADD COLUMN telegram_msg_id INTEGER",
186 "ALTER TABLE messages ADD COLUMN delivery_mode TEXT",
192 "ALTER TABLE approvals ADD COLUMN options_json TEXT",
199 "ALTER TABLE approvals ADD COLUMN decision_value TEXT",
200 ];
201 for stmt in migrations {
202 if let Err(e) = conn.execute(stmt, []) {
203 let msg = e.to_string();
204 if !msg.contains("duplicate column name") {
205 return Err(e);
206 }
207 }
208 }
209 Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn privileged_kind_const_is_system() {
218 assert_eq!(PRIVILEGED_KIND, "system");
222 }
223
224 #[test]
225 fn is_privileged_kind_true_only_for_system() {
226 assert!(is_privileged_kind("system"));
227 }
228
229 #[test]
230 fn is_privileged_kind_false_for_ordinary_kinds() {
231 for kind in ["image", "file", "media_error", "text", ""] {
236 assert!(
237 !is_privileged_kind(kind),
238 "kind {kind:?} must not be privileged"
239 );
240 }
241 }
242}