generated-scene: add scheduled monitoring runtime and helper lifecycle hardening

This commit is contained in:
木炎
2026-05-06 09:47:12 +08:00
parent 6cdd71b682
commit 8162118e6d
183 changed files with 103674 additions and 130 deletions

View File

@@ -1,10 +1,565 @@
use std::any::Any;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use sgclaw::config::SgClawSettings;
use sgclaw::generated_scene::scheduled_monitoring_runtime::{
run_scheduled_monitoring_command_adapter, run_scheduled_monitoring_skill_command_adapter,
run_scheduled_monitoring_skill_command_adapter_many,
ScheduledMonitoringCommandAdapterRequest, ScheduledMonitoringSkillCommandAdapterRequest,
ScheduledMonitoringSkillCommandAdapterManyRequest,
};
use serde_json::json;
fn main() -> ExitCode {
if let Err(err) = sgclaw::service::run() {
eprintln!("sg_claw failed: {err}");
return ExitCode::FAILURE;
let args: Vec<String> = std::env::args().skip(1).collect();
let args_for_log = args.clone();
let working_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let exit_code =
execute_with_top_level_logging(&working_dir, &args_for_log, || run_main(args));
ExitCode::from(exit_code as u8)
}
fn run_main(args: Vec<String>) -> i32 {
match parse_scheduled_monitoring_cli(args) {
Ok(Some(config)) => {
let result = if let Some(skills_dir) = config.skills_dir.as_ref() {
if config.trigger_paths.len() == 1 {
run_scheduled_monitoring_skill_command_adapter(
ScheduledMonitoringSkillCommandAdapterRequest {
trigger_path: &config.trigger_paths[0],
skills_dir,
config_path: config.config_path.as_deref(),
output_path: &config.output_path,
watch: config.watch,
max_runs: config.max_runs,
},
)
} else {
run_scheduled_monitoring_skill_command_adapter_many(
ScheduledMonitoringSkillCommandAdapterManyRequest {
trigger_paths: &config.trigger_paths,
skills_dir,
config_path: config.config_path.as_deref(),
output_path: &config.output_path,
watch: config.watch,
max_runs: config.max_runs,
},
)
}
} else {
if config.trigger_paths.len() != 1 {
eprintln!(
"scheduled monitoring trigger failed: multiple --scheduled-monitoring-trigger values require --skills-dir"
);
return 1;
}
run_scheduled_monitoring_command_adapter(ScheduledMonitoringCommandAdapterRequest {
trigger_path: &config.trigger_paths[0],
contract_path: &config.contract_path,
preview_fixtures_path: &config.fixtures_path,
output_path: &config.output_path,
})
};
match result {
Ok(_) => return 0,
Err(err) => {
eprintln!("scheduled monitoring trigger failed: {err}");
return 1;
}
}
}
Ok(None) => {}
Err(err) => {
eprintln!("sg_claw argument error: {err}");
return 1;
}
}
ExitCode::SUCCESS
if let Err(err) = sgclaw::service::run() {
eprintln!("sg_claw failed: {err}");
return 1;
}
0
}
fn execute_with_top_level_logging<F>(working_dir: &Path, args: &[String], runner: F) -> i32
where
F: FnOnce() -> i32,
{
let started_at = chrono::Local::now().to_rfc3339();
let outcome = panic::catch_unwind(AssertUnwindSafe(runner));
match outcome {
Ok(exit_code) => {
append_watch_exit_log(
working_dir,
&started_at,
args,
"process_exit",
if exit_code == 0 { "success" } else { "failure" },
exit_code,
None,
);
exit_code
}
Err(payload) => {
let panic_message = extract_panic_message(payload.as_ref());
append_watch_exit_log(
working_dir,
&started_at,
args,
"process_panic",
"panic",
1,
Some(&panic_message),
);
eprintln!("sg_claw panicked: {panic_message}");
1
}
}
}
fn extract_panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<String>() {
return message.clone();
}
if let Some(message) = payload.downcast_ref::<&str>() {
return (*message).to_string();
}
"unknown panic payload".to_string()
}
fn append_watch_exit_log(
working_dir: &Path,
started_at: &str,
args: &[String],
event: &str,
outcome: &str,
exit_code: i32,
message: Option<&str>,
) {
let log_path = working_dir.join("results").join("sgclaw-watch-fatal.log");
if let Some(parent) = log_path.parent() {
let _ = fs::create_dir_all(parent);
}
let payload = json!({
"ts": chrono::Local::now().to_rfc3339(),
"event": event,
"outcome": outcome,
"exitCode": exit_code,
"startedAt": started_at,
"cwd": working_dir.display().to_string(),
"watch": args.iter().any(|arg| arg == "--watch"),
"args": args,
"message": message,
});
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = writeln!(file, "{payload}");
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ScheduledMonitoringCliConfig {
trigger_paths: Vec<PathBuf>,
output_path: PathBuf,
skills_dir: Option<PathBuf>,
config_path: Option<PathBuf>,
contract_path: PathBuf,
fixtures_path: PathBuf,
watch: bool,
max_runs: Option<usize>,
}
fn parse_scheduled_monitoring_cli(
args: Vec<String>,
) -> Result<Option<ScheduledMonitoringCliConfig>, String> {
let mut trigger_paths = Vec::new();
let mut output_path = None;
let mut skills_dir = None;
let mut config_path = None;
let mut contract_path = None;
let mut fixtures_path = None;
let mut watch = false;
let mut max_runs = None;
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--scheduled-monitoring-trigger" => {
trigger_paths.push(PathBuf::from(next_arg(
&mut iter,
"--scheduled-monitoring-trigger",
)?));
}
"--output" => {
output_path = Some(PathBuf::from(next_arg(&mut iter, "--output")?));
}
"--skills-dir" => {
skills_dir = Some(PathBuf::from(next_arg(&mut iter, "--skills-dir")?));
}
"--config-path" => {
config_path = Some(resolve_process_path(PathBuf::from(next_arg(
&mut iter,
"--config-path",
)?)));
}
"--scheduled-monitoring-contract" => {
contract_path = Some(PathBuf::from(next_arg(
&mut iter,
"--scheduled-monitoring-contract",
)?));
}
"--scheduled-monitoring-fixtures" => {
fixtures_path = Some(PathBuf::from(next_arg(
&mut iter,
"--scheduled-monitoring-fixtures",
)?));
}
"--watch" => {
watch = true;
}
"--max-runs" => {
max_runs = Some(parse_usize_arg(&mut iter, "--max-runs")?);
}
_ => {
if let Some(value) = arg.strip_prefix("--scheduled-monitoring-trigger=") {
trigger_paths.push(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--output=") {
output_path = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--skills-dir=") {
skills_dir = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--config-path=") {
config_path = Some(resolve_process_path(PathBuf::from(value)));
} else if let Some(value) = arg.strip_prefix("--scheduled-monitoring-contract=") {
contract_path = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--scheduled-monitoring-fixtures=") {
fixtures_path = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--max-runs=") {
max_runs = Some(
value
.parse::<usize>()
.map_err(|_| format!("invalid value for --max-runs: {value}"))?,
);
}
}
}
}
if trigger_paths.is_empty() {
if watch {
if let Some(config_path) = config_path.as_deref() {
if let Some(settings) =
SgClawSettings::load(Some(config_path)).map_err(|err| err.to_string())?
{
trigger_paths = settings
.scheduled_monitoring_watch_tasks
.into_iter()
.map(PathBuf::from)
.collect();
}
}
}
if trigger_paths.is_empty() {
return Ok(None);
}
}
let output_path = output_path.ok_or_else(|| {
"missing required --output for --scheduled-monitoring-trigger".to_string()
})?;
Ok(Some(ScheduledMonitoringCliConfig {
trigger_paths,
output_path,
skills_dir,
config_path,
contract_path: contract_path.unwrap_or_else(|| {
PathBuf::from(
"tests/fixtures/generated_scene/scheduled_monitoring_action_trigger_runtime_contract_2026-04-22.json",
)
}),
fixtures_path: fixtures_path.unwrap_or_else(|| {
PathBuf::from(
"tests/fixtures/generated_scene/monitoring_action_mock_validation_fixtures_2026-04-22.json",
)
}),
watch,
max_runs,
}))
}
fn next_arg(iter: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
iter.next()
.ok_or_else(|| format!("missing value for {flag}"))
}
fn parse_usize_arg(iter: &mut impl Iterator<Item = String>, flag: &str) -> Result<usize, String> {
let value = next_arg(iter, flag)?;
value
.parse::<usize>()
.map_err(|_| format!("invalid value for {flag}: {value}"))
}
fn resolve_process_path(path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
#[cfg(test)]
mod tests {
use super::{execute_with_top_level_logging, parse_scheduled_monitoring_cli};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_json_file(prefix: &str, body: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}.json"));
fs::write(&path, body).unwrap();
path
}
fn temp_workspace(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
fs::create_dir_all(path.join("results")).unwrap();
path
}
fn read_exit_log_line(workspace: &Path) -> Value {
let raw = fs::read_to_string(workspace.join("results").join("sgclaw-watch-fatal.log"))
.unwrap();
serde_json::from_str(raw.lines().last().unwrap()).unwrap()
}
#[test]
fn scheduled_monitoring_cli_is_absent_by_default() {
assert!(parse_scheduled_monitoring_cli(vec![
"--config-path".to_string(),
"sgclaw_config.json".to_string(),
])
.unwrap()
.is_none());
}
#[test]
fn scheduled_monitoring_cli_requires_output() {
let error = parse_scheduled_monitoring_cli(vec![
"--scheduled-monitoring-trigger".to_string(),
"trigger.json".to_string(),
])
.unwrap_err();
assert!(error.contains("missing required --output"));
}
#[test]
fn scheduled_monitoring_cli_parses_trigger_output_and_defaults() {
let config = parse_scheduled_monitoring_cli(vec![
"--scheduled-monitoring-trigger".to_string(),
"trigger.json".to_string(),
"--output".to_string(),
"run-record.json".to_string(),
])
.unwrap()
.unwrap();
assert_eq!(config.trigger_paths, vec![PathBuf::from("trigger.json")]);
assert_eq!(config.output_path, PathBuf::from("run-record.json"));
assert_eq!(config.skills_dir, None);
assert!(config
.contract_path
.ends_with("scheduled_monitoring_action_trigger_runtime_contract_2026-04-22.json"));
assert!(config
.fixtures_path
.ends_with("monitoring_action_mock_validation_fixtures_2026-04-22.json"));
}
#[test]
fn scheduled_monitoring_cli_parses_optional_skills_dir() {
let config = parse_scheduled_monitoring_cli(vec![
"--scheduled-monitoring-trigger".to_string(),
"trigger.json".to_string(),
"--output".to_string(),
"run-record.json".to_string(),
"--skills-dir".to_string(),
"skills".to_string(),
"--config-path".to_string(),
"sgclaw_config.json".to_string(),
])
.unwrap()
.unwrap();
assert_eq!(config.skills_dir, Some(PathBuf::from("skills")));
assert!(
config
.config_path
.as_ref()
.is_some_and(|path| path.ends_with("sgclaw_config.json"))
);
}
#[test]
fn scheduled_monitoring_cli_parses_watch_and_max_runs() {
let config = parse_scheduled_monitoring_cli(vec![
"--scheduled-monitoring-trigger".to_string(),
"trigger.json".to_string(),
"--output".to_string(),
"run-record.json".to_string(),
"--watch".to_string(),
"--max-runs".to_string(),
"2".to_string(),
])
.unwrap()
.unwrap();
assert!(config.watch);
assert_eq!(config.max_runs, Some(2));
}
#[test]
fn scheduled_monitoring_cli_parses_multiple_triggers() {
let config = parse_scheduled_monitoring_cli(vec![
"--scheduled-monitoring-trigger".to_string(),
"fee.json".to_string(),
"--scheduled-monitoring-trigger".to_string(),
"archive.json".to_string(),
"--output".to_string(),
"watch-run-record.json".to_string(),
"--skills-dir".to_string(),
"skills".to_string(),
"--watch".to_string(),
])
.unwrap()
.unwrap();
assert_eq!(
config.trigger_paths,
vec![PathBuf::from("fee.json"), PathBuf::from("archive.json")]
);
assert_eq!(config.output_path, PathBuf::from("watch-run-record.json"));
assert_eq!(config.skills_dir, Some(PathBuf::from("skills")));
assert!(config.watch);
}
#[test]
fn scheduled_monitoring_cli_loads_watch_tasks_from_config_when_watch_has_no_trigger() {
let config_path = temp_json_file(
"sgclaw-watch-config",
r#"{
"apiKey": "sk-test",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"scheduledMonitoring": {
"watchTasks": [
"handoff/trigger.read_only.template.json",
"handoff/trigger.archive_workorder_grid_push.template.json"
]
}
}"#,
);
let config = parse_scheduled_monitoring_cli(vec![
"--config-path".to_string(),
config_path.to_string_lossy().into_owned(),
"--skills-dir".to_string(),
"skills".to_string(),
"--output".to_string(),
"watch-run-record.json".to_string(),
"--watch".to_string(),
])
.unwrap()
.unwrap();
assert_eq!(
config.trigger_paths,
vec![
PathBuf::from("handoff/trigger.read_only.template.json"),
PathBuf::from("handoff/trigger.archive_workorder_grid_push.template.json")
]
);
}
#[test]
fn scheduled_monitoring_cli_explicit_trigger_overrides_config_watch_tasks() {
let config_path = temp_json_file(
"sgclaw-watch-config-override",
r#"{
"apiKey": "sk-test",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"scheduledMonitoring": {
"watchTasks": [
"handoff/trigger.read_only.template.json",
"handoff/trigger.archive_workorder_grid_push.template.json"
]
}
}"#,
);
let config = parse_scheduled_monitoring_cli(vec![
"--config-path".to_string(),
config_path.to_string_lossy().into_owned(),
"--scheduled-monitoring-trigger".to_string(),
"only-this.json".to_string(),
"--output".to_string(),
"watch-run-record.json".to_string(),
"--watch".to_string(),
])
.unwrap()
.unwrap();
assert_eq!(config.trigger_paths, vec![PathBuf::from("only-this.json")]);
}
#[test]
fn top_level_logging_records_non_zero_exit() {
let workspace = temp_workspace("sgclaw-exit-log");
let args = vec!["--watch".to_string(), "--output".to_string(), "watch-run-record.json".to_string()];
let exit_code = execute_with_top_level_logging(&workspace, &args, || 1);
assert_eq!(exit_code, 1);
let payload = read_exit_log_line(&workspace);
assert_eq!(payload["event"], "process_exit");
assert_eq!(payload["outcome"], "failure");
assert_eq!(payload["exitCode"], 1);
assert_eq!(payload["watch"], true);
assert_eq!(payload["args"][0], "--watch");
}
#[test]
fn top_level_logging_records_panic_as_failure() {
let workspace = temp_workspace("sgclaw-panic-log");
let args = vec!["--watch".to_string()];
let exit_code = execute_with_top_level_logging(&workspace, &args, || {
panic!("boom");
});
assert_eq!(exit_code, 1);
let payload = read_exit_log_line(&workspace);
assert_eq!(payload["event"], "process_panic");
assert_eq!(payload["outcome"], "panic");
assert_eq!(payload["exitCode"], 1);
assert_eq!(payload["message"], "boom");
}
}

