feat: align browser callback runtime and export flows

Consolidate the browser task runtime around the callback path, add safer artifact opening for Zhihu exports, and cover the new service/browser flows with focused tests and supporting docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
木炎
2026-04-06 21:44:53 +08:00
parent 0dd655712c
commit bdf8e12246
55 changed files with 14440 additions and 1053 deletions

267
src/compat/artifact_open.rs Normal file
View File

@@ -0,0 +1,267 @@
use std::path::Path;
use std::process::Command;
use serde_json::{json, Value};
use crate::browser::BrowserBackend;
use crate::pipe::{Action, CommandOutput};
pub const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
pub const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
pub const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";
const DISABLE_POST_EXPORT_OPEN_ENV: &str = "SGCLAW_DISABLE_POST_EXPORT_OPEN";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PostExportOpen {
Opened,
Failed(String),
}
pub fn open_exported_xlsx(output_path: &Path) -> PostExportOpen {
open_exported_xlsx_with(output_path, launch_with_default_xlsx_app)
}
fn open_exported_xlsx_with<F>(output_path: &Path, opener: F) -> PostExportOpen
where
F: FnOnce(&Path) -> Result<(), String>,
{
if !output_path.exists() {
return PostExportOpen::Failed(format!(
"导出的 Excel 文件不存在:{}",
output_path.display()
));
}
match opener(output_path) {
Ok(()) => PostExportOpen::Opened,
Err(reason) => PostExportOpen::Failed(reason),
}
}
pub fn open_local_dashboard(
browser_backend: &dyn BrowserBackend,
output_path: &Path,
presentation_url: &str,
) -> PostExportOpen {
if !output_path.exists() {
return PostExportOpen::Failed(format!(
"生成的大屏文件不存在:{}",
output_path.display()
));
}
if presentation_url.trim().is_empty() {
return PostExportOpen::Failed("screen_html_export did not return presentation.url".to_string());
}
let params = json!({
"url": presentation_url,
"sgclaw_local_dashboard_open": {
"source": LOCAL_DASHBOARD_SOURCE,
"kind": LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN,
"output_path": output_path.to_string_lossy(),
"presentation_url": presentation_url,
}
});
match browser_backend.invoke(Action::Navigate, params, LOCAL_DASHBOARD_EXPECTED_DOMAIN) {
Ok(output) if output.success => PostExportOpen::Opened,
Ok(output) => PostExportOpen::Failed(command_output_reason(&output)),
Err(err) => PostExportOpen::Failed(err.to_string()),
}
}
#[cfg(windows)]
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
return Ok(());
}
let output = Command::new("cmd")
.args(["/C", "start", "", &output_path.display().to_string()])
.output()
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!(
"启动 Excel 默认程序失败exit status {}",
output.status
))
} else {
Err(format!("启动 Excel 默认程序失败:{stderr}"))
}
}
}
#[cfg(target_os = "macos")]
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
return Ok(());
}
let status = Command::new("open")
.arg(output_path)
.status()
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("启动 Excel 默认程序失败exit status {status}"))
}
}
#[cfg(all(unix, not(target_os = "macos")))]
fn launch_with_default_xlsx_app(output_path: &Path) -> Result<(), String> {
if std::env::var_os(DISABLE_POST_EXPORT_OPEN_ENV).is_some() {
return Ok(());
}
let status = Command::new("xdg-open")
.arg(output_path)
.status()
.map_err(|err| format!("启动 Excel 默认程序失败:{err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("启动 Excel 默认程序失败exit status {status}"))
}
}
fn command_output_reason(output: &CommandOutput) -> String {
output
.data
.get("error")
.and_then(Value::as_str)
.or_else(|| output.data.get("message").and_then(Value::as_str))
.map(str::to_string)
.unwrap_or_else(|| output.data.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Mutex;
use serde_json::json;
use crate::pipe::{ExecutionSurfaceMetadata, PipeError, Timing};
fn temp_file_path(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-artifact-open-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).expect("temp root should exist");
root.join(name)
}
#[test]
fn open_exported_xlsx_with_passes_generated_path_to_launcher() {
let output_path = temp_file_path("zhihu-hotlist.xlsx");
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
let seen = Mutex::new(None::<PathBuf>);
let result = open_exported_xlsx_with(&output_path, |path| {
*seen.lock().unwrap() = Some(path.to_path_buf());
Ok(())
});
assert!(matches!(result, PostExportOpen::Opened));
assert_eq!(seen.lock().unwrap().clone().unwrap(), output_path);
}
#[test]
fn open_exported_xlsx_with_reports_launcher_failure() {
let output_path = temp_file_path("zhihu-hotlist.xlsx");
std::fs::write(&output_path, b"xlsx").expect("xlsx fixture should be writable");
let result = open_exported_xlsx_with(&output_path, |_path| Err("launcher failed".to_string()));
assert!(matches!(result, PostExportOpen::Failed(reason) if reason.contains("launcher failed")));
}
#[derive(Default)]
struct FakeBrowserBackend {
responses: Mutex<VecDeque<Result<CommandOutput, PipeError>>>,
invocations: Mutex<Vec<(Action, Value, String)>>,
}
impl FakeBrowserBackend {
fn new(responses: Vec<Result<CommandOutput, PipeError>>) -> Self {
Self {
responses: Mutex::new(VecDeque::from(responses)),
invocations: Mutex::new(Vec::new()),
}
}
}
impl BrowserBackend for FakeBrowserBackend {
fn invoke(
&self,
action: Action,
params: Value,
expected_domain: &str,
) -> Result<CommandOutput, PipeError> {
self.invocations
.lock()
.unwrap()
.push((action, params, expected_domain.to_string()));
self.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| Err(PipeError::Timeout))
}
fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
ExecutionSurfaceMetadata::privileged_browser_pipe("fake_backend")
}
}
#[test]
fn open_local_dashboard_uses_exact_approved_marker_payload() {
let output_path = temp_file_path("zhihu-hotlist-screen.html");
std::fs::write(&output_path, "<html></html>").expect("dashboard fixture should be writable");
let presentation_url = format!("file:///{}", output_path.display().to_string().replace('\\', "/"));
let backend = FakeBrowserBackend::new(vec![Ok(CommandOutput {
seq: 1,
success: true,
data: json!({ "navigated": true }),
aom_snapshot: vec![],
timing: Timing {
queue_ms: 1,
exec_ms: 1,
},
})]);
let result = open_local_dashboard(&backend, &output_path, &presentation_url);
let invocations = backend.invocations.lock().unwrap().clone();
assert!(matches!(result, PostExportOpen::Opened));
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].0, Action::Navigate);
assert_eq!(invocations[0].2, LOCAL_DASHBOARD_EXPECTED_DOMAIN.to_string());
assert_eq!(invocations[0].1["url"], json!(presentation_url));
assert_eq!(
invocations[0].1["sgclaw_local_dashboard_open"]["source"],
json!(LOCAL_DASHBOARD_SOURCE)
);
assert_eq!(
invocations[0].1["sgclaw_local_dashboard_open"]["kind"],
json!(LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN)
);
assert_eq!(
invocations[0].1["sgclaw_local_dashboard_open"]["output_path"],
json!(output_path.to_string_lossy().to_string())
);
assert_eq!(
invocations[0].1["sgclaw_local_dashboard_open"]["presentation_url"],
invocations[0].1["url"]
);
}
}