Skip to content
Closed
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ Releases before v0.27.0 predate the changelog.

### Added

- **`preloop store import-legacy` imports a released v11 `preloop.db` into a
fresh control SQLite database.** The source is opened read-only at `PRAGMA
user_version = 11` and hashed before and after; runs, queued jobs (secrets
move to the SecretProvider run tier and are stripped from the stored message
templates), attempts, steps, log bytes, counters, runners, sessions,
webhooks, check ids, and the durable metadata snapshot are written in one
transaction into `<target>.importing`, verified (`PRAGMA
foreign_key_check`, row counts, schema version), then renamed into place —
a failure leaves no target and a retry is safe. Claimed-but-unfinished
attempts, session bindings, and live concurrency gates refuse by default;
`--active=requeue|cancel` drains them explicitly. The legacy event log is
carried into the outbox (ids and order preserved), per-attempt message
frames are verified against the imported templates (reconstructing them
for terminal jobs), the finalized artifact registry — legacy v1
`artifact_records` and `artifact_v2_registry.json` — moves into
`artifacts`, and buffered timeline events move into the outbox. Anything
not carried into a table is written to
`<state-dir>/legacy-import-archive.json`; only node-local state that
cannot be carried (queued broker frames, in-flight artifact/cache uploads,
pool provisioning marks) refuses the import, and only provably unreachable
tombstones/ephemeral tokens are reported as skipped. `preloop serve` still never migrates a
database on its own.

- The control plane now enforces per-namespace state and quotas on both store
backends. A `suspended` or `deleted` namespace starts no jobs; a `draining`
one finishes its queued jobs. `namespace_limits.max_running_jobs` and
Expand Down
11 changes: 10 additions & 1 deletion crates/preloop-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod init;

mod push;
mod server_install;
mod store;
mod update;
mod webhooks;

Expand Down Expand Up @@ -750,6 +751,10 @@ enum Command {
/// them without touching PRELOOP_HOME data unless asked.
Server(server_install::ServerArgs),

/// Control-database operations: one-time legacy import (and, in the
/// migrations build, `migrate` / `status` / `rollback`).
Store(store::StoreArgs),

/// Open a shell in a preserved VM.
Shell(ShellArgs),

Expand Down Expand Up @@ -1089,6 +1094,9 @@ async fn main() -> anyhow::Result<()> {
Command::Doctor(args) => github_setup::cmd_doctor(args).await,
Command::Secret(args) => github_setup::cmd_secret(args).await,
Command::Server(args) => server_install::run(args),
// Database operations are explicit and offline; never bootstrap the
// engine for them.
Command::Store(args) => store::run(args),
// Planning parses local workflow files only; do not bootstrap the
// control-plane engine for a command that never contacts it.
Command::Plan(args) => cmd_plan(args).await,
Expand Down Expand Up @@ -1120,7 +1128,8 @@ async fn main() -> anyhow::Result<()> {
| Command::Init(_)
| Command::Doctor(_)
| Command::Secret(_)
| Command::Server(_) => {
| Command::Server(_)
| Command::Store(_) => {
unreachable!("daemon commands handled before client startup")
}
},
Expand Down
101 changes: 101 additions & 0 deletions crates/preloop-cli/src/store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! `preloop store` — operator commands for the control database.
//!
//! `import-legacy` is the one-time, explicit move from a released v11
//! `preloop.db` (the durable-state store the control backend replaced) to a
//! fresh control SQLite database. It never runs from `preloop serve`: the
//! operator stops the server, imports, and moves the target into place (or
//! points `--store`/`PRELOOP_STORE_URL` at it).

use anyhow::Context;
use clap::{Args, Subcommand};
use preloop_runner_server::legacy_import::{self, ActivePolicy, ImportOptions};
use std::path::{Path, PathBuf};

#[derive(Debug, Args)]
pub struct StoreArgs {
#[command(subcommand)]
pub command: StoreCommand,
}

#[derive(Debug, Subcommand)]
pub enum StoreCommand {
/// Import a legacy v11 `preloop.db` into a fresh control database.
///
/// The source is opened read-only and hashed before and after; the target
/// is built in `<target>.importing` and only renamed into place once every
/// row is written and verified, so a failure leaves nothing to clean up
/// and a retry is safe.
#[command(name = "import-legacy")]
ImportLegacy(ImportLegacyArgs),
}

#[derive(Debug, Args)]
pub struct ImportLegacyArgs {
/// Legacy `preloop.db` to read (never modified).
#[arg(long, value_name = "LEGACY_DB")]
pub source: PathBuf,

/// Control database to create. Must not exist yet.
#[arg(long, value_name = "CONTROL_DB")]
pub target: PathBuf,

/// Server state directory the target will live in. Secret tiers and log
/// segments are written here. Defaults to the target's directory.
#[arg(long, value_name = "DIR")]
pub state_dir: Option<PathBuf>,

/// Cluster key file (32 bytes). Defaults to `PRELOOP_HMAC_KEY`, then
/// `<state_dir>/hmac-key.bin`. The importer never generates a key.
#[arg(long, value_name = "FILE")]
pub key: Option<PathBuf>,

/// Policy for work that was in flight when the legacy server stopped:
/// `refuse` (default, list it and abort), `requeue` (release claims and
/// requeue), or `cancel` (settle it as cancelled).
#[arg(long, default_value = "refuse", value_name = "POLICY")]
pub active: String,

/// Print the machine-readable report.
#[arg(long)]
pub json: bool,
}

pub fn run(args: StoreArgs) -> anyhow::Result<()> {
match args.command {
StoreCommand::ImportLegacy(args) => import_legacy(args),
}
}

fn import_legacy(args: ImportLegacyArgs) -> anyhow::Result<()> {
let state_dir = args
.state_dir
.clone()
.unwrap_or_else(|| target_dir(&args.target));
let key = legacy_import::load_import_key(&state_dir, args.key.as_deref())?;
let active = ActivePolicy::parse(&args.active)?;
let options = ImportOptions {
source: args.source.clone(),
target: args.target.clone(),
state_dir,
key,
active,
};
let report = legacy_import::run_import(&options)?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&report).context("serialize import report")?
);
} else {
print!("{}", report.render_human());
}
Ok(())
}

fn target_dir(target: &Path) -> PathBuf {
target
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
Loading