chore: replace legacy SQLite migrations with baselines

This commit is contained in:
2026-09-04 12:48:45 +09:00
parent 64c268582d
commit 89856eb7c3
13 changed files with 1403 additions and 8092 deletions
@@ -1513,33 +1513,6 @@ mod tests {
assert_eq!(revision, first.snapshot);
}
#[tokio::test]
async fn migration_materializes_main_for_existing_workspace_without_config() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::configure_sqlite(&conn).unwrap();
crate::store::apply_migrations_through(&conn, 30).unwrap();
conn.execute(
"INSERT INTO workspaces (
workspace_id, display_name, state, created_at, updated_at
) VALUES ('legacy', 'Legacy', 'active', '2026-08-06T00:00:00Z', '2026-08-06T00:00:00Z')",
[],
)
.unwrap();
crate::store::persist_workspace_config_schema_bundles(&conn).unwrap();
crate::store::materialize_main_config_entrypoint(&conn).unwrap();
let state = load_state(&conn, "legacy").unwrap().unwrap();
assert!(
state
.snapshot
.entries
.contains_key(&path(MAIN_CONFIG_ENTRYPOINT))
);
assert_eq!(
state.contract.entrypoints,
vec![path(MAIN_CONFIG_ENTRYPOINT)]
);
}
#[test]
fn exports_typescript_transport_contract() {
use ts_rs::TS;
File diff suppressed because it is too large Load Diff
+2 -59
View File
@@ -20,7 +20,6 @@ enum Command {
Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> },
Skills(SkillsCommand),
Help,
}
@@ -71,17 +70,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => {
let database = database.unwrap_or_else(ServerConfig::default_server_database_path);
let plan = SqliteWorkspaceStore::migration_plan(&database).map_err(|error| {
CliError(format!(
"migration dry-run failed for {}: {error}",
database.display()
))
})?;
println!("{}", serde_json::to_string_pretty(&plan)?);
Ok(())
}
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()),
}
@@ -96,7 +84,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest),
"skills" => parse_skills_command(rest),
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
@@ -625,32 +612,6 @@ fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result<PathBu
))
}
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
let mut dry_run = false;
let mut database = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--dry-run" => dry_run = true,
"--database" => {
index += 1;
database =
Some(PathBuf::from(args.get(index).ok_or_else(|| {
CliError("--database requires a path".to_string())
})?));
}
value => {
return Err(CliError(format!("unknown migrate option: {value}")));
}
}
index += 1;
}
if !dry_run {
return Err(CliError("migrate currently requires --dry-run".to_string()));
}
Ok(Command::MigrateDryRun { database })
}
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_skills_help();
@@ -770,8 +731,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
@@ -783,8 +743,7 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
);
}
@@ -823,22 +782,6 @@ mod tests {
assert_eq!(name, "debug-rust");
}
#[test]
fn parse_migrate_requires_dry_run_and_accepts_database_path() {
let error = parse_migrate_command(&[]).unwrap_err();
assert_eq!(error.to_string(), "migrate currently requires --dry-run");
let command = parse_migrate_command(&[
"--dry-run".to_string(),
"--database".to_string(),
"/tmp/server.db".to_string(),
])
.unwrap();
let Command::MigrateDryRun { database } = command else {
panic!("expected migration dry-run command");
};
assert_eq!(database, Some(PathBuf::from("/tmp/server.db")));
}
#[test]
fn parse_serve_accepts_listen_and_host_config() {
let args = vec![
-127
View File
@@ -163,101 +163,6 @@ pub enum WorkerRetentionError {
Invalid(String),
}
pub(crate) fn repair_worker_diagnostics_archive_table(conn: &Connection) -> crate::Result<bool> {
let existed: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='worker_diagnostics_archives')",
[],
|row| row.get(0),
)?;
if !existed {
conn.execute_batch(
"CREATE TABLE worker_diagnostics_archives (
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);",
)?;
}
Ok(!existed)
}
pub(crate) fn create_worker_retention_tables(conn: &Connection) -> crate::Result<()> {
conn.execute_batch(r#"
CREATE TABLE workspace_worker_retention_policy_revisions (
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')),
metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')),
archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')),
archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')),
diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,policy_id,revision),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE workspace_worker_retention_policies (
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE worker_removal_operations (
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
worker_revision TEXT NOT NULL, run_generation INTEGER NOT NULL CHECK(run_generation>=0),
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL,
diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')),
reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE INDEX worker_removal_operations_worker_idx ON worker_removal_operations(workspace_id,runtime_id,worker_id,created_at);
CREATE TABLE worker_session_archives (
archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL,
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
CREATE TABLE worker_diagnostics_archives (
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE worker_tombstones (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
PRIMARY KEY(workspace_id,runtime_id,worker_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id),
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
CREATE TABLE worker_orphan_diagnostics (
diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
category TEXT NOT NULL, detail TEXT NOT NULL, observed_at TEXT NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE worker_retention_audit_events (
event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
event_kind TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN
INSERT INTO workspace_worker_retention_policy_revisions
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at)
VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at);
END;
"#)?;
let now = Utc::now().to_rfc3339();
conn.execute("INSERT OR IGNORE INTO workspace_worker_retention_policy_revisions
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
SELECT workspace_id,?1,1,'archive','tombstone','forever',NULL,'purge',NULL,?2 FROM workspaces", params![CONSERVATIVE_POLICY_ID,now])?;
conn.execute("INSERT OR IGNORE INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at)
SELECT workspace_id,?1,1,?2 FROM workspaces", params![CONSERVATIVE_POLICY_ID,now])?;
Ok(())
}
impl SqliteWorkspaceStore {
pub fn worker_retention_policy(
&self,
@@ -1724,36 +1629,4 @@ mod tests {
);
assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Failed);
}
#[test]
fn old_schema_upgrade_seeds_existing_workspace() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
{
let connection = rusqlite::Connection::open(&path).unwrap();
crate::store::configure_sqlite(&connection).unwrap();
crate::store::apply_migrations_through(&connection, 27).unwrap();
connection
.execute(
"INSERT INTO accounts(
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', 'old', 'old')",
[],
)
.unwrap();
connection
.execute(
"INSERT INTO workspaces(
workspace_id, display_name, state, created_at, updated_at, owner_account_id
) VALUES ('legacy', 'Legacy', 'active', 'old', 'old', 'owner-account')",
[],
)
.unwrap();
}
let reopened = SqliteWorkspaceStore::open(&path).unwrap();
let p = reopened.worker_retention_policy("legacy").unwrap().unwrap();
assert_eq!(p.policy_id, CONSERVATIVE_POLICY_ID);
assert_eq!(p.session_disposition, SessionDisposition::Archive);
assert_eq!(p.metadata_disposition, MetadataDisposition::Tombstone);
}
}
File diff suppressed because it is too large Load Diff
@@ -91,44 +91,6 @@ pub struct WorkdirRemovalGuard {
pub detail: &'static str,
}
pub(crate) fn create_workdir_removal_operations(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE workdir_removal_operations (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
workdir_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
materialization_fingerprint TEXT NOT NULL,
source_actor TEXT NOT NULL,
reason TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')),
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)),
disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')),
failure_category TEXT,
attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0),
attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending
ON workdir_removal_operations(workspace_id, workdir_id)
WHERE state = 'pending';
CREATE INDEX idx_workdir_removal_operations_recovery
ON workdir_removal_operations(workspace_id, state, retryable, updated_at);
CREATE INDEX idx_workdir_removal_operations_workdir
ON workdir_removal_operations(workspace_id, workdir_id, created_at DESC);
"#,
)?;
Ok(())
}
pub fn workdir_materialization_fingerprint(record: &WorkdirRegistryRecord) -> String {
let bytes = serde_json::to_vec(&serde_json::json!([
record.workspace_id,