Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e7f9a2c
feat(plugins): add data layer for scheduled user-plugin sync
AshDevFr Jun 6, 2026
09bcf7b
feat(plugins): fan out scheduled user syncs on a plugin's cron
AshDevFr Jun 6, 2026
f546088
feat(plugins): API for admin sync cron and per-user auto/manual sync
AshDevFr Jun 6, 2026
78de94f
feat(plugins): UI for admin sync cron and per-user auto/manual sync
AshDevFr Jun 7, 2026
007d98e
docs(plugins): document scheduled automatic sync
AshDevFr Jun 7, 2026
6b0cbaa
refactor(plugins): extract shared per-series engagement builder
AshDevFr Jun 7, 2026
49a221f
feat(plugins): add opt-in series metadata to library and sync entries
AshDevFr Jun 7, 2026
035565e
feat(plugins): gate metadata enrichment behind per-field opt-in toggles
AshDevFr Jun 7, 2026
b2951bb
feat(plugins): add metadata-enrichment toggles to the connection sett…
AshDevFr Jun 7, 2026
17d0c86
feat(plugins): add detailed sync progress fields to the protocol
AshDevFr Jun 7, 2026
648dcee
feat(plugins): add wantsDetailedProgress capability flag
AshDevFr Jun 7, 2026
d608e5e
feat(plugins): compute and attach detailed sync progress on push
AshDevFr Jun 7, 2026
c5c157b
feat(plugins): consume accurate sync progress and add detailed-progre…
AshDevFr Jun 7, 2026
0ab62c4
fix(plugins): add wantsDetailedProgress to mock plugin capabilities
AshDevFr Jun 7, 2026
31aad27
feat(plugins): record echo plugin payloads to files for debugging
AshDevFr Jun 7, 2026
e4dd466
feat(plugins): add sync-echo debug plugin
AshDevFr Jun 7, 2026
1f178c7
feat(plugins): add recommendations-echo and wire up the echo plugins
AshDevFr Jun 7, 2026
489137d
feat(plugins): support credential-less plugins and send per-user iden…
AshDevFr Jun 7, 2026
a3ae162
refactor(plugins): make metadata enrichment admin-gated and relocate …
AshDevFr Jun 7, 2026
e33d8b1
feat(plugins): add custom-metadata admin gate and extend tags/genres …
AshDevFr Jun 7, 2026
73dd01e
fix(plugins): give worker-spawned plugins a real dataDir
AshDevFr Jun 7, 2026
e62cad4
test(scanner): align task priority assertions with reworked scheme
AshDevFr Jun 7, 2026
97529b2
test(tasks): align priority assertions with reworked scheme
AshDevFr Jun 7, 2026
a871e8a
test(models): fix stale default_priority assertions in extraction tests
AshDevFr Jun 7, 2026
4772471
feat(seed): accept preprocessing rules and match conditions as native…
AshDevFr Jun 7, 2026
9a831e3
fix(plugins): apply live schedule reloads when workers are disabled
AshDevFr Jun 7, 2026
2934468
feat(scheduler): make scheduled plugin sync safe across replicas
AshDevFr Jun 8, 2026
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
14 changes: 4 additions & 10 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,10 @@ screenshots/.vite/
screenshots/fixtures/
screenshots/output/

