@team-agent/installer 0.3.21 → 0.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +9 -1
- package/crates/team-agent/src/cli/mod.rs +158 -2
- package/crates/team-agent/src/cli/status.rs +61 -0
- package/crates/team-agent/src/cli/status_port.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +26 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
- package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +452 -17
- package/crates/team-agent/src/lifecycle/display.rs +23 -302
- package/crates/team-agent/src/lifecycle/launch.rs +38 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
- package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
- package/crates/team-agent/src/messaging/delivery.rs +324 -95
- package/crates/team-agent/src/messaging/scheduler.rs +95 -73
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
- package/crates/team-agent/src/session_capture.rs +261 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
- package/crates/team-agent/src/tmux_backend.rs +138 -2
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +11 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -218,7 +218,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
218
218
|
Some("fallback-send-leader") => "usage: team-agent fallback-send-leader --workspace WORKSPACE --team TEAM --sender AGENT --message-id ID --content TEXT --primary-error ERROR [--task TASK] [--json]\n\nEmergency one-shot fallback only after a leader-bound MCP send transport failure; restart-agent after use.".to_string(),
|
|
219
219
|
Some("fallback-report-result") => "usage: team-agent fallback-report-result --workspace WORKSPACE --team TEAM --agent-id AGENT --task-id TASK --result-json JSON --primary-error ERROR [--json]\n\nEmergency one-shot fallback only after a report_result MCP transport failure; persists the result DB row, then restart-agent after use.".to_string(),
|
|
220
220
|
Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
|
|
221
|
-
Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]".to_string(),
|
|
221
|
+
Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n默认输出: worker,空闲|工作|错误;错误细分走 status --summary".to_string(),
|
|
222
222
|
Some("stop") => "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
|
|
223
223
|
Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
|
|
224
224
|
Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]".to_string(),
|
|
@@ -1663,6 +1663,14 @@ mod tests {
|
|
|
1663
1663
|
}
|
|
1664
1664
|
}
|
|
1665
1665
|
|
|
1666
|
+
#[test]
|
|
1667
|
+
fn status_help_mentions_summary_for_error_details() {
|
|
1668
|
+
assert!(
|
|
1669
|
+
command_help(Some("status")).contains("错误细分走 status --summary"),
|
|
1670
|
+
"status help must tell users where detailed error classes live"
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1666
1674
|
#[test]
|
|
1667
1675
|
fn ux_quick_start_workspace_resolves_relative_agents_dir_inside_workspace() {
|
|
1668
1676
|
let cwd = tmp_workspace();
|
|
@@ -269,6 +269,21 @@ pub mod lifecycle_port {
|
|
|
269
269
|
error: Option<String>,
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
#[derive(Debug)]
|
|
273
|
+
struct OwnedShutdownEndpoint {
|
|
274
|
+
endpoint: String,
|
|
275
|
+
socket_file: Option<PathBuf>,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[derive(Debug, Default)]
|
|
279
|
+
struct OwnedEndpointCleanup {
|
|
280
|
+
residual_files: Vec<String>,
|
|
281
|
+
removed_files: Vec<String>,
|
|
282
|
+
skipped_files: Vec<String>,
|
|
283
|
+
remaining_sessions: Vec<String>,
|
|
284
|
+
error: Option<String>,
|
|
285
|
+
}
|
|
286
|
+
|
|
272
287
|
fn push_unique_session(
|
|
273
288
|
sessions: &mut Vec<crate::transport::SessionName>,
|
|
274
289
|
session: crate::transport::SessionName,
|
|
@@ -419,6 +434,119 @@ pub mod lifecycle_port {
|
|
|
419
434
|
}
|
|
420
435
|
}
|
|
421
436
|
|
|
437
|
+
fn owned_shutdown_endpoint(
|
|
438
|
+
workspace: &Path,
|
|
439
|
+
state: &Value,
|
|
440
|
+
transport: &dyn crate::transport::Transport,
|
|
441
|
+
) -> Option<OwnedShutdownEndpoint> {
|
|
442
|
+
if state_uses_external_leader(state) {
|
|
443
|
+
return None;
|
|
444
|
+
}
|
|
445
|
+
if state.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env") {
|
|
446
|
+
return None;
|
|
447
|
+
}
|
|
448
|
+
let endpoint = legacy_worker_tmux_endpoint(state)
|
|
449
|
+
.map(str::to_string)
|
|
450
|
+
.or_else(|| transport.tmux_endpoint())
|
|
451
|
+
.unwrap_or_else(|| crate::tmux_backend::socket_name_for_workspace(workspace));
|
|
452
|
+
if endpoint.is_empty() || endpoint == "default" {
|
|
453
|
+
return None;
|
|
454
|
+
}
|
|
455
|
+
let workspace_socket = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
456
|
+
let source = state.get("tmux_socket_source").and_then(Value::as_str);
|
|
457
|
+
let owned = source == Some("workspace")
|
|
458
|
+
|| source.is_none()
|
|
459
|
+
&& endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
|
|
460
|
+
if !owned {
|
|
461
|
+
return None;
|
|
462
|
+
}
|
|
463
|
+
let socket_file = socket_file_for_endpoint(&endpoint);
|
|
464
|
+
Some(OwnedShutdownEndpoint {
|
|
465
|
+
endpoint,
|
|
466
|
+
socket_file,
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
fn endpoint_matches_workspace_socket(
|
|
471
|
+
endpoint: &str,
|
|
472
|
+
workspace: &Path,
|
|
473
|
+
workspace_socket: &str,
|
|
474
|
+
) -> bool {
|
|
475
|
+
if endpoint == workspace_socket {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
let Some(workspace_path) = crate::tmux_backend::socket_path_for_workspace(workspace) else {
|
|
479
|
+
return false;
|
|
480
|
+
};
|
|
481
|
+
Path::new(endpoint) == workspace_path
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
fn socket_file_for_endpoint(endpoint: &str) -> Option<PathBuf> {
|
|
485
|
+
if Path::new(endpoint).is_absolute() {
|
|
486
|
+
Some(PathBuf::from(endpoint))
|
|
487
|
+
} else {
|
|
488
|
+
crate::tmux_backend::socket_path_for_name(endpoint)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
fn cleanup_owned_empty_endpoint(
|
|
493
|
+
transport: &dyn crate::transport::Transport,
|
|
494
|
+
endpoint: &OwnedShutdownEndpoint,
|
|
495
|
+
event_log: &crate::event_log::EventLog,
|
|
496
|
+
) -> OwnedEndpointCleanup {
|
|
497
|
+
let mut cleanup = OwnedEndpointCleanup::default();
|
|
498
|
+
cleanup.remaining_sessions = transport
|
|
499
|
+
.list_targets()
|
|
500
|
+
.unwrap_or_default()
|
|
501
|
+
.into_iter()
|
|
502
|
+
.map(|pane| pane.session.as_str().to_string())
|
|
503
|
+
.collect::<std::collections::BTreeSet<_>>()
|
|
504
|
+
.into_iter()
|
|
505
|
+
.collect::<Vec<_>>();
|
|
506
|
+
if !cleanup.remaining_sessions.is_empty() {
|
|
507
|
+
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
508
|
+
cleanup.skipped_files.push(path.display().to_string());
|
|
509
|
+
}
|
|
510
|
+
let _ = event_log.write(
|
|
511
|
+
"shutdown.owned_endpoint_cleanup_skipped",
|
|
512
|
+
json!({
|
|
513
|
+
"endpoint": endpoint.endpoint,
|
|
514
|
+
"reason": "sessions_remain",
|
|
515
|
+
"remaining_sessions": cleanup.remaining_sessions.clone(),
|
|
516
|
+
"skipped_files": cleanup.skipped_files.clone(),
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
return cleanup;
|
|
520
|
+
}
|
|
521
|
+
if let Err(error) = transport.kill_server() {
|
|
522
|
+
cleanup.error = Some(error.to_string());
|
|
523
|
+
}
|
|
524
|
+
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
525
|
+
if path.exists() {
|
|
526
|
+
match std::fs::remove_file(path) {
|
|
527
|
+
Ok(()) => cleanup.removed_files.push(path.display().to_string()),
|
|
528
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
529
|
+
Err(error) => {
|
|
530
|
+
cleanup.error.get_or_insert_with(|| error.to_string());
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if path.exists() {
|
|
535
|
+
cleanup.residual_files.push(path.display().to_string());
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
let _ = event_log.write(
|
|
539
|
+
"shutdown.owned_endpoint_cleanup",
|
|
540
|
+
json!({
|
|
541
|
+
"endpoint": endpoint.endpoint,
|
|
542
|
+
"removed_files": cleanup.removed_files.clone(),
|
|
543
|
+
"residual_files": cleanup.residual_files.clone(),
|
|
544
|
+
"error": cleanup.error.clone(),
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
cleanup
|
|
548
|
+
}
|
|
549
|
+
|
|
422
550
|
pub fn shutdown_with_transport(
|
|
423
551
|
workspace: &Path,
|
|
424
552
|
keep_logs: bool,
|
|
@@ -506,6 +634,7 @@ pub mod lifecycle_port {
|
|
|
506
634
|
kill_error = Some(error.to_string());
|
|
507
635
|
}
|
|
508
636
|
}
|
|
637
|
+
let _ = crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
|
|
509
638
|
}
|
|
510
639
|
deadline.check("reap_workspace_residuals")?;
|
|
511
640
|
reap_workspace_process_residuals(
|
|
@@ -539,6 +668,20 @@ pub mod lifecycle_port {
|
|
|
539
668
|
if let Some(error) = session_residual_error {
|
|
540
669
|
kill_error.get_or_insert(error);
|
|
541
670
|
}
|
|
671
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
672
|
+
let owned_endpoint = owned_shutdown_endpoint(&run_workspace, &state, transport);
|
|
673
|
+
let owned_cleanup = owned_endpoint
|
|
674
|
+
.as_ref()
|
|
675
|
+
.map(|endpoint| cleanup_owned_empty_endpoint(transport, endpoint, &event_log))
|
|
676
|
+
.unwrap_or_default();
|
|
677
|
+
if let Some(error) = owned_cleanup.error.as_ref() {
|
|
678
|
+
kill_error.get_or_insert_with(|| error.clone());
|
|
679
|
+
}
|
|
680
|
+
let owned_file_residuals = owned_cleanup
|
|
681
|
+
.residual_files
|
|
682
|
+
.iter()
|
|
683
|
+
.map(|path| json!({ "path": path }))
|
|
684
|
+
.collect::<Vec<_>>();
|
|
542
685
|
deadline.check("process_residuals")?;
|
|
543
686
|
// C-①: the post-verify gets ONE fresh verification snapshot (reaps changed
|
|
544
687
|
// the world; #248 post-verify facts must be current, not the entry view).
|
|
@@ -597,7 +740,8 @@ pub mod lifecycle_port {
|
|
|
597
740
|
let session_killed = !killed_sessions.is_empty()
|
|
598
741
|
&& kill_error.is_none()
|
|
599
742
|
&& session_residuals.is_empty()
|
|
600
|
-
&& process_residuals.is_empty()
|
|
743
|
+
&& process_residuals.is_empty()
|
|
744
|
+
&& owned_file_residuals.is_empty();
|
|
601
745
|
mark_agents_stopped(&mut state);
|
|
602
746
|
deadline.check("save_state")?;
|
|
603
747
|
if team.is_some() {
|
|
@@ -624,6 +768,7 @@ pub mod lifecycle_port {
|
|
|
624
768
|
kill_error: kill_error.is_some(),
|
|
625
769
|
session_residuals: !session_residuals.is_empty(),
|
|
626
770
|
process_residuals: !process_residuals.is_empty(),
|
|
771
|
+
owned_file_residuals: !owned_file_residuals.is_empty(),
|
|
627
772
|
cleanup_truth_degraded,
|
|
628
773
|
coordinator_timeout,
|
|
629
774
|
coordinator_stop_ok: stopped.as_ref().map(|stopped| stopped.ok),
|
|
@@ -655,6 +800,10 @@ pub mod lifecycle_port {
|
|
|
655
800
|
"verification_degraded": verification_degraded,
|
|
656
801
|
"probe_timeout_kind": probe_timeout_kind,
|
|
657
802
|
"probe_timeout": probe_timeout_value,
|
|
803
|
+
"owned_endpoint": owned_endpoint.as_ref().map(|endpoint| endpoint.endpoint.clone()),
|
|
804
|
+
"owned_files_removed": owned_cleanup.removed_files.clone(),
|
|
805
|
+
"owned_files_skipped": owned_cleanup.skipped_files.clone(),
|
|
806
|
+
"owned_file_residuals": owned_file_residuals.clone(),
|
|
658
807
|
}),
|
|
659
808
|
)
|
|
660
809
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
@@ -675,6 +824,7 @@ pub mod lifecycle_port {
|
|
|
675
824
|
"residuals": {
|
|
676
825
|
"sessions": session_residuals,
|
|
677
826
|
"processes": process_residuals,
|
|
827
|
+
"owned_files": owned_file_residuals,
|
|
678
828
|
},
|
|
679
829
|
"error": kill_error,
|
|
680
830
|
"coordinator": {
|
|
@@ -706,6 +856,7 @@ pub mod lifecycle_port {
|
|
|
706
856
|
pub(crate) kill_error: bool,
|
|
707
857
|
pub(crate) session_residuals: bool,
|
|
708
858
|
pub(crate) process_residuals: bool,
|
|
859
|
+
pub(crate) owned_file_residuals: bool,
|
|
709
860
|
pub(crate) cleanup_truth_degraded: bool,
|
|
710
861
|
pub(crate) coordinator_timeout: bool,
|
|
711
862
|
pub(crate) coordinator_stop_ok: Option<bool>,
|
|
@@ -724,6 +875,7 @@ pub mod lifecycle_port {
|
|
|
724
875
|
&& !input.kill_error
|
|
725
876
|
&& !input.session_residuals
|
|
726
877
|
&& !input.process_residuals
|
|
878
|
+
&& !input.owned_file_residuals
|
|
727
879
|
&& !input.cleanup_truth_degraded;
|
|
728
880
|
if ok {
|
|
729
881
|
return ShutdownOutcome {
|
|
@@ -736,7 +888,11 @@ pub mod lifecycle_port {
|
|
|
736
888
|
("timeout", Some("stop_coordinator"))
|
|
737
889
|
} else if input.cleanup_truth_degraded {
|
|
738
890
|
("partial", Some("os_probe"))
|
|
739
|
-
} else if input.kill_error
|
|
891
|
+
} else if input.kill_error
|
|
892
|
+
|| input.session_residuals
|
|
893
|
+
|| input.process_residuals
|
|
894
|
+
|| input.owned_file_residuals
|
|
895
|
+
{
|
|
740
896
|
("failed", None)
|
|
741
897
|
} else {
|
|
742
898
|
("partial", None)
|
|
@@ -145,6 +145,67 @@ pub fn format_status_summary(data: &Value) -> String {
|
|
|
145
145
|
)
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
pub fn format_status_csv(data: &Value) -> String {
|
|
149
|
+
let Some(agents) = data.get("agents").and_then(Value::as_object) else {
|
|
150
|
+
return String::new();
|
|
151
|
+
};
|
|
152
|
+
let health = data.get("agent_health").and_then(Value::as_object);
|
|
153
|
+
agents
|
|
154
|
+
.iter()
|
|
155
|
+
.map(|(agent_id, agent)| {
|
|
156
|
+
let status = csv_agent_status(agent_id, agent, health);
|
|
157
|
+
format!("{agent_id},{status}")
|
|
158
|
+
})
|
|
159
|
+
.collect::<Vec<_>>()
|
|
160
|
+
.join("\n")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn csv_agent_status(
|
|
164
|
+
agent_id: &str,
|
|
165
|
+
agent: &Value,
|
|
166
|
+
health: Option<&serde_json::Map<String, Value>>,
|
|
167
|
+
) -> &'static str {
|
|
168
|
+
let raw = agent
|
|
169
|
+
.get("status")
|
|
170
|
+
.and_then(Value::as_str)
|
|
171
|
+
.unwrap_or("")
|
|
172
|
+
.to_ascii_lowercase();
|
|
173
|
+
let hstatus = health
|
|
174
|
+
.and_then(|map| map.get(agent_id))
|
|
175
|
+
.and_then(|v| v.get("status"))
|
|
176
|
+
.and_then(Value::as_str)
|
|
177
|
+
.unwrap_or("")
|
|
178
|
+
.to_ascii_lowercase();
|
|
179
|
+
if matches!(raw.as_str(), "failed" | "error" | "spawn_failed")
|
|
180
|
+
|| matches!(hstatus.as_str(), "failed" | "error" | "spawn_failed")
|
|
181
|
+
|| matches!(raw.as_str(), "stopped" | "done")
|
|
182
|
+
{
|
|
183
|
+
return "错误";
|
|
184
|
+
}
|
|
185
|
+
let pane_present = agent
|
|
186
|
+
.get("pane_id")
|
|
187
|
+
.and_then(Value::as_str)
|
|
188
|
+
.filter(|pane| !pane.is_empty())
|
|
189
|
+
.is_some()
|
|
190
|
+
|| agent
|
|
191
|
+
.pointer("/display/pane_id")
|
|
192
|
+
.and_then(Value::as_str)
|
|
193
|
+
.filter(|pane| !pane.is_empty())
|
|
194
|
+
.is_some();
|
|
195
|
+
if matches!(raw.as_str(), "running" | "busy" | "working") && !pane_present {
|
|
196
|
+
return "错误";
|
|
197
|
+
}
|
|
198
|
+
if raw == "idle" || hstatus == "idle" {
|
|
199
|
+
return "空闲";
|
|
200
|
+
}
|
|
201
|
+
if matches!(raw.as_str(), "running" | "busy" | "working")
|
|
202
|
+
|| matches!(hstatus.as_str(), "running" | "working" | "busy")
|
|
203
|
+
{
|
|
204
|
+
return "工作";
|
|
205
|
+
}
|
|
206
|
+
"错误"
|
|
207
|
+
}
|
|
208
|
+
|
|
148
209
|
/// `_latest_result_line`(`commands.py:333-337`):agent_id/summary/created_at 渲染单行。
|
|
149
210
|
/// summary `\n`→` ` 后 [:80] 截断、空 → `-`;agent_id 空 → `-`;created_at 经 age_text。
|
|
150
211
|
pub(crate) fn format_latest_result(value: &Value) -> String {
|
|
@@ -127,8 +127,8 @@ use rusqlite::params;
|
|
|
127
127
|
format_agent_status(workspace, &status, agent)
|
|
128
128
|
}
|
|
129
129
|
None => {
|
|
130
|
-
let status = status_scoped(workspace, state, owner_team_id,
|
|
131
|
-
Ok(crate::cli::
|
|
130
|
+
let status = status_scoped(workspace, state, owner_team_id, false, false)?;
|
|
131
|
+
Ok(crate::cli::format_status_csv(&status))
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
}
|
|
@@ -363,6 +363,32 @@ latest result: none";
|
|
|
363
363
|
assert!(!line2.contains("interacted"), "no marker when interacted==0");
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
#[test]
|
|
367
|
+
fn format_status_csv_preserves_agent_order_and_collapses_errors() {
|
|
368
|
+
let data = json!({
|
|
369
|
+
"agents": {
|
|
370
|
+
"zeta": {"status": "idle", "pane_id": "%1"},
|
|
371
|
+
"alpha": {"status": "running", "pane_id": "%2"},
|
|
372
|
+
"err_failed": {"status": "failed", "pane_id": "%3"},
|
|
373
|
+
"err_missing_pane": {"status": "running"},
|
|
374
|
+
"err_unknown": {"status": "mystery", "pane_id": "%4"},
|
|
375
|
+
"err_stopped": {"status": "stopped", "pane_id": "%5"}
|
|
376
|
+
},
|
|
377
|
+
"agent_health": {
|
|
378
|
+
"alpha": {"status": "working"}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
assert_eq!(
|
|
382
|
+
format_status_csv(&data),
|
|
383
|
+
"zeta,空闲\nalpha,工作\nerr_failed,错误\nerr_missing_pane,错误\nerr_unknown,错误\nerr_stopped,错误"
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#[test]
|
|
388
|
+
fn format_status_csv_zero_workers_is_empty() {
|
|
389
|
+
assert_eq!(format_status_csv(&json!({"agents": {}, "agent_health": {}})), "");
|
|
390
|
+
}
|
|
391
|
+
|
|
366
392
|
// =========================================================================
|
|
367
393
|
// emit(helpers.py:12-23):--json sort_keys+indent=2 | dict 逐键 | 非 dict
|
|
368
394
|
// =========================================================================
|