tempo-x402-node 7.0.0

Self-deploying x402 node: gateway + identity bootstrap + clone orchestration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
//! Clone orchestration logic.
//!
//! Coordinates the full lifecycle of spawning a child instance on Railway:
//! service creation, environment configuration, Docker image or source-based
//! deployment, volume attachment, domain assignment, and deployment trigger.
//!
//! Supports two deployment modes:
//! - **Docker**: Deploy from a pre-built image (existing behavior)
//! - **Source**: Create a branch on a GitHub repo and build from source on Railway

use crate::railway::{RailwayClient, RailwayError};
use serde::{Deserialize, Serialize};

/// Configuration for clone operations.
#[derive(Clone, Debug)]
pub struct CloneConfig {
    /// Docker image to deploy (e.g., `ghcr.io/compusophy/tempo-x402:latest`).
    /// If None and `source_repo` is set, uses source-based builds.
    pub docker_image: Option<String>,
    /// GitHub repo for source-based builds (e.g., `compusophy-bot/tempo-x402`).
    /// Each clone gets its own `clone/{name}` branch.
    pub source_repo: Option<String>,
    /// GitHub token for branch creation via GitHub REST API.
    /// Required when `source_repo` is set.
    pub github_token: Option<String>,
    /// RPC URL for the Tempo chain
    pub rpc_url: String,
    /// URL of this (parent) instance, so children can register back
    pub self_url: String,
    /// Maximum number of children this instance can spawn
    pub max_children: u32,
    /// CPU limit for child instances in millicores (default: 2000 = 2 vCPU)
    pub clone_cpu_millicores: u32,
    /// Memory limit for child instances in MB (default: 2048 = 2GB)
    pub clone_memory_mb: u32,
    /// Extra env vars to inject into children (e.g., soul config)
    pub child_env_vars: std::collections::HashMap<String, String>,
}

