merge: sqlite memory authority

This commit is contained in:
Keisuke Hirata 2026-07-26 14:29:28 +09:00
commit 386152749a
No known key found for this signature in database
3 changed files with 656 additions and 29 deletions

View File

@ -1,5 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use chrono::Utc;
use ticket::{ use ticket::{
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery, SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
TicketWorkspaceActionPriority, project_ticket_workspace_item, TicketWorkspaceActionPriority, project_ticket_workspace_item,
@ -9,22 +11,24 @@ use crate::records::{
ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail, ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail,
TicketSummary, summarize_body, truncate_body, validate_project_id, TicketSummary, summarize_body, truncate_body, validate_project_id,
}; };
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore}; use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
SqliteWorkspaceStore,
};
use crate::{Error, Result}; use crate::{Error, Result};
const DETAIL_BODY_LIMIT: usize = 64 * 1024; const DETAIL_BODY_LIMIT: usize = 64 * 1024;
const DEFAULT_MEMORY_DOCUMENT_BODY: &str = "# Memory\n\n";
const RECORD_SOURCE_WORKSPACE_SQLITE: &str = "workspace-sqlite";
/// Workspace-scoped runtime authority for project resources. /// Workspace-scoped runtime authority for project resources.
/// ///
/// Normal Backend API handlers should depend on this authority abstraction, not /// Normal Backend API handlers should depend on this authority abstraction, not
/// on legacy filesystem layouts. Filesystem readers belong in explicitly /// on legacy filesystem layouts. Filesystem readers belong in explicitly
/// temporary migration/repair tools and tests, not normal runtime authority paths. /// temporary migration/repair tools and tests, not normal runtime authority paths.
pub trait WorkspaceAuthority: ObjectiveAuthority + TicketAuthority + MemoryAuthorityMarker {} pub trait WorkspaceAuthority: ObjectiveAuthority + TicketAuthority + MemoryAuthority {}
impl<T> WorkspaceAuthority for T where impl<T> WorkspaceAuthority for T where T: ObjectiveAuthority + TicketAuthority + MemoryAuthority {}
T: ObjectiveAuthority + TicketAuthority + MemoryAuthorityMarker
{
}
pub trait TicketAuthority { pub trait TicketAuthority {
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>>; fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>>;
@ -36,19 +40,57 @@ pub trait ObjectiveAuthority {
fn objective(&self, id: &str) -> Result<ObjectiveDetail>; fn objective(&self, id: &str) -> Result<ObjectiveDetail>;
} }
/// Marker for the pending Memory authority split. pub trait MemoryAuthority {
/// fn ensure_memory_document(&self) -> Result<MemoryDocument>;
/// Memory still routes through the legacy `memory` crate backend in this codebase. fn memory_document(&self) -> Result<MemoryDocument>;
/// This marker makes the missing SQL-backed Memory authority explicit so callers fn update_memory_document(&self, body_md: &str) -> Result<MemoryDocument>;
/// do not mistake the current Backend API boundary for a completed storage fn list_memory_staging_records(&self, limit: usize) -> Result<Vec<MemoryStagingEntry>>;
/// authority migration. fn memory_staging_record(&self, candidate_id: &str) -> Result<MemoryStagingEntry>;
pub trait MemoryAuthorityMarker { fn upsert_memory_staging_record(
fn memory_authority_status(&self) -> MemoryAuthorityStatus; &self,
candidate_id: &str,
raw_json: &str,
source_path: Option<&str>,
) -> Result<MemoryStagingEntry>;
fn close_memory_staging_record(
&self,
candidate_id: &str,
action: &str,
reason: &str,
affected_refs_json: &str,
) -> Result<MemoryStagingResolution>;
fn list_memory_staging_resolutions(&self, limit: usize)
-> Result<Vec<MemoryStagingResolution>>;
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemoryAuthorityStatus { pub struct MemoryDocument {
LegacyFilesystemBackendPendingSqliteAuthority, pub body_md: String,
pub created_at: String,
pub updated_at: String,
pub record_source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryStagingEntry {
pub candidate_id: String,
pub raw_json: String,
pub source_path: Option<String>,
pub imported_at: String,
pub record_source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryStagingResolution {
pub candidate_id: String,
pub action: String,
pub reason: String,
pub affected_refs_json: String,
pub staging_raw_json: String,
pub source_path: Option<String>,
pub imported_at: String,
pub resolved_at: String,
pub record_source: String,
} }
#[derive(Clone)] #[derive(Clone)]
@ -196,9 +238,192 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
} }
} }
impl MemoryAuthorityMarker for SqliteWorkspaceAuthority { impl MemoryAuthority for SqliteWorkspaceAuthority {
fn memory_authority_status(&self) -> MemoryAuthorityStatus { fn ensure_memory_document(&self) -> Result<MemoryDocument> {
MemoryAuthorityStatus::LegacyFilesystemBackendPendingSqliteAuthority let now = now_rfc3339();
self.store
.ensure_memory_document(&self.workspace_id, DEFAULT_MEMORY_DOCUMENT_BODY, &now)
.map(memory_document_from_record)
}
fn memory_document(&self) -> Result<MemoryDocument> {
self.store
.get_memory_document(&self.workspace_id)?
.map(memory_document_from_record)
.ok_or_else(|| Error::Store("workspace memory document is not initialized".to_string()))
}
fn update_memory_document(&self, body_md: &str) -> Result<MemoryDocument> {
let existing = self.ensure_memory_document()?;
let updated_at = now_rfc3339();
let record = MemoryDocumentRecord {
workspace_id: self.workspace_id.clone(),
body_md: body_md.to_string(),
created_at: existing.created_at,
updated_at,
};
self.store.upsert_memory_document(&record)?;
Ok(memory_document_from_record(record))
}
fn list_memory_staging_records(&self, limit: usize) -> Result<Vec<MemoryStagingEntry>> {
self.store
.list_memory_staging_records(&self.workspace_id, limit)
.map(|records| {
records
.into_iter()
.map(memory_staging_from_record)
.collect()
})
}
fn memory_staging_record(&self, candidate_id: &str) -> Result<MemoryStagingEntry> {
validate_memory_candidate_id(candidate_id)?;
self.store
.get_memory_staging_record(&self.workspace_id, candidate_id)?
.map(memory_staging_from_record)
.ok_or_else(|| {
Error::Store(format!("unknown memory staging candidate '{candidate_id}'"))
})
}
fn upsert_memory_staging_record(
&self,
candidate_id: &str,
raw_json: &str,
source_path: Option<&str>,
) -> Result<MemoryStagingEntry> {
validate_memory_candidate_id(candidate_id)?;
validate_json_object(raw_json, "raw_json")?;
let imported_at = now_rfc3339();
let record = MemoryStagingRecord {
workspace_id: self.workspace_id.clone(),
candidate_id: candidate_id.to_string(),
raw_json: raw_json.to_string(),
source_path: source_path.map(str::to_string),
imported_at,
};
self.store.upsert_memory_staging_record(&record)?;
Ok(memory_staging_from_record(record))
}
fn close_memory_staging_record(
&self,
candidate_id: &str,
action: &str,
reason: &str,
affected_refs_json: &str,
) -> Result<MemoryStagingResolution> {
validate_memory_candidate_id(candidate_id)?;
validate_non_empty(action, "action")?;
validate_non_empty(reason, "reason")?;
validate_json_array(affected_refs_json, "affected_refs_json")?;
let staging = self
.store
.get_memory_staging_record(&self.workspace_id, candidate_id)?
.ok_or_else(|| {
Error::Store(format!("unknown memory staging candidate '{candidate_id}'"))
})?;
let record = MemoryStagingResolutionRecord {
workspace_id: self.workspace_id.clone(),
candidate_id: staging.candidate_id,
action: action.trim().to_string(),
reason: reason.trim().to_string(),
affected_refs_json: affected_refs_json.to_string(),
staging_raw_json: staging.raw_json,
source_path: staging.source_path,
imported_at: staging.imported_at,
resolved_at: now_rfc3339(),
};
self.store.insert_memory_staging_resolution(&record)?;
self.store
.delete_memory_staging_record(&self.workspace_id, candidate_id)?;
Ok(memory_resolution_from_record(record))
}
fn list_memory_staging_resolutions(
&self,
limit: usize,
) -> Result<Vec<MemoryStagingResolution>> {
self.store
.list_memory_staging_resolutions(&self.workspace_id, limit)
.map(|records| {
records
.into_iter()
.map(memory_resolution_from_record)
.collect()
})
}
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn validate_memory_candidate_id(candidate_id: &str) -> Result<()> {
validate_non_empty(candidate_id, "candidate_id")
}
fn validate_non_empty(value: &str, field: &str) -> Result<()> {
if value.trim().is_empty() {
Err(Error::Store(format!("memory {field} must not be empty")))
} else {
Ok(())
}
}
fn validate_json_object(raw_json: &str, field: &str) -> Result<()> {
let value: serde_json::Value = serde_json::from_str(raw_json)
.map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?;
if value.is_object() {
Ok(())
} else {
Err(Error::Store(format!(
"memory {field} must be a JSON object"
)))
}
}
fn validate_json_array(raw_json: &str, field: &str) -> Result<()> {
let value: serde_json::Value = serde_json::from_str(raw_json)
.map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?;
if value.is_array() {
Ok(())
} else {
Err(Error::Store(format!("memory {field} must be a JSON array")))
}
}
fn memory_document_from_record(record: MemoryDocumentRecord) -> MemoryDocument {
MemoryDocument {
body_md: record.body_md,
created_at: record.created_at,
updated_at: record.updated_at,
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
}
}
fn memory_staging_from_record(record: MemoryStagingRecord) -> MemoryStagingEntry {
MemoryStagingEntry {
candidate_id: record.candidate_id,
raw_json: record.raw_json,
source_path: record.source_path,
imported_at: record.imported_at,
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
}
}
fn memory_resolution_from_record(record: MemoryStagingResolutionRecord) -> MemoryStagingResolution {
MemoryStagingResolution {
candidate_id: record.candidate_id,
action: record.action,
reason: record.reason,
affected_refs_json: record.affected_refs_json,
staging_raw_json: record.staging_raw_json,
source_path: record.source_path,
imported_at: record.imported_at,
resolved_at: record.resolved_at,
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
} }
} }
@ -293,9 +518,36 @@ mod tests {
let objective = authority.objective("00000000001J3").unwrap(); let objective = authority.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body")); assert!(objective.body.contains("Objective body"));
let memory = authority.ensure_memory_document().unwrap();
assert_eq!(memory.body_md, DEFAULT_MEMORY_DOCUMENT_BODY);
let updated = authority
.update_memory_document("# Memory\n\n- Durable fact.\n")
.unwrap();
assert!(updated.body_md.contains("Durable fact"));
let staging = authority
.upsert_memory_staging_record(
"candidate-1",
r#"{"kind":"summary","body_md":"Candidate"}"#,
Some("memory/_staging/candidate-1.json"),
)
.unwrap();
assert_eq!(staging.record_source, "workspace-sqlite");
assert_eq!(authority.list_memory_staging_records(20).unwrap().len(), 1);
let resolution = authority
.close_memory_staging_record("candidate-1", "apply", "accepted", r#"["memory"]"#)
.unwrap();
assert_eq!(resolution.action, "apply");
assert_eq!(resolution.reason, "accepted");
assert!(
authority
.list_memory_staging_records(20)
.unwrap()
.is_empty()
);
assert_eq!( assert_eq!(
authority.memory_authority_status(), authority.list_memory_staging_resolutions(20).unwrap().len(),
MemoryAuthorityStatus::LegacyFilesystemBackendPendingSqliteAuthority 1
); );
} }

View File

@ -21,8 +21,8 @@ pub mod skills;
pub mod store; pub mod store;
pub use authority::{ pub use authority::{
MemoryAuthorityMarker, MemoryAuthorityStatus, ObjectiveAuthority, SqliteWorkspaceAuthority, MemoryAuthority, MemoryDocument, MemoryStagingEntry, MemoryStagingResolution,
TicketAuthority, WorkspaceAuthority, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, WorkspaceAuthority,
}; };
pub use config::{ pub use config::{
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig, BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,

View File

@ -67,6 +67,11 @@ const MIGRATIONS: &[Migration] = &[
name: "sqlite objective authority and memory staging import", name: "sqlite objective authority and memory staging import",
apply: create_objective_sqlite_authority_tables, apply: create_objective_sqlite_authority_tables,
}, },
Migration {
version: 11,
name: "sqlite memory authority documents and staging resolutions",
apply: create_memory_authority_tables,
},
]; ];
struct Migration { struct Migration {
@ -257,6 +262,14 @@ pub struct ObjectiveResourceRecord {
pub updated_at: String, pub updated_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryDocumentRecord {
pub workspace_id: String,
pub body_md: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryStagingRecord { pub struct MemoryStagingRecord {
pub workspace_id: String, pub workspace_id: String,
@ -266,6 +279,19 @@ pub struct MemoryStagingRecord {
pub imported_at: String, pub imported_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryStagingResolutionRecord {
pub workspace_id: String,
pub candidate_id: String,
pub action: String,
pub reason: String,
pub affected_refs_json: String,
pub staging_raw_json: String,
pub source_path: Option<String>,
pub imported_at: String,
pub resolved_at: String,
}
#[async_trait] #[async_trait]
pub trait ControlPlaneStore: Send + Sync { pub trait ControlPlaneStore: Send + Sync {
async fn schema_version(&self) -> Result<i64>; async fn schema_version(&self) -> Result<i64>;
@ -299,8 +325,36 @@ pub trait ControlPlaneStore: Send + Sync {
workspace_id: &str, workspace_id: &str,
objective_id: &str, objective_id: &str,
) -> Result<Vec<ObjectiveResourceRecord>>; ) -> Result<Vec<ObjectiveResourceRecord>>;
fn ensure_memory_document(
&self,
workspace_id: &str,
default_body_md: &str,
now: &str,
) -> Result<MemoryDocumentRecord>;
fn get_memory_document(&self, workspace_id: &str) -> Result<Option<MemoryDocumentRecord>>;
fn upsert_memory_document(&self, record: &MemoryDocumentRecord) -> Result<()>;
fn upsert_memory_staging_record(&self, record: &MemoryStagingRecord) -> Result<()>; fn upsert_memory_staging_record(&self, record: &MemoryStagingRecord) -> Result<()>;
fn list_memory_staging_records(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<MemoryStagingRecord>>;
fn get_memory_staging_record(
&self,
workspace_id: &str,
candidate_id: &str,
) -> Result<Option<MemoryStagingRecord>>;
fn delete_memory_staging_record(&self, workspace_id: &str, candidate_id: &str) -> Result<bool>;
fn count_memory_staging_records(&self, workspace_id: &str) -> Result<usize>; fn count_memory_staging_records(&self, workspace_id: &str) -> Result<usize>;
fn insert_memory_staging_resolution(
&self,
record: &MemoryStagingResolutionRecord,
) -> Result<()>;
fn list_memory_staging_resolutions(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<MemoryStagingResolutionRecord>>;
fn upsert_account(&self, record: &AccountRecord) -> Result<()>; fn upsert_account(&self, record: &AccountRecord) -> Result<()>;
fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>; fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>;
@ -712,6 +766,64 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn ensure_memory_document(
&self,
workspace_id: &str,
default_body_md: &str,
now: &str,
) -> Result<MemoryDocumentRecord> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT OR IGNORE INTO workspace_memory_documents (
workspace_id, body_md, created_at, updated_at
) VALUES (?1, ?2, ?3, ?3)"#,
params![workspace_id, default_body_md, now],
)?;
conn.query_row(
r#"SELECT workspace_id, body_md, created_at, updated_at
FROM workspace_memory_documents
WHERE workspace_id = ?1"#,
params![workspace_id],
read_memory_document_record,
)
.map_err(Error::from)
})
}
fn get_memory_document(&self, workspace_id: &str) -> Result<Option<MemoryDocumentRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, body_md, created_at, updated_at
FROM workspace_memory_documents
WHERE workspace_id = ?1"#,
params![workspace_id],
read_memory_document_record,
)
.optional()
.map_err(Error::from)
})
}
fn upsert_memory_document(&self, record: &MemoryDocumentRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO workspace_memory_documents (
workspace_id, body_md, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(workspace_id) DO UPDATE SET
body_md = excluded.body_md,
updated_at = excluded.updated_at"#,
params![
record.workspace_id,
record.body_md,
record.created_at,
record.updated_at,
],
)?;
Ok(())
})
}
fn upsert_memory_staging_record(&self, record: &MemoryStagingRecord) -> Result<()> { fn upsert_memory_staging_record(&self, record: &MemoryStagingRecord) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
@ -734,6 +846,56 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn list_memory_staging_records(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<MemoryStagingRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, candidate_id, raw_json, source_path, imported_at
FROM memory_staging_records
WHERE workspace_id = ?1
ORDER BY imported_at DESC, candidate_id ASC
LIMIT ?2"#,
)?;
let rows = stmt.query_map(
params![workspace_id, limit as i64],
read_memory_staging_record,
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn get_memory_staging_record(
&self,
workspace_id: &str,
candidate_id: &str,
) -> Result<Option<MemoryStagingRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, candidate_id, raw_json, source_path, imported_at
FROM memory_staging_records
WHERE workspace_id = ?1 AND candidate_id = ?2"#,
params![workspace_id, candidate_id],
read_memory_staging_record,
)
.optional()
.map_err(Error::from)
})
}
fn delete_memory_staging_record(&self, workspace_id: &str, candidate_id: &str) -> Result<bool> {
self.with_conn(|conn| {
let changed = conn.execute(
"DELETE FROM memory_staging_records WHERE workspace_id = ?1 AND candidate_id = ?2",
params![workspace_id, candidate_id],
)?;
Ok(changed > 0)
})
}
fn count_memory_staging_records(&self, workspace_id: &str) -> Result<usize> { fn count_memory_staging_records(&self, workspace_id: &str) -> Result<usize> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.query_row( conn.query_row(
@ -746,6 +908,55 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn insert_memory_staging_resolution(
&self,
record: &MemoryStagingResolutionRecord,
) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO memory_staging_resolutions (
workspace_id, candidate_id, action, reason, affected_refs_json,
staging_raw_json, source_path, imported_at, resolved_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#,
params![
record.workspace_id,
record.candidate_id,
record.action,
record.reason,
record.affected_refs_json,
record.staging_raw_json,
record.source_path,
record.imported_at,
record.resolved_at,
],
)?;
Ok(())
})
}
fn list_memory_staging_resolutions(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<MemoryStagingResolutionRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, candidate_id, action, reason, affected_refs_json,
staging_raw_json, source_path, imported_at, resolved_at
FROM memory_staging_resolutions
WHERE workspace_id = ?1
ORDER BY resolved_at DESC, candidate_id ASC
LIMIT ?2"#,
)?;
let rows = stmt.query_map(
params![workspace_id, limit as i64],
read_memory_staging_resolution_record,
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn upsert_account(&self, record: &AccountRecord) -> Result<()> { fn upsert_account(&self, record: &AccountRecord) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
@ -1569,6 +1780,41 @@ fn read_objective_resource_record(
}) })
} }
fn read_memory_document_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryDocumentRecord> {
Ok(MemoryDocumentRecord {
workspace_id: row.get(0)?,
body_md: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
})
}
fn read_memory_staging_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryStagingRecord> {
Ok(MemoryStagingRecord {
workspace_id: row.get(0)?,
candidate_id: row.get(1)?,
raw_json: row.get(2)?,
source_path: row.get(3)?,
imported_at: row.get(4)?,
})
}
fn read_memory_staging_resolution_record(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<MemoryStagingResolutionRecord> {
Ok(MemoryStagingResolutionRecord {
workspace_id: row.get(0)?,
candidate_id: row.get(1)?,
action: row.get(2)?,
reason: row.get(3)?,
affected_refs_json: row.get(4)?,
staging_raw_json: row.get(5)?,
source_path: row.get(6)?,
imported_at: row.get(7)?,
resolved_at: row.get(8)?,
})
}
fn worker_registry_select_sql(where_clause: &str) -> String { fn worker_registry_select_sql(where_clause: &str) -> String {
format!( format!(
"SELECT workspace_id, runtime_id, runtime_worker_id, display_name, profile, \ "SELECT workspace_id, runtime_id, runtime_worker_id, display_name, profile, \
@ -1732,6 +1978,33 @@ CREATE TABLE IF NOT EXISTS memory_staging_records (
Ok(()) Ok(())
} }
fn create_memory_authority_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS workspace_memory_documents (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
body_md TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memory_staging_resolutions (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
candidate_id TEXT NOT NULL,
action TEXT NOT NULL,
reason TEXT NOT NULL,
affected_refs_json TEXT NOT NULL,
staging_raw_json TEXT NOT NULL,
source_path TEXT,
imported_at TEXT NOT NULL,
resolved_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, candidate_id, resolved_at)
);
"#,
)?;
Ok(())
}
fn create_account_identity_tables(conn: &Connection) -> Result<()> { fn create_account_identity_tables(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
r#" r#"
@ -2293,6 +2566,26 @@ CREATE TABLE IF NOT EXISTS memory_staging_records (
PRIMARY KEY (workspace_id, candidate_id) PRIMARY KEY (workspace_id, candidate_id)
); );
CREATE TABLE IF NOT EXISTS workspace_memory_documents (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
body_md TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memory_staging_resolutions (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
candidate_id TEXT NOT NULL,
action TEXT NOT NULL,
reason TEXT NOT NULL,
affected_refs_json TEXT NOT NULL,
staging_raw_json TEXT NOT NULL,
source_path TEXT,
imported_at TEXT NOT NULL,
resolved_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, candidate_id, resolved_at)
);
CREATE TABLE IF NOT EXISTS repositories ( CREATE TABLE IF NOT EXISTS repositories (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
repository_id TEXT PRIMARY KEY, repository_id TEXT PRIMARY KEY,
@ -2424,7 +2717,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite"); let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap(); let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 10); assert_eq!(store.schema_version().await.unwrap(), 11);
let record = WorkspaceRecord { let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
@ -2437,7 +2730,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap(); store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 10); assert_eq!(reopened.schema_version().await.unwrap(), 11);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@ -2458,6 +2751,10 @@ mod tests {
"ticket_relations", "ticket_relations",
"objectives", "objectives",
"objective_ticket_links", "objective_ticket_links",
"objective_resources",
"memory_staging_records",
"workspace_memory_documents",
"memory_staging_resolutions",
"repositories", "repositories",
"ticket_targets", "ticket_targets",
"ticket_target_paths", "ticket_target_paths",
@ -2654,7 +2951,7 @@ mod tests {
.unwrap(); .unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 10); assert_eq!(store.schema_version().await.unwrap(), 11);
store store
.with_conn(|conn| { .with_conn(|conn| {
@ -2667,6 +2964,9 @@ mod tests {
"ticket_worker_links", "ticket_worker_links",
"artifacts", "artifacts",
"audit_events", "audit_events",
"workspace_memory_documents",
"memory_staging_records",
"memory_staging_resolutions",
"legacy_workspaces", "legacy_workspaces",
"legacy_repositories", "legacy_repositories",
"legacy_runs", "legacy_runs",
@ -2745,7 +3045,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn repository_records_round_trip() { async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 10); assert_eq!(store.schema_version().await.unwrap(), 11);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@ -2780,6 +3080,81 @@ mod tests {
); );
} }
#[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 11);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
display_name: "Local Dev".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
};
store.upsert_workspace(&workspace).await.unwrap();
let document = store
.ensure_memory_document("local-dev", "# Memory\n", "2")
.unwrap();
assert_eq!(document.body_md, "# Memory\n");
let updated = MemoryDocumentRecord {
workspace_id: "local-dev".to_string(),
body_md: "# Memory\n\n- fact\n".to_string(),
created_at: document.created_at.clone(),
updated_at: "3".to_string(),
};
store.upsert_memory_document(&updated).unwrap();
assert_eq!(
store.get_memory_document("local-dev").unwrap(),
Some(updated)
);
let staging = MemoryStagingRecord {
workspace_id: "local-dev".to_string(),
candidate_id: "candidate-a".to_string(),
raw_json: r#"{"kind":"summary","body":"candidate"}"#.to_string(),
source_path: Some("memory/_staging/candidate-a.json".to_string()),
imported_at: "4".to_string(),
};
store.upsert_memory_staging_record(&staging).unwrap();
assert_eq!(
store
.get_memory_staging_record("local-dev", "candidate-a")
.unwrap(),
Some(staging.clone())
);
assert_eq!(
store.list_memory_staging_records("local-dev", 10).unwrap(),
vec![staging.clone()]
);
let resolution = MemoryStagingResolutionRecord {
workspace_id: "local-dev".to_string(),
candidate_id: staging.candidate_id.clone(),
action: "apply".to_string(),
reason: "accepted".to_string(),
affected_refs_json: r#"["summary"]"#.to_string(),
staging_raw_json: staging.raw_json.clone(),
source_path: staging.source_path.clone(),
imported_at: staging.imported_at.clone(),
resolved_at: "5".to_string(),
};
store.insert_memory_staging_resolution(&resolution).unwrap();
assert!(
store
.delete_memory_staging_record("local-dev", "candidate-a")
.unwrap()
);
assert_eq!(store.count_memory_staging_records("local-dev").unwrap(), 0);
assert_eq!(
store
.list_memory_staging_resolutions("local-dev", 10)
.unwrap(),
vec![resolution]
);
}
#[tokio::test] #[tokio::test]
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() { async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@ -2882,7 +3257,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn account_and_login_records_round_trip() { async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 10); assert_eq!(store.schema_version().await.unwrap(), 11);
let now = "2026-07-22T00:00:00Z".to_string(); let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord { let account = AccountRecord {
account_id: "acct-user-alice".to_string(), account_id: "acct-user-alice".to_string(),