mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
feat(pi): add first-class Coding Agent support
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub(crate) mod pi_catalog;
|
||||
pub mod pi_projections;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
pub mod provider_write;
|
||||
@@ -13,6 +15,7 @@ pub mod providers;
|
||||
pub mod providers_seed;
|
||||
pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skill_deployments;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Transactional database half of Pi catalog coordination.
|
||||
//!
|
||||
//! Provider row/endpoint SQL remains owned by the certified provider-write
|
||||
//! primitives. This module only composes those primitives with Pi's exact-key
|
||||
//! ownership ledger in one SQLite transaction.
|
||||
|
||||
use super::pi_projections::PiProviderProjection;
|
||||
use super::provider_write::{
|
||||
insert_endpoint, insert_row, restore_provider_aggregate_on_tx, NewEndpoint,
|
||||
NewProviderAggregate, ProviderKey, ProviderRowUpdate,
|
||||
};
|
||||
use super::providers::delete_provider_on_tx;
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ProviderAggregate, ProviderMutationInput};
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn restore_pi_catalog_snapshot(
|
||||
&self,
|
||||
aggregates: &IndexMap<String, ProviderAggregate>,
|
||||
projections: &[PiProviderProjection],
|
||||
current_provider: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
tx.execute("DELETE FROM pi_provider_projections", [])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let current_ids = {
|
||||
let mut statement = tx
|
||||
.prepare("SELECT id FROM providers WHERE app_type = 'pi'")
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let ids = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
ids
|
||||
};
|
||||
for provider_id in current_ids
|
||||
.iter()
|
||||
.filter(|provider_id| !aggregates.contains_key(provider_id.as_str()))
|
||||
{
|
||||
// Only rows created after the snapshot are removed. Updating
|
||||
// providers which existed in the snapshot preserves dependent
|
||||
// provider_health history instead of triggering ON DELETE CASCADE.
|
||||
delete_provider_on_tx(&tx, "pi", provider_id)?;
|
||||
}
|
||||
|
||||
for aggregate in aggregates.values() {
|
||||
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
|
||||
let mut input = provider_mutation_input(aggregate);
|
||||
if let Some(meta) = input.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
let endpoints = aggregate
|
||||
.endpoints
|
||||
.values()
|
||||
.cloned()
|
||||
.map(NewEndpoint::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
restore_provider_aggregate_on_tx(
|
||||
&tx,
|
||||
&key,
|
||||
&row,
|
||||
aggregate.provider.created_at,
|
||||
aggregate.provider.sort_index,
|
||||
current_provider == Some(key.id()),
|
||||
aggregate.provider.in_failover_queue,
|
||||
&endpoints,
|
||||
)?;
|
||||
}
|
||||
for projection in projections {
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
projection.provider_id,
|
||||
projection.provider_key,
|
||||
projection.created_at,
|
||||
projection.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn create_pi_catalog_provider(
|
||||
&self,
|
||||
input: NewProviderAggregate,
|
||||
provider_key: &str,
|
||||
) -> Result<PiProviderProjection, AppError> {
|
||||
if input.key.app_type() != "pi" || provider_key.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi catalog create requires app_type=pi and a non-empty native key".to_string(),
|
||||
));
|
||||
}
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
insert_row(
|
||||
&tx,
|
||||
&input.key,
|
||||
&input.row.content,
|
||||
input.row.created_at,
|
||||
input.sort_index,
|
||||
false,
|
||||
input.in_failover_queue,
|
||||
)?;
|
||||
for endpoint in &input.initial_endpoints {
|
||||
insert_endpoint(&tx, &input.key, endpoint)?;
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)",
|
||||
params![input.key.id(), provider_key, now],
|
||||
)
|
||||
.map_err(|error| match &error {
|
||||
rusqlite::Error::SqliteFailure(code, _)
|
||||
if matches!(
|
||||
code.extended_code,
|
||||
rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
| rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
|
||||
) =>
|
||||
{
|
||||
AppError::Conflict(format!(
|
||||
"Pi native provider key '{provider_key}' is already claimed"
|
||||
))
|
||||
}
|
||||
_ => AppError::Database(error.to_string()),
|
||||
})?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: input.key.id().to_string(),
|
||||
provider_key: provider_key.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn update_pi_catalog_provider(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
) -> Result<(), AppError> {
|
||||
if key.app_type() != "pi" {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Pi catalog update requires app_type=pi".to_string(),
|
||||
));
|
||||
}
|
||||
self.update_provider(key, row)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_catalog_provider(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<PiProviderProjection>, AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let projection = tx
|
||||
.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections
|
||||
WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
|row| {
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: row.get(0)?,
|
||||
provider_key: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
delete_provider_on_tx(&tx, "pi", provider_id)?;
|
||||
tx.execute(
|
||||
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_pi_catalog_provider(
|
||||
&self,
|
||||
aggregate: &ProviderAggregate,
|
||||
was_current: bool,
|
||||
projection: Option<&PiProviderProjection>,
|
||||
) -> Result<(), AppError> {
|
||||
let key = ProviderKey::new("pi", aggregate.provider.id.clone())?;
|
||||
let mut input = provider_mutation_input(aggregate);
|
||||
if let Some(meta) = input.meta.as_mut() {
|
||||
meta.custom_endpoints.clear();
|
||||
}
|
||||
let row = ProviderRowUpdate::from_input(&input)?;
|
||||
let endpoints = aggregate
|
||||
.endpoints
|
||||
.values()
|
||||
.cloned()
|
||||
.map(NewEndpoint::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
restore_provider_aggregate_on_tx(
|
||||
&tx,
|
||||
&key,
|
||||
&row,
|
||||
aggregate.provider.created_at,
|
||||
aggregate.provider.sort_index,
|
||||
was_current,
|
||||
aggregate.provider.in_failover_queue,
|
||||
&endpoints,
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[key.id()],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(projection) = projection {
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
projection.provider_id,
|
||||
projection.provider_key,
|
||||
projection.created_at,
|
||||
projection.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_mutation_input(aggregate: &ProviderAggregate) -> ProviderMutationInput {
|
||||
let provider = &aggregate.provider;
|
||||
ProviderMutationInput {
|
||||
id: provider.id.clone(),
|
||||
name: provider.name.clone(),
|
||||
settings_config: provider.settings_config.clone(),
|
||||
website_url: provider.website_url.clone(),
|
||||
category: provider.category.clone(),
|
||||
created_at: provider.created_at,
|
||||
sort_index: provider.sort_index,
|
||||
notes: provider.notes.clone(),
|
||||
meta: provider.meta.clone(),
|
||||
icon: provider.icon.clone(),
|
||||
icon_color: provider.icon_color.clone(),
|
||||
in_failover_queue: provider.in_failover_queue,
|
||||
}
|
||||
}
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Device-local ownership ledger for exact keys in Pi's shared models.json.
|
||||
|
||||
// The projection writer is introduced in a later contract-ordered commit.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PiProviderProjection {
|
||||
pub provider_id: String,
|
||||
pub provider_key: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
fn decode_projection(row: &rusqlite::Row<'_>) -> rusqlite::Result<PiProviderProjection> {
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: row.get(0)?,
|
||||
provider_key: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn get_pi_projection(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<PiProviderProjection>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
decode_projection,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_projection_for_key(
|
||||
&self,
|
||||
provider_key: &str,
|
||||
) -> Result<Option<PiProviderProjection>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections WHERE provider_key = ?1",
|
||||
[provider_key],
|
||||
decode_projection,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_projection_manifest(
|
||||
&self,
|
||||
) -> Result<IndexMap<String, PiProviderProjection>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections ORDER BY provider_id",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], decode_projection)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let mut manifest = IndexMap::new();
|
||||
for row in rows {
|
||||
let projection = row.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
manifest.insert(projection.provider_id.clone(), projection);
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Claim an exact key. Existing exact claims are idempotent; either-side
|
||||
/// collisions fail and are never rewritten.
|
||||
pub(crate) fn claim_pi_projection_key(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_key: &str,
|
||||
) -> Result<PiProviderProjection, AppError> {
|
||||
if provider_id.trim().is_empty() || provider_key.trim().is_empty() {
|
||||
return Err(AppError::Config(
|
||||
"Pi projection provider id and key must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let by_provider = tx
|
||||
.query_row(
|
||||
"SELECT provider_id, provider_key, created_at, updated_at
|
||||
FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
decode_projection,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(existing) = by_provider {
|
||||
if existing.provider_key != provider_key {
|
||||
return Err(AppError::Config(format!(
|
||||
"Pi provider '{provider_id}' already owns key '{}', not '{provider_key}'",
|
||||
existing.provider_key
|
||||
)));
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
return Ok(existing);
|
||||
}
|
||||
if let Some(existing_owner) = tx
|
||||
.query_row(
|
||||
"SELECT provider_id FROM pi_provider_projections WHERE provider_key = ?1",
|
||||
[provider_key],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
{
|
||||
return Err(AppError::Config(format!(
|
||||
"Pi key '{provider_key}' is already owned by provider '{existing_owner}'"
|
||||
)));
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
tx.execute(
|
||||
"INSERT INTO pi_provider_projections
|
||||
(provider_id, provider_key, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)",
|
||||
params![provider_id, provider_key, now],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(PiProviderProjection {
|
||||
provider_id: provider_id.to_string(),
|
||||
provider_key: provider_key.to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_projection_key(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
expected_key: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let removed = conn
|
||||
.execute(
|
||||
"DELETE FROM pi_provider_projections
|
||||
WHERE provider_id = ?1 AND provider_key = ?2",
|
||||
params![provider_id, expected_key],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if removed == 0
|
||||
&& conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM pi_provider_projections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Config(format!(
|
||||
"refusing to delete Pi projection '{provider_id}': expected key changed"
|
||||
)));
|
||||
}
|
||||
Ok(removed == 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn projection_claims_are_exact_idempotent_and_collision_safe() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let first = db.claim_pi_projection_key("provider-a", "native-a")?;
|
||||
let repeated = db.claim_pi_projection_key("provider-a", "native-a")?;
|
||||
assert_eq!(first, repeated);
|
||||
assert!(db
|
||||
.claim_pi_projection_key("provider-a", "native-b")
|
||||
.is_err());
|
||||
assert!(db
|
||||
.claim_pi_projection_key("provider-b", "native-a")
|
||||
.is_err());
|
||||
assert_eq!(db.get_pi_projection_manifest()?.len(), 1);
|
||||
assert!(db.delete_pi_projection_key("provider-a", "wrong").is_err());
|
||||
assert!(db.get_pi_projection("provider-a")?.is_some());
|
||||
assert!(db.delete_pi_projection_key("provider-a", "native-a")?);
|
||||
assert!(db.get_pi_projection_for_key("native-a")?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -6,51 +6,106 @@ use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::prompt::Prompt;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
use rusqlite::{params, Connection, Transaction};
|
||||
|
||||
fn query_prompts(conn: &Connection, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, content, description, enabled, created_at, updated_at
|
||||
FROM prompts WHERE app_type = ?1
|
||||
ORDER BY created_at ASC, id ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let prompt_iter = stmt
|
||||
.query_map(params![app_type], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let content: String = row.get(2)?;
|
||||
let description: Option<String> = row.get(3)?;
|
||||
let enabled: bool = row.get(4)?;
|
||||
let created_at: Option<i64> = row.get(5)?;
|
||||
let updated_at: Option<i64> = row.get(6)?;
|
||||
|
||||
Ok((
|
||||
id.clone(),
|
||||
Prompt {
|
||||
id,
|
||||
name,
|
||||
content,
|
||||
description,
|
||||
enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut prompts = IndexMap::new();
|
||||
for prompt_res in prompt_iter {
|
||||
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
prompts.insert(id, prompt);
|
||||
}
|
||||
Ok(prompts)
|
||||
}
|
||||
|
||||
fn validate_prompt_selection(prompts: &IndexMap<String, Prompt>) -> Result<(), AppError> {
|
||||
if prompts.values().filter(|prompt| prompt.enabled).count() > 1 {
|
||||
return Err(AppError::InvalidInput(
|
||||
"at most one prompt may be enabled for an app".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_prompt_rows(
|
||||
transaction: &Transaction<'_>,
|
||||
app_type: &str,
|
||||
prompts: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
transaction
|
||||
.execute("DELETE FROM prompts WHERE app_type = ?1", [app_type])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let mut statement = transaction
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO prompts (
|
||||
id, app_type, name, content, description, enabled, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
for prompt in prompts.values() {
|
||||
statement
|
||||
.execute(params![
|
||||
prompt.id,
|
||||
app_type,
|
||||
prompt.name,
|
||||
prompt.content,
|
||||
prompt.description,
|
||||
prompt.enabled,
|
||||
prompt.created_at,
|
||||
prompt.updated_at,
|
||||
])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_libraries_equal(
|
||||
left: &IndexMap<String, Prompt>,
|
||||
right: &IndexMap<String, Prompt>,
|
||||
) -> bool {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.all(|(id, prompt)| right.get(id) == Some(prompt))
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 获取指定应用类型的所有提示词
|
||||
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, content, description, enabled, created_at, updated_at
|
||||
FROM prompts WHERE app_type = ?1
|
||||
ORDER BY created_at ASC, id ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let prompt_iter = stmt
|
||||
.query_map(params![app_type], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let content: String = row.get(2)?;
|
||||
let description: Option<String> = row.get(3)?;
|
||||
let enabled: bool = row.get(4)?;
|
||||
let created_at: Option<i64> = row.get(5)?;
|
||||
let updated_at: Option<i64> = row.get(6)?;
|
||||
|
||||
Ok((
|
||||
id.clone(),
|
||||
Prompt {
|
||||
id,
|
||||
name,
|
||||
content,
|
||||
description,
|
||||
enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut prompts = IndexMap::new();
|
||||
for prompt_res in prompt_iter {
|
||||
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
prompts.insert(id, prompt);
|
||||
}
|
||||
Ok(prompts)
|
||||
query_prompts(&conn, app_type)
|
||||
}
|
||||
|
||||
/// 保存提示词
|
||||
@@ -75,6 +130,75 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist a complete prompt-library selection atomically.
|
||||
///
|
||||
/// Pi projects the single enabled row into AGENTS.md. A sequence of
|
||||
/// individual `save_prompt` calls can expose two enabled rows (or none) to
|
||||
/// concurrent readers, so selection changes use one SQLite transaction.
|
||||
pub(crate) fn save_prompt_selection(
|
||||
&self,
|
||||
app_type: &str,
|
||||
prompts: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
validate_prompt_selection(prompts)?;
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
replace_prompt_rows(&transaction, app_type, prompts)?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
/// Atomically publish a complete prompt library only while its full
|
||||
/// before-image still matches. This is the database half of Pi's
|
||||
/// native-file/portable-library compare-and-swap boundary.
|
||||
pub(crate) fn compare_exchange_prompt_selection(
|
||||
&self,
|
||||
app_type: &str,
|
||||
expected: &IndexMap<String, Prompt>,
|
||||
replacement: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
validate_prompt_selection(replacement)?;
|
||||
self.compare_exchange_prompt_selection_unchecked(app_type, expected, replacement)
|
||||
}
|
||||
|
||||
/// Restore a captured before-image only if the database still contains the
|
||||
/// exact attempted projection. The before-image may predate the current
|
||||
/// single-selection invariant, so compensation must preserve it byte for
|
||||
/// byte instead of refusing to restore legacy rows.
|
||||
pub(crate) fn restore_prompt_selection_if_attempted(
|
||||
&self,
|
||||
app_type: &str,
|
||||
attempted: &IndexMap<String, Prompt>,
|
||||
before: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
self.compare_exchange_prompt_selection_unchecked(app_type, attempted, before)
|
||||
}
|
||||
|
||||
fn compare_exchange_prompt_selection_unchecked(
|
||||
&self,
|
||||
app_type: &str,
|
||||
expected: &IndexMap<String, Prompt>,
|
||||
replacement: &IndexMap<String, Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let observed = query_prompts(&transaction, app_type)?;
|
||||
if !prompt_libraries_equal(&observed, expected) {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"{app_type} prompt library changed since it was read"
|
||||
)));
|
||||
}
|
||||
replace_prompt_rows(&transaction, app_type, replacement)?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
/// 删除提示词
|
||||
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -37,14 +37,14 @@ impl ProviderKey {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowUpdate {
|
||||
name: String,
|
||||
settings_config: Value,
|
||||
website_url: Option<String>,
|
||||
category: Option<String>,
|
||||
notes: Option<String>,
|
||||
meta: ProviderMeta,
|
||||
icon: Option<String>,
|
||||
icon_color: Option<String>,
|
||||
pub(super) name: String,
|
||||
pub(super) settings_config: Value,
|
||||
pub(super) website_url: Option<String>,
|
||||
pub(super) category: Option<String>,
|
||||
pub(super) notes: Option<String>,
|
||||
pub(super) meta: ProviderMeta,
|
||||
pub(super) icon: Option<String>,
|
||||
pub(super) icon_color: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderRowUpdate {
|
||||
@@ -71,15 +71,15 @@ impl ProviderRowUpdate {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowCreate {
|
||||
content: ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
pub(super) content: ProviderRowUpdate,
|
||||
pub(super) created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewEndpoint {
|
||||
url: String,
|
||||
added_at: Option<i64>,
|
||||
last_used: Option<i64>,
|
||||
pub(super) url: String,
|
||||
pub(super) added_at: Option<i64>,
|
||||
pub(super) last_used: Option<i64>,
|
||||
}
|
||||
|
||||
impl NewEndpoint {
|
||||
@@ -116,11 +116,11 @@ impl TryFrom<CustomEndpoint> for NewEndpoint {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProviderAggregate {
|
||||
key: ProviderKey,
|
||||
row: ProviderRowCreate,
|
||||
sort_index: Option<usize>,
|
||||
in_failover_queue: bool,
|
||||
initial_endpoints: Vec<NewEndpoint>,
|
||||
pub(super) key: ProviderKey,
|
||||
pub(super) row: ProviderRowCreate,
|
||||
pub(super) sort_index: Option<usize>,
|
||||
pub(super) in_failover_queue: bool,
|
||||
pub(super) initial_endpoints: Vec<NewEndpoint>,
|
||||
}
|
||||
|
||||
impl NewProviderAggregate {
|
||||
@@ -219,7 +219,7 @@ fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> {
|
||||
Ok((settings_config, meta))
|
||||
}
|
||||
|
||||
fn insert_row(
|
||||
pub(super) fn insert_row(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
@@ -272,7 +272,7 @@ fn insert_row(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_endpoint(
|
||||
pub(super) fn insert_endpoint(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
endpoint: &NewEndpoint,
|
||||
@@ -357,7 +357,7 @@ pub(super) fn restore_provider_aggregate_on_tx(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_row(
|
||||
pub(super) fn update_row(
|
||||
tx: &Transaction<'_>,
|
||||
key: &ProviderKey,
|
||||
row: &ProviderRowUpdate,
|
||||
@@ -618,23 +618,38 @@ impl Database {
|
||||
|
||||
pub(crate) fn update_provider_sort_index(
|
||||
&self,
|
||||
key: &ProviderKey,
|
||||
sort_index: usize,
|
||||
updates: &[(ProviderKey, usize)],
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if conn
|
||||
.execute(
|
||||
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![sort_index, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
let mut seen = std::collections::HashSet::with_capacity(updates.len());
|
||||
for (key, _) in updates {
|
||||
if !seen.insert((key.app_type().to_string(), key.id().to_string())) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"duplicate provider sort update for '{}/{}'",
|
||||
key.app_type(),
|
||||
key.id()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
for (key, sort_index) in updates {
|
||||
if tx
|
||||
.execute(
|
||||
"UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![sort_index, key.id, key.app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!(
|
||||
"provider '{}/{}'",
|
||||
key.app_type, key.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,24 @@ use indexmap::IndexMap;
|
||||
use rusqlite::{params, OptionalExtension, Row};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub(super) fn delete_provider_on_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
app_type: &str,
|
||||
id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if tx
|
||||
.execute(
|
||||
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::NotFound(format!("provider '{app_type}/{id}'")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) struct StoredProviderRow {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -295,6 +313,16 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_current_provider_for_app(&self, app_type: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
||||
params![app_type],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_omo_provider_current(
|
||||
&self,
|
||||
app_type: &str,
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Device-local evidence for Pi Skill deployments.
|
||||
|
||||
// Pi skill reconciliation consumes this ledger in a later contract-ordered commit.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum SkillDeploymentMethod {
|
||||
Symlink,
|
||||
Copy,
|
||||
}
|
||||
|
||||
impl SkillDeploymentMethod {
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Symlink => "symlink",
|
||||
Self::Copy => "copy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SkillDeploymentMethod {
|
||||
type Err = AppError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"symlink" => Ok(Self::Symlink),
|
||||
"copy" => Ok(Self::Copy),
|
||||
_ => Err(AppError::Database(format!(
|
||||
"unknown Pi Skill deployment method '{value}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillDeployment {
|
||||
pub skill_id: String,
|
||||
pub destination: String,
|
||||
pub destination_key: String,
|
||||
pub method: SkillDeploymentMethod,
|
||||
pub source_identity: String,
|
||||
pub deployed_digest: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
fn decode_deployment(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillDeployment> {
|
||||
let method: String = row.get(3)?;
|
||||
let method = method.parse().map_err(|error: AppError| {
|
||||
rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(error))
|
||||
})?;
|
||||
Ok(SkillDeployment {
|
||||
skill_id: row.get(0)?,
|
||||
destination: row.get(1)?,
|
||||
destination_key: row.get(2)?,
|
||||
method,
|
||||
source_identity: row.get(4)?,
|
||||
deployed_digest: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn set_pi_skill_desired(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
desired_enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let changed = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
|
||||
params![desired_enabled, skill_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if changed != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Pi Skill '{skill_id}' disappeared before desired state was saved"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_skill_deployment(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
destination_key: &str,
|
||||
) -> Result<Option<SkillDeployment>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
FROM skill_deployments
|
||||
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
|
||||
params![skill_id, destination_key],
|
||||
decode_deployment,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_skill_deployments(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
) -> Result<Vec<SkillDeployment>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
FROM skill_deployments
|
||||
WHERE app_type = 'pi' AND skill_id = ?1
|
||||
ORDER BY created_at, destination_key",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([skill_id], decode_deployment)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
rows.map(|row| row.map_err(|error| AppError::Database(error.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn save_pi_skill_deployment(
|
||||
&self,
|
||||
deployment: &SkillDeployment,
|
||||
) -> Result<(), AppError> {
|
||||
self.save_pi_skill_deployment_with_desired(deployment, None)
|
||||
}
|
||||
|
||||
/// Commit ledger evidence and, for a user toggle, the desired Pi bit in
|
||||
/// the same SQLite transaction. Filesystem publication happens before
|
||||
/// this point; a failed transaction is therefore safe to compensate by
|
||||
/// restoring the staged destination without exposing split DB authority.
|
||||
pub(crate) fn save_pi_skill_deployment_with_desired(
|
||||
&self,
|
||||
deployment: &SkillDeployment,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> Result<(), AppError> {
|
||||
if deployment.skill_id.trim().is_empty()
|
||||
|| deployment.destination.trim().is_empty()
|
||||
|| deployment.destination_key.trim().is_empty()
|
||||
|| deployment.source_identity.trim().is_empty()
|
||||
{
|
||||
return Err(AppError::Config(
|
||||
"Pi Skill deployment identity fields must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO skill_deployments (
|
||||
app_type, skill_id, destination, destination_key, method,
|
||||
source_identity, deployed_digest, created_at, updated_at
|
||||
) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(app_type, skill_id, destination_key) DO UPDATE SET
|
||||
destination = excluded.destination,
|
||||
method = excluded.method,
|
||||
source_identity = excluded.source_identity,
|
||||
deployed_digest = excluded.deployed_digest,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
deployment.skill_id,
|
||||
deployment.destination,
|
||||
deployment.destination_key,
|
||||
deployment.method.as_str(),
|
||||
deployment.source_identity,
|
||||
deployment.deployed_digest,
|
||||
deployment.created_at,
|
||||
deployment.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(desired_enabled) = desired_enabled {
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
|
||||
params![desired_enabled, deployment.skill_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if changed != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Pi Skill '{}' disappeared before deployment commit",
|
||||
deployment.skill_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_skill_deployment(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
destination_key: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
self.delete_pi_skill_deployment_with_desired(skill_id, destination_key, None)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_pi_skill_deployment_with_desired(
|
||||
&self,
|
||||
skill_id: &str,
|
||||
destination_key: &str,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> Result<bool, AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if let Some(desired_enabled) = desired_enabled {
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_pi = ?1 WHERE id = ?2",
|
||||
params![desired_enabled, skill_id],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
if changed != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Pi Skill '{skill_id}' disappeared before deployment cleanup"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let removed = transaction
|
||||
.execute(
|
||||
"DELETE FROM skill_deployments
|
||||
WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2",
|
||||
params![skill_id, destination_key],
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
== 1;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment {
|
||||
SkillDeployment {
|
||||
skill_id: skill_id.into(),
|
||||
destination: format!("/tmp/{destination_key}"),
|
||||
destination_key: destination_key.into(),
|
||||
method: SkillDeploymentMethod::Copy,
|
||||
source_identity: format!("source:{skill_id}"),
|
||||
deployed_digest: Some("sha256:initial".into()),
|
||||
created_at: 10,
|
||||
updated_at: 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_ledger_preserves_created_at_and_rejects_destination_collision() -> Result<(), AppError>
|
||||
{
|
||||
let db = Database::memory()?;
|
||||
db.save_pi_skill_deployment(&deployment("one", "destination"))?;
|
||||
let mut updated = deployment("one", "destination");
|
||||
updated.updated_at = 20;
|
||||
updated.deployed_digest = Some("sha256:updated".into());
|
||||
db.save_pi_skill_deployment(&updated)?;
|
||||
let saved = db
|
||||
.get_pi_skill_deployment("one", "destination")?
|
||||
.expect("deployment");
|
||||
assert_eq!(saved.created_at, 10);
|
||||
assert_eq!(saved.updated_at, 20);
|
||||
assert_eq!(saved.deployed_digest.as_deref(), Some("sha256:updated"));
|
||||
|
||||
assert!(db
|
||||
.save_pi_skill_deployment(&deployment("two", "destination"))
|
||||
.is_err());
|
||||
assert_eq!(db.get_pi_skill_deployments("one")?.len(), 1);
|
||||
assert!(db.delete_pi_skill_deployment("one", "destination")?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ impl Database {
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
enabled_opencode, enabled_hermes, enabled_pi,
|
||||
installed_at, content_hash, updated_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -46,10 +47,11 @@ impl Database {
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
pi: row.get(14)?,
|
||||
},
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
installed_at: row.get(15)?,
|
||||
content_hash: row.get(16)?,
|
||||
updated_at: row.get::<_, i64>(17).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -69,7 +71,8 @@ impl Database {
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
|
||||
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
|
||||
enabled_opencode, enabled_hermes, enabled_pi,
|
||||
installed_at, content_hash, updated_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -91,10 +94,11 @@ impl Database {
|
||||
grokbuild: row.get(11)?,
|
||||
opencode: row.get(12)?,
|
||||
hermes: row.get(13)?,
|
||||
pi: row.get(14)?,
|
||||
},
|
||||
installed_at: row.get(14)?,
|
||||
content_hash: row.get(15)?,
|
||||
updated_at: row.get::<_, i64>(16).unwrap_or(0),
|
||||
installed_at: row.get(15)?,
|
||||
content_hash: row.get(16)?,
|
||||
updated_at: row.get::<_, i64>(17).unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
@@ -109,11 +113,28 @@ impl Database {
|
||||
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
"INSERT INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
|
||||
installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
enabled_pi, installed_at, content_hash, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
directory = excluded.directory,
|
||||
repo_owner = excluded.repo_owner,
|
||||
repo_name = excluded.repo_name,
|
||||
repo_branch = excluded.repo_branch,
|
||||
readme_url = excluded.readme_url,
|
||||
enabled_claude = excluded.enabled_claude,
|
||||
enabled_codex = excluded.enabled_codex,
|
||||
enabled_gemini = excluded.enabled_gemini,
|
||||
enabled_grokbuild = excluded.enabled_grokbuild,
|
||||
enabled_opencode = excluded.enabled_opencode,
|
||||
enabled_hermes = excluded.enabled_hermes,
|
||||
installed_at = excluded.installed_at,
|
||||
content_hash = excluded.content_hash,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -129,6 +150,7 @@ impl Database {
|
||||
skill.apps.grokbuild,
|
||||
skill.apps.opencode,
|
||||
skill.apps.hermes,
|
||||
skill.apps.pi,
|
||||
skill.installed_at,
|
||||
skill.content_hash,
|
||||
skill.updated_at,
|
||||
@@ -160,8 +182,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_pi = ?7 WHERE id = ?8",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, apps.pi, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
@@ -262,3 +284,52 @@ impl Database {
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn installed_skill() -> InstalledSkill {
|
||||
InstalledSkill {
|
||||
id: "owner/repo:skill".into(),
|
||||
name: "Skill".into(),
|
||||
description: Some("before".into()),
|
||||
directory: "skill".into(),
|
||||
repo_owner: Some("owner".into()),
|
||||
repo_name: Some("repo".into()),
|
||||
repo_branch: Some("main".into()),
|
||||
readme_url: None,
|
||||
apps: SkillApps::default(),
|
||||
installed_at: 10,
|
||||
content_hash: Some("sha256:before".into()),
|
||||
updated_at: 11,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_skill_save_preserves_pi_desired_state() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let mut skill = installed_skill();
|
||||
db.save_skill(&skill)?;
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"UPDATE skills SET enabled_pi = 1 WHERE id = ?1",
|
||||
[&skill.id],
|
||||
)?;
|
||||
}
|
||||
|
||||
skill.name = "Updated".into();
|
||||
skill.content_hash = Some("sha256:after".into());
|
||||
db.save_skill(&skill)?;
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let saved: (String, String, bool) = conn.query_row(
|
||||
"SELECT name, content_hash, enabled_pi FROM skills WHERE id = ?1",
|
||||
[&skill.id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
assert_eq!(saved, ("Updated".into(), "sha256:after".into(), true));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user