//! Single source of truth for default plugin repo/tag pairs. //! //! Both `animus plugin install-defaults` (in `orchestrator-cli`) and the //! daemon plugin preflight (`plugin_preflight::PluginPreflightSpec`) must //! resolve the same `(owner/repo, tag)` for each role. Before this module //! existed, preflight referenced `animus-provider-claude@v0.1.0` while //! install-defaults shipped `v0.2.x`, and preflight mapped both `task` and //! `requirement` subject kinds to `animus-subject-linear` even though the //! correct backends are `animus-subject-default` and //! `animus-subject-requirements`. Drift like that produced fix commands that //! installed the wrong plugin. //! //! Bump the constants here when the curated launchapp-dev releases ship a //! new tag and the workspace agrees to roll the floor. /// Curated provider plugins installed by default. The order matters: the /// first entry is the one preflight points users at for /// `at_least_one_provider`. pub const DEFAULT_PROVIDER_PLUGINS: &[(&str, &str)] = &[ ("launchapp-dev/animus-provider-claude", "v0.2.8"), // Codex is driven over MCP (`codex mcp-server`) by animus-provider-codex-mcp, // which advertises provider_tool = "codex" (drop-in for the old stdout // provider). Gemini + OpenCode drive their harnesses over ACP internally. ("launchapp-dev/animus-provider-codex-mcp", "v0.1.0"), ("launchapp-dev/animus-provider-gemini", "v0.3.0"), ("launchapp-dev/animus-provider-opencode", "v0.3.0"), ("launchapp-dev/animus-provider-oai", "v0.2.2"), ]; /// v0.5 workflow_runner + queue plugins installed as part of the default /// flavor. Required for `animus daemon start` once the plugin-only path /// is the default (deletion gate in Wave 3 "Out of scope"). pub const DEFAULT_WORKFLOW_RUNNER_PLUGINS: &[(&str, &str)] = &[("launchapp-dev/animus-workflow-runner-default", "v0.4.8")]; /// v0.5 queue plugin. See [`DEFAULT_WORKFLOW_RUNNER_PLUGINS`]. pub const DEFAULT_QUEUE_PLUGINS: &[(&str, &str)] = &[("launchapp-dev/animus-queue-default", "v0.3.3")]; /// v0.6 config-source plugin (the default YAML source for the `config_source` /// plugin role). Published as `launchapp-dev/animus-config-yaml` v0.1.0 in the /// v0.6.1 release; the daemon requires a `config_source` to boot. pub const DEFAULT_CONFIG_SOURCE_PLUGINS: &[(&str, &str)] = &[("launchapp-dev/animus-config-yaml", "v0.1.0")]; /// v0.5 durable-store plugin (DBOS Option A). The `animus-step-durable-dbos` /// repository has not shipped a tagged release yet; until it does this list /// stays empty so `animus plugin install-defaults --include-durable-stores` /// no-ops cleanly instead of failing the install with a 404. pub const DEFAULT_DURABLE_STORE_PLUGINS: &[(&str, &str)] = &[]; /// v0.5 memory-store plugin (Zep Cloud). Same shape as /// [`DEFAULT_DURABLE_STORE_PLUGINS`] — the `animus-memory-zep` repo has not /// shipped a tagged release yet; empty until it does. pub const DEFAULT_MEMORY_STORE_PLUGINS: &[(&str, &str)] = &[]; /// v0.5.3 notifier plugin (HTTP + Slack webhook). Optional role; the /// daemon starts cleanly without a notifier installed. Listed here so /// `animus plugin install-defaults --include-notifiers` (and any future /// auto-install pass) can resolve a curated tag. pub const DEFAULT_NOTIFIER_PLUGINS: &[(&str, &str)] = &[("launchapp-dev/animus-notifier-http", "v0.1.0")]; /// Optional add-on opted in by `--include-oai-agent`. pub const DEFAULT_OAI_AGENT_PLUGINS: &[(&str, &str)] = &[("launchapp-dev/animus-provider-oai-agent", "v0.1.5")]; /// Subject backends installed by `--include-subjects`. Indexed lookups /// below (`default_subject_repo_for_kind`) assume each entry is listed /// here so preflight and install-defaults stay in lockstep. pub const DEFAULT_SUBJECT_PLUGINS: &[(&str, &str)] = &[ ("launchapp-dev/animus-subject-default", "v0.1.4"), ("launchapp-dev/animus-subject-requirements", "v0.1.7"), ("launchapp-dev/animus-subject-linear", "v0.1.5"), ("launchapp-dev/animus-subject-sqlite", "v0.1.4"), ("launchapp-dev/animus-subject-markdown", "v0.1.4"), ]; /// Transport + web UI plugins installed by `--include-transports`. pub const DEFAULT_TRANSPORT_PLUGINS: &[(&str, &str)] = &[ ("launchapp-dev/animus-transport-http", "v0.2.1"), ("launchapp-dev/animus-transport-graphql", "v0.2.3"), ("launchapp-dev/animus-web-ui", "v0.1.0"), ]; /// Format a registry entry into the `owner/repo@tag` spec accepted by /// `animus plugin install`. pub fn format_repo_spec(entry: (&str, &str)) -> String { format!("{}@{}", entry.0, entry.1) } /// Look up the curated pin for a plugin slug across every default table. /// Returns `None` for slugs the curated registry does not yet pin (e.g. /// the v0.5 `default` flavor lists `animus-provider-ollama` and the /// v0.5 trigger plugins ahead of their first curated release). Callers /// should treat `None` as "skip with a warning, don't fail". pub fn resolve_tag_for_slug(slug: &str) -> Option<&'static str> { for table in [ DEFAULT_PROVIDER_PLUGINS, DEFAULT_OAI_AGENT_PLUGINS, DEFAULT_SUBJECT_PLUGINS, DEFAULT_TRANSPORT_PLUGINS, DEFAULT_WORKFLOW_RUNNER_PLUGINS, DEFAULT_QUEUE_PLUGINS, DEFAULT_CONFIG_SOURCE_PLUGINS, DEFAULT_DURABLE_STORE_PLUGINS, DEFAULT_MEMORY_STORE_PLUGINS, DEFAULT_NOTIFIER_PLUGINS, ] { if let Some((_, tag)) = table.iter().find(|(s, _)| *s == slug) { return Some(*tag); } } None } /// Resolve a curated `(owner/repo, tag)` pin from a bare plugin slug (the repo /// basename, e.g. `animus-provider-claude`). Used by `animus install` / /// `animus add` so a manifest's short-form dependency (`animus-provider-claude /// = ">=0.2.7"`) resolves to the full `launchapp-dev/animus-provider-claude` /// repo and its curated tag. Returns `None` for slugs no curated table pins — /// the caller then requires an explicit `{ git = "OWNER/REPO", tag = "..." }`. pub fn resolve_curated_plugin_by_basename(name: &str) -> Option<(&'static str, &'static str)> { for table in [ DEFAULT_PROVIDER_PLUGINS, DEFAULT_OAI_AGENT_PLUGINS, DEFAULT_SUBJECT_PLUGINS, DEFAULT_TRANSPORT_PLUGINS, DEFAULT_WORKFLOW_RUNNER_PLUGINS, DEFAULT_QUEUE_PLUGINS, DEFAULT_CONFIG_SOURCE_PLUGINS, DEFAULT_DURABLE_STORE_PLUGINS, DEFAULT_MEMORY_STORE_PLUGINS, DEFAULT_NOTIFIER_PLUGINS, ] { if let Some((slug, tag)) = table.iter().find(|(slug, _)| slug.rsplit('/').next() == Some(name)) { return Some((*slug, *tag)); } } None } /// Repo+tag spec for the default provider that preflight should install /// when `at_least_one_provider` is unsatisfied. pub fn default_provider_repo_spec() -> String { let first = DEFAULT_PROVIDER_PLUGINS.first().copied().expect("DEFAULT_PROVIDER_PLUGINS must be non-empty"); format_repo_spec(first) } /// The curated subject backend preflight installs when NO subject backend is /// present and `at_least_one_subject_backend` is unsatisfied. This is a /// kind-agnostic starter — the daemon requires SOME subject backend but must /// not privilege `task`/`requirement`; a deployment that installs any other /// subject backend (sqlite, markdown, a custom kind) satisfies preflight /// without this ever being installed. Returns `None` only if the curated /// table is empty (a build-time invariant guards against that). pub fn default_subject_backend_repo() -> Option { DEFAULT_SUBJECT_PLUGINS.first().copied().map(format_repo_spec) } /// Resolve the curated subject backend for a given `subject_kind`. /// Returns `None` when no curated backend claims the kind, in which case /// the daemon falls back to whichever third-party plugin the user installs. /// /// No kind is privileged: the two built-ins whose backend basename does not /// match their kind name (`task` -> `animus-subject-default`, /// `requirement` -> `animus-subject-requirements`) are aliased explicitly, and /// every other kind resolves by the uniform `animus-subject-` convention /// (`sqlite`, `markdown`, `linear`, ...). Unknown kinds return `None`. pub fn default_subject_repo_for_kind(kind: &str) -> Option { let basename: String = match kind { "task" => "animus-subject-default".to_string(), "requirement" => "animus-subject-requirements".to_string(), other => format!("animus-subject-{other}"), }; DEFAULT_SUBJECT_PLUGINS .iter() .find(|(slug, _)| slug.rsplit('/').next() == Some(basename.as_str())) .copied() .map(format_repo_spec) } #[cfg(test)] mod tests { use super::*; #[test] fn provider_default_is_claude_at_curated_tag() { let spec = default_provider_repo_spec(); assert!( spec.starts_with("launchapp-dev/animus-provider-claude@"), "default provider must be claude, got: {spec}" ); assert!(spec.contains("@v0."), "default provider spec must pin a tag, got: {spec}"); } #[test] fn subject_kind_task_resolves_to_subject_default() { let spec = default_subject_repo_for_kind("task").expect("task kind must resolve"); assert!( spec.starts_with("launchapp-dev/animus-subject-default@"), "task subject must be animus-subject-default (NOT animus-subject-linear), got: {spec}" ); } #[test] fn subject_kind_requirement_resolves_to_subject_requirements() { let spec = default_subject_repo_for_kind("requirement").expect("requirement kind must resolve"); assert!( spec.starts_with("launchapp-dev/animus-subject-requirements@"), "requirement subject must be animus-subject-requirements (NOT animus-subject-linear), got: {spec}" ); } #[test] fn unknown_subject_kind_returns_none() { assert!(default_subject_repo_for_kind("nonsense").is_none()); } #[test] fn non_privileged_kinds_resolve_by_uniform_convention() { // De-privilege: task/requirement are no longer the only resolvable // kinds. Any curated `animus-subject-` backend resolves by its // uniform kind name — no special-casing required. for (kind, basename) in [("sqlite", "animus-subject-sqlite"), ("markdown", "animus-subject-markdown")] { let spec = default_subject_repo_for_kind(kind).unwrap_or_else(|| panic!("{kind} kind must resolve")); assert!(spec.contains(basename), "kind '{kind}' must resolve to {basename}, got: {spec}"); } } #[test] fn default_subject_backend_is_kind_agnostic_starter() { // The generic starter backend is resolved WITHOUT going through a // task-specific path, so the auto-install default no longer privileges // the `task` kind. let spec = default_subject_backend_repo().expect("curated subject table is non-empty"); assert!(spec.starts_with("launchapp-dev/animus-subject-"), "starter must be a curated subject backend: {spec}"); } #[test] fn all_subject_plugins_have_non_empty_tags() { for (slug, tag) in DEFAULT_SUBJECT_PLUGINS { assert!(!tag.is_empty(), "tag for {slug} must be non-empty"); assert!(tag.starts_with('v'), "tag for {slug} must look like v0.x.y, got {tag}"); } } #[test] fn all_provider_plugins_have_non_empty_tags() { for (slug, tag) in DEFAULT_PROVIDER_PLUGINS { assert!(!tag.is_empty(), "tag for {slug} must be non-empty"); assert!(tag.starts_with('v'), "tag for {slug} must look like v0.x.y, got {tag}"); } } }