Skip to main content

systemprompt_cli/commands/admin/config/
planes.rs

1//! `admin config planes`: list, diff, export and apply declared configuration
2//! planes.
3//!
4//! Every subcommand runs headless against the active profile's database; no
5//! browser session is involved. `apply` writes through the same audited path
6//! as the `config_reconcile` job, recording each changed entity in
7//! `config_change_audit` with actor `cli` and the given reason; `--entity`
8//! narrows it to the named entities on a plane that supports a scoped apply.
9//! `export` renders the plane's current rows as declaration text, the step
10//! that captures console-only rows before a plane moves to `file` authority.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19use anyhow::{Result, bail};
20use clap::{Args, Subcommand, ValueEnum};
21use serde::Serialize;
22use systemprompt_extension::prelude::{ApplyMode, ChangeActor, ChangeRequest, EntityScope};
23use systemprompt_runtime::AppContext;
24use systemprompt_scheduler::services::ConfigReconcileService;
25
26use crate::CliConfig;
27use crate::shared::{CommandOutput, render_result};
28
29#[derive(Debug, Subcommand)]
30pub enum PlanesCommands {
31    #[command(about = "List registered config planes with authority, hash and last reconcile")]
32    List,
33
34    #[command(about = "Show the drift between a plane's declaration and the database")]
35    Diff(DiffArgs),
36
37    #[command(about = "Apply a plane's declaration to the database (audited)")]
38    Apply(ApplyArgs),
39
40    #[command(about = "Render a plane's current database rows as its declaration file")]
41    Export(ExportArgs),
42}
43
44#[derive(Debug, Clone, Args)]
45pub struct DiffArgs {
46    #[arg(long)]
47    pub plane: String,
48}
49
50#[derive(Debug, Clone, Args)]
51pub struct ApplyArgs {
52    #[arg(long)]
53    pub plane: String,
54
55    #[arg(
56        long,
57        help = "Why the declaration is being applied; recorded in the audit"
58    )]
59    pub reason: String,
60
61    #[arg(long, value_enum, default_value_t = ModeArg::Overwrite)]
62    pub mode: ModeArg,
63
64    #[arg(
65        long = "entity",
66        help = "Apply only this entity key (repeatable); the plane must support a scoped apply"
67    )]
68    pub entities: Vec<String>,
69}
70
71#[derive(Debug, Clone, Args)]
72pub struct ExportArgs {
73    #[arg(long)]
74    pub plane: String,
75
76    #[arg(
77        long,
78        help = "Write the declaration to this file instead of printing it"
79    )]
80    pub out: Option<PathBuf>,
81
82    #[arg(long, requires = "out", help = "Replace --out if it already exists")]
83    pub force: bool,
84}
85
86#[derive(Debug, Clone, Copy, ValueEnum)]
87pub enum ModeArg {
88    Overwrite,
89    InsertOnly,
90    SeedIfEmpty,
91}
92
93impl From<ModeArg> for ApplyMode {
94    fn from(mode: ModeArg) -> Self {
95        match mode {
96            ModeArg::Overwrite => Self::Overwrite,
97            ModeArg::InsertOnly => Self::InsertOnly,
98            ModeArg::SeedIfEmpty => Self::SeedIfEmpty,
99        }
100    }
101}
102
103#[derive(Debug, Serialize)]
104struct PlaneListRow {
105    plane: &'static str,
106    declared_path: &'static str,
107    authority: &'static str,
108    effect: &'static str,
109    hash: Option<String>,
110    drift_rows: Option<i32>,
111    outcome: Option<String>,
112    reconciled_at: Option<String>,
113}
114
115pub async fn execute(command: PlanesCommands, config: &CliConfig) -> Result<()> {
116    let ctx = Arc::new(AppContext::new().await?);
117    let output = run(command, &ctx).await?;
118    render_result(&output, config);
119    Ok(())
120}
121
122pub async fn run(command: PlanesCommands, ctx: &Arc<AppContext>) -> Result<CommandOutput> {
123    let service = ConfigReconcileService::from_app_context(
124        ctx,
125        systemprompt_scheduler::repository::ConfigReconcileRepository::new(ctx.db_pool()),
126    )?;
127    match command {
128        PlanesCommands::List => list(&service).await,
129        PlanesCommands::Diff(args) => diff(&service, &args.plane).await,
130        PlanesCommands::Apply(args) => apply(&service, &args).await,
131        PlanesCommands::Export(args) => export(&service, &args).await,
132    }
133}
134
135async fn list(service: &ConfigReconcileService) -> Result<CommandOutput> {
136    let mut states: HashMap<String, _> = service
137        .repository()
138        .list_states()
139        .await?
140        .into_iter()
141        .map(|row| (row.plane.clone(), row))
142        .collect();
143    let set = service.planes();
144    let mut rows = Vec::new();
145    for plane in set.planes() {
146        let state = states.remove(plane.name());
147        let hash = match plane.load_declared(set.services_root()) {
148            Ok(declared) => declared.map(|d| d.hash),
149            Err(error) => Some(format!("unreadable: {error}")),
150        };
151        rows.push(PlaneListRow {
152            plane: plane.name(),
153            declared_path: plane.declared_path(),
154            authority: set.authority_of(plane.as_ref()).as_str(),
155            effect: plane.effect().as_str(),
156            hash,
157            drift_rows: state.as_ref().map(|s| s.drift_rows),
158            outcome: state.as_ref().map(|s| s.outcome.clone()),
159            reconciled_at: state.map(|s| s.reconciled_at.to_rfc3339()),
160        });
161    }
162    Ok(CommandOutput::table_of(
163        vec![
164            "plane",
165            "declared_path",
166            "authority",
167            "effect",
168            "hash",
169            "drift_rows",
170            "outcome",
171            "reconciled_at",
172        ],
173        &rows,
174    )
175    .with_title("Config planes"))
176}
177
178async fn diff(service: &ConfigReconcileService, plane: &str) -> Result<CommandOutput> {
179    let diff = service.diff(plane).await?;
180    let title = format!(
181        "{} ({}, {}, {}): {} drift rows",
182        diff.plane,
183        diff.declared_path,
184        diff.authority.as_str(),
185        diff.effect.as_str(),
186        diff.rows.len()
187    );
188    Ok(
189        CommandOutput::table_of(vec!["entity", "kind", "declared", "current"], &diff.rows)
190            .with_title(title),
191    )
192}
193
194async fn apply(service: &ConfigReconcileService, args: &ApplyArgs) -> Result<CommandOutput> {
195    if args.reason.trim().is_empty() {
196        bail!("--reason must not be empty; it is recorded in config_change_audit");
197    }
198    let change = ChangeRequest {
199        actor: ChangeActor::Cli,
200        reason: args.reason.trim(),
201    };
202    let scope = if args.entities.is_empty() {
203        EntityScope::All
204    } else {
205        EntityScope::Entities(&args.entities)
206    };
207    let applied = service
208        .apply(&args.plane, args.mode.into(), &change, scope)
209        .await?;
210    Ok(CommandOutput::card_value(
211        format!(
212            "Applied {} ({} entities changed)",
213            applied.plane, applied.changes
214        ),
215        &applied,
216    ))
217}
218
219async fn export(service: &ConfigReconcileService, args: &ExportArgs) -> Result<CommandOutput> {
220    let Some(text) = service.export(&args.plane).await? else {
221        return Ok(CommandOutput::text(format!(
222            "{} has no database rows to export; nothing written",
223            args.plane
224        )));
225    };
226    let Some(out) = &args.out else {
227        return Ok(CommandOutput::copy_paste_titled(
228            format!("{} declaration", args.plane),
229            text,
230        ));
231    };
232    if out.exists() && !args.force {
233        bail!(
234            "{} already exists; pass --force to replace it",
235            out.display()
236        );
237    }
238    if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
239        std::fs::create_dir_all(parent)?;
240    }
241    std::fs::write(out, &text)?;
242    Ok(CommandOutput::text(format!(
243        "Exported {} to {} ({} bytes)",
244        args.plane,
245        out.display(),
246        text.len()
247    )))
248}