Skip to content

Commit a7a262b

Browse files
sanil-23claude
andauthored
chore(migrations): phase out PROFILE.md from disk on schema_version=1 (tinyhumansai#1734)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 38924e3 commit a7a262b

7 files changed

Lines changed: 1075 additions & 0 deletions

File tree

src/openhuman/config/schema/load.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,11 +738,19 @@ impl Config {
738738
recovered = config_was_corrupted,
739739
"Config loaded"
740740
);
741+
crate::openhuman::migrations::run_pending(&mut config).await;
741742
Ok(config)
742743
} else {
744+
// Fresh install: there is no legacy on-disk state, so stamp
745+
// the workspace at the current schema version up front. This
746+
// makes `run_pending` a fast no-op on the first launch
747+
// (nothing to migrate) and keeps the "first launch on this
748+
// workspace" semantics aligned with "current binary built
749+
// this workspace".
743750
let mut config = Config {
744751
config_path: config_path.clone(),
745752
workspace_dir,
753+
schema_version: crate::openhuman::migrations::CURRENT_SCHEMA_VERSION,
746754
..Default::default()
747755
};
748756
config.save().await?;
@@ -762,6 +770,13 @@ impl Config {
762770
initialized = true,
763771
"Config loaded"
764772
);
773+
// Defensive: still call run_pending. It will see
774+
// `schema_version == CURRENT` and return immediately, but
775+
// the call site stays symmetric with the existing-config
776+
// branch so a future migration that needs to fire on fresh
777+
// installs (vanishingly unlikely, but possible) doesn't
778+
// require touching this path.
779+
crate::openhuman::migrations::run_pending(&mut config).await;
765780
Ok(config)
766781
}
767782
}

