Skip to main content

glass/cli/
runner.rs

1//! CLI command dispatch and session orchestration.
2//!
3//! Routes parsed CLI arguments to the appropriate runner: one-shot browser
4//! commands, interactive TUI, or the MCP stdio server.
5
6use super::args::{
7    CertifyCommand, CheckpointCommand, Cli, Commands, DaemonCommand, IrCommand, KnowledgeCommand,
8    KnowledgeInvalidationState, McpClient, ProfileCommand, ResultCommand, SnapshotCommand,
9    TaskCommand, WorkflowAuthoringCommand,
10};
11use crate::browser::policy::{BrowserPolicy, PolicyCapability};
12use crate::browser::profile::ProfileManager;
13use crate::browser::session::{
14    ActionKind, BatchStep, BrowserResult, BrowserSession, CheckpointV1, Cookie,
15    KnowledgeConfidence, KnowledgeStore, Locator, PdfOptions, ReconciliationOptions,
16    SemanticIntentExecutionRequest, SemanticIntentRequest, SemanticObservationLevel,
17    SessionOptions, SessionSnapshotStore, StructuredExtractionRequest, VerificationPredicate,
18    VisualCaptureOptions, WaitCondition, WorkflowAuthoringFormat, WorkflowCheckpoint,
19    WorkflowDefinition, WorkflowDiagnosticSeverity, WorkflowRecordingSession, compile_workflow,
20    default_knowledge_store_path, diff_workflows, format_workflow_yaml, preview_workflow,
21    record_semantic_events,
22};
23use crate::capabilities::GlassCapabilityManifest;
24use crate::protocol::{
25    GLASS_PROTOCOL_VERSION, GlassRequest, TASK_COMPILE_OPERATION, TASK_VALIDATE_OPERATION,
26    WEB_IR_CONTINUITY_OPERATION, WEB_IR_INSPECT_OPERATION, WEB_IR_VALIDATE_OPERATION,
27};
28use crate::reliability::{
29    ReliabilityFixtureManifest, ReliabilityReplayBundle, ReliabilityScenario,
30    ReliabilityScenarioObservation, build_reliability_scorecard,
31};
32use crate::reliability_runner::{ReliabilityRunOptions, run_reliability_scenario};
33use crate::results::{ResponseMode, ResultStore, project_and_store};
34use base64::Engine;
35use serde::Serialize;
36use serde_json::Value;
37use std::collections::BTreeMap;
38use std::io::Read;
39use std::path::Path;
40use std::process::Stdio;
41use std::time::Duration;
42use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
43
44/// Top-level command-line entry point: parses CLI arguments and dispatches
45/// to the appropriate runner (one-shot, TUI, or MCP server).
46pub async fn dispatch(cli: Cli) -> BrowserResult<()> {
47    let policy = policy_from_cli(&cli)?;
48    if cli.experimental_extensions {
49        eprintln!(concat!(
50            "warning: experimental extensions are enabled; extension code is untrusted, ",
51            "sandbox support is required, and behavior may break"
52        ));
53    }
54    if cli.mcp {
55        return crate::mcp::server::run_mcp_server(&cli).await;
56    }
57
58    match &cli.command {
59        Some(Commands::InstallChromium { update }) => {
60            let path = crate::browser::chrome::download_chromium(*update).await?;
61            println!("Chrome for Testing installed at {}", path.display());
62            return Ok(());
63        }
64        Some(Commands::Capabilities) => {
65            print_json(
66                &GlassCapabilityManifest::for_policy_with_experimental_extensions(
67                    &policy,
68                    cli.experimental_extensions,
69                ),
70            )?;
71            return Ok(());
72        }
73        Some(Commands::Daemon { action }) => {
74            dispatch_daemon(action).await?;
75            return Ok(());
76        }
77        Some(Commands::Doctor { json }) => {
78            dispatch_doctor(&cli, &policy, *json).await?;
79            return Ok(());
80        }
81        Some(Commands::McpConfig { client, print }) => {
82            dispatch_mcp_config(*client, *print)?;
83            return Ok(());
84        }
85        Some(Commands::Certify { action }) if !matches!(action, CertifyCommand::Run { .. }) => {
86            dispatch_certify(action)?;
87            return Ok(());
88        }
89        Some(Commands::Profiles { action }) => {
90            policy.require(PolicyCapability::PersistentProfile)?;
91            dispatch_profiles(action.as_ref())?;
92            return Ok(());
93        }
94        Some(Commands::DeleteProfile { name }) => {
95            policy.require(PolicyCapability::PersistentProfile)?;
96            ProfileManager::new().delete_profile(name)?;
97            println!("deleted profile {name}");
98            return Ok(());
99        }
100        Some(Commands::Knowledge { action }) => {
101            policy.require(PolicyCapability::PersistentProfile)?;
102            dispatch_knowledge(action, cli.knowledge_store.as_deref(), &cli.profile)?;
103            return Ok(());
104        }
105        Some(Commands::Snapshot { action }) if !matches!(action, SnapshotCommand::Create) => {
106            dispatch_snapshot(action, &cli.profile)?;
107            return Ok(());
108        }
109        Some(Commands::Result { action }) => {
110            dispatch_result(action)?;
111            return Ok(());
112        }
113        Some(Commands::Workflow {
114            action: Some(action),
115            input: None,
116        }) => {
117            dispatch_workflow_authoring(action)?;
118            return Ok(());
119        }
120        Some(Commands::Task { action }) => {
121            dispatch_task(action)?;
122            return Ok(());
123        }
124        Some(Commands::Ir { action }) => {
125            dispatch_ir(action)?;
126            return Ok(());
127        }
128        Some(Commands::Tui) | None if cli.prompt.is_none() => {
129            return crate::tui::app::run_tui(&cli).await;
130        }
131        _ => {}
132    }
133
134    let options = SessionOptions {
135        port: cli.port,
136        chrome_path: cli.chrome_path.clone(),
137        profile: cli.profile.clone(),
138        incognito: cli.incognito,
139        attach: cli.attach,
140        target_id: cli.target_id.clone(),
141        frame_id: cli.frame_id.clone(),
142        headed: cli.headed,
143        interaction_mode: cli.interaction,
144        audit: cli.audit,
145        policy: None,
146    };
147    let session = BrowserSession::start_with_policy(&options, policy).await?;
148    let result = if let Some(prompt) = &cli.prompt {
149        run_prompt(&session, prompt, cli.response_mode).await
150    } else if let Some(command) = &cli.command {
151        run_command(&session, command, cli.response_mode).await
152    } else {
153        Ok(())
154    };
155    if let Err(error) = &result
156        && cli.trace_on_error
157    {
158        let trace = session
159            .failure_trace_for(
160                cli_trace_action(cli.command.as_ref(), cli.prompt.as_deref()),
161                error.to_string(),
162            )
163            .await;
164        eprintln!("{}", serde_json::to_string(&trace)?);
165    }
166    let close_result = session.close().await;
167    result?;
168    close_result
169}
170
171async fn dispatch_daemon(action: &DaemonCommand) -> BrowserResult<()> {
172    match action {
173        DaemonCommand::Start { socket, status } => {
174            print_json(&crate::daemon::start(socket.as_deref(), status.as_deref()).await?)?;
175        }
176        DaemonCommand::Status { socket, status } => {
177            print_json(&crate::daemon::status(
178                socket.as_deref(),
179                status.as_deref(),
180            )?)?;
181        }
182        DaemonCommand::Stop { socket, status } => {
183            crate::daemon::stop(socket.as_deref(), status.as_deref())?;
184            print_json(&serde_json::json!({"status": "stopped"}))?;
185        }
186        DaemonCommand::Doctor { socket, status } => {
187            print_json(&crate::daemon::doctor(
188                socket.as_deref(),
189                status.as_deref(),
190            )?)?;
191        }
192        DaemonCommand::Logs { status } => {
193            print_json(&crate::daemon::logs(status.as_deref())?)?;
194        }
195        DaemonCommand::AcknowledgeRecovery {
196            status,
197            request_ids,
198        } => {
199            print_json(&crate::daemon::acknowledge_recovery(
200                status.as_deref(),
201                request_ids,
202            )?)?;
203        }
204        DaemonCommand::Serve { socket, status } => {
205            crate::daemon::serve(socket, status).await?;
206        }
207    }
208    Ok(())
209}
210
211async fn dispatch_doctor(cli: &Cli, policy: &BrowserPolicy, json: bool) -> BrowserResult<()> {
212    let executable = std::env::current_exe()
213        .ok()
214        .map(|path| path.display().to_string());
215    let chrome_path =
216        crate::browser::chrome::resolve_chrome_path(None).map(|path| path.display().to_string());
217    let profile_path = ProfileManager::new().profile_dir(&cli.profile);
218    let runtime_path = default_result_store_path();
219    let profile_writable = path_is_writable(profile_path.parent());
220    let runtime_writable = path_is_writable(runtime_path.parent());
221    let browser_version = chrome_path.as_deref().and_then(|path| {
222        std::process::Command::new(path)
223            .arg("--version")
224            .output()
225            .ok()
226            .filter(|output| output.status.success())
227            .and_then(|output| String::from_utf8(output.stdout).ok())
228            .map(|version| version.trim().to_string())
229    });
230    let mcp_initialized = probe_mcp_initialization(executable.as_deref()).await;
231    let (daemon_socket, daemon_status) = crate::daemon::default_paths();
232    let daemon = crate::daemon::doctor(Some(&daemon_socket), Some(&daemon_status))?;
233    let profiles = ProfileManager::new().list_profiles().unwrap_or_default();
234    let knowledge_path = cli
235        .knowledge_store
236        .clone()
237        .unwrap_or_else(|| default_knowledge_store_path(&cli.profile));
238    let knowledge_exists = knowledge_path.is_file();
239    let manifest = GlassCapabilityManifest::for_policy_with_experimental_extensions(
240        policy,
241        cli.experimental_extensions,
242    );
243    let platform_supported = manifest.constraints.platform != "unsupported";
244    let config_root = dirs::config_dir();
245    let config_writable = path_is_writable(config_root.as_deref());
246    let browser_available = chrome_path.is_some();
247    let status = if platform_supported
248        && browser_available
249        && config_writable
250        && profile_writable
251        && runtime_writable
252        && mcp_initialized
253    {
254        "ready"
255    } else {
256        "degraded"
257    };
258    let findings = serde_json::json!([
259        {
260            "severity": "info",
261            "code": "runtime.executable",
262            "message": executable.as_deref().unwrap_or("unable to resolve executable path"),
263            "remediation": null
264        },
265        {
266            "severity": if browser_available { "info" } else { "warning" },
267            "code": if browser_available { "browser.found" } else { "browser.missing" },
268            "message": if browser_available { "Chrome or Chromium was discovered" } else { "Chrome or Chromium was not discovered" },
269            "remediation": if browser_available { Value::Null } else { Value::String("install Chromium or pass --chrome-path".into()) }
270        },
271        {
272            "severity": if browser_version.is_some() { "info" } else { "warning" },
273            "code": if browser_version.is_some() { "browser.version" } else { "browser.versionUnavailable" },
274            "message": browser_version.as_deref().unwrap_or("browser version could not be queried"),
275            "remediation": if browser_version.is_some() { Value::Null } else { Value::String("verify the browser executable is runnable".into()) }
276        },
277        {
278            "severity": if profile_writable { "info" } else { "warning" },
279            "code": if profile_writable { "runtime.profileWritable" } else { "runtime.profileNotWritable" },
280            "message": profile_path.display().to_string(),
281            "remediation": if profile_writable { Value::Null } else { Value::String("choose a writable profile parent directory".into()) }
282        },
283        {
284            "severity": if runtime_writable { "info" } else { "warning" },
285            "code": if runtime_writable { "runtime.artifactStoreWritable" } else { "runtime.artifactStoreNotWritable" },
286            "message": runtime_path.display().to_string(),
287            "remediation": if runtime_writable { Value::Null } else { Value::String("choose a writable cache directory".into()) }
288        },
289        {
290            "severity": if config_writable { "info" } else { "warning" },
291            "code": if config_writable { "runtime.configWritable" } else { "runtime.configNotWritable" },
292            "message": if config_writable { "Glass configuration directory is writable" } else { "Glass configuration directory is unavailable or not writable" },
293            "remediation": if config_writable { Value::Null } else { Value::String("choose a writable HOME/XDG config directory".into()) }
294        },
295        {
296            "severity": "info",
297            "code": "mcp.stdoutClean",
298            "message": "MCP uses stdout for protocol frames and diagnostics use stderr",
299            "remediation": null
300        },
301        {
302            "severity": if mcp_initialized { "info" } else { "warning" },
303            "code": if mcp_initialized { "mcp.initialized" } else { "mcp.initializationFailed" },
304            "message": if mcp_initialized { "MCP initialize completed with clean stdout" } else { "MCP initialize did not return a valid response" },
305            "remediation": if mcp_initialized { Value::Null } else { Value::String("run the installed executable with --mcp and inspect stderr".into()) }
306        },
307        {
308            "severity": "info",
309            "code": "capability.manifest",
310            "message": "capability statuses are included in the report",
311            "remediation": null
312        }
313    ]);
314    let report = serde_json::json!({
315        "status": status,
316        "version": env!("CARGO_PKG_VERSION"),
317        "platform": manifest.constraints.platform,
318        "executable": executable,
319        "browser": {
320            "family": manifest.constraints.browser_family,
321            "version": browser_version,
322            "chromeAvailable": browser_available,
323            "chromePath": chrome_path,
324            "cdpPort": cli.port,
325            "cdpReachable": crate::browser::chrome::check_chrome_health(cli.port).await,
326        },
327        "profile": {"path": profile_path, "writable": profile_writable},
328        "runtime": {"artifactStore": runtime_path, "writable": runtime_writable},
329        "daemon": daemon,
330        "profiles": {"count": profiles.len(), "names": profiles},
331        "policy": {
332            "preset": manifest.constraints.policy,
333            "capabilities": manifest.capabilities,
334            "capabilityStatuses": manifest.capability_statuses,
335        },
336        "knowledgeStore": {"path": knowledge_path, "exists": knowledge_exists},
337        "extensions": {
338            "enabled": manifest.capabilities.get("extensions").copied().unwrap_or(false),
339            "status": manifest.capability_statuses.get("extensions"),
340            "loader": "disabled",
341        },
342        "findings": findings,
343    });
344    if json {
345        print_json(&report)?;
346    } else {
347        println!("Glass doctor: {status}");
348        for finding in report["findings"].as_array().into_iter().flatten() {
349            println!(
350                "{} {}: {}",
351                finding["severity"].as_str().unwrap_or("info"),
352                finding["code"].as_str().unwrap_or("runtime.unknown"),
353                finding["message"].as_str().unwrap_or("unknown")
354            );
355        }
356    }
357    Ok(())
358}
359
360fn path_is_writable(path: Option<&Path>) -> bool {
361    let Some(path) = path else {
362        return false;
363    };
364    let candidate = if path.exists() {
365        path.to_path_buf()
366    } else {
367        path.ancestors()
368            .find(|ancestor| ancestor.exists())
369            .unwrap_or(path)
370            .to_path_buf()
371    };
372    candidate.is_dir()
373        && std::fs::metadata(&candidate)
374            .map(|metadata| !metadata.permissions().readonly())
375            .unwrap_or(false)
376}
377
378async fn probe_mcp_initialization(executable: Option<&str>) -> bool {
379    let Some(executable) = executable else {
380        return false;
381    };
382    let mut child = match tokio::process::Command::new(executable)
383        .arg("--mcp")
384        .stdin(Stdio::piped())
385        .stdout(Stdio::piped())
386        .stderr(Stdio::null())
387        .spawn()
388    {
389        Ok(child) => child,
390        Err(_) => return false,
391    };
392    let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"glass-doctor","version":"0.2.2"}}}"#;
393    let Some(mut stdin) = child.stdin.take() else {
394        let _ = child.kill().await;
395        return false;
396    };
397    if stdin.write_all(request).await.is_err() || stdin.write_all(b"\n").await.is_err() {
398        let _ = child.kill().await;
399        return false;
400    }
401    drop(stdin);
402    let Some(stdout) = child.stdout.take() else {
403        let _ = child.kill().await;
404        return false;
405    };
406    let mut reader = BufReader::new(stdout);
407    let mut line = String::new();
408    let success = tokio::time::timeout(Duration::from_secs(2), reader.read_line(&mut line))
409        .await
410        .ok()
411        .and_then(Result::ok)
412        .is_some_and(|bytes| bytes > 0 && line.contains("\"result\""));
413    let _ = child.kill().await;
414    success
415}
416
417fn dispatch_mcp_config(client: McpClient, _print: bool) -> BrowserResult<()> {
418    let executable = std::env::current_exe()?;
419    let command = executable.display().to_string();
420    let value = match client {
421        McpClient::Generic => serde_json::json!({
422            "command": command,
423            "args": ["--mcp"]
424        }),
425        McpClient::ClaudeCode => serde_json::json!({
426            "mcpServers": {"glass": {"command": command, "args": ["--mcp"]}}
427        }),
428        McpClient::Codex => serde_json::json!({
429            "mcp_servers": {"glass": {"command": command, "args": ["--mcp"]}}
430        }),
431    };
432    print_json(&value)?;
433    Ok(())
434}
435
436fn dispatch_result(action: &ResultCommand) -> BrowserResult<()> {
437    let root = default_result_store_path();
438    let store = ResultStore::new(root);
439    match action {
440        ResultCommand::Show { result_id, section } => {
441            let artifact = store.load(result_id)?;
442            let value = if let Some(section) = section {
443                artifact
444                    .details
445                    .get(section)
446                    .cloned()
447                    .ok_or_else(|| format!("result section not found: {section}"))?
448            } else {
449                serde_json::to_value(artifact)?
450            };
451            print_json(&value)?;
452        }
453        ResultCommand::Purge { older_than } => {
454            let age = parse_result_age(older_than)?;
455            print_json(&serde_json::json!({
456                "removed": store.purge_older_than(age)?,
457                "olderThan": older_than,
458            }))?;
459        }
460    }
461    Ok(())
462}
463
464fn default_result_store_path() -> std::path::PathBuf {
465    dirs::cache_dir()
466        .unwrap_or_else(std::env::temp_dir)
467        .join("glass")
468        .join("results")
469}
470
471fn parse_result_age(value: &str) -> BrowserResult<Duration> {
472    let (number, suffix) = value.split_at(
473        value
474            .find(|character: char| !character.is_ascii_digit())
475            .unwrap_or(value.len()),
476    );
477    let amount: u64 = number
478        .parse()
479        .map_err(|_| "older-than must be a positive duration such as 7d or 24h")?;
480    if amount == 0 {
481        return Err("older-than must be positive".into());
482    }
483    let seconds = match suffix {
484        "s" => amount,
485        "m" => amount.saturating_mul(60),
486        "h" => amount.saturating_mul(60 * 60),
487        "d" => amount.saturating_mul(24 * 60 * 60),
488        _ => return Err("older-than must end in s, m, h, or d".into()),
489    };
490    Ok(Duration::from_secs(seconds))
491}
492
493fn cli_trace_action(command: Option<&Commands>, prompt: Option<&str>) -> ActionKind {
494    if let Some(prompt) = prompt {
495        let lower = prompt.trim().to_ascii_lowercase();
496        return if lower.starts_with("double click ") {
497            ActionKind::DoubleClick
498        } else if lower.starts_with("click ") {
499            ActionKind::Click
500        } else if lower.starts_with("type ") {
501            ActionKind::Type
502        } else {
503            ActionKind::Click
504        };
505    }
506    match command {
507        Some(Commands::DoubleClick { .. }) => ActionKind::DoubleClick,
508        Some(Commands::ClickExpectPopup { .. }) => ActionKind::ClickExpectPopup,
509        Some(Commands::Click { .. })
510        | Some(Commands::Preflight { .. })
511        | Some(Commands::ClickAt { .. }) => ActionKind::Click,
512        Some(Commands::Hover { .. }) => ActionKind::Hover,
513        Some(Commands::Drag { .. }) => ActionKind::Drag,
514        Some(Commands::Type { .. }) => ActionKind::Type,
515        Some(Commands::Key { .. }) => ActionKind::KeyPress,
516        Some(Commands::KeyDown { .. }) => ActionKind::KeyDown,
517        Some(Commands::KeyUp { .. }) => ActionKind::KeyUp,
518        Some(Commands::Shortcut { .. }) => ActionKind::Shortcut,
519        Some(Commands::Clear { .. }) => ActionKind::Clear,
520        Some(Commands::Check { .. }) => ActionKind::Check,
521        Some(Commands::Uncheck { .. }) => ActionKind::Uncheck,
522        Some(Commands::Select { .. }) => ActionKind::Select,
523        Some(Commands::Upload { .. }) => ActionKind::Upload,
524        Some(Commands::Scroll { .. }) => ActionKind::Scroll,
525        _ => ActionKind::Click,
526    }
527}
528
529fn dispatch_profiles(action: Option<&ProfileCommand>) -> BrowserResult<()> {
530    let manager = ProfileManager::new();
531    match action {
532        None | Some(ProfileCommand::List) => {
533            let profiles = manager.list_profiles()?;
534            if profiles.is_empty() {
535                println!("no saved profiles");
536            } else {
537                for profile in profiles {
538                    println!("{profile}");
539                }
540            }
541        }
542        Some(ProfileCommand::Create { name }) => {
543            manager.create_profile(name)?;
544            println!("created profile {name}");
545        }
546        Some(ProfileCommand::Delete { name }) => {
547            manager.delete_profile(name)?;
548            println!("deleted profile {name}");
549        }
550    }
551    Ok(())
552}
553
554fn dispatch_certify(action: &CertifyCommand) -> BrowserResult<()> {
555    match action {
556        CertifyCommand::Run { .. } => {
557            unreachable!("browser-backed reliability runs are handled after startup")
558        }
559        CertifyCommand::Plan { scenario, fixture } => {
560            let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
561            let fixture =
562                ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture)?)?;
563            let plan = scenario.execution_plan(&fixture)?;
564            print_json(&serde_json::json!({
565                "status": "valid",
566                "plan": plan,
567            }))?;
568        }
569        CertifyCommand::Release {
570            version,
571            scenarios,
572            observations,
573            replays,
574        } => {
575            let scenario_value = read_json_input(Some(scenarios))?;
576            let scenarios: Vec<ReliabilityScenario> = if scenario_value.is_array() {
577                serde_json::from_value(scenario_value)?
578            } else {
579                vec![serde_json::from_value(scenario_value)?]
580            };
581            let observations: Vec<ReliabilityScenarioObservation> =
582                serde_json::from_value(read_json_input(Some(observations))?)?;
583            let replays_validated = if let Some(replays) = replays {
584                let replay_value = read_json_input(Some(replays))?;
585                let replay_values: Vec<Value> = if replay_value.is_array() {
586                    serde_json::from_value(replay_value)?
587                } else {
588                    vec![replay_value]
589                };
590                let mut replay_by_id = BTreeMap::new();
591                for replay_value in replay_values {
592                    let scenario_id = replay_value
593                        .get("scenarioId")
594                        .and_then(Value::as_str)
595                        .ok_or("replay bundle is missing scenarioId")?
596                        .to_string();
597                    let scenario = scenarios
598                        .iter()
599                        .find(|scenario| scenario.id == scenario_id)
600                        .ok_or_else(|| {
601                            format!("replay references unknown scenario {scenario_id}")
602                        })?;
603                    let bundle = ReliabilityReplayBundle::from_value(replay_value, scenario)?;
604                    if replay_by_id.insert(scenario_id.clone(), bundle).is_some() {
605                        return Err(format!("duplicate replay for scenario {scenario_id}").into());
606                    }
607                }
608                if replay_by_id.len() != scenarios.len() {
609                    return Err("replay evidence must cover every scenario".into());
610                }
611                let observations_by_id: BTreeMap<_, _> = observations
612                    .iter()
613                    .map(|observation| (observation.scenario_id.as_str(), observation))
614                    .collect();
615                for (scenario_id, replay) in &replay_by_id {
616                    let observation =
617                        observations_by_id
618                            .get(scenario_id.as_str())
619                            .ok_or_else(|| {
620                                format!("replay has no matching observation for {scenario_id}")
621                            })?;
622                    if serde_json::to_value(&replay.observation)?
623                        != serde_json::to_value(observation)?
624                    {
625                        return Err(format!("replay observation differs for {scenario_id}").into());
626                    }
627                }
628                true
629            } else {
630                false
631            };
632            let scorecard = build_reliability_scorecard(&scenarios, &observations)?;
633            let certified = scorecard.certified;
634            print_json(&serde_json::json!({
635                "status": if certified { "certified" } else { "blocked" },
636                "version": version,
637                "tool": {"name": "glass", "version": env!("CARGO_PKG_VERSION")},
638                "replaysValidated": replays_validated,
639                "gate": &scorecard.gate,
640                "scorecard": &scorecard,
641            }))?;
642            if !certified {
643                return Err("reliability certification blocked".into());
644            }
645        }
646        CertifyCommand::Replay { scenario, input } => {
647            let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
648            let bundle =
649                ReliabilityReplayBundle::from_value(read_json_input(Some(input))?, &scenario)?;
650            print_json(&serde_json::json!({
651                "status": "valid",
652                "scenarioId": &bundle.scenario_id,
653                "replayHash": bundle.content_hash(&scenario)?,
654            }))?;
655        }
656        CertifyCommand::ReplayDiff {
657            scenario,
658            before,
659            after,
660        } => {
661            let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
662            let before =
663                ReliabilityReplayBundle::from_value(read_json_input(Some(before))?, &scenario)?;
664            let after =
665                ReliabilityReplayBundle::from_value(read_json_input(Some(after))?, &scenario)?;
666            let comparison = before.compare(&after, &scenario)?;
667            print_json(&serde_json::json!({
668                "status": if comparison.equivalent { "equivalent" } else { "changed" },
669                "comparison": comparison,
670            }))?;
671        }
672    }
673    Ok(())
674}
675
676async fn dispatch_certify_run(
677    session: &BrowserSession,
678    scenario_path: &std::path::Path,
679    fixture_path: &std::path::Path,
680    url: &str,
681    workflow_root: &std::path::Path,
682    inputs_path: Option<&std::path::Path>,
683    output: Option<&std::path::Path>,
684) -> BrowserResult<()> {
685    let scenario = ReliabilityScenario::from_json(&std::fs::read_to_string(scenario_path)?)?;
686    let fixture = ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture_path)?)?;
687    let inputs: BTreeMap<String, Value> = match inputs_path {
688        Some(path) => serde_json::from_str(&std::fs::read_to_string(path)?)
689            .map_err(|error| format!("invalid workflow inputs: {error}"))?,
690        None => BTreeMap::new(),
691    };
692    session.navigate(url).await?;
693    let evidence = run_reliability_scenario(
694        session,
695        &scenario,
696        &fixture,
697        &ReliabilityRunOptions {
698            workflow_root: workflow_root.to_path_buf(),
699            inputs,
700        },
701    )
702    .await?;
703    let value = serde_json::json!({
704        "observation": evidence.observation,
705        "replay": evidence.replay,
706    });
707    if let Some(output) = output {
708        tokio::fs::write(output, serde_json::to_vec_pretty(&value)?).await?;
709    }
710    print_json(&value)?;
711    Ok(())
712}
713
714fn dispatch_knowledge(
715    action: &KnowledgeCommand,
716    explicit_path: Option<&std::path::Path>,
717    profile: &str,
718) -> BrowserResult<()> {
719    ProfileManager::validate_name(profile)?;
720    let path = explicit_path
721        .map(std::path::Path::to_path_buf)
722        .unwrap_or_else(|| default_knowledge_store_path(profile));
723    let mut store = KnowledgeStore::open(path)?;
724    match action {
725        KnowledgeCommand::List => print_json(store.snapshot())?,
726        KnowledgeCommand::Show { record_id } => {
727            let record = store
728                .get(record_id)
729                .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
730            print_json(record)?;
731        }
732        KnowledgeCommand::Explain { record_id } => {
733            let record = store
734                .get(record_id)
735                .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
736            print_json(&serde_json::json!({
737                "recordId": &record.record_id,
738                "kind": record.kind,
739                "confidence": record.confidence,
740                "scope": &record.scope,
741                "source": &record.source,
742                "invalidation": &record.invalidation,
743                "history": &record.history,
744                "contentHash": record.content_hash()?,
745                "assessment": "requires a fresh observation; stored knowledge is never an authorization",
746            }))?;
747        }
748        KnowledgeCommand::Stats => print_json(&store.stats()?)?,
749        KnowledgeCommand::Export { output } => {
750            let canonical = store.snapshot().to_canonical_json()?;
751            if let Some(output) = output {
752                std::fs::write(output, canonical)?;
753                println!("exported knowledge to {}", output.display());
754            } else {
755                println!("{canonical}");
756            }
757        }
758        KnowledgeCommand::Import { input } => {
759            let snapshot = serde_json::from_value(read_json_input(Some(input))?)
760                .map_err(|error| format!("invalid knowledge snapshot: {error}"))?;
761            store.replace_snapshot(snapshot)?;
762            print_json(&store.stats()?)?;
763        }
764        KnowledgeCommand::Invalidate {
765            record_id,
766            state,
767            reason,
768            observed_at,
769        } => {
770            let next = match state {
771                KnowledgeInvalidationState::Stale => KnowledgeConfidence::Stale,
772                KnowledgeInvalidationState::Contradicted => KnowledgeConfidence::Contradicted,
773                KnowledgeInvalidationState::Quarantined => KnowledgeConfidence::Quarantined,
774            };
775            let change = store.transition(
776                record_id,
777                next,
778                reason
779                    .clone()
780                    .unwrap_or_else(|| "caller invalidated record".into()),
781                observed_at
782                    .clone()
783                    .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
784                false,
785            )?;
786            print_json(&change)?;
787        }
788        KnowledgeCommand::Purge { origin } => print_json(&store.purge_origin(origin)?)?,
789    }
790    Ok(())
791}
792
793fn dispatch_snapshot(action: &SnapshotCommand, profile: &str) -> BrowserResult<()> {
794    ProfileManager::validate_name(profile)?;
795    let store = SessionSnapshotStore::new(crate::browser::session::default_session_snapshot_path(
796        profile,
797    ));
798    match action {
799        SnapshotCommand::Create => unreachable!("snapshot creation requires a browser session"),
800        SnapshotCommand::List => print_json(&store.list()?)?,
801        SnapshotCommand::Inspect { snapshot_id } => print_json(&store.load(snapshot_id)?)?,
802        SnapshotCommand::Diff { from, to } => print_json(&store.diff(from, to)?)?,
803        SnapshotCommand::Purge => print_json(&serde_json::json!({"removed": store.purge()?}))?,
804    }
805    Ok(())
806}
807
808fn dispatch_task(action: &TaskCommand) -> BrowserResult<()> {
809    match action {
810        TaskCommand::Validate { input } => {
811            let request = read_task_request(input, TASK_VALIDATE_OPERATION)?;
812            let result = crate::protocol::validate_task_result(&request)?;
813            print_json(&result)?;
814        }
815        TaskCommand::Compile {
816            input,
817            output,
818            explain,
819        } => {
820            let request = read_task_request(input, TASK_COMPILE_OPERATION)?;
821            let task = request.decode_task_compile()?.task;
822            let result = crate::protocol::compile_task_result(&request)?;
823            if let Some(output) = output {
824                std::fs::write(output, serde_json::to_vec_pretty(&result.plan)?)?;
825                println!("compiled task to {}", output.display());
826            } else {
827                print_json(&result.plan)?;
828            }
829            if *explain {
830                eprintln!("{}", explain_task(&task, &result.plan)?);
831            }
832        }
833    }
834    Ok(())
835}
836
837fn read_task_request(path: &Path, operation: &str) -> BrowserResult<GlassRequest> {
838    let source = std::fs::read_to_string(path)?;
839    let task: Value = serde_json::from_str(&source)?;
840    let request = GlassRequest {
841        protocol_version: GLASS_PROTOCOL_VERSION,
842        request_id: format!("cli-{}", operation.replace('.', "-")),
843        correlation_id: None,
844        session_id: None,
845        mutation_lease: None,
846        operation: operation.into(),
847        payload: serde_json::json!({"task": task}),
848        deadline_ms: None,
849    };
850    request.validate()?;
851    Ok(request)
852}
853
854fn dispatch_ir(action: &IrCommand) -> BrowserResult<()> {
855    match action {
856        IrCommand::Validate { input } => {
857            let request = read_web_ir_request(input, WEB_IR_VALIDATE_OPERATION)?;
858            let result = crate::protocol::web_ir_validate_result(&request)?;
859            print_json(&result)?;
860        }
861        IrCommand::Inspect { input } => {
862            let request = read_web_ir_request(input, WEB_IR_INSPECT_OPERATION)?;
863            let result = crate::protocol::web_ir_inspect_result(&request)?;
864            print_json(&result)?;
865        }
866        IrCommand::Diff {
867            before,
868            after,
869            summary,
870        } => {
871            let before = read_web_ir_draft(before)?;
872            let after = read_web_ir_draft(after)?;
873            let diff = before.diff(&after)?;
874            if *summary {
875                print_json(&crate::protocol::WebIrDiffResult::from_diff(&diff))?;
876            } else {
877                print_json(&diff)?;
878            }
879        }
880        IrCommand::Continuity {
881            before,
882            after,
883            entity_id,
884        } => {
885            let request = read_web_ir_continuity_request(before, after, entity_id)?;
886            let result = crate::protocol::web_ir_continuity_result(&request)?;
887            print_json(&result)?;
888        }
889        IrCommand::Canonical { input } => {
890            let draft = read_web_ir_draft(input)?;
891            println!("{}", draft.to_canonical_json()?);
892        }
893    }
894    Ok(())
895}
896
897fn read_web_ir_request(path: &Path, operation: &str) -> BrowserResult<GlassRequest> {
898    let source = std::fs::read_to_string(path)?;
899    let draft: Value = serde_json::from_str(&source)?;
900    let request = GlassRequest {
901        protocol_version: GLASS_PROTOCOL_VERSION,
902        request_id: format!("cli-{}", operation.replace('.', "-")),
903        correlation_id: None,
904        session_id: None,
905        mutation_lease: None,
906        operation: operation.into(),
907        payload: serde_json::json!({"draft": draft}),
908        deadline_ms: None,
909    };
910    request.validate()?;
911    Ok(request)
912}
913
914fn read_web_ir_continuity_request(
915    before: &Path,
916    after: &Path,
917    entity_id: &str,
918) -> BrowserResult<GlassRequest> {
919    let before_source = std::fs::read_to_string(before)?;
920    let after_source = std::fs::read_to_string(after)?;
921    let request = GlassRequest {
922        protocol_version: GLASS_PROTOCOL_VERSION,
923        request_id: "cli-web-ir-continuity".into(),
924        correlation_id: None,
925        session_id: None,
926        mutation_lease: None,
927        operation: WEB_IR_CONTINUITY_OPERATION.into(),
928        payload: serde_json::json!({
929            "before": serde_json::from_str::<Value>(&before_source)?,
930            "after": serde_json::from_str::<Value>(&after_source)?,
931            "entityId": entity_id,
932        }),
933        deadline_ms: None,
934    };
935    request.validate()?;
936    Ok(request)
937}
938
939fn read_web_ir_draft(path: &Path) -> BrowserResult<crate::web_ir::GlassWebIrDraft> {
940    let source = std::fs::read_to_string(path)?;
941    let draft: crate::web_ir::GlassWebIrDraft = serde_json::from_str(&source)?;
942    draft.validate()?;
943    Ok(draft)
944}
945
946fn explain_task(
947    task: &crate::task_protocol::GlassTask,
948    plan: &crate::task_compiler::TaskExecutionPlan,
949) -> BrowserResult<String> {
950    let task_name = serde_json::to_string(&task.task)?
951        .trim_matches('"')
952        .to_owned();
953    let risk = serde_json::to_string(&task.risk)?
954        .trim_matches('"')
955        .to_owned();
956    let steps = plan
957        .steps
958        .iter()
959        .map(|step| {
960            let operation = serde_json::to_string(&step.operation)
961                .expect("task plan operations are always serializable")
962                .trim_matches('"')
963                .to_owned();
964            let inputs = if step.input_names.is_empty() {
965                String::new()
966            } else {
967                format!(
968                    " inputNames={}",
969                    serde_json::to_string(&step.input_names)
970                        .expect("task input names are always serializable")
971                )
972            };
973            format!(
974                "  {}. {}{} confirmationRequired={}",
975                step.ordinal, operation, inputs, step.requires_confirmation
976            )
977        })
978        .collect::<Vec<_>>()
979        .join("\n");
980    Ok(format!(
981        "task: {task_name}\nscope: {}\nrisk: {risk}\nconfirmationRequired: {}\nsteps:\n{steps}\npostconditions: {}\n",
982        serde_json::to_string(&task.scope)?,
983        plan.confirmation_required,
984        serde_json::to_string(&plan.postconditions)?
985    ))
986}
987
988fn dispatch_workflow_authoring(action: &WorkflowAuthoringCommand) -> BrowserResult<()> {
989    match action {
990        WorkflowAuthoringCommand::Templates { name, output } => {
991            let names = [
992                "account-search",
993                "checkout-submit",
994                "profile-update",
995                "report-download",
996                "support-ticket",
997            ];
998            let Some(name) = name else {
999                print_json(&names)?;
1000                return Ok(());
1001            };
1002            let source = workflow_template(name)
1003                .ok_or_else(|| format!("unknown workflow template: {name}"))?;
1004            let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
1005            if document
1006                .diagnostics
1007                .iter()
1008                .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
1009            {
1010                return Err(format!("workflow template {name} failed compilation").into());
1011            }
1012            if let Some(output) = output {
1013                std::fs::write(output, source)?;
1014            } else {
1015                print!("{source}");
1016            }
1017        }
1018        WorkflowAuthoringCommand::Init { name, output } => {
1019            let template = match name.as_str() {
1020                "search" => "account-search",
1021                "form-submit" => "checkout-submit",
1022                "paginated-extraction" => "report-download",
1023                "authenticated-session" => "profile-update",
1024                "dialog-and-download" => "support-ticket",
1025                _ => return Err(format!("unknown workflow starter: {name}").into()),
1026            };
1027            let source = workflow_template(template)
1028                .ok_or_else(|| format!("missing workflow starter: {template}"))?;
1029            let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
1030            if document
1031                .diagnostics
1032                .iter()
1033                .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
1034            {
1035                return Err(format!("workflow starter {name} failed compilation").into());
1036            }
1037            if let Some(output) = output {
1038                std::fs::write(output, source)?;
1039            } else {
1040                print!("{source}");
1041            }
1042        }
1043        WorkflowAuthoringCommand::Compile { input, output } => {
1044            let source = std::fs::read_to_string(input)?;
1045            let document = compile_workflow(&source, authoring_format(input))?;
1046            if let Some(output) = output {
1047                std::fs::write(output, &document.canonical_json)?;
1048                println!("compiled workflow to {}", output.display());
1049            } else {
1050                println!("{}", document.canonical_json);
1051            }
1052            if document
1053                .diagnostics
1054                .iter()
1055                .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
1056            {
1057                return Err("workflow compilation produced error diagnostics".into());
1058            }
1059        }
1060        WorkflowAuthoringCommand::Format { input, output } => {
1061            let source = std::fs::read_to_string(input)?;
1062            let document = compile_workflow(&source, authoring_format(input))?;
1063            let formatted = format_workflow_yaml(&document.definition)?;
1064            if let Some(output) = output {
1065                std::fs::write(output, formatted)?;
1066                println!("formatted workflow to {}", output.display());
1067            } else {
1068                print!("{formatted}");
1069            }
1070        }
1071        WorkflowAuthoringCommand::Preview { input } => {
1072            let source = std::fs::read_to_string(input)?;
1073            let document = compile_workflow(&source, authoring_format(input))?;
1074            let preview = preview_workflow(&document.definition)?;
1075            print_json(&serde_json::json!({
1076                "preview": preview,
1077                "diagnostics": document.diagnostics,
1078            }))?;
1079        }
1080        WorkflowAuthoringCommand::Diff { before, after } => {
1081            let before_source = std::fs::read_to_string(before)?;
1082            let after_source = std::fs::read_to_string(after)?;
1083            let before_document = compile_workflow(&before_source, authoring_format(before))?;
1084            let after_document = compile_workflow(&after_source, authoring_format(after))?;
1085            let diff = diff_workflows(&before_document.definition, &after_document.definition)?;
1086            print_json(&serde_json::json!({
1087                "diff": diff,
1088                "beforeDiagnostics": before_document.diagnostics,
1089                "afterDiagnostics": after_document.diagnostics,
1090            }))?;
1091        }
1092        WorkflowAuthoringCommand::Record { input, output } => {
1093            let value = read_json_input(input.as_ref())?;
1094            let session: WorkflowRecordingSession = serde_json::from_value(value)?;
1095            let draft = record_semantic_events(session)?;
1096            let serialized = serde_json::to_string_pretty(&draft)?;
1097            if let Some(output) = output {
1098                std::fs::write(output, serialized)?;
1099                println!("recorded workflow draft to {}", output.display());
1100            } else {
1101                println!("{serialized}");
1102            }
1103        }
1104        WorkflowAuthoringCommand::Validate { input } => {
1105            let source = std::fs::read_to_string(input)?;
1106            let document = compile_workflow(&source, authoring_format(input))?;
1107            print_json(&document)?;
1108        }
1109        WorkflowAuthoringCommand::Lint {
1110            input,
1111            warnings_as_errors,
1112        } => {
1113            let source = std::fs::read_to_string(input)?;
1114            let document = compile_workflow(&source, authoring_format(input))?;
1115            let failed = document.diagnostics.iter().any(|diagnostic| {
1116                diagnostic.severity == WorkflowDiagnosticSeverity::Error
1117                    || (*warnings_as_errors
1118                        && diagnostic.severity == WorkflowDiagnosticSeverity::Warning)
1119            });
1120            print_json(&document.diagnostics)?;
1121            if failed {
1122                return Err("workflow lint failed".into());
1123            }
1124        }
1125    }
1126    Ok(())
1127}
1128
1129fn authoring_format(path: &std::path::Path) -> WorkflowAuthoringFormat {
1130    if path
1131        .extension()
1132        .and_then(|extension| extension.to_str())
1133        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
1134    {
1135        WorkflowAuthoringFormat::Json
1136    } else {
1137        WorkflowAuthoringFormat::Yaml
1138    }
1139}
1140
1141fn workflow_template(name: &str) -> Option<&'static str> {
1142    Some(match name {
1143        "account-search" => include_str!("templates/account-search.yaml"),
1144        "checkout-submit" => include_str!("templates/checkout-submit.yaml"),
1145        "profile-update" => include_str!("templates/profile-update.yaml"),
1146        "report-download" => include_str!("templates/report-download.yaml"),
1147        "support-ticket" => include_str!("templates/support-ticket.yaml"),
1148        _ => return None,
1149    })
1150}
1151async fn run_command(
1152    session: &BrowserSession,
1153    command: &Commands,
1154    response_mode: ResponseMode,
1155) -> BrowserResult<()> {
1156    match command {
1157        Commands::Certify {
1158            action:
1159                CertifyCommand::Run {
1160                    scenario,
1161                    fixture,
1162                    url,
1163                    workflow_root,
1164                    inputs,
1165                    output,
1166                },
1167        } => {
1168            dispatch_certify_run(
1169                session,
1170                scenario,
1171                fixture,
1172                url,
1173                workflow_root,
1174                inputs.as_deref(),
1175                output.as_deref(),
1176            )
1177            .await?;
1178        }
1179        Commands::Snapshot {
1180            action: SnapshotCommand::Create,
1181        } => {
1182            let observation = session
1183                .semantic_observe(SemanticObservationLevel::Structured)
1184                .await?;
1185            let snapshot = crate::browser::session::SessionSnapshot::from_observation(
1186                session.profile.clone(),
1187                observation,
1188            );
1189            let store = SessionSnapshotStore::new(
1190                crate::browser::session::default_session_snapshot_path(&session.profile),
1191            );
1192            store.save(&snapshot)?;
1193            print_json(&snapshot)?;
1194        }
1195        Commands::Capabilities
1196        | Commands::Daemon { .. }
1197        | Commands::Doctor { .. }
1198        | Commands::McpConfig { .. }
1199        | Commands::Result { .. }
1200        | Commands::Certify { .. }
1201        | Commands::Knowledge { .. }
1202        | Commands::Snapshot { .. }
1203        | Commands::Task { .. } => {
1204            unreachable!("offline commands are handled before browser startup")
1205        }
1206        Commands::Navigate {
1207            url,
1208            timeout_ms,
1209            expected_revision,
1210        } => {
1211            if let Some(expected_revision) = expected_revision {
1212                print_json(
1213                    &session
1214                        .navigate_with_revision(
1215                            url,
1216                            Duration::from_millis(*timeout_ms),
1217                            *expected_revision,
1218                        )
1219                        .await?,
1220                )?;
1221            } else {
1222                let page = session
1223                    .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
1224                    .await?;
1225                print_json(&page)?;
1226            }
1227        }
1228        Commands::Click {
1229            target,
1230            expected_revision,
1231        } => {
1232            if let Some(expected_revision) = expected_revision {
1233                print_json(
1234                    &session
1235                        .click_with_revision(target, *expected_revision)
1236                        .await?,
1237                )?;
1238            } else {
1239                print_json(&session.click(target).await?)?;
1240            }
1241        }
1242        Commands::Preflight { target, action } => {
1243            print_json(&session.preflight_with_action(target, *action).await)?;
1244        }
1245        Commands::ClickAt { x, y } => {
1246            print_json(&session.click_at(*x, *y).await?)?;
1247        }
1248        Commands::ClickExpectPopup {
1249            target,
1250            expected_revision,
1251        } => {
1252            print_json(
1253                &session
1254                    .click_expect_popup_with_revision(target, *expected_revision)
1255                    .await?,
1256            )?;
1257        }
1258        Commands::DoubleClick {
1259            target,
1260            expected_revision,
1261        } => {
1262            print_json(
1263                &session
1264                    .double_click_with_revision(target, *expected_revision)
1265                    .await?,
1266            )?;
1267        }
1268        Commands::Hover { target } => print_json(&session.hover(target).await?)?,
1269        Commands::Drag {
1270            source,
1271            destination,
1272            expected_revision,
1273        } => {
1274            print_json(
1275                &session
1276                    .drag_with_revision(source, destination, *expected_revision)
1277                    .await?,
1278            )?;
1279        }
1280        Commands::Type {
1281            text,
1282            target,
1283            expected_revision,
1284        } => {
1285            print_json(
1286                &session
1287                    .type_text_with_expected_revision(text, target.as_deref(), *expected_revision)
1288                    .await?,
1289            )?;
1290        }
1291        Commands::Key {
1292            key,
1293            expected_revision,
1294        } => print_json(
1295            &session
1296                .key_press_with_revision(key, *expected_revision)
1297                .await?,
1298        )?,
1299        Commands::KeyDown {
1300            key,
1301            expected_revision,
1302        } => print_json(
1303            &session
1304                .key_down_with_revision(key, *expected_revision)
1305                .await?,
1306        )?,
1307        Commands::KeyUp {
1308            key,
1309            expected_revision,
1310        } => print_json(
1311            &session
1312                .key_up_with_revision(key, *expected_revision)
1313                .await?,
1314        )?,
1315        Commands::Shortcut {
1316            shortcut,
1317            expected_revision,
1318        } => print_json(
1319            &session
1320                .shortcut_with_revision(shortcut, *expected_revision)
1321                .await?,
1322        )?,
1323        Commands::Clear {
1324            target,
1325            expected_revision,
1326        } => print_json(
1327            &session
1328                .clear_with_revision(target, *expected_revision)
1329                .await?,
1330        )?,
1331        Commands::Check {
1332            target,
1333            expected_revision,
1334        } => print_json(
1335            &session
1336                .check_with_revision(target, *expected_revision)
1337                .await?,
1338        )?,
1339        Commands::Uncheck {
1340            target,
1341            expected_revision,
1342        } => print_json(
1343            &session
1344                .uncheck_with_revision(target, *expected_revision)
1345                .await?,
1346        )?,
1347        Commands::Select {
1348            target,
1349            value,
1350            expected_revision,
1351        } => {
1352            print_json(
1353                &session
1354                    .select_option_with_revision(target, value, *expected_revision)
1355                    .await?,
1356            )?;
1357        }
1358        Commands::Upload {
1359            target,
1360            files,
1361            expected_revision,
1362        } => {
1363            print_json(
1364                &session
1365                    .upload_files_with_revision(target, files, *expected_revision)
1366                    .await?,
1367            )?;
1368        }
1369        Commands::Screenshot {
1370            output,
1371            format,
1372            quality,
1373            scale,
1374            full_page,
1375            clip,
1376            target,
1377        } => {
1378            let output = session
1379                .policy()
1380                .require_output_path(std::path::Path::new(output))?;
1381            let capture = session
1382                .capture_visual(&VisualCaptureOptions {
1383                    format: *format,
1384                    quality: *quality,
1385                    scale: *scale,
1386                    clip: *clip,
1387                    full_page: *full_page,
1388                    target: target.clone(),
1389                })
1390                .await?;
1391            let mut source = base64::read::DecoderReader::new(
1392                capture.data.as_bytes(),
1393                &base64::engine::general_purpose::STANDARD,
1394            );
1395            let mut file = std::fs::File::create(&output)?;
1396            std::io::copy(&mut source, &mut file)?;
1397            println!("wrote {}", output.display());
1398            print_json(&capture.metadata)?;
1399        }
1400        Commands::Text => println!("{}", session.text().await?),
1401        Commands::Dom => print_json(&session.deep_dom().await?)?,
1402        Commands::Observe {
1403            deep_dom,
1404            screenshot,
1405            form_values,
1406            semantic_level,
1407            region,
1408        } => {
1409            if let Some(level_name) = semantic_level {
1410                if *deep_dom || *screenshot || *form_values {
1411                    return Err(
1412                        "semantic observation cannot be combined with deep DOM, screenshot, or form values"
1413                            .into(),
1414                    );
1415                }
1416                let level = parse_semantic_level(level_name)?;
1417                if let Some(region_id) = region {
1418                    let page = session.semantic_observe(level).await?;
1419                    print_json(
1420                        &session
1421                            .semantic_expand_region(region_id, page.revision, level)
1422                            .await?,
1423                    )?;
1424                } else {
1425                    print_json(&session.semantic_observe(level).await?)?;
1426                }
1427                return Ok(());
1428            }
1429            let context = match (*deep_dom, *screenshot, *form_values) {
1430                (false, false, false) => session.observe().await?,
1431                (true, false, false) => session.observe_with_dom().await?,
1432                (false, true, false) => session.observe_with_screenshot().await?,
1433                (true, true, false) => session.observe_with_dom_and_screenshot().await?,
1434                (false, false, true) => session.observe_with_form_values().await?,
1435                _ => return Err("form values can only be combined with compact observe".into()),
1436            };
1437            print_json_mode(&context, response_mode)?;
1438        }
1439        Commands::InspectPage => print_json_mode(&session.inspect_page().await?, response_mode)?,
1440        Commands::FindTarget { input } => {
1441            let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1442                &read_json_input(Some(input))?,
1443            )?)?;
1444            print_json_mode(&session.find_target(&request).await?, response_mode)?;
1445        }
1446        Commands::ActAndVerify {
1447            input,
1448            predicate,
1449            timeout_ms,
1450        } => {
1451            let request = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1452                &read_json_input(Some(input))?,
1453            )?)?;
1454            let predicate = predicate
1455                .as_deref()
1456                .map(serde_json::from_str::<VerificationPredicate>)
1457                .transpose()?;
1458            print_json_mode(
1459                &session
1460                    .act_and_verify(&request, predicate, Duration::from_millis(*timeout_ms))
1461                    .await?,
1462                response_mode,
1463            )?;
1464        }
1465        Commands::ExtractStructured { input } => {
1466            let request: StructuredExtractionRequest =
1467                serde_json::from_value(read_json_input(Some(input))?)?;
1468            print_json_mode(&session.extract_structured(&request).await?, response_mode)?;
1469        }
1470        Commands::RecoverRun { execution_id } => {
1471            print_json_mode(&session.recover_run(execution_id)?, response_mode)?;
1472        }
1473        Commands::Scroll {
1474            dx,
1475            dy,
1476            expected_revision,
1477        } => {
1478            print_json(
1479                &session
1480                    .scroll_with_revision(*dx, *dy, *expected_revision)
1481                    .await?,
1482            )?;
1483        }
1484        Commands::Wait {
1485            condition,
1486            timeout_ms,
1487        } => {
1488            print_json(
1489                &session
1490                    .wait(
1491                        WaitCondition::parse(condition)?,
1492                        Duration::from_millis(*timeout_ms),
1493                    )
1494                    .await?,
1495            )?;
1496        }
1497        Commands::Diagnostics { duration_ms } => print_json(
1498            &session
1499                .diagnostics(Duration::from_millis(*duration_ms))
1500                .await?,
1501        )?,
1502        Commands::AcceptDialog => {
1503            session.accept_dialog().await?;
1504            print_json(&serde_json::json!({"dialog": "accepted"}))?;
1505        }
1506        Commands::DismissDialog => {
1507            session.dismiss_dialog().await?;
1508            print_json(&serde_json::json!({"dialog": "dismissed"}))?;
1509        }
1510        Commands::DismissConsent => print_json(&session.dismiss_consent().await?)?,
1511        Commands::Download {
1512            destination,
1513            timeout_ms,
1514        } => print_json(
1515            &session
1516                .wait_for_download(destination, Duration::from_millis(*timeout_ms))
1517                .await?,
1518        )?,
1519        Commands::Targets => print_json(&session.list_targets().await?)?,
1520        Commands::NewTarget { url } => print_json(&session.create_target(url).await?)?,
1521        Commands::SelectTarget { id } => print_json(&session.select_target(id).await?)?,
1522        Commands::CloseTarget { id } => {
1523            session.close_target(id).await?;
1524            print_json(&serde_json::json!({"closed": id}))?;
1525        }
1526        Commands::Frames => print_json(&session.list_frames().await?)?,
1527        Commands::SelectFrame { id } => print_json(&session.select_frame(id).await?)?,
1528        Commands::Evaluate { expression } => {
1529            print_json(&session.evaluate(expression).await?)?;
1530        }
1531        Commands::Cookies => print_json(&session.cookies().await?)?,
1532        Commands::ExportCookies { output } => {
1533            let cookies = session.cookies().await?;
1534            let bytes = serde_json::to_vec_pretty(&cookies)?;
1535            tokio::fs::write(output, bytes).await?;
1536            println!("cookies exported to {}", output.display());
1537        }
1538        Commands::ImportCookies { input } => {
1539            const MAX_COOKIE_FILE_BYTES: u64 = 512 * 1024;
1540            let metadata = tokio::fs::metadata(input).await?;
1541            if metadata.len() > MAX_COOKIE_FILE_BYTES {
1542                return Err(format!(
1543                    "cookie file exceeds the {}-byte limit",
1544                    MAX_COOKIE_FILE_BYTES
1545                )
1546                .into());
1547            }
1548            let bytes = tokio::fs::read(input).await?;
1549            let cookies: Vec<Cookie> = serde_json::from_slice(&bytes)?;
1550            session.set_cookies(&cookies).await?;
1551            println!("{} cookies imported", cookies.len());
1552        }
1553        Commands::Pdf { output, background } => {
1554            let mut opts = PdfOptions::letter();
1555            if *background {
1556                opts.print_background = Some(true);
1557            }
1558            let data = session.print_to_pdf(&opts).await?;
1559            let bytes = base64::engine::general_purpose::STANDARD.decode(&data)?;
1560            tokio::fs::write(&output, &bytes).await?;
1561            println!("PDF saved to {output} ({} bytes)", bytes.len());
1562        }
1563        Commands::FillForm {
1564            fields,
1565            expected_revision,
1566        } => {
1567            let parsed: Vec<serde_json::Value> = serde_json::from_str(fields)?;
1568            let field_refs: Vec<(String, String)> = parsed
1569                .iter()
1570                .map(|v| {
1571                    (
1572                        v["target"].as_str().unwrap_or("").to_string(),
1573                        v["value"].as_str().unwrap_or("").to_string(),
1574                    )
1575                })
1576                .collect();
1577            let field_slices: Vec<(&str, &str)> = field_refs
1578                .iter()
1579                .map(|(t, v)| (t.as_str(), v.as_str()))
1580                .collect();
1581            print_json(
1582                &session
1583                    .fill_form_with_expected_revision(&field_slices, *expected_revision)
1584                    .await?,
1585            )?;
1586        }
1587        Commands::Batch {
1588            input,
1589            atomic,
1590            mode,
1591            expected_revision,
1592        } => {
1593            let payload = read_json_input(input.as_ref())?;
1594            let steps_value = payload.get("steps").cloned().unwrap_or(payload);
1595            let steps: Vec<BatchStep> = serde_json::from_value(steps_value)
1596                .map_err(|error| format!("invalid batch document: {error}"))?;
1597            print_json(
1598                &session
1599                    .run_batch_with_mode(&steps, *atomic, *mode, *expected_revision)
1600                    .await?,
1601            )?;
1602        }
1603        Commands::Workflow {
1604            action: None,
1605            input,
1606        } => {
1607            let payload = read_json_input(input.as_ref())?;
1608            let workflow_value = payload
1609                .get("workflow")
1610                .cloned()
1611                .unwrap_or_else(|| payload.clone());
1612            let inputs_value = payload
1613                .get("inputs")
1614                .cloned()
1615                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
1616            let workflow = WorkflowDefinition::from_value(workflow_value)
1617                .map_err(|error| format!("invalid workflow: {error}"))?;
1618            let inputs: BTreeMap<String, Value> = serde_json::from_value(inputs_value)
1619                .map_err(|error| format!("invalid workflow inputs: {error}"))?;
1620            print_json(&session.run_workflow(&workflow, &inputs).await?)?;
1621        }
1622        Commands::Workflow {
1623            action: Some(_), ..
1624        } => unreachable!("offline workflow authoring commands are handled before browser startup"),
1625        Commands::WorkflowResume {
1626            workflow,
1627            checkpoint,
1628            inputs,
1629        } => {
1630            let workflow = WorkflowDefinition::from_value(read_json_input(Some(workflow))?)
1631                .map_err(|error| format!("invalid workflow: {error}"))?;
1632            let checkpoint: WorkflowCheckpoint =
1633                serde_json::from_value(read_json_input(Some(checkpoint))?)
1634                    .map_err(|error| format!("invalid workflow checkpoint: {error}"))?;
1635            let inputs: BTreeMap<String, Value> = match inputs {
1636                Some(path) => serde_json::from_value(read_json_input(Some(path))?)
1637                    .map_err(|error| format!("invalid workflow inputs: {error}"))?,
1638                None => BTreeMap::new(),
1639            };
1640            print_json(
1641                &session
1642                    .resume_workflow(&workflow, &inputs, &checkpoint)
1643                    .await?,
1644            )?;
1645        }
1646        Commands::ResolveIntent { input } => {
1647            let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1648                &read_json_input(input.as_ref())?,
1649            )?)?;
1650            print_json(&session.resolve_intent(&request).await?)?;
1651        }
1652        Commands::ExecuteIntent { input } => {
1653            let execution = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1654                &read_json_input(input.as_ref())?,
1655            )?)?;
1656            print_json(&session.execute_intent(&execution).await?)?;
1657        }
1658        Commands::Verify {
1659            predicate,
1660            timeout_ms,
1661        } => {
1662            let predicate: VerificationPredicate = serde_json::from_str(predicate)
1663                .map_err(|error| format!("invalid verification predicate: {error}"))?;
1664            print_json(
1665                &session
1666                    .verify(predicate, Duration::from_millis(*timeout_ms))
1667                    .await?,
1668            )?;
1669        }
1670        Commands::ReconcileRefs {
1671            from_revision,
1672            hints,
1673            scope,
1674            refs,
1675        } => {
1676            let options = ReconciliationOptions {
1677                hints: hints
1678                    .iter()
1679                    .map(|hint| Locator::parse(hint))
1680                    .collect::<BrowserResult<Vec<_>>>()?,
1681                scope_ref: scope.clone(),
1682            };
1683            print_json(
1684                &session
1685                    .reconcile_references_with_options(*from_revision, refs, &options)
1686                    .await?,
1687            )?;
1688        }
1689        Commands::ObserveDelta => {
1690            print_json(&session.observe_delta().await?)?;
1691        }
1692        Commands::Checkpoint { action } => match action {
1693            CheckpointCommand::Export => print_json(&session.export_checkpoint().await?)?,
1694            CheckpointCommand::Import { input } => {
1695                let checkpoint: CheckpointV1 =
1696                    serde_json::from_value(read_json_input(input.as_ref())?)
1697                        .map_err(|error| format!("invalid checkpoint: {error}"))?;
1698                session.import_checkpoint(&checkpoint).await?;
1699                print_json(&serde_json::json!({"status": "checkpoint_imported"}))?;
1700            }
1701        },
1702        Commands::ClipboardRead => {
1703            let text = session.clipboard_read().await?;
1704            println!("{text}");
1705        }
1706        Commands::ClipboardWrite { text } => {
1707            session.clipboard_write(text).await?;
1708            println!("Text written to clipboard");
1709        }
1710        Commands::Tui
1711        | Commands::Ir { .. }
1712        | Commands::InstallChromium { .. }
1713        | Commands::Profiles { .. }
1714        | Commands::DeleteProfile { .. } => {
1715            unreachable!("handled before starting a browser session")
1716        }
1717    }
1718    Ok(())
1719}
1720
1721fn parse_semantic_level(value: &str) -> BrowserResult<SemanticObservationLevel> {
1722    match value {
1723        "summary" => Ok(SemanticObservationLevel::Summary),
1724        "interactive" => Ok(SemanticObservationLevel::Interactive),
1725        "structured" => Ok(SemanticObservationLevel::Structured),
1726        "detailed" => Ok(SemanticObservationLevel::Detailed),
1727        "raw" => Ok(SemanticObservationLevel::Raw),
1728        _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
1729    }
1730}
1731
1732fn read_json_input(path: Option<&std::path::PathBuf>) -> BrowserResult<serde_json::Value> {
1733    let mut input = String::new();
1734    match path {
1735        Some(path) => std::fs::File::open(path)?.read_to_string(&mut input)?,
1736        None => std::io::stdin().read_to_string(&mut input)?,
1737    };
1738    Ok(serde_json::from_str(&input)?)
1739}
1740
1741pub(crate) fn policy_from_cli(cli: &Cli) -> BrowserResult<BrowserPolicy> {
1742    Ok(BrowserPolicy::new(
1743        cli.policy,
1744        std::env::current_dir()?,
1745        cli.policy_allow.iter().copied(),
1746        cli.policy_confirm.iter().copied(),
1747    )?
1748    .with_host_rules(
1749        cli.policy_allow_host.iter().cloned(),
1750        cli.policy_deny_host.iter().cloned(),
1751    )?
1752    .with_confirmation_tokens(cli.policy_confirm_once.iter().copied())?)
1753}
1754
1755async fn run_prompt(
1756    session: &BrowserSession,
1757    prompt: &str,
1758    response_mode: ResponseMode,
1759) -> BrowserResult<()> {
1760    let trimmed = prompt.trim();
1761    let lower = trimmed.to_lowercase();
1762
1763    for prefix in ["navigate to ", "go to ", "open "] {
1764        if lower.starts_with(prefix) {
1765            let page = session.navigate(trimmed[prefix.len()..].trim()).await?;
1766            print_json_mode(&page, response_mode)?;
1767            return Ok(());
1768        }
1769    }
1770    if let Some(rest) = lower.strip_prefix("click ") {
1771        let target = &trimmed[trimmed.len() - rest.len()..];
1772        print_json_mode(
1773            &session.click(target.trim_matches('"')).await?,
1774            response_mode,
1775        )?;
1776        return Ok(());
1777    }
1778    if let Some(rest) = lower.strip_prefix("double click ") {
1779        let target = &trimmed[trimmed.len() - rest.len()..];
1780        print_json_mode(
1781            &session.double_click(target.trim_matches('"')).await?,
1782            response_mode,
1783        )?;
1784        return Ok(());
1785    }
1786    if let Some(rest) = lower.strip_prefix("type ") {
1787        let text = &trimmed[trimmed.len() - rest.len()..];
1788        print_json_mode(
1789            &session.type_text(text.trim_matches('"'), None).await?,
1790            response_mode,
1791        )?;
1792        return Ok(());
1793    }
1794    if lower.starts_with("screenshot") {
1795        let output = trimmed
1796            .split_once(char::is_whitespace)
1797            .map(|(_, value)| value.trim())
1798            .filter(|value| !value.is_empty())
1799            .unwrap_or("screenshot.png");
1800        let output = session
1801            .policy()
1802            .require_output_path(std::path::Path::new(output))?;
1803        std::fs::write(&output, session.screenshot_png().await?)?;
1804        println!("wrote {}", output.display());
1805        return Ok(());
1806    }
1807    if matches!(
1808        lower.as_str(),
1809        "text" | "get text" | "page text" | "get content"
1810    ) {
1811        println!("{}", session.text().await?);
1812        return Ok(());
1813    }
1814    if matches!(lower.as_str(), "dom" | "snapshot" | "get dom") {
1815        print_json_mode(&session.deep_dom().await?, response_mode)?;
1816        return Ok(());
1817    }
1818    if matches!(lower.as_str(), "observe" | "context") {
1819        print_json_mode(&session.observe().await?, response_mode)?;
1820        return Ok(());
1821    }
1822
1823    print_json_mode(&session.evaluate(trimmed).await?, response_mode)?;
1824    Ok(())
1825}
1826
1827fn print_json_mode<T: Serialize + ?Sized>(value: &T, mode: ResponseMode) -> BrowserResult<()> {
1828    let value = serde_json::to_value(value)?;
1829    let projected = project_and_store(value, mode, "cli", default_result_store_path())?;
1830    println!("{}", compact_json(&projected)?);
1831    Ok(())
1832}
1833
1834fn print_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<()> {
1835    println!("{}", compact_json(value)?);
1836    Ok(())
1837}
1838
1839fn compact_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<String> {
1840    let mut value = serde_json::to_value(value)?;
1841    let payload = serde_json::to_vec(&value)?;
1842    let payload_bytes = payload.len();
1843    if let Some(object) = value.as_object_mut() {
1844        object.insert(
1845            "contextCost".to_string(),
1846            serde_json::json!({
1847                "payloadBytes": payload_bytes,
1848                "estimatedTokens": payload_bytes.div_ceil(4)
1849            }),
1850        );
1851    }
1852    Ok(serde_json::to_string(&value)?)
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858    use serde_json::json;
1859
1860    #[test]
1861    fn structured_cli_output_is_compact_json() {
1862        let output = compact_json(&json!({
1863            "page": {"title": "Glass", "url": "https://example.com"},
1864            "items": [1, 2]
1865        }))
1866        .unwrap();
1867
1868        let parsed = serde_json::from_str::<serde_json::Value>(&output).unwrap();
1869        assert!(!output.contains('\n'));
1870        assert_eq!(parsed["items"], json!([1, 2]));
1871        assert!(parsed["contextCost"]["payloadBytes"].as_u64().unwrap() > 0);
1872        assert!(parsed["contextCost"]["estimatedTokens"].as_u64().unwrap() > 0);
1873    }
1874
1875    #[test]
1876    fn path_writability_accepts_missing_child_under_writable_parent() {
1877        let root = std::env::temp_dir().join(format!("glass-doctor-test-{}", std::process::id()));
1878        std::fs::create_dir_all(&root).unwrap();
1879        let missing_child = root.join("future").join("artifact");
1880
1881        assert!(path_is_writable(Some(&missing_child)));
1882
1883        std::fs::remove_dir_all(root).unwrap();
1884    }
1885
1886    #[test]
1887    fn path_writability_rejects_missing_path_without_parent() {
1888        assert!(!path_is_writable(None));
1889    }
1890
1891    #[test]
1892    fn workflow_starter_templates_compile_without_coordinate_actions() {
1893        for name in [
1894            "account-search",
1895            "checkout-submit",
1896            "profile-update",
1897            "report-download",
1898            "support-ticket",
1899        ] {
1900            let source = workflow_template(name).unwrap();
1901            let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml).unwrap();
1902            assert!(
1903                !document
1904                    .diagnostics
1905                    .iter()
1906                    .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error),
1907                "{name} diagnostics: {:?}",
1908                document.diagnostics
1909            );
1910            assert!(!source.contains("clickAt"));
1911        }
1912    }
1913    #[test]
1914    fn task_explanation_contains_metadata_without_input_values() {
1915        let task = crate::task_protocol::GlassTask::from_json(
1916            r#"{
1917                "schemaVersion": 1,
1918                "task": "form.fill",
1919                "scope": {"regionName": "Shipping address"},
1920                "inputs": {"city": "Kuching-secret"},
1921                "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
1922                "risk": "localMutation"
1923            }"#,
1924        )
1925        .unwrap();
1926        let plan = crate::task_compiler::compile_task(&task).unwrap();
1927
1928        let explanation = explain_task(&task, &plan).unwrap();
1929
1930        assert!(explanation.contains("task: form.fill"));
1931        assert!(explanation.contains("scope: {\"regionName\":\"Shipping address\"}"));
1932        assert!(explanation.contains("inputNames=[\"city\"]"));
1933        assert!(!explanation.contains("Kuching-secret"));
1934    }
1935    #[test]
1936    fn cli_task_requests_use_canonical_protocol_helpers() {
1937        let path = std::env::temp_dir().join(format!(
1938            "glass-cli-task-protocol-{}.json",
1939            std::process::id()
1940        ));
1941        std::fs::write(
1942            &path,
1943            r#"{
1944                "schemaVersion": 1,
1945                "task": "region.extract",
1946                "scope": {"regionName": "Checkout"},
1947                "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
1948                "risk": "readOnly"
1949            }"#,
1950        )
1951        .unwrap();
1952
1953        let validate = read_task_request(&path, TASK_VALIDATE_OPERATION).unwrap();
1954        assert_eq!(validate.operation, TASK_VALIDATE_OPERATION);
1955        assert!(crate::protocol::validate_task_result(&validate).is_ok());
1956
1957        let compile = read_task_request(&path, TASK_COMPILE_OPERATION).unwrap();
1958        assert_eq!(compile.operation, TASK_COMPILE_OPERATION);
1959        assert!(crate::protocol::compile_task_result(&compile).is_ok());
1960
1961        std::fs::write(
1962            &path,
1963            r#"{
1964                "schemaVersion": 1,
1965                "task": "form.fill",
1966                "scope": {"regionName": "Checkout"},
1967                "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
1968                "risk": "readOnly"
1969            }"#,
1970        )
1971        .unwrap();
1972        let invalid = read_task_request(&path, TASK_COMPILE_OPERATION).unwrap();
1973        assert!(matches!(
1974            crate::protocol::compile_task_result(&invalid),
1975            Err(crate::protocol::ProtocolError::TaskCompilation(_))
1976        ));
1977
1978        std::fs::remove_file(path).unwrap();
1979    }
1980    #[test]
1981    fn cli_web_ir_requests_use_canonical_protocol_helpers() {
1982        let fixture: Value =
1983            serde_json::from_str(include_str!("../../tests/fixtures/protocol-golden-v1.json"))
1984                .unwrap();
1985        let draft = fixture["requests"]
1986            .as_array()
1987            .unwrap()
1988            .iter()
1989            .find(|request| request["operation"] == WEB_IR_INSPECT_OPERATION)
1990            .and_then(|request| request["payload"]["draft"].as_object())
1991            .cloned()
1992            .map(Value::Object)
1993            .unwrap();
1994        let path = std::env::temp_dir().join(format!(
1995            "glass-cli-web-ir-protocol-{}.json",
1996            std::process::id()
1997        ));
1998        std::fs::write(&path, serde_json::to_vec(&draft).unwrap()).unwrap();
1999
2000        let validate = read_web_ir_request(&path, WEB_IR_VALIDATE_OPERATION).unwrap();
2001        assert_eq!(validate.operation, WEB_IR_VALIDATE_OPERATION);
2002        assert!(crate::protocol::web_ir_validate_result(&validate).is_ok());
2003
2004        let inspect = read_web_ir_request(&path, WEB_IR_INSPECT_OPERATION).unwrap();
2005        assert_eq!(inspect.operation, WEB_IR_INSPECT_OPERATION);
2006        assert!(crate::protocol::web_ir_inspect_result(&inspect).is_ok());
2007        let before = read_web_ir_draft(&path).unwrap();
2008        let diff = before.diff(&before).unwrap();
2009        let summary = crate::protocol::WebIrDiffResult::from_diff(&diff);
2010        assert_eq!(summary.from_revision, diff.from_revision);
2011        assert_eq!(summary.to_revision, diff.to_revision);
2012        assert_eq!(summary.entity_added_count, 0);
2013        assert_eq!(summary.entity_removed_count, 0);
2014        assert_eq!(summary.entity_changed_count, 0);
2015        assert_eq!(summary.relationship_added_count, 0);
2016        assert_eq!(summary.relationship_removed_count, 0);
2017
2018        let continuity = read_web_ir_continuity_request(&path, &path, "page").unwrap();
2019        assert_eq!(continuity.operation, WEB_IR_CONTINUITY_OPERATION);
2020        let result = crate::protocol::web_ir_continuity_result(&continuity).unwrap();
2021        assert_eq!(
2022            result.status,
2023            crate::web_ir::DraftEntityContinuityStatus::Unchanged
2024        );
2025
2026        std::fs::remove_file(path).unwrap();
2027    }
2028}