/// Result of a successful clone operation.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CloneResult {
    /// Unique ID for the child instance
    pub instance_id: String,
    /// Public URL of the child (Railway domain)
    pub url: String,
    /// Railway service ID
    pub railway_service_id: String,
    /// Railway deployment ID
    pub deployment_id: String,
    /// GitHub branch name (only for source-based clones)
    pub branch: Option<String>,
    /// Railway volume ID — MUST be stored for cleanup on delete.
    pub volume_id: Option<String>,
    /// Borg-style ordinal designation: "one", "two", etc.
    pub designation: String,
    /// Clone's own GitHub repo (e.g., "compusophy-bot/borg-0-2").
    /// None if repo creation failed (falls back to shared source).
    pub clone_repo: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum CloneError {
    #[error("Railway API error: {0}")]
    Railway(#[from] RailwayError),

    #[error("clone limit reached: {current}/{max} children")]
    LimitReached { current: u32, max: u32 },

    #[error("clone error: {0}")]
    Other(String),
}

/// Orchestrates the clone flow: create service, configure, deploy.
pub struct CloneOrchestrator {
    railway: RailwayClient,
    config: CloneConfig,
}

impl CloneOrchestrator {
    pub fn new(railway: RailwayClient, config: CloneConfig) -> Self {
        Self { railway, config }
    }

    /// Spawn a new child clone on Railway.
    ///
    /// Creates a service, then configures and deploys it. If any step after
    /// service creation fails, the service is cleaned up before returning the error.
    ///
    /// The `instance_id` should be pre-generated by the caller (e.g., after
    /// reserving a DB slot) so the same ID is used throughout the flow.
    pub async fn spawn_clone(
        &self,
        instance_id: &str,
        parent_address: &str,
    ) -> Result<CloneResult, CloneError> {
        self.spawn_clone_with_extra_vars(
            instance_id,
            parent_address,
            &std::collections::HashMap::new(),
        )
        .await
    }

    /// Spawn a clone with additional env vars merged on top of `child_env_vars`.
    /// Used by `/clone/specialist` to inject specialization without thread-unsafe `set_var`.
    pub async fn spawn_clone_with_extra_vars(
        &self,
        instance_id: &str,
        parent_address: &str,
        extra_vars: &std::collections::HashMap<String, String>,
    ) -> Result<CloneResult, CloneError> {
        // Borg-style ordinal designation: "one", "two", "three", ...
        let designation = extra_vars
            .get("DRONE_DESIGNATION")
            .cloned()
            .unwrap_or_else(|| format!("drone-{}", &instance_id[..8]));
        let service_name = designation.clone();

        tracing::info!(
            instance_id = %instance_id,
            service_name = %service_name,
            "Spawning clone"
        );

        // 1. Create service
        let service_id = self.railway.create_service(&service_name).await?;
        tracing::info!(service_id = %service_id, "Railway service created");

        // All subsequent steps run with cleanup-on-failure
        match self
            .spawn_clone_inner(
                &service_id,
                instance_id,
                &designation,
                parent_address,
                extra_vars,
            )
            .await
        {
            Ok(result) => Ok(result),
            Err(e) => {
                tracing::error!(
                    service_id = %service_id,
                    error = %e,
                    "Clone failed after service creation, cleaning up"
                );
                // Clean up Railway service
                if let Err(cleanup_err) = self.railway.delete_service(&service_id).await {
                    tracing::error!(
                        service_id = %service_id,
                        error = %cleanup_err,
                        "Cleanup of Railway service failed"
                    );
                } else {
                    tracing::info!(service_id = %service_id, "Railway service cleaned up");
                }
                // Clean up GitHub branch (best-effort)
                let branch = format!("clone/{}", &instance_id[..8]);
                if let (Some(ref repo), Some(ref token)) =
                    (&self.config.source_repo, &self.config.github_token)
                {
                    if let Err(branch_err) = delete_github_branch(token, repo, &branch).await {
                        tracing::warn!(
                            branch = %branch,
                            error = %branch_err,
                            "Cleanup of GitHub branch failed (best-effort)"
                        );
                    }
                }
                Err(e)
            }
        }
    }

    /// Inner clone steps after service creation. Separated so spawn_clone()
    /// can clean up the service if any of these fail.
    async fn spawn_clone_inner(
        &self,
        service_id: &str,
        instance_id: &str,
        designation: &str,
        parent_address: &str,
        extra_vars: &std::collections::HashMap<String, String>,
    ) -> Result<CloneResult, CloneError> {
        // 2. Get default environment
        let env_id = self.railway.get_default_environment().await?;

        // Deploy from shared colony fork. Each clone uses the same source repo
        // (compusophy-bot/tempo-x402) and differentiates via vm/{id} branches
        // when it starts making code changes. No per-clone repos — they cause
        // Railway permission issues and unnecessary complexity.
        let clone_repo: Option<String> = None;
        let deploy_repo = self.config.source_repo.as_deref();
        let use_source = deploy_repo.is_some();
        let branch_name = if use_source {
            Some("main".to_string())
        } else {
            None
        };

        // 4. Set environment variables
        let mut env_map = serde_json::Map::new();
        env_map.insert("AUTO_BOOTSTRAP".into(), "true".into());
        env_map.insert("INSTANCE_ID".into(), instance_id.into());
        env_map.insert("PARENT_URL".into(), self.config.self_url.clone().into());
        env_map.insert("PARENT_ADDRESS".into(), parent_address.into());
        env_map.insert("IDENTITY_PATH".into(), "/data/identity.json".into());
        env_map.insert("DB_PATH".into(), "/data/gateway.db".into());
        env_map.insert("NONCE_DB_PATH".into(), "/data/x402-nonces.db".into());
        env_map.insert("RPC_URL".into(), self.config.rpc_url.clone().into());
        env_map.insert("SPA_DIR".into(), "/app/spa".into());
        env_map.insert("PORT".into(), "4023".into());

        // Inject extra env vars (soul config, API keys, etc.)
        for (key, value) in &self.config.child_env_vars {
            env_map.insert(key.clone(), value.clone().into());
        }

        // Inject per-clone overrides (e.g., specialization env vars)
        for (key, value) in extra_vars {
            env_map.insert(key.clone(), value.clone().into());
        }

        // Override SOUL_FORK_REPO with clone's own repo if created
        // This makes the clone push to its own repo instead of the shared colony fork
        if let Some(ref repo) = clone_repo {
            env_map.insert("SOUL_FORK_REPO".into(), repo.clone().into());
            // SOUL_UPSTREAM_REPO stays as the colony fork — PRs flow upstream
            if let Some(ref source) = self.config.source_repo {
                env_map.insert("SOUL_UPSTREAM_REPO".into(), source.clone().into());
            }
        }

        let env_vars = serde_json::Value::Object(env_map);
        self.railway
            .set_variables(service_id, &env_id, env_vars)
            .await?;
        tracing::info!("Environment variables configured");

        // 5. Set deployment source (clone's own repo > shared source > Docker)
        if let (Some(repo), Some(ref branch)) = (deploy_repo, &branch_name) {
            self.railway
                .connect_repo(service_id, &env_id, repo, branch)
                .await?;
            tracing::info!(repo = %repo, branch = %branch, "Source repo connected");
        } else if let Some(ref image) = self.config.docker_image {
            self.railway.set_docker_image(service_id, image).await?;
            tracing::info!(image = %image, "Docker image set");
        } else {
            return Err(CloneError::Other(
                "no deployment source: set DOCKER_IMAGE or CLONE_SOURCE_REPO".to_string(),
            ));
        }

        // 6. Add volume (best-effort — clone works without persistent storage)
        // Capture volume_id so caller can store it for cleanup on delete.
        let volume_id = match self.railway.add_volume(service_id, &env_id, "/data").await {
            Ok(vid) => {
                tracing::info!(volume_id = %vid, "Volume attached at /data");
                Some(vid)
            }
            Err(e) => {
                tracing::warn!(error = %e, "Volume attachment failed (best-effort, continuing)");
                None
            }
        };

        // 7. Set resource limits (best-effort — Railway defaults are fine)
        if self.config.clone_cpu_millicores > 0 || self.config.clone_memory_mb > 0 {
            match self
                .railway
                .update_service_resources(
                    service_id,
                    &env_id,
                    self.config.clone_cpu_millicores,
                    self.config.clone_memory_mb,
                )
                .await
            {
                Ok(_) => tracing::info!(
                    cpu = self.config.clone_cpu_millicores,
                    memory_mb = self.config.clone_memory_mb,
                    "Resource limits configured"
                ),
                Err(e) => {
                    tracing::warn!(error = %e, "Resource limits failed (best-effort, continuing)")
                }
            }
        }

        // 8. Create domain
        let url = self.railway.create_domain(service_id, &env_id).await?;
        tracing::info!(url = %url, "Domain created");

        // 8b. Set ALLOWED_ORIGINS now that we know the domain
        let origins = format!("{},{}", url, self.config.self_url);
        let origins_var = serde_json::Value::Object({
            let mut m = serde_json::Map::new();
            m.insert("ALLOWED_ORIGINS".into(), origins.into());
            m
        });
        let _ = self
            .railway
            .set_variables(service_id, &env_id, origins_var)
            .await;

        // 9. Deploy (skip for source-based — the deployment trigger already builds)
        let deployment_id = if branch_name.is_none() {
            let id = self.railway.deploy_service(service_id, &env_id).await?;
            tracing::info!(deployment_id = %id, "Deployment triggered");
            id
        } else {
            tracing::info!("Source-based clone — deployment trigger will build automatically");
            "trigger-based".to_string()
        };

        Ok(CloneResult {
            instance_id: instance_id.to_string(),
            url,
            railway_service_id: service_id.to_string(),
            deployment_id,
            branch: branch_name,
            volume_id,
            designation: designation.to_string(),
            clone_repo,
        })
    }

    /// Update a clone's branch to main's HEAD and redeploy.
    ///
    /// For source-based clones, fast-forwards the clone's branch to main's latest
    /// commit before triggering a redeploy. For Docker clones, just redeploys.
    pub async fn redeploy_clone(&self, service_id: &str) -> Result<String, CloneError> {
        let env_id = self.railway.get_default_environment().await?;
        let result = self.railway.deploy_service(service_id, &env_id).await?;
        Ok(result)
    }

    /// Update a clone's GitHub branch to main's HEAD before redeploying.
    /// Best-effort: if the branch update fails, the redeploy still triggers
    /// (it'll just use the existing branch state).
    pub async fn update_and_redeploy_clone(
        &self,
        service_id: &str,
        instance_id: &str,
    ) -> Result<String, CloneError> {
        // Update branch to main if source-based
        if let (Some(ref repo), Some(ref token)) =
            (&self.config.source_repo, &self.config.github_token)
        {
            let branch = format!("clone/{}", &instance_id[..8]);
            match update_github_branch_to_main(token, repo, &branch).await {
                Ok(()) => {
                    tracing::info!(
                        instance_id = %instance_id,
                        branch = %branch,
                        "Updated clone branch to main"
                    );
                }
                Err(e) => {
                    tracing::warn!(
                        instance_id = %instance_id,
                        error = %e,
                        "Failed to update clone branch (will redeploy anyway)"
                    );
                }
            }
        }

        self.redeploy_clone(service_id).await
    }

    /// Delete a Railway volume. MUST be called BEFORE delete_service.
    pub async fn delete_volume(&self, volume_id: &str) -> Result<(), CloneError> {
        self.railway.delete_volume(volume_id).await?;
        Ok(())
    }

    /// Delete a Railway service. Delegates to the Railway client.
    pub async fn delete_service(&self, service_id: &str) -> Result<(), CloneError> {
        self.railway.delete_service(service_id).await?;
        Ok(())
    }

    pub fn config(&self) -> &CloneConfig {
        &self.config
    }
}

/// Create a dedicated GitHub repo for a clone — the stem cell differentiation model.
///
/// Each clone gets its own repo (e.g., `compusophy-bot/borg-0-2`), initialized
/// from the colony baseline. The clone pushes to its own repo, Railway builds
/// from it, and the clone evolves independently. Good changes flow upstream via PRs.
///
/// Returns the new repo's full name (e.g., "compusophy-bot/borg-0-2").
pub async fn create_clone_repo(
    token: &str,
    source_repo: &str,
    designation: &str,
) -> Result<String, CloneError> {
    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .redirect(reqwest::redirect::Policy::limited(5))
        .build()
        .map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;

    // Extract the org from source_repo (e.g., "compusophy-bot" from "compusophy-bot/tempo-x402")
    let org = source_repo
        .split('/')
        .next()
        .ok_or_else(|| CloneError::Other("invalid source_repo format".to_string()))?;

    let repo_name = designation.to_string();
    let full_name = format!("{org}/{repo_name}");

    // 1. Create the repo under the org (or user)
    // Try org endpoint first, fall back to user endpoint
    let create_url = format!("https://api.github.com/orgs/{org}/repos");
    let body = serde_json::json!({
        "name": repo_name,
        "description": format!("x402 autonomous agent: {designation}"),
        "private": false,
        "auto_init": false,
    });

    let resp = http
        .post(&create_url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "x402-node")
        .header("X-GitHub-Api-Version", "2022-11-28")
        .json(&body)
        .send()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub create repo error: {e}")))?;

    if !resp.status().is_success() {
        // Maybe it's a user account, not an org — try user endpoint
        let user_url = "https://api.github.com/user/repos";
        let resp2 = http
            .post(user_url)
            .header("Authorization", format!("Bearer {token}"))
            .header("Accept", "application/vnd.github+json")
            .header("User-Agent", "x402-node")
            .header("X-GitHub-Api-Version", "2022-11-28")
            .json(&body)
            .send()
            .await
            .map_err(|e| CloneError::Other(format!("GitHub create repo (user) error: {e}")))?;

        if !resp2.status().is_success() {
            let status = resp2.status();
            let body = resp2.text().await.unwrap_or_default();
            // 422 = repo already exists — that's fine, we can reuse it
            if status.as_u16() == 422 && body.contains("already exists") {
                tracing::info!(repo = %full_name, "Clone repo already exists — reusing");
                // Continue to push code below
            } else {
                return Err(CloneError::Other(format!(
                    "GitHub create repo failed (HTTP {status}): {}",
                    body.chars().take(200).collect::<String>()
                )));
            }
        }
    }

    tracing::info!(repo = %full_name, "Created clone repo");

    // 2. Mirror the source repo into the new repo
    // Use git CLI since it handles auth via the token in the URL
    let source_url = format!("https://x-access-token:{token}@github.com/{source_repo}.git");
    let target_url = format!("https://x-access-token:{token}@github.com/{full_name}.git");

    // Clone bare from source
    let tmp_dir = format!("/tmp/clone-mirror-{designation}");
    let _ = tokio::fs::remove_dir_all(&tmp_dir).await; // clean up any previous attempt

    let clone_result = tokio::process::Command::new("git")
        .args(["clone", "--bare", &source_url, &tmp_dir])
        .output()
        .await
        .map_err(|e| CloneError::Other(format!("git clone --bare failed: {e}")))?;

    if !clone_result.status.success() {
        let stderr = String::from_utf8_lossy(&clone_result.stderr);
        let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
        return Err(CloneError::Other(format!(
            "git clone --bare failed: {}",
            stderr.chars().take(200).collect::<String>()
        )));
    }

    // Push mirror to new repo
    let push_result = tokio::process::Command::new("git")
        .args(["push", "--mirror", &target_url])
        .current_dir(&tmp_dir)
        .output()
        .await
        .map_err(|e| CloneError::Other(format!("git push --mirror failed: {e}")))?;

    // Clean up temp dir
    let _ = tokio::fs::remove_dir_all(&tmp_dir).await;

    if !push_result.status.success() {
        let stderr = String::from_utf8_lossy(&push_result.stderr);
        return Err(CloneError::Other(format!(
            "git push --mirror failed: {}",
            stderr.chars().take(200).collect::<String>()
        )));
    }

    tracing::info!(
        source = %source_repo,
        target = %full_name,
        "Mirrored colony baseline into clone repo"
    );

    Ok(full_name)
}

/// Create a branch on a GitHub repo via the REST API.
///
/// 1. GET /repos/{repo}/git/ref/heads/main → get SHA
/// 2. POST /repos/{repo}/git/refs → create refs/heads/{branch}
async fn create_github_branch(token: &str, repo: &str, branch: &str) -> Result<(), CloneError> {
    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .redirect(reqwest::redirect::Policy::limited(5))
        .build()
        .map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;

    // 1. Get main branch SHA
    let ref_url = format!("https://api.github.com/repos/{repo}/git/ref/heads/main");
    let ref_resp = http
        .get(&ref_url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "x402-node")
        .header("X-GitHub-Api-Version", "2022-11-28")
        .send()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub API error (get ref): {e}")))?;

    if !ref_resp.status().is_success() {
        let status = ref_resp.status();
        let body = ref_resp.text().await.unwrap_or_default();
        return Err(CloneError::Other(format!(
            "GitHub GET ref failed (HTTP {status}): {body}"
        )));
    }

    let ref_json: serde_json::Value = ref_resp
        .json()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub API parse error: {e}")))?;

    let sha = ref_json["object"]["sha"]
        .as_str()
        .ok_or_else(|| CloneError::Other("missing SHA in GitHub ref response".to_string()))?;

    // 2. Create new branch
    let create_url = format!("https://api.github.com/repos/{repo}/git/refs");
    let create_resp = http
        .post(&create_url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "x402-node")
        .header("X-GitHub-Api-Version", "2022-11-28")
        .json(&serde_json::json!({
            "ref": format!("refs/heads/{branch}"),
            "sha": sha,
        }))
        .send()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub API error (create ref): {e}")))?;

    if !create_resp.status().is_success() {
        let status = create_resp.status();
        let body = create_resp.text().await.unwrap_or_default();
        return Err(CloneError::Other(format!(
            "GitHub create branch failed (HTTP {status}): {body}"
        )));
    }

    Ok(())
}

/// Fast-forward a branch to main's HEAD via the GitHub REST API.
/// Used by the health probe to update clone branches before redeploying.
pub async fn update_github_branch_to_main(
    token: &str,
    repo: &str,
    branch: &str,
) -> Result<(), CloneError> {
    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .redirect(reqwest::redirect::Policy::limited(5))
        .build()
        .map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;

    // 1. Get main branch SHA
    let ref_url = format!("https://api.github.com/repos/{repo}/git/ref/heads/main");
    let ref_resp = http
        .get(&ref_url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "x402-node")
        .header("X-GitHub-Api-Version", "2022-11-28")
        .send()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub API error (get ref): {e}")))?;

    if !ref_resp.status().is_success() {
        let status = ref_resp.status();
        let body = ref_resp.text().await.unwrap_or_default();
        return Err(CloneError::Other(format!(
            "GitHub GET ref failed (HTTP {status}): {}",
            body.chars().take(200).collect::<String>()
        )));
    }

    let ref_json: serde_json::Value = ref_resp
        .json()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub API parse error: {e}")))?;

    let main_sha = ref_json["object"]["sha"]
        .as_str()
        .ok_or_else(|| CloneError::Other("missing SHA in GitHub ref response".to_string()))?;

    // 2. Force-update the branch ref to point to main's SHA
    let update_url = format!("https://api.github.com/repos/{repo}/git/refs/heads/{branch}");
    let update_resp = http
        .patch(&update_url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "x402-node")
        .header("X-GitHub-Api-Version", "2022-11-28")
        .json(&serde_json::json!({
            "sha": main_sha,
            "force": true,
        }))
        .send()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub API error (update ref): {e}")))?;

    if !update_resp.status().is_success() {
        let status = update_resp.status();
        let body = update_resp.text().await.unwrap_or_default();
        return Err(CloneError::Other(format!(
            "GitHub update branch failed (HTTP {status}): {}",
            body.chars().take(200).collect::<String>()
        )));
    }

    Ok(())
}

/// Delete a branch on a GitHub repo via the REST API (best-effort cleanup).
pub async fn delete_github_branch(token: &str, repo: &str, branch: &str) -> Result<(), CloneError> {
    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .redirect(reqwest::redirect::Policy::limited(5))
        .build()
        .map_err(|e| CloneError::Other(format!("HTTP client error: {e}")))?;

    let url = format!("https://api.github.com/repos/{repo}/git/refs/heads/{branch}");
    let resp = http
        .delete(&url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Accept", "application/vnd.github+json")
        .header("User-Agent", "x402-node")
        .header("X-GitHub-Api-Version", "2022-11-28")
        .send()
        .await
        .map_err(|e| CloneError::Other(format!("GitHub API error (delete ref): {e}")))?;

    if !resp.status().is_success() && resp.status().as_u16() != 404 {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(CloneError::Other(format!(
            "GitHub delete branch failed (HTTP {status}): {body}"
        )));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_clone_config_docker() {
        let config = CloneConfig {
            docker_image: Some("ghcr.io/compusophy/tempo-x402:latest".to_string()),
            source_repo: None,
            github_token: None,
            rpc_url: "https://rpc.moderato.tempo.xyz".to_string(),
            self_url: "https://my-instance.up.railway.app".to_string(),
            max_children: 10,
            clone_cpu_millicores: 2000,
            clone_memory_mb: 2048,
            child_env_vars: std::collections::HashMap::new(),
        };
        assert_eq!(config.max_children, 10);
        assert_eq!(config.clone_cpu_millicores, 2000);
        assert!(config.docker_image.is_some());
        assert!(config.source_repo.is_none());
    }

    #[test]
    fn test_clone_config_source() {
        let config = CloneConfig {
            docker_image: None,
            source_repo: Some("compusophy-bot/tempo-x402".to_string()),
            github_token: Some("ghp_test".to_string()),
            rpc_url: "https://rpc.moderato.tempo.xyz".to_string(),
            self_url: "https://my-instance.up.railway.app".to_string(),
            max_children: 5,
            clone_cpu_millicores: 2000,
            clone_memory_mb: 2048,
            child_env_vars: std::collections::HashMap::new(),
        };
        assert!(config.docker_image.is_none());
        assert_eq!(
            config.source_repo.as_deref(),
            Some("compusophy-bot/tempo-x402")
        );
    }

    #[test]
    fn test_clone_error_display() {
        let err = CloneError::LimitReached {
            current: 10,
            max: 10,
        };
        assert!(err.to_string().contains("10/10"));
    }
}