From eefbbea9a93530068cea9bc95856979c4a3fc02f Mon Sep 17 00:00:00 2001 From: 1b2c Date: Tue, 28 Jul 2026 09:58:44 +0800 Subject: [PATCH 1/4] Let programmatic drivers consume MaaCore events as JSONL Co-Authored-By: Claude Opus 4.7 --- crates/maa-cli/docs/en-US/usage.md | 4 ++ crates/maa-cli/docs/zh-CN/usage.md | 4 ++ crates/maa-cli/src/command.rs | 16 +++++ crates/maa-cli/src/main.rs | 20 +++--- crates/maa-cli/src/run/callback/mod.rs | 81 +++++++++++++++++++++- crates/maa-cli/src/run/callback/summary.rs | 8 +++ crates/maa-cli/src/run/mod.rs | 27 +++++--- 7 files changed, 140 insertions(+), 20 deletions(-) diff --git a/crates/maa-cli/docs/en-US/usage.md b/crates/maa-cli/docs/en-US/usage.md index f04c5022..dd67823d 100644 --- a/crates/maa-cli/docs/en-US/usage.md +++ b/crates/maa-cli/docs/en-US/usage.md @@ -78,6 +78,10 @@ By default, logs go to standard error (stderr). The `--log-file` option can redi All logs normally include a timestamp and level prefix. The `MAA_LOG_PREFIX` environment variable controls this behavior: `Always` always includes prefixes, `Auto` includes prefixes in log files but not in stderr output, and `Never` omits prefixes even in log files. +### Structured Event Output + +For programmatic callers (such as a web UI or scheduler wrapping maa-cli), the global `--emit-json` flag makes every MaaCore callback appear as one JSON object per line on stdout, with the shape `{"kind": "TaskChainStart", "message": {…}}` — `kind` is the callback variant name and `message` is the raw MaaCore payload. The human-readable log still goes to stderr, and side effects such as the end-of-run summary, non-zero exit on task errors, and outbound report requests still fire. The flag is not intended for interactive use. + ### Other Subcommands Besides the above commands, maa-cli provides additional subcommands: diff --git a/crates/maa-cli/docs/zh-CN/usage.md b/crates/maa-cli/docs/zh-CN/usage.md index 52e46d06..ba650adc 100644 --- a/crates/maa-cli/docs/zh-CN/usage.md +++ b/crates/maa-cli/docs/zh-CN/usage.md @@ -85,6 +85,10 @@ maa-cli 默认会向标准误 (stderr) 输出日志。`--log-file` 选项可以 默认情况下,所有输出的日志会包含时间戳和日志级别的前缀。你可以通过环境变量 `MAA_LOG_PREFIX` 来改变这个行为。设置为 `Always` 时,总是会包含前缀,设置为 `Auto` 时输出到日志文件时会包含前缀,而输出到 stderr 时不会包含前缀,而设置为 `Never` 时即使是写入日志文件时也不会包含前缀。 +### 结构化事件输出 + +面向程序化调用方(例如上层的 Web UI 或调度器),maa-cli 提供 `--emit-json` 全局参数。启用后,MaaCore 每次回调都会以一行 JSON 的形式写入标准输出(stdout),每行的结构为 `{"kind": "TaskChainStart", "message": {…}}`,其中 `kind` 为回调类型名,`message` 为 MaaCore 原始回调载荷。人类可读的日志仍然写入 stderr,任务总结、错误退出码、上报请求等副作用也照常触发。该选项不适合交互式使用,建议仅在被其他程序调用时启用。 + ### 其他子命令 除了上述的命令外,maa-cli 还提供了其他一些子命令: diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index 0ddbd80e..60382d4d 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -18,6 +18,15 @@ pub(crate) struct Cli { /// and parameters will be set to default values. #[arg(long, global = true)] pub(crate) batch: bool, + /// Emit MaaCore callback events as JSONL on stdout for structured consumption. + /// + /// When set, each MaaCore callback becomes one line of JSON on stdout with + /// the shape `{"kind": "TaskChainStart", "message": {…}}`. The human log + /// still goes to stderr as usual, and the summary / error-flagging side + /// effects of callback processing still run. Intended for programmatic + /// drivers, not for interactive use. + #[arg(long, global = true, verbatim_doc_comment)] + pub(crate) emit_json: bool, #[command(flatten)] pub(crate) log: log::Args, } @@ -320,6 +329,13 @@ mod test { assert!(parse_from(["maa", "list", "--batch"]).batch); } + #[test] + fn emit_json() { + assert!(!parse_from(["maa", "run", "stage"]).emit_json); + assert!(parse_from(["maa", "--emit-json", "run", "stage"]).emit_json); + assert!(parse_from(["maa", "run", "stage", "--emit-json"]).emit_json); + } + #[cfg(feature = "core_installer")] #[test] fn install() { diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 89becacf..e6eea2e6 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -35,6 +35,8 @@ fn main() -> Result<()> { maa_value::userinput::enable_batch_mode() } + let emit_json = cli.emit_json; + match cli.command { #[cfg(feature = "core_installer")] Command::Install { force, common } => { @@ -94,15 +96,15 @@ fn main() -> Result<()> { _ => {} } } - Command::Run { task, common } => run::run_custom(task, common)?, - Command::StartUp { params, common } => run::run_preset(params, common)?, - Command::CloseDown { params, common } => run::run_preset(params, common)?, - Command::Fight { params, common } => run::run_preset(params, common)?, - Command::Roguelike { params, common } => run::run_preset(params, common)?, - Command::Copilot { params, common } => run::run_preset(params, common)?, - Command::SSSCopilot { params, common } => run::run_preset(params, common)?, - Command::ParadoxCopilot { params, common } => run::run_preset(params, common)?, - Command::Reclamation { params, common } => run::run_preset(params, common)?, + Command::Run { task, common } => run::run_custom(task, common, emit_json)?, + Command::StartUp { params, common } => run::run_preset(params, common, emit_json)?, + Command::CloseDown { params, common } => run::run_preset(params, common, emit_json)?, + Command::Fight { params, common } => run::run_preset(params, common, emit_json)?, + Command::Roguelike { params, common } => run::run_preset(params, common, emit_json)?, + Command::Copilot { params, common } => run::run_preset(params, common, emit_json)?, + Command::SSSCopilot { params, common } => run::run_preset(params, common, emit_json)?, + Command::ParadoxCopilot { params, common } => run::run_preset(params, common, emit_json)?, + Command::Reclamation { params, common } => run::run_preset(params, common, emit_json)?, Command::Convert { input, output, diff --git a/crates/maa-cli/src/run/callback/mod.rs b/crates/maa-cli/src/run/callback/mod.rs index e3d7a710..b9b37493 100644 --- a/crates/maa-cli/src/run/callback/mod.rs +++ b/crates/maa-cli/src/run/callback/mod.rs @@ -1,6 +1,7 @@ pub mod summary; use std::{ fmt::Write, + io::Write as _, sync::{Arc, atomic::AtomicBool}, }; @@ -18,16 +19,33 @@ fn json_pretty(value: &impl serde::Serialize) -> String { serde_json::to_string_pretty(value).unwrap_or_else(|e| format!("")) } +/// Render a callback event as a single line of JSON for `--emit-json` mode. +/// +/// The shape is `{"kind": "", "message": }`. +/// `MessageKind` does not derive `Serialize`, so its `Debug` representation is +/// used (e.g. `"TaskChainStart"`, `"Unknown(9999)"`). Returns `None` if the +/// event fails to serialize (should not happen in practice — `message` is a +/// `Value` we just parsed). +fn format_event_line(kind: MessageKind, message: &Value) -> Option { + let event = serde_json::json!({ + "kind": format!("{kind:?}"), + "message": message, + }); + serde_json::to_string(&event).ok() +} + pub struct MaaCallback { auto_reconnect: bool, + emit_json: bool, offline_stop_requested: Arc, } impl MaaCallback { - pub fn new(auto_reconnect: bool) -> (Self, Arc) { + pub fn new(auto_reconnect: bool, emit_json: bool) -> (Self, Arc) { let offline_stop_requested = Arc::new(AtomicBool::new(false)); let cb = Self { auto_reconnect, + emit_json, offline_stop_requested: Arc::clone(&offline_stop_requested), }; (cb, offline_stop_requested) @@ -44,6 +62,25 @@ impl Callback for MaaCallback { log::warn!("Failed to parse message for {kind:?}: {message}"); return; }; + + // Emit structured event on stdout for programmatic consumers before + // running the human-facing processor. `process_message` is always + // invoked afterwards because it carries side effects a driver still + // needs — `MAA_CORE_ERRORED` for the exit status, the `summary::` + // state for `--no-summary`, and the outbound `ReportRequest` HTTP + // send in `process_report`. + if self.emit_json { + if let Some(line) = format_event_line(kind, &message) { + let mut out = std::io::stdout().lock(); + // Ignore write errors: if stdout is closed (broken pipe), we + // silently drop the line rather than panic inside a MaaCore + // callback thread. + let _ = writeln!(out, "{line}"); + } else { + log::warn!("Failed to serialize event for {kind:?}"); + } + } + self.process_message(kind, message); } } @@ -629,15 +666,53 @@ mod tests { #[test] fn offline_confirm_stops_when_auto_reconnect_disabled() { - let (cb, offline_stop) = MaaCallback::new(false); + let (cb, offline_stop) = MaaCallback::new(false, false); cb.on_message(MessageKind::SubTaskStart, Some(offline_confirm_msg())); assert!(offline_stop.load(std::sync::atomic::Ordering::Relaxed)); } #[test] fn offline_confirm_does_not_stop_when_auto_reconnect_enabled() { - let (cb, offline_stop) = MaaCallback::new(true); + let (cb, offline_stop) = MaaCallback::new(true, false); cb.on_message(MessageKind::SubTaskStart, Some(offline_confirm_msg())); assert!(!offline_stop.load(std::sync::atomic::Ordering::Relaxed)); } + + #[test] + fn format_event_line_known_kind() { + let message: Value = serde_json::from_str(r#"{"taskchain":"Fight","taskid":42}"#).unwrap(); + let line = format_event_line(MessageKind::TaskChainStart, &message).unwrap(); + let parsed: Value = serde_json::from_str(&line).unwrap(); + assert_eq!( + parsed.get("kind").and_then(Value::as_str), + Some("TaskChainStart") + ); + assert_eq!( + parsed + .get("message") + .and_then(|m| m.get("taskchain")) + .and_then(Value::as_str), + Some("Fight"), + ); + assert_eq!( + parsed + .get("message") + .and_then(|m| m.get("taskid")) + .and_then(Value::as_i64), + Some(42), + ); + // The whole event is a single JSON line, no embedded newlines. + assert!(!line.contains('\n')); + } + + #[test] + fn format_event_line_unknown_kind() { + let message: Value = serde_json::from_str("{}").unwrap(); + let line = format_event_line(MessageKind::Unknown(9999), &message).unwrap(); + let parsed: Value = serde_json::from_str(&line).unwrap(); + assert_eq!( + parsed.get("kind").and_then(Value::as_str), + Some("Unknown(9999)"), + ); + } } diff --git a/crates/maa-cli/src/run/callback/summary.rs b/crates/maa-cli/src/run/callback/summary.rs index 079b363f..55ba0115 100644 --- a/crates/maa-cli/src/run/callback/summary.rs +++ b/crates/maa-cli/src/run/callback/summary.rs @@ -29,6 +29,14 @@ pub(crate) fn display() -> Option<()> { with_summary(|summary| print!("{summary}")) } +/// Write the summary to stderr instead of stdout. +/// +/// Used by `--emit-json` mode so the summary does not pollute the JSONL stream +/// on stdout while still remaining visible to a human watching stderr. +pub(crate) fn display_to_stderr() -> Option<()> { + with_summary(|summary| eprint!("{summary}")) +} + pub(super) fn start_task(id: AsstTaskId) -> Option<()> { with_summary_mut(|summary| summary.start_task(id)).flatten() } diff --git a/crates/maa-cli/src/run/mod.rs b/crates/maa-cli/src/run/mod.rs index 70d3bca9..73ca116c 100644 --- a/crates/maa-cli/src/run/mod.rs +++ b/crates/maa-cli/src/run/mod.rs @@ -122,7 +122,7 @@ fn find_profile(root: impl AsRef, profile: Option<&str>) -> Result(f: F, args: CommonArgs) -> Result<()> +fn run_core(f: F, args: CommonArgs, emit_json: bool) -> Result<()> where F: FnOnce(&AsstConfig) -> Result, { @@ -155,7 +155,7 @@ where // Create and setup Assistant let auto_reconnect = asst_config.behavior.auto_reconnect && !args.no_auto_reconnect; - let (maa_callback, offline_stop) = callback::MaaCallback::new(auto_reconnect); + let (maa_callback, offline_stop) = callback::MaaCallback::new(auto_reconnect, emit_json); let asst = Assistant::new_with_callback(maa_callback) .context("Failed to create Assistant: resources may not be loaded")?; asst_config.instance_options.apply_to(&asst)?; @@ -251,13 +251,19 @@ where // Wrapper for run_core, always try to display summary even if error occurred // It's safe to display summary even if summary is not initialized -pub fn run(f: F, args: CommonArgs) -> Result<()> +pub fn run(f: F, args: CommonArgs, emit_json: bool) -> Result<()> where F: FnOnce(&AsstConfig) -> Result, { - let ret = run_core(f, args); + let ret = run_core(f, args, emit_json); - summary::display(); + // Under `--emit-json`, stdout is a pure JSONL stream for programmatic + // consumers; route the human-facing summary to stderr instead. + if emit_json { + summary::display_to_stderr(); + } else { + summary::display(); + } ret?; @@ -268,11 +274,15 @@ where Ok(()) } -pub fn run_preset(params: impl preset::IntoTaskConfig, args: CommonArgs) -> Result<()> { - run(|config| params.into_task_config(config), args) +pub fn run_preset( + params: impl preset::IntoTaskConfig, + args: CommonArgs, + emit_json: bool, +) -> Result<()> { + run(|config| params.into_task_config(config), args, emit_json) } -pub fn run_custom(path: impl AsRef, args: CommonArgs) -> Result<()> { +pub fn run_custom(path: impl AsRef, args: CommonArgs, emit_json: bool) -> Result<()> { run( |_| { let path = path.as_ref(); @@ -286,6 +296,7 @@ pub fn run_custom(path: impl AsRef, args: CommonArgs) -> Result<()> { config.init().context("Failed to initialize task config!") }, args, + emit_json, ) } From 49527cc2fd24ccb62f7529078d37cd28cef3b9b9 Mon Sep 17 00:00:00 2001 From: 1b2c Date: Tue, 28 Jul 2026 12:26:05 +0800 Subject: [PATCH 2/4] Give consumers a preview of what will run before MaaCore starts Co-Authored-By: Claude Opus 5 --- crates/maa-cli/docs/en-US/usage.md | 2 + crates/maa-cli/docs/zh-CN/usage.md | 2 + crates/maa-cli/src/run/callback/mod.rs | 95 ++++++++++++++++++++------ crates/maa-cli/src/run/mod.rs | 14 ++++ 4 files changed, 94 insertions(+), 19 deletions(-) diff --git a/crates/maa-cli/docs/en-US/usage.md b/crates/maa-cli/docs/en-US/usage.md index dd67823d..f6991c23 100644 --- a/crates/maa-cli/docs/en-US/usage.md +++ b/crates/maa-cli/docs/en-US/usage.md @@ -82,6 +82,8 @@ All logs normally include a timestamp and level prefix. The `MAA_LOG_PREFIX` env For programmatic callers (such as a web UI or scheduler wrapping maa-cli), the global `--emit-json` flag makes every MaaCore callback appear as one JSON object per line on stdout, with the shape `{"kind": "TaskChainStart", "message": {…}}` — `kind` is the callback variant name and `message` is the raw MaaCore payload. The human-readable log still goes to stderr, and side effects such as the end-of-run summary, non-zero exit on task errors, and outbound report requests still fire. The flag is not intended for interactive use. +In addition to MaaCore callbacks, maa-cli emits a synthetic `TaskRegistered` event once per task before the run starts, in the same JSONL stream. Each event carries `{"id": …, "name": …, "task_type": …, "params": …}` — the task id MaaCore assigned at registration, the task's configured name (or its task type name when unnamed), the task type as it appears in MaaCore (e.g. `"Fight"`, `"Award"`), and the resolved parameter object. The `id` value matches the `taskid` field carried by MaaCore's own `TaskChainStart` and `TaskChainCompleted` callbacks, which lets a consumer tie a running task chain back to the parameters it was registered with. Consumers can use these events to preview the plan, for example to display which stage a `Fight` task will target, before any real MaaCore callback arrives. + ### Other Subcommands Besides the above commands, maa-cli provides additional subcommands: diff --git a/crates/maa-cli/docs/zh-CN/usage.md b/crates/maa-cli/docs/zh-CN/usage.md index ba650adc..550e42c0 100644 --- a/crates/maa-cli/docs/zh-CN/usage.md +++ b/crates/maa-cli/docs/zh-CN/usage.md @@ -89,6 +89,8 @@ maa-cli 默认会向标准误 (stderr) 输出日志。`--log-file` 选项可以 面向程序化调用方(例如上层的 Web UI 或调度器),maa-cli 提供 `--emit-json` 全局参数。启用后,MaaCore 每次回调都会以一行 JSON 的形式写入标准输出(stdout),每行的结构为 `{"kind": "TaskChainStart", "message": {…}}`,其中 `kind` 为回调类型名,`message` 为 MaaCore 原始回调载荷。人类可读的日志仍然写入 stderr,任务总结、错误退出码、上报请求等副作用也照常触发。该选项不适合交互式使用,建议仅在被其他程序调用时启用。 +除 MaaCore 回调之外,maa-cli 会在任务开始运行前,为每个成功注册到 MaaCore 的任务额外向同一 JSONL 流发送一条 `TaskRegistered` 合成事件。每条事件的 `message` 结构为 `{"id": …, "name": …, "task_type": …, "params": …}`:`id` 为注册时 MaaCore 为该任务分配的任务 id,与 MaaCore 自身 `TaskChainStart`、`TaskChainCompleted` 等回调所携带的 `taskid` 字段取值一致,调用方据此可将正在运行的任务链准确关联回其注册参数;`name` 为任务配置中的名称(未指定时回退为任务类型名称),`task_type` 为 MaaCore 使用的任务类型名(如 `"Fight"`、`"Award"`),`params` 为解析后的参数对象。调用方可利用这些事件预览本次运行的完整计划,例如在 `Fight` 任务真正开始前就得知其目标关卡。 + ### 其他子命令 除了上述的命令外,maa-cli 还提供了其他一些子命令: diff --git a/crates/maa-cli/src/run/callback/mod.rs b/crates/maa-cli/src/run/callback/mod.rs index b9b37493..20580a49 100644 --- a/crates/maa-cli/src/run/callback/mod.rs +++ b/crates/maa-cli/src/run/callback/mod.rs @@ -19,21 +19,34 @@ fn json_pretty(value: &impl serde::Serialize) -> String { serde_json::to_string_pretty(value).unwrap_or_else(|e| format!("")) } -/// Render a callback event as a single line of JSON for `--emit-json` mode. +/// Render an event as a single line of JSON for `--emit-json` mode. /// -/// The shape is `{"kind": "", "message": }`. -/// `MessageKind` does not derive `Serialize`, so its `Debug` representation is -/// used (e.g. `"TaskChainStart"`, `"Unknown(9999)"`). Returns `None` if the -/// event fails to serialize (should not happen in practice — `message` is a -/// `Value` we just parsed). -fn format_event_line(kind: MessageKind, message: &Value) -> Option { +/// The shape is `{"kind": "", "message": }`. The caller +/// supplies the kind as a string so the same helper can emit both MaaCore +/// callbacks (whose `MessageKind` is stringified via its `Debug` repr, e.g. +/// `"TaskChainStart"`, `"Unknown(9999)"`) and synthetic events such as +/// `"TaskRegistered"`. Returns `None` if the event fails to serialize (should +/// not happen in practice — `message` is a `Value` we just built). +pub(crate) fn format_event_line(kind: &str, message: &Value) -> Option { let event = serde_json::json!({ - "kind": format!("{kind:?}"), + "kind": kind, "message": message, }); serde_json::to_string(&event).ok() } +/// Write one JSONL event to stdout under `--emit-json`, silently dropping the +/// line on write failure (e.g. broken pipe). Locks stdout for the duration. +pub(crate) fn emit_json_event(kind: &str, message: &Value) { + if let Some(line) = format_event_line(kind, message) { + let mut out = std::io::stdout().lock(); + // Ignore write errors: if stdout is closed (broken pipe), we silently + // drop the line rather than propagate an error up through a MaaCore + // callback thread or the task-registration loop. + let _ = writeln!(out, "{line}"); + } +} + pub struct MaaCallback { auto_reconnect: bool, emit_json: bool, @@ -70,15 +83,7 @@ impl Callback for MaaCallback { // state for `--no-summary`, and the outbound `ReportRequest` HTTP // send in `process_report`. if self.emit_json { - if let Some(line) = format_event_line(kind, &message) { - let mut out = std::io::stdout().lock(); - // Ignore write errors: if stdout is closed (broken pipe), we - // silently drop the line rather than panic inside a MaaCore - // callback thread. - let _ = writeln!(out, "{line}"); - } else { - log::warn!("Failed to serialize event for {kind:?}"); - } + emit_json_event(&format!("{kind:?}"), &message); } self.process_message(kind, message); @@ -680,8 +685,9 @@ mod tests { #[test] fn format_event_line_known_kind() { + let kind = MessageKind::TaskChainStart; let message: Value = serde_json::from_str(r#"{"taskchain":"Fight","taskid":42}"#).unwrap(); - let line = format_event_line(MessageKind::TaskChainStart, &message).unwrap(); + let line = format_event_line(&format!("{kind:?}"), &message).unwrap(); let parsed: Value = serde_json::from_str(&line).unwrap(); assert_eq!( parsed.get("kind").and_then(Value::as_str), @@ -707,12 +713,63 @@ mod tests { #[test] fn format_event_line_unknown_kind() { + let kind = MessageKind::Unknown(9999); let message: Value = serde_json::from_str("{}").unwrap(); - let line = format_event_line(MessageKind::Unknown(9999), &message).unwrap(); + let line = format_event_line(&format!("{kind:?}"), &message).unwrap(); let parsed: Value = serde_json::from_str(&line).unwrap(); assert_eq!( parsed.get("kind").and_then(Value::as_str), Some("Unknown(9999)"), ); } + + #[test] + fn format_event_line_task_registered() { + // Synthetic event emitted by `run_core` for each task registered with + // MaaCore. Shape mirrors the JSON built in `run/mod.rs`. + let message = serde_json::json!({ + "id": 42, + "name": "Daily Fight", + "task_type": "Fight", + "params": { "stage": "1-7" }, + }); + let line = format_event_line("TaskRegistered", &message).unwrap(); + let parsed: Value = serde_json::from_str(&line).unwrap(); + assert_eq!( + parsed.get("kind").and_then(Value::as_str), + Some("TaskRegistered"), + ); + // The task id MaaCore assigned at registration; consumers match it + // against the `taskid` carried by `TaskChainStart`. + assert_eq!( + parsed + .get("message") + .and_then(|m| m.get("id")) + .and_then(Value::as_i64), + Some(42), + ); + assert_eq!( + parsed + .get("message") + .and_then(|m| m.get("name")) + .and_then(Value::as_str), + Some("Daily Fight"), + ); + assert_eq!( + parsed + .get("message") + .and_then(|m| m.get("task_type")) + .and_then(Value::as_str), + Some("Fight"), + ); + assert_eq!( + parsed + .get("message") + .and_then(|m| m.get("params")) + .and_then(|p| p.get("stage")) + .and_then(Value::as_str), + Some("1-7"), + ); + assert!(!line.contains('\n')); + } } diff --git a/crates/maa-cli/src/run/mod.rs b/crates/maa-cli/src/run/mod.rs index 73ca116c..7dc97c3b 100644 --- a/crates/maa-cli/src/run/mod.rs +++ b/crates/maa-cli/src/run/mod.rs @@ -184,6 +184,20 @@ where ) })?; + // Under `--emit-json`, publish a synthetic `TaskRegistered` event so + // consumers can preview the resolved plan before MaaCore starts + // firing per-task callbacks. Emitted only on successful registration + // to keep failed tasks out of the plan. + if emit_json { + let message = serde_json::json!({ + "id": id, + "name": task.name_or_default(), + "task_type": task_type, + "params": task.params, + }); + callback::emit_json_event("TaskRegistered", &message); + } + if let Some(s) = task_summary.as_mut() { s.insert(id, task.name, task_type); } From 6b05895ee0a3be608597554e2dd4849bf1838b99 Mon Sep 17 00:00:00 2001 From: 1b2c Date: Fri, 7 Aug 2026 03:47:25 +0800 Subject: [PATCH 3/4] Timestamp each JSON event so consumers can order and correlate them Co-Authored-By: Claude Opus 5 (1M context) --- crates/maa-cli/docs/en-US/usage.md | 2 +- crates/maa-cli/docs/zh-CN/usage.md | 2 +- crates/maa-cli/src/command.rs | 8 +- crates/maa-cli/src/run/callback/mod.rs | 100 +++++++++++++++++++++---- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/crates/maa-cli/docs/en-US/usage.md b/crates/maa-cli/docs/en-US/usage.md index f6991c23..6138bf1b 100644 --- a/crates/maa-cli/docs/en-US/usage.md +++ b/crates/maa-cli/docs/en-US/usage.md @@ -80,7 +80,7 @@ All logs normally include a timestamp and level prefix. The `MAA_LOG_PREFIX` env ### Structured Event Output -For programmatic callers (such as a web UI or scheduler wrapping maa-cli), the global `--emit-json` flag makes every MaaCore callback appear as one JSON object per line on stdout, with the shape `{"kind": "TaskChainStart", "message": {…}}` — `kind` is the callback variant name and `message` is the raw MaaCore payload. The human-readable log still goes to stderr, and side effects such as the end-of-run summary, non-zero exit on task errors, and outbound report requests still fire. The flag is not intended for interactive use. +For programmatic callers (such as a web UI or scheduler wrapping maa-cli), the global `--emit-json` flag makes every MaaCore callback appear as one JSON object per line on stdout, with the shape `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}` — `kind` is the callback variant name, `time` is when the event was emitted, and `message` is the raw MaaCore payload. MaaCore payloads carry no time of their own, so maa-cli generates `time` at the moment it writes the event, from the same clock the human-readable log uses. It is formatted as RFC 3339 with millisecond precision and the local UTC offset, which parses unambiguously in any consumer and keeps events emitted within the same second orderable by timestamp alone. The human-readable log still goes to stderr, and side effects such as the end-of-run summary, non-zero exit on task errors, and outbound report requests still fire. The flag is not intended for interactive use. In addition to MaaCore callbacks, maa-cli emits a synthetic `TaskRegistered` event once per task before the run starts, in the same JSONL stream. Each event carries `{"id": …, "name": …, "task_type": …, "params": …}` — the task id MaaCore assigned at registration, the task's configured name (or its task type name when unnamed), the task type as it appears in MaaCore (e.g. `"Fight"`, `"Award"`), and the resolved parameter object. The `id` value matches the `taskid` field carried by MaaCore's own `TaskChainStart` and `TaskChainCompleted` callbacks, which lets a consumer tie a running task chain back to the parameters it was registered with. Consumers can use these events to preview the plan, for example to display which stage a `Fight` task will target, before any real MaaCore callback arrives. diff --git a/crates/maa-cli/docs/zh-CN/usage.md b/crates/maa-cli/docs/zh-CN/usage.md index 550e42c0..b456e114 100644 --- a/crates/maa-cli/docs/zh-CN/usage.md +++ b/crates/maa-cli/docs/zh-CN/usage.md @@ -87,7 +87,7 @@ maa-cli 默认会向标准误 (stderr) 输出日志。`--log-file` 选项可以 ### 结构化事件输出 -面向程序化调用方(例如上层的 Web UI 或调度器),maa-cli 提供 `--emit-json` 全局参数。启用后,MaaCore 每次回调都会以一行 JSON 的形式写入标准输出(stdout),每行的结构为 `{"kind": "TaskChainStart", "message": {…}}`,其中 `kind` 为回调类型名,`message` 为 MaaCore 原始回调载荷。人类可读的日志仍然写入 stderr,任务总结、错误退出码、上报请求等副作用也照常触发。该选项不适合交互式使用,建议仅在被其他程序调用时启用。 +面向程序化调用方(例如上层的 Web UI 或调度器),maa-cli 提供 `--emit-json` 全局参数。启用后,MaaCore 每次回调都会以一行 JSON 的形式写入标准输出(stdout),每行的结构为 `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}`,其中 `kind` 为回调类型名,`time` 为事件的发出时刻,`message` 为 MaaCore 原始回调载荷。MaaCore 的回调载荷本身不携带时间,因此 `time` 由 maa-cli 在写出该事件的那一刻生成,所用时钟与人类可读日志一致。其格式为 RFC 3339,精确到毫秒,并带有本地时区偏移;该格式在各类调用方中均可无歧义地解析,且同一秒内发出的多条事件仅凭时间戳即可排序。人类可读的日志仍然写入 stderr,任务总结、错误退出码、上报请求等副作用也照常触发。该选项不适合交互式使用,建议仅在被其他程序调用时启用。 除 MaaCore 回调之外,maa-cli 会在任务开始运行前,为每个成功注册到 MaaCore 的任务额外向同一 JSONL 流发送一条 `TaskRegistered` 合成事件。每条事件的 `message` 结构为 `{"id": …, "name": …, "task_type": …, "params": …}`:`id` 为注册时 MaaCore 为该任务分配的任务 id,与 MaaCore 自身 `TaskChainStart`、`TaskChainCompleted` 等回调所携带的 `taskid` 字段取值一致,调用方据此可将正在运行的任务链准确关联回其注册参数;`name` 为任务配置中的名称(未指定时回退为任务类型名称),`task_type` 为 MaaCore 使用的任务类型名(如 `"Fight"`、`"Award"`),`params` 为解析后的参数对象。调用方可利用这些事件预览本次运行的完整计划,例如在 `Fight` 任务真正开始前就得知其目标关卡。 diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index 60382d4d..88b0ed84 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -21,9 +21,11 @@ pub(crate) struct Cli { /// Emit MaaCore callback events as JSONL on stdout for structured consumption. /// /// When set, each MaaCore callback becomes one line of JSON on stdout with - /// the shape `{"kind": "TaskChainStart", "message": {…}}`. The human log - /// still goes to stderr as usual, and the summary / error-flagging side - /// effects of callback processing still run. Intended for programmatic + /// the shape `{"kind": "TaskChainStart", "time": "", "message": {…}}`. + /// MaaCore payloads carry no time of their own, so `time` is generated when the + /// event is written, with millisecond precision and the local UTC offset. The + /// human log still goes to stderr as usual, and the summary / error-flagging + /// side effects of callback processing still run. Intended for programmatic /// drivers, not for interactive use. #[arg(long, global = true, verbatim_doc_comment)] pub(crate) emit_json: bool, diff --git a/crates/maa-cli/src/run/callback/mod.rs b/crates/maa-cli/src/run/callback/mod.rs index 20580a49..9fe12354 100644 --- a/crates/maa-cli/src/run/callback/mod.rs +++ b/crates/maa-cli/src/run/callback/mod.rs @@ -19,26 +19,53 @@ fn json_pretty(value: &impl serde::Serialize) -> String { serde_json::to_string_pretty(value).unwrap_or_else(|e| format!("")) } +/// One line of the `--emit-json` stream. +/// +/// A struct rather than `serde_json::json!` because the field order below is +/// the emitted key order (a `serde_json::Map` without `preserve_order` sorts +/// its keys), and reading a line as "what happened, when, with what payload" +/// is worth keeping. +#[derive(serde::Serialize)] +struct Event<'a> { + kind: &'a str, + time: String, + message: &'a Value, +} + /// Render an event as a single line of JSON for `--emit-json` mode. /// -/// The shape is `{"kind": "", "message": }`. The caller -/// supplies the kind as a string so the same helper can emit both MaaCore -/// callbacks (whose `MessageKind` is stringified via its `Debug` repr, e.g. -/// `"TaskChainStart"`, `"Unknown(9999)"`) and synthetic events such as -/// `"TaskRegistered"`. Returns `None` if the event fails to serialize (should -/// not happen in practice — `message` is a `Value` we just built). -pub(crate) fn format_event_line(kind: &str, message: &Value) -> Option { - let event = serde_json::json!({ - "kind": kind, - "message": message, - }); +/// The shape is `{"kind": "", "time": "", "message": }`. +/// The caller supplies the kind as a string so the same helper can emit both +/// MaaCore callbacks (whose `MessageKind` is stringified via its `Debug` repr, +/// e.g. `"TaskChainStart"`, `"Unknown(9999)"`) and synthetic events such as +/// `"TaskRegistered"`. MaaCore payloads carry no time of their own, so `time` +/// is taken from the caller's clock; it is passed in rather than read here so +/// tests can assert on a fixed value. Returns `None` if the event fails to +/// serialize (should not happen in practice — `message` is a `Value` we just +/// built). +pub(crate) fn format_event_line( + kind: &str, + time: chrono::DateTime, + message: &Value, +) -> Option { + let event = Event { + kind, + // RFC 3339 with millisecond precision and a local UTC offset: parses + // unambiguously in every consumer, and keeps events emitted within the + // same second orderable by timestamp alone. + time: time.to_rfc3339_opts(chrono::SecondsFormat::Millis, false), + message, + }; serde_json::to_string(&event).ok() } /// Write one JSONL event to stdout under `--emit-json`, silently dropping the /// line on write failure (e.g. broken pipe). Locks stdout for the duration. +/// +/// The event is timestamped here, at the moment it is emitted, from the same +/// clock the human-readable log uses, so the two streams stay consistent. pub(crate) fn emit_json_event(kind: &str, message: &Value) { - if let Some(line) = format_event_line(kind, message) { + if let Some(line) = format_event_line(kind, chrono::Local::now(), message) { let mut out = std::io::stdout().lock(); // Ignore write errors: if stdout is closed (broken pipe), we silently // drop the line rather than propagate an error up through a MaaCore @@ -683,11 +710,26 @@ mod tests { assert!(!offline_stop.load(std::sync::atomic::Ordering::Relaxed)); } + /// A fixed instant, so assertions on the emitted `time` field do not depend + /// on when the test runs. It is rendered in the machine's local offset, so + /// assertions compare instants rather than the literal string. + fn fixed_time() -> chrono::DateTime { + use chrono::{TimeZone as _, Timelike as _}; + + chrono::FixedOffset::east_opt(8 * 3600) + .unwrap() + .with_ymd_and_hms(2026, 8, 7, 2, 15, 51) + .unwrap() + .with_nanosecond(123_000_000) + .unwrap() + .into() + } + #[test] fn format_event_line_known_kind() { let kind = MessageKind::TaskChainStart; let message: Value = serde_json::from_str(r#"{"taskchain":"Fight","taskid":42}"#).unwrap(); - let line = format_event_line(&format!("{kind:?}"), &message).unwrap(); + let line = format_event_line(&format!("{kind:?}"), fixed_time(), &message).unwrap(); let parsed: Value = serde_json::from_str(&line).unwrap(); assert_eq!( parsed.get("kind").and_then(Value::as_str), @@ -711,11 +753,39 @@ mod tests { assert!(!line.contains('\n')); } + #[test] + fn format_event_line_carries_timestamp() { + let message: Value = serde_json::from_str(r#"{"taskchain":"Fight"}"#).unwrap(); + let line = format_event_line("TaskChainStart", fixed_time(), &message).unwrap(); + let parsed: Value = serde_json::from_str(&line).unwrap(); + + let time = parsed.get("time").and_then(Value::as_str).unwrap(); + // RFC 3339 with a UTC offset, so consumers parse it unambiguously. + assert_eq!( + chrono::DateTime::parse_from_rfc3339(time).unwrap(), + fixed_time(), + ); + // Millisecond precision, so events within the same second stay ordered. + assert!(time.contains(".123"), "{time} lacks millisecond precision"); + } + + #[test] + fn format_event_line_envelope_key_order() { + let message: Value = serde_json::from_str("{}").unwrap(); + let line = format_event_line("TaskChainStart", fixed_time(), &message).unwrap(); + + // The envelope reads as "what happened, when, with what payload". + let kind_at = line.find(r#""kind""#).unwrap(); + let time_at = line.find(r#""time""#).unwrap(); + let message_at = line.find(r#""message""#).unwrap(); + assert!(kind_at < time_at && time_at < message_at, "{line}"); + } + #[test] fn format_event_line_unknown_kind() { let kind = MessageKind::Unknown(9999); let message: Value = serde_json::from_str("{}").unwrap(); - let line = format_event_line(&format!("{kind:?}"), &message).unwrap(); + let line = format_event_line(&format!("{kind:?}"), fixed_time(), &message).unwrap(); let parsed: Value = serde_json::from_str(&line).unwrap(); assert_eq!( parsed.get("kind").and_then(Value::as_str), @@ -733,7 +803,7 @@ mod tests { "task_type": "Fight", "params": { "stage": "1-7" }, }); - let line = format_event_line("TaskRegistered", &message).unwrap(); + let line = format_event_line("TaskRegistered", fixed_time(), &message).unwrap(); let parsed: Value = serde_json::from_str(&line).unwrap(); assert_eq!( parsed.get("kind").and_then(Value::as_str), From 905f244f2589e13e58dc8636d9b0c662a4f6af57 Mon Sep 17 00:00:00 2001 From: 1b2c Date: Wed, 26 Aug 2026 06:38:41 +0800 Subject: [PATCH 4/4] Flush each event and scale back what the docs promise about it Co-Authored-By: Claude Opus 5 (1M context) --- crates/maa-cli/docs/en-US/usage.md | 2 +- crates/maa-cli/docs/zh-CN/usage.md | 2 +- crates/maa-cli/src/run/callback/mod.rs | 12 +++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/maa-cli/docs/en-US/usage.md b/crates/maa-cli/docs/en-US/usage.md index 6138bf1b..d039c4e2 100644 --- a/crates/maa-cli/docs/en-US/usage.md +++ b/crates/maa-cli/docs/en-US/usage.md @@ -80,7 +80,7 @@ All logs normally include a timestamp and level prefix. The `MAA_LOG_PREFIX` env ### Structured Event Output -For programmatic callers (such as a web UI or scheduler wrapping maa-cli), the global `--emit-json` flag makes every MaaCore callback appear as one JSON object per line on stdout, with the shape `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}` — `kind` is the callback variant name, `time` is when the event was emitted, and `message` is the raw MaaCore payload. MaaCore payloads carry no time of their own, so maa-cli generates `time` at the moment it writes the event, from the same clock the human-readable log uses. It is formatted as RFC 3339 with millisecond precision and the local UTC offset, which parses unambiguously in any consumer and keeps events emitted within the same second orderable by timestamp alone. The human-readable log still goes to stderr, and side effects such as the end-of-run summary, non-zero exit on task errors, and outbound report requests still fire. The flag is not intended for interactive use. +For programmatic callers (such as a web UI or scheduler wrapping maa-cli), the global `--emit-json` flag makes every MaaCore callback appear as one JSON object per line on stdout, with the shape `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}` — `kind` is the callback variant name, `time` is when the event was emitted, and `message` is the raw MaaCore payload. MaaCore payloads carry no time of their own, so maa-cli generates `time` at the moment it writes the event, from the same clock the human-readable log uses. It is formatted as RFC 3339 with millisecond precision and the local UTC offset, which parses unambiguously in any consumer. It records an approximate emission time rather than a strict ordering key: callbacks arriving within the same millisecond share a timestamp, and a wall-clock adjustment during a run can make successive timestamps tie or move backwards. The human-readable log still goes to stderr, and side effects such as the end-of-run summary, non-zero exit on task errors, and outbound report requests still fire. The flag is not intended for interactive use. One exception to the stdout split: the interactive `copilot` and `sss-copilot` presets print operator tables and confirmation prompts on stdout while building their task configuration, before any event is emitted, so their stdout is not pure JSONL. Those presets need a human at the keyboard regardless. In addition to MaaCore callbacks, maa-cli emits a synthetic `TaskRegistered` event once per task before the run starts, in the same JSONL stream. Each event carries `{"id": …, "name": …, "task_type": …, "params": …}` — the task id MaaCore assigned at registration, the task's configured name (or its task type name when unnamed), the task type as it appears in MaaCore (e.g. `"Fight"`, `"Award"`), and the resolved parameter object. The `id` value matches the `taskid` field carried by MaaCore's own `TaskChainStart` and `TaskChainCompleted` callbacks, which lets a consumer tie a running task chain back to the parameters it was registered with. Consumers can use these events to preview the plan, for example to display which stage a `Fight` task will target, before any real MaaCore callback arrives. diff --git a/crates/maa-cli/docs/zh-CN/usage.md b/crates/maa-cli/docs/zh-CN/usage.md index b456e114..783f585f 100644 --- a/crates/maa-cli/docs/zh-CN/usage.md +++ b/crates/maa-cli/docs/zh-CN/usage.md @@ -87,7 +87,7 @@ maa-cli 默认会向标准误 (stderr) 输出日志。`--log-file` 选项可以 ### 结构化事件输出 -面向程序化调用方(例如上层的 Web UI 或调度器),maa-cli 提供 `--emit-json` 全局参数。启用后,MaaCore 每次回调都会以一行 JSON 的形式写入标准输出(stdout),每行的结构为 `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}`,其中 `kind` 为回调类型名,`time` 为事件的发出时刻,`message` 为 MaaCore 原始回调载荷。MaaCore 的回调载荷本身不携带时间,因此 `time` 由 maa-cli 在写出该事件的那一刻生成,所用时钟与人类可读日志一致。其格式为 RFC 3339,精确到毫秒,并带有本地时区偏移;该格式在各类调用方中均可无歧义地解析,且同一秒内发出的多条事件仅凭时间戳即可排序。人类可读的日志仍然写入 stderr,任务总结、错误退出码、上报请求等副作用也照常触发。该选项不适合交互式使用,建议仅在被其他程序调用时启用。 +面向程序化调用方(例如上层的 Web UI 或调度器),maa-cli 提供 `--emit-json` 全局参数。启用后,MaaCore 每次回调都会以一行 JSON 的形式写入标准输出(stdout),每行的结构为 `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}`,其中 `kind` 为回调类型名,`time` 为事件的发出时刻,`message` 为 MaaCore 原始回调载荷。MaaCore 的回调载荷本身不携带时间,因此 `time` 由 maa-cli 在写出该事件的那一刻生成,所用时钟与人类可读日志一致。其格式为 RFC 3339,精确到毫秒,并带有本地时区偏移;该格式在各类调用方中均可无歧义地解析。需要注意的是,该字段记录的是事件的大致发出时刻,而非严格的排序依据:同一毫秒内到达的多条回调会共享同一时间戳,运行期间若系统时钟发生调整,相邻事件的时间戳也可能相同甚至回退。人类可读的日志仍然写入 stderr,任务总结、错误退出码、上报请求等副作用也照常触发。该选项不适合交互式使用,建议仅在被其他程序调用时启用。此外,stdout 与 stderr 的分离存在一处例外:`copilot` 与 `sss-copilot` 两个交互式预设在构建任务配置时会向 stdout 打印干员表格与确认提示,且发生在任何事件写出之前,因此这两个预设的 stdout 并非纯粹的 JSONL;这两个预设本身即需要人工操作。 除 MaaCore 回调之外,maa-cli 会在任务开始运行前,为每个成功注册到 MaaCore 的任务额外向同一 JSONL 流发送一条 `TaskRegistered` 合成事件。每条事件的 `message` 结构为 `{"id": …, "name": …, "task_type": …, "params": …}`:`id` 为注册时 MaaCore 为该任务分配的任务 id,与 MaaCore 自身 `TaskChainStart`、`TaskChainCompleted` 等回调所携带的 `taskid` 字段取值一致,调用方据此可将正在运行的任务链准确关联回其注册参数;`name` 为任务配置中的名称(未指定时回退为任务类型名称),`task_type` 为 MaaCore 使用的任务类型名(如 `"Fight"`、`"Award"`),`params` 为解析后的参数对象。调用方可利用这些事件预览本次运行的完整计划,例如在 `Fight` 任务真正开始前就得知其目标关卡。 diff --git a/crates/maa-cli/src/run/callback/mod.rs b/crates/maa-cli/src/run/callback/mod.rs index 9fe12354..e1adf5cf 100644 --- a/crates/maa-cli/src/run/callback/mod.rs +++ b/crates/maa-cli/src/run/callback/mod.rs @@ -50,9 +50,11 @@ pub(crate) fn format_event_line( ) -> Option { let event = Event { kind, - // RFC 3339 with millisecond precision and a local UTC offset: parses - // unambiguously in every consumer, and keeps events emitted within the - // same second orderable by timestamp alone. + // RFC 3339 with millisecond precision and a local UTC offset, which + // parses unambiguously in every consumer. It records an approximate + // emission time, not an ordering key: callbacks arriving within the + // same millisecond share a timestamp, and a wall-clock adjustment can + // make successive events tie or move backwards. time: time.to_rfc3339_opts(chrono::SecondsFormat::Millis, false), message, }; @@ -71,6 +73,10 @@ pub(crate) fn emit_json_event(kind: &str, message: &Value) { // drop the line rather than propagate an error up through a MaaCore // callback thread or the task-registration loop. let _ = writeln!(out, "{line}"); + // `Stdout` is only documented to be line-buffered when connected to a + // terminal, so on a pipe the flush is what actually gets the event out + // now rather than whenever the buffer happens to fill. + let _ = out.flush(); } }