@team-agent/installer 0.5.94 → 0.5.98
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/adapters.rs +381 -214
- package/crates/team-agent/src/cli/diagnose.rs +40 -10
- package/crates/team-agent/src/cli/emit.rs +6 -26
- package/crates/team-agent/src/cli/mod.rs +114 -30
- package/crates/team-agent/src/cli/spec.rs +2 -5
- package/crates/team-agent/src/cli/tests/base.rs +20 -10
- package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
- package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
- package/crates/team-agent/src/cli/tests/mod.rs +0 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +43 -0
- package/crates/team-agent/src/cli/triage.rs +31 -31
- package/crates/team-agent/src/cli/types.rs +3 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
- package/package.json +4 -4
- package/skills/team-agent/references/team-agent-operator.md +20 -28
- package/crates/team-agent/src/cli/tests/compile.rs +0 -192
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -269,50 +269,6 @@ fn append_reminder(text: String, reminder: &str) -> String {
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
/// `cmd_compile`(`commands.py:42`)。
|
|
273
|
-
pub fn cmd_compile(args: &CompileArgs) -> Result<CmdResult, CliError> {
|
|
274
|
-
let spec =
|
|
275
|
-
crate::compiler::compile_team(&args.team).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
276
|
-
warn_ignored_owner_team_id(&args.team);
|
|
277
|
-
std::fs::write(&args.out, crate::model::yaml::dumps(&spec))?;
|
|
278
|
-
Ok(CmdResult::from_json(
|
|
279
|
-
json!({
|
|
280
|
-
"ok": true,
|
|
281
|
-
"team_dir": args.team.to_string_lossy().to_string(),
|
|
282
|
-
"out": args.out.to_string_lossy().to_string(),
|
|
283
|
-
"agents": compiled_agent_ids_for_cli(&spec),
|
|
284
|
-
}),
|
|
285
|
-
args.json,
|
|
286
|
-
))
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
fn warn_ignored_owner_team_id(team_dir: &std::path::Path) {
|
|
290
|
-
let Ok(Some(ignored)) = crate::compiler::ignored_owner_team_id_from_team_md(team_dir) else {
|
|
291
|
-
return;
|
|
292
|
-
};
|
|
293
|
-
let workspace = crate::model::paths::team_workspace(team_dir)
|
|
294
|
-
.unwrap_or_else(|_| team_dir.parent().unwrap_or(team_dir).to_path_buf());
|
|
295
|
-
eprintln!(
|
|
296
|
-
"Warning: ignored TEAM.md {}={}",
|
|
297
|
-
ignored.field, ignored.value
|
|
298
|
-
);
|
|
299
|
-
eprintln!("Reason: owner identity is the canonical runtime team key, not TEAM.md front matter");
|
|
300
|
-
eprintln!("Action: remove {} from TEAM.md", ignored.field);
|
|
301
|
-
let fields = json!({
|
|
302
|
-
"field": ignored.field,
|
|
303
|
-
"source": team_dir.join("TEAM.md").to_string_lossy().to_string(),
|
|
304
|
-
"value": ignored.value,
|
|
305
|
-
"warning": "ignored user-set owner_team_id",
|
|
306
|
-
"reason": "owner identity is derived from the canonical runtime team key",
|
|
307
|
-
"action": "remove owner_team_id from TEAM.md",
|
|
308
|
-
});
|
|
309
|
-
if let Err(err) =
|
|
310
|
-
crate::event_log::EventLog::new(&workspace).write("spec.field_ignored", fields)
|
|
311
|
-
{
|
|
312
|
-
eprintln!("Warning: spec.field_ignored event write failed: {err}");
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
272
|
/// `cmd_status`(`commands.py:90`)。CLI status 统一为只读七字段 brief;
|
|
317
273
|
/// `--json` 与人读路径共享 native tmux/process projection;`--summary`/`--detail` 保留参数兼容性但不暴露诊断。
|
|
318
274
|
#[cfg(test)]
|
|
@@ -482,100 +438,22 @@ pub fn cmd_allow_peer_talk(args: &AllowPeerTalkArgs) -> Result<CmdResult, CliErr
|
|
|
482
438
|
Ok(CmdResult::from_json(value, args.json))
|
|
483
439
|
}
|
|
484
440
|
|
|
485
|
-
/// `
|
|
441
|
+
/// `diagnose` is the permanent compatibility spelling for `doctor`.
|
|
442
|
+
/// Keep this wrapper free of probing/reporting logic so direct Rust callers and
|
|
443
|
+
/// the CLI both converge on the canonical handler.
|
|
486
444
|
pub fn cmd_diagnose(args: &DiagnoseArgs) -> Result<CmdResult, CliError> {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
.workspace
|
|
500
|
-
.join(".team")
|
|
501
|
-
.join("logs")
|
|
502
|
-
.join("events.jsonl");
|
|
503
|
-
return Ok(crate::cli::triage::report(
|
|
504
|
-
json!({
|
|
505
|
-
"event_log": event_log.to_string_lossy().to_string(),
|
|
506
|
-
"issues": issues,
|
|
507
|
-
"ok": false,
|
|
508
|
-
"providers": provider_doctor_checks(),
|
|
509
|
-
"runtime": {
|
|
510
|
-
"workspace": args.workspace.to_string_lossy().to_string(),
|
|
511
|
-
"team_key": team_hint,
|
|
512
|
-
"session_name": Value::Null,
|
|
513
|
-
"leader_receiver": Value::Null,
|
|
514
|
-
"agent_count": 0,
|
|
515
|
-
"message_count": 0,
|
|
516
|
-
"result_count": 0,
|
|
517
|
-
},
|
|
518
|
-
"suggested_repairs": suggested_repairs,
|
|
519
|
-
}),
|
|
520
|
-
args.json,
|
|
521
|
-
"diagnose",
|
|
522
|
-
));
|
|
523
|
-
}
|
|
524
|
-
let selected = crate::state::selector::resolve_active_team_readonly(
|
|
525
|
-
&args.workspace,
|
|
526
|
-
args.team.as_deref(),
|
|
527
|
-
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
528
|
-
)
|
|
529
|
-
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
530
|
-
let state = selected.state;
|
|
531
|
-
let event_log = selected
|
|
532
|
-
.run_workspace
|
|
533
|
-
.join(".team")
|
|
534
|
-
.join("logs")
|
|
535
|
-
.join("events.jsonl");
|
|
536
|
-
// 0.5.x Phase 1d Batch 3: factory-routed diagnose backend so
|
|
537
|
-
// conpty teams get accurate `has_session` / capture from the shim
|
|
538
|
-
// rather than a workspace tmux fallback (which would always miss
|
|
539
|
-
// the ConPTY pane universe).
|
|
540
|
-
let backend: Box<dyn crate::transport::Transport> =
|
|
541
|
-
match crate::transport_factory::resolve_read_only_transport(
|
|
542
|
-
&selected.run_workspace,
|
|
543
|
-
Some(&state),
|
|
544
|
-
crate::transport_factory::TransportPurpose::Diagnose,
|
|
545
|
-
) {
|
|
546
|
-
Ok(r) => r.backend,
|
|
547
|
-
Err(_) => Box::new(crate::tmux_backend::TmuxBackend::for_workspace(
|
|
548
|
-
&selected.run_workspace,
|
|
549
|
-
)),
|
|
550
|
-
};
|
|
551
|
-
let (issues, suggested_repairs) =
|
|
552
|
-
diagnose_runtime_for_workspace(
|
|
553
|
-
&selected.run_workspace,
|
|
554
|
-
&state,
|
|
555
|
-
backend.as_ref(),
|
|
556
|
-
Some(selected.team_key.as_str()),
|
|
557
|
-
);
|
|
558
|
-
let ok = issues.as_array().is_some_and(Vec::is_empty);
|
|
559
|
-
Ok(crate::cli::triage::report(
|
|
560
|
-
json!({
|
|
561
|
-
"event_log": event_log.to_string_lossy().to_string(),
|
|
562
|
-
"issues": issues,
|
|
563
|
-
"ok": ok,
|
|
564
|
-
"providers": provider_doctor_checks(),
|
|
565
|
-
"runtime": {
|
|
566
|
-
"workspace": args.workspace.to_string_lossy().to_string(),
|
|
567
|
-
"team_key": selected.team_key,
|
|
568
|
-
"session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
|
|
569
|
-
"leader_receiver": state.get("leader_receiver").cloned().unwrap_or(Value::Null),
|
|
570
|
-
"agent_count": state.get("agents").and_then(Value::as_object).map_or(0, serde_json::Map::len),
|
|
571
|
-
"message_count": count_dir_entries(&selected.run_workspace.join(".team").join("messages")),
|
|
572
|
-
"result_count": count_dir_entries(&selected.run_workspace.join(".team").join("results")),
|
|
573
|
-
},
|
|
574
|
-
"suggested_repairs": suggested_repairs,
|
|
575
|
-
}),
|
|
576
|
-
args.json,
|
|
577
|
-
"diagnose",
|
|
578
|
-
))
|
|
445
|
+
cmd_doctor(&DoctorArgs {
|
|
446
|
+
spec: None,
|
|
447
|
+
workspace: args.workspace.clone(),
|
|
448
|
+
gate: None,
|
|
449
|
+
comms: false,
|
|
450
|
+
team: args.team.clone(),
|
|
451
|
+
fix: false,
|
|
452
|
+
fix_schema: false,
|
|
453
|
+
cleanup_orphans: false,
|
|
454
|
+
confirm: false,
|
|
455
|
+
json: args.json,
|
|
456
|
+
})
|
|
579
457
|
}
|
|
580
458
|
|
|
581
459
|
/// `cmd_preflight`(`parser.py:160`)。
|
|
@@ -1046,19 +924,6 @@ fn sessions_overview(state: &Value, spec: Option<&crate::model::yaml::Value>) ->
|
|
|
1046
924
|
Value::Array(rows)
|
|
1047
925
|
}
|
|
1048
926
|
|
|
1049
|
-
fn compiled_agent_ids_for_cli(spec: &crate::model::yaml::Value) -> Vec<String> {
|
|
1050
|
-
spec.get("agents")
|
|
1051
|
-
.and_then(crate::model::yaml::Value::as_list)
|
|
1052
|
-
.map(|agents| {
|
|
1053
|
-
agents
|
|
1054
|
-
.iter()
|
|
1055
|
-
.filter_map(|agent| agent.get("id").and_then(crate::model::yaml::Value::as_str))
|
|
1056
|
-
.map(str::to_string)
|
|
1057
|
-
.collect()
|
|
1058
|
-
})
|
|
1059
|
-
.unwrap_or_default()
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
927
|
fn session_row(
|
|
1063
928
|
state: &Value,
|
|
1064
929
|
spec_agent: &crate::model::yaml::Value,
|
|
@@ -1493,88 +1358,390 @@ pub fn cmd_acknowledge_idle(args: &AcknowledgeIdleArgs) -> Result<CmdResult, Cli
|
|
|
1493
1358
|
))
|
|
1494
1359
|
}
|
|
1495
1360
|
|
|
1496
|
-
/// `
|
|
1497
|
-
///
|
|
1498
|
-
/// 非 gate:`--fix-schema`→`fix_schema`;schema drift→注入;`--cleanup-orphans`→`cleanup_orphans`;
|
|
1499
|
-
/// else→`diagnose_port::doctor(spec)` + schema 注入。返回 `dict | str`(comms 人读 = boundary+json)。
|
|
1361
|
+
/// `doctor` is the single health/diagnostic pipeline. `diagnose` enters here
|
|
1362
|
+
/// through the compatibility wrapper above; it never gets a second facts engine.
|
|
1500
1363
|
pub fn cmd_doctor(args: &DoctorArgs) -> Result<CmdResult, CliError> {
|
|
1501
|
-
if args.fix && args.gate.is_none() {
|
|
1502
|
-
return Err(CliError::Runtime("--fix requires --gate".to_string()));
|
|
1503
|
-
}
|
|
1504
|
-
// swallow batch 3 ①: an unknown gate refuses explicitly (Python commands.py:234-235
|
|
1505
|
-
// `unknown doctor gate`), never an empty default-doctor green.
|
|
1506
1364
|
if let Some(DoctorGate::Unknown(raw)) = &args.gate {
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1365
|
+
let value = json!({
|
|
1366
|
+
"ok": false,
|
|
1367
|
+
"issues": ["unknown_gate"],
|
|
1368
|
+
"suggested_repairs": [{"action": "use --gate orphans or --gate comms", "issue": "unknown_gate"}],
|
|
1369
|
+
"status": "unknown_gate",
|
|
1370
|
+
"gate": raw,
|
|
1371
|
+
});
|
|
1372
|
+
return Ok(crate::cli::triage::report(value, args.json, "doctor"));
|
|
1511
1373
|
}
|
|
1512
|
-
if args.
|
|
1513
|
-
let value =
|
|
1374
|
+
if args.fix && args.gate.is_none() {
|
|
1375
|
+
let value = json!({
|
|
1376
|
+
"ok": false,
|
|
1377
|
+
"error": "--fix requires --gate",
|
|
1378
|
+
"issues": ["fix_requires_gate"],
|
|
1379
|
+
"suggested_repairs": [{"issue": "fix_requires_gate", "action": "add --gate orphans|comms"}],
|
|
1380
|
+
});
|
|
1381
|
+
return Ok(crate::cli::triage::report(value, args.json, "doctor"));
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
let explicit_comms = args.comms || matches!(args.gate, Some(DoctorGate::Comms));
|
|
1385
|
+
let default_report = !explicit_comms
|
|
1386
|
+
&& args.gate.is_none()
|
|
1387
|
+
&& !args.cleanup_orphans
|
|
1388
|
+
&& !args.fix_schema;
|
|
1389
|
+
let mut value = if explicit_comms {
|
|
1390
|
+
crate::diagnose::comms::doctor_comms_json(
|
|
1514
1391
|
&args.workspace,
|
|
1515
1392
|
args.team.as_deref(),
|
|
1516
1393
|
Some("comms"),
|
|
1517
|
-
)
|
|
1518
|
-
|
|
1519
|
-
if !args.json {
|
|
1520
|
-
let json_tail = match &result.output {
|
|
1521
|
-
CmdOutput::Json(value) => serde_json::to_string_pretty(&sort_json(value))?,
|
|
1522
|
-
_ => String::new(),
|
|
1523
|
-
};
|
|
1524
|
-
return Ok(CmdResult {
|
|
1525
|
-
output: CmdOutput::Human(format!("{COMMS_BOUNDARY_TEXT}\n{json_tail}")),
|
|
1526
|
-
..result
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
return Ok(result);
|
|
1530
|
-
}
|
|
1531
|
-
let default_report = args.gate.is_none() && !args.cleanup_orphans && !args.fix_schema;
|
|
1532
|
-
let mut value = if matches!(args.gate, Some(DoctorGate::Orphans)) {
|
|
1394
|
+
)?
|
|
1395
|
+
} else if matches!(args.gate, Some(DoctorGate::Orphans)) {
|
|
1533
1396
|
crate::diagnose::orphans::orphan_gate_json(&args.workspace, args.fix, args.confirm)?
|
|
1534
1397
|
} else if args.cleanup_orphans {
|
|
1535
1398
|
crate::diagnose::orphans::cleanup_orphans_json(&args.workspace, args.confirm)?
|
|
1536
1399
|
} else if args.fix_schema {
|
|
1537
1400
|
diagnose_port::fix_schema(&args.workspace)?
|
|
1538
1401
|
} else {
|
|
1539
|
-
let
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
)
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1402
|
+
let value = diagnose_port::doctor(&args.workspace, args.spec.as_deref())?;
|
|
1403
|
+
unified_default_doctor_report(args, value)
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
if explicit_comms {
|
|
1407
|
+
if let Some(object) = value.as_object_mut() {
|
|
1408
|
+
// The comms probe uses a random run id internally for its disposable
|
|
1409
|
+
// fixture; the public doctor/diagnose report must be alias-stable.
|
|
1410
|
+
object.insert("run_id".to_string(), Value::String("doctor".to_string()));
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if !default_report {
|
|
1414
|
+
finalize_doctor_report(&mut value, false);
|
|
1415
|
+
}
|
|
1416
|
+
let result = crate::cli::triage::report(value, args.json, "doctor");
|
|
1417
|
+
Ok(result)
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
fn unified_default_doctor_report(args: &DoctorArgs, mut value: Value) -> Value {
|
|
1421
|
+
let team_key = args
|
|
1422
|
+
.team
|
|
1423
|
+
.as_deref()
|
|
1424
|
+
.filter(|team| !team.is_empty())
|
|
1425
|
+
.unwrap_or("current");
|
|
1426
|
+
let requested_workspace = args.workspace.to_string_lossy().to_string();
|
|
1427
|
+
let mut runtime = json!({
|
|
1428
|
+
"status": "not_present",
|
|
1429
|
+
"workspace": requested_workspace,
|
|
1430
|
+
"run_workspace": Value::Null,
|
|
1431
|
+
"team_key": team_key,
|
|
1432
|
+
"session_name": Value::Null,
|
|
1433
|
+
"leader_receiver": Value::Null,
|
|
1434
|
+
"agent_count": 0,
|
|
1435
|
+
"message_count": 0,
|
|
1436
|
+
"result_count": 0,
|
|
1437
|
+
});
|
|
1438
|
+
// Resolve explicit selectors and nested team directories before deciding
|
|
1439
|
+
// that runtime inspection is inapplicable.
|
|
1440
|
+
let selected = crate::state::selector::resolve_active_team_readonly(
|
|
1441
|
+
&args.workspace,
|
|
1442
|
+
args.team.as_deref(),
|
|
1443
|
+
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
1444
|
+
);
|
|
1445
|
+
let run_workspace = selected.as_ref().map(|selected| selected.run_workspace.clone())
|
|
1446
|
+
.unwrap_or_else(|_| crate::model::paths::canonical_run_workspace(&args.workspace)
|
|
1447
|
+
.unwrap_or_else(|_| args.workspace.clone()));
|
|
1448
|
+
let runtime_present = selected.as_ref().is_ok_and(|selected| {
|
|
1449
|
+
crate::cli::diagnose::workspace_has_existing_team_runtime(
|
|
1450
|
+
&selected.run_workspace,
|
|
1451
|
+
&selected.team_key,
|
|
1452
|
+
)
|
|
1453
|
+
});
|
|
1454
|
+
let db_present = crate::model::paths::runtime_dir(&run_workspace).join("team.db").exists();
|
|
1455
|
+
// One read-only observation supplies both issue classification and details.
|
|
1456
|
+
// A physical database must be checked even before state.json is created.
|
|
1457
|
+
let health = (runtime_present || db_present).then(|| {
|
|
1458
|
+
crate::coordinator::coordinator_health_read_only(
|
|
1459
|
+
&crate::coordinator::WorkspacePath::new(run_workspace.clone()),
|
|
1460
|
+
)
|
|
1461
|
+
});
|
|
1462
|
+
match selected {
|
|
1463
|
+
Ok(selected) => {
|
|
1464
|
+
if let Some(health) = health.as_ref().filter(|_| runtime_present) {
|
|
1465
|
+
let state = selected.state;
|
|
1466
|
+
let backend: Box<dyn crate::transport::Transport> =
|
|
1467
|
+
match crate::transport_factory::resolve_read_only_transport(
|
|
1468
|
+
&selected.run_workspace,
|
|
1469
|
+
Some(&state),
|
|
1470
|
+
crate::transport_factory::TransportPurpose::Diagnose,
|
|
1471
|
+
) {
|
|
1472
|
+
Ok(resolved) => resolved.backend,
|
|
1473
|
+
Err(_) => Box::new(crate::tmux_backend::TmuxBackend::for_workspace(
|
|
1474
|
+
&selected.run_workspace,
|
|
1475
|
+
)),
|
|
1476
|
+
};
|
|
1477
|
+
let (issues, repairs) = crate::cli::diagnose::diagnose_runtime_for_workspace(
|
|
1478
|
+
&selected.run_workspace,
|
|
1479
|
+
&state,
|
|
1480
|
+
backend.as_ref(),
|
|
1481
|
+
Some(selected.team_key.as_str()),
|
|
1482
|
+
health,
|
|
1483
|
+
);
|
|
1484
|
+
merge_report_array(&mut value, "issues", &issues);
|
|
1485
|
+
merge_report_array(&mut value, "suggested_repairs", &repairs);
|
|
1486
|
+
let run_workspace = selected.run_workspace.to_string_lossy().to_string();
|
|
1487
|
+
runtime = json!({
|
|
1488
|
+
"status": "present",
|
|
1489
|
+
"workspace": args.workspace.to_string_lossy().to_string(),
|
|
1490
|
+
"run_workspace": run_workspace,
|
|
1491
|
+
"team_key": selected.team_key,
|
|
1492
|
+
"session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
|
|
1493
|
+
"leader_receiver": state.get("leader_receiver").cloned().unwrap_or(Value::Null),
|
|
1494
|
+
"agent_count": state.get("agents").and_then(Value::as_object).map_or(0, serde_json::Map::len),
|
|
1495
|
+
"message_count": count_dir_entries(&selected.run_workspace.join(".team").join("messages")),
|
|
1496
|
+
"result_count": count_dir_entries(&selected.run_workspace.join(".team").join("results")),
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
Err(error) => {
|
|
1501
|
+
runtime["status"] = Value::String("unresolved".to_string());
|
|
1502
|
+
append_issue_repair(
|
|
1503
|
+
&mut value,
|
|
1504
|
+
"runtime_selection_failed",
|
|
1505
|
+
format!("unable to select runtime team: {error}"),
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if let Some(health) = health {
|
|
1510
|
+
if db_present && !health.schema.ok {
|
|
1511
|
+
append_issue_repair(
|
|
1512
|
+
&mut value,
|
|
1513
|
+
"coordinator_schema_incompatible",
|
|
1514
|
+
"team-agent doctor --fix-schema --json".to_string(),
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
value["coordinator"] = diagnose_port::coordinator_health_value(health);
|
|
1518
|
+
}
|
|
1519
|
+
if let Some(object) = value.as_object_mut() {
|
|
1520
|
+
object.insert("runtime".to_string(), runtime);
|
|
1521
|
+
object.insert("event_log".to_string(), Value::String(
|
|
1522
|
+
run_workspace.join(".team").join("logs").join("events.jsonl")
|
|
1523
|
+
.to_string_lossy().to_string(),
|
|
1524
|
+
));
|
|
1525
|
+
}
|
|
1526
|
+
finalize_doctor_report(&mut value, true);
|
|
1527
|
+
value
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
fn merge_report_array(report: &mut Value, key: &str, incoming: &Value) {
|
|
1531
|
+
let Some(items) = incoming.as_array() else {
|
|
1532
|
+
return;
|
|
1533
|
+
};
|
|
1534
|
+
let Some(object) = report.as_object_mut() else {
|
|
1535
|
+
return;
|
|
1536
|
+
};
|
|
1537
|
+
let target = object
|
|
1538
|
+
.entry(key.to_string())
|
|
1539
|
+
.or_insert_with(|| Value::Array(Vec::new()));
|
|
1540
|
+
if !target.is_array() {
|
|
1541
|
+
*target = Value::Array(Vec::new());
|
|
1542
|
+
}
|
|
1543
|
+
let target = target
|
|
1544
|
+
.as_array_mut()
|
|
1545
|
+
.expect("report array normalized before merge");
|
|
1546
|
+
for item in items {
|
|
1547
|
+
let identity = report_item_identity(item);
|
|
1548
|
+
if !target.iter().any(|existing| report_item_identity(existing) == identity) {
|
|
1549
|
+
target.push(item.clone());
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
fn report_item_identity(item: &Value) -> String {
|
|
1555
|
+
if let Some(text) = item.as_str() {
|
|
1556
|
+
return text.to_string();
|
|
1557
|
+
}
|
|
1558
|
+
let primary = ["id", "code", "issue", "action", "reason", "message"]
|
|
1559
|
+
.iter()
|
|
1560
|
+
.find_map(|key| item.get(*key).and_then(Value::as_str));
|
|
1561
|
+
let scope = ["workspace", "team", "agent", "path", "rule"]
|
|
1562
|
+
.iter()
|
|
1563
|
+
.filter_map(|key| item.get(*key).and_then(Value::as_str))
|
|
1564
|
+
.collect::<Vec<_>>();
|
|
1565
|
+
let line = item.get("line").and_then(Value::as_u64);
|
|
1566
|
+
if primary.is_some() || !scope.is_empty() || line.is_some() {
|
|
1567
|
+
if scope.is_empty() && line.is_none() {
|
|
1568
|
+
return primary.unwrap_or("object").to_string();
|
|
1569
|
+
}
|
|
1570
|
+
return format!(
|
|
1571
|
+
"{}|{}|{}",
|
|
1572
|
+
primary.unwrap_or("object"),
|
|
1573
|
+
scope.join("|"),
|
|
1574
|
+
line.map_or_else(String::new, |line| line.to_string())
|
|
1549
1575
|
);
|
|
1550
|
-
|
|
1576
|
+
}
|
|
1577
|
+
item.to_string()
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
fn append_issue_repair(report: &mut Value, issue: &str, repair: String) {
|
|
1581
|
+
merge_report_array(report, "issues", &json!([issue]));
|
|
1582
|
+
merge_report_array(
|
|
1583
|
+
report,
|
|
1584
|
+
"suggested_repairs",
|
|
1585
|
+
&json!([{"issue": issue, "action": repair}]),
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
fn finalize_doctor_report(report: &mut Value, default_report: bool) {
|
|
1590
|
+
let Some(object) = report.as_object_mut() else {
|
|
1591
|
+
*report = json!({
|
|
1592
|
+
"ok": false,
|
|
1593
|
+
"issues": ["invalid_doctor_report"],
|
|
1594
|
+
"suggested_repairs": [{"issue": "invalid_doctor_report"}],
|
|
1595
|
+
});
|
|
1596
|
+
return;
|
|
1597
|
+
};
|
|
1598
|
+
let runtime_status = object
|
|
1599
|
+
.get("runtime")
|
|
1600
|
+
.and_then(|runtime| runtime.get("status"))
|
|
1601
|
+
.and_then(Value::as_str)
|
|
1602
|
+
.unwrap_or("not_run")
|
|
1603
|
+
.to_string();
|
|
1604
|
+
let mut issues = object
|
|
1605
|
+
.remove("issues")
|
|
1606
|
+
.and_then(|value| value.as_array().cloned())
|
|
1607
|
+
.unwrap_or_default();
|
|
1608
|
+
let mut repairs = object
|
|
1609
|
+
.remove("suggested_repairs")
|
|
1610
|
+
.and_then(|value| value.as_array().cloned())
|
|
1611
|
+
.unwrap_or_default();
|
|
1612
|
+
let mut add = |issue: Value, repair: Option<Value>| {
|
|
1613
|
+
if !issues.iter().any(|existing| report_item_identity(existing) == report_item_identity(&issue)) {
|
|
1614
|
+
issues.push(issue);
|
|
1615
|
+
}
|
|
1616
|
+
if let Some(repair) = repair {
|
|
1617
|
+
if !repairs.iter().any(|existing| report_item_identity(existing) == report_item_identity(&repair)) {
|
|
1618
|
+
repairs.push(repair);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1551
1621
|
};
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
let
|
|
1555
|
-
.get("
|
|
1556
|
-
.and_then(Value::
|
|
1557
|
-
.
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
})
|
|
1622
|
+
|
|
1623
|
+
if !default_report && object.get("ok").and_then(Value::as_bool) == Some(false) {
|
|
1624
|
+
let status = object
|
|
1625
|
+
.get("status")
|
|
1626
|
+
.and_then(Value::as_str)
|
|
1627
|
+
.or_else(|| object.get("reason").and_then(Value::as_str))
|
|
1628
|
+
.unwrap_or("doctor_failed");
|
|
1629
|
+
let issue = if status == "failed" {
|
|
1630
|
+
object
|
|
1631
|
+
.get("gate")
|
|
1632
|
+
.and_then(Value::as_str)
|
|
1633
|
+
.map_or_else(|| "doctor_failed".to_string(), |gate| format!("{gate}_failed"))
|
|
1634
|
+
} else if status.ends_with("_failed") || status == "refused" {
|
|
1635
|
+
status.to_string()
|
|
1636
|
+
} else {
|
|
1637
|
+
format!("{status}_failed")
|
|
1638
|
+
};
|
|
1639
|
+
let repair = object
|
|
1640
|
+
.get("action")
|
|
1641
|
+
.or_else(|| object.get("next_action"))
|
|
1642
|
+
.or_else(|| object.get("reason"))
|
|
1643
|
+
.cloned();
|
|
1644
|
+
add(Value::String(issue), repair.map(|value| json!({"action": value})));
|
|
1645
|
+
}
|
|
1646
|
+
if object
|
|
1647
|
+
.get("workspace")
|
|
1648
|
+
.and_then(Value::as_str)
|
|
1649
|
+
.is_some_and(|workspace| !std::path::Path::new(workspace).is_dir())
|
|
1650
|
+
{
|
|
1651
|
+
add(
|
|
1652
|
+
json!("invalid_workspace"),
|
|
1653
|
+
Some(json!({"issue": "invalid_workspace", "action": "use an existing workspace"})),
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
if let Some(profile) = object.get("profile_smoke") {
|
|
1657
|
+
if profile.get("ok").and_then(Value::as_bool) == Some(false)
|
|
1658
|
+
&& profile.get("status").and_then(Value::as_str) != Some("legacy_team_invalid")
|
|
1659
|
+
{
|
|
1660
|
+
add(
|
|
1661
|
+
json!("profile_smoke_failed"),
|
|
1662
|
+
profile
|
|
1663
|
+
.get("next_action")
|
|
1664
|
+
.cloned()
|
|
1665
|
+
.map(|action| json!({"issue": "profile_smoke_failed", "action": action})),
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if let Some(grok) = object.get("grok_slot") {
|
|
1670
|
+
if grok.get("readable").and_then(Value::as_bool) == Some(false)
|
|
1671
|
+
|| grok.get("consistent").and_then(Value::as_bool) == Some(false)
|
|
1672
|
+
{
|
|
1673
|
+
add(
|
|
1674
|
+
json!("grok_slot_mismatch"),
|
|
1675
|
+
grok.get("reason").cloned().map(|reason| {
|
|
1676
|
+
json!({"issue": "grok_slot_mismatch", "action": reason})
|
|
1677
|
+
}),
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
if let Some(findings) = object
|
|
1682
|
+
.get("secret_scan")
|
|
1683
|
+
.and_then(|scan| scan.get("findings"))
|
|
1684
|
+
.and_then(Value::as_array)
|
|
1685
|
+
{
|
|
1686
|
+
for finding in findings {
|
|
1687
|
+
let Some(finding_object) = finding.as_object() else {
|
|
1688
|
+
continue;
|
|
1689
|
+
};
|
|
1690
|
+
let Some(rule) = finding_object.get("rule").and_then(Value::as_str) else {
|
|
1691
|
+
continue;
|
|
1692
|
+
};
|
|
1693
|
+
let path = finding_object
|
|
1694
|
+
.get("path")
|
|
1695
|
+
.and_then(Value::as_str)
|
|
1696
|
+
.unwrap_or("unknown");
|
|
1697
|
+
let line = finding_object
|
|
1698
|
+
.get("line")
|
|
1699
|
+
.and_then(Value::as_u64)
|
|
1700
|
+
.unwrap_or(0);
|
|
1701
|
+
let issue = json!({
|
|
1702
|
+
"id": "secret_scan_finding",
|
|
1703
|
+
"rule": rule,
|
|
1704
|
+
"path": path,
|
|
1705
|
+
"line": line,
|
|
1564
1706
|
});
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1707
|
+
add(
|
|
1708
|
+
issue,
|
|
1709
|
+
Some(json!({
|
|
1710
|
+
"issue": "secret_scan_finding",
|
|
1711
|
+
"rule": rule,
|
|
1712
|
+
"path": path,
|
|
1713
|
+
"line": line,
|
|
1714
|
+
"action": format!("remove secret finding at {path}:{line}"),
|
|
1715
|
+
})),
|
|
1569
1716
|
);
|
|
1570
1717
|
}
|
|
1571
1718
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
}
|
|
1719
|
+
if runtime_status == "unresolved" {
|
|
1720
|
+
add(
|
|
1721
|
+
json!("runtime_selection_failed"),
|
|
1722
|
+
Some(json!({"issue": "runtime_selection_failed", "action": "select an existing Team runtime"})),
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
if runtime_status == "present"
|
|
1726
|
+
&& object
|
|
1727
|
+
.get("coordinator")
|
|
1728
|
+
.and_then(|coordinator| coordinator.get("ok"))
|
|
1729
|
+
.and_then(Value::as_bool)
|
|
1730
|
+
== Some(false)
|
|
1731
|
+
{
|
|
1732
|
+
let reason = object
|
|
1733
|
+
.get("coordinator")
|
|
1734
|
+
.and_then(|coordinator| coordinator.get("metadata_mismatch_reason"))
|
|
1735
|
+
.cloned()
|
|
1736
|
+
.unwrap_or_else(|| json!("coordinator unavailable"));
|
|
1737
|
+
add(
|
|
1738
|
+
json!("coordinator_unavailable"),
|
|
1739
|
+
Some(json!({"issue": "coordinator_unavailable", "action": reason})),
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
object.insert("issues".to_string(), Value::Array(issues.clone()));
|
|
1743
|
+
object.insert("suggested_repairs".to_string(), Value::Array(repairs));
|
|
1744
|
+
object.insert("ok".to_string(), Value::Bool(issues.is_empty()));
|
|
1578
1745
|
}
|
|
1579
1746
|
|
|
1580
1747
|
#[cfg(test)]
|