Spaces:
Sleeping
Sleeping
更新 main.rs
Browse files- src/main.rs +134 -90
src/main.rs
CHANGED
|
@@ -11,15 +11,18 @@ use reqwest::StatusCode;
|
|
| 11 |
use serde::{Deserialize, Serialize};
|
| 12 |
use tokio::io::AsyncWriteExt;
|
| 13 |
use tokio::process::{Child, Command};
|
| 14 |
-
use tokio::time::{interval, timeout};
|
| 15 |
use walkdir::WalkDir;
|
| 16 |
|
| 17 |
-
/// The OpenClaw config directory synced to/from HuggingFace.
|
| 18 |
const WORKSPACE_DIR: &str = "/home/user/.openclaw";
|
| 19 |
-
|
| 20 |
const STATE_FILE: &str = ".hf-sync-state.json";
|
| 21 |
const FINAL_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
#[derive(Debug, Clone)]
|
| 24 |
struct Config {
|
| 25 |
token: String,
|
|
@@ -96,10 +99,28 @@ async fn run() -> Result<()> {
|
|
| 96 |
|
| 97 |
let client = build_client(&cfg)?;
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
rebuild_sync_state(&cfg.workspace).await?;
|
| 102 |
|
|
|
|
| 103 |
let mut child = spawn_child_from_args()?;
|
| 104 |
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
| 105 |
let mut ticker = interval(cfg.sync_interval);
|
|
@@ -123,14 +144,14 @@ async fn run() -> Result<()> {
|
|
| 123 |
status = child.wait() => {
|
| 124 |
match status {
|
| 125 |
Ok(s) => {
|
| 126 |
-
eprintln!("child exited
|
| 127 |
if let Err(err) = push_workspace(&client, &cfg).await {
|
| 128 |
eprintln!("final push after child exit failed: {err:#}");
|
| 129 |
}
|
| 130 |
exit(s.code().unwrap_or(1));
|
| 131 |
}
|
| 132 |
Err(err) => {
|
| 133 |
-
eprintln!("
|
| 134 |
break;
|
| 135 |
}
|
| 136 |
}
|
|
@@ -141,10 +162,44 @@ async fn run() -> Result<()> {
|
|
| 141 |
Ok(())
|
| 142 |
}
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
fn load_config() -> Result<Config> {
|
| 145 |
let token = env::var("HF_TOKEN").context("HF_TOKEN is required")?;
|
| 146 |
|
| 147 |
-
// Accept OPENCLAW_DATASET_REPO (canonical) with HF_DATASET_ID as fallback.
|
| 148 |
let dataset_id = env::var("OPENCLAW_DATASET_REPO")
|
| 149 |
.or_else(|_| env::var("HF_DATASET_ID"))
|
| 150 |
.context("OPENCLAW_DATASET_REPO (or HF_DATASET_ID) is required")?;
|
|
@@ -154,10 +209,10 @@ fn load_config() -> Result<Config> {
|
|
| 154 |
let sync_interval_secs: u64 = env::var("SYNC_INTERVAL")
|
| 155 |
.unwrap_or_else(|_| "60".to_string())
|
| 156 |
.parse()
|
| 157 |
-
.context("SYNC_INTERVAL must be an integer
|
| 158 |
|
| 159 |
if sync_interval_secs == 0 {
|
| 160 |
-
return Err(anyhow!("SYNC_INTERVAL must be
|
| 161 |
}
|
| 162 |
|
| 163 |
Ok(Config {
|
|
@@ -168,14 +223,12 @@ fn load_config() -> Result<Config> {
|
|
| 168 |
})
|
| 169 |
}
|
| 170 |
|
| 171 |
-
fn validate_dataset_id(
|
| 172 |
-
let mut parts =
|
| 173 |
let owner = parts.next().unwrap_or_default();
|
| 174 |
let repo = parts.next().unwrap_or_default();
|
| 175 |
if owner.is_empty() || repo.is_empty() || parts.next().is_some() {
|
| 176 |
-
return Err(anyhow!(
|
| 177 |
-
"OPENCLAW_DATASET_REPO must be in the form owner/name"
|
| 178 |
-
));
|
| 179 |
}
|
| 180 |
Ok(())
|
| 181 |
}
|
|
@@ -186,33 +239,33 @@ fn build_client(cfg: &Config) -> Result<reqwest::Client> {
|
|
| 186 |
AUTHORIZATION,
|
| 187 |
format!("Bearer {}", cfg.token)
|
| 188 |
.parse()
|
| 189 |
-
.context("invalid HF_TOKEN
|
| 190 |
);
|
| 191 |
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
|
| 192 |
|
| 193 |
reqwest::Client::builder()
|
| 194 |
.default_headers(headers)
|
|
|
|
| 195 |
.build()
|
| 196 |
-
.context("failed to
|
| 197 |
}
|
| 198 |
|
| 199 |
async fn ensure_dataset_exists(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 200 |
let url = format!("https://huggingface.co/api/datasets/{}", cfg.dataset_id);
|
| 201 |
-
let
|
| 202 |
|
| 203 |
-
match
|
| 204 |
StatusCode::OK => {
|
| 205 |
-
let repo: RepoLookup =
|
| 206 |
-
eprintln!("dataset
|
| 207 |
Ok(())
|
| 208 |
}
|
| 209 |
StatusCode::NOT_FOUND => {
|
| 210 |
-
let
|
| 211 |
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
| 212 |
.unwrap_or(false);
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
eprintln!("dataset {} not found — AUTO_CREATE_DATASET=true, creating…", cfg.dataset_id);
|
| 216 |
create_private_dataset(client, cfg).await
|
| 217 |
} else {
|
| 218 |
Err(anyhow!(
|
|
@@ -222,18 +275,15 @@ async fn ensure_dataset_exists(client: &reqwest::Client, cfg: &Config) -> Result
|
|
| 222 |
))
|
| 223 |
}
|
| 224 |
}
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
}
|
| 230 |
}
|
| 231 |
|
| 232 |
async fn create_private_dataset(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 233 |
-
let (owner, name) = cfg
|
| 234 |
-
.dataset_id
|
| 235 |
-
.split_once('/')
|
| 236 |
-
.ok_or_else(|| anyhow!("OPENCLAW_DATASET_REPO must be in the form owner/name"))?;
|
| 237 |
|
| 238 |
let username = client
|
| 239 |
.get("https://huggingface.co/api/whoami-v2")
|
|
@@ -254,40 +304,42 @@ async fn create_private_dataset(client: &reqwest::Client, cfg: &Config) -> Resul
|
|
| 254 |
repo_type: "dataset".to_string(),
|
| 255 |
};
|
| 256 |
|
| 257 |
-
let
|
| 258 |
.post("https://huggingface.co/api/repos/create")
|
| 259 |
.json(&req)
|
| 260 |
.send()
|
| 261 |
.await?;
|
| 262 |
|
| 263 |
-
if
|
| 264 |
Ok(())
|
| 265 |
} else {
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
| 269 |
}
|
| 270 |
}
|
| 271 |
|
| 272 |
async fn pull_workspace(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 273 |
-
eprintln!("pulling workspace from
|
| 274 |
-
let
|
| 275 |
|
| 276 |
-
for file in &
|
| 277 |
let url = format!(
|
| 278 |
"https://huggingface.co/datasets/{}/resolve/main/{}",
|
| 279 |
cfg.dataset_id, file
|
| 280 |
);
|
| 281 |
let bytes = client.get(url).send().await?.error_for_status()?.bytes().await?;
|
| 282 |
let target = cfg.workspace.join(file);
|
| 283 |
-
if let Some(
|
| 284 |
-
tokio::fs::create_dir_all(
|
| 285 |
}
|
| 286 |
let mut f = tokio::fs::File::create(&target).await?;
|
| 287 |
f.write_all(&bytes).await?;
|
| 288 |
}
|
| 289 |
|
| 290 |
-
eprintln!("pull complete: {} files
|
| 291 |
Ok(())
|
| 292 |
}
|
| 293 |
|
|
@@ -296,16 +348,15 @@ async fn list_remote_files(client: &reqwest::Client, cfg: &Config) -> Result<Vec
|
|
| 296 |
"https://huggingface.co/api/datasets/{}/tree/main?recursive=true",
|
| 297 |
cfg.dataset_id
|
| 298 |
);
|
| 299 |
-
let
|
| 300 |
-
if
|
| 301 |
-
return Ok(
|
| 302 |
}
|
| 303 |
-
let entries: Vec<TreeEntry> =
|
| 304 |
Ok(entries.into_iter().filter(|e| e.kind == "file").map(|e| e.path).collect())
|
| 305 |
}
|
| 306 |
|
| 307 |
async fn push_workspace(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 308 |
-
eprintln!("pushing workspace to HuggingFace…");
|
| 309 |
let state_path = cfg.workspace.join(STATE_FILE);
|
| 310 |
let mut state = load_state(&state_path).await?;
|
| 311 |
|
|
@@ -317,46 +368,43 @@ async fn push_workspace(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
|
| 317 |
.filter_map(|e| e.ok())
|
| 318 |
.filter(|e| e.file_type().is_file())
|
| 319 |
{
|
| 320 |
-
let
|
| 321 |
-
if
|
| 322 |
continue;
|
| 323 |
}
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
.strip_prefix(&cfg.workspace)
|
| 327 |
-
.context("failed to strip workspace prefix")?
|
| 328 |
.to_string_lossy()
|
| 329 |
.replace('\\', "/");
|
| 330 |
|
| 331 |
-
let bytes = tokio::fs::read(
|
| 332 |
let md5 = format!("{:x}", md5::compute(&bytes));
|
| 333 |
let size = bytes.len() as u64;
|
| 334 |
|
| 335 |
let changed = state
|
| 336 |
.files
|
| 337 |
-
.get(&
|
| 338 |
.map(|s| s.md5 != md5 || s.size != size)
|
| 339 |
.unwrap_or(true);
|
| 340 |
|
| 341 |
if changed {
|
| 342 |
operations.push(CommitOperation::AddOrUpdate {
|
| 343 |
-
path:
|
| 344 |
encoding: "base64".to_string(),
|
| 345 |
content: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
| 346 |
});
|
| 347 |
}
|
| 348 |
-
|
| 349 |
-
current.insert(relative, FileState { md5, size });
|
| 350 |
}
|
| 351 |
|
| 352 |
-
let
|
| 353 |
-
let
|
| 354 |
-
for removed in
|
| 355 |
operations.push(CommitOperation::Delete { path: removed.clone() });
|
| 356 |
}
|
| 357 |
|
| 358 |
if operations.is_empty() {
|
| 359 |
-
eprintln!("no workspace changes
|
| 360 |
return Ok(());
|
| 361 |
}
|
| 362 |
|
|
@@ -369,11 +417,13 @@ async fn push_workspace(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
|
| 369 |
"https://huggingface.co/api/datasets/{}/commit/main",
|
| 370 |
cfg.dataset_id
|
| 371 |
);
|
| 372 |
-
let
|
| 373 |
-
if !
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
| 377 |
}
|
| 378 |
|
| 379 |
state.files = current;
|
|
@@ -383,25 +433,24 @@ async fn push_workspace(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
|
| 383 |
}
|
| 384 |
|
| 385 |
async fn rebuild_sync_state(workspace: &Path) -> Result<()> {
|
| 386 |
-
let mut files = HashMap::<String, FileState>::new();
|
| 387 |
let state_path = workspace.join(STATE_FILE);
|
|
|
|
| 388 |
|
| 389 |
for entry in WalkDir::new(workspace)
|
| 390 |
.into_iter()
|
| 391 |
.filter_map(|e| e.ok())
|
| 392 |
.filter(|e| e.file_type().is_file())
|
| 393 |
{
|
| 394 |
-
let
|
| 395 |
-
if
|
| 396 |
continue;
|
| 397 |
}
|
| 398 |
-
let
|
| 399 |
-
.strip_prefix(workspace)
|
| 400 |
-
.context("failed to strip workspace prefix")?
|
| 401 |
.to_string_lossy()
|
| 402 |
.replace('\\', "/");
|
| 403 |
-
let bytes = tokio::fs::read(
|
| 404 |
-
files.insert(
|
| 405 |
md5: format!("{:x}", md5::compute(&bytes)),
|
| 406 |
size: bytes.len() as u64,
|
| 407 |
});
|
|
@@ -415,7 +464,7 @@ async fn load_state(path: &Path) -> Result<SyncState> {
|
|
| 415 |
return Ok(SyncState::default());
|
| 416 |
}
|
| 417 |
let raw = tokio::fs::read(path).await?;
|
| 418 |
-
Ok(serde_json::from_slice(&raw).context("
|
| 419 |
}
|
| 420 |
|
| 421 |
async fn save_state(path: &Path, state: &SyncState) -> Result<()> {
|
|
@@ -426,9 +475,7 @@ async fn save_state(path: &Path, state: &SyncState) -> Result<()> {
|
|
| 426 |
fn spawn_child_from_args() -> Result<Child> {
|
| 427 |
let args: Vec<String> = env::args().skip(1).collect();
|
| 428 |
if args.is_empty() {
|
| 429 |
-
return Err(anyhow!(
|
| 430 |
-
"no command provided — pass the child command as entrypoint arguments"
|
| 431 |
-
));
|
| 432 |
}
|
| 433 |
Command::new(&args[0])
|
| 434 |
.args(&args[1..])
|
|
@@ -441,10 +488,10 @@ fn spawn_child_from_args() -> Result<Child> {
|
|
| 441 |
|
| 442 |
fn forward_sigterm(child: &mut Child) {
|
| 443 |
if let Some(id) = child.id() {
|
| 444 |
-
// SAFETY:
|
| 445 |
let ret = unsafe { libc::kill(id as libc::pid_t, libc::SIGTERM) };
|
| 446 |
if ret != 0 {
|
| 447 |
-
eprintln!("
|
| 448 |
}
|
| 449 |
}
|
| 450 |
}
|
|
@@ -452,15 +499,12 @@ fn forward_sigterm(child: &mut Child) {
|
|
| 452 |
async fn wait_for_child_shutdown(child: &mut Child) {
|
| 453 |
match timeout(FINAL_WAIT_TIMEOUT, child.wait()).await {
|
| 454 |
Ok(Ok(s)) => eprintln!("child exited after SIGTERM: {s}"),
|
| 455 |
-
Ok(Err(e)) => eprintln!("
|
| 456 |
Err(_) => {
|
| 457 |
-
eprintln!("child
|
| 458 |
if let Some(id) = child.id() {
|
| 459 |
-
// SAFETY:
|
| 460 |
-
|
| 461 |
-
if ret != 0 {
|
| 462 |
-
eprintln!("failed to SIGKILL: {}", std::io::Error::last_os_error());
|
| 463 |
-
}
|
| 464 |
}
|
| 465 |
}
|
| 466 |
}
|
|
|
|
| 11 |
use serde::{Deserialize, Serialize};
|
| 12 |
use tokio::io::AsyncWriteExt;
|
| 13 |
use tokio::process::{Child, Command};
|
| 14 |
+
use tokio::time::{interval, sleep, timeout};
|
| 15 |
use walkdir::WalkDir;
|
| 16 |
|
|
|
|
| 17 |
const WORKSPACE_DIR: &str = "/home/user/.openclaw";
|
|
|
|
| 18 |
const STATE_FILE: &str = ".hf-sync-state.json";
|
| 19 |
const FINAL_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
|
| 20 |
|
| 21 |
+
/// How long to wait for HF to become reachable at startup before giving up
|
| 22 |
+
/// and continuing with an empty / local workspace.
|
| 23 |
+
const NETWORK_STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
|
| 24 |
+
const NETWORK_RETRY_INTERVAL: Duration = Duration::from_secs(5);
|
| 25 |
+
|
| 26 |
#[derive(Debug, Clone)]
|
| 27 |
struct Config {
|
| 28 |
token: String,
|
|
|
|
| 99 |
|
| 100 |
let client = build_client(&cfg)?;
|
| 101 |
|
| 102 |
+
// ── Startup sync: wait for network, then pull. Non-fatal on failure. ────────
|
| 103 |
+
// HF Space containers sometimes take 10-30s for DNS/routing to become
|
| 104 |
+
// available after the container starts. We wait up to 60s, then proceed.
|
| 105 |
+
eprintln!("waiting for network connectivity to huggingface.co…");
|
| 106 |
+
match wait_for_network(&client).await {
|
| 107 |
+
true => {
|
| 108 |
+
eprintln!("network is up — proceeding with startup sync");
|
| 109 |
+
if let Err(err) = startup_sync(&client, &cfg).await {
|
| 110 |
+
eprintln!("startup sync failed (continuing with local workspace): {err:#}");
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
false => {
|
| 114 |
+
eprintln!(
|
| 115 |
+
"huggingface.co unreachable after {:?} — starting with local workspace",
|
| 116 |
+
NETWORK_STARTUP_TIMEOUT
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
rebuild_sync_state(&cfg.workspace).await?;
|
| 122 |
|
| 123 |
+
// ── Spawn child process ──────────────────────────────────────────────────────
|
| 124 |
let mut child = spawn_child_from_args()?;
|
| 125 |
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
| 126 |
let mut ticker = interval(cfg.sync_interval);
|
|
|
|
| 144 |
status = child.wait() => {
|
| 145 |
match status {
|
| 146 |
Ok(s) => {
|
| 147 |
+
eprintln!("child exited: {s}");
|
| 148 |
if let Err(err) = push_workspace(&client, &cfg).await {
|
| 149 |
eprintln!("final push after child exit failed: {err:#}");
|
| 150 |
}
|
| 151 |
exit(s.code().unwrap_or(1));
|
| 152 |
}
|
| 153 |
Err(err) => {
|
| 154 |
+
eprintln!("error waiting for child: {err:#}");
|
| 155 |
break;
|
| 156 |
}
|
| 157 |
}
|
|
|
|
| 162 |
Ok(())
|
| 163 |
}
|
| 164 |
|
| 165 |
+
/// Poll HF until reachable or timeout. Returns true if network came up.
|
| 166 |
+
async fn wait_for_network(client: &reqwest::Client) -> bool {
|
| 167 |
+
let probe_url = "https://huggingface.co/api/whoami-v2";
|
| 168 |
+
let deadline = tokio::time::Instant::now() + NETWORK_STARTUP_TIMEOUT;
|
| 169 |
+
|
| 170 |
+
loop {
|
| 171 |
+
// Use a short per-attempt timeout so we fail fast and retry
|
| 172 |
+
let attempt = timeout(
|
| 173 |
+
Duration::from_secs(8),
|
| 174 |
+
client.get(probe_url).send(),
|
| 175 |
+
)
|
| 176 |
+
.await;
|
| 177 |
+
|
| 178 |
+
match attempt {
|
| 179 |
+
// Any HTTP response (even 401) means DNS + routing is working
|
| 180 |
+
Ok(Ok(_)) => return true,
|
| 181 |
+
Ok(Err(err)) => eprintln!("network probe error: {err}"),
|
| 182 |
+
Err(_) => eprintln!("network probe timed out"),
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if tokio::time::Instant::now() >= deadline {
|
| 186 |
+
return false;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
eprintln!("retrying in {:?}…", NETWORK_RETRY_INTERVAL);
|
| 190 |
+
sleep(NETWORK_RETRY_INTERVAL).await;
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/// Pull workspace from HF dataset on first boot.
|
| 195 |
+
async fn startup_sync(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 196 |
+
ensure_dataset_exists(client, cfg).await?;
|
| 197 |
+
pull_workspace(client, cfg).await
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
fn load_config() -> Result<Config> {
|
| 201 |
let token = env::var("HF_TOKEN").context("HF_TOKEN is required")?;
|
| 202 |
|
|
|
|
| 203 |
let dataset_id = env::var("OPENCLAW_DATASET_REPO")
|
| 204 |
.or_else(|_| env::var("HF_DATASET_ID"))
|
| 205 |
.context("OPENCLAW_DATASET_REPO (or HF_DATASET_ID) is required")?;
|
|
|
|
| 209 |
let sync_interval_secs: u64 = env::var("SYNC_INTERVAL")
|
| 210 |
.unwrap_or_else(|_| "60".to_string())
|
| 211 |
.parse()
|
| 212 |
+
.context("SYNC_INTERVAL must be an integer")?;
|
| 213 |
|
| 214 |
if sync_interval_secs == 0 {
|
| 215 |
+
return Err(anyhow!("SYNC_INTERVAL must be > 0"));
|
| 216 |
}
|
| 217 |
|
| 218 |
Ok(Config {
|
|
|
|
| 223 |
})
|
| 224 |
}
|
| 225 |
|
| 226 |
+
fn validate_dataset_id(id: &str) -> Result<()> {
|
| 227 |
+
let mut parts = id.split('/');
|
| 228 |
let owner = parts.next().unwrap_or_default();
|
| 229 |
let repo = parts.next().unwrap_or_default();
|
| 230 |
if owner.is_empty() || repo.is_empty() || parts.next().is_some() {
|
| 231 |
+
return Err(anyhow!("OPENCLAW_DATASET_REPO must be owner/name"));
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
Ok(())
|
| 234 |
}
|
|
|
|
| 239 |
AUTHORIZATION,
|
| 240 |
format!("Bearer {}", cfg.token)
|
| 241 |
.parse()
|
| 242 |
+
.context("invalid HF_TOKEN")?,
|
| 243 |
);
|
| 244 |
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
|
| 245 |
|
| 246 |
reqwest::Client::builder()
|
| 247 |
.default_headers(headers)
|
| 248 |
+
.timeout(Duration::from_secs(30))
|
| 249 |
.build()
|
| 250 |
+
.context("failed to build HTTP client")
|
| 251 |
}
|
| 252 |
|
| 253 |
async fn ensure_dataset_exists(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 254 |
let url = format!("https://huggingface.co/api/datasets/{}", cfg.dataset_id);
|
| 255 |
+
let resp = client.get(&url).send().await?;
|
| 256 |
|
| 257 |
+
match resp.status() {
|
| 258 |
StatusCode::OK => {
|
| 259 |
+
let repo: RepoLookup = resp.json().await.unwrap_or(RepoLookup { id: None });
|
| 260 |
+
eprintln!("dataset ok: {}", repo.id.unwrap_or(cfg.dataset_id.clone()));
|
| 261 |
Ok(())
|
| 262 |
}
|
| 263 |
StatusCode::NOT_FOUND => {
|
| 264 |
+
let auto = env::var("AUTO_CREATE_DATASET")
|
| 265 |
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
| 266 |
.unwrap_or(false);
|
| 267 |
+
if auto {
|
| 268 |
+
eprintln!("dataset not found — AUTO_CREATE_DATASET=true, creating…");
|
|
|
|
| 269 |
create_private_dataset(client, cfg).await
|
| 270 |
} else {
|
| 271 |
Err(anyhow!(
|
|
|
|
| 275 |
))
|
| 276 |
}
|
| 277 |
}
|
| 278 |
+
s => Err(anyhow!(
|
| 279 |
+
"dataset lookup failed ({s}): {}",
|
| 280 |
+
resp.text().await.unwrap_or_default()
|
| 281 |
+
)),
|
| 282 |
}
|
| 283 |
}
|
| 284 |
|
| 285 |
async fn create_private_dataset(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 286 |
+
let (owner, name) = cfg.dataset_id.split_once('/').unwrap();
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
let username = client
|
| 289 |
.get("https://huggingface.co/api/whoami-v2")
|
|
|
|
| 304 |
repo_type: "dataset".to_string(),
|
| 305 |
};
|
| 306 |
|
| 307 |
+
let resp = client
|
| 308 |
.post("https://huggingface.co/api/repos/create")
|
| 309 |
.json(&req)
|
| 310 |
.send()
|
| 311 |
.await?;
|
| 312 |
|
| 313 |
+
if resp.status().is_success() || resp.status() == StatusCode::CONFLICT {
|
| 314 |
Ok(())
|
| 315 |
} else {
|
| 316 |
+
Err(anyhow!(
|
| 317 |
+
"create dataset failed ({}): {}",
|
| 318 |
+
resp.status(),
|
| 319 |
+
resp.text().await.unwrap_or_default()
|
| 320 |
+
))
|
| 321 |
}
|
| 322 |
}
|
| 323 |
|
| 324 |
async fn pull_workspace(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
| 325 |
+
eprintln!("pulling workspace from {}", cfg.dataset_id);
|
| 326 |
+
let files = list_remote_files(client, cfg).await?;
|
| 327 |
|
| 328 |
+
for file in &files {
|
| 329 |
let url = format!(
|
| 330 |
"https://huggingface.co/datasets/{}/resolve/main/{}",
|
| 331 |
cfg.dataset_id, file
|
| 332 |
);
|
| 333 |
let bytes = client.get(url).send().await?.error_for_status()?.bytes().await?;
|
| 334 |
let target = cfg.workspace.join(file);
|
| 335 |
+
if let Some(p) = target.parent() {
|
| 336 |
+
tokio::fs::create_dir_all(p).await?;
|
| 337 |
}
|
| 338 |
let mut f = tokio::fs::File::create(&target).await?;
|
| 339 |
f.write_all(&bytes).await?;
|
| 340 |
}
|
| 341 |
|
| 342 |
+
eprintln!("pull complete: {} files", files.len());
|
| 343 |
Ok(())
|
| 344 |
}
|
| 345 |
|
|
|
|
| 348 |
"https://huggingface.co/api/datasets/{}/tree/main?recursive=true",
|
| 349 |
cfg.dataset_id
|
| 350 |
);
|
| 351 |
+
let resp = client.get(url).send().await?;
|
| 352 |
+
if resp.status() == StatusCode::NOT_FOUND {
|
| 353 |
+
return Ok(vec![]);
|
| 354 |
}
|
| 355 |
+
let entries: Vec<TreeEntry> = resp.error_for_status()?.json().await?;
|
| 356 |
Ok(entries.into_iter().filter(|e| e.kind == "file").map(|e| e.path).collect())
|
| 357 |
}
|
| 358 |
|
| 359 |
async fn push_workspace(client: &reqwest::Client, cfg: &Config) -> Result<()> {
|
|
|
|
| 360 |
let state_path = cfg.workspace.join(STATE_FILE);
|
| 361 |
let mut state = load_state(&state_path).await?;
|
| 362 |
|
|
|
|
| 368 |
.filter_map(|e| e.ok())
|
| 369 |
.filter(|e| e.file_type().is_file())
|
| 370 |
{
|
| 371 |
+
let full = entry.path();
|
| 372 |
+
if full == state_path {
|
| 373 |
continue;
|
| 374 |
}
|
| 375 |
+
let rel = full
|
| 376 |
+
.strip_prefix(&cfg.workspace)?
|
|
|
|
|
|
|
| 377 |
.to_string_lossy()
|
| 378 |
.replace('\\', "/");
|
| 379 |
|
| 380 |
+
let bytes = tokio::fs::read(full).await?;
|
| 381 |
let md5 = format!("{:x}", md5::compute(&bytes));
|
| 382 |
let size = bytes.len() as u64;
|
| 383 |
|
| 384 |
let changed = state
|
| 385 |
.files
|
| 386 |
+
.get(&rel)
|
| 387 |
.map(|s| s.md5 != md5 || s.size != size)
|
| 388 |
.unwrap_or(true);
|
| 389 |
|
| 390 |
if changed {
|
| 391 |
operations.push(CommitOperation::AddOrUpdate {
|
| 392 |
+
path: rel.clone(),
|
| 393 |
encoding: "base64".to_string(),
|
| 394 |
content: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
| 395 |
});
|
| 396 |
}
|
| 397 |
+
current.insert(rel, FileState { md5, size });
|
|
|
|
| 398 |
}
|
| 399 |
|
| 400 |
+
let old: HashSet<_> = state.files.keys().cloned().collect();
|
| 401 |
+
let new: HashSet<_> = current.keys().cloned().collect();
|
| 402 |
+
for removed in old.difference(&new) {
|
| 403 |
operations.push(CommitOperation::Delete { path: removed.clone() });
|
| 404 |
}
|
| 405 |
|
| 406 |
if operations.is_empty() {
|
| 407 |
+
eprintln!("no workspace changes");
|
| 408 |
return Ok(());
|
| 409 |
}
|
| 410 |
|
|
|
|
| 417 |
"https://huggingface.co/api/datasets/{}/commit/main",
|
| 418 |
cfg.dataset_id
|
| 419 |
);
|
| 420 |
+
let resp = client.post(url).json(&req).send().await?;
|
| 421 |
+
if !resp.status().is_success() {
|
| 422 |
+
return Err(anyhow!(
|
| 423 |
+
"push failed ({}): {}",
|
| 424 |
+
resp.status(),
|
| 425 |
+
resp.text().await.unwrap_or_default()
|
| 426 |
+
));
|
| 427 |
}
|
| 428 |
|
| 429 |
state.files = current;
|
|
|
|
| 433 |
}
|
| 434 |
|
| 435 |
async fn rebuild_sync_state(workspace: &Path) -> Result<()> {
|
|
|
|
| 436 |
let state_path = workspace.join(STATE_FILE);
|
| 437 |
+
let mut files = HashMap::new();
|
| 438 |
|
| 439 |
for entry in WalkDir::new(workspace)
|
| 440 |
.into_iter()
|
| 441 |
.filter_map(|e| e.ok())
|
| 442 |
.filter(|e| e.file_type().is_file())
|
| 443 |
{
|
| 444 |
+
let full = entry.path();
|
| 445 |
+
if full == state_path {
|
| 446 |
continue;
|
| 447 |
}
|
| 448 |
+
let rel = full
|
| 449 |
+
.strip_prefix(workspace)?
|
|
|
|
| 450 |
.to_string_lossy()
|
| 451 |
.replace('\\', "/");
|
| 452 |
+
let bytes = tokio::fs::read(full).await?;
|
| 453 |
+
files.insert(rel, FileState {
|
| 454 |
md5: format!("{:x}", md5::compute(&bytes)),
|
| 455 |
size: bytes.len() as u64,
|
| 456 |
});
|
|
|
|
| 464 |
return Ok(SyncState::default());
|
| 465 |
}
|
| 466 |
let raw = tokio::fs::read(path).await?;
|
| 467 |
+
Ok(serde_json::from_slice(&raw).context("bad sync state")?)
|
| 468 |
}
|
| 469 |
|
| 470 |
async fn save_state(path: &Path, state: &SyncState) -> Result<()> {
|
|
|
|
| 475 |
fn spawn_child_from_args() -> Result<Child> {
|
| 476 |
let args: Vec<String> = env::args().skip(1).collect();
|
| 477 |
if args.is_empty() {
|
| 478 |
+
return Err(anyhow!("no child command provided"));
|
|
|
|
|
|
|
| 479 |
}
|
| 480 |
Command::new(&args[0])
|
| 481 |
.args(&args[1..])
|
|
|
|
| 488 |
|
| 489 |
fn forward_sigterm(child: &mut Child) {
|
| 490 |
if let Some(id) = child.id() {
|
| 491 |
+
// SAFETY: valid pid from live child
|
| 492 |
let ret = unsafe { libc::kill(id as libc::pid_t, libc::SIGTERM) };
|
| 493 |
if ret != 0 {
|
| 494 |
+
eprintln!("SIGTERM forward failed: {}", std::io::Error::last_os_error());
|
| 495 |
}
|
| 496 |
}
|
| 497 |
}
|
|
|
|
| 499 |
async fn wait_for_child_shutdown(child: &mut Child) {
|
| 500 |
match timeout(FINAL_WAIT_TIMEOUT, child.wait()).await {
|
| 501 |
Ok(Ok(s)) => eprintln!("child exited after SIGTERM: {s}"),
|
| 502 |
+
Ok(Err(e)) => eprintln!("wait error: {e:#}"),
|
| 503 |
Err(_) => {
|
| 504 |
+
eprintln!("child timeout — sending SIGKILL");
|
| 505 |
if let Some(id) = child.id() {
|
| 506 |
+
// SAFETY: valid pid from live child
|
| 507 |
+
unsafe { libc::kill(id as libc::pid_t, libc::SIGKILL) };
|
|
|
|
|
|
|
|
|
|
| 508 |
}
|
| 509 |
}
|
| 510 |
}
|