# Plugins build artifacts and dependencies
plugins/sdk-typescript/node_modules/
plugins/sdk-typescript/dist/
plugins/sdk-typescript/.vite/
plugins/metadata-mangabaka/node_modules/
plugins/metadata-mangabaka/dist/
plugins/metadata-mangabaka/.vite/
plugins/metadata-echo/node_modules/
plugins/metadata-echo/dist/
plugins/metadata-echo/.vite/
# Plugins build artifacts and dependencies (all plugins; rebuilt inside the image)
plugins/*/node_modules/
plugins/*/dist/
plugins/*/.vite/

# Database files
*.db
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ jobs:
plugin:
- sdk-typescript
- metadata-echo
- sync-echo
- recommendations-echo
- metadata-mangabaka
- metadata-openlibrary
- recommendations-anilist
Expand Down Expand Up @@ -576,6 +578,8 @@ jobs:
matrix:
plugin:
- metadata-echo
- sync-echo
- recommendations-echo
- metadata-mangabaka
- metadata-openlibrary
- recommendations-anilist
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ jobs:
plugin:
- sdk-typescript
- metadata-echo
- sync-echo
- recommendations-echo
- metadata-mangabaka
- metadata-openlibrary
- recommendations-anilist
Expand Down
45 changes: 45 additions & 0 deletions config/seed-config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ plugins:
- "library:scan"
credential_delivery: env

# Echo Sync - test/debug sync plugin (no credentials needed)
- name: sync-echo
display_name: Echo Sync Plugin
description: Test sync plugin that echoes push payloads and returns deterministic pull entries
plugin_type: user
command: node
args: ["/opt/codex/plugins/sync-echo/dist/index.js"]
permissions: []
scopes: []
credential_delivery: env

# Echo Recommendations - test/debug recommendations plugin (no credentials needed)
- name: recommendations-echo
display_name: Echo Recommendations Plugin
description: Test recommendations plugin that echoes library seeds back as recommendations
plugin_type: user
command: node
args: ["/opt/codex/plugins/recommendations-echo/dist/index.js"]
permissions: []
scopes: []
credential_delivery: env

# Open Library - Book metadata provider (no credentials needed)
- name: metadata-openlibrary
display_name: Open Library
Expand Down Expand Up @@ -194,13 +216,36 @@ plugins:
# default_reading_direction: LEFT_TO_RIGHT (default), RIGHT_TO_LEFT, VERTICAL, WEBTOON
# allowed_formats: list of formats, e.g. [cbz, cbr, epub, pdf]
# excluded_patterns: list of glob patterns, e.g. ["*.txt", "thumbs.db"]
#
# title_preprocessing_rules: regex transforms applied to series titles before
# metadata search, in order. Each rule: pattern, replacement (empty to remove),
# optional description, optional enabled (default true). Use single-quoted
# scalars so backslashes stay literal: '\s*\(Digital\)$' not "\\s*\\(Digital\\)$".
#
# auto_match_conditions: gate auto-matching on series properties.
# mode: all (AND) or any (OR); rules: [{field, operator, value}].
# operators: is_null, is_not_null, equals, not_equals, gt, gte, lt, lte,
# contains, not_contains, starts_with, ends_with, matches, in, not_in
libraries:
- name: Comics
path: /libraries/comics
# series_strategy: series_volume
# book_strategy: filename
# number_strategy: file_order
# default_reading_direction: LEFT_TO_RIGHT
# title_preprocessing_rules:
# - pattern: '\s*\(Digital\)$'
# replacement: ''
# description: Remove (Digital) suffix
# enabled: true
# auto_match_conditions:
# mode: all
# rules:
# - field: external_ids.plugin:mangabaka
# operator: is_null
# - field: book_count
# operator: gte
# value: 1

- name: Manga
path: /libraries/manga
Expand Down
4 changes: 4 additions & 0 deletions crates/codex-api/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,8 @@ The following paths are exempt from rate limiting:
v1::handlers::user_plugins::disconnect_plugin,
v1::handlers::user_plugins::get_user_plugin,
v1::handlers::user_plugins::update_user_plugin_config,
v1::handlers::user_plugins::set_sync_mode,
v1::handlers::user_plugins::set_metadata_settings,
v1::handlers::user_plugins::oauth_start,
v1::handlers::user_plugins::oauth_callback,
v1::handlers::user_plugins::set_user_credentials,
Expand Down Expand Up @@ -977,6 +979,8 @@ The following paths are exempt from rate limiting:
v1::dto::UserPluginsListResponse,
v1::dto::OAuthStartResponse,
v1::dto::UpdateUserPluginConfigRequest,
v1::dto::SetSyncModeRequest,
v1::dto::SetMetadataSettingsRequest,
v1::dto::SetUserCredentialsRequest,
v1::dto::SyncTriggerResponse,
v1::dto::SyncStatusDto,
Expand Down
29 changes: 29 additions & 0 deletions crates/codex-api/src/routes/v1/dto/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ pub struct PluginDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub internal_config: Option<InternalPluginConfig>,

/// Admin-managed cron schedule for automatic user-plugin syncs
/// (null = no scheduled sync)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(example = "0 0 */6 * * *")]
pub sync_cron_schedule: Option<String>,

/// Number of users who have enabled this plugin (only for user-type plugins)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(example = 3)]
Expand Down Expand Up @@ -224,6 +230,7 @@ impl From<plugins::Model> for PluginDto {
.internal_config
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
sync_cron_schedule: model.sync_cron_schedule,
user_count: None,
}
}
Expand Down Expand Up @@ -416,6 +423,14 @@ pub struct PluginCapabilitiesDto {
/// new chapter / volume releases for tracked series).
#[serde(default)]
pub release_source: bool,
/// Whether the plugin consumes enriched series data (bibliographic metadata,
/// custom metadata) on the sync/recommendation entries it receives.
#[serde(default)]
pub wants_full_metadata: bool,
/// Whether the plugin consumes the per-book reading-progress breakdown on the
/// sync entries it receives. Only meaningful when `user_read_sync` is true.
#[serde(default)]
pub wants_detailed_progress: bool,
}

