fix: expose pi rust advanced diagnostics
This commit is contained in:
@@ -305,6 +305,7 @@ pub(crate) struct AiRuntimeResolvedModel {
|
||||
pub(crate) struct EffectiveAiRuntimePolicy {
|
||||
pub default_model: String,
|
||||
pub allowed_models: Vec<String>,
|
||||
pub pi_runtime: Value,
|
||||
pub enabled_skills: Vec<String>,
|
||||
pub enabled_skill_sources: Vec<String>,
|
||||
pub enabled_mcp_servers: Vec<String>,
|
||||
@@ -1997,6 +1998,11 @@ pub(crate) fn load_effective_ai_runtime_policy(
|
||||
EffectiveAiRuntimePolicy {
|
||||
default_model: normalize_model_ref(None, &default_model).unwrap_or(default_model),
|
||||
allowed_models,
|
||||
pi_runtime: model_policy
|
||||
.get("piRuntime")
|
||||
.or_else(|| model_policy.get("advancedRuntime"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({})),
|
||||
enabled_skills,
|
||||
enabled_skill_sources,
|
||||
enabled_mcp_servers,
|
||||
|
||||
@@ -605,6 +605,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/page-ai/pi/state", post(page_ai_pi::state))
|
||||
.route("/api/page-ai/pi/compact", post(page_ai_pi::compact))
|
||||
.route("/api/page-ai/pi/rpc-command", post(page_ai_pi::rpc_command))
|
||||
.route("/api/page-ai/pi/diagnostics", post(page_ai_pi::diagnostics))
|
||||
.route(
|
||||
"/api/page-ai/pi/queue-config",
|
||||
post(page_ai_pi::queue_config),
|
||||
|
||||
@@ -44,6 +44,7 @@ const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
|
||||
const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
|
||||
const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
|
||||
const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
|
||||
const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
|
||||
const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
|
||||
const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
|
||||
const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
|
||||
@@ -73,6 +74,7 @@ const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
|
||||
const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
|
||||
const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
|
||||
const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
|
||||
const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
|
||||
pub const PI_LAB_PROFILE: &str = "pi_lab";
|
||||
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
|
||||
|
||||
@@ -171,6 +173,30 @@ struct PiLabUiPendingResponse {
|
||||
expires_at_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PiLabAdvancedRuntimeConfig {
|
||||
extension_policy: Option<String>,
|
||||
repair_policy: Option<String>,
|
||||
session_durability: Option<String>,
|
||||
request_timeout_secs: Option<u64>,
|
||||
max_tool_iterations: Option<u64>,
|
||||
hide_cwd_in_prompt: bool,
|
||||
}
|
||||
|
||||
impl PiLabAdvancedRuntimeConfig {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
extension_policy: None,
|
||||
repair_policy: None,
|
||||
session_durability: None,
|
||||
request_timeout_secs: None,
|
||||
max_tool_iterations: None,
|
||||
hide_cwd_in_prompt: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PiLabToolReceipt {
|
||||
@@ -291,6 +317,19 @@ pub struct PiLabRpcCommandRequest {
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PiLabDiagnosticsRequest {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub params: Value,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub root_uri: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PiLabRenameSessionRequest {
|
||||
@@ -884,6 +923,7 @@ fn session_from_request(
|
||||
runtime_policy_snapshot =
|
||||
refresh_runtime_policy_permission_mode_value(runtime_policy_snapshot, permission_mode);
|
||||
}
|
||||
runtime_policy_snapshot = refresh_runtime_policy_advanced_config_value(runtime_policy_snapshot);
|
||||
let now = now_ms();
|
||||
Ok(PiLabSession {
|
||||
session_id: session_id.clone(),
|
||||
@@ -1032,6 +1072,7 @@ fn pi_lab_start_response(session: &PiLabSession, reused: bool) -> Value {
|
||||
"piExtensionToolNames": pi_extension_tool_names,
|
||||
"thinkingLevel": session.thinking_level,
|
||||
"permissionMode": session_permission_mode(session),
|
||||
"advancedRuntime": pi_lab_effective_advanced_runtime_config(session),
|
||||
"runtimeImplementation": runtime_impl,
|
||||
"runtimeBinary": runtime_binary,
|
||||
"runtimeAvailable": runtime_available,
|
||||
@@ -1040,6 +1081,164 @@ fn pi_lab_start_response(session: &PiLabSession, reused: bool) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn policy_string_value(policy: Option<&Value>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| policy.and_then(|value| value.get(*key)))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn policy_bool_value(policy: Option<&Value>, keys: &[&str]) -> Option<bool> {
|
||||
keys.iter()
|
||||
.find_map(|key| policy.and_then(|value| value.get(*key)))
|
||||
.and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn policy_u64_value(policy: Option<&Value>, keys: &[&str]) -> Option<u64> {
|
||||
keys.iter()
|
||||
.find_map(|key| policy.and_then(|value| value.get(*key)))
|
||||
.and_then(|value| {
|
||||
value.as_u64().or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn truthy_config_value(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_one_of(value: &str, allowed: &[&str]) -> Option<String> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
allowed
|
||||
.iter()
|
||||
.any(|allowed| *allowed == normalized)
|
||||
.then_some(normalized)
|
||||
}
|
||||
|
||||
fn pi_lab_advanced_runtime_policy(session: &PiLabSession) -> Option<&Value> {
|
||||
session.runtime_policy_snapshot.as_ref().and_then(|policy| {
|
||||
policy
|
||||
.get("piRuntime")
|
||||
.or_else(|| policy.get("advancedRuntime"))
|
||||
})
|
||||
}
|
||||
|
||||
fn pi_lab_effective_advanced_runtime_config(session: &PiLabSession) -> PiLabAdvancedRuntimeConfig {
|
||||
let policy = pi_lab_advanced_runtime_policy(session);
|
||||
let extension_policy = policy_string_value(policy, &["extensionPolicy", "extension_policy"])
|
||||
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_EXTENSION_POLICY"))
|
||||
.and_then(|value| normalize_one_of(&value, &["safe", "balanced", "permissive", "standard"]))
|
||||
.map(|value| {
|
||||
if value == "standard" {
|
||||
"balanced".to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
});
|
||||
let repair_policy = policy_string_value(policy, &["repairPolicy", "repair_policy"])
|
||||
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_REPAIR_POLICY"))
|
||||
.and_then(|value| {
|
||||
normalize_one_of(&value, &["off", "suggest", "auto-safe", "auto-strict"])
|
||||
});
|
||||
let session_durability =
|
||||
policy_string_value(policy, &["sessionDurability", "session_durability"])
|
||||
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_SESSION_DURABILITY"))
|
||||
.and_then(|value| normalize_one_of(&value, &["strict", "balanced", "throughput"]));
|
||||
let request_timeout_secs =
|
||||
policy_u64_value(policy, &["requestTimeoutSecs", "request_timeout_secs"])
|
||||
.or_else(|| {
|
||||
env_trimmed("MNOTE_PAGE_AI_PI_REQUEST_TIMEOUT_SECS")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
.filter(|value| *value <= 86_400);
|
||||
let max_tool_iterations =
|
||||
policy_u64_value(policy, &["maxToolIterations", "max_tool_iterations"])
|
||||
.or_else(|| {
|
||||
env_trimmed("MNOTE_PAGE_AI_PI_MAX_TOOL_ITERATIONS")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
.filter(|value| (1..=1000).contains(value));
|
||||
let hide_cwd_in_prompt = policy_bool_value(policy, &["hideCwdInPrompt", "hide_cwd_in_prompt"])
|
||||
.or_else(|| {
|
||||
env_trimmed("MNOTE_PAGE_AI_PI_HIDE_CWD_IN_PROMPT")
|
||||
.map(|value| truthy_config_value(&value))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
PiLabAdvancedRuntimeConfig {
|
||||
extension_policy,
|
||||
repair_policy,
|
||||
session_durability,
|
||||
request_timeout_secs,
|
||||
max_tool_iterations,
|
||||
hide_cwd_in_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_runtime_policy_advanced_config_value(mut policy: Value) -> Value {
|
||||
let session = PiLabSession {
|
||||
session_id: String::new(),
|
||||
mnote_user_id: String::new(),
|
||||
bridge_token: String::new(),
|
||||
status: PiLabSessionStatus::Idle,
|
||||
provider_session_id: String::new(),
|
||||
pi_session_dir: String::new(),
|
||||
pi_session_file: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
page_path: None,
|
||||
page_title: None,
|
||||
model_provider: None,
|
||||
model_id: None,
|
||||
thinking_level: None,
|
||||
allowed_roots_snapshot: None,
|
||||
runtime_policy_snapshot: Some(policy.clone()),
|
||||
runtime_pid: None,
|
||||
runtime_mode: String::new(),
|
||||
runtime_error: None,
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
message_count: 0,
|
||||
};
|
||||
let config = pi_lab_effective_advanced_runtime_config(&session);
|
||||
policy["piRuntime"] = serde_json::to_value(config).unwrap_or_else(|_| json!({}));
|
||||
policy
|
||||
}
|
||||
|
||||
fn append_pi_lab_advanced_runtime_cli_args(
|
||||
command: &mut Command,
|
||||
config: &PiLabAdvancedRuntimeConfig,
|
||||
) {
|
||||
if let Some(value) = config.extension_policy.as_deref() {
|
||||
command.arg("--extension-policy").arg(value);
|
||||
}
|
||||
if let Some(value) = config.repair_policy.as_deref() {
|
||||
command.arg("--repair-policy").arg(value);
|
||||
}
|
||||
if let Some(value) = config.session_durability.as_deref() {
|
||||
command.arg("--session-durability").arg(value);
|
||||
}
|
||||
if let Some(value) = config.request_timeout_secs {
|
||||
command.arg("--request-timeout").arg(value.to_string());
|
||||
}
|
||||
if let Some(value) = config.max_tool_iterations {
|
||||
command.arg("--max-tool-iterations").arg(value.to_string());
|
||||
}
|
||||
if config.hide_cwd_in_prompt {
|
||||
command.arg("--hide-cwd-in-prompt");
|
||||
}
|
||||
}
|
||||
|
||||
fn pi_mcp_extension_source() -> Option<String> {
|
||||
let value = env_trimmed("MNOTE_PAGE_AI_PI_MCP_EXTENSION")?;
|
||||
let normalized = value.to_ascii_lowercase();
|
||||
@@ -3143,6 +3342,7 @@ async fn start_runtime_for_session(
|
||||
)?;
|
||||
let bridge_base_url = pi_lab_public_base_url();
|
||||
let pi_extension_tool_names = pi_lab_runtime_pi_extension_tool_names(&session);
|
||||
let advanced_runtime_config = pi_lab_effective_advanced_runtime_config(&session);
|
||||
let mut pi_tool_names = pi_lab_extension_tool_names(&session);
|
||||
pi_tool_names.extend(pi_extension_tool_names.clone());
|
||||
pi_tool_names.extend(enabled_builtin_tools.clone());
|
||||
@@ -3174,6 +3374,7 @@ async fn start_runtime_for_session(
|
||||
"mcpConfigPath": mcp_config_path.clone(),
|
||||
"sharedMcpCachePath": shared_mcp_cache_path.clone(),
|
||||
"permissionConfigPath": permission_config_path.clone(),
|
||||
"advancedRuntime": advanced_runtime_config.clone(),
|
||||
"configuredPiExtensionSources": configured_extension_sources.clone(),
|
||||
"piExtensionSources": pi_extension_sources.clone(),
|
||||
"piExtensionToolNames": pi_extension_tool_names.clone(),
|
||||
@@ -3196,6 +3397,7 @@ async fn start_runtime_for_session(
|
||||
"mcpConfigPath": mcp_config_path,
|
||||
"sharedMcpCachePath": shared_mcp_cache_path,
|
||||
"permissionConfigPath": permission_config_path,
|
||||
"advancedRuntime": advanced_runtime_config,
|
||||
"configuredPiExtensionSources": configured_extension_sources,
|
||||
"piExtensionSources": pi_extension_sources,
|
||||
"piExtensionToolNames": pi_extension_tool_names,
|
||||
@@ -3248,6 +3450,7 @@ async fn start_runtime_for_session(
|
||||
for arg in pi_lab_skill_cli_args(&session) {
|
||||
command.arg(arg);
|
||||
}
|
||||
append_pi_lab_advanced_runtime_cli_args(&mut command, &advanced_runtime_config);
|
||||
if let Some(provider) = session
|
||||
.model_provider
|
||||
.as_deref()
|
||||
@@ -3391,6 +3594,7 @@ async fn start_runtime_for_session(
|
||||
"mnotePiExtension": mnote_pi_extension_path,
|
||||
"mcpConfigPath": mcp_config_path,
|
||||
"permissionConfigPath": permission_config_path,
|
||||
"advancedRuntime": advanced_runtime_config,
|
||||
"configuredPiExtensionSources": configured_extension_sources,
|
||||
"piExtensionSources": pi_extension_sources,
|
||||
"piExtensionToolNames": pi_extension_tool_names,
|
||||
@@ -3751,6 +3955,262 @@ fn build_pi_rpc_command(command_type: &str, params: Value) -> Result<Value, WebE
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn diagnostics_string_param(params: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| params.get(*key))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn diagnostics_u64_param(params: &Value, keys: &[&str]) -> Option<u64> {
|
||||
keys.iter()
|
||||
.find_map(|key| params.get(*key))
|
||||
.and_then(|value| {
|
||||
value.as_u64().or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn diagnostics_string_array_param(params: &Value, keys: &[&str]) -> Vec<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| params.get(*key))
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_diagnostics_output(bytes: &[u8]) -> (String, bool) {
|
||||
let truncated = bytes.len() > PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES;
|
||||
let output = if truncated {
|
||||
&bytes[..PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES]
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
(String::from_utf8_lossy(output).to_string(), truncated)
|
||||
}
|
||||
|
||||
fn safe_relative_or_absolute_path(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(trimmed);
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn build_pi_diagnostics_args(
|
||||
request: &PiLabDiagnosticsRequest,
|
||||
) -> Result<(Vec<String>, bool), WebError> {
|
||||
if !request.args.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_pi_lab_diagnostics_raw_args_not_allowed",
|
||||
"Pi diagnostics 不接受裸 args 透传;请使用 params 白名单字段",
|
||||
));
|
||||
}
|
||||
let command = request.command.trim().to_ascii_lowercase();
|
||||
let params = &request.params;
|
||||
let mut args = Vec::new();
|
||||
let mut mutates_cache = false;
|
||||
match command.as_str() {
|
||||
"doctor" => {
|
||||
args.push("doctor".to_string());
|
||||
let format =
|
||||
diagnostics_string_param(params, &["format"]).unwrap_or_else(|| "json".into());
|
||||
let normalized_format = normalize_one_of(&format, &["text", "json", "markdown"])
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_format",
|
||||
"doctor format 只能是 text/json/markdown",
|
||||
)
|
||||
})?;
|
||||
args.push("--format".into());
|
||||
args.push(normalized_format);
|
||||
if let Some(policy) =
|
||||
diagnostics_string_param(params, &["policy", "extensionPolicy", "extension_policy"])
|
||||
{
|
||||
let policy =
|
||||
normalize_one_of(&policy, &["safe", "balanced", "permissive", "standard"])
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_policy",
|
||||
"doctor policy 只能是 safe/balanced/permissive/standard",
|
||||
)
|
||||
})?;
|
||||
args.push("--policy".into());
|
||||
args.push(policy);
|
||||
}
|
||||
if let Some(only) = diagnostics_string_param(params, &["only"]) {
|
||||
let only = normalize_one_of(
|
||||
&only,
|
||||
&[
|
||||
"config",
|
||||
"dirs",
|
||||
"auth",
|
||||
"shell",
|
||||
"sessions",
|
||||
"swarm",
|
||||
"extensions",
|
||||
],
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_only",
|
||||
"doctor only 只能是 config/dirs/auth/shell/sessions/swarm/extensions",
|
||||
)
|
||||
})?;
|
||||
args.push("--only".into());
|
||||
args.push(only);
|
||||
}
|
||||
if let Some(path) =
|
||||
diagnostics_string_param(params, &["path", "extensionPath", "extension_path"])
|
||||
{
|
||||
let path = safe_relative_or_absolute_path(&path).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_path",
|
||||
"diagnostics path 不能为空、不能包含 NUL 或 ..",
|
||||
)
|
||||
})?;
|
||||
args.push(path);
|
||||
}
|
||||
}
|
||||
"context-preview" | "contextpreview" => {
|
||||
args.push("context-preview".to_string());
|
||||
let format =
|
||||
diagnostics_string_param(params, &["format"]).unwrap_or_else(|| "json".into());
|
||||
let normalized_format =
|
||||
normalize_one_of(&format, &["text", "json"]).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_format",
|
||||
"context-preview format 只能是 text/json",
|
||||
)
|
||||
})?;
|
||||
args.push("--format".into());
|
||||
args.push(normalized_format);
|
||||
if let Some(bead) = diagnostics_string_param(params, &["bead"]) {
|
||||
args.push("--bead".into());
|
||||
args.push(bead);
|
||||
}
|
||||
for changed_path in
|
||||
diagnostics_string_array_param(params, &["changedPaths", "changed_paths"])
|
||||
{
|
||||
let changed_path =
|
||||
safe_relative_or_absolute_path(&changed_path).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_path",
|
||||
"changedPath 不能为空、不能包含 NUL 或 ..",
|
||||
)
|
||||
})?;
|
||||
args.push("--changed-path".into());
|
||||
args.push(changed_path);
|
||||
}
|
||||
if let Some(failing_command) =
|
||||
diagnostics_string_param(params, &["failingCommand", "failing_command"])
|
||||
{
|
||||
args.push("--failing-command".into());
|
||||
args.push(failing_command);
|
||||
}
|
||||
if let Some(max_items) = diagnostics_u64_param(params, &["maxItems", "max_items"]) {
|
||||
if !(1..=100).contains(&max_items) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_limit",
|
||||
"context-preview maxItems 必须在 1..=100",
|
||||
));
|
||||
}
|
||||
args.push("--max-items".into());
|
||||
args.push(max_items.to_string());
|
||||
}
|
||||
if let Some(max_bytes) = diagnostics_u64_param(params, &["maxBytes", "max_bytes"]) {
|
||||
if !(1024..=1_048_576).contains(&max_bytes) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_limit",
|
||||
"context-preview maxBytes 必须在 1024..=1048576",
|
||||
));
|
||||
}
|
||||
args.push("--max-bytes".into());
|
||||
args.push(max_bytes.to_string());
|
||||
}
|
||||
let query = diagnostics_string_array_param(params, &["query", "queries"]);
|
||||
args.extend(query);
|
||||
}
|
||||
"list" => {
|
||||
args.push("list".to_string());
|
||||
}
|
||||
"info" => {
|
||||
args.push("info".to_string());
|
||||
let name = diagnostics_string_param(params, &["name", "id"]).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_diagnostics_name_required",
|
||||
"info 需要 name 或 id",
|
||||
)
|
||||
})?;
|
||||
args.push(name);
|
||||
}
|
||||
"search" => {
|
||||
args.push("search".to_string());
|
||||
let query = diagnostics_string_param(params, &["query", "q"]).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_diagnostics_query_required",
|
||||
"search 需要 query",
|
||||
)
|
||||
})?;
|
||||
if let Some(tag) = diagnostics_string_param(params, &["tag"]) {
|
||||
args.push("--tag".into());
|
||||
args.push(tag);
|
||||
}
|
||||
if let Some(sort) = diagnostics_string_param(params, &["sort"]) {
|
||||
let sort = normalize_one_of(&sort, &["relevance", "name"]).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_sort",
|
||||
"search sort 只能是 relevance/name",
|
||||
)
|
||||
})?;
|
||||
args.push("--sort".into());
|
||||
args.push(sort);
|
||||
}
|
||||
if let Some(limit) = diagnostics_u64_param(params, &["limit"]) {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_pi_lab_invalid_diagnostics_limit",
|
||||
"search limit 必须在 1..=100",
|
||||
));
|
||||
}
|
||||
args.push("--limit".into());
|
||||
args.push(limit.to_string());
|
||||
}
|
||||
args.push(query);
|
||||
}
|
||||
"update-index" | "updateindex" => {
|
||||
args.push("update-index".to_string());
|
||||
mutates_cache = true;
|
||||
}
|
||||
_ => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_pi_lab_diagnostics_command_not_allowed",
|
||||
"Pi diagnostics 只允许 doctor/context-preview/list/info/search/update-index",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok((args, mutates_cache))
|
||||
}
|
||||
|
||||
fn apply_text_operations(current: &str, operations: &Value) -> Result<String, WebError> {
|
||||
match operations {
|
||||
Value::Array(ops) => {
|
||||
@@ -5034,6 +5494,7 @@ pub async fn status(
|
||||
"defaultModelId": default_model_id(),
|
||||
"defaultThinkingLevel": default_thinking_level(),
|
||||
"permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)),
|
||||
"advancedRuntime": current_session.as_ref().map(pi_lab_effective_advanced_runtime_config).unwrap_or_else(PiLabAdvancedRuntimeConfig::empty),
|
||||
"omnirouteBaseUrl": omniroute_base_url(),
|
||||
"piExtensions": current_session
|
||||
.as_ref()
|
||||
@@ -5613,6 +6074,82 @@ pub async fn rpc_command(
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn diagnostics(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<PiLabDiagnosticsRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_enabled(&state)?;
|
||||
cleanup_expired_sessions();
|
||||
ensure_authenticated(&state, &context)?;
|
||||
let (args, mutates_cache) = build_pi_diagnostics_args(&request)?;
|
||||
let binary = pi_binary();
|
||||
if !pi_runtime_binary_available(&binary) {
|
||||
let runtime_impl = pi_runtime_impl();
|
||||
let message = pi_runtime_install_hint(&runtime_impl, &binary)
|
||||
.unwrap_or_else(|| format!("Pi runtime 不可用: {binary}"));
|
||||
return Err(
|
||||
WebError::bad_gateway_code("page_ai_pi_lab_runtime_missing", message).with_details(
|
||||
json!({
|
||||
"runtimeImplementation": runtime_impl,
|
||||
"runtimeBinary": binary,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
let cwd = active_allowed_roots(&state, &context)?
|
||||
.into_iter()
|
||||
.find(|root| {
|
||||
request
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.is_none_or(|root_uri| root.root_uri.as_deref() == Some(root_uri))
|
||||
&& request
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.is_none_or(|workspace_id| root.workspace_id.as_deref() == Some(workspace_id))
|
||||
})
|
||||
.map(|root| root.root_path)
|
||||
.or_else(|| std::env::current_dir().ok());
|
||||
let timeout_ms = request.timeout_ms.unwrap_or(15_000).clamp(1_000, 120_000);
|
||||
let mut command = Command::new(&binary);
|
||||
command
|
||||
.args(&args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(cwd) = cwd.as_deref() {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
let output = tokio::time::timeout(Duration::from_millis(timeout_ms), command.output())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
WebError::new(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"page_ai_pi_lab_diagnostics_timeout",
|
||||
format!("Pi diagnostics 超时: {}", request.command.trim()),
|
||||
)
|
||||
})?
|
||||
.map_err(|error| WebError::internal(format!("执行 Pi diagnostics 失败: {error}")))?;
|
||||
let (stdout, stdout_truncated) = truncate_diagnostics_output(&output.stdout);
|
||||
let (stderr, stderr_truncated) = truncate_diagnostics_output(&output.stderr);
|
||||
Ok(Json(json!({
|
||||
"ok": output.status.success(),
|
||||
"schema": PI_LAB_SCHEMA_DIAGNOSTICS,
|
||||
"command": args.first().cloned().unwrap_or_else(|| request.command.trim().to_string()),
|
||||
"args": args,
|
||||
"mutatesCache": mutates_cache,
|
||||
"runtimeBinary": binary,
|
||||
"cwd": cwd.map(|path| path.to_string_lossy().to_string()),
|
||||
"statusCode": output.status.code(),
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"stdoutTruncated": stdout_truncated,
|
||||
"stderrTruncated": stderr_truncated,
|
||||
"timeoutMs": timeout_ms,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/page-ai/pi/state
|
||||
/// 封装官方 Pi RPC `get_state`。
|
||||
/// mock 返回完整假数据;real 发送 get_state RPC 并等待响应,超时降级。
|
||||
@@ -8941,6 +9478,96 @@ mod tests {
|
||||
assert!(!source.contains(".arg(\"--no-context-files\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_runtime_config_accepts_policy_and_env_fallbacks() {
|
||||
std::env::set_var("MNOTE_PAGE_AI_PI_REPAIR_POLICY", "auto-safe");
|
||||
let root = temp_root("mnote-pi-advanced-runtime");
|
||||
let mut session = permission_mode_test_session(&root, "full_access");
|
||||
session.runtime_policy_snapshot = Some(json!({
|
||||
"piRuntime": {
|
||||
"extensionPolicy": "permissive",
|
||||
"sessionDurability": "throughput",
|
||||
"requestTimeoutSecs": 120,
|
||||
"maxToolIterations": 80,
|
||||
"hideCwdInPrompt": true
|
||||
}
|
||||
}));
|
||||
|
||||
let config = pi_lab_effective_advanced_runtime_config(&session);
|
||||
assert_eq!(config.extension_policy.as_deref(), Some("permissive"));
|
||||
assert_eq!(config.repair_policy.as_deref(), Some("auto-safe"));
|
||||
assert_eq!(config.session_durability.as_deref(), Some("throughput"));
|
||||
assert_eq!(config.request_timeout_secs, Some(120));
|
||||
assert_eq!(config.max_tool_iterations, Some(80));
|
||||
assert!(config.hide_cwd_in_prompt);
|
||||
std::env::remove_var("MNOTE_PAGE_AI_PI_REPAIR_POLICY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_builder_allows_only_safe_official_commands() {
|
||||
let doctor = PiLabDiagnosticsRequest {
|
||||
command: "doctor".into(),
|
||||
params: json!({"only": "sessions", "policy": "balanced"}),
|
||||
args: Vec::new(),
|
||||
timeout_ms: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
};
|
||||
let (args, mutates_cache) = build_pi_diagnostics_args(&doctor).expect("doctor args");
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["doctor", "--format", "json", "--policy", "balanced", "--only", "sessions"]
|
||||
);
|
||||
assert!(!mutates_cache);
|
||||
|
||||
let search = PiLabDiagnosticsRequest {
|
||||
command: "search".into(),
|
||||
params: json!({"query": "git", "sort": "name", "limit": 10}),
|
||||
args: Vec::new(),
|
||||
timeout_ms: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
};
|
||||
let (args, _) = build_pi_diagnostics_args(&search).expect("search args");
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["search", "--sort", "name", "--limit", "10", "git"]
|
||||
);
|
||||
|
||||
let update_index = PiLabDiagnosticsRequest {
|
||||
command: "updateIndex".into(),
|
||||
params: json!({}),
|
||||
args: Vec::new(),
|
||||
timeout_ms: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
};
|
||||
let (args, mutates_cache) =
|
||||
build_pi_diagnostics_args(&update_index).expect("update-index args");
|
||||
assert_eq!(args, vec!["update-index"]);
|
||||
assert!(mutates_cache);
|
||||
|
||||
let denied = PiLabDiagnosticsRequest {
|
||||
command: "install".into(),
|
||||
params: json!({"source": "npm:unsafe"}),
|
||||
args: Vec::new(),
|
||||
timeout_ms: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
};
|
||||
assert!(build_pi_diagnostics_args(&denied).is_err());
|
||||
|
||||
let raw_args_denied = PiLabDiagnosticsRequest {
|
||||
command: "doctor".into(),
|
||||
params: json!({}),
|
||||
args: vec!["--fix".into()],
|
||||
timeout_ms: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
};
|
||||
assert!(build_pi_diagnostics_args(&raw_args_denied).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_filters_by_current_user_and_pi_profile() {
|
||||
let app = test_app();
|
||||
|
||||
Reference in New Issue
Block a user