View File

@@ -442,21 +442,69 @@ fn build_get_text_js(source_url: &str, selector: &str) -> String {
fn build_eval_js(source_url: &str, script: &str) -> String {
let escaped_source_url = escape_js_single_quoted(source_url);
let escaped_script = escape_js_single_quoted(script);
let callback = EVAL_CALLBACK_NAME;
let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
let script_len = script.len();
let eval_sentinel = format!("sgclaw-eval-wrapper-{script_len}");
eprintln!(
"[callback_backend] build_eval_js source_url={} script_len={} sentinel={}",
source_url,
script_len,
eval_sentinel
);
format!(
"(function(){{try{{\
var v=(function(){{return {script}}})();\
function _s(v){{\
var t=(typeof v==='string')?v:JSON.stringify(v);\
var __sgclawEvalSentinel='{eval_sentinel}';\
function _j(v){{\
if(typeof v==='string')return v;\
try{{return JSON.stringify(v);}}catch(_){{return String(v);}}\
}}\
function _e(err){{\
if(!err)return{{message:'unknown error'}};\
if(typeof err==='string')return{{message:err}};\
return {{\
name: err.name||'Error',\
message: err.message||String(err),\
stack: err.stack||'',\
code: err.code||'',\
stage: err.stage||'',\
url: err.url||'',\
timeoutMs: err.timeoutMs||0,\
trace: err.trace||null\
}};\
}}\
function _emit(payload){{\
var t=_j(payload);\
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:payload}});\
try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
}}\
if(v&&typeof v.then==='function'){{v.then(_s).catch(function(){{}});}}else{{_s(v);}}\
}}catch(e){{}}}})()"
var _compiled;\
try{{\
_compiled = new Function('return ({escaped_script});');\
}}catch(compileErr){{\
_emit({{value:null,error:_e(compileErr),phase:'eval_compile_failed'}});\
return;\
}}\
var v;\
try{{\
v = _compiled();\
}}catch(runErr){{\
_emit({{value:null,error:_e(runErr),phase:'eval_runtime_failed'}});\
return;\
}}\
function _s(v){{_emit({{value:v??null,error:null}});}}\
function _f(err){{_emit({{value:null,error:_e(err)}});}}\
if(v&&typeof v.then==='function'){{v.then(_s).catch(_f);}}else{{_s(v);}}\
}}catch(e){{\
var payload={{value:null,error:{{name:e&&e.name||'Error',message:e&&e.message||String(e),stack:e&&e.stack||''}}}};\
try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+JSON.stringify(payload))}}catch(_){{}}\
try{{var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:payload}});var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
try{{navigator.sendBeacon('{events_url}',new Blob([JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:payload}})],{{type:'application/json'}}))}}catch(_){{}}\
}}}})()"
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,297 @@
use chrono::Local;
use serde::Serialize;
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug)]
pub(crate) struct CallbackHostLifecycleLogger {
path: PathBuf,
writer: Mutex<Option<BufWriter<File>>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SharedLifecycleFields {
pub ts: String,
pub event: String,
pub host_instance_id: String,
pub run_id: String,
pub skill_id: String,
pub helper_url: String,
pub listener_port: u16,
pub thread: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostCreateEvent {
#[serde(flatten)]
pub shared: SharedLifecycleFields,
pub browser_ws_url: String,
pub use_hidden_domain: bool,
pub request_url: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReadyStateTransitionEvent {
#[serde(flatten)]
pub shared: SharedLifecycleFields,
pub helper_loaded: bool,
pub ready: bool,
pub source: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CommandIssueEvent {
#[serde(flatten)]
pub shared: SharedLifecycleFields,
pub seq: u64,
pub action: String,
pub script_len: usize,
pub expected_domain: String,
pub request_url: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PollErrorEvent {
#[serde(flatten)]
pub shared: SharedLifecycleFields,
pub seq: Option<u64>,
pub message: String,
pub elapsed_ms: Option<u128>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HostDropEvent {
#[serde(flatten)]
pub shared: SharedLifecycleFields,
pub uptime_ms: u128,
pub matched_callback_count: u64,
pub unmatched_callback_count: u64,
pub poll_error_count: u64,
pub helper_loaded: bool,
pub ready: bool,
pub drop_join_ok: bool,
}
impl CallbackHostLifecycleLogger {
pub(crate) fn new(path: PathBuf) -> Self {
Self {
path,
writer: Mutex::new(None),
}
}
pub(crate) fn now_local_rfc3339() -> String {
Local::now().to_rfc3339()
}
pub(crate) fn write_host_create(&self, event: HostCreateEvent) -> std::io::Result<()> {
self.write_line(&event)
}
pub(crate) fn write_ready_state_transition(
&self,
event: ReadyStateTransitionEvent,
) -> std::io::Result<()> {
self.write_line(&event)
}
pub(crate) fn write_command_issue(&self, event: CommandIssueEvent) -> std::io::Result<()> {
self.write_line(&event)
}
pub(crate) fn write_poll_error(&self, event: PollErrorEvent) -> std::io::Result<()> {
self.write_line(&event)
}
pub(crate) fn write_host_drop(&self, event: HostDropEvent) -> std::io::Result<()> {
self.write_line(&event)
}
fn ensure_writer(&self) -> std::io::Result<()> {
let mut guard = self.writer.lock().unwrap();
if guard.is_none() {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
*guard = Some(BufWriter::new(file));
}
Ok(())
}
fn write_line<T: Serialize>(&self, value: &T) -> std::io::Result<()> {
self.ensure_writer()?;
let mut guard = self.writer.lock().unwrap();
let writer = guard.as_mut().expect("writer initialized");
serde_json::to_writer(&mut *writer, value)?;
writer.write_all(b"\n")?;
writer.flush()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
CallbackHostLifecycleLogger, CommandIssueEvent, HostCreateEvent, HostDropEvent,
PollErrorEvent, ReadyStateTransitionEvent,
SharedLifecycleFields,
};
#[test]
fn lifecycle_log_writes_host_create_event_with_shared_fields() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("callback-host-lifecycle.ndjson");
let logger = CallbackHostLifecycleLogger::new(log_path.clone());
logger
.write_host_create(HostCreateEvent {
shared: SharedLifecycleFields {
ts: "2026-04-29T21:15:32.123+08:00".to_string(),
event: "host_create".to_string(),
host_instance_id: "cbh-000123".to_string(),
run_id: "run-001".to_string(),
skill_id: "command-center-fee-control-monitor".to_string(),
helper_url: "http://127.0.0.1:60882/sgclaw/browser-helper.html".to_string(),
listener_port: 60882,
thread: "callback-host".to_string(),
},
browser_ws_url: "ws://127.0.0.1:12345".to_string(),
use_hidden_domain: true,
request_url: "http://yx.gs.sgcc.com.cn/".to_string(),
})
.unwrap();
let text = std::fs::read_to_string(log_path).unwrap();
assert!(text.contains("\"event\":\"host_create\""));
assert!(text.contains("\"hostInstanceId\":\"cbh-000123\""));
assert!(text.contains("\"listenerPort\":60882"));
assert!(text.contains("\"browserWsUrl\":\"ws://127.0.0.1:12345\""));
}
#[test]
fn lifecycle_log_writes_ready_state_transition_event() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("callback-host-lifecycle.ndjson");
let logger = CallbackHostLifecycleLogger::new(log_path.clone());
logger
.write_ready_state_transition(ReadyStateTransitionEvent {
shared: SharedLifecycleFields {
ts: "2026-04-29T21:15:32.441+08:00".to_string(),
event: "ready_state_transition".to_string(),
host_instance_id: "cbh-000123".to_string(),
run_id: "run-001".to_string(),
skill_id: "command-center-fee-control-monitor".to_string(),
helper_url: "http://127.0.0.1:60882/sgclaw/browser-helper.html".to_string(),
listener_port: 60882,
thread: "callback-host".to_string(),
},
helper_loaded: true,
ready: true,
source: "ready_endpoint_hit".to_string(),
})
.unwrap();
let text = std::fs::read_to_string(log_path).unwrap();
assert!(text.contains("\"event\":\"ready_state_transition\""));
assert!(text.contains("\"helperLoaded\":true"));
assert!(text.contains("\"ready\":true"));
assert!(text.contains("\"source\":\"ready_endpoint_hit\""));
}
#[test]
fn lifecycle_log_writes_command_issue_and_poll_error_events() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("callback-host-lifecycle.ndjson");
let logger = CallbackHostLifecycleLogger::new(log_path.clone());
logger
.write_command_issue(CommandIssueEvent {
shared: SharedLifecycleFields {
ts: "2026-04-29T21:15:33.018+08:00".to_string(),
event: "command_issue".to_string(),
host_instance_id: "cbh-000123".to_string(),
run_id: "run-001".to_string(),
skill_id: "command-center-fee-control-monitor".to_string(),
helper_url: "http://127.0.0.1:60882/sgclaw/browser-helper.html".to_string(),
listener_port: 60882,
thread: "callback-host".to_string(),
},
seq: 2,
action: "eval".to_string(),
script_len: 29470,
expected_domain: "yx.gs.sgcc.com.cn".to_string(),
request_url: "http://127.0.0.1:60882/sgclaw/browser-helper.html".to_string(),
})
.unwrap();
logger
.write_poll_error(PollErrorEvent {
shared: SharedLifecycleFields {
ts: "2026-04-29T21:15:36.566+08:00".to_string(),
event: "poll_error".to_string(),
host_instance_id: "cbh-000123".to_string(),
run_id: "run-001".to_string(),
skill_id: "command-center-fee-control-monitor".to_string(),
helper_url: "http://127.0.0.1:60882/sgclaw/browser-helper.html".to_string(),
listener_port: 60882,
thread: "callback-host".to_string(),
},
seq: Some(2),
message: "Failed to fetch".to_string(),
elapsed_ms: Some(3548),
})
.unwrap();
let text = std::fs::read_to_string(log_path).unwrap();
assert!(text.contains("\"event\":\"command_issue\""));
assert!(text.contains("\"scriptLen\":29470"));
assert!(text.contains("\"event\":\"poll_error\""));
assert!(text.contains("\"message\":\"Failed to fetch\""));
}
#[test]
fn lifecycle_log_writes_host_drop_event() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("callback-host-lifecycle.ndjson");
let logger = CallbackHostLifecycleLogger::new(log_path.clone());
logger
.write_host_drop(HostDropEvent {
shared: SharedLifecycleFields {
ts: "2026-04-29T21:15:58.020+08:00".to_string(),
event: "host_drop".to_string(),
host_instance_id: "cbh-000123".to_string(),
run_id: "run-001".to_string(),
skill_id: "command-center-fee-control-monitor".to_string(),
helper_url: "http://127.0.0.1:60882/sgclaw/browser-helper.html".to_string(),
listener_port: 60882,
thread: "callback-host".to_string(),
},
uptime_ms: 23340,
matched_callback_count: 2,
unmatched_callback_count: 14,
poll_error_count: 11,
helper_loaded: true,
ready: true,
drop_join_ok: true,
})
.unwrap();
let text = std::fs::read_to_string(log_path).unwrap();
assert!(text.contains("\"event\":\"host_drop\""));
assert!(text.contains("\"uptimeMs\":23340"));
assert!(text.contains("\"dropJoinOk\":true"));
}
}

View File

@@ -4,6 +4,7 @@ pub mod bridge_contract;
pub mod bridge_transport;
pub mod callback_backend;
pub(crate) mod callback_host;
pub(crate) mod callback_host_lifecycle_log;
mod pipe_backend;
pub mod ws_backend;
pub mod ws_probe;

View File

@@ -1,3 +1,4 @@
use std::fs;
use std::net::TcpStream;
use std::time::Duration;
@@ -83,24 +84,23 @@ pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError> {
let value = args
.get(index)
.ok_or_else(|| ProbeError::Args("missing value for --step".to_string()))?;
let (label, payload) = value.split_once("::").ok_or_else(|| {
ProbeError::Args(format!(
"invalid --step value (expected <label>::<payload>): {value}"
))
steps.push(parse_step_value(value, "--step")?);
}
"--step-file" => {
index += 1;
let value = args.get(index).ok_or_else(|| {
ProbeError::Args("missing value for --step-file".to_string())
})?;
if label.is_empty() {
return Err(ProbeError::Args("step label must not be empty".to_string()));
let contents = fs::read_to_string(value).map_err(|err| {
ProbeError::Args(format!("failed to read --step-file {value}: {err}"))
})?;
let normalized = contents.trim();
if normalized.is_empty() {
return Err(ProbeError::Args(format!(
"--step-file must not be empty: {value}"
)));
}
if payload.is_empty() {
return Err(ProbeError::Args(
"step payload must not be empty".to_string(),
));
}
steps.push(ProbeStep {
label: label.to_string(),
payload: payload.to_string(),
expect_reply: true,
});
steps.push(parse_step_value(normalized, "--step-file")?);
}
flag => {
return Err(ProbeError::Args(format!("unknown argument: {flag}")));
@@ -127,6 +127,27 @@ pub fn parse_probe_args(args: &[String]) -> Result<ProbeCliConfig, ProbeError> {
})
}
fn parse_step_value(value: &str, source: &str) -> Result<ProbeStep, ProbeError> {
let (label, payload) = value.split_once("::").ok_or_else(|| {
ProbeError::Args(format!(
"invalid {source} value (expected <label>::<payload>): {value}"
))
})?;
if label.is_empty() {
return Err(ProbeError::Args("step label must not be empty".to_string()));
}
if payload.is_empty() {
return Err(ProbeError::Args(
"step payload must not be empty".to_string(),
));
}
Ok(ProbeStep {
label: label.to_string(),
payload: payload.to_string(),
expect_reply: true,
})
}
fn validate_ws_url(ws_url: &str) -> Result<(), ProbeError> {
if ws_url.starts_with("ws://") {
return Ok(());

View File

@@ -12,6 +12,18 @@ use zeroclaw::tools::{Tool, ToolResult};
use crate::browser::BrowserBackend;
use crate::pipe::Action;
fn preview_utf8(text: &str, max_chars: usize) -> &str {
if text.chars().count() <= max_chars {
return text;
}
let mut end = 0usize;
for (idx, ch) in text.char_indices().take(max_chars) {
end = idx + ch.len_utf8();
}
&text[..end]
}
pub struct BrowserScriptSkillTool {
tool_name: String,
tool_description: String,
@@ -269,11 +281,7 @@ fn execute_browser_script_impl(
);
eprintln!(
"[execute_browser_script_impl] 包装后脚本前500字符: {}",
if wrapped_script.len() > 500 {
&wrapped_script[..500]
} else {
&wrapped_script
}
preview_utf8(&wrapped_script, 500)
);
eprintln!("[execute_browser_script_impl] 调用 browser_tool.invoke(Action::Eval)...");

View File

@@ -165,13 +165,23 @@ fn build_scene_execution_plan(
instruction: &str,
mut args: Map<String, Value>,
) -> SceneExecutionPlan {
let bootstrap = entry
.manifest
.bootstrap
.as_ref()
.expect("report scene registry should only contain manifests with bootstrap");
let artifact = entry
.manifest
.artifact
.as_ref()
.expect("report scene registry should only contain manifests with artifact");
args.insert(
"expected_domain".to_string(),
Value::String(entry.manifest.bootstrap.expected_domain.clone()),
Value::String(bootstrap.expected_domain.clone()),
);
args.insert(
"target_url".to_string(),
Value::String(entry.manifest.bootstrap.target_url.clone()),
Value::String(bootstrap.target_url.clone()),
);
SceneExecutionPlan {
@@ -181,11 +191,11 @@ fn build_scene_execution_plan(
"{}.{}",
entry.manifest.scene.skill, entry.manifest.scene.tool
),
expected_domain: entry.manifest.bootstrap.expected_domain.clone(),
target_url: entry.manifest.bootstrap.target_url.clone(),
expected_domain: bootstrap.expected_domain.clone(),
target_url: bootstrap.target_url.clone(),
args,
success_statuses: entry.manifest.artifact.success_status.clone(),
failure_statuses: entry.manifest.artifact.failure_status.clone(),
success_statuses: artifact.success_status.clone(),
failure_statuses: artifact.failure_status.clone(),
postprocess: entry.manifest.postprocess.clone(),
}
}
@@ -252,7 +262,8 @@ fn score_scene(
page_url: Option<&str>,
page_title: Option<&str>,
) -> Option<usize> {
let deterministic = &entry.manifest.deterministic;
let deterministic = entry.manifest.deterministic.as_ref()?;
let bootstrap = entry.manifest.bootstrap.as_ref()?;
if deterministic.suffix != DETERMINISTIC_SUFFIX {
return None;
}
@@ -279,11 +290,7 @@ fn score_scene(
let normalized_url = page_url.unwrap_or_default().to_ascii_lowercase();
if !normalized_url.is_empty() {
if normalized_url.contains(
&entry
.manifest
.bootstrap
.expected_domain
.to_ascii_lowercase(),
&bootstrap.expected_domain.to_ascii_lowercase(),
) {
score += 100;
} else if normalized_url.contains(&entry.manifest.scene.id.to_ascii_lowercase()) {
@@ -296,6 +303,7 @@ fn score_scene(
&& entry
.manifest
.bootstrap
.as_ref()?
.page_title_keywords
.iter()
.any(|keyword| !keyword.trim().is_empty() && title.contains(keyword.as_str()))
@@ -324,8 +332,18 @@ fn log_registry_diag(registry: &[SceneRegistryEntry]) {
registry.len(),
DIAGNOSTIC_SCENE_ID,
entry.skill_root.display(),
entry.manifest.deterministic.suffix == DETERMINISTIC_SUFFIX,
entry.manifest.deterministic.include_keywords
entry
.manifest
.deterministic
.as_ref()
.map(|deterministic| deterministic.suffix == DETERMINISTIC_SUFFIX)
.unwrap_or(false),
entry
.manifest
.deterministic
.as_ref()
.map(|deterministic| deterministic.include_keywords.clone())
.unwrap_or_default()
)),
None => log_deterministic_diag(format!(
"registry loaded count={} diagnostic_scene={} registered=false",
@@ -336,7 +354,13 @@ fn log_registry_diag(registry: &[SceneRegistryEntry]) {
}
fn log_scene_match_diag(entry: &SceneRegistryEntry, instruction: &str) {
let deterministic = &entry.manifest.deterministic;
let Some(deterministic) = entry.manifest.deterministic.as_ref() else {
log_deterministic_diag(format!(
"diagnostic_scene={} deterministic=false",
entry.manifest.scene.id
));
return;
};
let include_hits = deterministic
.include_keywords
.iter()

View File

@@ -1,3 +1,4 @@
pub mod dispatch;
pub mod registry;
pub mod resolvers;
pub mod scheduled_registry;

View File

@@ -0,0 +1,293 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use zeroclaw::skills::{load_skills_from_directory, Skill};
use crate::scene_contract::manifest::{
SceneManifest, SCENE_MANIFEST_FILE_NAME, SUPPORTED_SCHEMA_VERSION_V1,
SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1, SUPPORTED_SCHEDULED_MONITORING_KIND_V1,
};
#[derive(Debug, Clone)]
pub struct ScheduledMonitoringRegistryEntry {
pub manifest: SceneManifest,
pub skill_root: PathBuf,
pub workflow_id: String,
}
#[derive(Debug, Error)]
pub enum ScheduledMonitoringRegistryError {
#[error("failed to read skills directory {path}: {source}")]
ReadSkillsDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to read scene manifest {path}: {source}")]
ReadManifest {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse scene manifest {path}: {source}")]
ParseManifest {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("scheduled scene manifest {path} declares unsupported schema_version {version}; only {supported} is supported in v1")]
UnsupportedSchemaVersion {
path: PathBuf,
version: String,
supported: &'static str,
},
#[error("scheduled scene manifest {path} declares unsupported kind {kind}; only {supported} is supported")]
UnsupportedSceneKind {
path: PathBuf,
kind: String,
supported: &'static str,
},
#[error("scheduled scene manifest {path} declares unsupported category {category}; only {supported} is supported")]
UnsupportedSceneCategory {
path: PathBuf,
category: String,
supported: &'static str,
},
#[error("scheduled scene manifest {path} points to missing skill package {skill}")]
MissingSkill { path: PathBuf, skill: String },
#[error("scheduled scene manifest {path} declares skill {manifest_skill}, but containing skill package is {package_skill}")]
SkillPackageMismatch {
path: PathBuf,
manifest_skill: String,
package_skill: String,
},
#[error("scheduled scene manifest {path} is missing trigger section")]
MissingTriggerSection { path: PathBuf },
#[error("scheduled scene manifest {path} is missing modes section")]
MissingModesSection { path: PathBuf },
#[error("scheduled scene manifest {path} is missing runtime_context section")]
MissingRuntimeContextSection { path: PathBuf },
#[error("scheduled scene manifest {path} is missing safety section")]
MissingSafetySection { path: PathBuf },
#[error("scheduled scene manifest {path} is missing tools section")]
MissingToolsSection { path: PathBuf },
#[error("scheduled scene manifest {path} is missing references section")]
MissingReferencesSection { path: PathBuf },
#[error("scheduled scene manifest {path} is missing workflow_id")]
MissingWorkflowId { path: PathBuf },
#[error("scheduled scene manifest {path} has unsafe active or queue_process enablement")]
UnsafeModesEnabled { path: PathBuf },
#[error("scheduled scene manifest {path} incorrectly exposes natural-language primary trigger")]
NaturalLanguagePrimaryEnabled { path: PathBuf },
#[error("scheduled workflow id {workflow_id} is declared twice: {first_path} and {second_path}")]
DuplicateWorkflowId {
workflow_id: String,
first_path: PathBuf,
second_path: PathBuf,
},
}
pub fn load_scheduled_monitoring_registry(
skills_dir: &Path,
) -> Result<Vec<ScheduledMonitoringRegistryEntry>, ScheduledMonitoringRegistryError> {
if !skills_dir.exists() {
return Ok(Vec::new());
}
let mut skill_roots = Vec::new();
for entry in fs::read_dir(skills_dir).map_err(|source| {
ScheduledMonitoringRegistryError::ReadSkillsDir {
path: skills_dir.to_path_buf(),
source,
}
})? {
let entry = entry.map_err(|source| ScheduledMonitoringRegistryError::ReadSkillsDir {
path: skills_dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.is_dir() {
skill_roots.push(path);
}
}
skill_roots.sort();
let skills_by_root = index_skills_by_root(skills_dir);
let mut workflow_ids = HashMap::new();
let mut registry = Vec::new();
for skill_root in skill_roots {
let manifest_path = skill_root.join(SCENE_MANIFEST_FILE_NAME);
if !manifest_path.exists() {
continue;
}
let manifest = load_manifest(&manifest_path)?;
if manifest.scene.kind != SUPPORTED_SCHEDULED_MONITORING_KIND_V1 {
continue;
}
let workflow_id = validate_manifest(&manifest, &manifest_path, &skill_root, &skills_by_root)?;
if let Some(first_path) = workflow_ids.insert(workflow_id.clone(), manifest_path.clone()) {
return Err(ScheduledMonitoringRegistryError::DuplicateWorkflowId {
workflow_id,
first_path,
second_path: manifest_path,
});
}
registry.push(ScheduledMonitoringRegistryEntry {
manifest,
skill_root,
workflow_id,
});
}
Ok(registry)
}
fn index_skills_by_root(skills_dir: &Path) -> HashMap<PathBuf, Skill> {
load_skills_from_directory(skills_dir, true)
.into_iter()
.filter_map(|skill| {
let skill_root = skill
.location
.as_deref()
.and_then(Path::parent)
.map(Path::to_path_buf)?;
Some((skill_root, skill))
})
.collect()
}
fn load_manifest(path: &Path) -> Result<SceneManifest, ScheduledMonitoringRegistryError> {
let content =
fs::read_to_string(path).map_err(|source| ScheduledMonitoringRegistryError::ReadManifest {
path: path.to_path_buf(),
source,
})?;
toml::from_str(&content).map_err(|source| ScheduledMonitoringRegistryError::ParseManifest {
path: path.to_path_buf(),
source,
})
}
fn validate_manifest(
manifest: &SceneManifest,
manifest_path: &Path,
skill_root: &Path,
skills_by_root: &HashMap<PathBuf, Skill>,
) -> Result<String, ScheduledMonitoringRegistryError> {
if manifest.manifest.schema_version != SUPPORTED_SCHEMA_VERSION_V1 {
return Err(ScheduledMonitoringRegistryError::UnsupportedSchemaVersion {
path: manifest_path.to_path_buf(),
version: manifest.manifest.schema_version.clone(),
supported: SUPPORTED_SCHEMA_VERSION_V1,
});
}
if manifest.scene.kind != SUPPORTED_SCHEDULED_MONITORING_KIND_V1 {
return Err(ScheduledMonitoringRegistryError::UnsupportedSceneKind {
path: manifest_path.to_path_buf(),
kind: manifest.scene.kind.clone(),
supported: SUPPORTED_SCHEDULED_MONITORING_KIND_V1,
});
}
if manifest.scene.category != SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1 {
return Err(ScheduledMonitoringRegistryError::UnsupportedSceneCategory {
path: manifest_path.to_path_buf(),
category: manifest.scene.category.clone(),
supported: SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1,
});
}
let trigger = manifest
.trigger
.as_ref()
.ok_or_else(|| ScheduledMonitoringRegistryError::MissingTriggerSection {
path: manifest_path.to_path_buf(),
})?;
let modes = manifest
.modes
.as_ref()
.ok_or_else(|| ScheduledMonitoringRegistryError::MissingModesSection {
path: manifest_path.to_path_buf(),
})?;
let _runtime_context = manifest.runtime_context.as_ref().ok_or_else(|| {
ScheduledMonitoringRegistryError::MissingRuntimeContextSection {
path: manifest_path.to_path_buf(),
}
})?;
let safety = manifest
.safety
.as_ref()
.ok_or_else(|| ScheduledMonitoringRegistryError::MissingSafetySection {
path: manifest_path.to_path_buf(),
})?;
let tools = manifest
.tools
.as_ref()
.ok_or_else(|| ScheduledMonitoringRegistryError::MissingToolsSection {
path: manifest_path.to_path_buf(),
})?;
let _references = manifest.references.as_ref().ok_or_else(|| {
ScheduledMonitoringRegistryError::MissingReferencesSection {
path: manifest_path.to_path_buf(),
}
})?;
let workflow_id = manifest
.scene
.workflow_id
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| ScheduledMonitoringRegistryError::MissingWorkflowId {
path: manifest_path.to_path_buf(),
})?;
if safety.active_enabled || safety.queue_process_enabled {
return Err(ScheduledMonitoringRegistryError::UnsafeModesEnabled {
path: manifest_path.to_path_buf(),
});
}
if trigger.natural_language_primary {
return Err(ScheduledMonitoringRegistryError::NaturalLanguagePrimaryEnabled {
path: manifest_path.to_path_buf(),
});
}
if !modes.enabled.iter().any(|mode| mode == "dry_run" || mode == "monitor_only") {
return Err(ScheduledMonitoringRegistryError::UnsafeModesEnabled {
path: manifest_path.to_path_buf(),
});
}
let Some(skill) = skills_by_root.get(skill_root) else {
return Err(ScheduledMonitoringRegistryError::MissingSkill {
path: manifest_path.to_path_buf(),
skill: manifest.scene.skill.clone(),
});
};
if skill.name != manifest.scene.skill {
return Err(ScheduledMonitoringRegistryError::SkillPackageMismatch {
path: manifest_path.to_path_buf(),
manifest_skill: manifest.scene.skill.clone(),
package_skill: skill.name.clone(),
});
}
for expected_tool in [&tools.detect, &tools.decide, &tools.action_plan] {
if expected_tool.trim().is_empty() {
return Err(ScheduledMonitoringRegistryError::MissingToolsSection {
path: manifest_path.to_path_buf(),
});
}
}
Ok(workflow_id)
}

View File

@@ -152,6 +152,8 @@ pub struct SgClawSettings {
pub office_backend: OfficeBackend,
pub browser_ws_url: Option<String>,
pub service_ws_listen_addr: Option<String>,
pub scheduled_monitoring_platform_service_base_url: Option<String>,
pub scheduled_monitoring_watch_tasks: Vec<String>,
}
impl SgClawSettings {
@@ -190,6 +192,8 @@ impl SgClawSettings {
None,
None,
None,
None,
Vec::new(),
)
}
@@ -237,6 +241,16 @@ impl SgClawSettings {
}),
browser_ws_url: self.browser_ws_url.clone(),
service_ws_listen_addr: self.service_ws_listen_addr.clone(),
scheduled_monitoring_platform_service_base_url: self
.scheduled_monitoring_platform_service_base_url
.clone(),
scheduled_monitoring: if self.scheduled_monitoring_watch_tasks.is_empty() {
None
} else {
Some(SerializableScheduledMonitoringSettings {
watch_tasks: self.scheduled_monitoring_watch_tasks.clone(),
})
},
providers: self
.providers
.iter()
@@ -290,6 +304,8 @@ impl SgClawSettings {
None,
None,
None,
None,
Vec::new(),
)?))
}
@@ -376,6 +392,11 @@ impl SgClawSettings {
office_backend,
config.browser_ws_url,
config.service_ws_listen_addr,
config.scheduled_monitoring_platform_service_base_url,
config
.scheduled_monitoring
.map(|value| value.watch_tasks)
.unwrap_or_default(),
)
.map_err(|err| err.with_path(path))
}
@@ -395,6 +416,8 @@ impl SgClawSettings {
office_backend: Option<OfficeBackend>,
browser_ws_url: Option<String>,
service_ws_listen_addr: Option<String>,
scheduled_monitoring_platform_service_base_url: Option<String>,
scheduled_monitoring_watch_tasks: Vec<String>,
) -> Result<Self, ConfigError> {
let direct_submit_skill = normalize_direct_submit_skill(direct_submit_skill)?;
let providers = if providers.is_empty() {
@@ -438,6 +461,10 @@ impl SgClawSettings {
office_backend: office_backend.unwrap_or(OfficeBackend::OpenXml),
browser_ws_url: normalize_optional_value(browser_ws_url),
service_ws_listen_addr: normalize_optional_value(service_ws_listen_addr),
scheduled_monitoring_platform_service_base_url: normalize_optional_value(
scheduled_monitoring_platform_service_base_url,
),
scheduled_monitoring_watch_tasks,
})
}
}
@@ -627,10 +654,26 @@ struct SerializableRawSgClawSettings {
skip_serializing_if = "Option::is_none"
)]
service_ws_listen_addr: Option<String>,
#[serde(
rename = "scheduledMonitoringPlatformServiceBaseUrl",
skip_serializing_if = "Option::is_none"
)]
scheduled_monitoring_platform_service_base_url: Option<String>,
#[serde(
rename = "scheduledMonitoring",
skip_serializing_if = "Option::is_none"
)]
scheduled_monitoring: Option<SerializableScheduledMonitoringSettings>,
#[serde(default)]
providers: Vec<SerializableProviderSettings>,
}
#[derive(Debug, Serialize)]
struct SerializableScheduledMonitoringSettings {
#[serde(rename = "watchTasks")]
watch_tasks: Vec<String>,
}
#[derive(Debug, Serialize)]
struct SerializableProviderSettings {
id: String,
@@ -680,10 +723,24 @@ struct RawSgClawSettings {
default
)]
service_ws_listen_addr: Option<String>,
#[serde(
rename = "scheduledMonitoringPlatformServiceBaseUrl",
alias = "scheduled_monitoring_platform_service_base_url",
default
)]
scheduled_monitoring_platform_service_base_url: Option<String>,
#[serde(rename = "scheduledMonitoring", default)]
scheduled_monitoring: Option<RawScheduledMonitoringSettings>,
#[serde(default)]
providers: Vec<RawProviderSettings>,
}
#[derive(Debug, Deserialize)]
struct RawScheduledMonitoringSettings {
#[serde(rename = "watchTasks", alias = "watch_tasks", default)]
watch_tasks: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawProviderSettings {
#[serde(default)]

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,8 @@ pub enum WorkflowArchetype {
LocalDocPipeline,
#[serde(rename = "page_state_eval")]
PageStateEval,
#[serde(rename = "monitoring_action_workflow")]
MonitoringActionWorkflow,
}
impl WorkflowArchetype {
@@ -34,6 +36,7 @@ impl WorkflowArchetype {
Self::MultiEndpointInventory => "multi_endpoint_inventory",
Self::LocalDocPipeline => "local_doc_pipeline",
Self::PageStateEval => "page_state_eval",
Self::MonitoringActionWorkflow => "monitoring_action_workflow",
}
}
@@ -47,6 +50,7 @@ impl WorkflowArchetype {
"multi_endpoint_inventory" => Some(Self::MultiEndpointInventory),
"local_doc_pipeline" => Some(Self::LocalDocPipeline),
"page_state_eval" => Some(Self::PageStateEval),
"monitoring_action_workflow" => Some(Self::MonitoringActionWorkflow),
_ => None,
}
}
@@ -252,6 +256,259 @@ pub struct RuntimeDependencyIr {
pub subordinate_to_business_chain: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringRuntimeContextIr {
#[serde(rename = "runtimeContextUrl", default)]
pub runtime_context_url: String,
#[serde(rename = "expectedDomain", default)]
pub expected_domain: String,
#[serde(rename = "gatewayDomain", default)]
pub gateway_domain: String,
#[serde(rename = "localhostServiceBase", default)]
pub localhost_service_base: String,
#[serde(rename = "browserAttachedRequired", default)]
pub browser_attached_required: bool,
#[serde(rename = "hostBridgeRequired", default)]
pub host_bridge_required: bool,
#[serde(
rename = "executionContextMode",
default = "default_execution_context_mode"
)]
pub execution_context_mode: String,
#[serde(
rename = "requestClientMode",
default = "default_request_client_mode"
)]
pub request_client_mode: String,
#[serde(rename = "encryptionMode", default = "default_encryption_mode")]
pub encryption_mode: String,
#[serde(
rename = "attachedPageBrowserActionPolicy",
default = "default_attached_page_browser_action_policy"
)]
pub attached_page_browser_action_policy: String,
#[serde(
rename = "platformWritePolicy",
default = "default_platform_write_policy"
)]
pub platform_write_policy: String,
#[serde(rename = "storageReads", default)]
pub storage_reads: Vec<MonitoringStorageReadIr>,
#[serde(rename = "readSlices", default)]
pub read_slices: Vec<MonitoringReadSliceIr>,
#[serde(rename = "encryptionResolution", default)]
pub encryption_resolution: MonitoringEncryptionResolutionIr,
#[serde(rename = "timeoutContract", default)]
pub timeout_contract: MonitoringTimeoutContractIr,
#[serde(rename = "outputContract", default)]
pub output_contract: MonitoringOutputContractIr,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringStorageReadIr {
#[serde(default)]
pub key: String,
#[serde(default)]
pub source: String,
#[serde(rename = "fallbackOrder", default)]
pub fallback_order: Vec<String>,
#[serde(default)]
pub required: bool,
#[serde(rename = "parseMode", default)]
pub parse_mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringReadSliceIr {
#[serde(default)]
pub name: String,
#[serde(rename = "endpointBinding", default)]
pub endpoint_binding: String,
#[serde(rename = "requestTemplateOverride", default)]
pub request_template_override: Value,
#[serde(rename = "responsePath", default)]
pub response_path: String,
#[serde(rename = "timeoutMs", default)]
pub timeout_ms: u64,
#[serde(rename = "mergeRole", default)]
pub merge_role: String,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringEncryptionResolutionIr {
#[serde(rename = "primaryMethod", default)]
pub primary_method: String,
#[serde(rename = "fallbackMethods", default)]
pub fallback_methods: Vec<String>,
#[serde(rename = "requiredContext", default)]
pub required_context: Vec<String>,
#[serde(rename = "hardFail", default)]
pub hard_fail: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringTimeoutContractIr {
#[serde(rename = "perStepTimeoutMs", default)]
pub per_step_timeout_ms: u64,
#[serde(rename = "overallDetectTimeoutMs", default)]
pub overall_detect_timeout_ms: u64,
#[serde(rename = "statusOnTimeout", default)]
pub status_on_timeout: String,
#[serde(rename = "statusOnPartial", default)]
pub status_on_partial: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringSidecarOutputIr {
#[serde(default)]
pub name: String,
#[serde(rename = "relativePath", default)]
pub relative_path: String,
#[serde(rename = "payloadSchema", default)]
pub payload_schema: Value,
#[serde(rename = "sourceField", default)]
pub source_field: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitoringOutputContractIr {
#[serde(rename = "runRecordMode", default = "default_run_record_mode")]
pub run_record_mode: String,
#[serde(rename = "businessLogEnabled", default = "default_true")]
pub business_log_enabled: bool,
#[serde(rename = "sidecarOutputs", default)]
pub sidecar_outputs: Vec<MonitoringSidecarOutputIr>,
#[serde(rename = "deltaState", default)]
pub delta_state: Option<MonitoringDeltaStateIr>,
}
impl Default for MonitoringOutputContractIr {
fn default() -> Self {
Self {
run_record_mode: default_run_record_mode(),
business_log_enabled: true,
sidecar_outputs: Vec::new(),
delta_state: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringDeltaStateIr {
#[serde(rename = "identityFields", default)]
pub identity_fields: Vec<String>,
#[serde(rename = "stateSidecarPath", default)]
pub state_sidecar_path: String,
#[serde(rename = "comparisonMode", default)]
pub comparison_mode: String,
#[serde(rename = "emitPolicy", default)]
pub emit_policy: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringDependencyIr {
#[serde(default)]
pub name: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub classification: String,
#[serde(rename = "sideEffect", default)]
pub side_effect: bool,
#[serde(rename = "blockedByDefault", default)]
pub blocked_by_default: bool,
}
impl MonitoringDependencyIr {
pub fn normalized_classification(&self) -> String {
let url = self.url.trim().to_ascii_lowercase();
if url.starts_with("http://localhost:13313/")
|| url.starts_with("https://localhost:13313/")
{
return "host_runtime_local_service".to_string();
}
if url.contains("/monitorservices/")
|| url.contains("/marketingservices/")
|| url.contains("/configservices/")
|| url.contains("/reportservices/")
|| url.contains("/surfaceservices/")
|| url.contains("/msgoffilecenter/")
{
return "remote_platform_service".to_string();
}
let classification = self.classification.trim();
if classification.is_empty() {
String::new()
} else {
classification.to_string()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringSideEffectIr {
#[serde(default)]
pub action: String,
#[serde(default)]
pub signals: Vec<String>,
#[serde(rename = "blockedByDefault", default)]
pub blocked_by_default: bool,
#[serde(rename = "requiredFutureGate", default)]
pub required_future_gate: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringSideEffectPolicyIr {
#[serde(rename = "dryRunDefault", default)]
pub dry_run_default: bool,
#[serde(rename = "requiresExplicitConfirmation", default)]
pub requires_explicit_confirmation: bool,
#[serde(rename = "previewBeforeAction", default)]
pub preview_before_action: bool,
#[serde(rename = "maxItemsRequiredForActionModes", default)]
pub max_items_required_for_action_modes: bool,
#[serde(rename = "auditRecordRequired", default)]
pub audit_record_required: bool,
#[serde(rename = "blockedCallSignatures", default)]
pub blocked_call_signatures: Vec<String>,
#[serde(rename = "blockedActions", default)]
pub blocked_actions: Vec<MonitoringSideEffectIr>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitoringActionWorkflowIr {
#[serde(rename = "workflowId", default)]
pub workflow_id: String,
#[serde(rename = "displayName", default)]
pub display_name: String,
#[serde(rename = "defaultMode", default)]
pub default_mode: String,
#[serde(rename = "workflowStages", default)]
pub workflow_stages: Vec<String>,
#[serde(rename = "mvpAllowedStages", default)]
pub mvp_allowed_stages: Vec<String>,
#[serde(rename = "blockedByDefaultStages", default)]
pub blocked_by_default_stages: Vec<String>,
#[serde(rename = "runtimeContext", default)]
pub runtime_context: MonitoringRuntimeContextIr,
#[serde(rename = "localStorageReads", default)]
pub local_storage_reads: Vec<String>,
#[serde(rename = "sessionStorageReads", default)]
pub session_storage_reads: Vec<String>,
#[serde(rename = "localServiceDependencies", default)]
pub local_service_dependencies: Vec<MonitoringDependencyIr>,
#[serde(rename = "businessApiDependencies", default)]
pub business_api_dependencies: Vec<MonitoringDependencyIr>,
#[serde(rename = "previewSchema", default)]
pub preview_schema: Vec<String>,
#[serde(rename = "sideEffectPolicy", default)]
pub side_effect_policy: MonitoringSideEffectPolicyIr,
#[serde(rename = "archetype", default = "default_monitoring_archetype")]
pub archetype: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MergeFieldMappingIr {
#[serde(rename = "outputField", default)]
@@ -296,6 +553,38 @@ impl Default for ArtifactContractIr {
}
}
fn default_execution_context_mode() -> String {
"attached_page_direct".to_string()
}
fn default_request_client_mode() -> String {
"isolated_xhr".to_string()
}
fn default_encryption_mode() -> String {
"emsslib_data_encrypt_pub".to_string()
}
fn default_attached_page_browser_action_policy() -> String {
"forbid_secondary_jump".to_string()
}
fn default_platform_write_policy() -> String {
"skip_when_zero".to_string()
}
fn default_run_record_mode() -> String {
"per_scene_file".to_string()
}
fn default_true() -> bool {
true
}
fn default_monitoring_archetype() -> String {
"marketing_gateway_monitor".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ValidationHintsIr {
#[serde(
@@ -447,6 +736,8 @@ pub struct SceneIr {
pub confidence: f64,
#[serde(default)]
pub uncertainties: Vec<String>,
#[serde(rename = "monitoringActionWorkflow", default)]
pub monitoring_action_workflow: Option<MonitoringActionWorkflowIr>,
}
impl SceneIr {
@@ -620,6 +911,7 @@ impl From<LegacySceneInfoJson> for SceneIr {
column_defs: value.column_defs,
confidence: 0.0,
uncertainties: Vec::new(),
monitoring_action_workflow: None,
}
}
}

View File

@@ -2,3 +2,4 @@ pub mod analyzer;
pub mod generator;
pub mod ir;
pub mod lessons;
pub mod scheduled_monitoring_runtime;

File diff suppressed because it is too large Load Diff

View File

@@ -5,28 +5,50 @@ pub const SCENE_MANIFEST_FILE_NAME: &str = "scene.toml";
pub const SUPPORTED_SCHEMA_VERSION_V1: &str = "1";
pub const SUPPORTED_SCENE_KIND_V1: &str = "browser_script";
pub const SUPPORTED_SCENE_CATEGORY_V1: &str = "report_collection";
pub const SUPPORTED_SCHEDULED_MONITORING_KIND_V1: &str = "scheduled_monitoring_action_workflow";
pub const SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1: &str = "monitoring";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneManifest {
pub scene: SceneSection,
pub manifest: ManifestSection,
pub bootstrap: BootstrapSection,
pub deterministic: DeterministicSection,
#[serde(default)]
pub bootstrap: Option<BootstrapSection>,
#[serde(default)]
pub deterministic: Option<DeterministicSection>,
#[serde(default)]
pub params: Vec<SceneParam>,
pub artifact: ArtifactSection,
#[serde(default)]
pub artifact: Option<ArtifactSection>,
#[serde(default)]
pub postprocess: Option<PostprocessSection>,
#[serde(default)]
pub trigger: Option<TriggerSection>,
#[serde(default)]
pub modes: Option<ModesSection>,
#[serde(default)]
pub runtime_context: Option<RuntimeContextSection>,
#[serde(default)]
pub safety: Option<SafetySection>,
#[serde(default)]
pub tools: Option<ScheduledToolsSection>,
#[serde(default)]
pub references: Option<ScheduledReferencesSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneSection {
pub id: String,
pub skill: String,
#[serde(default)]
pub tool: String,
pub kind: String,
pub version: String,
pub category: String,
#[serde(default)]
pub workflow_id: Option<String>,
#[serde(default)]
pub archetype: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -34,7 +56,7 @@ pub struct ManifestSection {
pub schema_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BootstrapSection {
pub expected_domain: String,
pub target_url: String,
@@ -43,7 +65,7 @@ pub struct BootstrapSection {
pub requires_target_page: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeterministicSection {
pub suffix: String,
#[serde(default)]
@@ -63,7 +85,7 @@ pub struct SceneParam {
pub resolver_config: Table,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ArtifactSection {
#[serde(rename = "type")]
pub artifact_type: String,
@@ -79,3 +101,201 @@ pub struct PostprocessSection {
#[serde(default)]
pub auto_open: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TriggerSection {
#[serde(default)]
pub primary: Vec<String>,
#[serde(default)]
pub validation: Vec<String>,
#[serde(default)]
pub allowed: Vec<String>,
#[serde(default)]
pub natural_language_primary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModesSection {
#[serde(default)]
pub enabled: Vec<String>,
#[serde(default)]
pub disabled: Vec<String>,
#[serde(default)]
pub default: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RuntimeContextSection {
#[serde(default)]
pub runtime_context_url: String,
#[serde(default)]
pub expected_domain: String,
#[serde(default)]
pub gateway_domain: String,
#[serde(default)]
pub localhost_service_base: String,
#[serde(default)]
pub browser_attached_required: bool,
#[serde(default)]
pub host_bridge_required: bool,
#[serde(default)]
pub execution_context_mode: String,
#[serde(default)]
pub request_client_mode: String,
#[serde(default)]
pub encryption_mode: String,
#[serde(default)]
pub attached_page_browser_action_policy: String,
#[serde(default)]
pub platform_write_policy: String,
#[serde(default)]
pub storage_reads: Vec<StorageReadSection>,
#[serde(default)]
pub read_slices: Vec<ReadSliceSection>,
#[serde(default)]
pub encryption_resolution: EncryptionResolutionSection,
#[serde(default)]
pub timeout_contract: TimeoutContractSection,
#[serde(default)]
pub output_contract: OutputContractSection,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StorageReadSection {
#[serde(default)]
pub key: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub fallback_order: Vec<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub parse_mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReadSliceSection {
#[serde(default)]
pub name: String,
#[serde(default)]
pub endpoint_binding: String,
#[serde(default)]
pub request_template_override: Option<serde_json::Value>,
#[serde(default)]
pub response_path: String,
#[serde(default)]
pub timeout_ms: u64,
#[serde(default)]
pub merge_role: String,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EncryptionResolutionSection {
#[serde(default)]
pub primary_method: String,
#[serde(default)]
pub fallback_methods: Vec<String>,
#[serde(default)]
pub required_context: Vec<String>,
#[serde(default)]
pub hard_fail: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TimeoutContractSection {
#[serde(default)]
pub per_step_timeout_ms: u64,
#[serde(default)]
pub overall_detect_timeout_ms: u64,
#[serde(default)]
pub status_on_timeout: String,
#[serde(default)]
pub status_on_partial: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OutputContractSection {
#[serde(default)]
pub run_record_mode: String,
#[serde(default)]
pub business_log_enabled: bool,
#[serde(default)]
pub sidecar_outputs: Vec<SidecarOutputSection>,
#[serde(default)]
pub delta_state: Option<DeltaStateSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SidecarOutputSection {
#[serde(default)]
pub name: String,
#[serde(default)]
pub relative_path: String,
#[serde(default)]
pub source_field: String,
#[serde(default)]
pub payload_schema: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeltaStateSection {
#[serde(default)]
pub identity_fields: Vec<String>,
#[serde(default)]
pub state_sidecar_path: String,
#[serde(default)]
pub comparison_mode: String,
#[serde(default)]
pub emit_policy: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SafetySection {
#[serde(default)]
pub dry_run_default: bool,
#[serde(default)]
pub active_enabled: bool,
#[serde(default)]
pub queue_process_enabled: bool,
#[serde(default)]
pub side_effects_default: String,
#[serde(default)]
pub audit_required_before_active: bool,
#[serde(default)]
pub idempotency_required_before_active: bool,
#[serde(default)]
pub blocked_call_signatures: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScheduledToolsSection {
#[serde(default)]
pub detect: String,
#[serde(default)]
pub decide: String,
#[serde(default)]
pub action_plan: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScheduledReferencesSection {
#[serde(default)]
pub source_evidence: String,
#[serde(default)]
pub workflow_ir: String,
#[serde(default)]
pub trigger_contract: String,
#[serde(default)]
pub platform_dependencies: String,
#[serde(default)]
pub side_effect_policy: String,
#[serde(default)]
pub audit_policy: String,
#[serde(default)]
pub idempotency_policy: String,
#[serde(default)]
pub generation_report: String,
}

View File

@@ -1,7 +1,10 @@
pub mod manifest;
pub use manifest::{
ArtifactSection, BootstrapSection, DeterministicSection, ManifestSection, PostprocessSection,
SceneManifest, SceneParam, SceneSection, SCENE_MANIFEST_FILE_NAME, SUPPORTED_SCENE_CATEGORY_V1,
SUPPORTED_SCENE_KIND_V1, SUPPORTED_SCHEMA_VERSION_V1,
ArtifactSection, BootstrapSection, DeterministicSection, ManifestSection, ModesSection,
PostprocessSection, RuntimeContextSection, SafetySection, ScheduledReferencesSection,
ScheduledToolsSection, SceneManifest, SceneParam, SceneSection, TriggerSection,
SCENE_MANIFEST_FILE_NAME, SUPPORTED_SCENE_CATEGORY_V1, SUPPORTED_SCENE_KIND_V1,
SUPPORTED_SCHEMA_VERSION_V1, SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1,
SUPPORTED_SCHEDULED_MONITORING_KIND_V1,
};

View File

@@ -364,6 +364,7 @@ pub(crate) fn serve_client(
Duration::from_secs(15),
BROWSER_RESPONSE_TIMEOUT,
true, // use_hidden_domain: hidden domain for invisible helper
None,
) {
Ok(host) => {
send_info_log(sink.as_ref(), "callback-host startup ready")?;