feat: remote rollback support, fix analyze sessions, set app product name

- Add remote_preview_rollback and remote_rollback Tauri commands
- Enable rollback UI for remote instances in History page
- Fix remote_analyze_sessions SSH script: JSON-escape agent/session names,
  fix comma logic, improve grep error resilience
- Set productName to "ClawPal" in tauri.conf.json (was defaulting to lowercase)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
zhixian
2026-02-19 21:11:00 +09:00
parent d6f7bad09c
commit 7138e97e87
6 changed files with 89 additions and 16 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -447,7 +447,7 @@ dependencies = [
[[package]]
name = "clawpal"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"async-trait",
"chrono",

View File

@@ -4883,7 +4883,7 @@ pub async fn remote_list_history(
"id": entry.name,
"createdAt": created_at_iso,
"source": source,
"canRollback": false,
"canRollback": true,
}));
}
// Sort newest first
@@ -4895,6 +4895,60 @@ pub async fn remote_list_history(
Ok(serde_json::json!({ "items": items }))
}
#[tauri::command]
pub async fn remote_preview_rollback(
pool: State<'_, SshConnectionPool>,
host_id: String,
snapshot_id: String,
) -> Result<PreviewResult, String> {
let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}");
let snapshot_text = pool.sftp_read(&host_id, &snapshot_path).await?;
let target: Value = serde_json::from_str(&snapshot_text)
.map_err(|e| format!("Failed to parse snapshot: {e}"))?;
let current_text = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
let current: Value = serde_json::from_str(&current_text)
.map_err(|e| format!("Failed to parse config: {e}"))?;
let before = serde_json::to_string_pretty(&current).unwrap_or_else(|_| "{}".into());
let after = serde_json::to_string_pretty(&target).unwrap_or_else(|_| "{}".into());
Ok(PreviewResult {
recipe_id: "rollback".into(),
diff: format_diff(&current, &target),
config_before: before,
config_after: after,
changes: collect_change_paths(&current, &target),
overwrites_existing: true,
can_rollback: true,
impact_level: "medium".into(),
warnings: vec!["Rollback will replace current configuration".into()],
})
}
#[tauri::command]
pub async fn remote_rollback(
pool: State<'_, SshConnectionPool>,
host_id: String,
snapshot_id: String,
) -> Result<ApplyResult, String> {
let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}");
let target_text = pool.sftp_read(&host_id, &snapshot_path).await?;
let target: Value = serde_json::from_str(&target_text)
.map_err(|e| format!("Failed to parse snapshot: {e}"))?;
let current_text = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
remote_write_config_with_snapshot(&pool, &host_id, &current_text, &target, "rollback").await?;
Ok(ApplyResult {
ok: true,
snapshot_id: Some(snapshot_id),
config_path: "~/.openclaw/openclaw.json".into(),
backup_path: None,
warnings: vec!["rolled back".into()],
errors: Vec::new(),
})
}
#[tauri::command]
pub async fn remote_list_discord_guild_channels(
pool: State<'_, SshConnectionPool>,
@@ -5017,29 +5071,34 @@ pub async fn remote_analyze_sessions(
// Run a shell script via SSH that scans session files and outputs JSON.
// This is MUCH faster than doing per-file SFTP reads.
let script = r#"
cd ~/.openclaw/agents 2>/dev/null || exit 0
cd ~/.openclaw/agents 2>/dev/null || { echo '[]'; exit 0; }
now=$(date +%s)
sep=""
echo "["
first_agent=1
for agent_dir in */; do
[ -d "$agent_dir" ] || continue
agent="${agent_dir%/}"
first_session=1
# Sanitize agent name for JSON (escape backslash then double-quote)
safe_agent=$(printf '%s' "$agent" | sed 's/\\/\\\\/g; s/"/\\"/g')
for kind in sessions sessions_archive; do
dir="$agent_dir$kind"
[ -d "$dir" ] || continue
for f in "$dir"/*.jsonl; do
[ -f "$f" ] || continue
fname=$(basename "$f" .jsonl)
safe_fname=$(printf '%s' "$fname" | sed 's/\\/\\\\/g; s/"/\\"/g')
size=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
msgs=$(grep -c '"type":"message"' "$f" 2>/dev/null || echo 0)
user_msgs=$(grep -c '"role":"user"' "$f" 2>/dev/null || echo 0)
asst_msgs=$(grep -c '"role":"assistant"' "$f" 2>/dev/null || echo 0)
msgs=$(grep -c '"type":"message"' "$f" 2>/dev/null || true)
[ -z "$msgs" ] && msgs=0
user_msgs=$(grep -c '"role":"user"' "$f" 2>/dev/null || true)
[ -z "$user_msgs" ] && user_msgs=0
asst_msgs=$(grep -c '"role":"assistant"' "$f" 2>/dev/null || true)
[ -z "$asst_msgs" ] && asst_msgs=0
mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0)
age_days=$(( (now - mtime) / 86400 ))
if [ "$first_agent" = "1" ] && [ "$first_session" = "1" ]; then first_agent=0; else echo ","; fi
first_session=0
printf '{"agent":"%s","sessionId":"%s","sizeBytes":%s,"messageCount":%s,"userMessageCount":%s,"assistantMessageCount":%s,"ageDays":%s,"kind":"%s"}' \
"$agent" "$fname" "$size" "$msgs" "$user_msgs" "$asst_msgs" "$age_days" "$kind"
printf '%s{"agent":"%s","sessionId":"%s","sizeBytes":%s,"messageCount":%s,"userMessageCount":%s,"assistantMessageCount":%s,"ageDays":%s,"kind":"%s"}' \
"$sep" "$safe_agent" "$safe_fname" "$size" "$msgs" "$user_msgs" "$asst_msgs" "$age_days" "$kind"
sep=","
done
done
done

View File

@@ -25,7 +25,8 @@ use crate::commands::{
remote_restart_gateway, remote_apply_config_patch,
remote_create_agent, remote_delete_agent,
remote_assign_channel_agent, remote_set_global_model, remote_set_agent_model,
remote_run_doctor, remote_list_history, remote_list_discord_guild_channels, remote_write_raw_config,
remote_run_doctor, remote_list_history, remote_preview_rollback, remote_rollback,
remote_list_discord_guild_channels, remote_write_raw_config,
remote_analyze_sessions, remote_delete_sessions_by_ids,
remote_list_session_files, remote_clear_all_sessions, remote_preview_session,
remote_list_model_profiles, remote_upsert_model_profile, remote_delete_model_profile, remote_resolve_api_keys,
@@ -132,6 +133,8 @@ pub fn run() {
remote_set_agent_model,
remote_run_doctor,
remote_list_history,
remote_preview_rollback,
remote_rollback,
remote_list_discord_guild_channels,
remote_write_raw_config,
remote_analyze_sessions,

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "ClawPal",
"identifier": "xyz.clawpal",
"build": {
"beforeDevCommand": "npm run dev",

View File

@@ -168,6 +168,10 @@ export const api = {
invoke("remote_run_doctor", { hostId }),
remoteListHistory: (hostId: string): Promise<{ items: HistoryItem[] }> =>
invoke("remote_list_history", { hostId }),
remotePreviewRollback: (hostId: string, snapshotId: string): Promise<PreviewResult> =>
invoke("remote_preview_rollback", { hostId, snapshotId }),
remoteRollback: (hostId: string, snapshotId: string): Promise<ApplyResult> =>
invoke("remote_rollback", { hostId, snapshotId }),
remoteWriteRawConfig: (hostId: string, content: string): Promise<boolean> =>
invoke("remote_write_raw_config", { hostId, content }),
remoteAnalyzeSessions: (hostId: string): Promise<AgentSessionAnalysis[]> =>

View File

@@ -73,14 +73,16 @@ export function History() {
<Badge variant="outline" className="text-muted-foreground">not rollbackable</Badge>
)}
</div>
{!isRollback && !isRemote && (
{!isRollback && (
<div className="flex gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const p = await api.previewRollback(item.id);
const p = isRemote
? await api.remotePreviewRollback(instanceId, item.id)
: await api.previewRollback(item.id);
setPreview(p);
} catch (err) {
setMessage(String(err));
@@ -95,7 +97,11 @@ export function History() {
size="sm"
onClick={async () => {
try {
await api.rollback(item.id);
if (isRemote) {
await api.remoteRollback(instanceId, item.id);
} else {
await api.rollback(item.id);
}
setMessage("Rollback completed");
await refreshHistory();
} catch (err) {