Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ jobs:
--locked \
-- --test-threads=1

# The SQLite → Postgres migration suite is entirely `#![cfg(feature =
# "postgres")]`, so this job is the only place it has any tests at all.
- name: Run migration tests against Postgres
run: |
cargo test \
-p atomic-core \
--features postgres \
--test migrate_tests \
--locked \
-- --test-threads=1

# End-to-end HTTP suites exercise the full atomic-server stack
# (BearerAuth middleware, X-Atomic-Database routing, route handlers,
# background pipeline) against Postgres. The suite is parameterized
Expand All @@ -149,6 +160,8 @@ jobs:
--test e2e_websocket \
--test e2e_concurrent \
--test e2e_mcp \
--test e2e_wiki \
--test e2e_tags_settings \
--locked \
-- --test-threads=1

Expand Down Expand Up @@ -224,5 +237,10 @@ jobs:
- name: Install dependencies
run: npm ci

# Vitest strips types without checking them, so a type error reaches
# main unless something type-checks the app separately.
- name: Typecheck
run: npx tsc --noEmit

- name: Run Vitest
run: npm test
45 changes: 43 additions & 2 deletions crates/atomic-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ impl Database {
/// 1. Add a new `if version < N` block at the end (before the virtual-table section)
/// 2. End the block with `PRAGMA user_version = N;`
/// 3. Bump LATEST_VERSION
const LATEST_VERSION: i32 = 24;
const LATEST_VERSION: i32 = 25;

pub fn run_migrations(conn: &Connection) -> Result<(), AtomicCoreError> {
Self::run_migrations_internal(conn, false)
Expand Down Expand Up @@ -1174,9 +1174,50 @@ impl Database {
)?;
}

conn.execute_batch(&format!("PRAGMA user_version = {};", Self::LATEST_VERSION))?;
conn.execute_batch("PRAGMA user_version = 24;")?;
}

// V25: a tag can override the wiki prompts used for its own article.
// NULL means "no override" — the resolver in
// `AtomicCore::build_wiki_strategy_context` then falls through to the
// global setting and finally the built-in prompt.
//
// Two ALTERs and the version bump run in one transaction. Left to
// autocommit they land separately, and the probe below only asks about
// the *first* column: a crash between the two ALTERs would re-enter
// with `has_col` true, skip the second column, and stamp 25 over a
// schema that has no `wiki_update_prompt` — every wiki read then fails
// for good, with no version left to repair it.
if version < 25 {
let has_col: bool = conn
.query_row(
"SELECT 1 FROM pragma_table_info('tags') WHERE name='wiki_generation_prompt'",
[],
|_| Ok(true),
)
.unwrap_or(false);

let tx = conn.unchecked_transaction()?;
if !has_col {
tx.execute_batch(
"ALTER TABLE tags ADD COLUMN wiki_generation_prompt TEXT;
ALTER TABLE tags ADD COLUMN wiki_update_prompt TEXT;",
)?;
}
tx.execute_batch("PRAGMA user_version = 25;")?;
tx.commit()?;
}

// Each block above stamps its own literal N, while `LATEST_VERSION` is
// a separate declaration; a new migration that bumps one without the
// other is a skew nothing else would catch. Debug builds — every test
// run — assert the two agree.
debug_assert_eq!(
conn.query_row::<i32, _, _>("PRAGMA user_version", [], |row| row.get(0))?,
Self::LATEST_VERSION,
"migrations must leave the database at LATEST_VERSION"
);

