WS-9: cloud-sync (convergence mapper + outbox + push CLI) → main - #22
Conversation
WS-9: add the relayhistory cloud-sync lens (Agent Relay Loop) directly on main. Re-applied onto current main rather than via the stale codex/rust-core-phase1-scaffold branch (#21), which main has since superseded with a more advanced Rust CLI. Additive only — no regression to main's existing crates. ai-hist-core: - convergence.rs — map the local recall store onto the ratified WS-1 convergence envelope (relayhistory-cloud ADR 2026-06-21): ConvergenceEnvelope + IngestRequest/IngestResponse; map_trajectory (decisions+retro fan-out, finding/reflection/decision kinds, collision-free kind-namespaced eventIds, source/lens=trajectories, structured task fields, no client Task: prefix — server enriches); map_history_entry; self-contained epoch→ISO; home-dir path scrub; confidence float-on-wire (server toBasisPoints); all six trajectory blob edge cases. - outbox.rs — build_outbox_batch over local SQLite past a JSON-persistable SyncCursor + incognito exclusion; pure sync, no network. ai-hist (binary): - cloud.rs — ureq HTTP behind an Ingestor trait (push orchestration unit-tested without a server); rth_at_/rth_rt_ token storage (0600); cursor persistence + server-confirmed advance; deterministic retry-safe batch_id; stable machine_id. - login / admin-mint / push subcommands. docs/cloud-sync.md — human quickstart + automation/agent guide. Verified end-to-end across in-process, local wrangler dev, deployed dev Worker, and prod posture (admin-mint→404, unauth→401) via the shipped CLI; trajectory rows byte-identical across 7 runs. 30 tests (5 cli + 25 core), no new clippy warnings. Supersedes PRs #21 + #20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (8)
📝 WalkthroughWalkthroughAdds WS-9 cloud-sync to the relayhistory CLI. A new ChangesWS-9 Cloud Sync
Sequence DiagramsequenceDiagram
participant User
participant CLI as ai-hist (main.rs)
participant Cloud as cloud.rs
participant Core as ai-hist-core outbox
participant SQLite as Local SQLite DB
participant Relay as POST /v1/ingest
User->>CLI: ai-hist push [--limit N] [--incognito S]
CLI->>Cloud: load_auth() / load_cursor()
CLI->>Cloud: push(conn, client, auth, machine, cursor, limit, incognito)
Cloud->>Core: build_outbox_batch(conn, cursor, limit, incognito)
Core->>SQLite: SELECT history rows > history_id
Core->>SQLite: SELECT trajectory rows > trajectory_rowid
Core-->>Cloud: OutboxBatch { records, advanced_cursor }
Cloud->>Relay: POST /v1/ingest { batch_id, machine, records, cursors }
Relay-->>Cloud: IngestResponse { accepted, cursors }
Cloud->>Cloud: save_cursor(advanced_cursor)
Cloud-->>CLI: PushReport { sent, accepted, batch_id }
CLI-->>User: JSON or human-readable summary
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request implements the WS-9 cloud-sync feature, enabling the local recall store (prompts and trajectory reasoning events) to be pushed to relayhistory-cloud. It introduces mapping logic to the WS-1 convergence envelope, an outbox batch builder with incognito session filtering, and a client transport layer using ureq with CLI commands for login, admin token minting, and pushing. The review feedback highlights a security vulnerability regarding non-atomic file permission setting for private files, cross-platform compatibility issues with hostname resolution on Windows, and a reliability concern due to missing timeouts on HTTP requests.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| fn write_private(path: &std::path::Path, body: &str) -> Result<()> { | ||
| if let Some(parent) = path.parent() { | ||
| fs::create_dir_all(parent)?; | ||
| } | ||
| fs::write(path, body)?; | ||
| // best-effort 0600 on unix (token/secret hygiene) | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
The write_private function writes sensitive authentication tokens to disk using fs::write (which creates the file with default permissions) and only restricts the permissions to 0600 afterwards. This creates a race condition where another local user could read the sensitive tokens before the permissions are restricted. To prevent this, the file should be created with 0600 permissions atomically using OpenOptions with mode(0o600) on Unix.
fn write_private(path: &std::path::Path, body: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
use std::fs::OpenOptions;
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
use std::io::Write;
let mut file = options.open(path)?;
file.write_all(body.as_bytes())?;
Ok(())
}| fn hostname() -> String { | ||
| std::env::var("HOSTNAME") | ||
| .ok() | ||
| .filter(|s| !s.is_empty()) | ||
| .unwrap_or_else(|| "unknown-host".to_string()) | ||
| } |
There was a problem hiding this comment.
The hostname function only checks the HOSTNAME environment variable, which is typically not set on Windows systems. On Windows, the standard environment variable for the host name is COMPUTERNAME. Adding a fallback to COMPUTERNAME ensures cross-platform compatibility.
| fn hostname() -> String { | |
| std::env::var("HOSTNAME") | |
| .ok() | |
| .filter(|s| !s.is_empty()) | |
| .unwrap_or_else(|| "unknown-host".to_string()) | |
| } | |
| fn hostname() -> String { | |
| std::env::var("HOSTNAME") | |
| .or_else(|_| std::env::var("COMPUTERNAME")) | |
| .ok() | |
| .filter(|s| !s.is_empty()) | |
| .unwrap_or_else(|| "unknown-host".to_string()) | |
| } |
| .context("not authenticated — run `ai-hist login` or `ai-hist admin-mint` first")?; | ||
| let machine = MachineIdentity { | ||
| id: cloud::machine_id()?, | ||
| hostname: std::env::var("HOSTNAME").ok().filter(|s| !s.is_empty()), |
There was a problem hiding this comment.
For consistency and Windows compatibility, the hostname resolution here should also fall back to COMPUTERNAME if HOSTNAME is not set.
| hostname: std::env::var("HOSTNAME").ok().filter(|s| !s.is_empty()), | |
| hostname: std::env::var("HOSTNAME").or_else(|_| std::env::var("COMPUTERNAME")).ok().filter(|s| !s.is_empty()), |
| let resp = ureq::post(&url) | ||
| .set("Authorization", &format!("Bearer {}", auth.access_token)) | ||
| .set("Content-Type", "application/json") | ||
| .send_json(serde_json::to_value(req)?); |
There was a problem hiding this comment.
The ureq HTTP requests are initiated without any timeout. If the server is slow or hangs, the CLI sync process will block indefinitely. It is highly recommended to set a reasonable timeout (e.g., 30 seconds) on all network requests to ensure reliability, especially when run in automated environments like cron or launchd.
let resp = ureq::post(&url)
.timeout(std::time::Duration::from_secs(30))
.set("Authorization", &format!("Bearer {}", auth.access_token))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(req)?);
WS-9 — relayhistory cloud-sync lens (Agent Relay Loop)
Lands the WS-9 client (Capture→push→store) directly on
main, superseding the scaffold-stacked chain (#21 → #20).Why this supersedes #21/#20
While closing the chain I found
mainalready has a more advanced Rust CLI than thecodex/rust-core-phase1-scaffoldbranch (main'sai-hist-corehas XDG_DATA_HOME /git_branch/ WAL;crates/ai-hist/src/main.rsis a full clap CLI; deps include chrono/flate2/rusqlite). Merging the scaffold (#21) into main would have regressed it. So WS-9 is re-applied cleanly on top of currentmain— purely additive, no scaffold needed. Please close #21 (obsolete) and #20 (scaffold-stacked) in favor of this PR.Contents (additive; 30 tests, no new clippy warnings)
ai-hist-core/convergence.rs— local store → ratified WS-1ConvergenceEnvelope+IngestRequest/IngestResponse;map_trajectory(decisions+retro fan-out,finding/reflection/decisionkinds, collision-free kind-namespaced eventIds keyed offtrajectoryId,source/lens="trajectories", structuredtaskTitle/etc., no clientTask:prefix — server enriches),map_history_entry, self-contained epoch→ISO, home-dir path scrub, confidence float-on-wire. Handles all six trajectory-blob edge cases.ai-hist-core/outbox.rs—build_outbox_batchover local SQLite past a JSON-persistableSyncCursor+ incognito exclusion (pure sync, no network).ai-hist/cloud.rs+ CLI:ai-hist login(/v1/cli/login),ai-hist admin-mint(dev/v1/admin/mint),ai-hist push(incremental, idempotent,--incognito,--json).ureqHTTP behind anIngestortrait (orchestration unit-tested without a server);rth_at_/rth_rt_token storage (0600); cursor persistence + server-confirmed advance; deterministic retry-safebatch_id.docs/cloud-sync.md— human quickstart + automation/agent guide.Verification
End-to-end via the shipped CLI across in-process → local
wrangler dev→ deployed dev Worker → prod posture (admin-mint→404, unauth→401 surfaced cleanly by the CLI). Trajectory rows byte-identical across 7 independent runs, signed off by trajectories-expert. Contract pinned to the WS-1 ADR; shares thetraj_abcfixture with relayhistory-cloud's PGliteapplyIngesttest.🤖 Generated with Claude Code