impl From<PluginCapabilities> for PluginCapabilitiesDto {
Expand All @@ -431,6 +446,8 @@ impl From<PluginCapabilities> for PluginCapabilitiesDto {
external_id_source: c.external_id_source,
user_recommendation_provider: c.user_recommendation_provider,
release_source,
wants_full_metadata: c.wants_full_metadata,
wants_detailed_progress: c.wants_detailed_progress,
}
}
}
Expand Down Expand Up @@ -718,6 +735,18 @@ pub struct UpdatePluginRequest {
/// Validated as InternalPluginConfig on the server
#[serde(skip_serializing_if = "Option::is_none")]
pub internal_config: Option<serde_json::Value>,

/// Admin-managed cron schedule for automatic user-plugin syncs.
/// Omit to leave unchanged; `null` clears it (no scheduled sync); a string
/// sets/normalizes it. Only allowed on plugins whose manifest declares the
/// `user_read_sync` capability.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_nullable"
)]
#[schema(example = "0 0 */6 * * *")]
pub sync_cron_schedule: Option<serde_json::Value>,
}

// =============================================================================
Expand Down
111 changes: 110 additions & 1 deletion crates/codex-api/src/routes/v1/dto/user_plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,14 @@ pub struct UserPluginDto {
pub plugin_type: String,
/// Whether the user has enabled this plugin
pub enabled: bool,
/// Whether the plugin is connected (has valid credentials/OAuth)
/// Whether the plugin is connected and ready to operate. True when the
/// plugin has valid credentials/OAuth, or when it requires no per-user
/// authentication (credential-less or shared-key plugins).
pub connected: bool,
/// Whether this plugin requires per-user authentication (OAuth or required
/// credentials). When false, the connect/disconnect flow is not applicable;
/// the plugin is usable as soon as it is enabled.
pub requires_auth: bool,
/// Health status of this user's plugin instance
pub health_status: String,
/// External service username (if connected via OAuth)
Expand All @@ -71,6 +77,21 @@ pub struct UserPluginDto {
pub user_setup_instructions: Option<String>,
/// Per-user configuration
pub config: serde_json::Value,
/// Whether this connection is opted into automatic scheduled syncs
/// (host-side preference, derived from `config._codex.autoSync`). When
/// false (the default), syncs run only when manually triggered.
pub auto_sync: bool,
/// The admin-configured cron schedule that drives automatic syncs for this
/// plugin (the normalized 6-field form), or `None` when the admin has not
/// set one. Surfaced read-only so the UI can show the cadence to users and
/// indicate when auto sync isn't set up yet. The cadence is plugin-wide, not
/// per-user.
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_cron_schedule: Option<String>,
/// User privacy opt-out for sending user-defined custom metadata (host-side,
/// from `config._codex.sendCustomMetadata`). Default false. tags/genres/the
/// bibliographic block are admin policy on the plugin, not user-controlled.
pub send_custom_metadata: bool,
/// Plugin capabilities (derived from manifest)
pub capabilities: UserPluginCapabilitiesDto,
/// User-facing configuration schema (from plugin manifest)
Expand Down Expand Up @@ -115,6 +136,12 @@ pub struct UserPluginCapabilitiesDto {
pub read_sync: bool,
/// Can provide recommendations
pub user_recommendation_provider: bool,
/// Consumes enriched series data; gates whether the `_codex.send*` metadata
/// toggles are shown on the connection.
pub wants_full_metadata: bool,
/// Consumes the per-book reading-progress breakdown (`readBooks`); when set,
/// the host attaches per-book volume/chapter/page detail to sync entries.
pub wants_detailed_progress: bool,
}

/// Request to update user plugin configuration
Expand All @@ -125,6 +152,29 @@ pub struct UpdateUserPluginConfigRequest {
pub config: serde_json::Value,
}

/// Request to set a connection's automatic-sync preference (manual vs auto).
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SetSyncModeRequest {
/// `true` opts the connection into scheduled syncs on the plugin's
/// admin-configured cadence; `false` is manual-only (the default).
pub auto: bool,
}

/// Request to set a connection's metadata-enrichment opt-ins (user-controlled).
///
/// Only `sendCustomMetadata` is a per-user choice (a privacy opt-out for the
/// user's custom fields). Whether tags/genres/the bibliographic block are sent is
/// admin policy on the plugin, not set here. Other `_codex` settings are
/// preserved (partial update). Only meaningful for `wantsFullMetadata` plugins.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SetMetadataSettingsRequest {
/// Send user-defined custom metadata to the plugin.
#[serde(default)]
pub send_custom_metadata: Option<bool>,
}

/// Request to set user credentials (e.g., personal access token)
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -368,6 +418,7 @@ mod tests {
plugin_type: "user".to_string(),
enabled: true,
connected: true,
requires_auth: true,
health_status: "healthy".to_string(),
external_username: None,
external_avatar_url: None,
Expand All @@ -378,9 +429,14 @@ mod tests {
description: None,
user_setup_instructions: None,
config: serde_json::json!({}),
auto_sync: false,
sync_cron_schedule: None,
send_custom_metadata: false,
capabilities: UserPluginCapabilitiesDto {
read_sync: true,
user_recommendation_provider: false,
wants_full_metadata: false,
wants_detailed_progress: false,
},
user_config_schema: None,
last_sync_result: None,
Expand All @@ -389,8 +445,49 @@ mod tests {
let json = serde_json::to_value(&dto).unwrap();
assert_eq!(json["capabilities"]["readSync"], true);
assert_eq!(json["capabilities"]["userRecommendationProvider"], false);
assert_eq!(json["capabilities"]["wantsDetailedProgress"], false);
assert!(!json.as_object().unwrap().contains_key("userConfigSchema"));
assert!(!json.as_object().unwrap().contains_key("lastSyncResult"));
// Absent when the admin hasn't set a schedule.
assert!(!json.as_object().unwrap().contains_key("syncCronSchedule"));
}

#[test]
fn test_user_plugin_dto_exposes_sync_cron_schedule() {
let dto = UserPluginDto {
id: Uuid::new_v4(),
plugin_id: Uuid::new_v4(),
plugin_name: "sync-anilist".to_string(),
plugin_display_name: "AniList Sync".to_string(),
plugin_type: "user".to_string(),
enabled: true,
connected: true,
requires_auth: true,
health_status: "healthy".to_string(),
external_username: None,
external_avatar_url: None,
last_sync_at: None,
last_success_at: None,
requires_oauth: true,
oauth_configured: true,
description: None,
user_setup_instructions: None,
config: serde_json::json!({}),
auto_sync: true,
sync_cron_schedule: Some("0 */5 * * * *".to_string()),
send_custom_metadata: false,
capabilities: UserPluginCapabilitiesDto {
read_sync: true,
user_recommendation_provider: false,
wants_full_metadata: false,
wants_detailed_progress: false,
},
user_config_schema: None,
last_sync_result: None,
created_at: chrono::Utc::now(),
};
let json = serde_json::to_value(&dto).unwrap();
assert_eq!(json["syncCronSchedule"], "0 */5 * * * *");
}

#[test]
Expand All @@ -416,6 +513,7 @@ mod tests {
plugin_type: "user".to_string(),
enabled: true,
connected: true,
requires_auth: true,
health_status: "healthy".to_string(),
external_username: None,
external_avatar_url: None,
Expand All @@ -426,9 +524,14 @@ mod tests {
description: None,
user_setup_instructions: None,
config: serde_json::json!({}),
auto_sync: false,
sync_cron_schedule: None,
send_custom_metadata: false,
capabilities: UserPluginCapabilitiesDto {
read_sync: true,
user_recommendation_provider: false,
wants_full_metadata: false,
wants_detailed_progress: false,
},
user_config_schema: Some(schema),
last_sync_result: None,
Expand Down Expand Up @@ -459,6 +562,7 @@ mod tests {
plugin_type: "user".to_string(),
enabled: true,
connected: true,
requires_auth: true,
health_status: "healthy".to_string(),
external_username: None,
external_avatar_url: None,
Expand All @@ -469,9 +573,14 @@ mod tests {
description: None,
user_setup_instructions: None,
config: serde_json::json!({}),
auto_sync: false,
sync_cron_schedule: None,
send_custom_metadata: false,
capabilities: UserPluginCapabilitiesDto {
read_sync: true,
user_recommendation_provider: false,
wants_full_metadata: false,
wants_detailed_progress: false,
},
user_config_schema: None,
last_sync_result: Some(sync_result.clone()),
Expand Down
Loading
Loading