use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
mod fts;
use fts::create_fts_index;
#[cfg(test)]
use fts::events_extract_sql;
pub use fts::sanitize_fts5_query;
mod turn_chain;
use turn_chain::{
genesis_hash, insert_turn_row, last_turn, next_tick, turn_row_from_sql, TurnRow,
TURN_ENCODING_VERSION_CURRENT,
};
use crate::artifact::{
ArtifactId, ArtifactKind, ArtifactRelation, NewPromptArtifact, PromptArtifact,
};
use crate::conversation::{
new_conversation_id, session_plan_dir, ConversationRecord, ConversationSummary,
ConversationTurn,
};
use crate::prompt::{NewPrompt, PromptId, PromptOrigin, PromptReceipt, TurnPromptContext};
const DB_FILE: &str = "conversations.db";
const LEGACY_JSON_DIR: &str = "conversations";
const LEGACY_BACKUP_DIR: &str = "conversations.imported";
const NONCE_FILE: &str = "install-nonce";
const IDENTITY_PEM_FILE: &str = "identity.pem";
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_PROMPT_LINEAGE_DEPTH: usize = 256;
const ARTIFACT_GENESIS_PREFIX: &[u8] = b"newt-prompt-artifact-chain-genesis:v1";
const MAX_ARTIFACT_PAGE_SIZE: usize = 256;
#[derive(Debug, Clone)]
pub struct ConversationStore {
conn: Arc<Mutex<Connection>>,
workspace: PathBuf,
workspace_id: String,
writer_fingerprint: String,
max_per_workspace: usize,
wal_fallback: Option<String>,
claim_clock: fn() -> i64,
host: String,
boot_id: String,
pid: i64,
liveness: LivenessFn,
}
impl ConversationStore {
pub fn new(
root: impl AsRef<Path>,
workspace: impl AsRef<Path>,
max_per_workspace: usize,
) -> anyhow::Result<Self> {
let root = root.as_ref().to_path_buf();
let workspace = std::fs::canonicalize(workspace.as_ref())?;
let workspace_id = crate::workspace_key::workspace_key_v2(&workspace)?;
std::fs::create_dir_all(&root)?;
let writer_fingerprint = resolve_writer_fingerprint(&root)?;
let conn = Connection::open(root.join(DB_FILE))?;
conn.busy_timeout(BUSY_TIMEOUT)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
let wal_fallback = {
let mut attempt = 0u32;
loop {
match apply_journal_mode(&conn)
.and_then(|fb| create_schema(&conn).map(|()| fb))
.and_then(|fb| reconcile_schema(&conn).map(|()| fb))
.and_then(|fb| migrate_roadmaps_pk(&conn).map(|()| fb))
.and_then(|fb| create_fts_index(&conn).map(|()| fb))
{
Ok(fb) => break fb,
Err(e)
if attempt < 20
&& e.to_string().to_ascii_lowercase().contains("locked") =>
{
attempt += 1;
std::thread::sleep(std::time::Duration::from_millis(
25 * u64::from(attempt.min(4)),
));
}
Err(e) => return Err(e),
}
}
};
import_legacy_json(&conn, &root, &writer_fingerprint)?;
migrate_workspace_key(&conn, &workspace, &workspace_id)?;
let (host, boot_id) = current_host_boot();
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
workspace,
workspace_id,
writer_fingerprint,
max_per_workspace,
wal_fallback,
claim_clock: now_claim_nanos,
host,
boot_id,
pid: i64::from(std::process::id()),
liveness: system_liveness,
})
}
#[deprecated(
since = "0.6.8",
note = "v1 keying is path-fragile; use `newt_core::workspace_key_v2` \
(17.2). This stays only for the UUIDv5→v2 row migration and \
legacy-import dir names."
)]
pub fn workspace_id_for_path(path: impl AsRef<Path>) -> anyhow::Result<String> {
let canonical = std::fs::canonicalize(path.as_ref())?;
let normalized = canonical.to_string_lossy().replace('\\', "/");
Ok(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, normalized.as_bytes()).to_string())
}
pub fn wal_fallback_notice(&self) -> Option<&str> {
self.wal_fallback.as_deref()
}
pub fn writer_fingerprint(&self) -> &str {
&self.writer_fingerprint
}
pub fn begin_prompt(
&self,
conversation_id: &str,
title: &str,
persona: Option<&str>,
prompt: NewPrompt,
) -> anyhow::Result<TurnPromptContext> {
validate_record_id(conversation_id)?;
std::str::from_utf8(prompt.model_text()).map_err(|error| {
anyhow::anyhow!(
"prompt model text is not valid UTF-8 and cannot be sent to inference: {error}"
)
})?;
let now = (self.claim_clock)();
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let owner: Option<String> = tx
.query_row(
"SELECT workspace_key FROM conversations WHERE id = ?1",
[conversation_id],
|row| row.get(0),
)
.optional()?;
if let Some(owner) = owner.as_deref() {
if owner != self.workspace_id {
anyhow::bail!(
"conversation id `{conversation_id}` belongs to another workspace \
(key {owner}); refusing to attach a prompt across the workspace fence"
);
}
}
let tick = next_tick(&tx, &self.writer_fingerprint)?;
let created = owner.is_none();
if created {
tx.execute(
"INSERT INTO conversations
(id, title, workspace_path, workspace_key, persona, end_reason,
writer_fingerprint, activity_tick, tip_hash,
started_at_claim, updated_at_claim)
VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9, ?9)",
rusqlite::params![
conversation_id,
title.trim(),
self.workspace.to_string_lossy(),
self.workspace_id,
persona,
self.writer_fingerprint,
tick,
genesis_hash(conversation_id, &self.writer_fingerprint),
now,
],
)?;
} else {
tx.execute(
"UPDATE conversations
SET activity_tick = ?2, updated_at_claim = ?3
WHERE id = ?1 AND workspace_key = ?4",
rusqlite::params![conversation_id, tick, now, self.workspace_id],
)?;
}
let previous_prompt_id = latest_prompt_on_conn(&tx, conversation_id, &self.workspace_id)?
.map(|receipt| receipt.id());
let prompt_id = PromptId::new();
let parent = match prompt.parent_prompt_id {
Some(parent_id) => Some(
load_prompt_in_conversation_on_conn(
&tx,
conversation_id,
parent_id,
&self.workspace_id,
)?
.ok_or_else(|| {
anyhow::anyhow!(
"prompt parent {parent_id} is not in conversation \
`{conversation_id}` in this workspace"
)
})?,
),
None => None,
};
if prompt.origin == PromptOrigin::HarnessRetry && parent.is_none() {
anyhow::bail!("a harness retry must name an operator-prompt parent");
}
let root_prompt_id = match parent.as_ref() {
Some(parent) => {
validate_objective_root_on_conn(
&tx,
conversation_id,
parent.root_prompt_id(),
&self.workspace_id,
)?;
parent.root_prompt_id()
}
None => prompt_id,
};
let active_operator = match (prompt.origin, parent.as_ref()) {
(PromptOrigin::Operator, None) => None,
(PromptOrigin::Operator, Some(parent)) | (PromptOrigin::HarnessRetry, Some(parent)) => {
let (active, parent_depth) = resolve_active_operator_on_conn(
&tx,
conversation_id,
parent,
&self.workspace_id,
)?;
if parent_depth >= MAX_PROMPT_LINEAGE_DEPTH {
anyhow::bail!(
"prompt lineage would exceed the maximum prompt lineage depth of \
{MAX_PROMPT_LINEAGE_DEPTH} receipts"
);
}
Some(active)
}
(PromptOrigin::HarnessRetry, None) => {
anyhow::bail!("a harness retry must name an operator-prompt parent")
}
};
let active_operator_id = active_operator
.as_ref()
.map_or(prompt_id, PromptReceipt::id);
let receipt = PromptReceipt::new(
prompt_id,
conversation_id.to_string(),
self.writer_fingerprint.clone(),
tick,
previous_prompt_id,
parent.as_ref().map(PromptReceipt::id),
root_prompt_id,
active_operator_id,
prompt.origin,
prompt.raw_text,
prompt.model_text,
now,
);
insert_prompt_receipt(&tx, &receipt)?;
let context = TurnPromptContext::new(
receipt.clone(),
active_operator.unwrap_or_else(|| receipt.clone()),
);
tx.commit()?;
drop(conn);
if created {
if let Err(error) = self.prune_to_cap_excluding(conversation_id) {
tracing::warn!(
%error,
conversation_id,
"prompt committed but conversation retention pruning failed"
);
}
}
Ok(context)
}
pub fn load_prompt(&self, prompt_id: PromptId) -> anyhow::Result<Option<PromptReceipt>> {
let conn = self.lock_conn();
load_prompt_on_conn(&conn, prompt_id, &self.workspace_id)
}
pub fn load_prompt_in_conversation(
&self,
conversation_id: &str,
prompt_id: PromptId,
) -> anyhow::Result<Option<PromptReceipt>> {
validate_record_id(conversation_id)?;
let conn = self.lock_conn();
load_prompt_in_conversation_on_conn(&conn, conversation_id, prompt_id, &self.workspace_id)
}
pub fn latest_prompt(&self, conversation_id: &str) -> anyhow::Result<Option<PromptReceipt>> {
validate_record_id(conversation_id)?;
let conn = self.lock_conn();
latest_prompt_on_conn(&conn, conversation_id, &self.workspace_id)
}
pub fn previous_prompt(&self, prompt_id: PromptId) -> anyhow::Result<Option<PromptReceipt>> {
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Deferred)?;
let Some(receipt) = load_prompt_on_conn(&tx, prompt_id, &self.workspace_id)? else {
tx.commit()?;
return Ok(None);
};
let Some(previous) = receipt.previous_prompt_id() else {
tx.commit()?;
return Ok(None);
};
let previous = load_prompt_in_conversation_on_conn(
&tx,
receipt.conversation_id(),
previous,
&self.workspace_id,
)?;
tx.commit()?;
Ok(previous)
}
pub fn prompt_chain(&self, conversation_id: &str) -> anyhow::Result<Vec<PromptReceipt>> {
validate_record_id(conversation_id)?;
let conn = self.lock_conn();
prompt_chain_on_conn(&conn, conversation_id, &self.workspace_id)
}
pub fn append_prompt_artifact(
&self,
conversation_id: &str,
prompt_id: PromptId,
content: NewPromptArtifact,
) -> anyhow::Result<PromptArtifact> {
validate_record_id(conversation_id)?;
content.validate()?;
let now = (self.claim_clock)();
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let owner: Option<String> = tx
.query_row(
"SELECT workspace_key FROM conversations WHERE id = ?1",
[conversation_id],
|row| row.get(0),
)
.optional()?;
match owner.as_deref() {
Some(owner) if owner == self.workspace_id => {}
Some(owner) => anyhow::bail!(
"conversation id `{conversation_id}` belongs to another workspace \
(key {owner}); refusing to append an artifact across the workspace fence"
),
None => anyhow::bail!("unknown conversation `{conversation_id}`"),
}
let prompt_chain = prompt_chain_on_conn(&tx, conversation_id, &self.workspace_id)?;
let prompt = prompt_chain
.iter()
.find(|prompt| prompt.id() == prompt_id)
.ok_or_else(|| {
anyhow::anyhow!(
"prompt {prompt_id} is not in conversation `{conversation_id}` in this workspace"
)
})?;
validate_objective_root_on_conn(
&tx,
conversation_id,
prompt.root_prompt_id(),
&self.workspace_id,
)?;
let chain = artifact_chain_on_conn(&tx, conversation_id, &self.workspace_id)?;
let (seq, prev_hash) = match chain.last() {
Some(previous) => (
previous
.seq()
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("prompt artifact sequence overflow"))?,
previous.artifact_hash().to_string(),
),
None => (1, artifact_genesis_hash(conversation_id)),
};
let artifact = PromptArtifact::mint(
ArtifactId::new(),
conversation_id.to_string(),
self.writer_fingerprint.clone(),
seq,
prev_hash,
prompt.id(),
prompt.root_prompt_id(),
content,
now,
)?;
insert_prompt_artifact(&tx, &artifact)?;
tx.commit()?;
Ok(artifact)
}
pub fn load_prompt_artifact(
&self,
conversation_id: &str,
artifact_id: ArtifactId,
) -> anyhow::Result<Option<PromptArtifact>> {
validate_record_id(conversation_id)?;
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Deferred)?;
let found = artifact_chain_on_conn(&tx, conversation_id, &self.workspace_id)?
.into_iter()
.find(|artifact| artifact.id() == artifact_id);
tx.commit()?;
Ok(found)
}
pub fn list_prompt_artifacts(
&self,
conversation_id: &str,
offset: usize,
limit: usize,
) -> anyhow::Result<Vec<PromptArtifact>> {
self.page_prompt_artifacts(conversation_id, offset, limit)
.map(|(artifacts, _)| artifacts)
}
pub fn page_prompt_artifacts(
&self,
conversation_id: &str,
offset: usize,
limit: usize,
) -> anyhow::Result<(Vec<PromptArtifact>, usize)> {
validate_record_id(conversation_id)?;
validate_artifact_page_size(limit)?;
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Deferred)?;
let chain = artifact_chain_on_conn(&tx, conversation_id, &self.workspace_id)?;
let total = chain.len();
let page = chain.into_iter().skip(offset).take(limit).collect();
tx.commit()?;
Ok((page, total))
}
pub fn count_prompt_artifacts(&self, conversation_id: &str) -> anyhow::Result<usize> {
self.page_prompt_artifacts(conversation_id, 0, 0)
.map(|(_, total)| total)
}
pub fn list_prompt_artifacts_for_prompt(
&self,
conversation_id: &str,
prompt_id: PromptId,
offset: usize,
limit: usize,
) -> anyhow::Result<Vec<PromptArtifact>> {
self.page_prompt_artifacts_for_prompt(conversation_id, prompt_id, offset, limit)
.map(|(artifacts, _)| artifacts)
}
pub fn page_prompt_artifacts_for_prompt(
&self,
conversation_id: &str,
prompt_id: PromptId,
offset: usize,
limit: usize,
) -> anyhow::Result<(Vec<PromptArtifact>, usize)> {
validate_record_id(conversation_id)?;
validate_artifact_page_size(limit)?;
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Deferred)?;
if load_prompt_in_conversation_on_conn(&tx, conversation_id, prompt_id, &self.workspace_id)?
.is_none()
{
tx.commit()?;
return Ok((Vec::new(), 0));
}
let matching: Vec<_> = artifact_chain_on_conn(&tx, conversation_id, &self.workspace_id)?
.into_iter()
.filter(|artifact| artifact.prompt_id() == prompt_id)
.collect();
let total = matching.len();
let page = matching.into_iter().skip(offset).take(limit).collect();
tx.commit()?;
Ok((page, total))
}
pub fn count_prompt_artifacts_for_prompt(
&self,
conversation_id: &str,
prompt_id: PromptId,
) -> anyhow::Result<usize> {
self.page_prompt_artifacts_for_prompt(conversation_id, prompt_id, 0, 0)
.map(|(_, total)| total)
}
pub fn list_prompt_artifacts_for_root(
&self,
conversation_id: &str,
root_prompt_id: PromptId,
offset: usize,
limit: usize,
) -> anyhow::Result<Vec<PromptArtifact>> {
self.page_prompt_artifacts_for_root(conversation_id, root_prompt_id, offset, limit)
.map(|(artifacts, _)| artifacts)
}
pub fn page_prompt_artifacts_for_root(
&self,
conversation_id: &str,
root_prompt_id: PromptId,
offset: usize,
limit: usize,
) -> anyhow::Result<(Vec<PromptArtifact>, usize)> {
validate_record_id(conversation_id)?;
validate_artifact_page_size(limit)?;
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Deferred)?;
let Some(root) = load_prompt_in_conversation_on_conn(
&tx,
conversation_id,
root_prompt_id,
&self.workspace_id,
)?
else {
tx.commit()?;
return Ok((Vec::new(), 0));
};
if root.id() != root.root_prompt_id() {
anyhow::bail!("prompt {root_prompt_id} is not an objective root");
}
validate_objective_root_on_conn(&tx, conversation_id, root_prompt_id, &self.workspace_id)?;
let matching: Vec<_> = artifact_chain_on_conn(&tx, conversation_id, &self.workspace_id)?
.into_iter()
.filter(|artifact| artifact.root_prompt_id() == root_prompt_id)
.collect();
let total = matching.len();
let page = matching.into_iter().skip(offset).take(limit).collect();
tx.commit()?;
Ok((page, total))
}
pub fn count_prompt_artifacts_for_root(
&self,
conversation_id: &str,
root_prompt_id: PromptId,
) -> anyhow::Result<usize> {
self.page_prompt_artifacts_for_root(conversation_id, root_prompt_id, 0, 0)
.map(|(_, total)| total)
}
pub fn verify_prompt_artifact_chain(&self, conversation_id: &str) -> anyhow::Result<()> {
validate_record_id(conversation_id)?;
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Deferred)?;
artifact_chain_on_conn(&tx, conversation_id, &self.workspace_id)?;
tx.commit()?;
Ok(())
}
pub fn turn_prompt_context(
&self,
conversation_id: &str,
submitted_prompt_id: PromptId,
) -> anyhow::Result<Option<TurnPromptContext>> {
validate_record_id(conversation_id)?;
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Deferred)?;
let Some(submitted) = load_prompt_in_conversation_on_conn(
&tx,
conversation_id,
submitted_prompt_id,
&self.workspace_id,
)?
else {
tx.commit()?;
return Ok(None);
};
let (active, _) =
resolve_active_operator_on_conn(&tx, conversation_id, &submitted, &self.workspace_id)?;
tx.commit()?;
Ok(Some(TurnPromptContext::new(submitted, active)))
}
pub fn create(&self, title: &str, persona: Option<&str>) -> anyhow::Result<String> {
let id = new_conversation_id();
self.create_with_id(&id, title, persona)?;
Ok(id)
}
pub fn create_with_id(
&self,
id: &str,
title: &str,
persona: Option<&str>,
) -> anyhow::Result<()> {
validate_record_id(id)?;
let now = (self.claim_clock)();
{
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let foreign: Option<String> = tx
.query_row(
"SELECT workspace_key FROM conversations WHERE id = ?1",
rusqlite::params![id],
|row| row.get(0),
)
.optional()?;
if let Some(owner) = foreign {
if owner != self.workspace_id {
anyhow::bail!(
"conversation id `{id}` already exists in another workspace \
(key {owner}); refusing to overwrite across the workspace fence"
);
}
let has_prompt_receipts: bool = tx.query_row(
"SELECT EXISTS(
SELECT 1 FROM prompt_receipts WHERE conversation_id = ?1
)",
[id],
|row| row.get(0),
)?;
if has_prompt_receipts {
anyhow::bail!(
"conversation `{id}` has immutable prompt receipts; refusing to \
recreate it implicitly (delete it explicitly before reusing the id)"
);
}
}
let tick = next_tick(&tx, &self.writer_fingerprint)?;
tx.execute(
"INSERT OR REPLACE INTO conversations
(id, title, workspace_path, workspace_key, persona, end_reason,
writer_fingerprint, activity_tick, tip_hash,
started_at_claim, updated_at_claim)
VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9, ?9)",
rusqlite::params![
id,
title.trim(),
self.workspace.to_string_lossy(),
self.workspace_id,
persona,
self.writer_fingerprint,
tick,
genesis_hash(id, &self.writer_fingerprint),
now,
],
)?;
tx.commit()?;
}
if let Err(error) = self.prune_to_cap_excluding(id) {
tracing::warn!(
%error,
conversation_id = id,
"conversation created but retention pruning failed"
);
}
Ok(())
}
pub fn exists(&self, id: &str) -> anyhow::Result<bool> {
let conn = self.lock_conn();
Ok(conn
.query_row(
"SELECT 1 FROM conversations WHERE id = ?1 AND workspace_key = ?2",
rusqlite::params![id, self.workspace_id],
|_| Ok(()),
)
.optional()?
.is_some())
}
pub fn append_turn(&self, id: &str, user: &str, assistant: &str) -> anyhow::Result<()> {
self.append_turn_full(id, user, assistant, &[], &[], None, None)
}
#[allow(clippy::too_many_arguments)]
pub fn append_turn_full(
&self,
id: &str,
user: &str,
assistant: &str,
events: &[crate::ToolEvent],
phantom_reaches: &[crate::PhantomReach],
tokens_in: Option<u32>,
tokens_out: Option<u32>,
) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
let now = (self.claim_clock)();
let events_json = serde_json::to_string(events)?;
let phantom_reaches_json = serde_json::to_string(phantom_reaches)?;
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let tick = next_tick(&tx, &self.writer_fingerprint)?;
let prev_hash = match last_turn(&tx, &id, &self.writer_fingerprint)? {
Some(prev) => prev.content_hash()?,
None => genesis_hash(&id, &self.writer_fingerprint),
};
let row = TurnRow {
conversation_id: id.clone(),
writer_fingerprint: self.writer_fingerprint.clone(),
seq: tick,
prev_hash,
user: user.to_string(),
assistant: assistant.to_string(),
events: events_json,
tokens_in: tokens_in.map(i64::from),
tokens_out: tokens_out.map(i64::from),
ts_claim: now,
encoding_version: TURN_ENCODING_VERSION_CURRENT,
};
insert_turn_row(&tx, &row, &phantom_reaches_json)?;
tx.execute(
"UPDATE conversations
SET writer_fingerprint = ?2, activity_tick = ?3, tip_hash = ?4,
updated_at_claim = ?5
WHERE id = ?1",
rusqlite::params![id, self.writer_fingerprint, tick, row.content_hash()?, now],
)?;
tx.commit()?;
Ok(())
}
pub fn load(&self, id: &str) -> anyhow::Result<ConversationRecord> {
let id = self.resolve_id(id)?;
let conn = self.lock_conn();
let (mut record, scratchpad_json, plan_json) = conn
.query_row(
"SELECT id, title, workspace_path, workspace_key, persona,
started_at_claim, updated_at_claim, scratchpad, plan,
roadmap_id, node_id
FROM conversations
WHERE id = ?1 AND workspace_key = ?2",
rusqlite::params![id, self.workspace_id],
|row| {
Ok((
ConversationRecord {
id: row.get(0)?,
title: row.get(1)?,
workspace: row.get(2)?,
workspace_id: row.get(3)?,
persona: row.get(4)?,
turns: Vec::new(),
scratchpad: std::collections::BTreeMap::new(),
plan: crate::PlanSnapshot::default(),
roadmap_id: row.get(9)?,
node_id: row.get(10)?,
created_at_unix_nanos: claim_to_u128(row.get(5)?),
updated_at_unix_nanos: claim_to_u128(row.get(6)?),
},
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
))
},
)
.optional()?
.ok_or_else(|| anyhow::anyhow!("conversation `{id}` not found"))?;
record.scratchpad = serde_json::from_str(&scratchpad_json).map_err(|e| {
anyhow::anyhow!(
"conversation `{id}`: scratchpad column is not valid <state> JSON \
({e}); refusing to load garbage"
)
})?;
record.plan = serde_json::from_str(&plan_json).map_err(|e| {
anyhow::anyhow!(
"conversation `{id}`: plan column is not valid <plan> snapshot JSON \
({e}); refusing to load garbage"
)
})?;
let mut stmt = conn.prepare(
"SELECT user, assistant, events, tokens_in, tokens_out, phantom_reaches FROM turns
WHERE conversation_id = ?1
ORDER BY seq ASC, writer_fingerprint ASC",
)?;
let turns = stmt.query_map([&id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, String>(5)?,
))
})?;
for turn in turns {
let (user, assistant, events_json, tokens_in, tokens_out, phantom_reaches_json) = turn?;
let events: Vec<crate::ToolEvent> =
serde_json::from_str(&events_json).map_err(|e| {
anyhow::anyhow!(
"conversation `{id}`: turn events column is not valid tool-event \
JSON ({e}); refusing to load garbage"
)
})?;
let phantom_reaches: Vec<crate::PhantomReach> =
serde_json::from_str(&phantom_reaches_json).map_err(|e| {
anyhow::anyhow!(
"conversation `{id}`: turn phantom_reaches column is not valid \
phantom-reach JSON ({e}); refusing to load garbage"
)
})?;
record.turns.push(ConversationTurn {
user,
assistant,
events,
phantom_reaches,
tokens_in: tokens_from_sql(tokens_in)?,
tokens_out: tokens_from_sql(tokens_out)?,
});
}
Ok(record)
}
pub fn load_turn(&self, id: &str, seq: i64) -> anyhow::Result<Option<ConversationTurn>> {
let id = match self.resolve_id(id) {
Ok(id) => id,
Err(_) => return Ok(None),
};
let conn = self.lock_conn();
let row = conn
.query_row(
"SELECT t.user, t.assistant, t.events, t.tokens_in, t.tokens_out, t.phantom_reaches
FROM turns t
JOIN conversations c
ON c.id = t.conversation_id AND c.workspace_key = ?3
WHERE t.conversation_id = ?1 AND t.seq = ?2",
rusqlite::params![id, seq, self.workspace_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, String>(5)?,
))
},
)
.optional()?;
let Some((user, assistant, events_json, tokens_in, tokens_out, phantom_reaches_json)) = row
else {
return Ok(None);
};
let events: Vec<crate::ToolEvent> = serde_json::from_str(&events_json).map_err(|e| {
anyhow::anyhow!(
"conversation `{id}`: turn events column is not valid tool-event \
JSON ({e}); refusing to load garbage"
)
})?;
let phantom_reaches: Vec<crate::PhantomReach> = serde_json::from_str(&phantom_reaches_json)
.map_err(|e| {
anyhow::anyhow!(
"conversation `{id}`: turn phantom_reaches column is not valid \
phantom-reach JSON ({e}); refusing to load garbage"
)
})?;
Ok(Some(ConversationTurn {
user,
assistant,
events,
phantom_reaches,
tokens_in: tokens_from_sql(tokens_in)?,
tokens_out: tokens_from_sql(tokens_out)?,
}))
}
pub fn list(&self) -> anyhow::Result<Vec<ConversationSummary>> {
let conn = self.lock_conn();
let mut stmt = conn.prepare(
"SELECT c.id, c.title, c.persona, c.updated_at_claim,
(SELECT COUNT(*) FROM turns t WHERE t.conversation_id = c.id)
FROM conversations c
WHERE c.workspace_key = ?1
ORDER BY c.activity_tick ASC, c.id ASC",
)?;
let rows = stmt.query_map([&self.workspace_id], |row| {
Ok(ConversationSummary {
id: row.get(0)?,
title: row.get(1)?,
persona: row.get(2)?,
updated_at_unix_nanos: claim_to_u128(row.get(3)?),
turn_count: row.get::<_, i64>(4)?.max(0) as usize,
})
})?;
let mut summaries = Vec::new();
for row in rows {
summaries.push(row?);
}
Ok(summaries)
}
pub fn list_all(&self) -> anyhow::Result<Vec<(ConversationSummary, String)>> {
let conn = self.lock_conn();
let mut stmt = conn.prepare(
"SELECT c.id, c.title, c.persona, c.updated_at_claim, c.workspace_path,
(SELECT COUNT(*) FROM turns t WHERE t.conversation_id = c.id)
FROM conversations c
ORDER BY c.activity_tick DESC, c.id ASC",
)?;
let rows = stmt.query_map([], |row| {
Ok((
ConversationSummary {
id: row.get(0)?,
title: row.get(1)?,
persona: row.get(2)?,
updated_at_unix_nanos: claim_to_u128(row.get(3)?),
turn_count: row.get::<_, i64>(5)?.max(0) as usize,
},
row.get::<_, String>(4)?,
))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn inject_prompt(
&self,
conversation_id: &str,
body: &str,
idem_key: Option<&str>,
) -> anyhow::Result<InjectOutcome> {
validate_record_id(conversation_id)?;
let now = (self.claim_clock)();
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let owner: Option<String> = tx
.query_row(
"SELECT workspace_key FROM conversations WHERE id = ?1",
[conversation_id],
|row| row.get(0),
)
.optional()?;
match owner.as_deref() {
None => anyhow::bail!("cannot inject into unknown conversation `{conversation_id}`"),
Some(key) if key != self.workspace_id => {
anyhow::bail!("conversation `{conversation_id}` belongs to another workspace")
}
_ => {}
}
if let Some(key) = idem_key {
let seen = tx
.query_row(
"SELECT 1 FROM conversation_inbox
WHERE conversation_id = ?1 AND idem_key = ?2",
rusqlite::params![conversation_id, key],
|_| Ok(()),
)
.optional()?
.is_some();
if seen {
tx.commit()?;
return Ok(InjectOutcome::Duplicate);
}
}
let seq: i64 = tx.query_row(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM conversation_inbox WHERE conversation_id = ?1",
[conversation_id],
|row| row.get(0),
)?;
tx.execute(
"INSERT INTO conversation_inbox
(id, conversation_id, workspace_key, seq, body, idem_key, delivered, injected_at_claim)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7)",
rusqlite::params![
new_conversation_id(),
conversation_id,
self.workspace_id,
seq,
body,
idem_key,
now,
],
)?;
tx.commit()?;
Ok(InjectOutcome::Enqueued)
}
pub fn take_injected_prompt(
&self,
conversation_id: &str,
) -> anyhow::Result<Option<InjectedPrompt>> {
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let row: Option<(String, String, i64)> = tx
.query_row(
"SELECT id, body, seq FROM conversation_inbox
WHERE conversation_id = ?1 AND workspace_key = ?2 AND delivered = 0
ORDER BY seq ASC LIMIT 1",
rusqlite::params![conversation_id, self.workspace_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()?;
let Some((id, body, seq)) = row else {
tx.commit()?;
return Ok(None);
};
tx.execute(
"UPDATE conversation_inbox SET delivered = 1 WHERE id = ?1",
[&id],
)?;
tx.commit()?;
Ok(Some(InjectedPrompt { id, body, seq }))
}
pub fn link_inbox_delivery(&self, inbox_id: &str, receipt_id: &str) -> anyhow::Result<()> {
let conn = self.lock_conn();
conn.execute(
"UPDATE conversation_inbox SET delivered_receipt_id = ?2 WHERE id = ?1",
[inbox_id, receipt_id],
)?;
Ok(())
}
pub(crate) const PERMISSION_REQUEST_TTL_NANOS: i64 = 5 * 60 * 1_000_000_000;
pub fn publish_permission_request(
&self,
conversation_id: &str,
requests_json: &str,
danger_json: &str,
) -> anyhow::Result<String> {
validate_record_id(conversation_id)?;
let now = (self.claim_clock)();
let request_id = new_conversation_id();
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let owner: Option<String> = tx
.query_row(
"SELECT workspace_key FROM conversations WHERE id = ?1",
[conversation_id],
|row| row.get(0),
)
.optional()?;
match owner.as_deref() {
None => anyhow::bail!(
"cannot publish a permission request for unknown conversation `{conversation_id}`"
),
Some(key) if key != self.workspace_id => {
anyhow::bail!("conversation `{conversation_id}` belongs to another workspace")
}
_ => {}
}
tx.execute(
"INSERT INTO permission_requests
(request_id, conversation_id, workspace_key, requests_json, danger_json, resolved, created_tick)
VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)",
rusqlite::params![
request_id,
conversation_id,
self.workspace_id,
requests_json,
danger_json,
now,
],
)?;
tx.commit()?;
Ok(request_id)
}
pub fn pending_permission_request(
&self,
conversation_id: &str,
) -> anyhow::Result<Option<PendingPermission>> {
let conn = self.lock_conn();
let cutoff = (self.claim_clock)().saturating_sub(Self::PERMISSION_REQUEST_TTL_NANOS);
conn.query_row(
"SELECT request_id, requests_json, danger_json FROM permission_requests
WHERE conversation_id = ?1 AND workspace_key = ?2 AND resolved = 0 AND verdict IS NULL
AND created_tick > ?3
ORDER BY created_tick ASC LIMIT 1",
rusqlite::params![conversation_id, self.workspace_id, cutoff],
|row| {
Ok(PendingPermission {
request_id: row.get(0)?,
requests_json: row.get(1)?,
danger_json: row.get(2)?,
})
},
)
.optional()
.map_err(Into::into)
}
pub fn answer_permission_request(
&self,
conversation_id: &str,
request_id: &str,
verdict: Verdict,
) -> anyhow::Result<AnswerOutcome> {
let cutoff = (self.claim_clock)().saturating_sub(Self::PERMISSION_REQUEST_TTL_NANOS);
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let state: Option<(i64, Option<String>, i64)> = tx
.query_row(
"SELECT resolved, verdict, created_tick FROM permission_requests
WHERE request_id = ?1 AND conversation_id = ?2 AND workspace_key = ?3",
rusqlite::params![request_id, conversation_id, self.workspace_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()?;
let outcome = match state {
None => AnswerOutcome::Unknown,
Some((_, _, created_tick)) if created_tick <= cutoff => AnswerOutcome::Unknown,
Some((1, _, _)) | Some((_, Some(_), _)) => AnswerOutcome::AlreadyResolved,
Some((_, None, _)) => {
tx.execute(
"UPDATE permission_requests SET verdict = ?2, answered_by = 'web'
WHERE request_id = ?1 AND resolved = 0 AND verdict IS NULL",
rusqlite::params![request_id, verdict.as_db_str()],
)?;
AnswerOutcome::Answered
}
};
tx.commit()?;
Ok(outcome)
}
pub fn take_permission_decision(
&self,
conversation_id: &str,
request_id: &str,
) -> anyhow::Result<Option<Verdict>> {
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let verdict: Option<String> = tx
.query_row(
"SELECT verdict FROM permission_requests
WHERE request_id = ?1 AND conversation_id = ?2 AND workspace_key = ?3
AND resolved = 0 AND verdict IS NOT NULL",
rusqlite::params![request_id, conversation_id, self.workspace_id],
|row| row.get::<_, String>(0),
)
.optional()?;
let Some(v) = verdict else {
tx.commit()?;
return Ok(None);
};
tx.execute(
"UPDATE permission_requests SET resolved = 1 WHERE request_id = ?1",
[request_id],
)?;
tx.commit()?;
Ok(Verdict::from_db_str(&v))
}
pub fn resolve_permission_request(
&self,
conversation_id: &str,
request_id: &str,
by: &str,
) -> anyhow::Result<bool> {
let conn = self.lock_conn();
let changed = conn.execute(
"UPDATE permission_requests SET resolved = 1, answered_by = ?4
WHERE request_id = ?1 AND conversation_id = ?2 AND workspace_key = ?3
AND resolved = 0 AND verdict IS NULL",
rusqlite::params![request_id, conversation_id, self.workspace_id, by],
)?;
Ok(changed == 1)
}
pub(crate) const ENROLLMENT_REQUEST_TTL_NANOS: i64 = 5 * 60 * 1_000_000_000;
pub fn publish_enrollment_candidate(
&self,
conversation_id: &str,
candidate_json: &str,
) -> anyhow::Result<String> {
validate_record_id(conversation_id)?;
let now = (self.claim_clock)();
let request_id = new_conversation_id();
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let owner: Option<String> = tx
.query_row(
"SELECT workspace_key FROM conversations WHERE id = ?1",
[conversation_id],
|row| row.get(0),
)
.optional()?;
match owner.as_deref() {
None => anyhow::bail!(
"cannot stage an enrollment for unknown conversation `{conversation_id}`"
),
Some(key) if key != self.workspace_id => {
anyhow::bail!("conversation `{conversation_id}` belongs to another workspace")
}
_ => {}
}
tx.execute(
"INSERT INTO enrollment_requests
(request_id, conversation_id, workspace_key, candidate_json, resolved, created_tick)
VALUES (?1, ?2, ?3, ?4, 0, ?5)",
rusqlite::params![
request_id,
conversation_id,
self.workspace_id,
candidate_json,
now,
],
)?;
tx.commit()?;
Ok(request_id)
}
pub fn pending_enrollment_candidate(
&self,
conversation_id: &str,
) -> anyhow::Result<Option<PendingEnrollment>> {
let conn = self.lock_conn();
let cutoff = (self.claim_clock)().saturating_sub(Self::ENROLLMENT_REQUEST_TTL_NANOS);
conn.query_row(
"SELECT request_id, candidate_json FROM enrollment_requests
WHERE conversation_id = ?1 AND workspace_key = ?2 AND resolved = 0
AND created_tick > ?3
ORDER BY created_tick ASC LIMIT 1",
rusqlite::params![conversation_id, self.workspace_id, cutoff],
|row| {
Ok(PendingEnrollment {
request_id: row.get(0)?,
candidate_json: row.get(1)?,
})
},
)
.optional()
.map_err(Into::into)
}
pub fn take_enrollment_candidate(
&self,
conversation_id: &str,
request_id: &str,
) -> anyhow::Result<Option<String>> {
let cutoff = (self.claim_clock)().saturating_sub(Self::ENROLLMENT_REQUEST_TTL_NANOS);
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let candidate: Option<String> = tx
.query_row(
"SELECT candidate_json FROM enrollment_requests
WHERE request_id = ?1 AND conversation_id = ?2 AND workspace_key = ?3
AND resolved = 0 AND created_tick > ?4",
rusqlite::params![request_id, conversation_id, self.workspace_id, cutoff],
|row| row.get::<_, String>(0),
)
.optional()?;
if candidate.is_some() {
tx.execute(
"UPDATE enrollment_requests SET resolved = 1, resolved_by = 'terminal'
WHERE request_id = ?1",
[request_id],
)?;
}
tx.commit()?;
Ok(candidate)
}
pub fn decline_enrollment_candidate(
&self,
conversation_id: &str,
request_id: &str,
) -> anyhow::Result<bool> {
let conn = self.lock_conn();
let changed = conn.execute(
"UPDATE enrollment_requests SET resolved = 1, resolved_by = 'declined'
WHERE request_id = ?1 AND conversation_id = ?2 AND workspace_key = ?3
AND resolved = 0",
rusqlite::params![request_id, conversation_id, self.workspace_id],
)?;
Ok(changed == 1)
}
pub fn latest_open(&self) -> anyhow::Result<Option<ConversationSummary>> {
let conn = self.lock_conn();
conn.query_row(
"SELECT c.id, c.title, c.persona, c.updated_at_claim,
(SELECT COUNT(*) FROM turns t WHERE t.conversation_id = c.id)
FROM conversations c
WHERE c.workspace_key = ?1 AND c.end_reason IS NULL
ORDER BY c.activity_tick DESC, c.id DESC
LIMIT 1",
[&self.workspace_id],
|row| {
Ok(ConversationSummary {
id: row.get(0)?,
title: row.get(1)?,
persona: row.get(2)?,
updated_at_unix_nanos: claim_to_u128(row.get(3)?),
turn_count: row.get::<_, i64>(4)?.max(0) as usize,
})
},
)
.optional()
.map_err(Into::into)
}
pub fn end_conversation(&self, id: &str, reason: &str) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
let now = (self.claim_clock)();
let conn = self.lock_conn();
conn.execute(
"UPDATE conversations SET end_reason = ?2, updated_at_claim = ?3
WHERE id = ?1 AND workspace_key = ?4",
rusqlite::params![id, reason.trim(), now, self.workspace_id],
)?;
Ok(())
}
pub fn link_conversation_to_node(
&self,
id: &str,
roadmap_id: Option<&str>,
node_id: Option<&str>,
) -> anyhow::Result<()> {
let conn = self.lock_conn();
conn.execute(
"UPDATE conversations SET roadmap_id = ?2, node_id = ?3
WHERE id = ?1 AND workspace_key = ?4",
rusqlite::params![id, roadmap_id, node_id, self.workspace_id],
)?;
Ok(())
}
pub fn claim(&self, id: &str) -> anyhow::Result<ClaimOutcome> {
validate_record_id(id)?;
let now = (self.claim_clock)();
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let existing = live_owner_row(&tx, id)?;
if let Some(owner) = &existing {
let is_ours =
owner.host == self.host && owner.boot_id == self.boot_id && owner.pid == self.pid;
if !is_ours && (self.liveness)(owner, now) {
return Ok(ClaimOutcome::HeldBy {
host: owner.host.clone(),
pid: owner.pid,
});
}
}
tx.execute(
"INSERT OR REPLACE INTO live_owners
(conversation_id, host, boot_id, pid, writer_fingerprint, heartbeat_tick)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
id,
self.host,
self.boot_id,
self.pid,
self.writer_fingerprint,
now
],
)?;
tx.commit()?;
Ok(ClaimOutcome::Claimed)
}
pub fn release(&self, id: &str) -> anyhow::Result<()> {
let conn = self.lock_conn();
conn.execute(
"DELETE FROM live_owners
WHERE conversation_id = ?1 AND host = ?2 AND boot_id = ?3 AND pid = ?4",
rusqlite::params![id, self.host, self.boot_id, self.pid],
)?;
Ok(())
}
pub fn heartbeat(&self, id: &str) -> anyhow::Result<()> {
let now = (self.claim_clock)();
let conn = self.lock_conn();
conn.execute(
"UPDATE live_owners SET heartbeat_tick = ?2
WHERE conversation_id = ?1 AND host = ?3 AND boot_id = ?4 AND pid = ?5",
rusqlite::params![id, now, self.host, self.boot_id, self.pid],
)?;
Ok(())
}
pub fn live_owner(&self, id: &str) -> anyhow::Result<Option<StoredOwner>> {
let conn = self.lock_conn();
live_owner_row(&conn, id)
}
#[must_use]
pub fn is_owner_live(&self, owner: &StoredOwner) -> bool {
(self.liveness)(owner, (self.claim_clock)())
}
pub fn create_roadmap(
&self,
id: &str,
title: &str,
tree: &crate::plan::Plan,
) -> anyhow::Result<()> {
validate_record_id(id)?;
let now = (self.claim_clock)();
let toml = tree.to_toml_string()?;
let conn = self.lock_conn();
conn.execute(
"INSERT OR REPLACE INTO roadmaps
(id, workspace_key, title, tree, schema_version, created_at_claim, updated_at_claim)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
rusqlite::params![
id,
self.workspace_id,
title.trim(),
toml,
ROADMAP_SCHEMA_VERSION,
now
],
)?;
Ok(())
}
pub fn load_roadmap(&self, id: &str) -> anyhow::Result<Option<Roadmap>> {
let conn = self.lock_conn();
let row = conn
.query_row(
"SELECT title, tree FROM roadmaps WHERE id = ?1 AND workspace_key = ?2",
rusqlite::params![id, self.workspace_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional()?;
let Some((title, toml)) = row else {
return Ok(None);
};
let tree = crate::plan::Plan::from_toml_str(&toml).map_err(|e| {
anyhow::anyhow!("roadmap `{id}`: tree column is not valid plan TOML ({e})")
})?;
Ok(Some(Roadmap {
id: id.to_string(),
title,
tree,
}))
}
pub fn update_roadmap(&self, id: &str, tree: &crate::plan::Plan) -> anyhow::Result<()> {
let now = (self.claim_clock)();
let toml = tree.to_toml_string()?;
let conn = self.lock_conn();
conn.execute(
"UPDATE roadmaps SET tree = ?2, updated_at_claim = ?3
WHERE id = ?1 AND workspace_key = ?4",
rusqlite::params![id, toml, now, self.workspace_id],
)?;
Ok(())
}
pub fn list_roadmaps(&self) -> anyhow::Result<Vec<RoadmapSummary>> {
let conn = self.lock_conn();
let mut stmt = conn.prepare(
"SELECT id, title, tree FROM roadmaps
WHERE workspace_key = ?1
ORDER BY updated_at_claim DESC, id DESC",
)?;
let rows = stmt.query_map([&self.workspace_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (id, title, toml) = row?;
let node_count = crate::plan::Plan::from_toml_str(&toml)
.map(|p| p.subtasks.len())
.unwrap_or(0);
out.push(RoadmapSummary {
id,
title,
node_count,
});
}
Ok(out)
}
pub fn rename(&self, id: &str, title: &str) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
let now = (self.claim_clock)();
let conn = self.lock_conn();
conn.execute(
"UPDATE conversations SET title = ?2, updated_at_claim = ?3 WHERE id = ?1",
rusqlite::params![id, title.trim(), now],
)?;
Ok(())
}
pub fn update_scratchpad(
&self,
id: &str,
scratchpad: &std::collections::BTreeMap<String, String>,
) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
let json = serde_json::to_string(scratchpad)?;
let conn = self.lock_conn();
conn.execute(
"UPDATE conversations SET scratchpad = ?2 WHERE id = ?1 AND workspace_key = ?3",
rusqlite::params![id, json, self.workspace_id],
)?;
Ok(())
}
pub fn update_plan_snapshot(&self, id: &str, plan: &crate::PlanSnapshot) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
let json = serde_json::to_string(plan)?;
let conn = self.lock_conn();
conn.execute(
"UPDATE conversations SET plan = ?2 WHERE id = ?1 AND workspace_key = ?3",
rusqlite::params![id, json, self.workspace_id],
)?;
Ok(())
}
pub fn delete(&self, id: &str) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
{
let conn = self.lock_conn();
conn.execute(
"DELETE FROM conversations WHERE id = ?1 AND workspace_key = ?2",
rusqlite::params![id, self.workspace_id],
)?;
}
let plan_dir = self.workspace.join(session_plan_dir(&id));
let _ = std::fs::remove_dir_all(plan_dir);
Ok(())
}
pub fn resolve_id(&self, id_or_prefix: &str) -> anyhow::Result<String> {
validate_record_id(id_or_prefix)?;
let conn = self.lock_conn();
let exact = conn
.query_row(
"SELECT id FROM conversations WHERE id = ?1 AND workspace_key = ?2",
rusqlite::params![id_or_prefix, self.workspace_id],
|row| row.get::<_, String>(0),
)
.optional()?;
if let Some(id) = exact {
return Ok(id);
}
let mut stmt = conn.prepare(
"SELECT id FROM conversations
WHERE workspace_key = ?1 AND substr(id, 1, length(?2)) = ?2
ORDER BY id ASC",
)?;
let matches = stmt
.query_map(rusqlite::params![self.workspace_id, id_or_prefix], |row| {
row.get::<_, String>(0)
})?
.collect::<Result<Vec<_>, _>>()?;
match matches.as_slice() {
[id] => Ok(id.clone()),
[] => anyhow::bail!("conversation `{id_or_prefix}` not found"),
many => anyhow::bail!(
"ambiguous conversation id prefix `{}`; matches: {}",
id_or_prefix,
many.join(", ")
),
}
}
pub fn verify_chain(&self, id: &str) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
let conn = self.lock_conn();
let (tip, tip_writer): (String, String) = conn.query_row(
"SELECT tip_hash, writer_fingerprint FROM conversations WHERE id = ?1",
[&id],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let mut stmt = conn.prepare(
"SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
events, tokens_in, tokens_out, ts_claim, encoding_version
FROM turns
WHERE conversation_id = ?1
ORDER BY writer_fingerprint ASC, seq ASC",
)?;
let rows = stmt
.query_map([&id], turn_row_from_sql)?
.collect::<Result<Vec<_>, _>>()?;
let mut prev: Option<&TurnRow> = None;
for row in &rows {
let same_writer = prev.is_some_and(|p| p.writer_fingerprint == row.writer_fingerprint);
if same_writer {
let p = prev.expect("same_writer implies prev");
if row.seq <= p.seq {
anyhow::bail!(
"chain violation in `{id}`: seq {} not strictly after {}",
row.seq,
p.seq
);
}
if row.prev_hash != p.content_hash()? {
anyhow::bail!(
"chain violation in `{id}`: turn seq {} does not link to seq {} \
(row tampered or out of order)",
row.seq,
p.seq
);
}
} else {
let genesis = genesis_hash(&id, &row.writer_fingerprint);
if row.prev_hash != genesis {
anyhow::bail!(
"chain violation in `{id}`: first turn of writer {} (seq {}) does \
not link to the genesis hash",
row.writer_fingerprint,
row.seq
);
}
}
prev = Some(row);
}
let expected_tip = match rows.iter().rfind(|r| r.writer_fingerprint == tip_writer) {
Some(row) => row.content_hash()?,
None => genesis_hash(&id, &tip_writer),
};
if tip != expected_tip {
anyhow::bail!("chain violation in `{id}`: stored tip_hash does not match the chain");
}
Ok(())
}
pub fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
let fts_query = sanitize_fts5_query(query)?;
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
let conn = self.lock_conn();
let mut stmt = conn.prepare(
"SELECT t.conversation_id, c.title, t.seq,
snippet(turns_fts, -1, '>>>', '<<<', '…', 21),
bm25(turns_fts)
FROM turns_fts
JOIN turns t ON t.rowid = turns_fts.rowid
JOIN conversations c
ON c.id = t.conversation_id AND c.workspace_key = ?2
WHERE turns_fts MATCH ?1
ORDER BY bm25(turns_fts) ASC, t.conversation_id ASC, t.seq ASC
LIMIT ?3",
)?;
let rows = stmt.query_map(
rusqlite::params![fts_query, self.workspace_id, limit],
|row| {
Ok(SearchHit {
conversation_id: row.get(0)?,
title: row.get(1)?,
seq: row.get(2)?,
snippet: row.get(3)?,
rank: row.get(4)?,
})
},
)?;
let mut hits = Vec::new();
for row in rows {
hits.push(row?);
}
Ok(hits)
}
#[doc(hidden)]
pub fn set_claim_clock_for_test(&mut self, clock: fn() -> i64) {
self.claim_clock = clock;
}
#[doc(hidden)]
pub fn set_liveness_for_test(&mut self, liveness: LivenessFn) {
self.liveness = liveness;
}
#[doc(hidden)]
pub fn set_owner_for_test(&mut self, host: &str, boot_id: &str, pid: i64) {
self.host = host.to_string();
self.boot_id = boot_id.to_string();
self.pid = pid;
}
fn prune_to_cap_excluding(&self, protected_id: &str) -> anyhow::Result<()> {
if self.max_per_workspace == 0 {
return Ok(());
}
let now = (self.claim_clock)();
let victims: Vec<String> = {
let conn = self.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
let count: i64 = tx.query_row(
"SELECT COUNT(*) FROM conversations WHERE workspace_key = ?1",
[&self.workspace_id],
|row| row.get(0),
)?;
let excess = count - self.max_per_workspace as i64;
if excess <= 0 {
return Ok(());
}
let candidates = {
let mut stmt = tx.prepare(
"SELECT c.id
FROM conversations c
WHERE c.workspace_key = ?1
AND c.id <> ?2
ORDER BY c.activity_tick ASC, c.id ASC",
)?;
let selected = stmt
.query_map(rusqlite::params![self.workspace_id, protected_id], |row| {
row.get::<_, String>(0)
})?
.collect::<Result<Vec<_>, _>>()?;
selected
};
let mut ids = Vec::with_capacity(excess as usize);
for id in candidates {
if ids.len() >= excess as usize {
break;
}
if let Some(owner) = live_owner_row(&tx, &id)? {
if (self.liveness)(&owner, now) {
continue;
}
tx.execute("DELETE FROM live_owners WHERE conversation_id = ?1", [&id])?;
}
ids.push(id);
}
for id in &ids {
tx.execute(
"DELETE FROM conversations WHERE id = ?1 AND workspace_key = ?2",
rusqlite::params![id, self.workspace_id],
)?;
}
tx.commit()?;
ids
};
for id in victims {
let plan_dir = self.workspace.join(session_plan_dir(&id));
let _ = std::fs::remove_dir_all(plan_dir);
}
Ok(())
}
fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SearchHit {
pub conversation_id: String,
pub title: String,
pub seq: i64,
pub snippet: String,
pub rank: f64,
}
#[derive(Debug)]
struct PromptRow {
id: String,
conversation_id: String,
writer_fingerprint: String,
seq: i64,
previous_prompt_id: Option<String>,
parent_prompt_id: Option<String>,
root_prompt_id: String,
active_operator_id: Option<String>,
origin: String,
raw_text: Vec<u8>,
model_text: Vec<u8>,
raw_digest: String,
model_digest: String,
receipt_hash: String,
ts_claim: i64,
encoding_version: i64,
}
impl PromptRow {
fn into_receipt(self) -> anyhow::Result<PromptReceipt> {
let receipt = PromptReceipt::from_stored_parts(
self.id.parse()?,
self.conversation_id,
self.writer_fingerprint,
self.seq,
self.previous_prompt_id.map(|id| id.parse()).transpose()?,
self.parent_prompt_id.map(|id| id.parse()).transpose()?,
self.root_prompt_id.parse()?,
self.active_operator_id.map(|id| id.parse()).transpose()?,
PromptOrigin::from_db_str(&self.origin)?,
self.raw_text,
self.model_text,
self.raw_digest,
self.model_digest,
self.receipt_hash,
self.ts_claim,
self.encoding_version,
);
receipt.verify_integrity()?;
Ok(receipt)
}
}
fn prompt_row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result<PromptRow> {
Ok(PromptRow {
id: row.get(0)?,
conversation_id: row.get(1)?,
writer_fingerprint: row.get(2)?,
seq: row.get(3)?,
previous_prompt_id: row.get(4)?,
parent_prompt_id: row.get(5)?,
root_prompt_id: row.get(6)?,
active_operator_id: row.get(7)?,
origin: row.get(8)?,
raw_text: row.get(9)?,
model_text: row.get(10)?,
raw_digest: row.get(11)?,
model_digest: row.get(12)?,
receipt_hash: row.get(13)?,
ts_claim: row.get(14)?,
encoding_version: row.get(15)?,
})
}
fn insert_prompt_receipt(conn: &Connection, receipt: &PromptReceipt) -> anyhow::Result<()> {
conn.execute(
"INSERT INTO prompt_receipts
(id, conversation_id, writer_fingerprint, seq, previous_prompt_id,
parent_prompt_id, root_prompt_id, active_operator_id, origin,
raw_text, model_text, raw_digest, model_digest, receipt_hash,
ts_claim, encoding_version)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
rusqlite::params![
receipt.id().to_string(),
receipt.conversation_id(),
receipt.writer_fingerprint(),
receipt.seq(),
receipt.previous_prompt_id().map(|id| id.to_string()),
receipt.parent_prompt_id().map(|id| id.to_string()),
receipt.root_prompt_id().to_string(),
receipt.active_operator_id().map(|id| id.to_string()),
receipt.origin().as_db_str(),
receipt.raw_text(),
receipt.model_text(),
receipt.raw_digest(),
receipt.model_digest(),
receipt.receipt_hash(),
receipt.ts_claim(),
receipt.encoding_version(),
],
)?;
Ok(())
}
fn load_prompt_on_conn(
conn: &Connection,
prompt_id: PromptId,
workspace_id: &str,
) -> anyhow::Result<Option<PromptReceipt>> {
let row = conn
.query_row(
"SELECT p.id, p.conversation_id, p.writer_fingerprint, p.seq,
p.previous_prompt_id, p.parent_prompt_id, p.root_prompt_id,
p.active_operator_id, p.origin, p.raw_text, p.model_text,
p.raw_digest, p.model_digest, p.receipt_hash, p.ts_claim,
p.encoding_version
FROM prompt_receipts p
JOIN conversations c ON c.id = p.conversation_id
WHERE p.id = ?1 AND c.workspace_key = ?2",
rusqlite::params![prompt_id.to_string(), workspace_id],
prompt_row_from_sql,
)
.optional()?;
row.map(PromptRow::into_receipt).transpose()
}
fn load_prompt_in_conversation_on_conn(
conn: &Connection,
conversation_id: &str,
prompt_id: PromptId,
workspace_id: &str,
) -> anyhow::Result<Option<PromptReceipt>> {
let row = conn
.query_row(
"SELECT p.id, p.conversation_id, p.writer_fingerprint, p.seq,
p.previous_prompt_id, p.parent_prompt_id, p.root_prompt_id,
p.active_operator_id, p.origin, p.raw_text, p.model_text,
p.raw_digest, p.model_digest, p.receipt_hash, p.ts_claim,
p.encoding_version
FROM prompt_receipts p
JOIN conversations c ON c.id = p.conversation_id
WHERE p.id = ?1 AND p.conversation_id = ?2
AND c.workspace_key = ?3",
rusqlite::params![prompt_id.to_string(), conversation_id, workspace_id],
prompt_row_from_sql,
)
.optional()?;
row.map(PromptRow::into_receipt).transpose()
}
fn validate_objective_root_on_conn(
conn: &Connection,
conversation_id: &str,
root_prompt_id: PromptId,
workspace_id: &str,
) -> anyhow::Result<PromptReceipt> {
let root =
load_prompt_in_conversation_on_conn(conn, conversation_id, root_prompt_id, workspace_id)?
.ok_or_else(|| {
anyhow::anyhow!(
"prompt root {root_prompt_id} is missing from conversation `{conversation_id}`"
)
})?;
if root.origin() != PromptOrigin::Operator
|| root.root_prompt_id() != root.id()
|| root
.active_operator_id()
.is_some_and(|active| active != root.id())
{
anyhow::bail!("prompt root {root_prompt_id} is not a self-rooted operator prompt");
}
Ok(root)
}
fn resolve_active_operator_on_conn(
conn: &Connection,
conversation_id: &str,
submitted: &PromptReceipt,
workspace_id: &str,
) -> anyhow::Result<(PromptReceipt, usize)> {
validate_objective_root_on_conn(
conn,
conversation_id,
submitted.root_prompt_id(),
workspace_id,
)?;
let objective_root = submitted.root_prompt_id();
let mut visited = std::collections::HashSet::new();
let mut retry_authorities: Vec<(PromptId, Option<PromptId>)> = Vec::new();
let mut current = submitted.clone();
for depth in 1..=MAX_PROMPT_LINEAGE_DEPTH {
if !visited.insert(current.id()) {
anyhow::bail!("prompt parent cycle detected at {}", current.id());
}
if current.conversation_id() != conversation_id
|| current.root_prompt_id() != objective_root
{
anyhow::bail!(
"prompt {} crosses its conversation or objective-root boundary",
current.id()
);
}
let is_operator_continuation = current.origin() == PromptOrigin::Operator
&& current.parent_prompt_id().is_some()
&& current.active_operator_id() != Some(current.id());
if current.origin() == PromptOrigin::HarnessRetry || is_operator_continuation {
let parent_id = current.parent_prompt_id().ok_or_else(|| {
anyhow::anyhow!(
"prompt {} continues a lineage but has no parent",
current.id()
)
})?;
retry_authorities.push((current.id(), current.active_operator_id()));
current = load_prompt_in_conversation_on_conn(
conn,
conversation_id,
parent_id,
workspace_id,
)?
.ok_or_else(|| {
anyhow::anyhow!(
"prompt {} references missing parent {parent_id}",
current.id()
)
})?;
} else {
for (retry_id, stored_authority) in retry_authorities {
if stored_authority.is_some_and(|stored| stored != current.id()) {
anyhow::bail!(
"prompt {retry_id} active operator disagrees with parent \
authority {}",
current.id()
);
}
}
return Ok((current, depth));
}
}
anyhow::bail!(
"prompt lineage from {} exceeds the maximum depth of \
{MAX_PROMPT_LINEAGE_DEPTH} receipts",
submitted.id()
)
}
fn latest_prompt_on_conn(
conn: &Connection,
conversation_id: &str,
workspace_id: &str,
) -> anyhow::Result<Option<PromptReceipt>> {
let mut chain = prompt_chain_on_conn(conn, conversation_id, workspace_id)?;
Ok(chain.pop())
}
fn prompt_chain_on_conn(
conn: &Connection,
conversation_id: &str,
workspace_id: &str,
) -> anyhow::Result<Vec<PromptReceipt>> {
let mut stmt = conn.prepare(
"SELECT p.id, p.conversation_id, p.writer_fingerprint, p.seq,
p.previous_prompt_id, p.parent_prompt_id, p.root_prompt_id,
p.active_operator_id, p.origin, p.raw_text, p.model_text,
p.raw_digest, p.model_digest, p.receipt_hash, p.ts_claim,
p.encoding_version
FROM prompt_receipts p
JOIN conversations c ON c.id = p.conversation_id
WHERE p.conversation_id = ?1 AND c.workspace_key = ?2
ORDER BY p.receipt_order ASC",
)?;
let rows = stmt.query_map(
rusqlite::params![conversation_id, workspace_id],
prompt_row_from_sql,
)?;
let receipts: Vec<PromptReceipt> = rows
.map(|row| {
row.map_err(anyhow::Error::from)
.and_then(PromptRow::into_receipt)
})
.collect::<anyhow::Result<_>>()?;
let mut expected_previous = None;
for receipt in &receipts {
if receipt.previous_prompt_id() != expected_previous {
anyhow::bail!(
"prompt chronology mismatch in conversation `{conversation_id}` at {}: \
expected previous {:?}, stored {:?}",
receipt.id(),
expected_previous,
receipt.previous_prompt_id()
);
}
expected_previous = Some(receipt.id());
}
Ok(receipts)
}
#[derive(Debug)]
struct ArtifactRow {
id: String,
conversation_id: String,
writer_fingerprint: String,
seq: i64,
prev_hash: String,
prompt_id: String,
root_prompt_id: String,
kind: String,
relation: String,
locator: Option<String>,
body: Option<String>,
metadata_json: String,
ts_claim: i64,
encoding_version: i64,
artifact_hash: String,
}
impl ArtifactRow {
fn into_artifact(self) -> anyhow::Result<PromptArtifact> {
let artifact = PromptArtifact::from_stored_parts(
self.id.parse()?,
self.conversation_id,
self.writer_fingerprint,
self.seq,
self.prev_hash,
self.prompt_id.parse()?,
self.root_prompt_id.parse()?,
ArtifactKind::from_db_str(&self.kind)?,
ArtifactRelation::from_db_str(&self.relation)?,
self.locator,
self.body,
self.metadata_json,
self.ts_claim,
self.encoding_version,
self.artifact_hash,
)?;
artifact.verify_integrity()?;
Ok(artifact)
}
}
fn artifact_row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result<ArtifactRow> {
Ok(ArtifactRow {
id: row.get(0)?,
conversation_id: row.get(1)?,
writer_fingerprint: row.get(2)?,
seq: row.get(3)?,
prev_hash: row.get(4)?,
prompt_id: row.get(5)?,
root_prompt_id: row.get(6)?,
kind: row.get(7)?,
relation: row.get(8)?,
locator: row.get(9)?,
body: row.get(10)?,
metadata_json: row.get(11)?,
ts_claim: row.get(12)?,
encoding_version: row.get(13)?,
artifact_hash: row.get(14)?,
})
}
fn insert_prompt_artifact(conn: &Connection, artifact: &PromptArtifact) -> anyhow::Result<()> {
conn.execute(
"INSERT INTO prompt_artifacts
(id, conversation_id, writer_fingerprint, seq, prev_hash,
prompt_id, root_prompt_id, kind, relation, locator, body, metadata,
ts_claim, encoding_version, artifact_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
rusqlite::params![
artifact.id().to_string(),
artifact.conversation_id(),
artifact.writer_fingerprint(),
artifact.seq(),
artifact.prev_hash(),
artifact.prompt_id().to_string(),
artifact.root_prompt_id().to_string(),
artifact.kind().as_db_str(),
artifact.relation().as_db_str(),
artifact.locator(),
artifact.body(),
serde_json::to_string(artifact.metadata())?,
artifact.ts_claim(),
artifact.encoding_version(),
artifact.artifact_hash(),
],
)?;
conn.execute(
"INSERT INTO prompt_artifact_tips (conversation_id, last_seq, tip_hash)
VALUES (?1, ?2, ?3)
ON CONFLICT(conversation_id) DO UPDATE SET
last_seq = excluded.last_seq,
tip_hash = excluded.tip_hash",
rusqlite::params![
artifact.conversation_id(),
artifact.seq(),
artifact.artifact_hash(),
],
)?;
Ok(())
}
fn artifact_chain_on_conn(
conn: &Connection,
conversation_id: &str,
workspace_id: &str,
) -> anyhow::Result<Vec<PromptArtifact>> {
let _ = prompt_chain_on_conn(conn, conversation_id, workspace_id)?;
let mut stmt = conn.prepare(
"SELECT a.id, a.conversation_id, a.writer_fingerprint, a.seq,
a.prev_hash, a.prompt_id, a.root_prompt_id, a.kind, a.relation,
a.locator, a.body, a.metadata, a.ts_claim, a.encoding_version,
a.artifact_hash
FROM prompt_artifacts a
JOIN conversations c ON c.id = a.conversation_id
WHERE a.conversation_id = ?1 AND c.workspace_key = ?2
ORDER BY a.seq ASC",
)?;
let rows = stmt.query_map(
rusqlite::params![conversation_id, workspace_id],
artifact_row_from_sql,
)?;
let artifacts: Vec<PromptArtifact> = rows
.map(|row| {
row.map_err(anyhow::Error::from)
.and_then(ArtifactRow::into_artifact)
})
.collect::<anyhow::Result<_>>()?;
let mut expected_seq = 1_i64;
let mut expected_prev = artifact_genesis_hash(conversation_id);
for artifact in &artifacts {
let prompt = load_prompt_in_conversation_on_conn(
conn,
conversation_id,
artifact.prompt_id(),
workspace_id,
)?
.ok_or_else(|| {
anyhow::anyhow!(
"prompt artifact {} is orphaned from prompt {}",
artifact.id(),
artifact.prompt_id()
)
})?;
if prompt.root_prompt_id() != artifact.root_prompt_id() {
anyhow::bail!(
"prompt artifact {} claims objective root {}, but prompt {} is rooted at {}",
artifact.id(),
artifact.root_prompt_id(),
prompt.id(),
prompt.root_prompt_id()
);
}
validate_objective_root_on_conn(
conn,
conversation_id,
artifact.root_prompt_id(),
workspace_id,
)?;
if artifact.seq() != expected_seq {
anyhow::bail!(
"prompt artifact sequence mismatch in conversation `{conversation_id}` at {}: \
expected {expected_seq}, stored {}",
artifact.id(),
artifact.seq()
);
}
if artifact.prev_hash() != expected_prev {
anyhow::bail!(
"prompt artifact predecessor mismatch in conversation `{conversation_id}` at {}",
artifact.id()
);
}
expected_seq = expected_seq
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("prompt artifact sequence overflow"))?;
expected_prev = artifact.artifact_hash().to_string();
}
let stored_tip: Option<(i64, String)> = conn
.query_row(
"SELECT t.last_seq, t.tip_hash
FROM prompt_artifact_tips t
JOIN conversations c ON c.id = t.conversation_id
WHERE t.conversation_id = ?1 AND c.workspace_key = ?2",
rusqlite::params![conversation_id, workspace_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
match (artifacts.last(), stored_tip) {
(None, None) => {}
(Some(last), Some((last_seq, tip_hash)))
if last.seq() == last_seq && last.artifact_hash() == tip_hash => {}
(None, Some(_)) => anyhow::bail!(
"prompt artifact chain tip exists without rows in conversation `{conversation_id}`"
),
(Some(_), None) => anyhow::bail!(
"prompt artifact rows exist without a chain tip in conversation `{conversation_id}`"
),
(Some(last), Some((last_seq, tip_hash))) => anyhow::bail!(
"prompt artifact chain tip mismatch in conversation `{conversation_id}`: \
row tip is ({}, {}), stored tip is ({last_seq}, {tip_hash})",
last.seq(),
last.artifact_hash()
),
}
Ok(artifacts)
}
fn artifact_genesis_hash(conversation_id: &str) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(ARTIFACT_GENESIS_PREFIX);
hasher.update(&(conversation_id.len() as u64).to_le_bytes());
hasher.update(conversation_id.as_bytes());
hasher.finalize().to_hex().to_string()
}
fn validate_artifact_page_size(limit: usize) -> anyhow::Result<()> {
if limit > MAX_ARTIFACT_PAGE_SIZE {
anyhow::bail!("prompt artifact page limit exceeds {MAX_ARTIFACT_PAGE_SIZE}");
}
Ok(())
}
fn apply_journal_mode(conn: &Connection) -> anyhow::Result<Option<String>> {
let wal: Result<String, rusqlite::Error> =
conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0));
match wal {
Ok(mode) if mode.eq_ignore_ascii_case("wal") => {
conn.pragma_update(None, "synchronous", "NORMAL")?;
Ok(None)
}
Ok(mode) => {
tracing::warn!(%mode, "journal_mode=WAL did not take; keeping synchronous=FULL");
Ok(Some(format!("journal_mode pragma returned `{mode}`")))
}
Err(e) if wal_fallback_eligible(&e.to_string()) => {
let captured = e.to_string();
conn.pragma_update(None, "journal_mode", "DELETE")?;
tracing::warn!(
error = %captured,
"SQLite refused WAL (network filesystem?); conversations.db is running \
on the slower journal_mode=DELETE fallback"
);
Ok(Some(captured))
}
Err(e) => Err(e.into()),
}
}
fn wal_fallback_eligible(error_text: &str) -> bool {
let lower = error_text.to_lowercase();
lower.contains("locking protocol") || lower.contains("disk i/o error")
}
fn create_schema(conn: &Connection) -> anyhow::Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
workspace_path TEXT NOT NULL, -- display only
workspace_key TEXT NOT NULL, -- scoping key: workspace_key_v2 (17.2 — blake3 remote+branch, path fallback)
persona TEXT,
end_reason TEXT, -- set by 17.7
writer_fingerprint TEXT NOT NULL, -- §6 ordering key, half 1
activity_tick INTEGER NOT NULL, -- §6 ordering key, half 2 (per-writer Lamport tick)
tip_hash TEXT NOT NULL, -- §6 chain tip (BLAKE3)
started_at_claim INTEGER NOT NULL, -- DISPLAY ONLY (wall-clock claim, unix nanos)
updated_at_claim INTEGER NOT NULL, -- DISPLAY ONLY
scratchpad TEXT NOT NULL DEFAULT '{}', -- JSON scratchpad <state> snapshot (#713); working memory, NOT hashed (§6 chain unchanged)
plan TEXT NOT NULL DEFAULT '{}', -- JSON plan-ledger snapshot (#715); working memory, NOT hashed (§6 chain unchanged)
roadmap_id TEXT, -- #1030: roadmap this conv's Plan node belongs to (NULL = ad-hoc chat); thin pointer, tree lives in `roadmaps`
node_id TEXT -- #1030: the `roadmaps` tree Subtask id this conversation realizes (NULL = ad-hoc chat)
);
CREATE TABLE IF NOT EXISTS turns (
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
writer_fingerprint TEXT NOT NULL, -- §6: whose clock ticked
seq INTEGER NOT NULL, -- §6: strictly monotonic per writer — THE ordering key
prev_hash TEXT NOT NULL, -- §6: BLAKE3 of prior turn's canonical encoding
user TEXT NOT NULL,
assistant TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '[]', -- JSON tool events; filled by 17.6
tokens_in INTEGER, -- filled by 17.6, consumed by 18.x
tokens_out INTEGER,
ts_claim INTEGER NOT NULL, -- DISPLAY ONLY (wall-clock claim, unix nanos)
encoding_version INTEGER NOT NULL DEFAULT 1, -- canonical-encoding dispatch (N1 on #261)
phantom_reaches TEXT NOT NULL DEFAULT '[]', -- JSON phantom-reach telemetry (#717); NOT hashed (§6 chain unchanged)
PRIMARY KEY (conversation_id, writer_fingerprint, seq)
);
-- Immutable prompt receipts are written before inference/tool work.
-- They are deliberately separate from `turns`: a failed turn still
-- has a prompt, while the existing turn-chain writer/tip pair remains
-- byte-for-byte compatible with all prior databases.
CREATE TABLE IF NOT EXISTS prompt_receipts (
receipt_order INTEGER PRIMARY KEY AUTOINCREMENT, -- serialized receipt chronology
id TEXT NOT NULL UNIQUE, -- prompt:<uuid>
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
writer_fingerprint TEXT NOT NULL,
seq INTEGER NOT NULL, -- writer Lamport tick
previous_prompt_id TEXT REFERENCES prompt_receipts(id), -- automatic chronology
parent_prompt_id TEXT REFERENCES prompt_receipts(id), -- explicit semantic ancestry
root_prompt_id TEXT NOT NULL REFERENCES prompt_receipts(id),
active_operator_id TEXT REFERENCES prompt_receipts(id), -- nearest operator authority; v1 rows are NULL
origin TEXT NOT NULL CHECK (origin IN ('operator', 'harness_retry')),
raw_text BLOB NOT NULL,
model_text BLOB NOT NULL,
raw_digest TEXT NOT NULL,
model_digest TEXT NOT NULL,
receipt_hash TEXT NOT NULL,
ts_claim INTEGER NOT NULL, -- display-only wall clock
encoding_version INTEGER NOT NULL DEFAULT 1,
UNIQUE (conversation_id, writer_fingerprint, seq)
);
-- The per-writer Lamport clock (§6 'each agent is its own clock').
CREATE TABLE IF NOT EXISTS writer_clock (
writer_fingerprint TEXT PRIMARY KEY,
last_tick INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conversations_ws_tick
ON conversations (workspace_key, activity_tick);
CREATE INDEX IF NOT EXISTS idx_prompt_receipts_conversation_order
ON prompt_receipts (conversation_id, receipt_order);
CREATE INDEX IF NOT EXISTS idx_prompt_receipts_root
ON prompt_receipts (root_prompt_id);
-- Composite uniqueness lets artifact foreign keys enforce that both
-- the direct prompt and objective root belong to the artifact's own
-- conversation, not merely that those globally-unique ids exist.
CREATE UNIQUE INDEX IF NOT EXISTS idx_prompt_receipts_conversation_id
ON prompt_receipts (conversation_id, id);
-- Bounded derived-work lineage. This is not a duplicate transcript:
-- bodies and structured metadata have hard byte ceilings, while file
-- and commit records retain locators/digests rather than raw output.
CREATE TABLE IF NOT EXISTS prompt_artifacts (
id TEXT PRIMARY KEY, -- artifact:<uuid>
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
writer_fingerprint TEXT NOT NULL,
seq INTEGER NOT NULL CHECK (seq > 0), -- per-conversation causal order
prev_hash TEXT NOT NULL,
prompt_id TEXT NOT NULL,
root_prompt_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'plan_revision', 'compaction_checkpoint',
'file_change', 'turn_outcome', 'commit', 'decision')),
relation TEXT NOT NULL CHECK (relation IN (
'derived_from', 'updates', 'summarizes', 'realizes')),
locator TEXT CHECK (locator IS NULL OR length(CAST(locator AS BLOB)) <= 4096),
body TEXT CHECK (body IS NULL OR length(CAST(body AS BLOB)) <= 65536),
metadata TEXT NOT NULL CHECK (
json_valid(metadata)
AND json_type(metadata) = 'object'
AND length(CAST(metadata AS BLOB)) <= 16384),
ts_claim INTEGER NOT NULL, -- display-only wall clock
encoding_version INTEGER NOT NULL DEFAULT 1,
artifact_hash TEXT NOT NULL,
UNIQUE (conversation_id, seq),
FOREIGN KEY (conversation_id, prompt_id)
REFERENCES prompt_receipts(conversation_id, id) ON DELETE CASCADE,
FOREIGN KEY (conversation_id, root_prompt_id)
REFERENCES prompt_receipts(conversation_id, id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_prompt_artifacts_prompt
ON prompt_artifacts (conversation_id, prompt_id, seq);
CREATE INDEX IF NOT EXISTS idx_prompt_artifacts_root
ON prompt_artifacts (conversation_id, root_prompt_id, seq);
-- A separately stored tip detects truncation of the final row. Like
-- the existing conversation tip, this is anti-naive-edit integrity,
-- not a signature against an adversary who can rewrite every table.
CREATE TABLE IF NOT EXISTS prompt_artifact_tips (
conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
last_seq INTEGER NOT NULL CHECK (last_seq > 0),
tip_hash TEXT NOT NULL
);
-- #1030 Plans within Plans: the roadmap tree, persisted as a serialized
-- plan.rs::Plan blob (Roadmap->Phase->Plan->Task Subtask nodes). A Plan
-- node binds a conversations row via conversations.node_id; the tree's
-- shape lives HERE, never on the hash-chained transcript rows.
CREATE TABLE IF NOT EXISTS roadmaps (
id TEXT NOT NULL,
workspace_key TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
tree TEXT NOT NULL DEFAULT '', -- serialized plan.rs::Plan (TOML); empty = no nodes yet
schema_version INTEGER NOT NULL DEFAULT 1,
created_at_claim INTEGER NOT NULL DEFAULT 0,
updated_at_claim INTEGER NOT NULL DEFAULT 0,
-- #1086: a roadmap's identity is (id, workspace_key), NOT id alone.
-- With id-only PK an `INSERT OR REPLACE` from /roadmap import could
-- REPLACE a same-id row owned by ANOTHER workspace (the read fence
-- held, the write fence did not). The composite key makes the write
-- path workspace-fenced too: importing an id that lives under a
-- different workspace inserts a separate row, never steals.
PRIMARY KEY (id, workspace_key)
);
CREATE INDEX IF NOT EXISTS idx_roadmaps_ws
ON roadmaps (workspace_key);
-- #1030 collision fix: at most ONE live process owns a conversation.
-- conversation_id is the global PK, so a second live newt cannot claim a
-- conversation another holds; a stale claim (dead pid / new boot_id) is
-- reclaimed on the next claim. Also the source of the /resume liveness column.
CREATE TABLE IF NOT EXISTS live_owners (
conversation_id TEXT PRIMARY KEY,
host TEXT NOT NULL,
boot_id TEXT NOT NULL,
pid INTEGER NOT NULL,
writer_fingerprint TEXT NOT NULL,
heartbeat_tick INTEGER NOT NULL
);
-- A3 (W6) attach-inject inbox: prompts an ATTACH surface (newt-web)
-- enqueues for the RUNNING session to consume as its next turn. The
-- attach surface writes ONLY here — never a turn/claim — so the
-- claim-holding REPL stays the sole writer of the transcript (D2). A
-- brand-new table: `IF NOT EXISTS` creates it on every existing db, no
-- rebuild. `delivered_receipt_id` back-links the durable turn the
-- injection became (the additive, auditable 'entered via web' proof
-- that avoids a prompt_receipts CHECK migration).
CREATE TABLE IF NOT EXISTS conversation_inbox (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
workspace_key TEXT NOT NULL, -- same fence every table carries
seq INTEGER NOT NULL, -- per-conversation FIFO order (ASC)
body TEXT NOT NULL, -- the injected prompt text
idem_key TEXT, -- idempotency (double-submit / SSE reconnect)
delivered INTEGER NOT NULL DEFAULT 0,
delivered_receipt_id TEXT, -- back-link → prompt_receipts.id
injected_at_claim INTEGER NOT NULL, -- DISPLAY ONLY (wall-clock, unix nanos)
UNIQUE(conversation_id, idem_key)
);
CREATE INDEX IF NOT EXISTS idx_conversation_inbox_poll
ON conversation_inbox (conversation_id, workspace_key, delivered, seq);
-- A4 (W6) permission-decision channel: the RUNNING gate (sole authority
-- minter) publishes a pending permission decision here; an ATTACH
-- surface (newt-web) renders it and writes back a VERDICT (never
-- caveats). The gate races this against the TTY and resolves it
-- exactly-once. A brand-new table: `IF NOT EXISTS` on every existing db,
-- no rebuild. `danger_json` is GATE-STAMPED (the web never classifies
-- danger) so a high-danger row cannot be forged to a lower tier.
CREATE TABLE IF NOT EXISTS permission_requests (
request_id TEXT PRIMARY KEY, -- unguessable nonce (new_conversation_id)
conversation_id TEXT NOT NULL,
workspace_key TEXT NOT NULL, -- same fence every table carries
requests_json TEXT NOT NULL, -- serialized &[PermissionRequest]
danger_json TEXT NOT NULL, -- gate-stamped per-target tier
verdict TEXT, -- NULL until a surface answers
answered_by TEXT, -- audit: 'web' | 'tty' | 'expired'
resolved INTEGER NOT NULL DEFAULT 0,
created_tick INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_permission_requests_pending
ON permission_requests (conversation_id, workspace_key, resolved);
-- Passkey enrollment staging (#1369). The WEB stages a candidate
-- binding here; the TERMINAL promotes it to the signed credential
-- registry. Deliberately UNLIKE permission_requests, there is no
-- `verdict` column and no web-writable answer: a web actor can only
-- ever propose, never confer authority. Nothing in this table is
-- authority — a row is a proposal that expires.
CREATE TABLE IF NOT EXISTS enrollment_requests (
request_id TEXT PRIMARY KEY, -- unguessable nonce (new_conversation_id)
conversation_id TEXT NOT NULL,
workspace_key TEXT NOT NULL, -- same fence every table carries
candidate_json TEXT NOT NULL, -- serialized EnrollmentCandidate
resolved INTEGER NOT NULL DEFAULT 0,
resolved_by TEXT, -- audit: 'terminal' | 'declined'
created_tick INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_enrollment_requests_pending
ON enrollment_requests (conversation_id, workspace_key, resolved);",
)?;
Ok(())
}
const EXPECTED_COLUMNS: &[(&str, &[(&str, &str)])] = &[
(
"conversations",
&[
("id", "TEXT"),
("title", "TEXT NOT NULL DEFAULT ''"),
("workspace_path", "TEXT NOT NULL DEFAULT ''"),
("workspace_key", "TEXT NOT NULL DEFAULT ''"),
("persona", "TEXT"),
("end_reason", "TEXT"),
("writer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
("activity_tick", "INTEGER NOT NULL DEFAULT 0"),
("tip_hash", "TEXT NOT NULL DEFAULT ''"),
("started_at_claim", "INTEGER NOT NULL DEFAULT 0"),
("updated_at_claim", "INTEGER NOT NULL DEFAULT 0"),
("scratchpad", "TEXT NOT NULL DEFAULT '{}'"),
("plan", "TEXT NOT NULL DEFAULT '{}'"),
("roadmap_id", "TEXT"),
("node_id", "TEXT"),
],
),
(
"turns",
&[
("conversation_id", "TEXT"),
("writer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
("seq", "INTEGER NOT NULL DEFAULT 0"),
("prev_hash", "TEXT NOT NULL DEFAULT ''"),
("user", "TEXT NOT NULL DEFAULT ''"),
("assistant", "TEXT NOT NULL DEFAULT ''"),
("events", "TEXT NOT NULL DEFAULT '[]'"),
("tokens_in", "INTEGER"),
("tokens_out", "INTEGER"),
("ts_claim", "INTEGER NOT NULL DEFAULT 0"),
("encoding_version", "INTEGER NOT NULL DEFAULT 1"),
("phantom_reaches", "TEXT NOT NULL DEFAULT '[]'"),
],
),
(
"writer_clock",
&[
("writer_fingerprint", "TEXT"),
("last_tick", "INTEGER NOT NULL DEFAULT 0"),
],
),
(
"prompt_receipts",
&[
("active_operator_id", "TEXT REFERENCES prompt_receipts(id)"),
],
),
];
fn reconcile_schema(conn: &Connection) -> anyhow::Result<()> {
for (table, expected) in EXPECTED_COLUMNS {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let present: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
for (name, decl) in *expected {
if !present.iter().any(|c| c == name) {
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {name} {decl}"))?;
tracing::info!(
table = *table,
column = *name,
"schema migration: added missing column"
);
}
}
}
Ok(())
}
fn import_legacy_json(
conn: &Connection,
root: &Path,
writer_fingerprint: &str,
) -> anyhow::Result<()> {
let legacy_root = root.join(LEGACY_JSON_DIR);
if !legacy_root.is_dir() {
return Ok(());
}
let mut records = collect_legacy_records(&legacy_root)?;
records.sort_by(|a, b| {
a.updated_at_unix_nanos
.cmp(&b.updated_at_unix_nanos)
.then_with(|| a.created_at_unix_nanos.cmp(&b.created_at_unix_nanos))
.then_with(|| a.id.cmp(&b.id))
});
let mut imported = 0usize;
for record in &records {
if import_one_record(conn, record, writer_fingerprint)? {
imported += 1;
}
}
let backup = retire_legacy_dir(root, &legacy_root)?;
tracing::info!(
imported,
found = records.len(),
backup = %backup.display(),
"one-time import of legacy JSON conversations complete; \
the original tree is kept as a backup"
);
Ok(())
}
fn migrate_roadmaps_pk(conn: &Connection) -> anyhow::Result<()> {
let sql: Option<String> = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='roadmaps'",
[],
|r| r.get(0),
)
.optional()?;
let Some(sql) = sql else {
return Ok(());
};
let normalized = sql
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
if normalized.contains("primary key (id, workspace_key)") {
return Ok(()); }
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
tx.execute_batch(
"CREATE TABLE roadmaps_v2 (
id TEXT NOT NULL,
workspace_key TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
tree TEXT NOT NULL DEFAULT '',
schema_version INTEGER NOT NULL DEFAULT 1,
created_at_claim INTEGER NOT NULL DEFAULT 0,
updated_at_claim INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (id, workspace_key)
);
INSERT INTO roadmaps_v2
(id, workspace_key, title, tree, schema_version, created_at_claim, updated_at_claim)
SELECT id, workspace_key, title, tree, schema_version, created_at_claim, updated_at_claim
FROM roadmaps;
DROP TABLE roadmaps;
ALTER TABLE roadmaps_v2 RENAME TO roadmaps;
CREATE INDEX IF NOT EXISTS idx_roadmaps_ws ON roadmaps (workspace_key);",
)?;
tx.commit()?;
tracing::info!("migrated roadmaps to a composite (id, workspace_key) primary key (#1086)");
Ok(())
}
fn migrate_workspace_key(conn: &Connection, workspace: &Path, v2_key: &str) -> anyhow::Result<()> {
#[allow(deprecated)]
let old_key = ConversationStore::workspace_id_for_path(workspace)?;
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
let migrated = tx.execute(
"UPDATE conversations SET workspace_key = ?2 WHERE workspace_key = ?1",
rusqlite::params![old_key, v2_key],
)?;
tx.commit()?;
if migrated > 0 {
tracing::info!(
migrated,
workspace = %workspace.display(),
"re-keyed conversations from the retired UUIDv5 workspace key to v2"
);
}
Ok(())
}
fn collect_legacy_records(legacy_root: &Path) -> anyhow::Result<Vec<ConversationRecord>> {
let mut records = Vec::new();
for ws_entry in std::fs::read_dir(legacy_root)? {
let ws_dir = ws_entry?.path();
if !ws_dir.is_dir() {
continue;
}
let dir_key = ws_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
for entry in std::fs::read_dir(&ws_dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let parsed = std::fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|text| Ok(serde_json::from_str::<ConversationRecord>(&text)?));
let record = match parsed {
Ok(record) => record,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"skipping unreadable legacy conversation record \
(the file is kept in the import backup)"
);
continue;
}
};
if record.workspace_id != dir_key {
tracing::warn!(
path = %path.display(),
body_workspace = %record.workspace_id,
dir_workspace = %dir_key,
"skipping legacy record whose workspace id does not match its dir"
);
continue;
}
if let Err(e) = validate_record_id(&record.id) {
tracing::warn!(path = %path.display(), error = %e, "skipping legacy record");
continue;
}
records.push(record);
}
}
Ok(records)
}
fn import_one_record(
conn: &Connection,
record: &ConversationRecord,
writer_fingerprint: &str,
) -> anyhow::Result<bool> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
let already: Option<i64> = tx
.query_row(
"SELECT 1 FROM conversations WHERE id = ?1",
[&record.id],
|row| row.get(0),
)
.optional()?;
if already.is_some() {
tracing::debug!(id = %record.id, "legacy conversation already in the db; skipping");
return Ok(false);
}
let started_claim = clamp_claim(record.created_at_unix_nanos);
let updated_claim = clamp_claim(record.updated_at_unix_nanos);
let create_tick = next_tick(&tx, writer_fingerprint)?;
tx.execute(
"INSERT INTO conversations
(id, title, workspace_path, workspace_key, persona, end_reason,
writer_fingerprint, activity_tick, tip_hash,
started_at_claim, updated_at_claim)
VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
record.id,
record.title,
record.workspace,
record.workspace_id,
record.persona,
writer_fingerprint,
create_tick,
genesis_hash(&record.id, writer_fingerprint),
started_claim,
updated_claim,
],
)?;
let mut prev_hash = genesis_hash(&record.id, writer_fingerprint);
let mut last_tick = create_tick;
for turn in &record.turns {
let seq = next_tick(&tx, writer_fingerprint)?;
let row = TurnRow {
conversation_id: record.id.clone(),
writer_fingerprint: writer_fingerprint.to_string(),
seq,
prev_hash,
user: turn.user.clone(),
assistant: turn.assistant.clone(),
events: "[]".to_string(),
tokens_in: None,
tokens_out: None,
ts_claim: updated_claim,
encoding_version: TURN_ENCODING_VERSION_CURRENT,
};
insert_turn_row(&tx, &row, "[]")?;
prev_hash = row.content_hash()?;
last_tick = seq;
}
if !record.turns.is_empty() {
tx.execute(
"UPDATE conversations SET activity_tick = ?2, tip_hash = ?3 WHERE id = ?1",
rusqlite::params![record.id, last_tick, prev_hash],
)?;
}
tx.commit()?;
Ok(true)
}
fn retire_legacy_dir(root: &Path, legacy_root: &Path) -> anyhow::Result<PathBuf> {
for attempt in 0u32..100 {
let candidate = if attempt == 0 {
root.join(LEGACY_BACKUP_DIR)
} else {
root.join(format!("{LEGACY_BACKUP_DIR}.{attempt}"))
};
if candidate.exists() {
continue;
}
return match std::fs::rename(legacy_root, &candidate) {
Ok(()) => Ok(candidate),
Err(_) if !legacy_root.exists() => Ok(candidate),
Err(e) => Err(anyhow::Error::from(e).context(format!(
"imported legacy conversations but could not move {} aside to {}",
legacy_root.display(),
candidate.display()
))),
};
}
anyhow::bail!(
"no free backup name for {} (conversations.imported* all taken)",
legacy_root.display()
)
}
fn clamp_claim(nanos: u128) -> i64 {
i64::try_from(nanos).unwrap_or(i64::MAX)
}
fn resolve_writer_fingerprint(root: &Path) -> anyhow::Result<String> {
let pem = root.join(IDENTITY_PEM_FILE);
if pem.is_file() {
match agent_mesh_protocol::UserKey::load(&pem) {
Ok(user) => return Ok(user.fingerprint().hex()),
Err(e) => {
tracing::warn!(
path = %pem.display(),
error = %e,
"identity.pem exists but did not parse; \
falling back to the per-install nonce writer fingerprint"
);
}
}
}
load_or_create_writer_fingerprint(root)
}
fn load_or_create_writer_fingerprint(root: &Path) -> anyhow::Result<String> {
let path = root.join(NONCE_FILE);
let nonce = match std::fs::read_to_string(&path) {
Ok(text) if !text.trim().is_empty() => text.trim().to_string(),
_ => {
let minted = uuid::Uuid::new_v4().to_string();
let tmp = root.join(format!(
"{NONCE_FILE}.{}.{:?}.tmp",
std::process::id(),
std::thread::current().id()
));
std::fs::write(&tmp, &minted)?;
let publish = std::fs::hard_link(&tmp, &path);
let _ = std::fs::remove_file(&tmp);
match publish {
Ok(()) => minted,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let adopted = std::fs::read_to_string(&path)?.trim().to_string();
if adopted.is_empty() {
anyhow::bail!(
"install nonce at {} exists but is empty — remove it and retry",
path.display()
);
}
adopted
}
Err(e) => return Err(e.into()),
}
}
};
Ok(blake3::hash(nonce.as_bytes()).to_hex().to_string())
}
fn now_claim_nanos() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
const ROADMAP_SCHEMA_VERSION: i64 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Roadmap {
pub id: String,
pub title: String,
pub tree: crate::plan::Plan,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoadmapSummary {
pub id: String,
pub title: String,
pub node_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredOwner {
pub host: String,
pub boot_id: String,
pub pid: i64,
pub writer_fingerprint: String,
pub heartbeat_tick: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaimOutcome {
Claimed,
HeldBy { host: String, pid: i64 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InjectOutcome {
Enqueued,
Duplicate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InjectedPrompt {
pub id: String,
pub body: String,
pub seq: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
AllowOnce,
AllowSession,
Deny,
}
impl Verdict {
fn as_db_str(self) -> &'static str {
match self {
Self::AllowOnce => "allow_once",
Self::AllowSession => "allow_session",
Self::Deny => "deny",
}
}
fn from_db_str(s: &str) -> Option<Self> {
match s {
"allow_once" => Some(Self::AllowOnce),
"allow_session" => Some(Self::AllowSession),
"deny" => Some(Self::Deny),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingPermission {
pub request_id: String,
pub requests_json: String,
pub danger_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingEnrollment {
pub request_id: String,
pub candidate_json: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnswerOutcome {
Answered,
AlreadyResolved,
Unknown,
}
pub type LivenessFn = fn(owner: &StoredOwner, now: i64) -> bool;
const LIVENESS_STALE_AFTER_NANOS: i64 = 3_600 * 1_000_000_000;
fn system_liveness(owner: &StoredOwner, now: i64) -> bool {
let (host, boot_id) = current_host_boot();
if owner.host == host && owner.boot_id == boot_id {
return pid_is_alive(owner.pid);
}
now.saturating_sub(owner.heartbeat_tick) < LIVENESS_STALE_AFTER_NANOS
}
#[cfg(unix)]
fn pid_is_alive(pid: i64) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
if pid <= 0 {
return false;
}
let rc = unsafe { libc::kill(pid, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(windows)]
fn pid_is_alive(pid: i64) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED};
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
let Ok(pid) = u32::try_from(pid) else {
return false;
};
if pid == 0 {
return false;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return std::io::Error::last_os_error().raw_os_error() == Some(ERROR_ACCESS_DENIED as i32);
}
unsafe { CloseHandle(handle) };
true
}
fn current_host_boot() -> (String, String) {
let host = std::fs::read_to_string("/proc/sys/kernel/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "localhost".to_string());
let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
.map(|s| s.trim().to_string())
.unwrap_or_default();
(host, boot_id)
}
fn live_owner_row(conn: &Connection, conversation_id: &str) -> anyhow::Result<Option<StoredOwner>> {
conn.query_row(
"SELECT host, boot_id, pid, writer_fingerprint, heartbeat_tick
FROM live_owners WHERE conversation_id = ?1",
rusqlite::params![conversation_id],
|row| {
Ok(StoredOwner {
host: row.get(0)?,
boot_id: row.get(1)?,
pid: row.get(2)?,
writer_fingerprint: row.get(3)?,
heartbeat_tick: row.get(4)?,
})
},
)
.optional()
.map_err(Into::into)
}
fn claim_to_u128(claim: i64) -> u128 {
claim.max(0) as u128
}
fn tokens_from_sql(value: Option<i64>) -> anyhow::Result<Option<u32>> {
value
.map(|v| {
u32::try_from(v)
.map_err(|_| anyhow::anyhow!("token count {v} out of range (tampered row?)"))
})
.transpose()
}
fn validate_record_id(id: &str) -> anyhow::Result<()> {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
anyhow::bail!("invalid conversation id `{id}`");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn insert_prompt_lineage_for_test(
store: &ConversationStore,
conversation_id: &str,
depth: usize,
) -> (PromptId, PromptId) {
assert!(depth >= 1);
store
.create_with_id(conversation_id, "lineage test", None)
.unwrap();
let writer = store.writer_fingerprint().to_string();
let root_id = PromptId::new();
let root = PromptReceipt::new(
root_id,
conversation_id.to_string(),
writer.clone(),
1,
None,
None,
root_id,
root_id,
PromptOrigin::Operator,
b"root".to_vec(),
b"root".to_vec(),
1,
);
let conn = store.lock_conn();
let tx =
rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate).unwrap();
insert_prompt_receipt(&tx, &root).unwrap();
let mut previous_id = root_id;
for index in 1..depth {
let id = PromptId::new();
let text = format!("retry-{index}").into_bytes();
let retry = PromptReceipt::new(
id,
conversation_id.to_string(),
writer.clone(),
i64::try_from(index + 1).unwrap(),
Some(previous_id),
Some(previous_id),
root_id,
root_id,
PromptOrigin::HarnessRetry,
text.clone(),
text,
i64::try_from(index + 1).unwrap(),
);
insert_prompt_receipt(&tx, &retry).unwrap();
previous_id = id;
}
tx.commit().unwrap();
(root_id, previous_id)
}
#[test]
fn prompt_receipt_is_byte_exact_and_survives_an_incomplete_turn() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let conversation_id = "prompt-byte-exact";
let receipt = {
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let prompt = crate::prompt::NewPrompt::operator(
b"raw\0bytes\xff".to_vec(),
"model text\nwith Unicode: \u{1f9ad}".as_bytes().to_vec(),
);
store
.begin_prompt(conversation_id, "prompt title", None, prompt)
.unwrap()
.submitted()
.receipt()
.clone()
};
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let loaded = reopened
.load_prompt_in_conversation(conversation_id, receipt.id())
.unwrap()
.expect("prompt receipt survives a failed/interrupted turn");
assert_eq!(loaded.raw_text(), b"raw\0bytes\xff");
assert_eq!(
loaded.model_text_utf8().unwrap(),
"model text\nwith Unicode: \u{1f9ad}"
);
loaded.verify_integrity().unwrap();
assert!(reopened.load(conversation_id).unwrap().turns.is_empty());
}
#[test]
fn prompt_chronology_is_automatic_but_objective_parentage_is_explicit() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = "prompt-ancestry";
let first = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("first", "first"),
)
.unwrap()
.submitted()
.receipt()
.clone();
assert_eq!(first.root_prompt_id(), first.id());
assert_eq!(first.previous_prompt_id(), None);
assert_eq!(first.parent_prompt_id(), None);
let second = store
.begin_prompt(
conv,
"ignored on existing conversation",
None,
crate::prompt::NewPrompt::operator("second", "second"),
)
.unwrap()
.submitted()
.receipt()
.clone();
assert_eq!(second.previous_prompt_id(), Some(first.id()));
assert_eq!(second.parent_prompt_id(), None);
assert_eq!(second.root_prompt_id(), second.id());
let retry = store
.begin_prompt(
conv,
"ignored",
None,
crate::prompt::NewPrompt::harness_retry("retry", "retry", first.id()),
)
.unwrap()
.submitted()
.receipt()
.clone();
assert_eq!(retry.previous_prompt_id(), Some(second.id()));
assert_eq!(retry.parent_prompt_id(), Some(first.id()));
assert_eq!(retry.root_prompt_id(), first.id());
let context = store
.turn_prompt_context(conv, retry.id())
.unwrap()
.expect("retry context");
assert_eq!(context.submitted_prompt().id(), retry.id());
assert_eq!(context.active_operator_prompt().id(), first.id());
assert_eq!(
store.prompt_chain(conv).unwrap(),
vec![first, second, retry]
);
}
#[test]
fn mutable_receipt_order_cannot_reparent_the_verified_prompt_chain() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = "prompt-order-tamper";
let first = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("first", "first"),
)
.unwrap()
.submitted()
.id();
let second = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("second", "second"),
)
.unwrap()
.submitted()
.id();
store
.lock_conn()
.execute(
"UPDATE prompt_receipts
SET receipt_order = CASE id WHEN ?1 THEN 2002 WHEN ?2 THEN 2001 END
WHERE id IN (?1, ?2)",
rusqlite::params![first.to_string(), second.to_string()],
)
.unwrap();
let latest_error = store.latest_prompt(conv).unwrap_err().to_string();
assert!(
latest_error.contains("prompt chronology mismatch"),
"{latest_error}"
);
let append_error = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("third", "third"),
)
.unwrap_err()
.to_string();
assert!(
append_error.contains("prompt chronology mismatch"),
"{append_error}"
);
let receipt_count: i64 = store
.lock_conn()
.query_row(
"SELECT COUNT(*) FROM prompt_receipts WHERE conversation_id = ?1",
[conv],
|row| row.get(0),
)
.unwrap();
assert_eq!(receipt_count, 2, "failed append must roll back completely");
}
#[test]
fn concurrent_store_connections_serialize_prompt_predecessors() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let conv = "concurrent-prompt-append";
let seed_store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let seed = seed_store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("seed", "seed"),
)
.unwrap()
.submitted()
.id();
drop(seed_store);
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
let mut workers = Vec::new();
for label in ["left", "right"] {
let root_path = root.path().to_path_buf();
let workspace_path = workspace.path().to_path_buf();
let barrier = barrier.clone();
workers.push(std::thread::spawn(move || {
let store = ConversationStore::new(root_path, workspace_path, 100).unwrap();
barrier.wait();
store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator(label, label),
)
.unwrap()
.submitted()
.id()
}));
}
barrier.wait();
let appended: Vec<PromptId> = workers
.into_iter()
.map(|worker| worker.join().unwrap())
.collect();
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let chain = reopened.prompt_chain(conv).unwrap();
assert_eq!(chain.len(), 3);
assert_eq!(chain[0].id(), seed);
assert_eq!(chain[1].previous_prompt_id(), Some(seed));
assert_eq!(chain[2].previous_prompt_id(), Some(chain[1].id()));
assert!(appended.contains(&chain[1].id()));
assert!(appended.contains(&chain[2].id()));
}
#[test]
fn prompt_reads_are_conversation_and_workspace_fenced_and_delete_cascades() {
let root = tempfile::tempdir().unwrap();
let ws_a = tempfile::tempdir().unwrap();
let ws_b = tempfile::tempdir().unwrap();
let store_a = ConversationStore::new(root.path(), ws_a.path(), 100).unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
let a = store_a
.begin_prompt(
"conversation-a",
"A",
None,
crate::prompt::NewPrompt::operator("secret-a", "secret-a"),
)
.unwrap()
.submitted()
.receipt()
.clone();
let cross_workspace_append = store_b
.begin_prompt(
"conversation-a",
"foreign",
None,
crate::prompt::NewPrompt::operator("intruder", "intruder"),
)
.unwrap_err()
.to_string();
assert!(
cross_workspace_append.contains("belongs to another workspace"),
"{cross_workspace_append}"
);
let b = store_b
.begin_prompt(
"conversation-b",
"B",
None,
crate::prompt::NewPrompt::operator("secret-b", "secret-b"),
)
.unwrap()
.submitted()
.receipt()
.clone();
assert!(store_b.load_prompt(a.id()).unwrap().is_none());
assert!(store_a
.load_prompt_in_conversation("conversation-a", b.id())
.unwrap()
.is_none());
assert!(store_b.latest_prompt("conversation-a").unwrap().is_none());
assert!(store_b.prompt_chain("conversation-a").unwrap().is_empty());
assert!(store_b
.turn_prompt_context("conversation-a", a.id())
.unwrap()
.is_none());
assert!(store_a.previous_prompt(b.id()).unwrap().is_none());
store_a.delete("conversation-a").unwrap();
assert!(store_a.load_prompt(a.id()).unwrap().is_none());
}
#[test]
fn prompt_lineage_accepts_the_documented_depth_boundary() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conversation_id = "lineage-at-boundary";
let (root_id, leaf_id) =
insert_prompt_lineage_for_test(&store, conversation_id, MAX_PROMPT_LINEAGE_DEPTH);
let context = store
.turn_prompt_context(conversation_id, leaf_id)
.unwrap()
.expect("a lineage exactly at the documented limit is valid");
assert_eq!(context.active().id(), root_id);
}
#[test]
fn prompt_lineage_rejects_a_deeper_retry_before_inserting_it() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conversation_id = "lineage-over-boundary";
let (_, leaf_id) =
insert_prompt_lineage_for_test(&store, conversation_id, MAX_PROMPT_LINEAGE_DEPTH);
let error = store
.begin_prompt(
conversation_id,
"ignored",
None,
crate::prompt::NewPrompt::harness_retry("too deep", "too deep", leaf_id),
)
.unwrap_err()
.to_string();
assert!(error.contains("maximum prompt lineage depth"), "{error}");
assert_eq!(
store.prompt_chain(conversation_id).unwrap().len(),
MAX_PROMPT_LINEAGE_DEPTH
);
}
#[test]
fn prompt_lineage_rejects_a_persisted_chain_over_the_depth_limit() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conversation_id = "persisted-lineage-over-boundary";
let (_, leaf_id) =
insert_prompt_lineage_for_test(&store, conversation_id, MAX_PROMPT_LINEAGE_DEPTH + 1);
let error = store
.turn_prompt_context(conversation_id, leaf_id)
.unwrap_err()
.to_string();
assert!(error.contains("exceeds the maximum depth"), "{error}");
}
#[test]
fn prompt_lineage_cycle_is_detected_without_recursion() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conversation_id = "lineage-cycle";
store
.create_with_id(conversation_id, "cycle test", None)
.unwrap();
let writer = store.writer_fingerprint().to_string();
let root_id = PromptId::new();
let retry_a_id = PromptId::new();
let retry_b_id = PromptId::new();
let root_receipt = PromptReceipt::new(
root_id,
conversation_id.to_string(),
writer.clone(),
1,
None,
None,
root_id,
root_id,
PromptOrigin::Operator,
b"root".to_vec(),
b"root".to_vec(),
1,
);
let retry_a = PromptReceipt::new(
retry_a_id,
conversation_id.to_string(),
writer.clone(),
2,
Some(root_id),
Some(retry_b_id),
root_id,
root_id,
PromptOrigin::HarnessRetry,
b"retry-a".to_vec(),
b"retry-a".to_vec(),
2,
);
let retry_b = PromptReceipt::new(
retry_b_id,
conversation_id.to_string(),
writer,
3,
Some(retry_a_id),
Some(retry_a_id),
root_id,
root_id,
PromptOrigin::HarnessRetry,
b"retry-b".to_vec(),
b"retry-b".to_vec(),
3,
);
{
let conn = store.lock_conn();
let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.unwrap();
tx.execute_batch("PRAGMA defer_foreign_keys = ON").unwrap();
insert_prompt_receipt(&tx, &root_receipt).unwrap();
insert_prompt_receipt(&tx, &retry_a).unwrap();
insert_prompt_receipt(&tx, &retry_b).unwrap();
tx.commit().unwrap();
}
let error = store
.turn_prompt_context(conversation_id, retry_b_id)
.unwrap_err()
.to_string();
assert!(error.contains("prompt parent cycle detected"), "{error}");
}
#[test]
fn prompt_ticks_reseed_the_writer_clock_without_moving_the_turn_chain_tip() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = "prompt-clock";
let first = store
.begin_prompt(
conv,
"clock",
None,
crate::prompt::NewPrompt::operator("one", "one"),
)
.unwrap()
.submitted()
.receipt()
.clone();
let (writer_before, tip_before): (String, String) = {
let conn = store.lock_conn();
conn.query_row(
"SELECT writer_fingerprint, tip_hash FROM conversations WHERE id = ?1",
[conv],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap()
};
{
let conn = store.lock_conn();
conn.execute(
"DELETE FROM writer_clock WHERE writer_fingerprint = ?1",
[store.writer_fingerprint()],
)
.unwrap();
}
let second = store
.begin_prompt(
conv,
"clock",
None,
crate::prompt::NewPrompt::operator("two", "two"),
)
.unwrap()
.submitted()
.receipt()
.clone();
assert!(second.seq() > first.seq());
let (writer_after, tip_after): (String, String) = {
let conn = store.lock_conn();
conn.query_row(
"SELECT writer_fingerprint, tip_hash FROM conversations WHERE id = ?1",
[conv],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap()
};
assert_eq!(writer_after, writer_before);
assert_eq!(tip_after, tip_before);
}
#[test]
fn prompt_receipts_do_not_backfill_historical_turns() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = store.create("historical", None).unwrap();
store
.append_turn(&conv, "old user text", "old answer")
.unwrap();
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
assert!(reopened.prompt_chain(&conv).unwrap().is_empty());
assert!(reopened.latest_prompt(&conv).unwrap().is_none());
}
#[test]
fn begin_prompt_rolls_back_lazy_conversation_when_ancestry_is_invalid() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let missing = crate::prompt::PromptId::new();
let err = store
.begin_prompt(
"atomic-invalid-parent",
"must roll back",
None,
crate::prompt::NewPrompt::harness_retry("raw", "model", missing),
)
.unwrap_err()
.to_string();
assert!(err.contains("is not in conversation"), "{err}");
assert!(!store.exists("atomic-invalid-parent").unwrap());
assert!(store.load_prompt(missing).unwrap().is_none());
}
#[test]
fn begin_prompt_rejects_non_utf8_model_bytes_before_creating_a_conversation() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let error = store
.begin_prompt(
"invalid-model-encoding",
"must not persist",
None,
crate::prompt::NewPrompt::operator(b"raw may be bytes".to_vec(), vec![0xff]),
)
.unwrap_err()
.to_string();
assert!(error.contains("not valid UTF-8"), "{error}");
assert!(!store.exists("invalid-model-encoding").unwrap());
}
#[test]
fn prompt_retention_never_prunes_the_receipt_it_just_accepted() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 1).unwrap();
store
.begin_prompt(
"older-conversation",
"older",
None,
crate::prompt::NewPrompt::operator("older", "older"),
)
.unwrap();
{
let conn = store.lock_conn();
conn.execute(
"UPDATE conversations SET activity_tick = 100 WHERE id = ?1",
["older-conversation"],
)
.unwrap();
conn.execute(
"UPDATE writer_clock SET last_tick = 0 WHERE writer_fingerprint = ?1",
[store.writer_fingerprint()],
)
.unwrap();
}
let accepted = store
.begin_prompt(
"newly-accepted",
"new",
None,
crate::prompt::NewPrompt::operator("new", "new"),
)
.unwrap();
assert!(store.exists("newly-accepted").unwrap());
assert!(store
.load_prompt_in_conversation("newly-accepted", accepted.submitted().id())
.unwrap()
.is_some());
assert!(!store.exists("older-conversation").unwrap());
}
#[test]
fn prompt_retention_skips_live_owners_but_reclaims_stale_claims() {
fn never_live(_owner: &StoredOwner, _now: i64) -> bool {
false
}
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let mut store = ConversationStore::new(root.path(), workspace.path(), 1).unwrap();
let first = store
.begin_prompt(
"live-retention-owner",
"first",
None,
crate::prompt::NewPrompt::operator("first", "first"),
)
.unwrap();
assert_eq!(
store.claim("live-retention-owner").unwrap(),
ClaimOutcome::Claimed
);
store
.begin_prompt(
"protected-new-prompt",
"second",
None,
crate::prompt::NewPrompt::operator("second", "second"),
)
.unwrap();
assert!(store.exists("live-retention-owner").unwrap());
assert!(store.exists("protected-new-prompt").unwrap());
assert!(store.load_prompt(first.submitted().id()).unwrap().is_some());
store.set_liveness_for_test(never_live);
store
.begin_prompt(
"third-prompt",
"third",
None,
crate::prompt::NewPrompt::operator("third", "third"),
)
.unwrap();
assert!(!store.exists("live-retention-owner").unwrap());
assert!(!store.exists("protected-new-prompt").unwrap());
assert!(store.exists("third-prompt").unwrap());
assert!(store.live_owner("live-retention-owner").unwrap().is_none());
}
#[test]
fn create_with_id_cannot_implicitly_erase_an_accepted_prompt() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conversation_id = "prompt-cannot-be-replaced";
let accepted = store
.begin_prompt(
conversation_id,
"original",
None,
crate::prompt::NewPrompt::operator("raw", "model"),
)
.unwrap();
let error = store
.create_with_id(conversation_id, "replacement", None)
.unwrap_err()
.to_string();
assert!(error.contains("immutable prompt receipts"), "{error}");
assert!(store
.load_prompt_in_conversation(conversation_id, accepted.submitted().id())
.unwrap()
.is_some());
assert_eq!(store.load(conversation_id).unwrap().title, "original");
}
#[test]
fn operator_continuation_inherits_root_and_parent_stays_active() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = "operator-continuation";
let root_prompt = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("root", "root"),
)
.unwrap()
.submitted()
.receipt()
.clone();
let continuation = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator_continuation(
"continue",
"continue",
root_prompt.id(),
),
)
.unwrap();
assert_eq!(continuation.submitted().root_prompt_id(), root_prompt.id());
assert_eq!(
continuation.active().id(),
root_prompt.id(),
"a continuation must not usurp the parent ask as the active \
operator prompt — the task the card protects lives there"
);
}
#[test]
fn retries_preserve_nearest_operator_authority_across_reopen() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let conv = "continuation-retry-authority";
let (a_id, _b_id, retry_id, retry_again_id) = {
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let a = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("A", "A root objective"),
)
.unwrap();
let b = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator_continuation(
"B",
"B locked clarification",
a.active().id(),
),
)
.unwrap();
let retry = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::harness_retry(
"retry B",
"retry B",
b.submitted().id(),
),
)
.unwrap();
let retry_again = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::harness_retry(
"retry retry B",
"retry retry B",
retry.submitted().id(),
),
)
.unwrap();
assert_eq!(b.submitted().root_prompt_id(), a.submitted().id());
assert_eq!(b.active().id(), a.submitted().id());
assert_eq!(retry.submitted().root_prompt_id(), a.submitted().id());
assert_eq!(retry.active().id(), a.submitted().id());
assert_eq!(retry_again.active().id(), a.submitted().id());
for id in [b.submitted().id(), retry.submitted().id()] {
let legacy = store
.load_prompt(id)
.unwrap()
.unwrap()
.into_legacy_v1_for_test();
let conn = store.lock_conn();
conn.execute(
"UPDATE prompt_receipts
SET active_operator_id = NULL, receipt_hash = ?2,
encoding_version = 1
WHERE id = ?1",
rusqlite::params![id.to_string(), legacy.receipt_hash()],
)
.unwrap();
}
(
a.submitted().id(),
b.submitted().id(),
retry.submitted().id(),
retry_again.submitted().id(),
)
};
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
for retry_id in [retry_id, retry_again_id] {
let context = reopened
.turn_prompt_context(conv, retry_id)
.unwrap()
.expect("retry receipt survives reopen");
assert_eq!(context.active().id(), a_id);
assert_eq!(context.submitted().root_prompt_id(), a_id);
}
}
#[test]
fn retry_rejects_hashed_active_pointer_that_disagrees_with_parent() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = "tampered-retry-authority";
let a = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("A", "A"),
)
.unwrap();
let b = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("B", "B"),
)
.unwrap();
let retry = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::harness_retry("retry", "retry", b.submitted().id()),
)
.unwrap();
let forged = retry
.submitted()
.receipt()
.clone()
.with_active_operator_for_test(a.submitted().id());
{
let conn = store.lock_conn();
conn.execute(
"UPDATE prompt_receipts
SET active_operator_id = ?2, receipt_hash = ?3
WHERE id = ?1",
rusqlite::params![
forged.id().to_string(),
forged.active_operator_id().unwrap().to_string(),
forged.receipt_hash(),
],
)
.unwrap();
}
let error = store
.turn_prompt_context(conv, forged.id())
.unwrap_err()
.to_string();
assert!(error.contains("disagrees with parent authority"), "{error}");
}
#[test]
fn post_commit_prune_failure_does_not_report_prompt_failure() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 1).unwrap();
store
.begin_prompt(
"retention-first",
"first",
None,
crate::prompt::NewPrompt::operator("first", "first"),
)
.unwrap();
{
let conn = store.lock_conn();
conn.execute_batch("ALTER TABLE live_owners RENAME TO broken_live_owners")
.unwrap();
}
let accepted = store
.begin_prompt(
"retention-second",
"second",
None,
crate::prompt::NewPrompt::operator("second", "second"),
)
.expect("post-commit housekeeping cannot negate prompt acceptance");
let loaded = store
.load_prompt(accepted.submitted().id())
.unwrap()
.expect("committed receipt remains readable");
assert_eq!(loaded.model_text_utf8().unwrap(), "second");
}
#[test]
fn opening_v1_prompt_schema_adds_authority_column_without_backfill_guessing() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let conv = "v1-authority-migration";
let (a_id, retry_id) = {
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let a = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("A", "A"),
)
.unwrap();
let b = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator_continuation("B", "B", a.submitted().id()),
)
.unwrap();
let retry = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::harness_retry("retry", "retry", b.submitted().id()),
)
.unwrap();
for receipt in store.prompt_chain(conv).unwrap() {
let legacy = receipt.into_legacy_v1_for_test();
let conn = store.lock_conn();
conn.execute(
"UPDATE prompt_receipts
SET active_operator_id = NULL, receipt_hash = ?2,
encoding_version = 1
WHERE id = ?1",
rusqlite::params![legacy.id().to_string(), legacy.receipt_hash()],
)
.unwrap();
}
{
let conn = store.lock_conn();
conn.execute_batch("ALTER TABLE prompt_receipts DROP COLUMN active_operator_id")
.unwrap();
}
let _ = &b; (a.submitted().id(), retry.submitted().id())
};
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let context = reopened
.turn_prompt_context(conv, retry_id)
.unwrap()
.unwrap();
assert_eq!(context.active().id(), a_id);
assert_eq!(context.submitted().receipt().active_operator_id(), None);
let columns: Vec<String> = {
let conn = reopened.lock_conn();
let mut stmt = conn.prepare("PRAGMA table_info(prompt_receipts)").unwrap();
let selected = stmt
.query_map([], |row| row.get(1))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
selected
};
assert!(columns.iter().any(|column| column == "active_operator_id"));
}
#[test]
fn prompt_load_rejects_tampered_exact_bytes() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let receipt = store
.begin_prompt(
"tamper",
"title",
None,
crate::prompt::NewPrompt::operator("raw", "model"),
)
.unwrap()
.submitted()
.receipt()
.clone();
{
let conn = store.lock_conn();
conn.execute(
"UPDATE prompt_receipts SET model_text = ?2 WHERE id = ?1",
rusqlite::params![receipt.id().to_string(), b"changed".as_slice()],
)
.unwrap();
}
let err = store.load_prompt(receipt.id()).unwrap_err().to_string();
assert!(err.contains("model-text digest mismatch"), "{err}");
}
#[test]
fn wal_fallback_classifier_matches_known_nfs_failures() {
assert!(wal_fallback_eligible("locking protocol"));
assert!(wal_fallback_eligible("disk I/O error"));
assert!(wal_fallback_eligible(
"sqlite failure: `Error code 15: Locking Protocol`"
));
assert!(!wal_fallback_eligible("no such table: turns"));
assert!(!wal_fallback_eligible("database is locked"));
assert!(!wal_fallback_eligible(""));
}
#[test]
fn clamp_claim_saturates_oversized_legacy_nanos() {
assert_eq!(clamp_claim(0), 0);
assert_eq!(clamp_claim(42), 42);
assert_eq!(clamp_claim(u128::MAX), i64::MAX);
}
#[test]
fn writer_fingerprint_is_stable_per_install_and_distinct_across_installs() {
let root_a = tempfile::tempdir().unwrap();
let root_b = tempfile::tempdir().unwrap();
let first = load_or_create_writer_fingerprint(root_a.path()).unwrap();
let again = load_or_create_writer_fingerprint(root_a.path()).unwrap();
let other = load_or_create_writer_fingerprint(root_b.path()).unwrap();
assert_eq!(first, again, "fingerprint must be stable per install");
assert_ne!(first, other, "two installs must not share a fingerprint");
assert_eq!(first.len(), 64, "blake3 hex");
}
#[test]
fn wal_mode_pairs_with_synchronous_normal_on_the_stores_connection() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conn = store.lock_conn();
let sync_level: i64 = conn
.query_row("PRAGMA synchronous", [], |row| row.get(0))
.unwrap();
assert_eq!(sync_level, 1, "WAL must run at synchronous=NORMAL (1)");
}
#[test]
fn claim_clock_saturates_instead_of_wrapping() {
let now = now_claim_nanos();
assert!(now > 0);
assert_eq!(claim_to_u128(-5), 0);
assert_eq!(claim_to_u128(42), 42);
}
#[test]
fn load_turn_reads_one_turn_by_seq_and_misses_are_none() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = store.create("t", None).unwrap();
store
.append_turn(&conv, "the question", "the answer")
.unwrap();
let hits = store.search("question", 5).unwrap();
assert_eq!(hits.len(), 1);
let seq = hits[0].seq;
let turn = store.load_turn(&conv, seq).unwrap().expect("turn exists");
assert_eq!(turn.user, "the question");
assert_eq!(turn.assistant, "the answer");
assert!(store.load_turn(&conv, seq + 9_999).unwrap().is_none());
assert!(store.load_turn("no-such-conv", seq).unwrap().is_none());
}
#[test]
fn end_conversation_hides_row_from_latest_open_but_not_from_list() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let c1 = store.create("first", None).unwrap();
store.append_turn(&c1, "q1", "a1").unwrap();
let c2 = store.create("second", None).unwrap();
store.append_turn(&c2, "q2", "a2").unwrap();
assert_eq!(store.latest_open().unwrap().unwrap().id, c2);
store.end_conversation(&c2, "wq").unwrap();
assert_eq!(
store.latest_open().unwrap().unwrap().id,
c1,
"an ended conversation is skipped on auto-resume"
);
assert_eq!(store.list().unwrap().len(), 2);
assert!(
store
.search("q2", 5)
.unwrap()
.iter()
.any(|h| h.conversation_id == c2),
"ended conversation stays in the FTS index for /recall"
);
store.end_conversation(&c1, "end").unwrap();
assert!(store.latest_open().unwrap().is_none());
assert_eq!(store.list().unwrap().len(), 2, "still listed after ending");
}
#[test]
fn list_all_spans_workspaces_and_carries_their_paths() {
let root = tempfile::tempdir().unwrap();
let ws_a = tempfile::tempdir().unwrap();
let ws_b = tempfile::tempdir().unwrap();
let canon = |d: &std::path::Path| {
std::fs::canonicalize(d)
.unwrap()
.to_string_lossy()
.into_owned()
};
let store_a = ConversationStore::new(root.path(), ws_a.path(), 100).unwrap();
let a = store_a.create("in A", None).unwrap();
store_a.append_turn(&a, "q", "a").unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
let b = store_b.create("in B", None).unwrap();
store_b.append_turn(&b, "q", "a").unwrap();
assert_eq!(store_a.list().unwrap().len(), 1);
assert_eq!(store_b.list().unwrap().len(), 1);
let all = store_a.list_all().unwrap();
assert_eq!(all.len(), 2, "both workspaces' conversations");
let path_of = |id: &str| {
all.iter()
.find(|(s, _)| s.id == id)
.map(|(_, p)| p.clone())
.unwrap()
};
assert_eq!(path_of(&a), canon(ws_a.path()));
assert_eq!(path_of(&b), canon(ws_b.path()));
let follower = ConversationStore::new(root.path(), path_of(&b), 100).unwrap();
assert_eq!(follower.load(&b).unwrap().title, "in B");
}
#[test]
fn inbox_inject_take_is_exactly_once_fifo_idempotent_and_fenced() {
let root = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let conv = store.create("session", None).unwrap();
assert_eq!(store.take_injected_prompt(&conv).unwrap(), None);
assert_eq!(
store.inject_prompt(&conv, "first", None).unwrap(),
InjectOutcome::Enqueued
);
assert_eq!(
store.inject_prompt(&conv, "second", None).unwrap(),
InjectOutcome::Enqueued
);
assert_eq!(
store.take_injected_prompt(&conv).unwrap().unwrap().body,
"first"
);
assert_eq!(
store.take_injected_prompt(&conv).unwrap().unwrap().body,
"second"
);
assert_eq!(
store.take_injected_prompt(&conv).unwrap(),
None,
"drained exactly once"
);
assert_eq!(
store.inject_prompt(&conv, "again", Some("k1")).unwrap(),
InjectOutcome::Enqueued
);
assert_eq!(
store.inject_prompt(&conv, "again", Some("k1")).unwrap(),
InjectOutcome::Duplicate
);
assert_eq!(
store.take_injected_prompt(&conv).unwrap().unwrap().body,
"again"
);
assert_eq!(
store.take_injected_prompt(&conv).unwrap(),
None,
"the idem duplicate did not enqueue twice"
);
store.inject_prompt(&conv, "linked", None).unwrap();
let taken = store.take_injected_prompt(&conv).unwrap().unwrap();
store.link_inbox_delivery(&taken.id, "receipt-123").unwrap();
let ws_b = tempfile::tempdir().unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
assert!(
store_b.inject_prompt(&conv, "cross-ws", None).is_err(),
"cross-workspace inject is rejected"
);
store.inject_prompt(&conv, "mine", None).unwrap();
assert_eq!(
store_b.take_injected_prompt(&conv).unwrap(),
None,
"cross-workspace take sees nothing"
);
assert!(
store.take_injected_prompt(&conv).unwrap().is_some(),
"the owning workspace still dequeues it"
);
}
#[test]
fn permission_request_expires_on_created_tick_ttl() {
let root = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
let mut store = ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let conv = store.create("session", None).unwrap();
store.set_claim_clock_for_test(|| 0);
let r1 = store
.publish_permission_request(&conv, "[]", r#"["low"]"#)
.unwrap();
assert!(
store.pending_permission_request(&conv).unwrap().is_some(),
"a fresh request is pending"
);
store.set_claim_clock_for_test(|| ConversationStore::PERMISSION_REQUEST_TTL_NANOS + 1);
assert_eq!(
store.pending_permission_request(&conv).unwrap(),
None,
"an aged-out request is not surfaced"
);
assert_eq!(
store
.answer_permission_request(&conv, &r1, Verdict::AllowOnce)
.unwrap(),
AnswerOutcome::Unknown,
"answering an expired request is rejected as gone"
);
let r2 = store
.publish_permission_request(&conv, "[]", r#"["low"]"#)
.unwrap();
assert!(store.pending_permission_request(&conv).unwrap().is_some());
assert_eq!(
store
.answer_permission_request(&conv, &r2, Verdict::AllowOnce)
.unwrap(),
AnswerOutcome::Answered,
"a within-TTL request still answers"
);
}
#[test]
fn permission_channel_publish_answer_take_race_and_fence() {
let root = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let conv = store.create("session", None).unwrap();
assert_eq!(store.pending_permission_request(&conv).unwrap(), None);
let r1 = store
.publish_permission_request(&conv, r#"[{"tool":"run_command"}]"#, r#"["low"]"#)
.unwrap();
let pending = store.pending_permission_request(&conv).unwrap().unwrap();
assert_eq!(pending.request_id, r1);
assert_eq!(pending.danger_json, r#"["low"]"#);
assert_eq!(store.take_permission_decision(&conv, &r1).unwrap(), None);
assert_eq!(
store
.answer_permission_request(&conv, &r1, Verdict::AllowSession)
.unwrap(),
AnswerOutcome::Answered
);
assert_eq!(
store.take_permission_decision(&conv, &r1).unwrap(),
Some(Verdict::AllowSession)
);
assert_eq!(
store.take_permission_decision(&conv, &r1).unwrap(),
None,
"a resolved request yields the verdict exactly once"
);
assert_eq!(
store.pending_permission_request(&conv).unwrap(),
None,
"an answered request is no longer pending"
);
assert_eq!(
store
.answer_permission_request(&conv, &r1, Verdict::Deny)
.unwrap(),
AnswerOutcome::AlreadyResolved
);
let r2 = store.publish_permission_request(&conv, "[]", "[]").unwrap();
assert!(
store.resolve_permission_request(&conv, &r2, "tty").unwrap(),
"the TTY resolve wins when unresolved"
);
assert!(
!store.resolve_permission_request(&conv, &r2, "tty").unwrap(),
"resolving an already-resolved request loses"
);
assert_eq!(
store
.answer_permission_request(&conv, &r2, Verdict::AllowOnce)
.unwrap(),
AnswerOutcome::AlreadyResolved,
"a web answer after the TTY won is a no-op"
);
assert_eq!(
store.take_permission_decision(&conv, &r2).unwrap(),
None,
"no web verdict applies once the TTY resolved it"
);
let ws_b = tempfile::tempdir().unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
let r3 = store.publish_permission_request(&conv, "[]", "[]").unwrap();
assert!(
!store_b
.resolve_permission_request(&conv, &r3, "expired")
.unwrap(),
"a cross-workspace resolver must not win"
);
assert_eq!(
store
.answer_permission_request(&conv, &r3, Verdict::AllowOnce)
.unwrap(),
AnswerOutcome::Answered
);
let answered_by: String = store
.lock_conn()
.query_row(
"SELECT answered_by FROM permission_requests WHERE request_id = ?1",
[&r3],
|row| row.get(0),
)
.unwrap();
assert_eq!(answered_by, "web");
assert!(
!store
.resolve_permission_request(&conv, &r3, "expired")
.unwrap(),
"timeout must not clobber a recorded web answer"
);
assert_eq!(
store.take_permission_decision(&conv, &r3).unwrap(),
Some(Verdict::AllowOnce)
);
let r4 = store.publish_permission_request(&conv, "[]", "[]").unwrap();
let r5 = store.publish_permission_request(&conv, "[]", "[]").unwrap();
store
.answer_permission_request(&conv, &r4, Verdict::AllowOnce)
.unwrap();
assert_eq!(store.take_permission_decision(&conv, &r5).unwrap(), None);
assert_eq!(
store.take_permission_decision(&conv, &r4).unwrap(),
Some(Verdict::AllowOnce)
);
assert_eq!(
store
.answer_permission_request(&conv, "no-such-id", Verdict::Deny)
.unwrap(),
AnswerOutcome::Unknown
);
let ws_b = tempfile::tempdir().unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
assert!(
store_b
.publish_permission_request(&conv, "[]", "[]")
.is_err(),
"cross-workspace publish is rejected"
);
let r5 = store.publish_permission_request(&conv, "[]", "[]").unwrap();
assert_eq!(
store_b.pending_permission_request(&conv).unwrap(),
None,
"cross-workspace pending read sees nothing"
);
assert_eq!(
store_b
.answer_permission_request(&conv, &r5, Verdict::AllowOnce)
.unwrap(),
AnswerOutcome::Unknown,
"cross-workspace answer cannot resolve it"
);
}
#[test]
fn end_conversation_does_not_tick_activity_and_is_idempotent() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let older = store.create("older", None).unwrap();
store.append_turn(&older, "q", "a").unwrap();
let newer = store.create("newer", None).unwrap();
store.append_turn(&newer, "q", "a").unwrap();
let tick_of = |id: &str| -> i64 {
let conn = store.lock_conn();
conn.query_row(
"SELECT activity_tick FROM conversations WHERE id = ?1",
[id],
|row| row.get(0),
)
.unwrap()
};
let before = tick_of(&older);
store.end_conversation(&older, "new").unwrap();
assert_eq!(tick_of(&older), before, "ending must not bump the tick");
store.end_conversation(&older, "new").unwrap();
assert_eq!(store.latest_open().unwrap().unwrap().id, newer);
}
fn s(raw: &str) -> String {
sanitize_fts5_query(raw).unwrap()
}
#[test]
fn sanitizer_auto_quotes_dotted_hyphenated_and_path_tokens() {
assert_eq!(s("chat-send"), "\"chat-send\"");
assert_eq!(s("P2.2"), "\"P2.2\"");
assert_eq!(s("my-app.config.ts"), "\"my-app.config.ts\"");
assert_eq!(s("src/store.rs"), "\"src/store.rs\"");
assert_eq!(s("tcp:p4d.p4d-ascii:1666"), "\"tcp:p4d.p4d-ascii:1666\"");
assert_eq!(s("issue #246"), "issue \"#246\"");
assert_eq!(s("hello world"), "hello world");
assert_eq!(s("writer_clock"), "writer_clock");
assert_eq!(s("schlüssel wörter"), "schlüssel wörter");
}
#[test]
fn sanitizer_preserves_balanced_phrases_and_drops_dangling_quotes() {
assert_eq!(s("\"exact phrase\" extra"), "\"exact phrase\" extra");
assert_eq!(s("say \"hello world\" now"), "say \"hello world\" now");
assert_eq!(s("foo \"bar"), "foo bar");
assert_eq!(s("\"unclosed"), "unclosed");
assert_eq!(s("\"a b\" \"c"), "\"a b\" c");
assert_eq!(s("\"AND OR\""), "\"AND OR\"");
assert_eq!(s("\"P2.2 chat-send\""), "\"P2.2 chat-send\"");
let err = sanitize_fts5_query("\"\"").unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{err}");
let err = sanitize_fts5_query("\"--\"").unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{err}");
}
#[test]
fn sanitizer_trims_dangling_operators() {
assert_eq!(s("foo AND"), "foo");
assert_eq!(s("OR foo"), "foo");
assert_eq!(s("NOT foo"), "foo");
assert_eq!(s("foo AND AND bar"), "foo AND bar");
assert_eq!(s("foo AND OR bar"), "foo AND bar");
assert_eq!(s("AND foo OR"), "foo");
assert_eq!(s("foo OR bar"), "foo OR bar");
assert_eq!(s("foo NOT bar"), "foo NOT bar");
assert_eq!(s("a OR b OR c"), "a OR b OR c");
assert_eq!(s("foo and bar"), "foo and bar");
let err = sanitize_fts5_query("AND").unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{err}");
assert_eq!(s("NEAR"), "\"NEAR\"");
assert_eq!(s("near"), "near");
}
#[test]
fn sanitizer_strips_metacharacter_injection() {
assert_eq!(s("(foo OR bar) AND baz"), "foo OR bar AND baz");
assert_eq!(s("foo* ^bar"), "foo bar");
assert_eq!(s("col*umn"), "column");
for q in ["\"", "*", "^", "( )", "*^()"] {
let err = sanitize_fts5_query(q).unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
}
assert_eq!(s("fo\"o bar"), "fo o bar");
assert_eq!(s("?? foo !!"), "foo");
assert_eq!(s("foo \u{a0} "), "foo"); }
#[test]
fn sanitizer_handles_mixed_phrases_terms_and_operators() {
assert_eq!(
s("\"tuning writeback\" OR coverage-floor"),
"\"tuning writeback\" OR \"coverage-floor\""
);
assert_eq!(
s("error \"chain violation\" NOT P2.2"),
"error \"chain violation\" NOT \"P2.2\""
);
assert_eq!(s("AND \"lead phrase\" tail"), "\"lead phrase\" tail");
}
#[test]
fn sanitizer_errors_on_empty_and_whitespace_queries() {
for q in ["", " ", "\t\n"] {
let err = sanitize_fts5_query(q).unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
}
}
#[test]
fn events_extract_sql_guards_and_targets_the_seam_keys() {
let sql = events_extract_sql("new.events", "tool");
assert!(sql.contains("json_valid(new.events)"));
assert!(sql.contains("json_each(new.events)"));
assert!(sql.contains("'$.tool'"));
assert!(sql.contains("ELSE '' END"));
}
#[test]
fn create_roadmap_is_workspace_fenced_and_never_steals() {
let root = tempfile::TempDir::new().unwrap();
let ws_a = tempfile::TempDir::new().unwrap();
let ws_b = tempfile::TempDir::new().unwrap();
let store_a = ConversationStore::new(root.path(), ws_a.path(), 100).unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
let id = "1783727322129749288-shared";
store_a
.create_roadmap(id, "A's roadmap", &crate::plan::Plan::default())
.unwrap();
store_b
.create_roadmap(id, "B's roadmap", &crate::plan::Plan::default())
.unwrap();
assert_eq!(
store_a.load_roadmap(id).unwrap().unwrap().title,
"A's roadmap",
"workspace A's roadmap must survive B's import of the same id"
);
assert_eq!(
store_b.load_roadmap(id).unwrap().unwrap().title,
"B's roadmap"
);
assert_eq!(store_a.list_roadmaps().unwrap().len(), 1);
assert_eq!(store_b.list_roadmaps().unwrap().len(), 1);
}
#[test]
fn create_roadmap_overwrites_within_the_same_workspace() {
let root = tempfile::TempDir::new().unwrap();
let ws = tempfile::TempDir::new().unwrap();
let store = ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let id = "rm-1";
store
.create_roadmap(id, "first", &crate::plan::Plan::default())
.unwrap();
store
.create_roadmap(id, "second", &crate::plan::Plan::default())
.unwrap();
assert_eq!(store.load_roadmap(id).unwrap().unwrap().title, "second");
assert_eq!(store.list_roadmaps().unwrap().len(), 1);
}
#[test]
fn migrate_roadmaps_pk_rebuilds_legacy_table_losslessly() {
let dir = tempfile::TempDir::new().unwrap();
let conn = Connection::open(dir.path().join("t.db")).unwrap();
conn.execute_batch(
"CREATE TABLE roadmaps (
id TEXT PRIMARY KEY,
workspace_key TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
tree TEXT NOT NULL DEFAULT '',
schema_version INTEGER NOT NULL DEFAULT 1,
created_at_claim INTEGER NOT NULL DEFAULT 0,
updated_at_claim INTEGER NOT NULL DEFAULT 0
);
INSERT INTO roadmaps (id, workspace_key, title) VALUES ('x', 'wsA', 'kept');",
)
.unwrap();
migrate_roadmaps_pk(&conn).unwrap();
let sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='roadmaps'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
sql.to_ascii_lowercase()
.contains("primary key (id, workspace_key)"),
"PK must be composite after migration: {sql}"
);
let title: String = conn
.query_row("SELECT title FROM roadmaps WHERE id='x'", [], |r| r.get(0))
.unwrap();
assert_eq!(title, "kept");
conn.execute(
"INSERT INTO roadmaps (id, workspace_key, title) VALUES ('x', 'wsB', 'other')",
[],
)
.unwrap();
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM roadmaps WHERE id='x'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(n, 2, "same id can coexist across workspaces");
migrate_roadmaps_pk(&conn).unwrap();
let n2: i64 = conn
.query_row("SELECT COUNT(*) FROM roadmaps", [], |r| r.get(0))
.unwrap();
assert_eq!(n2, 2);
}
#[test]
fn fresh_store_roadmaps_table_is_already_composite() {
let root = tempfile::TempDir::new().unwrap();
let ws = tempfile::TempDir::new().unwrap();
let _store = ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let conn = Connection::open(root.path().join(DB_FILE)).unwrap();
let sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='roadmaps'",
[],
|r| r.get(0),
)
.unwrap();
assert!(sql
.to_ascii_lowercase()
.contains("primary key (id, workspace_key)"));
}
}