src/openhuman/config/schema/types.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ pub struct Config {
2828
pub workspace_dir: PathBuf,
2929
#[serde(skip)]
3030
pub config_path: PathBuf,
31+
/// Workspace data-schema version. Bumped each time a one-shot data
32+
/// migration under [`crate::openhuman::migrations`] runs successfully.
33+
/// `#[serde(default)]` so existing `config.toml` files (which predate
34+
/// the field) load as version `0` and pick up pending migrations on
35+
/// the first launch of the new build.
36+
#[serde(default)]
37+
pub schema_version: u32,
3138
pub api_url: Option<String>,
3239
pub api_key: Option<String>,
3340
/// Custom LLM inference endpoint (OpenAI-compatible). When set together
@@ -272,6 +279,7 @@ impl Default for Config {
272279
Self {
273280
workspace_dir: openhuman_dir.join("workspace"),
274281
config_path: openhuman_dir.join("config.toml"),
282+
schema_version: 0,
275283
api_url: None,
276284
api_key: None,
277285
inference_url: None,

src/openhuman/migrations/mod.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//! Startup data migrations gated by [`Config::schema_version`].
2+
//!
3+
//! Each migration is a one-shot, idempotent transformation of on-disk
4+
//! data. The runner is invoked from [`Config::load_or_init`] and is a
5+
//! fast no-op for workspaces whose `schema_version` already matches
6+
//! [`CURRENT_SCHEMA_VERSION`]. Failures are logged but never block
7+
//! startup — the next launch retries.
8+
//!
9+
//! ## Adding a new migration
10+
//!
11+
//! 1. Add a module here (e.g. `mod my_migration;`).
12+
//! 2. Bump [`CURRENT_SCHEMA_VERSION`].
13+
//! 3. Extend [`run_pending`] with a `if config.schema_version < N`
14+
//! branch that calls the new module and bumps `config.schema_version`
15+
//! on success.
16+
//!
17+
//! ## Distinction from `crate::openhuman::migration`
18+
//!
19+
//! The sibling `migration` (singular) module is a user-triggered RPC
20+
//! that imports memory from a legacy OpenClaw workspace. This module
21+
//! (`migrations`, plural) is the automatic schema-version runner that
22+
//! fires once per workspace on first launch of a new build.
23+
24+
use crate::openhuman::config::Config;
25+
26+
mod phase_out_profile_md;
27+
28+
/// Current target schema version. Bumped alongside every new migration.
29+
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
30+
31+
/// Run any migrations whose `schema_version` gate hasn't yet been
32+
/// crossed for this workspace.
33+
///
34+
/// Best-effort: failures inside a migration are logged and never
35+
/// propagate. The `schema_version` is only bumped after a migration
36+
/// reports success **and** the bump is persisted via [`Config::save`],
37+
/// so a partial run leaves the gate unchanged and the next launch
38+
/// retries from the same starting version.
39+
pub async fn run_pending(config: &mut Config) {
40+
if config.schema_version >= CURRENT_SCHEMA_VERSION {
41+
log::debug!(
42+
"[migrations] schema_version={} already at current={} — nothing to do",
43+
config.schema_version,
44+
CURRENT_SCHEMA_VERSION
45+
);
46+
return;
47+
}
48+
49+
log::info!(
50+
"[migrations] running pending migrations schema_version={} -> {}",
51+
config.schema_version,
52+
CURRENT_SCHEMA_VERSION
53+
);
54+
55+
// 0 -> 1: phase out PROFILE.md from persisted session transcripts.
56+
//
57+
// The migration body is synchronous fs I/O (read_dir + read_to_string +
58+
// write across potentially hundreds of files). `run_pending` is called
59+
// from `Config::load_or_init`, which runs on a tokio runtime — so we
60+
// move the blocking walk onto a dedicated `spawn_blocking` task to
61+
// keep the executor responsive.
62+
if config.schema_version < 1 {
63+
let workspace_dir = config.workspace_dir.clone();
64+
let run_result =
65+
tokio::task::spawn_blocking(move || phase_out_profile_md::run(&workspace_dir)).await;
66+
match run_result {
67+
Ok(Ok(stats)) => {
68+
let previous_version = config.schema_version;
69+
config.schema_version = 1;
70+
if let Err(err) = config.save().await {
71+
// Roll the in-memory version back so a subsequent
72+
// `load_or_init` (or future migration) doesn't believe
73+
// we've already crossed this gate when disk still
74+
// says 0. Next launch retries from the same start.
75+
config.schema_version = previous_version;
76+
log::warn!(
77+
"[migrations] phase_out_profile_md ran but config.save failed: \
78+
{err:#} — rolled in-memory schema_version back to {previous_version}, \
79+
will retry on next launch"
80+
);
81+
return;
82+
}
83+
log::info!(
84+
"[migrations] schema_version bumped to 1 (phase_out_profile_md \
85+
scanned={} cleaned={} skipped={} errors={})",
86+
stats.scanned,
87+
stats.cleaned,
88+
stats.skipped,
89+
stats.errors
90+
);
91+
}
92+
Ok(Err(err)) => {
93+
log::warn!(
94+
"[migrations] phase_out_profile_md failed: {err:#} — \
95+
will retry on next launch"
96+
);
97+
}
98+
Err(join_err) => {
99+
log::warn!(
100+
"[migrations] phase_out_profile_md blocking task did not complete: \
101+
{join_err} — will retry on next launch"
102+
);
103+
}
104+
}
105+
}
106+
}
107+
108+
#[cfg(test)]
109+
#[path = "mod_tests.rs"]
110+
mod tests;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
use super::*;
2+
use crate::openhuman::agent::harness::session::transcript::{
3+
read_transcript, write_transcript, TranscriptMeta,
4+
};
5+
use crate::openhuman::providers::ChatMessage;
6+
use std::fs;
7+
use std::path::Path;
8+
use tempfile::TempDir;
9+
10+
fn tainted_prompt() -> String {
11+
"## Identity\n\nYou are an assistant.\n\n\
12+
### PROFILE.md\n\n\
13+
style/calm tooling/rust\n\n\
14+
### Tools\n\n- shell\n"
15+
.to_string()
16+
}
17+
18+
fn meta() -> TranscriptMeta {
19+
TranscriptMeta {
20+
agent_name: "main".into(),
21+
dispatcher: "native".into(),
22+
created: "2026-05-01T00:00:00Z".into(),
23+
updated: "2026-05-01T00:00:00Z".into(),
24+
turn_count: 1,
25+
input_tokens: 0,
26+
output_tokens: 0,
27+
cached_input_tokens: 0,
28+
charged_amount_usd: 0.0,
29+
thread_id: None,
30+
}
31+
}
32+
33+
fn config_in(tmp: &TempDir) -> Config {
34+
Config {
35+
config_path: tmp.path().join("config.toml"),
36+
workspace_dir: tmp.path().join("workspace"),
37+
..Default::default()
38+
}
39+
}
40+
41+
fn seed_tainted_transcript(workspace_dir: &Path) -> std::path::PathBuf {
42+
let raw_dir = workspace_dir.join("session_raw");
43+
fs::create_dir_all(&raw_dir).unwrap();
44+
let path = raw_dir.join("1700000000_main.jsonl");
45+
let messages = vec![
46+
ChatMessage::system(tainted_prompt()),
47+
ChatMessage::user("hello"),
48+
];
49+
write_transcript(&path, &messages, &meta(), None).unwrap();
50+
path
51+
}
52+
53+
#[tokio::test]
54+
async fn run_pending_skips_when_version_current() {
55+
let tmp = TempDir::new().unwrap();
56+
let path = seed_tainted_transcript(&tmp.path().join("workspace"));
57+
let before = fs::read(&path).unwrap();
58+
59+
let mut config = config_in(&tmp);
60+
config.schema_version = CURRENT_SCHEMA_VERSION;
61+
run_pending(&mut config).await;
62+
63+
assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
64+
let after = fs::read(&path).unwrap();
65+
assert_eq!(before, after, "transcript must be untouched");
66+
}
67+
68+
#[tokio::test]
69+
async fn run_pending_runs_phase_out_when_version_zero() {
70+
let tmp = TempDir::new().unwrap();
71+
let path = seed_tainted_transcript(&tmp.path().join("workspace"));
72+
73+
let mut config = config_in(&tmp);
74+
assert_eq!(config.schema_version, 0);
75+
run_pending(&mut config).await;
76+
77+
assert_eq!(config.schema_version, 1);
78+
let session = read_transcript(&path).unwrap();
79+
assert!(
80+
!session.messages[0].content.contains("### PROFILE.md"),
81+
"PROFILE.md block must be stripped, got:\n{}",
82+
session.messages[0].content
83+
);
84+
85+
let on_disk = std::fs::read_to_string(&config.config_path).unwrap();
86+
assert!(
87+
on_disk.contains("schema_version = 1"),
88+
"saved config.toml must record schema_version=1, got:\n{on_disk}"
89+
);
90+
}
91+
92+
#[tokio::test]
93+
async fn run_pending_bumps_version_on_fresh_install() {
94+
let tmp = TempDir::new().unwrap();
95+
// No session_raw/ at all — pure fresh install.
96+
fs::create_dir_all(tmp.path().join("workspace")).unwrap();
97+
98+
let mut config = config_in(&tmp);
99+
run_pending(&mut config).await;
100+
101+
assert_eq!(config.schema_version, 1);
102+
let on_disk = std::fs::read_to_string(&config.config_path).unwrap();
103+
assert!(on_disk.contains("schema_version = 1"));
104+
}
105+
106+
#[tokio::test]
107+
async fn run_pending_rolls_back_schema_version_when_save_fails() {
108+
let tmp = TempDir::new().unwrap();
109+
seed_tainted_transcript(&tmp.path().join("workspace"));
110+
111+
let mut config = config_in(&tmp);
112+
// Point config.save() at a path whose parent directory cannot be
113+
// created (a regular file occupies that name), forcing save() to
114+
// error after the migration body has succeeded.
115+
let blocker = tmp.path().join("blocker");
116+
fs::write(&blocker, "not a directory").unwrap();
117+
config.config_path = blocker.join("nested").join("config.toml");
118+
119+
assert_eq!(config.schema_version, 0);
120+
run_pending(&mut config).await;
121+
122+
assert_eq!(
123+
config.schema_version, 0,
124+
"save failed → in-memory schema_version must be rolled back to 0"
125+
);
126+
}
127+
128+
#[tokio::test]
129+
async fn run_pending_is_a_no_op_on_second_invocation() {
130+
let tmp = TempDir::new().unwrap();
131+
seed_tainted_transcript(&tmp.path().join("workspace"));
132+
133+
let mut config = config_in(&tmp);
134+
run_pending(&mut config).await;
135+
assert_eq!(config.schema_version, 1);
136+
137+
// Mutate the config file timestamp marker by reading + comparing
138+
// before vs after the second invocation.
139+
let before = fs::metadata(&config.config_path).unwrap().modified().ok();
140+
std::thread::sleep(std::time::Duration::from_millis(20));
141+
run_pending(&mut config).await;
142+
let after = fs::metadata(&config.config_path).unwrap().modified().ok();
143+
144+
assert_eq!(config.schema_version, 1);
145+
assert_eq!(
146+
before, after,
147+
"config.toml must not be re-saved on second run"
148+
);
149+
}

0 commit comments

Comments
 (0)