feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s
Nightly Build / build (push) Successful in 7m40s
The first AgentScope::PerSubject system agent, and the reason that scope exists. Once a night, for each person with a supervision edge, it reads every message that person and the assistant exchanged since the previous review — across all their conversations — and writes one report for the people who supervise them. Schema (all registry except reports): - supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge, answering both 'whom does a background agent look at' and 'who may read what it produced', with real FKs so deleting a user cascades both ways - system_agent_coverage(agent_id, subject_user_id, covered_through): the per-subject watermark that makes 'everything since last time' a window — neither system_agent_runs (history for humans) nor system_agent_state (advances before the work), and advanced only on a completed pass so a crash re-covers instead of skipping - reports (owner schema, the second two-homes table after memory_docs): instance rows land in system.db, deliberately cleartext to the box owner, who is the intended reader (§2); the subject cannot see them structurally The pass reads the subject's database inside a supervisor's runtime, so the ephemeral session and run row land in the watcher's file; iteration is over subjects, so two parents watching one child get one review; and the subject need not be logged in when their space is unencrypted — via the new UserManager::open_unencrypted, which refuses an encrypted user outright (no key to be had) and never registers the pool as unlocked. The agent declares the new AgentMeta flag allow_tools: false, so its turn gets an empty tool registry — nothing for a prompt injection in the transcript to call — and produces its report as its final assistant message, read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on quiet days). chat_history::conversation_window is the transcript query; its four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a specific way the review would otherwise be wrong, and tool calls are absent by construction. Cadence is Run at (hour) rather than Interval — 4am local by default — with due-ness answered inside has_work against the coverage watermark, so a machine off for three days covers the whole stretch in one pass. Reports announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn gains a per-pass system_substitutions map, which the review uses to hand the model the subject's profile under __SUBJECT_PROFILE__ — the system-context substitutions describe the session owner, the wrong person here. docs/system-agents.md gains the conversation review section; CLAUDE.md documents the scope, the tables and the tool-less design.
This commit is contained in:
@@ -213,6 +213,136 @@ pub async fn for_stack_since(
|
||||
rows.into_iter().map(row_to_message).collect()
|
||||
}
|
||||
|
||||
/// One line of a cross-session transcript: a message with the conversation it
|
||||
/// belongs to. See [`conversation_window`].
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct TranscriptLine {
|
||||
pub session_id: i64,
|
||||
pub session_title: Option<String>,
|
||||
pub source: String,
|
||||
pub agent_id: String,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Every message this database's owner exchanged with an assistant between
|
||||
/// `since` (inclusive) and `until` (exclusive), across **all** their
|
||||
/// conversations, oldest first.
|
||||
///
|
||||
/// The window is half-open so consecutive calls tile without overlapping or
|
||||
/// skipping: today's `until` is tomorrow's `since`. Both bounds are UTC
|
||||
/// `'YYYY-MM-DD HH:MM:SS'`, the shape `datetime('now')` writes, so they compare
|
||||
/// as plain strings against `created_at`.
|
||||
///
|
||||
/// **Four filters, and each one exists because of a specific way the result would
|
||||
/// otherwise be wrong:**
|
||||
///
|
||||
/// - `is_ephemeral = 0` — a background agent's own throwaway sessions live in the
|
||||
/// same table. Without this, a pass that reads conversations would read the
|
||||
/// transcript its *previous* pass was given, and report on itself.
|
||||
/// - `depth = 0` — only the root frame. Deeper frames are sub-agents talking to
|
||||
/// each other: machine-to-machine chatter that nobody typed.
|
||||
/// - `is_synthetic = 0` — turns the machinery injected as if they were the user
|
||||
/// (notification briefings, job results). Attributing those to the person would
|
||||
/// be a lie about who said what.
|
||||
/// - `content <> ''` — an assistant row whose whole content was a tool call.
|
||||
///
|
||||
/// **Tool calls and their results are not here at all**, and that is by
|
||||
/// construction rather than by filter: they live in `chat_llm_tools`, keyed to a
|
||||
/// message id. So this returns what was *said*, never what was *done* — a web
|
||||
/// search the assistant ran is invisible, including its query.
|
||||
pub async fn conversation_window(
|
||||
pool: &SqlitePool,
|
||||
since: &str,
|
||||
until: &str,
|
||||
limit: i64,
|
||||
) -> anyhow::Result<Vec<TranscriptLine>> {
|
||||
// Newest-first with a LIMIT, then reversed: over budget, the window that
|
||||
// matters is the recent end, not whatever happened to come first.
|
||||
let mut rows = sqlx::query_as::<_, TranscriptLine>(
|
||||
"SELECT s.id AS session_id,
|
||||
s.title AS session_title,
|
||||
s.source AS source,
|
||||
s.agent_id AS agent_id,
|
||||
h.role AS role,
|
||||
h.content AS content,
|
||||
h.created_at AS created_at
|
||||
FROM chat_history h
|
||||
JOIN chat_sessions_stack st ON st.id = h.session_stack_id
|
||||
JOIN chat_sessions s ON s.id = st.session_id
|
||||
WHERE h.created_at >= ? AND h.created_at < ?
|
||||
AND h.status = 'ok'
|
||||
AND h.is_synthetic = 0
|
||||
AND h.content <> ''
|
||||
AND h.role IN ('user', 'assistant')
|
||||
AND st.depth = 0
|
||||
AND s.is_ephemeral = 0
|
||||
ORDER BY h.created_at DESC, h.id DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(since)
|
||||
.bind(until)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.reverse();
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many messages [`conversation_window`] would return, without loading them.
|
||||
/// The cheap look a scheduler takes before deciding a pass is worth opening.
|
||||
pub async fn conversation_window_count(
|
||||
pool: &SqlitePool,
|
||||
since: &str,
|
||||
until: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
let n = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT count(*)
|
||||
FROM chat_history h
|
||||
JOIN chat_sessions_stack st ON st.id = h.session_stack_id
|
||||
JOIN chat_sessions s ON s.id = st.session_id
|
||||
WHERE h.created_at >= ? AND h.created_at < ?
|
||||
AND h.status = 'ok'
|
||||
AND h.is_synthetic = 0
|
||||
AND h.content <> ''
|
||||
AND h.role IN ('user', 'assistant')
|
||||
AND st.depth = 0
|
||||
AND s.is_ephemeral = 0",
|
||||
)
|
||||
.bind(since)
|
||||
.bind(until)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The last thing the assistant said in a session's **root** frame.
|
||||
///
|
||||
/// For a caller whose agent produces a document rather than a side effect: the
|
||||
/// turn's answer is the deliverable, and it has to be read back from the store
|
||||
/// because `handle_message` returns nothing. Root frame only — the deepest
|
||||
/// sub-agent's last words are not the session's answer.
|
||||
pub async fn last_assistant_for_session(
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let content = sqlx::query_scalar::<_, String>(
|
||||
"SELECT h.content
|
||||
FROM chat_history h
|
||||
JOIN chat_sessions_stack st ON st.id = h.session_stack_id
|
||||
WHERE st.session_id = ? AND st.depth = 0
|
||||
AND h.role = 'assistant' AND h.status = 'ok' AND h.content <> ''
|
||||
ORDER BY h.id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Returns the most recent ok message for a stack frame, or `None` if empty.
|
||||
/// Used by Telegram's `/context` command to show last turn's token usage.
|
||||
pub async fn last_message_for_stack(
|
||||
@@ -274,3 +404,144 @@ pub async fn estimate_tokens_for_stack(
|
||||
|
||||
Ok((total_chars / 4).max(0) as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A standalone owner-schema database with one ordinary conversation and one
|
||||
/// of every thing the window must leave out.
|
||||
async fn seeded() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
crate::db::create_owner_tables(&pool).await.unwrap();
|
||||
|
||||
let q = |sql: &'static str| sqlx::query(sql).execute(&pool);
|
||||
|
||||
// A real conversation, and a second one the same day.
|
||||
q("INSERT INTO chat_sessions (id, title, source, agent_id, is_ephemeral) VALUES (1, 'Homework', 'web', 'kid', 0)").await.unwrap();
|
||||
q("INSERT INTO chat_sessions (id, title, source, agent_id, is_ephemeral) VALUES (2, NULL, 'telegram', 'kid', 0)").await.unwrap();
|
||||
// A background agent's throwaway session — the one that would make a
|
||||
// review read its own previous pass.
|
||||
q("INSERT INTO chat_sessions (id, title, source, agent_id, is_ephemeral) VALUES (3, 'review', 'conversation-review', 'conversation-review', 1)").await.unwrap();
|
||||
|
||||
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (1, 1, 0)").await.unwrap();
|
||||
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (2, 2, 0)").await.unwrap();
|
||||
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (3, 3, 0)").await.unwrap();
|
||||
// A sub-agent frame of the real conversation.
|
||||
q("INSERT INTO chat_sessions_stack (id, session_id, depth) VALUES (4, 1, 1)").await.unwrap();
|
||||
|
||||
let msg = |stack: i64, role: &'static str, content: &'static str, at: &'static str,
|
||||
synthetic: i64, status: &'static str| {
|
||||
sqlx::query(
|
||||
"INSERT INTO chat_history (session_stack_id, role, content, created_at, is_synthetic, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(stack).bind(role).bind(content).bind(at).bind(synthetic).bind(status)
|
||||
.execute(&pool)
|
||||
};
|
||||
|
||||
msg(1, "user", "kept: in window", "2026-07-28 21:00:00", 0, "ok").await.unwrap();
|
||||
msg(1, "assistant", "kept: the reply", "2026-07-28 21:00:30", 0, "ok").await.unwrap();
|
||||
msg(2, "user", "kept: other session", "2026-07-29 02:00:00", 0, "ok").await.unwrap();
|
||||
|
||||
msg(1, "user", "dropped: before", "2026-07-27 10:00:00", 0, "ok").await.unwrap();
|
||||
msg(1, "user", "dropped: after", "2026-07-30 10:00:00", 0, "ok").await.unwrap();
|
||||
msg(3, "user", "dropped: ephemeral", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
|
||||
msg(4, "assistant", "dropped: sub-agent", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
|
||||
msg(1, "user", "dropped: synthetic", "2026-07-28 22:00:00", 1, "ok").await.unwrap();
|
||||
msg(1, "assistant", "dropped: failed", "2026-07-28 22:00:00", 0, "failed").await.unwrap();
|
||||
msg(1, "assistant", "", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
|
||||
msg(1, "agent", "dropped: agent role", "2026-07-28 22:00:00", 0, "ok").await.unwrap();
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
const SINCE: &str = "2026-07-28 04:00:00";
|
||||
const UNTIL: &str = "2026-07-29 04:00:00";
|
||||
|
||||
/// Each exclusion is a way the review would otherwise be wrong; assert them
|
||||
/// together, because it is the *set* that defines "what was said".
|
||||
#[tokio::test]
|
||||
async fn the_window_keeps_only_what_was_said_in_it() {
|
||||
let pool = seeded().await;
|
||||
|
||||
let lines = conversation_window(&pool, SINCE, UNTIL, 100).await.unwrap();
|
||||
let kept: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
|
||||
|
||||
assert_eq!(kept, vec![
|
||||
"kept: in window",
|
||||
"kept: the reply",
|
||||
"kept: other session",
|
||||
], "everything else is a way the transcript would lie");
|
||||
|
||||
// The count is the same question, asked cheaply.
|
||||
assert_eq!(conversation_window_count(&pool, SINCE, UNTIL).await.unwrap(), 3);
|
||||
|
||||
// Oldest first, and each line carries the conversation it belongs to.
|
||||
assert_eq!(lines[0].session_id, 1);
|
||||
assert_eq!(lines[0].session_title.as_deref(), Some("Homework"));
|
||||
assert_eq!(lines[2].session_id, 2);
|
||||
assert_eq!(lines[2].source, "telegram");
|
||||
assert!(lines[2].session_title.is_none());
|
||||
}
|
||||
|
||||
/// Half-open, so consecutive windows tile: a message exactly on the boundary
|
||||
/// belongs to the later window, never to both and never to neither.
|
||||
#[tokio::test]
|
||||
async fn the_window_is_half_open() {
|
||||
let pool = seeded().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO chat_history (session_stack_id, role, content, created_at)
|
||||
VALUES (1, 'user', 'exactly on the edge', ?)",
|
||||
)
|
||||
.bind(UNTIL)
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
let before = conversation_window(&pool, SINCE, UNTIL, 100).await.unwrap();
|
||||
assert!(!before.iter().any(|l| l.content == "exactly on the edge"),
|
||||
"`until` is exclusive");
|
||||
|
||||
let after = conversation_window(&pool, UNTIL, "2026-07-30 04:00:00", 100).await.unwrap();
|
||||
assert!(after.iter().any(|l| l.content == "exactly on the edge"),
|
||||
"`since` is inclusive, so nothing falls between two windows");
|
||||
}
|
||||
|
||||
/// Over budget, the recent end is what survives — a truncated review of last
|
||||
/// night beats a complete review of last month.
|
||||
#[tokio::test]
|
||||
async fn a_capped_window_keeps_the_most_recent_messages() {
|
||||
let pool = seeded().await;
|
||||
|
||||
let lines = conversation_window(&pool, SINCE, UNTIL, 2).await.unwrap();
|
||||
let kept: Vec<&str> = lines.iter().map(|l| l.content.as_str()).collect();
|
||||
assert_eq!(kept, vec!["kept: the reply", "kept: other session"]);
|
||||
|
||||
// The count ignores the cap, which is how the caller knows it truncated.
|
||||
assert_eq!(conversation_window_count(&pool, SINCE, UNTIL).await.unwrap(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_last_assistant_message_comes_from_the_root_frame() {
|
||||
let pool = seeded().await;
|
||||
|
||||
// Session 1's newest root-frame assistant line, not the sub-agent's.
|
||||
sqlx::query(
|
||||
"INSERT INTO chat_history (session_stack_id, role, content, created_at)
|
||||
VALUES (1, 'assistant', 'the answer', '2026-07-29 03:00:00')",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO chat_history (session_stack_id, role, content, created_at)
|
||||
VALUES (4, 'assistant', 'sub-agent chatter', '2026-07-29 03:30:00')",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
last_assistant_for_session(&pool, 1).await.unwrap().as_deref(),
|
||||
Some("the answer"),
|
||||
);
|
||||
// A session that never got an answer says so rather than inventing one.
|
||||
assert!(last_assistant_for_session(&pool, 2).await.unwrap().is_none());
|
||||
assert!(last_assistant_for_session(&pool, 99).await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,15 @@ pub mod oauth_providers;
|
||||
pub mod plugins;
|
||||
pub mod plugin_access;
|
||||
pub mod plugin_user_configs;
|
||||
pub mod reports;
|
||||
pub mod role_capabilities;
|
||||
pub mod roles;
|
||||
pub mod scheduled_jobs;
|
||||
pub mod scratchpad;
|
||||
pub mod shared_folders;
|
||||
pub mod sources;
|
||||
pub mod supervision;
|
||||
pub mod system_agent_coverage;
|
||||
pub mod system_agent_runs;
|
||||
pub mod system_agent_state;
|
||||
pub mod tool_permission_groups;
|
||||
@@ -679,6 +682,76 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// The supervision edge (§0.1): one person's activity may be read on another's
|
||||
// behalf. **A generic edge between two users, and nothing more** — the domain
|
||||
// reading of it ("a parent watches a child") lives in the seed data and the UI
|
||||
// copy, never here, so a pivot to a mentor watching a trainee, or a care worker
|
||||
// watching a resident, renames nothing.
|
||||
//
|
||||
// It answers two questions with one table, which is why it is an edge and not a
|
||||
// per-agent list of subjects: *whom does a background agent look at* (the
|
||||
// distinct subjects) and *who may read what it produced* (the supervisors of a
|
||||
// given subject). The second is what the reports' `audience = 'supervisors'`
|
||||
// resolves against.
|
||||
//
|
||||
// Both FKs are registry→registry (same file), so they are allowed and the
|
||||
// cascade is real: deleting a user takes their edges with them, in both
|
||||
// directions.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS supervision (
|
||||
subject_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
supervisor_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (subject_user_id, supervisor_user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supervision_supervisor
|
||||
ON supervision(supervisor_user_id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// How far a background agent has *processed* a subject — the watermark that
|
||||
// makes "everything since last time" a well-defined window.
|
||||
//
|
||||
// **Not `system_agent_runs`, and not `system_agent_state`**, though it sits
|
||||
// between them and the difference is the whole reason it exists:
|
||||
//
|
||||
// `system_agent_state` when an agent last *attempted* a pass. Advances on
|
||||
// every tick, including idle ones, and is marked
|
||||
// *before* the work — so it can never delimit the
|
||||
// window the work is about.
|
||||
// this table how far the work actually got. Advances **only on a
|
||||
// completed pass**, so a crash mid-pass re-covers the
|
||||
// same stretch next time. For a review, a duplicate
|
||||
// report is a nuisance and a skipped window is a blind
|
||||
// spot: at-least-once is the only acceptable direction.
|
||||
//
|
||||
// The obvious alternative — deriving the watermark from the last report's
|
||||
// `period_end` — fails on a single ordinary action: a supervisor deleting an
|
||||
// old report would move the scheduler's window back and regenerate the very
|
||||
// report they discarded. A document is the user's to delete; scheduler state is
|
||||
// not, so they cannot be the same row.
|
||||
//
|
||||
// Registry, not owner, for a reason specific to how these passes run: the pass
|
||||
// executes inside *some* supervisor's runtime, and which one depends on who is
|
||||
// logged in tonight. A watermark in the acting user's file would give one
|
||||
// subject two unsynchronised clocks.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS system_agent_coverage (
|
||||
agent_id TEXT NOT NULL,
|
||||
subject_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
covered_through TEXT NOT NULL, -- UTC 'YYYY-MM-DD HH:MM:SS'
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (agent_id, subject_user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1128,6 +1201,68 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(trigger).execute(pool).await?;
|
||||
}
|
||||
|
||||
// Reports — the documents system agents write about a stretch of time
|
||||
// (blueprint §13). Like `memory_docs` above, one owner schema backs **two
|
||||
// homes**, and which file a row lands in *is* its audience:
|
||||
//
|
||||
// `{userid}.db` a report that belongs to that user, about that user —
|
||||
// their weekly "what you struggled to get done" digest.
|
||||
// Behind SQLCipher: nobody else can read it, admin included.
|
||||
// `system.db` an instance report, written about someone *for* the
|
||||
// people who supervise them. Cleartext to whoever owns the
|
||||
// box, deliberately — they are the intended reader (§2).
|
||||
//
|
||||
// That split is why nothing here filters by reader: a report's subject can
|
||||
// never see an instance report about them, because their tools only ever
|
||||
// touch their own pool. The invisibility is structural, not a rule someone
|
||||
// has to remember in each query.
|
||||
//
|
||||
// The producer's scope decides the file with no extra concept:
|
||||
// `AgentScope::PerUser` writes into `ctx.pool`, `AgentScope::Instance` into
|
||||
// the registry pool the agent already holds.
|
||||
//
|
||||
// `subject_user_id` / `producer_user_id` / `run_id` are **bare** columns, not
|
||||
// foreign keys: `users` lives in the registry (an owner→registry FK would
|
||||
// fail every INSERT), and for an instance row the `system_agent_runs` trace
|
||||
// sits in the *acting* user's file. They are snapshots, and a deleted user
|
||||
// leaves them dangling on purpose — the report outlives the account.
|
||||
//
|
||||
// `kind` is free-form producer-declared text, never an enum (§0.1). Rows are
|
||||
// immutable once written: the only UPDATE is the read acknowledgement.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- producer-declared type, not an enum
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT, -- one line: lists + notification text
|
||||
body TEXT NOT NULL DEFAULT '', -- markdown
|
||||
severity TEXT NOT NULL DEFAULT 'info', -- 'info' | 'notice' | 'alert'
|
||||
subject_user_id TEXT, -- who it is about (bare snapshot)
|
||||
audience TEXT NOT NULL DEFAULT 'owner', -- 'owner' | 'admins' | 'supervisors'
|
||||
period_start TEXT, -- the window it covers
|
||||
period_end TEXT,
|
||||
produced_by TEXT NOT NULL, -- system agent id
|
||||
producer_user_id TEXT, -- whose runtime ran the pass
|
||||
run_id INTEGER, -- system_agent_runs.id (bare snapshot)
|
||||
metadata TEXT, -- JSON counters; never contents
|
||||
read_at TEXT, -- shared acknowledgement: first reader wins
|
||||
read_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Listing is always newest-first, optionally narrowed to one subject. `kind`
|
||||
// is deliberately unindexed: a handful of rows a week means a scan is
|
||||
// cheaper than the index it would need.
|
||||
for index in [
|
||||
"CREATE INDEX IF NOT EXISTS idx_reports_created ON reports(created_at DESC, id DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_reports_subject ON reports(subject_user_id, created_at DESC)",
|
||||
] {
|
||||
sqlx::query(index).execute(pool).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1186,6 +1321,11 @@ mod tests {
|
||||
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
|
||||
// Fires the AFTER INSERT trigger into the external-content FTS5 table.
|
||||
one("INSERT INTO memory_docs (path, content) VALUES ('notes/x.md', 'hello world')").await.unwrap();
|
||||
// Bare `subject_user_id` / `producer_user_id` (registry `users`) and a
|
||||
// bare `run_id` that points at no row in this file — an FK on any of the
|
||||
// three would die right here.
|
||||
one("INSERT INTO reports (kind, title, body, produced_by, subject_user_id, producer_user_id, run_id)
|
||||
VALUES ('conversation-review', 't', 'b', 'agent', 'u-absent', 'u-also-absent', 4242)").await.unwrap();
|
||||
|
||||
// ...and the FTS index actually answers a MATCH.
|
||||
let (hits,): (i64,) = sqlx::query_as(
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Accessor for `reports` — the documents system agents write about a stretch
|
||||
//! of time (blueprint §13).
|
||||
//!
|
||||
//! **The pool is the audience.** Like [`super::memory_docs`], one owner schema
|
||||
//! backs two homes and the file a row lands in decides who may read it: a user's
|
||||
//! own encrypted database holds the reports that belong to them, `system.db`
|
||||
//! holds the instance ones — written about someone, for the people who supervise
|
||||
//! them. Nothing in here filters by reader, because there is nothing to filter:
|
||||
//! a subject's tools only ever reach their own pool. The separation is
|
||||
//! structural, not a predicate someone has to remember to add.
|
||||
//!
|
||||
//! Which file a producer writes into falls out of its own scope with no new
|
||||
//! concept: `AgentScope::PerUser` passes `ctx.pool`, `AgentScope::Instance`
|
||||
//! passes the registry pool it already holds.
|
||||
//!
|
||||
//! **A report is immutable.** It is a snapshot of a window that has closed, so
|
||||
//! there is no `update`: the only write after [`create`] is [`mark_read`], and
|
||||
//! even that is once — see its "first reader wins" note.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Severity, in ascending order of "someone should look at this". Free text in
|
||||
/// the column; these are the vocabulary the UI knows how to render.
|
||||
pub const SEVERITY_INFO: &str = "info";
|
||||
pub const SEVERITY_NOTICE: &str = "notice";
|
||||
pub const SEVERITY_ALERT: &str = "alert";
|
||||
|
||||
/// The report belongs to whoever owns the file it is in — the default, and the
|
||||
/// only meaningful value inside a `{userid}.db`.
|
||||
pub const AUDIENCE_OWNER: &str = "owner";
|
||||
/// An instance report (`system.db`) for the admins.
|
||||
pub const AUDIENCE_ADMINS: &str = "admins";
|
||||
/// An instance report for whoever holds a [`super::supervision`] edge over its
|
||||
/// `subject_user_id` — the audience that is *computed*, not enumerated, so adding
|
||||
/// a second parent to the edge widens the readership of every past report at once.
|
||||
pub const AUDIENCE_SUPERVISORS: &str = "supervisors";
|
||||
|
||||
/// A report with its body. Use [`ReportSummary`] for listings — the body is the
|
||||
/// bulk of the row and a list never renders it.
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct Report {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub body: String,
|
||||
pub severity: String,
|
||||
pub subject_user_id: Option<String>,
|
||||
pub audience: String,
|
||||
pub period_start: Option<String>,
|
||||
pub period_end: Option<String>,
|
||||
pub produced_by: String,
|
||||
pub producer_user_id: Option<String>,
|
||||
pub run_id: Option<i64>,
|
||||
pub metadata: Option<String>,
|
||||
pub read_at: Option<String>,
|
||||
pub read_by: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// A listing row: everything but `body`.
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct ReportSummary {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub severity: String,
|
||||
pub subject_user_id: Option<String>,
|
||||
pub audience: String,
|
||||
pub period_start: Option<String>,
|
||||
pub period_end: Option<String>,
|
||||
pub produced_by: String,
|
||||
pub producer_user_id: Option<String>,
|
||||
pub run_id: Option<i64>,
|
||||
pub metadata: Option<String>,
|
||||
pub read_at: Option<String>,
|
||||
pub read_by: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// The fields a producer supplies. `kind`, `title`, `body` and `produced_by` are
|
||||
/// the ones with no sensible default; everything else has one.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewReport<'a> {
|
||||
/// Producer-declared type — data, never an enum (§0.1). Groups the UI.
|
||||
pub kind: &'a str,
|
||||
pub title: &'a str,
|
||||
/// One line for lists and for the notification that announces it.
|
||||
pub summary: Option<&'a str>,
|
||||
/// Markdown.
|
||||
pub body: &'a str,
|
||||
pub severity: &'a str,
|
||||
/// Who the report is about. `None` for a report about nobody in particular.
|
||||
pub subject_user_id: Option<&'a str>,
|
||||
pub audience: &'a str,
|
||||
/// The window covered, ISO-8601. Both `None` for a point-in-time report.
|
||||
pub period_start: Option<&'a str>,
|
||||
pub period_end: Option<&'a str>,
|
||||
/// The system agent's id.
|
||||
pub produced_by: &'a str,
|
||||
/// Whose runtime ran the pass — for an instance report, not the subject.
|
||||
pub producer_user_id: Option<&'a str>,
|
||||
/// `system_agent_runs.id`. A bare snapshot: for an instance report that row
|
||||
/// lives in the acting user's file, not this one.
|
||||
pub run_id: Option<i64>,
|
||||
/// JSON counters. Never contents — the body is the only place text belongs.
|
||||
pub metadata: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Hand-written, not derived, for the same reason `RoleAttrs`'s is: a derived
|
||||
/// `Default` would leave `severity` and `audience` empty strings, and both are
|
||||
/// `NOT NULL` columns whose value the UI dispatches on. The defaults are the
|
||||
/// quiet, narrow ones — informational, and readable only by the file's owner.
|
||||
impl Default for NewReport<'_> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: "",
|
||||
title: "",
|
||||
summary: None,
|
||||
body: "",
|
||||
severity: SEVERITY_INFO,
|
||||
subject_user_id: None,
|
||||
audience: AUDIENCE_OWNER,
|
||||
period_start: None,
|
||||
period_end: None,
|
||||
produced_by: "",
|
||||
producer_user_id: None,
|
||||
run_id: None,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How to narrow a [`list`]. All-`None` lists everything, newest first.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ListFilter<'a> {
|
||||
pub kind: Option<&'a str>,
|
||||
pub subject_user_id: Option<&'a str>,
|
||||
/// Only reports nobody has acknowledged yet.
|
||||
pub unread_only: bool,
|
||||
/// Only reports created at or after this ISO timestamp.
|
||||
pub since: Option<&'a str>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
const SUMMARY_COLS: &str = "id, kind, title, summary, severity, subject_user_id, audience, \
|
||||
period_start, period_end, produced_by, producer_user_id, run_id, metadata, \
|
||||
read_at, read_by, created_at";
|
||||
|
||||
/// Write a report. Returns its id.
|
||||
pub async fn create(pool: &SqlitePool, report: &NewReport<'_>) -> Result<i64> {
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO reports
|
||||
(kind, title, summary, body, severity, subject_user_id, audience,
|
||||
period_start, period_end, produced_by, producer_user_id, run_id, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(report.kind)
|
||||
.bind(report.title)
|
||||
.bind(report.summary)
|
||||
.bind(report.body)
|
||||
.bind(report.severity)
|
||||
.bind(report.subject_user_id)
|
||||
.bind(report.audience)
|
||||
.bind(report.period_start)
|
||||
.bind(report.period_end)
|
||||
.bind(report.produced_by)
|
||||
.bind(report.producer_user_id)
|
||||
.bind(report.run_id)
|
||||
.bind(report.metadata)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Fetch one report, body included.
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Report>> {
|
||||
let row = sqlx::query_as::<_, Report>(
|
||||
"SELECT id, kind, title, summary, body, severity, subject_user_id, audience,
|
||||
period_start, period_end, produced_by, producer_user_id, run_id, metadata,
|
||||
read_at, read_by, created_at
|
||||
FROM reports WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// List reports newest first, without their bodies.
|
||||
///
|
||||
/// `id DESC` breaks ties: `created_at` has second resolution, and two reports of
|
||||
/// the same pass land inside one tick often enough that the order would
|
||||
/// otherwise be whatever SQLite felt like.
|
||||
pub async fn list(pool: &SqlitePool, filter: &ListFilter<'_>) -> Result<Vec<ReportSummary>> {
|
||||
// The SQL text is assembled only from these literals — every caller-supplied
|
||||
// value goes through a bind, in the same order the predicates were pushed.
|
||||
let mut predicates: Vec<&str> = Vec::new();
|
||||
if filter.kind.is_some() { predicates.push("kind = ?"); }
|
||||
if filter.subject_user_id.is_some() { predicates.push("subject_user_id = ?"); }
|
||||
if filter.unread_only { predicates.push("read_at IS NULL"); }
|
||||
if filter.since.is_some() { predicates.push("created_at >= ?"); }
|
||||
|
||||
let mut sql = format!("SELECT {SUMMARY_COLS} FROM reports");
|
||||
if !predicates.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&predicates.join(" AND "));
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC, id DESC");
|
||||
if filter.limit.is_some() {
|
||||
sql.push_str(" LIMIT ?");
|
||||
}
|
||||
|
||||
let mut query = sqlx::query_as::<_, ReportSummary>(sqlx::AssertSqlSafe(sql));
|
||||
if let Some(kind) = filter.kind { query = query.bind(kind); }
|
||||
if let Some(subject) = filter.subject_user_id { query = query.bind(subject); }
|
||||
if let Some(since) = filter.since { query = query.bind(since); }
|
||||
if let Some(limit) = filter.limit { query = query.bind(limit); }
|
||||
|
||||
Ok(query.fetch_all(pool).await?)
|
||||
}
|
||||
|
||||
/// How many reports nobody has acknowledged — the badge count.
|
||||
pub async fn unread_count(pool: &SqlitePool) -> Result<i64> {
|
||||
let n = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM reports WHERE read_at IS NULL")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Acknowledge a report on behalf of `user_id`. Returns whether this call is the
|
||||
/// one that marked it.
|
||||
///
|
||||
/// **First reader wins, and that is the semantics, not an optimisation.** An
|
||||
/// instance report can have several readers (two admins); an alert about the
|
||||
/// same evening is one thing to deal with, dealt with once. The `read_at IS
|
||||
/// NULL` guard makes the write idempotent and keeps `read_by` pointing at
|
||||
/// whoever actually took it, instead of whoever opened it last.
|
||||
pub async fn mark_read(pool: &SqlitePool, id: i64, user_id: &str) -> Result<bool> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE reports SET read_at = datetime('now'), read_by = ?
|
||||
WHERE id = ? AND read_at IS NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Delete a report. Returns whether a row was removed.
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
let n = sqlx::query("DELETE FROM reports WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A standalone owner-schema database in a throwaway temp dir, as in
|
||||
/// `memory_docs`: `tag` plus a counter keep parallel tests off one file.
|
||||
async fn owner_pool(tag: &str) -> (SqlitePool, PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("skald-reports-{}-{tag}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pool = crate::db::create_user_pool(&dir.join("owner.db"), None).await.unwrap();
|
||||
(pool, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_stores_every_field_and_defaults_the_rest() {
|
||||
let (pool, dir) = owner_pool("create").await;
|
||||
|
||||
// The minimum a producer must supply: the defaults fill the rest.
|
||||
let bare = create(&pool, &NewReport {
|
||||
kind: "usage-digest",
|
||||
title: "Your week with the assistant",
|
||||
body: "You asked for a calendar three times and got nowhere.",
|
||||
produced_by: "usage-digest",
|
||||
..Default::default()
|
||||
}).await.unwrap();
|
||||
|
||||
let got = get(&pool, bare).await.unwrap().unwrap();
|
||||
assert_eq!(got.severity, SEVERITY_INFO, "a bare report is informational");
|
||||
assert_eq!(got.audience, AUDIENCE_OWNER, "...and readable only by its file's owner");
|
||||
assert!(got.subject_user_id.is_none());
|
||||
assert!(got.read_at.is_none(), "a fresh report is unread");
|
||||
assert!(!got.created_at.is_empty());
|
||||
|
||||
// A full instance report: subject and run_id are bare snapshots, so
|
||||
// neither has to exist anywhere in this file.
|
||||
let full = create(&pool, &NewReport {
|
||||
kind: "conversation-review",
|
||||
title: "Something to look at",
|
||||
summary: Some("one line for the notification"),
|
||||
body: "# Detail\n\nnarrated, not quoted.",
|
||||
severity: SEVERITY_ALERT,
|
||||
subject_user_id: Some("u-nobody"),
|
||||
audience: AUDIENCE_ADMINS,
|
||||
period_start: Some("2026-07-28T00:00:00Z"),
|
||||
period_end: Some("2026-07-29T00:00:00Z"),
|
||||
produced_by: "conversation-review",
|
||||
producer_user_id: Some("u-someone-else"),
|
||||
run_id: Some(4242),
|
||||
metadata: Some(r#"{"sessions_scanned":7}"#),
|
||||
}).await.unwrap();
|
||||
|
||||
let got = get(&pool, full).await.unwrap().unwrap();
|
||||
assert_eq!(got.severity, SEVERITY_ALERT);
|
||||
assert_eq!(got.audience, AUDIENCE_ADMINS);
|
||||
assert_eq!(got.subject_user_id.as_deref(), Some("u-nobody"));
|
||||
assert_eq!(got.producer_user_id.as_deref(), Some("u-someone-else"));
|
||||
assert_eq!(got.run_id, Some(4242));
|
||||
assert_eq!(got.period_end.as_deref(), Some("2026-07-29T00:00:00Z"));
|
||||
assert!(got.body.starts_with("# Detail"));
|
||||
|
||||
assert!(get(&pool, 9999).await.unwrap().is_none());
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_narrows_and_orders_newest_first() {
|
||||
let (pool, dir) = owner_pool("list").await;
|
||||
|
||||
let mk = |kind: &'static str, subject: Option<&'static str>| {
|
||||
let pool = pool.clone();
|
||||
async move {
|
||||
create(&pool, &NewReport {
|
||||
kind,
|
||||
title: "t",
|
||||
body: "b",
|
||||
subject_user_id: subject,
|
||||
produced_by: "agent",
|
||||
..Default::default()
|
||||
}).await.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
let first = mk("usage-digest", None).await;
|
||||
let second = mk("conversation-review", Some("u-kid")).await;
|
||||
let third = mk("conversation-review", Some("u-other")).await;
|
||||
|
||||
// Newest first, with `id` breaking the same-second tie.
|
||||
let all = list(&pool, &ListFilter::default()).await.unwrap();
|
||||
assert_eq!(all.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second, first]);
|
||||
|
||||
let by_kind = list(&pool, &ListFilter {
|
||||
kind: Some("conversation-review"), ..Default::default()
|
||||
}).await.unwrap();
|
||||
assert_eq!(by_kind.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second]);
|
||||
|
||||
let by_subject = list(&pool, &ListFilter {
|
||||
subject_user_id: Some("u-kid"), ..Default::default()
|
||||
}).await.unwrap();
|
||||
assert_eq!(by_subject.len(), 1);
|
||||
assert_eq!(by_subject[0].id, second);
|
||||
|
||||
// Two filters compose, and `limit` applies after the ordering.
|
||||
let both = list(&pool, &ListFilter {
|
||||
kind: Some("conversation-review"),
|
||||
subject_user_id: Some("u-other"),
|
||||
..Default::default()
|
||||
}).await.unwrap();
|
||||
assert_eq!(both.len(), 1);
|
||||
assert_eq!(both[0].id, third);
|
||||
|
||||
let capped = list(&pool, &ListFilter { limit: Some(2), ..Default::default() }).await.unwrap();
|
||||
assert_eq!(capped.iter().map(|r| r.id).collect::<Vec<_>>(), vec![third, second]);
|
||||
|
||||
// `since` is inclusive, and a future timestamp excludes everything.
|
||||
assert!(list(&pool, &ListFilter {
|
||||
since: Some("2999-01-01T00:00:00Z"), ..Default::default()
|
||||
}).await.unwrap().is_empty());
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Several admins share one instance report; whoever gets there first is the
|
||||
/// one who took it, and the count reflects the household, not each reader.
|
||||
#[tokio::test]
|
||||
async fn acknowledgement_is_shared_and_first_writer_wins() {
|
||||
let (pool, dir) = owner_pool("read").await;
|
||||
|
||||
let id = create(&pool, &NewReport {
|
||||
kind: "conversation-review", title: "t", body: "b",
|
||||
audience: AUDIENCE_ADMINS, produced_by: "agent", ..Default::default()
|
||||
}).await.unwrap();
|
||||
let other = create(&pool, &NewReport {
|
||||
kind: "usage-digest", title: "t2", body: "b", produced_by: "agent", ..Default::default()
|
||||
}).await.unwrap();
|
||||
|
||||
assert_eq!(unread_count(&pool).await.unwrap(), 2);
|
||||
assert_eq!(list(&pool, &ListFilter { unread_only: true, ..Default::default() })
|
||||
.await.unwrap().len(), 2);
|
||||
|
||||
assert!(mark_read(&pool, id, "u-anna").await.unwrap(), "the first reader takes it");
|
||||
assert!(!mark_read(&pool, id, "u-bruno").await.unwrap(), "the second changes nothing");
|
||||
|
||||
let got = get(&pool, id).await.unwrap().unwrap();
|
||||
assert_eq!(got.read_by.as_deref(), Some("u-anna"), "read_by keeps whoever took it");
|
||||
assert!(got.read_at.is_some());
|
||||
|
||||
assert_eq!(unread_count(&pool).await.unwrap(), 1);
|
||||
let unread = list(&pool, &ListFilter { unread_only: true, ..Default::default() })
|
||||
.await.unwrap();
|
||||
assert_eq!(unread.len(), 1);
|
||||
assert_eq!(unread[0].id, other);
|
||||
|
||||
assert!(!mark_read(&pool, 9999, "u-anna").await.unwrap(), "an absent report marks nothing");
|
||||
|
||||
assert!(delete(&pool, id).await.unwrap());
|
||||
assert!(!delete(&pool, id).await.unwrap(), "a second delete is a no-op");
|
||||
assert!(get(&pool, id).await.unwrap().is_none());
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Accessor for `supervision` — the edge that says one person's activity may be
|
||||
//! read on another's behalf (§0.1).
|
||||
//!
|
||||
//! Deliberately anaemic: an edge, two directions, no attributes. It carries no
|
||||
//! notion of *what* the supervisor may see, because that belongs to whatever
|
||||
//! reads it — today one background agent, tomorrow a read gate on the reports it
|
||||
//! writes. Putting "may read conversations" / "may read memory" on the row here
|
||||
//! would be inventing a permission model before anything asks for one.
|
||||
//!
|
||||
//! Registry table, so both foreign keys are real and the cascade is too: deleting
|
||||
//! either user removes the edge.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// One edge.
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct SupervisionEdge {
|
||||
pub subject_user_id: String,
|
||||
pub supervisor_user_id: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Every user somebody supervises, in a stable order.
|
||||
///
|
||||
/// The order matters more than it looks: it is the order a background pass walks
|
||||
/// its subjects in, and a stable one makes a partial pass (the process died
|
||||
/// halfway) resume predictably instead of favouring whoever sorts first by
|
||||
/// accident.
|
||||
pub async fn subjects(pool: &SqlitePool) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_scalar::<_, String>(
|
||||
"SELECT DISTINCT subject_user_id FROM supervision ORDER BY subject_user_id",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Who supervises `subject`, in a stable order.
|
||||
pub async fn supervisors_of(pool: &SqlitePool, subject: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_scalar::<_, String>(
|
||||
"SELECT supervisor_user_id FROM supervision
|
||||
WHERE subject_user_id = ?
|
||||
ORDER BY supervisor_user_id",
|
||||
)
|
||||
.bind(subject)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Whom `supervisor` watches, in a stable order.
|
||||
pub async fn subjects_of(pool: &SqlitePool, supervisor: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_scalar::<_, String>(
|
||||
"SELECT subject_user_id FROM supervision
|
||||
WHERE supervisor_user_id = ?
|
||||
ORDER BY subject_user_id",
|
||||
)
|
||||
.bind(supervisor)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Is this edge present? The question a future read gate on a report asks.
|
||||
pub async fn supervises(pool: &SqlitePool, supervisor: &str, subject: &str) -> Result<bool> {
|
||||
let n = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT count(*) FROM supervision
|
||||
WHERE supervisor_user_id = ? AND subject_user_id = ?",
|
||||
)
|
||||
.bind(supervisor)
|
||||
.bind(subject)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Every edge, for an admin listing.
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<SupervisionEdge>> {
|
||||
let rows = sqlx::query_as::<_, SupervisionEdge>(
|
||||
"SELECT subject_user_id, supervisor_user_id, created_at FROM supervision
|
||||
ORDER BY subject_user_id, supervisor_user_id",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Add an edge. Idempotent on the primary key.
|
||||
///
|
||||
/// Refuses a self-edge: supervising yourself would make every subject their own
|
||||
/// supervisor, which is not a special case anyone wants — it is the pass reading
|
||||
/// its own runtime's data and reporting it to itself.
|
||||
pub async fn add(pool: &SqlitePool, subject: &str, supervisor: &str) -> Result<()> {
|
||||
if subject == supervisor {
|
||||
anyhow::bail!("a user cannot supervise themselves");
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO supervision (subject_user_id, supervisor_user_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(subject_user_id, supervisor_user_id) DO NOTHING",
|
||||
)
|
||||
.bind(subject)
|
||||
.bind(supervisor)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an edge. Returns whether one was there.
|
||||
pub async fn remove(pool: &SqlitePool, subject: &str, supervisor: &str) -> Result<bool> {
|
||||
let n = sqlx::query(
|
||||
"DELETE FROM supervision WHERE subject_user_id = ? AND supervisor_user_id = ?",
|
||||
)
|
||||
.bind(subject)
|
||||
.bind(supervisor)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A registry pool with two users to hang edges off — the FKs are enforced,
|
||||
/// so the rows have to exist.
|
||||
async fn registry() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
crate::db::create_registry_tables(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
|
||||
.execute(&pool).await.unwrap();
|
||||
for (id, name) in [("u-anna", "anna"), ("u-bruno", "bruno"), ("u-kid", "kid")] {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, display_name, role_id, encrypted)
|
||||
VALUES (?, ?, ?, 'member', 0)",
|
||||
)
|
||||
.bind(id).bind(name).bind(name)
|
||||
.execute(&pool).await.unwrap();
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_edge_reads_from_both_ends() {
|
||||
let pool = registry().await;
|
||||
|
||||
add(&pool, "u-kid", "u-anna").await.unwrap();
|
||||
add(&pool, "u-kid", "u-bruno").await.unwrap();
|
||||
add(&pool, "u-kid", "u-anna").await.unwrap(); // idempotent
|
||||
|
||||
assert_eq!(subjects(&pool).await.unwrap(), vec!["u-kid"]);
|
||||
assert_eq!(supervisors_of(&pool, "u-kid").await.unwrap(), vec!["u-anna", "u-bruno"]);
|
||||
assert_eq!(subjects_of(&pool, "u-anna").await.unwrap(), vec!["u-kid"]);
|
||||
assert!(supervises(&pool, "u-anna", "u-kid").await.unwrap());
|
||||
assert!(!supervises(&pool, "u-kid", "u-anna").await.unwrap(), "the edge is directed");
|
||||
assert_eq!(list(&pool).await.unwrap().len(), 2);
|
||||
|
||||
assert!(remove(&pool, "u-kid", "u-anna").await.unwrap());
|
||||
assert!(!remove(&pool, "u-kid", "u-anna").await.unwrap());
|
||||
assert_eq!(supervisors_of(&pool, "u-kid").await.unwrap(), vec!["u-bruno"]);
|
||||
// One supervisor left, so the subject is still watched.
|
||||
assert_eq!(subjects(&pool).await.unwrap(), vec!["u-kid"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_self_edge_is_refused() {
|
||||
let pool = registry().await;
|
||||
assert!(add(&pool, "u-anna", "u-anna").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_takes_their_edges_from_both_directions() {
|
||||
let pool = registry().await;
|
||||
add(&pool, "u-kid", "u-anna").await.unwrap();
|
||||
add(&pool, "u-bruno", "u-anna").await.unwrap();
|
||||
|
||||
// The supervisor goes: both edges they were on go with them.
|
||||
sqlx::query("DELETE FROM users WHERE id = 'u-anna'").execute(&pool).await.unwrap();
|
||||
assert!(list(&pool).await.unwrap().is_empty(), "cascade must clear both directions");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Accessor for `system_agent_coverage` — how far a background agent has
|
||||
//! processed a given subject.
|
||||
//!
|
||||
//! This is the watermark that turns "everything since last time" into a
|
||||
//! well-defined window `[covered_through, now)`. Two properties carry the whole
|
||||
//! design, and both are the opposite of [`super::system_agent_state`]:
|
||||
//!
|
||||
//! - **It advances only on a completed pass.** A crash halfway leaves the mark
|
||||
//! where it was, so the next pass re-covers that stretch. For a review, a
|
||||
//! duplicate is a nuisance and a gap is a blind spot.
|
||||
//! - **It is written after the work, not before.** `mark_attempt` is deliberately
|
||||
//! the first thing `run_and_record` does, which is exactly why it can never
|
||||
//! delimit the window the work is about.
|
||||
//!
|
||||
//! Timestamps are UTC `'YYYY-MM-DD HH:MM:SS'` — the format SQLite's
|
||||
//! `datetime('now')` produces — so they compare as strings against the
|
||||
//! `created_at` columns they are used to filter.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Format an instant the way SQLite's `datetime('now')` does, so the two are
|
||||
/// string-comparable. The one place that knows the format.
|
||||
pub fn stamp(at: chrono::DateTime<chrono::Utc>) -> String {
|
||||
at.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Now, in that format.
|
||||
pub fn now_stamp() -> String {
|
||||
stamp(chrono::Utc::now())
|
||||
}
|
||||
|
||||
/// How far `agent_id` has processed `subject`, or `None` if it never has.
|
||||
pub async fn covered_through(
|
||||
pool: &SqlitePool,
|
||||
agent_id: &str,
|
||||
subject: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let at = sqlx::query_scalar::<_, String>(
|
||||
"SELECT covered_through FROM system_agent_coverage
|
||||
WHERE agent_id = ? AND subject_user_id = ?",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(subject)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// Move the watermark forward to `through`.
|
||||
///
|
||||
/// **Monotonic**: an older value than the one stored is ignored rather than
|
||||
/// applied. Two passes for the same subject cannot run concurrently today (the
|
||||
/// scheduler is sequential and single-instance), so this is not a race guard — it
|
||||
/// is a guard against a caller computing a window start and writing *that* back
|
||||
/// instead of the window end, which would silently make the agent re-read the
|
||||
/// same stretch forever.
|
||||
pub async fn advance(
|
||||
pool: &SqlitePool,
|
||||
agent_id: &str,
|
||||
subject: &str,
|
||||
through: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO system_agent_coverage (agent_id, subject_user_id, covered_through)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(agent_id, subject_user_id) DO UPDATE SET
|
||||
covered_through = MAX(system_agent_coverage.covered_through, excluded.covered_through),
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(subject)
|
||||
.bind(through)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a subject's watermark, so the next pass starts from scratch. For an
|
||||
/// admin-side "review this person again from the beginning".
|
||||
pub async fn clear(pool: &SqlitePool, agent_id: &str, subject: &str) -> Result<bool> {
|
||||
let n = sqlx::query(
|
||||
"DELETE FROM system_agent_coverage WHERE agent_id = ? AND subject_user_id = ?",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(subject)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn registry() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
crate::db::create_registry_tables(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, role_id, encrypted)
|
||||
VALUES ('u-kid', 'kid', 'member', 0)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn never_covered_reads_as_none_then_advances() {
|
||||
let pool = registry().await;
|
||||
assert!(covered_through(&pool, "conversation-review", "u-kid").await.unwrap().is_none());
|
||||
|
||||
advance(&pool, "conversation-review", "u-kid", "2026-07-28 04:00:00").await.unwrap();
|
||||
assert_eq!(
|
||||
covered_through(&pool, "conversation-review", "u-kid").await.unwrap().unwrap(),
|
||||
"2026-07-28 04:00:00",
|
||||
);
|
||||
|
||||
advance(&pool, "conversation-review", "u-kid", "2026-07-29 04:00:00").await.unwrap();
|
||||
assert_eq!(
|
||||
covered_through(&pool, "conversation-review", "u-kid").await.unwrap().unwrap(),
|
||||
"2026-07-29 04:00:00",
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard that stops a caller from writing the window *start* back.
|
||||
#[tokio::test]
|
||||
async fn the_watermark_never_moves_backwards() {
|
||||
let pool = registry().await;
|
||||
advance(&pool, "conversation-review", "u-kid", "2026-07-29 04:00:00").await.unwrap();
|
||||
advance(&pool, "conversation-review", "u-kid", "2026-07-01 04:00:00").await.unwrap();
|
||||
assert_eq!(
|
||||
covered_through(&pool, "conversation-review", "u-kid").await.unwrap().unwrap(),
|
||||
"2026-07-29 04:00:00",
|
||||
"an older value must not rewind the watermark",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agents_and_subjects_do_not_share_a_row() {
|
||||
let pool = registry().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, role_id, encrypted)
|
||||
VALUES ('u-two', 'two', 'member', 0)",
|
||||
)
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
advance(&pool, "conversation-review", "u-kid", "2026-07-29 04:00:00").await.unwrap();
|
||||
assert!(covered_through(&pool, "conversation-review", "u-two").await.unwrap().is_none());
|
||||
assert!(covered_through(&pool, "weekly-digest", "u-kid").await.unwrap().is_none());
|
||||
|
||||
assert!(clear(&pool, "conversation-review", "u-kid").await.unwrap());
|
||||
assert!(!clear(&pool, "conversation-review", "u-kid").await.unwrap());
|
||||
assert!(covered_through(&pool, "conversation-review", "u-kid").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stamp_matches_sqlite_datetime_shape() {
|
||||
let s = now_stamp();
|
||||
assert_eq!(s.len(), 19, "'YYYY-MM-DD HH:MM:SS'");
|
||||
assert_eq!(&s[4..5], "-");
|
||||
assert_eq!(&s[10..11], " ");
|
||||
assert_eq!(&s[13..14], ":");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user