// --- Triggers (recreated every startup to stay current) ---
conn.execute_batch(
"DROP TRIGGER IF EXISTS atom_tags_insert_count;
Expand Down
48 changes: 46 additions & 2 deletions crates/atomic-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,24 @@ impl AtomicCore {
.await
}

/// Read a tag's wiki prompt overrides. `None` when the tag doesn't exist.
pub async fn get_tag_wiki_prompts(
&self,
id: &str,
) -> Result<Option<TagWikiPrompts>, AtomicCoreError> {
self.storage.get_tag_wiki_prompts_impl(id).await
}

/// Replace a tag's wiki prompt overrides. Takes effect the next time the
/// tag's article is generated or updated — nothing is regenerated here.
pub async fn set_tag_wiki_prompts(
&self,
id: &str,
prompts: &TagWikiPrompts,
) -> Result<(), AtomicCoreError> {
self.storage.set_tag_wiki_prompts_impl(id, prompts).await
}

/// Configure auto-tag targets in one shot — used by the onboarding wizard
/// and the settings tab.
///
Expand Down Expand Up @@ -1933,6 +1951,32 @@ impl AtomicCore {
.map(|s| s.as_str())
.unwrap_or("centroid"),
);
// A tag deleted out from under an in-flight generation resolves as
// "no overrides"; the missing tag surfaces on the caller's own path.
let TagWikiPrompts {
generation_prompt: tag_generation_prompt,
update_prompt: tag_update_prompt,
} = self
.storage
.get_tag_wiki_prompts_impl(tag_id)
.await?
.unwrap_or_default();
// Precedence, per field independently: this tag's override, then the
// global custom prompt, then the built-in default (applied by
// `WikiStrategyContext` when both are absent).
//
// The update chain takes the tag's *generation* prompt as its middle
// term: the article was written to that prompt, so an update falling
// straight through to the global one would accrete stock encyclopedia
// prose onto a deliberately-shaped article — a "collect the unchecked
// tasks" wiki would stop being a checklist after its first update.
// `section_ops_prompt` prepends whatever it is handed, so the JSON
// section-ops contract holds for either text.
let custom_update_prompt = tag_update_prompt
.or_else(|| tag_generation_prompt.clone())
.or_else(|| settings_map.get("wiki_update_prompt").cloned());
let custom_generation_prompt =
tag_generation_prompt.or_else(|| settings_map.get("wiki_generation_prompt").cloned());
let related = self
.storage
.get_related_tags_impl(tag_id, MAX_CROSS_LINK_TAGS)
Expand All @@ -1952,8 +1996,8 @@ impl AtomicCore {
tag_id: tag_id.to_string(),
tag_name: tag_name.to_string(),
linkable_article_names,
custom_generation_prompt: settings_map.get("wiki_generation_prompt").cloned(),
custom_update_prompt: settings_map.get("wiki_update_prompt").cloned(),
custom_generation_prompt,
custom_update_prompt,
};
Ok((strategy, ctx))
}
Expand Down
7 changes: 6 additions & 1 deletion crates/atomic-core/src/migrate/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ pub(super) const TABLE_SPECS: &[TableSpec] = &[
table: "tags",
source_table: "tags",
select_exprs: "id, name, NULL, created_at, 0, COALESCE(is_autotag_target, 0), \
COALESCE(autotag_description, '')",
COALESCE(autotag_description, ''), wiki_generation_prompt, \
wiki_update_prompt",
guard: None,
pg_cols: &[
"id",
Expand All @@ -119,6 +120,8 @@ pub(super) const TABLE_SPECS: &[TableSpec] = &[
"atom_count",
"is_autotag_target",
"autotag_description",
"wiki_generation_prompt",
"wiki_update_prompt",
],
types: &[
Col::Text,
Expand All @@ -128,6 +131,8 @@ pub(super) const TABLE_SPECS: &[TableSpec] = &[
Col::Int,
Col::Bool,
Col::Text,
Col::Text,
Col::Text,
],
binds_skip_json: false,
},
Expand Down
32 changes: 32 additions & 0 deletions crates/atomic-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,38 @@ pub struct Tag {
pub autotag_description: String,
}

/// Per-tag overrides for the wiki prompts, `None` meaning "no override".
///
/// Deliberately not a field on [`Tag`]: the tag tree ships hundreds of rows to
/// every client on load and a prompt can run to several kilobytes, so these are
/// fetched on demand for the one tag being edited.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TagWikiPrompts {
#[serde(default)]
pub generation_prompt: Option<String>,
#[serde(default)]
pub update_prompt: Option<String>,
}

impl TagWikiPrompts {
/// Blank text clears an override. Persisting `Some("")` instead would pin
/// the tag to an empty prompt and cut off the global/default fallback.
pub fn normalized(&self) -> Self {
fn clear_if_blank(prompt: &Option<String>) -> Option<String> {
prompt
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_string)
}
Self {
generation_prompt: clear_if_blank(&self.generation_prompt),
update_prompt: clear_if_blank(&self.update_prompt),
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AtomWithTags {
Expand Down
5 changes: 5 additions & 0 deletions crates/atomic-core/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ impl_reborrow_struct!(
CreateAtomRequest,
UpdateAtomRequest,
ListAtomsParams,
TagWikiPrompts,
WikiArticle,
WikiProposal,
crate::models::KindFilter,
Expand Down Expand Up @@ -420,6 +421,10 @@ dispatch! {
=> sqlite: set_tag_autotag_target_impl, pg_trait: TagStore, pg_method: set_tag_autotag_target;
fn set_tag_autotag_description_impl(&self, id: &str, description: &str) -> Result<(), AtomicCoreError>
=> sqlite: set_tag_autotag_description_impl, pg_trait: TagStore, pg_method: set_tag_autotag_description;
fn get_tag_wiki_prompts_impl(&self, id: &str) -> Result<Option<TagWikiPrompts>, AtomicCoreError>
=> sqlite: get_tag_wiki_prompts_impl, pg_trait: TagStore, pg_method: get_tag_wiki_prompts;
fn set_tag_wiki_prompts_impl(&self, id: &str, prompts: &TagWikiPrompts) -> Result<(), AtomicCoreError>
=> sqlite: set_tag_wiki_prompts_impl, pg_trait: TagStore, pg_method: set_tag_wiki_prompts;
fn configure_autotag_targets_impl(&self, keep_default_names: &[String], add_custom_names: &[String]) -> Result<Vec<Tag>, AtomicCoreError>
=> sqlite: configure_autotag_targets_impl, pg_trait: TagStore, pg_method: configure_autotag_targets;
fn get_related_tags_impl(&self, tag_id: &str, limit: usize) -> Result<Vec<RelatedTag>, AtomicCoreError>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Migration 026: a tag can override the wiki prompts used for its own article.
--
-- NULL means "no override" — the resolver in
-- `AtomicCore::build_wiki_strategy_context` then falls through to the global
-- setting and finally the built-in prompt, so existing tags keep behaving
-- exactly as they do today.

ALTER TABLE tags ADD COLUMN IF NOT EXISTS wiki_generation_prompt TEXT;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS wiki_update_prompt TEXT;

INSERT INTO schema_version (version) VALUES (26);
1 change: 1 addition & 0 deletions crates/atomic-core/src/storage/postgres/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ const MIGRATIONS: &[(i32, &str)] = &[
25,
include_str!("migrations/025_conversation_tags_mode.sql"),
),
(26, include_str!("migrations/026_tag_wiki_prompts.sql")),
];

/// Postgres-backed storage implementation using sqlx + pgvector.
Expand Down
36 changes: 36 additions & 0 deletions crates/atomic-core/src/storage/postgres/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,42 @@ impl TagStore for PostgresStorage {
Ok(())
}

async fn get_tag_wiki_prompts(&self, id: &str) -> StorageResult<Option<TagWikiPrompts>> {
let row: Option<(Option<String>, Option<String>)> = sqlx::query_as(
"SELECT wiki_generation_prompt, wiki_update_prompt
FROM tags WHERE id = $1 AND db_id = $2",
)
.bind(id)
.bind(&self.db_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?;
Ok(row.map(|r| TagWikiPrompts {
generation_prompt: r.0,
update_prompt: r.1,
}))
}

async fn set_tag_wiki_prompts(&self, id: &str, prompts: &TagWikiPrompts) -> StorageResult<()> {
let prompts = prompts.normalized();
let result = sqlx::query(
"UPDATE tags
SET wiki_generation_prompt = $1, wiki_update_prompt = $2
WHERE id = $3 AND db_id = $4",
)
.bind(&prompts.generation_prompt)
.bind(&prompts.update_prompt)
.bind(id)
.bind(&self.db_id)
.execute(&self.pool)
.await
.map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(AtomicCoreError::NotFound(format!("tag {}", id)));
}
Ok(())
}

async fn configure_autotag_targets(
&self,
keep_default_names: &[String],
Expand Down
48 changes: 48 additions & 0 deletions crates/atomic-core/src/storage/sqlite/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,46 @@ impl SqliteStorage {
Ok(())
}

pub(crate) fn get_tag_wiki_prompts_impl(
&self,
id: &str,
) -> StorageResult<Option<TagWikiPrompts>> {
let conn = self.db.read_conn()?;
conn.query_row(
"SELECT wiki_generation_prompt, wiki_update_prompt FROM tags WHERE id = ?1",
[id],
|row| {
Ok(TagWikiPrompts {
generation_prompt: row.get(0)?,
update_prompt: row.get(1)?,
})
},
)
.optional()
.map_err(AtomicCoreError::from)
}

pub(crate) fn set_tag_wiki_prompts_impl(
&self,
id: &str,
prompts: &TagWikiPrompts,
) -> StorageResult<()> {
let prompts = prompts.normalized();
let conn = self
.db
.conn
.lock()
.map_err(|e| AtomicCoreError::Lock(e.to_string()))?;
let affected = conn.execute(
"UPDATE tags SET wiki_generation_prompt = ?1, wiki_update_prompt = ?2 WHERE id = ?3",
rusqlite::params![prompts.generation_prompt, prompts.update_prompt, id],
)?;
if affected == 0 {
return Err(AtomicCoreError::NotFound(format!("tag {}", id)));
}
Ok(())
}

/// Apply a full auto-tag-target configuration in a single transaction.
///
/// Steps run atomically: any error rolls back the savepoint, leaving the
Expand Down Expand Up @@ -722,6 +762,14 @@ impl TagStore for SqliteStorage {
self.set_tag_autotag_description_impl(id, description)
}

async fn get_tag_wiki_prompts(&self, id: &str) -> StorageResult<Option<TagWikiPrompts>> {
self.get_tag_wiki_prompts_impl(id)
}

async fn set_tag_wiki_prompts(&self, id: &str, prompts: &TagWikiPrompts) -> StorageResult<()> {
self.set_tag_wiki_prompts_impl(id, prompts)
}

async fn configure_autotag_targets(
&self,
keep_default_names: &[String],
Expand Down
7 changes: 7 additions & 0 deletions crates/atomic-core/src/storage/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,13 @@ pub trait TagStore: Send + Sync {
/// Set optional guidance used when this tag is an auto-tag target.
async fn set_tag_autotag_description(&self, id: &str, description: &str) -> StorageResult<()>;

/// Read a tag's wiki prompt overrides. `None` when no tag with that id exists.
async fn get_tag_wiki_prompts(&self, id: &str) -> StorageResult<Option<TagWikiPrompts>>;

/// Replace a tag's wiki prompt overrides, normalizing blank text to "no
/// override". Errors with `NotFound` when no tag with that id exists.
async fn set_tag_wiki_prompts(&self, id: &str, prompts: &TagWikiPrompts) -> StorageResult<()>;

/// Apply a full auto-tag-target configuration in a single transaction.
/// See `AtomicCore::configure_autotag_targets` for semantics.
async fn configure_autotag_targets(
Expand Down
Loading
Loading