diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py index 41eea7ca1..575d978a0 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/handlers.py @@ -1,32 +1,51 @@ +import asyncio +import base64 import json import math -import os import re import sqlite3 import time -from collections import Counter +from collections import Counter, OrderedDict from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path +from threading import RLock DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") -SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -CONTRACTS_ROOT = Path(__file__).resolve().parents[4] -ACCESS_CONFIG_PATH = CONTRACTS_ROOT / "access" / "soc_alerts.json" -DEFAULT_DATA_SOURCE = "sqlite" DEFAULT_SQLITE_DB = Path.home() / ".flocks" / "data" / "soc.db" DEFAULT_SQLITE_TABLE = "alert_records" DEFAULT_SQLITE_RECORD_COLUMN = "record_json" DEFAULT_SQLITE_DATE_COLUMN = "asset_date" DEFAULT_SQLITE_EVENT_TIME_COLUMN = "event_time" -SIMULATED_TIME_BUCKETS = 24 +FACTS_TABLE = "soc_dashboard_alert_facts" +ACTIVITY_TABLE = "soc_dashboard_activity" +META_TABLE = "soc_dashboard_meta" +SCHEMA_VERSION = "2" +ACTIVITY_DEFAULT_LIMIT = 20 +ACTIVITY_MAX_LIMIT = 50 +ACTIVITY_WINDOW_MS = 3000 +ACTIVITY_NORMAL_LIMIT = 5 +ACTIVITY_SURGE_LIMIT = 100 +ACTIVITY_RETENTION_ROWS = 100_000 +ACTIVITY_PRUNE_INTERVAL = 3600.0 WORKFLOW_DB = Path.home() / ".flocks" / "data" / "workflow.db" +WORKFLOW_SNAPSHOT_TABLE = "soc_dashboard_workflow_stats_samples" -_workflow_stats_cache: dict = {} -_cache_updated_at: float = 0 +_workflow_stats_cache: OrderedDict = OrderedDict() _CACHE_TTL: float = 30.0 +_WORKFLOW_CACHE_MAX = 64 +_denoise_detail_cache: OrderedDict = OrderedDict() +_DENOISE_DETAIL_CACHE_TTL = 300.0 +_DENOISE_DETAIL_CACHE_MAX = 64 +_stats_response_cache: OrderedDict = OrderedDict() +_STATS_RESPONSE_CACHE_TTL = 300.0 +_STATS_RESPONSE_CACHE_MAX = 32 +_cache_lock = RLock() +_schema_lock = RLock() +_schema_ready: set = set() +_activity_pruned_at: float = 0 @dataclass(frozen=True) @@ -36,71 +55,901 @@ class _RecordSource: date: str data_source: str record_count: int = 0 + start_time: int = 0 + end_time: int = 0 + + +_FACT_COLUMNS = ( + "alert_row_id", + "row_key", + "asset_date", + "event_time", + "source_type", + "threat_name", + "is_duplicate", + "phase", + "direction", + "result", + "protocol", + "severity", + "response_code", + "port", + "has_triage", + "triage_persisted_at", + "triage_status", + "triage_source", + "verdict", + "risk_level", + "triage_ms", + "attack_success", +) + + +def _json_value(prefix, path): + return ( + f"CASE WHEN json_valid({prefix}.record_json) " + f"THEN json_extract({prefix}.record_json, '$.{path}') END" + ) + + +def _fact_expressions(prefix): + source_type = _json_value(prefix, "_source_type") + source_type_fallback = _json_value(prefix, "source_type") + threat_name = _json_value(prefix, "threat_name") + threat_type = _json_value(prefix, "_threat_type") + phase = _json_value(prefix, "threat_phase") + attack_phase = _json_value(prefix, "attack_phase") + kill_chain = _json_value(prefix, "kill_chain_phase") + direction = _json_value(prefix, "direction") + traffic_direction = _json_value(prefix, "traffic_direction") + result = _json_value(prefix, "threat_result") + verdict = _json_value(prefix, "attack_verdict") + protocol = _json_value(prefix, "net_type") + app_protocol = _json_value(prefix, "net_app_proto") + protocol_fallback = _json_value(prefix, "protocol") + severity = _json_value(prefix, "threat_severity") + threat_level = _json_value(prefix, "threat_level") + risk_level = _json_value(prefix, "risk_level") + response = _json_value(prefix, "rsp_status_code") + status_code = _json_value(prefix, "status_code") + destination_port = _json_value(prefix, "dport") + destination_port_fallback = _json_value(prefix, "dst_port") + destination_port_legacy = _json_value(prefix, "destination_port") + triage_persisted_at = _json_value(prefix, "_triage_persisted_at") + triage_status = _json_value(prefix, "triage_status") + triage_source = _json_value(prefix, "triage_source") + triage_report = _json_value(prefix, "triage_report") + triage_ms = _json_value(prefix, "triage_ms") + attack_success = _json_value(prefix, "attack_success") + has_triage = ( + "CASE WHEN " + f"NULLIF({triage_status}, '') IS NOT NULL " + f"OR NULLIF({triage_persisted_at}, '') IS NOT NULL " + f"OR {triage_report} IS NOT NULL THEN 1 ELSE 0 END" + ) + return ( + f"{prefix}.rowid", + f"COALESCE(NULLIF({prefix}.row_id, ''), CAST({prefix}.rowid AS TEXT))", + f"{prefix}.asset_date", + f"{prefix}.event_time", + f"COALESCE(NULLIF({prefix}.source_type, ''), NULLIF({source_type}, ''), " + f"NULLIF({source_type_fallback}, ''), 'unknown')", + f"COALESCE(NULLIF({prefix}.threat_name, ''), NULLIF({threat_name}, ''), " + f"NULLIF({threat_type}, ''), 'unknown')", + f"COALESCE({prefix}.is_duplicate, 0)", + f"COALESCE(NULLIF({phase}, ''), NULLIF({attack_phase}, ''), NULLIF({kill_chain}, ''), 'unknown')", + f"COALESCE(NULLIF({direction}, ''), NULLIF({traffic_direction}, ''), 'unknown')", + f"COALESCE(NULLIF({result}, ''), NULLIF({verdict}, ''), 'unknown')", + f"COALESCE(NULLIF({protocol}, ''), NULLIF({app_protocol}, ''), " + f"NULLIF({protocol_fallback}, ''), 'unknown')", + f"COALESCE(NULLIF({severity}, ''), NULLIF({threat_level}, ''), NULLIF({risk_level}, ''), 'unknown')", + f"COALESCE(NULLIF({response}, ''), NULLIF({status_code}, ''), 'unknown')", + f"COALESCE(NULLIF({destination_port}, ''), NULLIF({destination_port_fallback}, ''), " + f"NULLIF({destination_port_legacy}, ''), 'unknown')", + has_triage, + f"COALESCE({triage_persisted_at}, '')", + f"COALESCE({triage_status}, '')", + f"COALESCE({triage_source}, '')", + f"COALESCE({verdict}, 'unknown')", + f"COALESCE({risk_level}, {threat_level}, {severity}, 'unknown')", + f"COALESCE(CAST({triage_ms} AS INTEGER), 0)", + f"CASE WHEN {attack_success} IN (1, '1', 'true') THEN 1 ELSE 0 END", + ) + + +def _fact_upsert_sql(prefix): + columns = ", ".join(_FACT_COLUMNS) + values = ", ".join(_fact_expressions(prefix)) + return f"INSERT OR REPLACE INTO {FACTS_TABLE} ({columns}) VALUES ({values})" + + +def _fact_backfill_sql(): + columns = ", ".join(_FACT_COLUMNS) + values = ", ".join(_fact_expressions("source")) + return ( + f"INSERT OR REPLACE INTO {FACTS_TABLE} ({columns}) " + f"SELECT {values} FROM {DEFAULT_SQLITE_TABLE} AS source" + ) + + +def _triage_marker(prefix): + status_value = _json_value(prefix, "triage_status") + persisted_value = _json_value(prefix, "_triage_persisted_at") + report_value = _json_value(prefix, "triage_report") + return ( + f"(NULLIF({status_value}, '') IS NOT NULL " + f"OR NULLIF({persisted_value}, '') IS NOT NULL " + f"OR {report_value} IS NOT NULL)" + ) + + +def _column_exists(conn, table_name, column_name): + return any( + str(row[1]) == column_name + for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall() + ) + + +def _add_column_if_missing(conn, table_name, column_name, column_definition): + if not _column_exists(conn, table_name, column_name): + conn.execute( + f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_definition}" + ) + + +def _drop_dashboard_triggers(conn): + for trigger_name in ( + "soc_dashboard_fact_insert", + "soc_dashboard_fact_update", + "soc_dashboard_fact_delete", + ): + conn.execute(f"DROP TRIGGER IF EXISTS {trigger_name}") + + +def _ensure_alert_record_columns(conn): + for column_name, column_definition in ( + ("row_id", "TEXT"), + ("record_id", "TEXT"), + ("source_file", "TEXT"), + ("line_number", "INTEGER"), + ("source_type", "TEXT"), + ("threat_name", "TEXT"), + ("is_duplicate", "INTEGER DEFAULT 0"), + ): + _add_column_if_missing( + conn, + DEFAULT_SQLITE_TABLE, + column_name, + column_definition, + ) + conn.execute( + f"UPDATE {DEFAULT_SQLITE_TABLE} " + f"SET row_id = CAST(rowid AS TEXT) " + f"WHERE row_id IS NULL OR row_id = ''" + ) + + +def _create_dashboard_triggers(conn): + insert_fact = _fact_upsert_sql("NEW") + new_row_key = _fact_expressions("NEW")[1] + new_persisted = _json_value("NEW", "_triage_persisted_at") + old_persisted = _json_value("OLD", "_triage_persisted_at") + new_status = _json_value("NEW", "triage_status") + old_status = _json_value("OLD", "triage_status") + new_report = _json_value("NEW", "triage_report") + old_report = _json_value("OLD", "triage_report") + conn.execute( + f""" + CREATE TRIGGER IF NOT EXISTS soc_dashboard_fact_insert + AFTER INSERT ON {DEFAULT_SQLITE_TABLE} + BEGIN + {insert_fact}; + INSERT OR IGNORE INTO {ACTIVITY_TABLE} ( + event_key, alert_row_id, record_row_id, asset_date, event_time, record_json + ) + SELECT COALESCE(NULLIF(NEW.row_id, ''), CAST(NEW.rowid AS TEXT)) || + ':insert:' || COALESCE({new_persisted}, {new_status}, CAST(NEW.rowid AS TEXT)), + NEW.rowid, COALESCE(NULLIF(NEW.row_id, ''), CAST(NEW.rowid AS TEXT)), + NEW.asset_date, NEW.event_time, NEW.record_json + WHERE {_triage_marker('NEW')}; + END + """ + ) + conn.execute( + f""" + CREATE TRIGGER IF NOT EXISTS soc_dashboard_fact_update + AFTER UPDATE OF row_id, asset_date, event_time, source_type, threat_name, is_duplicate, record_json + ON {DEFAULT_SQLITE_TABLE} + BEGIN + DELETE FROM {FACTS_TABLE} + WHERE alert_row_id = NEW.rowid OR row_key = {new_row_key}; + {insert_fact}; + INSERT OR IGNORE INTO {ACTIVITY_TABLE} ( + event_key, alert_row_id, record_row_id, asset_date, event_time, record_json + ) + SELECT COALESCE(NULLIF(NEW.row_id, ''), CAST(NEW.rowid AS TEXT)) || + ':update:' || strftime('%s', 'now') || ':' || lower(hex(randomblob(6))), + NEW.rowid, COALESCE(NULLIF(NEW.row_id, ''), CAST(NEW.rowid AS TEXT)), + NEW.asset_date, NEW.event_time, NEW.record_json + WHERE {_triage_marker('NEW')} + AND NEW.record_json <> OLD.record_json + AND ( + COALESCE({new_persisted}, '') <> COALESCE({old_persisted}, '') + OR COALESCE({new_status}, '') <> COALESCE({old_status}, '') + OR COALESCE({new_report}, '') <> COALESCE({old_report}, '') + ); + END + """ + ) + conn.execute( + f""" + CREATE TRIGGER IF NOT EXISTS soc_dashboard_fact_delete + AFTER DELETE ON {DEFAULT_SQLITE_TABLE} + BEGIN + DELETE FROM {FACTS_TABLE} WHERE alert_row_id = OLD.rowid; + END + """ + ) + + +def _ensure_sqlite_schema(): + db_path = DEFAULT_SQLITE_DB + if not db_path.is_file(): + return False + stat = db_path.stat() + identity = (str(db_path), getattr(stat, "st_ino", 0)) + if identity in _schema_ready: + return True + with _schema_lock: + if identity in _schema_ready: + return True + with sqlite3.connect(db_path, timeout=30) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (DEFAULT_SQLITE_TABLE,), + ).fetchone() + if not exists: + return False + _drop_dashboard_triggers(conn) + _ensure_alert_record_columns(conn) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_alert_records_asset_event " + f"ON {DEFAULT_SQLITE_TABLE}(asset_date, event_time)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_alert_records_event_time " + f"ON {DEFAULT_SQLITE_TABLE}(event_time)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_alert_records_asset_date " + f"ON {DEFAULT_SQLITE_TABLE}(asset_date)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_alert_records_source_type " + f"ON {DEFAULT_SQLITE_TABLE}(source_type)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_alert_records_threat_name " + f"ON {DEFAULT_SQLITE_TABLE}(threat_name)" + ) + conn.execute( + f"CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_records_row_id " + f"ON {DEFAULT_SQLITE_TABLE}(row_id)" + ) + conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {META_TABLE} ( + meta_key TEXT PRIMARY KEY, + meta_value TEXT NOT NULL + ) + """ + ) + version_row = conn.execute( + f"SELECT meta_value FROM {META_TABLE} WHERE meta_key='schema_version'" + ).fetchone() + needs_rebuild = not version_row or str(version_row[0]) != SCHEMA_VERSION + if needs_rebuild: + conn.execute(f"DROP TABLE IF EXISTS {FACTS_TABLE}") + conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {FACTS_TABLE} ( + alert_row_id INTEGER PRIMARY KEY, + row_key TEXT NOT NULL UNIQUE, + asset_date TEXT NOT NULL, + event_time INTEGER, + source_type TEXT, + threat_name TEXT, + is_duplicate INTEGER NOT NULL DEFAULT 0, + phase TEXT, + direction TEXT, + result TEXT, + protocol TEXT, + severity TEXT, + response_code TEXT, + port TEXT, + has_triage INTEGER NOT NULL DEFAULT 0, + triage_persisted_at TEXT, + triage_status TEXT, + triage_source TEXT, + verdict TEXT, + risk_level TEXT, + triage_ms INTEGER NOT NULL DEFAULT 0, + attack_success INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_soc_dashboard_facts_asset_event " + f"ON {FACTS_TABLE}(asset_date, event_time)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_soc_dashboard_facts_event " + f"ON {FACTS_TABLE}(event_time, asset_date)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_soc_dashboard_facts_triage_event " + f"ON {FACTS_TABLE}(has_triage, asset_date, event_time)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_soc_dashboard_facts_triage_time " + f"ON {FACTS_TABLE}(has_triage, event_time, asset_date)" + ) + conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {ACTIVITY_TABLE} ( + activity_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + alert_row_id INTEGER NOT NULL, + record_row_id TEXT NOT NULL, + asset_date TEXT NOT NULL, + event_time INTEGER, + record_json TEXT NOT NULL + ) + """ + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_soc_dashboard_activity_event " + f"ON {ACTIVITY_TABLE}(asset_date, event_time, activity_id)" + ) + conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_soc_dashboard_activity_time " + f"ON {ACTIVITY_TABLE}(event_time, activity_id)" + ) + if needs_rebuild: + conn.execute(_fact_backfill_sql()) + persisted = _json_value("source", "_triage_persisted_at") + status_value = _json_value("source", "triage_status") + conn.execute( + f""" + INSERT OR IGNORE INTO {ACTIVITY_TABLE} ( + event_key, alert_row_id, record_row_id, asset_date, event_time, record_json + ) + SELECT COALESCE(NULLIF(source.row_id, ''), CAST(source.rowid AS TEXT)) || ':bootstrap:' || + COALESCE({persisted}, {status_value}, CAST(source.rowid AS TEXT)), + source.rowid, + COALESCE(NULLIF(source.row_id, ''), CAST(source.rowid AS TEXT)), + source.asset_date, + source.event_time, source.record_json + FROM {DEFAULT_SQLITE_TABLE} AS source + WHERE {_triage_marker('source')} + """ + ) + conn.execute( + f"INSERT OR REPLACE INTO {META_TABLE}(meta_key, meta_value) " + f"VALUES('schema_version', ?)", + (SCHEMA_VERSION,), + ) + _create_dashboard_triggers(conn) + conn.commit() + _schema_ready.add(identity) + return True + + +def _maybe_prune_activity(): + global _activity_pruned_at + now = time.monotonic() + if now - _activity_pruned_at < ACTIVITY_PRUNE_INTERVAL: + return + with _schema_lock: + if now - _activity_pruned_at < ACTIVITY_PRUNE_INTERVAL: + return + try: + with sqlite3.connect(DEFAULT_SQLITE_DB, timeout=5) as conn: + conn.execute( + f"DELETE FROM {ACTIVITY_TABLE} WHERE activity_id <= " + f"(SELECT COALESCE(MAX(activity_id), 0) - ? FROM {ACTIVITY_TABLE})", + (ACTIVITY_RETENTION_ROWS,), + ) + conn.commit() + except Exception: + pass + _activity_pruned_at = now -def _get_workflow_call_count(workflow_name: str, date: str = None) -> int: - global _workflow_stats_cache, _cache_updated_at +def _safe_json_object(value): + try: + parsed = json.loads(value or "{}") + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _workflow_stats_sample_deltas(conn, workflow_name, start_time=0, end_time=0): + stats_exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_stats'" + ).fetchone() + if not stats_exists: + return None + columns = { + row[1] for row in conn.execute("PRAGMA table_info(workflow_stats)").fetchall() + } + success_expr = "success_count" if "success_count" in columns else "0" + error_expr = "error_count" if "error_count" in columns else "0" + updated_expr = "updated_at" if "updated_at" in columns else "0" + current = conn.execute( + f"SELECT call_count, {success_expr}, {error_expr}, {updated_expr} " + "FROM workflow_stats WHERE workflow_id = ?", + (workflow_name,), + ).fetchone() + if current is None: + return None + + conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {WORKFLOW_SNAPSHOT_TABLE} ( + workflow_id TEXT NOT NULL, + sampled_at INTEGER NOT NULL, + call_count INTEGER NOT NULL, + success_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (workflow_id, sampled_at) + ) + """ + ) + last = conn.execute( + f"SELECT sampled_at, call_count, success_count, error_count " + f"FROM {WORKFLOW_SNAPSHOT_TABLE} WHERE workflow_id = ? " + "ORDER BY sampled_at DESC LIMIT 1", + (workflow_name,), + ).fetchone() + current_count = max(_safe_int(current[0]), 0) + success_count = max(_safe_int(current[1]), 0) + error_count = max(_safe_int(current[2]), 0) + if last is None or (current_count, success_count, error_count) != tuple(last[1:4]): + stats_updated_at = _safe_int(current[3]) or int(time.time() * 1000) + sampled_at = stats_updated_at - (stats_updated_at % 60000) + if last is not None and sampled_at <= _safe_int(last[0]): + sampled_at = _safe_int(last[0]) + conn.execute( + f"UPDATE {WORKFLOW_SNAPSHOT_TABLE} " + "SET call_count = ?, success_count = ?, error_count = ? " + "WHERE workflow_id = ? AND sampled_at = ?", + (current_count, success_count, error_count, workflow_name, sampled_at), + ) + else: + conn.execute( + f"INSERT INTO {WORKFLOW_SNAPSHOT_TABLE} " + "(workflow_id, sampled_at, call_count, success_count, error_count) " + "VALUES (?, ?, ?, ?, ?)", + (workflow_name, sampled_at, current_count, success_count, error_count), + ) + conn.commit() + last = (sampled_at, current_count, success_count, error_count) + + if not (start_time > 0 and end_time > 0): + sampled_at = max(_safe_int(current[3]), _safe_int(last[0] if last else 0)) + return [(current_count, success_count, error_count, sampled_at)] if current_count else [] + + start_ms = int(start_time * 1000) + end_ms = int(end_time * 1000) + previous = conn.execute( + f"SELECT call_count, success_count, error_count " + f"FROM {WORKFLOW_SNAPSHOT_TABLE} " + "WHERE workflow_id = ? AND sampled_at < ? " + "ORDER BY sampled_at DESC LIMIT 1", + (workflow_name, start_ms), + ).fetchone() + rows = conn.execute( + f"SELECT sampled_at, call_count, success_count, error_count " + f"FROM {WORKFLOW_SNAPSHOT_TABLE} " + "WHERE workflow_id = ? AND sampled_at >= ? AND sampled_at <= ? " + "ORDER BY sampled_at", + (workflow_name, start_ms, end_ms), + ).fetchall() + previous_counts = tuple(previous) if previous is not None else (0, 0, 0) + deltas = [] + for sampled_at, call_count, sample_success, sample_error in rows: + current_counts = ( + max(_safe_int(call_count), 0), + max(_safe_int(sample_success), 0), + max(_safe_int(sample_error), 0), + ) + delta_counts = tuple( + current_value - previous_value + if current_value >= previous_value + else current_value + for current_value, previous_value in zip(current_counts, previous_counts) + ) + if delta_counts[0] > 0: + deltas.append((*delta_counts, _safe_int(sampled_at))) + previous_counts = current_counts + return deltas + + +def _workflow_metric_value(stats, key, fallback=0): + value = stats.get(key) + return max(_safe_int(fallback if value is None else value), 0) + + +def _workflow_alert_preview(output): + for key in ("unique_alerts", "enriched_alerts"): + value = output.get(key) + if isinstance(value, dict): + value = value.get("preview") + if isinstance(value, list): + return next( + ( + item + for item in value + if isinstance(item, dict) and item.get("_type") != "dict" + ), + {}, + ) + return {} + + +def _workflow_input_preview(inputs): + syslog_message = inputs.get("syslog_message") or inputs.get("syslog") + if isinstance(syslog_message, dict): + message = syslog_message.get("message") + if isinstance(message, str): + parsed = _safe_json_object(message) + if parsed: + return parsed + alerts = inputs.get("alerts") or inputs.get("alert_list") + if isinstance(alerts, dict): + alerts = alerts.get("data") + if isinstance(alerts, list): + return next((item for item in alerts if isinstance(item, dict)), {}) + return {} + + +def _workflow_execution_metrics(output_text, input_text=""): + output = _safe_json_object(output_text) + inputs = _safe_json_object(input_text) + stats = output.get("stats") if isinstance(output.get("stats"), dict) else {} + duplicate_flag_available = isinstance(output.get("is_duplicate"), bool) + metrics_available = duplicate_flag_available or any( + stats.get(key) is not None + for key in ( + "raw_count", + "normalized_count", + "after_filter_count", + "after_dedup_count", + ) + ) + raw_count = _workflow_metric_value(stats, "raw_count", 1) + normalized_count = _workflow_metric_value(stats, "normalized_count", raw_count) + after_filter_count = _workflow_metric_value( + stats, + "after_filter_count", + normalized_count, + ) + unique_count = _workflow_metric_value( + stats, + "after_dedup_count", + after_filter_count, + ) + filter_removed_count = _workflow_metric_value( + stats, + "filter_removed_count", + max(normalized_count - after_filter_count, 0), + ) + duplicate_count = _workflow_metric_value( + stats, + "dedup_removed_count", + max(after_filter_count - unique_count, 0), + ) + is_duplicate = output.get("is_duplicate") is True + if ( + is_duplicate + and raw_count == 1 + and after_filter_count == 1 + and unique_count == 1 + and duplicate_count == 0 + ): + # Older streaming executions only counted duplicates within the current + # one-alert batch, even when cross-batch state marked it as duplicate. + unique_count = 0 + duplicate_count = 1 + reduced_count = ( + max(raw_count - unique_count, filter_removed_count + duplicate_count, 0) + if metrics_available + else 0 + ) + + source_counts = {} + raw_source_counts = stats.get("normalize_type_counts") + if isinstance(raw_source_counts, dict) and raw_source_counts.get("_type") != "dict": + source_counts = { + _norm(key): max(_safe_int(value), 0) + for key, value in raw_source_counts.items() + if max(_safe_int(value), 0) > 0 + } + preview = _workflow_alert_preview(output) or _workflow_input_preview(inputs) + syslog_message = inputs.get("syslog_message") or inputs.get("syslog") + syslog_app = syslog_message.get("app_name") if isinstance(syslog_message, dict) else "" + source_type = _norm( + preview.get("_source_type") + or inputs.get("source_log_type") + or output.get("source_log_type") + or output.get("input_mode") + or syslog_app + ) + if not source_counts and normalized_count > 0: + source_counts[source_type] = normalized_count + + return { + "metricsAvailable": metrics_available, + "rawCount": raw_count, + "normalizedCount": normalized_count, + "afterFilterCount": after_filter_count, + "uniqueCount": unique_count, + "filterRemovedCount": filter_removed_count, + "duplicateCount": duplicate_count, + "reducedCount": reduced_count, + "reductionRate": _ratio(reduced_count, raw_count) if metrics_available else 0, + "dedupRate": _ratio(duplicate_count, after_filter_count) if metrics_available else 0, + "clusterCount": _workflow_metric_value(stats, "lsh_total_clusters"), + "isDuplicate": is_duplicate, + "sourceCounts": source_counts, + "sourceType": source_type, + "preview": preview, + } + + +def _empty_workflow_denoise_stats(): + return { + "callCount": 0, + "successCount": 0, + "errorCount": 0, + "earliestStartedAt": 0, + "latestStartedAt": 0, + "rawCount": 0, + "normalizedCount": 0, + "afterFilterCount": 0, + "uniqueCount": 0, + "filterRemovedCount": 0, + "duplicateCount": 0, + "reducedCount": 0, + "reductionRate": 0, + "dedupRate": 0, + "sourceCounts": {}, + "seriesRaw": [], + "seriesUnique": [], + "timelineLabels": [], + "timelineWindow": "", + } + + +def _get_workflow_denoise_stats( + workflow_name: str, + start_time: int = 0, + end_time: int = 0, + *, + force: bool = False, +) -> dict: now = time.time() - cache_key = f"{workflow_name}:{date or 'total'}" + cache_key = f"denoise:{workflow_name}:{start_time or 0}:{end_time or 0}" - if now - _cache_updated_at < _CACHE_TTL and cache_key in _workflow_stats_cache: - return _workflow_stats_cache[cache_key] + with _cache_lock: + cached = _workflow_stats_cache.get(cache_key) + if not force and cached and now - float(cached.get("updatedAt") or 0) < _CACHE_TTL: + _workflow_stats_cache.move_to_end(cache_key) + return cached["value"] - empty = {"callCount": 0, "dupCount": 0, "uniqueCount": 0} + empty = _empty_workflow_denoise_stats() if not WORKFLOW_DB.is_file(): return empty try: - if date: - start = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) - end = start + 86400 * 1000 - with sqlite3.connect(WORKFLOW_DB) as conn: - rows = conn.execute( - """ - SELECT output_results - FROM workflow_executions - WHERE workflow_id = ? AND started_at >= ? AND started_at < ? - """, - (workflow_name, start, end), - ).fetchall() - dup_count = 0 - unique_count = 0 - for (output_text,) in rows: - try: - output = json.loads(output_text or "{}") - except Exception: - output = {} - stats = output.get("stats") if isinstance(output.get("stats"), dict) else {} - raw = _safe_int(stats.get("raw_count")) - if "after_dedup_count" in stats and stats.get("after_dedup_count") is not None: - unique_count += _safe_int(stats.get("after_dedup_count")) + with sqlite3.connect(WORKFLOW_DB) as conn: + sample_deltas = _workflow_stats_sample_deltas( + conn, + workflow_name, + start_time, + end_time, + ) + if sample_deltas is None: + query = ( + "SELECT status, started_at FROM workflow_executions " + "WHERE workflow_id = ?" + ) + query_params = [workflow_name] + if start_time > 0 and end_time > 0: + query += " AND started_at >= ? AND started_at <= ?" + query_params.extend((int(start_time * 1000), int(end_time * 1000))) + query += " ORDER BY started_at" + execution_rows = conn.execute(query, query_params).fetchall() + samples = [ + ( + 1, + 1 if str(status).lower() == "success" else 0, + 0 if str(status).lower() == "success" else 1, + _safe_int(started_at), + ) + for status, started_at in execution_rows + ] + else: + samples = sample_deltas + + result_dict = _empty_workflow_denoise_stats() + processed_total = sum(call_count for call_count, _, _, _ in samples) + if processed_total: + result_dict.update( + { + "callCount": processed_total, + "successCount": sum(success_count for _, success_count, _, _ in samples), + "errorCount": sum(error_count for _, _, error_count, _ in samples), + "earliestStartedAt": samples[0][3], + "latestStartedAt": samples[-1][3], + "rawCount": processed_total, + "normalizedCount": processed_total, + "afterFilterCount": processed_total, + "sourceCounts": {"ndr": processed_total}, + } + ) + first_started = samples[0][3] // 1000 + last_started = samples[-1][3] // 1000 + bucket_start, bucket_seconds, bucket_count, labels, window = _timeline_spec( + [], + start_time or first_started, + end_time or max(last_started, first_started), + ) + series_raw = [0] * bucket_count + for call_count, _, _, started_at in samples: + index = int(((started_at // 1000) - bucket_start) / bucket_seconds) + if 0 <= index < bucket_count: + series_raw[index] += call_count + result_dict["seriesRaw"] = series_raw + result_dict["timelineLabels"] = labels + result_dict["timelineWindow"] = window + + with _cache_lock: + _workflow_stats_cache[cache_key] = {"updatedAt": now, "value": result_dict} + _workflow_stats_cache.move_to_end(cache_key) + while len(_workflow_stats_cache) > _WORKFLOW_CACHE_MAX: + _workflow_stats_cache.popitem(last=False) + return result_dict + except Exception: + with _cache_lock: + cached = _workflow_stats_cache.get(cache_key) + return cached["value"] if cached else empty + + +def _get_workflow_progress( + workflow_name: str, + start_time: int = 0, + end_time: int = 0, +) -> dict: + unavailable = {"callCount": None, "latestStartedAt": None} + if not WORKFLOW_DB.is_file(): + return unavailable + + try: + with sqlite3.connect(WORKFLOW_DB) as conn: + if start_time > 0 and end_time > 0: + sample_deltas = _workflow_stats_sample_deltas( + conn, + workflow_name, + start_time, + end_time, + ) + if sample_deltas is None: + row = conn.execute( + "SELECT COUNT(*), COALESCE(MAX(started_at), 0) " + "FROM workflow_executions " + "WHERE workflow_id = ? AND started_at >= ? AND started_at <= ?", + (workflow_name, int(start_time * 1000), int(end_time * 1000)), + ).fetchone() else: - unique_count += raw - if output.get("is_duplicate") is True: - dup_count += 1 - result_dict = { - "callCount": len(rows), - "dupCount": dup_count, - "uniqueCount": unique_count, - } - else: - with sqlite3.connect(WORKFLOW_DB) as conn: + latest_row = conn.execute( + "SELECT COALESCE(MAX(started_at), 0) FROM workflow_executions " + "WHERE workflow_id = ? AND started_at >= ? AND started_at <= ?", + (workflow_name, int(start_time * 1000), int(end_time * 1000)), + ).fetchone() + latest_sample = sample_deltas[-1][3] if sample_deltas else 0 + row = ( + sum(max(_safe_int(item[0]), 0) for item in sample_deltas), + max(_safe_int(latest_row[0] if latest_row else 0), _safe_int(latest_sample)), + ) + else: row = conn.execute( "SELECT call_count FROM workflow_stats WHERE workflow_id = ?", (workflow_name,), ).fetchone() - result_dict = {"callCount": _safe_int(row[0] if row else 0), "dupCount": 0, "uniqueCount": 0} + except Exception: + return unavailable - _workflow_stats_cache[cache_key] = result_dict - _cache_updated_at = now - return result_dict + if row is None: + return {"callCount": 0, "latestStartedAt": 0} + return { + "callCount": max(_safe_int(row[0]), 0), + "latestStartedAt": max(_safe_int(row[1] if len(row) > 1 else 0), 0), + } + + +def _get_workflow_recent_events( + workflow_name: str, + start_time: int = 0, + end_time: int = 0, + limit: int = 10, +) -> list: + if not WORKFLOW_DB.is_file(): + return [] + query = ( + "SELECT id, status, started_at, output_results, input_params " + "FROM workflow_executions WHERE workflow_id = ?" + ) + query_params = [workflow_name] + if start_time > 0 and end_time > 0: + query += " AND started_at >= ? AND started_at <= ?" + query_params.extend((int(start_time * 1000), int(end_time * 1000))) + query += " ORDER BY started_at DESC LIMIT ?" + query_params.append(max(1, min(_safe_int(limit), 10))) + try: + with sqlite3.connect(WORKFLOW_DB) as conn: + rows = conn.execute(query, query_params).fetchall() except Exception: - return _workflow_stats_cache.get(cache_key, empty) + return [] + + events = [] + for execution_id, status, started_at, output_text, input_text in rows: + metrics = _workflow_execution_metrics(output_text, input_text) + preview = metrics["preview"] + raw_count = metrics["rawCount"] + unique_count = metrics["uniqueCount"] + threat_name = str( + preview.get("threat_name") + or preview.get("_threat_type") + or preview.get("threat_type") + or f"降噪批次 · 原始 {raw_count} 条" + ) + events.append( + { + "eventId": f"workflow-execution:{execution_id}", + "stage": "denoise", + "status": "completed" if str(status).lower() == "success" else "failed", + "occurredAt": datetime.fromtimestamp( + _safe_int(started_at) / 1000 + ).astimezone().isoformat(timespec="seconds"), + "triggerSource": "workflow_execution", + "sampleCount": max(unique_count, 1), + "alert": { + "id": str(preview.get("id") or execution_id), + "sourceType": metrics["sourceType"], + "threatName": threat_name, + "srcIp": preview.get("sip") or preview.get("src_ip") or preview.get("net_real_src_ip"), + "dstIp": preview.get("dip") or preview.get("dst_ip") or preview.get("net_dest_ip"), + }, + "result": { + "isDuplicate": metrics["isDuplicate"], + "clusterId": str(metrics["clusterCount"] or "--"), + **{ + key: value + for key, value in metrics.items() + if key not in {"preview", "sourceCounts", "sourceType"} + }, + }, + } + ) + return events SOURCE_DEFS = [ - ("ndr", "NDR 网络流量", ("ndr", "tdp", "network")), - ("edr", "EDR 主机告警", ("edr", "hids", "linux")), + ("ndr", "NDR", ("ndr", "tdp", "network")), + ("edr", "HIDS", ("edr", "hids", "linux")), ("waf", "WAF Web 防护", ("waf", "web")), ("ids", "IDS/IPS 入侵检测", ("ids", "ips", "skyeye")), ("cloud", "云日志", ("cloud", "aliyun", "qcloud")), @@ -136,45 +985,607 @@ def _get_workflow_call_count(workflow_name: str, date: str = None) -> int: } -def get_stats(ctx, request): - date = _normalize_date(request.query_params.get("date") or _latest_asset_date()) - start_date, end_date = _normalize_range( - request.query_params.get("startDate"), - request.query_params.get("endDate"), - date, +async def get_activity(ctx, request): + params = dict(request.query_params) + return await asyncio.to_thread(_get_activity, params) + + +def _get_activity(params): + _ensure_sqlite_schema() + _maybe_prune_activity() + settings = _sqlite_settings() + db_path = settings["db_path"] + time_window = _normalize_time_window( + params.get("startTime"), + params.get("endTime"), + ) + start_time, end_time = time_window or (0, 0) + workflow_stats = _get_workflow_progress( + "stream_alert_denoise", + start_time, + end_time, + ) + workflow_events = _get_workflow_recent_events( + "stream_alert_denoise", + start_time, + end_time, + ) + raw_cursor = str(params.get("cursor") or "").strip() + bootstrap = str(params.get("bootstrap") or "").strip().lower() == "latest" + limit = max(1, min(_safe_int(params.get("limit") or ACTIVITY_DEFAULT_LIMIT), ACTIVITY_MAX_LIMIT)) + + if not db_path.is_file(): + return _activity_response( + [], + 0, + "", + cursor_reset=bool(raw_cursor), + workflow_stats=workflow_stats, + workflow_events=workflow_events, + ) + + cursor = _decode_activity_cursor(raw_cursor) if raw_cursor else None + cursor_reset = bool(raw_cursor and cursor is None) + + try: + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA query_only = ON") + latest_row_id, latest_activity_id = _activity_latest_cursor(conn, settings) + + if bootstrap or cursor is None: + recent_events = _activity_recent_events( + conn, + settings, + limit=10, + start_time=start_time, + end_time=end_time, + ) + return _activity_response( + [], + latest_row_id, + latest_activity_id, + cursor_reset=cursor_reset, + recent_events=recent_events, + workflow_stats=workflow_stats, + workflow_events=workflow_events, + ) + + last_row_id = max(_safe_int(cursor.get("lastRowId")), 0) + last_activity_id = max(_safe_int(cursor.get("lastActivityId")), 0) + if last_row_id > latest_row_id or last_activity_id > latest_activity_id: + last_row_id = 0 + last_activity_id = 0 + cursor_reset = True + rows, overflow_count, batch = _activity_rows( + conn, + settings, + last_row_id=last_row_id, + last_activity_id=last_activity_id, + latest_row_id=latest_row_id, + latest_activity_id=latest_activity_id, + limit=limit, + ) + except Exception as exc: + return { + **_activity_response( + [], + 0, + "", + workflow_stats=workflow_stats, + workflow_events=workflow_events, + ), + "error": f"activity query failed: {exc}", + } + + events = [] + seen = set() + for row in rows: + event = _activity_event(row) + if event is None or event["eventId"] in seen: + continue + seen.add(event["eventId"]) + events.append(event) + + return { + **_activity_response( + events, + latest_row_id, + latest_activity_id, + cursor_reset=cursor_reset, + batch=batch, + workflow_stats=workflow_stats, + workflow_events=workflow_events, + ), + "overflowCount": overflow_count, + } + + +def _activity_response( + events, + last_row_id, + last_activity_id, + *, + cursor_reset=False, + recent_events=None, + batch=None, + workflow_stats=None, + workflow_events=None, +): + return { + "cursor": _encode_activity_cursor(last_row_id, last_activity_id), + "generatedAt": datetime.now().astimezone().isoformat(timespec="seconds"), + "events": events, + "recentEvents": recent_events or [], + "overflowCount": 0, + "batch": batch or _empty_activity_batch(), + "cursorReset": cursor_reset, + "workflowStats": workflow_stats or {"callCount": None, "latestStartedAt": None}, + "workflowEvents": workflow_events or [], + } + + +def _empty_activity_batch(): + return { + "mode": "normal", + "windowMs": ACTIVITY_WINDOW_MS, + "receivedCount": 0, + "duplicateCount": 0, + "uniqueCount": 0, + "clusterCount": 0, + "triageUpdatedCount": 0, + "sampledCount": 0, + "suppressedCount": 0, + "ratePerSecond": 0, + } + + +def _encode_activity_cursor(last_row_id, last_activity_id): + payload = json.dumps( + { + "lastRowId": max(_safe_int(last_row_id), 0), + "lastActivityId": max(_safe_int(last_activity_id), 0), + }, + separators=(",", ":"), + ).encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + + +def _decode_activity_cursor(value): + try: + padding = "=" * (-len(value) % 4) + payload = json.loads(base64.urlsafe_b64decode((value + padding).encode("ascii"))) + except Exception: + return None + if not isinstance(payload, dict) or "lastRowId" not in payload: + return None + return payload + + +def _activity_latest_cursor(conn, settings): + latest_row_id = _safe_int( + conn.execute(f"SELECT COALESCE(MAX(rowid), 0) FROM {settings['table']}").fetchone()[0] + ) + latest_activity_id = _safe_int( + conn.execute( + f"SELECT COALESCE(MAX(activity_id), 0) FROM {settings['activity_table']}" + ).fetchone()[0] + ) + return latest_row_id, latest_activity_id + + +def _activity_rows( + conn, + settings, + *, + last_row_id, + last_activity_id, + latest_row_id, + latest_activity_id, + limit, +): + summary = _activity_insert_summary(conn, settings, last_row_id, latest_row_id) + new_count = summary["receivedCount"] + updated_count = _safe_int( + conn.execute( + f"SELECT COUNT(*) FROM {settings['activity_table']} " + f"WHERE alert_row_id <= ? AND activity_id > ? AND activity_id <= ?", + (last_row_id, last_activity_id, latest_activity_id), + ).fetchone()[0] + ) + + triage_reserve = min(updated_count, 3) + inserted = _activity_insert_samples( + conn, + settings, + last_row_id=last_row_id, + latest_row_id=latest_row_id, + received_count=new_count, + limit=max(limit - triage_reserve, 1), + ) + remaining = max(limit - len(inserted), 0) + updated = [] + if remaining and updated_count: + updated = conn.execute( + f"SELECT alert_row_id AS activity_row_id, activity_id AS activity_log_id, " + f"event_time AS activity_event_time, record_json, 1 AS sample_count " + f"FROM {settings['activity_table']} " + f"WHERE alert_row_id <= ? AND activity_id > ? AND activity_id <= ? " + f"ORDER BY activity_id DESC LIMIT ?", + (last_row_id, last_activity_id, latest_activity_id, remaining), + ).fetchall() + + rows = list(reversed(inserted)) + list(reversed(updated)) + sampled_count = len(inserted) + batch = { + **summary, + "mode": ( + "surge" + if new_count > ACTIVITY_SURGE_LIMIT + else "burst" if new_count > ACTIVITY_NORMAL_LIMIT else "normal" + ), + "windowMs": ACTIVITY_WINDOW_MS, + "triageUpdatedCount": updated_count, + "sampledCount": sampled_count, + "suppressedCount": max(new_count - sampled_count, 0), + "ratePerSecond": round(new_count / (ACTIVITY_WINDOW_MS / 1000), 1), + } + return rows, max(new_count + updated_count - len(rows), 0), batch + + +def _activity_table_columns(conn, settings): + return { + str(row[1]) + for row in conn.execute(f"PRAGMA table_info({settings['table']})").fetchall() + } + + +def _activity_insert_summary(conn, settings, last_row_id, latest_row_id): + columns = _activity_table_columns(conn, settings) + table = settings["table"] + if {"is_duplicate", "threat_name", "source_type"}.issubset(columns): + row = conn.execute( + f"SELECT COUNT(*) AS received_count, " + f"COALESCE(SUM(CASE WHEN \"is_duplicate\" = 1 THEN 1 ELSE 0 END), 0) AS duplicate_count, " + f"COUNT(DISTINCT COALESCE(NULLIF(\"source_type\", ''), 'unknown') || '|' || " + f"COALESCE(NULLIF(\"threat_name\", ''), 'unknown') || '|' || CAST(\"is_duplicate\" AS TEXT)) " + f"AS cluster_count FROM {table} WHERE rowid > ? AND rowid <= ?", + (last_row_id, latest_row_id), + ).fetchone() + received_count = _safe_int(row[0]) + duplicate_count = _safe_int(row[1]) + cluster_count = _safe_int(row[2]) + else: + received_count = _safe_int( + conn.execute( + f"SELECT COUNT(*) FROM {table} WHERE rowid > ? AND rowid <= ?", + (last_row_id, latest_row_id), + ).fetchone()[0] + ) + duplicate_count = 0 + cluster_count = received_count + return { + "receivedCount": received_count, + "duplicateCount": duplicate_count, + "uniqueCount": max(received_count - duplicate_count, 0), + "clusterCount": cluster_count, + } + + +def _activity_insert_samples(conn, settings, *, last_row_id, latest_row_id, received_count, limit): + if received_count <= 0: + return [] + table = settings["table"] + record_column = settings["record_column"] + if received_count <= limit: + return conn.execute( + f"SELECT rowid AS activity_row_id, {settings['event_time_column']} AS activity_event_time, " + f"{record_column} AS record_json, 1 AS sample_count " + f"FROM {table} WHERE rowid > ? AND rowid <= ? ORDER BY rowid DESC", + (last_row_id, latest_row_id), + ).fetchall() + + columns = _activity_table_columns(conn, settings) + if {"is_duplicate", "threat_name", "source_type"}.issubset(columns): + return conn.execute( + f"SELECT MAX(rowid) AS activity_row_id, MAX({settings['event_time_column']}) AS activity_event_time, " + f"{record_column} AS record_json, COUNT(*) AS sample_count " + f"FROM {table} WHERE rowid > ? AND rowid <= ? " + f"GROUP BY COALESCE(NULLIF(\"source_type\", ''), 'unknown'), " + f"COALESCE(NULLIF(\"threat_name\", ''), 'unknown'), \"is_duplicate\" " + f"ORDER BY sample_count DESC, activity_row_id DESC LIMIT ?", + (last_row_id, latest_row_id, limit), + ).fetchall() + return conn.execute( + f"SELECT rowid AS activity_row_id, {settings['event_time_column']} AS activity_event_time, " + f"{record_column} AS record_json, 1 AS sample_count " + f"FROM {table} WHERE rowid > ? AND rowid <= ? ORDER BY rowid DESC LIMIT ?", + (last_row_id, latest_row_id, limit), + ).fetchall() + + +def _activity_recent_events(conn, settings, *, limit, start_time=0, end_time=0): + raw_time_condition = "" + activity_time_condition = "" + time_params = [] + if start_time > 0 and end_time > 0: + start_date = datetime.fromtimestamp(start_time).strftime("%Y-%m-%d") + end_date = datetime.fromtimestamp(end_time).strftime("%Y-%m-%d") + raw_time_condition = ( + f"source.{settings['date_column']} BETWEEN ? AND ? " + f"AND source.{settings['event_time_column']} BETWEEN ? AND ?" + ) + activity_time_condition = ( + "asset_date BETWEEN ? AND ? AND event_time BETWEEN ? AND ?" + ) + time_params = [start_date, end_date, start_time, end_time] + recent_where = f" WHERE {raw_time_condition}" if raw_time_condition else "" + recent_rows = conn.execute( + f"SELECT source.rowid AS activity_row_id, " + f"source.{settings['event_time_column']} AS activity_event_time, " + f"source.{settings['record_column']} AS record_json, " + f"1 AS sample_count FROM {settings['table']} AS source{recent_where} " + f"ORDER BY source.{settings['event_time_column']} DESC, source.rowid DESC LIMIT ?", + (*time_params, limit), + ).fetchall() + triage_where = f" WHERE {activity_time_condition}" if activity_time_condition else "" + triage_rows = conn.execute( + f"SELECT alert_row_id AS activity_row_id, activity_id AS activity_log_id, " + f"event_time AS activity_event_time, record_json, 1 AS sample_count " + f"FROM {settings['activity_table']}{triage_where} " + f"ORDER BY event_time DESC, activity_id DESC LIMIT ?", + (*time_params, limit), + ).fetchall() + events = [] + seen = set() + for row in [*recent_rows, *triage_rows]: + event = _activity_event(row) + if event is None or event["eventId"] in seen: + continue + seen.add(event["eventId"]) + events.append(event) + return events + + +def _activity_event(row): + try: + record = json.loads(row["record_json"]) + except Exception: + return None + if not isinstance(record, dict): + return None + + row_id = str(row["activity_row_id"]) + triage_status = str(record.get("triage_status") or "").strip().lower() + triage_at = str(record.get("_triage_persisted_at") or "") + is_triage = bool(triage_status or triage_at or record.get("triage_report")) + stage = "triage" if is_triage else "denoise" + version = triage_at or row_id + event_time = _activity_event_time(record) + if not event_time and "activity_event_time" in row.keys(): + parsed_event_time = _parse_event_time(row["activity_event_time"]) + event_time = parsed_event_time.astimezone().isoformat(timespec="seconds") if parsed_event_time else "" + event = { + "eventId": f"{row_id}:{stage}:{version}", + "stage": stage, + "status": "failed" if triage_status in {"failed", "error"} else "completed", + "occurredAt": event_time, + "sampleCount": max(_safe_int(row["sample_count"]), 1) if "sample_count" in row.keys() else 1, + "alert": { + "id": _first_activity_text(record, "id", "record_id", "uuid", "event_id", "dedup_key"), + "sourceType": _first_activity_text(record, "_source_type", "source_type", "device_type"), + "threatName": _first_activity_text(record, "threat_name", "_threat_type", "threat_type") or "未知告警", + "srcIp": _first_activity_text(record, "sip", "src_ip", "source_ip"), + "dstIp": _first_activity_text(record, "dip", "dst_ip", "destination_ip"), + "requestUri": _first_activity_text(record, "req_http_url", "uri", "url"), + "threatPhase": _first_activity_text(record, "threat_phase"), + "threatType": _first_activity_text(record, "threat_type", "_threat_type"), + }, + } + if stage == "denoise": + event["result"] = { + "isDuplicate": record.get("is_duplicate") is True, + "clusterId": _first_activity_text(record, "_lsh_cluster_id"), + "dedupKey": _first_activity_text(record, "dedup_key"), + } + else: + verdict = _norm(record.get("attack_verdict") or "unknown") + event["result"] = { + "triageStatus": triage_status or "completed", + "triageSource": str(record.get("triage_source") or "").strip().lower() or "triaged", + "durationMs": _safe_int(record.get("triage_ms")), + "verdict": verdict, + "verdictLabel": RESULT_LABELS.get(verdict, "待确认"), + "riskLevel": _first_activity_text(record, "risk_level", "threat_level"), + "reportTitle": _first_activity_text(record, "report_title"), + "hasReport": bool(record.get("triage_report")), + } + return event + + +def _activity_event_time(record): + for key in ("time", "event_time", "timestamp", "timestamp_real", "occur_time", "created_at"): + value = _parse_event_time(record.get(key)) + if value is not None: + return value.astimezone().isoformat(timespec="seconds") + return "" + + +def _first_activity_text(record, *keys): + for key in keys: + value = record.get(key) + if value not in (None, "", "none", "None"): + return str(value) + return "" + + +def _file_revision(path): + try: + stat = path.stat() + return stat.st_size, stat.st_mtime_ns + except Exception: + return 0, 0 + + +def _stats_cache_ttl(start_time, end_time): + span = max(end_time - start_time, 0) + if span <= 2 * 60 * 60: + return 15.0 + if span <= 24 * 60 * 60: + return 30.0 + if span <= 7 * 24 * 60 * 60: + return 120.0 + return _STATS_RESPONSE_CACHE_TTL + + +def _stats_cache_get(cache_key, ttl): + now = time.monotonic() + with _cache_lock: + cached = _stats_response_cache.get(cache_key) + if not cached: + return None + if now - float(cached.get("updatedAt") or 0) >= ttl: + _stats_response_cache.pop(cache_key, None) + return None + _stats_response_cache.move_to_end(cache_key) + return cached["value"] + + +def _stats_cache_put(cache_key, value): + with _cache_lock: + _stats_response_cache[cache_key] = { + "updatedAt": time.monotonic(), + "value": value, + } + _stats_response_cache.move_to_end(cache_key) + while len(_stats_response_cache) > _STATS_RESPONSE_CACHE_MAX: + _stats_response_cache.popitem(last=False) + + +async def get_stats(ctx, request): + params = dict(request.query_params) + return await asyncio.to_thread(_get_stats, params) + + +def _get_stats(params): + _ensure_sqlite_schema() + time_window = _normalize_time_window( + params.get("startTime"), + params.get("endTime"), ) + if time_window: + start_time, end_time = time_window + start_date = datetime.fromtimestamp(start_time).strftime("%Y-%m-%d") + end_date = datetime.fromtimestamp(end_time).strftime("%Y-%m-%d") + date = start_date + else: + start_time = end_time = 0 + date = _normalize_date(params.get("date") or _latest_asset_date()) + start_date, end_date = _normalize_range( + params.get("startDate"), + params.get("endDate"), + date, + ) started = time.time() + range_start_time = start_time or int(datetime.strptime(start_date, "%Y-%m-%d").timestamp()) + range_end_time = end_time or int( + (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).timestamp() + ) - 1 + cache_key = ( + start_date, + end_date, + start_time, + end_time, + ) + force_refresh = str(params.get("force") or "").strip().lower() in {"1", "true", "yes"} + cached = None if force_refresh else _stats_cache_get( + cache_key, + _stats_cache_ttl(range_start_time, range_end_time), + ) + if cached is not None: + return { + **cached, + "generatedAt": datetime.now().isoformat(timespec="seconds"), + "latencyMs": round((time.time() - started) * 1000), + "cacheHit": True, + } denoise_files, denoise_locations = [], [] triage_files, triage_locations = [], [] - asset_files = _find_asset_files(start_date, end_date) + asset_files = _find_asset_files(start_date, end_date, start_time, end_time) asset_denoise_files = [path for path in asset_files if _asset_file_role(path) == "denoise"] asset_triage_files = [path for path in asset_files if _asset_file_role(path) == "triage"] sample_mode = bool(asset_denoise_files or asset_triage_files) if asset_denoise_files: denoise_files = asset_denoise_files - if asset_triage_files: - triage_files = asset_triage_files + triage_files = asset_triage_files or asset_denoise_files - workflow_stats = _get_workflow_call_count("stream_alert_denoise", date=start_date) - denoise = _read_denoise(denoise_files) + workflow_stats = _get_workflow_denoise_stats( + "stream_alert_denoise", + range_start_time, + range_end_time, + force=force_refresh, + ) + denoise = _read_denoise(denoise_files, workflow_stats.get("callCount", 0)) + soc_unique_count = denoise["totalUnique"] + soc_unique_series = denoise["seriesUnique"] + timeline_labels = workflow_stats["timelineLabels"] or denoise.get("_timelineLabels", []) + timeline_window = workflow_stats["timelineWindow"] or denoise.get("_timelineWindow", "") + workflow_series_raw = workflow_stats["seriesRaw"] + if not workflow_series_raw and soc_unique_series: + workflow_series_raw = [0] * len(soc_unique_series) + processed_total = workflow_stats["callCount"] + reduced_count = max(processed_total - soc_unique_count, 0) + reduction_rate = _ratio(reduced_count, processed_total) + denoise.update( + { + "totalRaw": processed_total, + "totalNormalized": processed_total, + "afterFilter": processed_total, + "totalUnique": soc_unique_count, + "filterRemoved": 0, + "dedupRemoved": reduced_count, + "duplicates": reduced_count, + "duplicateRate": reduction_rate, + "dedupRate": reduction_rate, + "uniqueRate": _ratio(min(soc_unique_count, processed_total), processed_total), + "files": processed_total, + "sourceCounter": Counter(workflow_stats["sourceCounts"]), + "seriesRaw": workflow_series_raw, + "seriesUnique": soc_unique_series, + "_timelineLabels": timeline_labels, + "_timelineWindow": timeline_window, + "workflowCallCount": processed_total, + "dataSource": "workflow.db.workflow_stats.call_count + soc.db.unique", + } + ) triage = _read_triage(triage_files) - if triage["totalRecords"] == 0 and denoise["totalRaw"] > 0: - triage = _simulate_triage_from_denoise(denoise_files) - sources = _build_sources(denoise["sourceCounter"] or triage["sourceCounter"]) + if denoise.get("_seriesTriage") is not None: + triage["seriesTotal"] = denoise["_seriesTriage"] + triage["seriesAttack"] = denoise["_seriesAttack"] + sources = _build_sources(denoise["sourceCounter"]) closed_loop = _build_closed_loop(triage) pipeline = _build_pipeline(denoise, triage) - date_range = _build_date_range(start_date, end_date, asset_files) + available_dates = _available_asset_dates() + date_range = _build_date_range(start_date, end_date, asset_files, available_dates) event_range = _build_event_range(date_range, denoise, triage) - return { + result = { "date": start_date, "dateRange": date_range, "eventRange": event_range, "generatedAt": datetime.now().isoformat(timespec="seconds"), "latencyMs": round((time.time() - started) * 1000), "sourceStatus": { + "dataPolicy": { + "mode": "sqlite-only", + "jsonlEnabled": False, + "allowedDatabases": [ + _display_path(DEFAULT_SQLITE_DB), + _display_path(WORKFLOW_DB), + ], + }, "workflowStatsDb": _display_path(WORKFLOW_DB), "workflowStats": workflow_stats, "sampleMode": sample_mode, @@ -183,9 +1594,9 @@ def get_stats(ctx, request): "path": _display_path(_active_source_path()), "exists": _active_source_exists(), "dataSource": _active_data_source(), - "config": _display_path(ACCESS_CONFIG_PATH), + "locked": True, "fileCount": len(asset_files), - "availableDates": _available_asset_dates(), + "availableDates": available_dates, "selectedDates": date_range["fileDates"], }, "assetFiles": [_file_brief(path) for path in asset_files], @@ -215,114 +1626,19 @@ def get_stats(ctx, request): "topThreats": _counter_items(triage["threatCounter"] or denoise["threatCounter"], 14), "riskLevels": _counter_items(triage["riskCounter"], 5), "timeline": { - "labels": _series_labels(max(len(denoise["seriesRaw"]), len(triage["seriesTotal"]))), - "window": _timeline_window(start_date, end_date, len(denoise["seriesRaw"])), + "labels": denoise.get("_timelineLabels") + or _series_labels(max(len(denoise["seriesRaw"]), len(triage["seriesTotal"]))), + "window": denoise.get("_timelineWindow") + or _timeline_window(start_date, end_date, len(denoise["seriesRaw"])), "denoiseRaw": denoise["seriesRaw"], "denoiseUnique": denoise["seriesUnique"], "triageTotal": triage["seriesTotal"], "triageAttack": triage["seriesAttack"], }, } - - -def _simulate_triage_from_denoise(paths): - total_records = 0 - parse_errors = 0 - source_counter = Counter() - threat_counter = Counter() - risk_counter = Counter() - verdict_counter = Counter() - profile_counters = _new_profile_counters() - event_start = None - event_end = None - series_total = [] - series_attack = [] - - for path in paths: - file_total = 0 - file_attack = 0 - for obj in _iter_source_records(path): - if obj is None: - parse_errors += 1 - continue - if obj.get("_type") == "file_header" or obj.get("is_duplicate") is True: - continue - total_records += 1 - file_total += 1 - verdict = _dedup_record_verdict(obj) - verdict_counter[verdict] += 1 - if verdict in {"attack_success", "attack", "attack_failed"}: - file_attack += 1 - source_counter[_norm(obj.get("_source_type") or obj.get("source_type") or obj.get("device_type"))] += 1 - threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 - risk_counter[_norm(obj.get("threat_level") or obj.get("threat_severity") or obj.get("risk_level"))] += 1 - _update_profile_counters(obj, profile_counters) - event_start, event_end = _merge_record_time(event_start, event_end, obj) - series_total.append(file_total) - series_attack.append(file_attack) - - attack_success = verdict_counter["attack_success"] - attack = verdict_counter["attack"] - attack_failed = verdict_counter["attack_failed"] - attack_total = attack_success + attack + attack_failed - benign = verdict_counter["benign"] - unknown = verdict_counter["unknown"] - new_triaged = round(total_records * 0.22) - cache_hit = round(total_records * 0.68) - followers_reused = max(total_records - new_triaged - cache_hit, 0) - - series_total = _expand_series(series_total, total_records, seed=17) - series_attack = _expand_series(series_attack, attack_total, seed=19) - - return { - "totalRecords": total_records, - "batchTotal": total_records, - "newTriaged": new_triaged, - "cacheHit": cache_hit, - "triageFailed": 0, - "followersReused": followers_reused, - "attackTotal": attack_total, - "attackSuccess": attack_success, - "attack": attack, - "attackFailed": attack_failed, - "benign": benign, - "unknown": unknown, - "attackRate": _ratio(attack_total, total_records), - "successRate": _ratio(attack_success, attack_total), - "cacheRate": _ratio(cache_hit + followers_reused, total_records), - "coverageRate": _ratio(total_records, total_records), - "headers": 0, - "files": len(paths), - "parseErrors": parse_errors, - "eventStart": _format_event_time(event_start), - "eventEnd": _format_event_time(event_end), - "sourceCounter": source_counter, - "threatCounter": threat_counter, - "riskCounter": risk_counter, - "statusCounter": Counter({"simulated": total_records}), - **profile_counters, - "seriesTotal": series_total, - "seriesAttack": series_attack, - } - - -def _dedup_record_verdict(obj): - threat_level = _norm(obj.get("threat_level")) - threat_result = _norm(obj.get("threat_result")) - status = _safe_int(obj.get("rsp_status_code")) - body_len = _safe_int(obj.get("rsp_body_len")) - - if threat_level in {"benign", "info", "low"}: - return "benign" - if threat_result in {"success", "succeeded"}: - return "attack_success" - if threat_result in {"failed", "blocked"} or status in {401, 403, 404, 405, 406, 410}: - return "attack_failed" - if status == 200 and body_len > 0: - return "attack_success" - if threat_level == "attack" or obj.get("threat_name"): - return "attack" - return "unknown" + result["cacheHit"] = False + _stats_cache_put(cache_key, result) + return result def _normalize_date(value): @@ -339,6 +1655,16 @@ def _normalize_range(start_value, end_value, fallback_date): return start_date, end_date +def _normalize_time_window(start_value, end_value): + start_time = _safe_int(start_value) + end_time = _safe_int(end_value) + if start_time <= 0 or end_time <= 0: + return None + if start_time > end_time: + start_time, end_time = end_time, start_time + return start_time, end_time + + def _date_span(start_date, end_date): start = datetime.strptime(start_date, "%Y-%m-%d").date() end = datetime.strptime(end_date, "%Y-%m-%d").date() @@ -348,8 +1674,8 @@ def _date_span(start_date, end_date): current += timedelta(days=1) -def _find_asset_files(start_date, end_date): - return _find_sqlite_sources(start_date, end_date) +def _find_asset_files(start_date, end_date, start_time=0, end_time=0): + return _find_sqlite_sources(start_date, end_date, start_time, end_time) def _asset_file_date(path): @@ -367,76 +1693,22 @@ def _available_asset_dates(): return _available_sqlite_dates() -def _load_alerts_config(): - raw = {} - if ACCESS_CONFIG_PATH.is_file(): - try: - value = json.loads(ACCESS_CONFIG_PATH.read_text(encoding="utf-8")) - except Exception: - value = {} - if isinstance(value, dict): - raw = value - - sqlite_config = raw.get("sqlite") if isinstance(raw.get("sqlite"), dict) else {} - return { - "dataSource": DEFAULT_DATA_SOURCE, - "sqlite": { - "dbPath": _read_config_string( - os.environ.get("FLOCKS_SOC_ALERTS_SQLITE_DB"), - sqlite_config.get("dbPath"), - str(DEFAULT_SQLITE_DB), - ), - "table": _read_config_string(sqlite_config.get("table"), DEFAULT_SQLITE_TABLE), - "recordColumn": _read_config_string(sqlite_config.get("recordColumn"), DEFAULT_SQLITE_RECORD_COLUMN), - "dateColumn": _read_config_string(sqlite_config.get("dateColumn"), DEFAULT_SQLITE_DATE_COLUMN), - "eventTimeColumn": _read_config_string( - sqlite_config.get("eventTimeColumn"), - DEFAULT_SQLITE_EVENT_TIME_COLUMN, - ), - }, - } - - def _active_data_source(): return "sqlite" -def _read_config_string(*values): - for value in values: - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _resolve_config_path(value, fallback): - text = _read_config_string(value, str(fallback)) - path = Path(text).expanduser() - if path.is_absolute(): - return path - return (ACCESS_CONFIG_PATH.parent / path).resolve() - - def _sqlite_settings(): - config = _load_alerts_config()["sqlite"] return { - "db_path": _resolve_config_path(config.get("dbPath"), DEFAULT_SQLITE_DB), - "table": _sql_identifier(config.get("table"), DEFAULT_SQLITE_TABLE), - "record_column": _sql_identifier(config.get("recordColumn"), DEFAULT_SQLITE_RECORD_COLUMN), - "date_column": _sql_identifier(config.get("dateColumn"), DEFAULT_SQLITE_DATE_COLUMN), - "event_time_column": _sql_identifier( - config.get("eventTimeColumn"), - DEFAULT_SQLITE_EVENT_TIME_COLUMN, - ), + "db_path": DEFAULT_SQLITE_DB, + "table": f'"{DEFAULT_SQLITE_TABLE}"', + "facts_table": f'"{FACTS_TABLE}"', + "activity_table": f'"{ACTIVITY_TABLE}"', + "record_column": f'"{DEFAULT_SQLITE_RECORD_COLUMN}"', + "date_column": f'"{DEFAULT_SQLITE_DATE_COLUMN}"', + "event_time_column": f'"{DEFAULT_SQLITE_EVENT_TIME_COLUMN}"', } -def _sql_identifier(value, fallback): - text = _read_config_string(value, fallback) - if not SQL_IDENTIFIER_RE.match(text): - text = fallback - return f'"{text}"' - - def _active_source_path(): return _sqlite_settings()["db_path"] @@ -446,22 +1718,27 @@ def _active_source_exists(): return path.is_file() -def _find_sqlite_sources(start_date, end_date): +def _find_sqlite_sources(start_date, end_date, start_time=0, end_time=0): settings = _sqlite_settings() db_path = settings["db_path"] if not db_path.is_file(): return [] + time_clause = "" + query_params = [start_date, end_date] + if start_time > 0 and end_time > 0: + time_clause = f" AND {settings['event_time_column']} BETWEEN ? AND ?" + query_params.extend((start_time, end_time)) query = ( f"SELECT {settings['date_column']} AS asset_date, COUNT(*) AS record_count " - f"FROM {settings['table']} " - f"WHERE {settings['date_column']} BETWEEN ? AND ? " + f"FROM {settings['facts_table']} " + f"WHERE {settings['date_column']} BETWEEN ? AND ?{time_clause} " f"GROUP BY {settings['date_column']} " f"ORDER BY {settings['date_column']}" ) try: with sqlite3.connect(db_path) as conn: - rows = conn.execute(query, (start_date, end_date)).fetchall() + rows = conn.execute(query, query_params).fetchall() except Exception: return [] @@ -477,6 +1754,8 @@ def _find_sqlite_sources(start_date, end_date): date=asset_date, data_source="sqlite", record_count=int(record_count or 0), + start_time=start_time, + end_time=end_time, ) ) return sources @@ -489,7 +1768,7 @@ def _available_sqlite_dates(): return [] query = ( f"SELECT DISTINCT {settings['date_column']} AS asset_date " - f"FROM {settings['table']} " + f"FROM {settings['facts_table']} " f"WHERE {settings['date_column']} IS NOT NULL " f"ORDER BY {settings['date_column']}" ) @@ -501,13 +1780,13 @@ def _available_sqlite_dates(): return [str(row[0]) for row in rows if DATE_RE.match(str(row[0]))] -def _build_date_range(start_date, end_date, asset_files): +def _build_date_range(start_date, end_date, asset_files, available_dates=None): file_dates = sorted({date for date in (_asset_file_date(path) for path in asset_files) if date}) return { "start": start_date, "end": end_date, "label": start_date if start_date == end_date else f"{start_date} 至 {end_date}", - "availableDates": _available_asset_dates(), + "availableDates": available_dates if available_dates is not None else _available_asset_dates(), "fileDates": file_dates, } @@ -542,8 +1821,6 @@ def _timeline_window(start_date, end_date, series_length): if start_date != end_date: days = len(list(_date_span(start_date, end_date))) return f"{days} 天范围聚合" - if series_length >= SIMULATED_TIME_BUCKETS: - return "近 24 小时分布" return "按批次统计" @@ -557,6 +1834,11 @@ def _asset_file_role(path): def _read_denoise(paths, workflow_call_count: int = 0): + if paths and all(isinstance(path, _RecordSource) and path.data_source == "sqlite" for path in paths): + optimized = _read_sqlite_denoise(paths, workflow_call_count) + if optimized is not None: + return optimized + total_raw = 0 duplicates = 0 parse_errors = 0 @@ -615,7 +1897,361 @@ def _read_denoise(paths, workflow_call_count: int = 0): } +def _read_sqlite_denoise(paths, workflow_call_count): + settings = _sqlite_settings() + dates = sorted({path.date for path in paths}) + if not dates: + return None + placeholders = ",".join("?" for _ in dates) + date_column = settings["date_column"] + event_time_column = settings["event_time_column"] + table = settings["facts_table"] + where_clause = f"{date_column} IN ({placeholders})" + query_params = list(dates) + start_time = min((path.start_time for path in paths if path.start_time > 0), default=0) + end_time = max((path.end_time for path in paths if path.end_time > 0), default=0) + if start_time > 0 and end_time > 0: + where_clause += f" AND {event_time_column} BETWEEN ? AND ?" + query_params.extend((start_time, end_time)) + try: + with sqlite3.connect(settings["db_path"]) as conn: + rows = conn.execute( + f"SELECT {date_column}, COUNT(*), " + f"COALESCE(SUM(CASE WHEN \"is_duplicate\" = 1 THEN 1 ELSE 0 END), 0), " + f"MIN({event_time_column}), MAX({event_time_column}) " + f"FROM {table} WHERE {where_clause} " + f"GROUP BY {date_column} ORDER BY {date_column}", + query_params, + ).fetchall() + source_rows = conn.execute( + f"SELECT \"source_type\", COUNT(*) FROM {table} " + f"WHERE {where_clause} GROUP BY \"source_type\"", + query_params, + ).fetchall() + profile_counters, threat_counter = _sqlite_detail_counters( + conn, + settings, + where_clause, + query_params, + ) + timeline = _sqlite_timeline( + conn, + settings, + where_clause, + query_params, + dates, + start_time, + end_time, + ) + except Exception: + return None + + total_raw = sum(_safe_int(row[1]) for row in rows) + duplicates = sum(_safe_int(row[2]) for row in rows) + total_unique = max(total_raw - duplicates, 0) + event_values = [ + _parse_event_time(value) + for row in rows + for value in (row[3], row[4]) + if value not in (None, "") + ] + series_raw = [_safe_int(row[1]) for row in rows] + series_unique = [max(_safe_int(row[1]) - _safe_int(row[2]), 0) for row in rows] + return { + "totalRaw": total_raw, + "totalUnique": total_unique, + "duplicates": duplicates, + "duplicateRate": _ratio(duplicates, total_raw), + "uniqueRate": _ratio(total_unique, total_raw), + "headers": 0, + "files": len(paths), + "parseErrors": 0, + "eventStart": _format_event_time(min(event_values) if event_values else None), + "eventEnd": _format_event_time(max(event_values) if event_values else None), + "sourceCounter": Counter({_norm(key): _safe_int(value) for key, value in source_rows}), + "threatCounter": threat_counter, + **profile_counters, + "seriesRaw": timeline["raw"], + "seriesUnique": timeline["unique"], + "_seriesTriage": timeline["triage"], + "_seriesAttack": timeline["attack"], + "_timelineLabels": timeline["labels"], + "_timelineWindow": timeline["window"], + "workflowCallCount": workflow_call_count, + } + + +def _timeline_spec(dates, start_time, end_time): + if start_time > 0 and end_time > 0: + bucket_start = start_time + bucket_end = end_time + else: + bucket_start = int(datetime.strptime(min(dates), "%Y-%m-%d").timestamp()) + bucket_end = int( + (datetime.strptime(max(dates), "%Y-%m-%d") + timedelta(days=1)).timestamp() + ) - 1 + span = max(bucket_end - bucket_start + 1, 1) + if span <= 30 * 60: + bucket_seconds, window = 60, "按分钟真实分布" + elif span <= 2 * 60 * 60: + bucket_seconds, window = 5 * 60, "按 5 分钟真实分布" + elif span <= 24 * 60 * 60: + bucket_seconds, window = 60 * 60, "按小时真实分布" + elif span <= 7 * 24 * 60 * 60: + bucket_seconds, window = 6 * 60 * 60, "按 6 小时真实分布" + else: + bucket_seconds, window = 24 * 60 * 60, "按天真实分布" + bucket_count = max(1, min(math.ceil(span / bucket_seconds), 60)) + labels = [] + for index in range(bucket_count): + point = datetime.fromtimestamp(bucket_start + index * bucket_seconds) + if bucket_seconds >= 24 * 60 * 60: + labels.append(point.strftime("%m-%d")) + elif bucket_seconds >= 60 * 60: + labels.append(point.strftime("%m-%d %H:%M")) + else: + labels.append(point.strftime("%H:%M")) + return bucket_start, bucket_seconds, bucket_count, labels, window + + +def _sqlite_timeline(conn, settings, where_clause, query_params, dates, start_time, end_time): + bucket_start, bucket_seconds, bucket_count, labels, window = _timeline_spec( + dates, + start_time, + end_time, + ) + rows = conn.execute( + f"SELECT CAST((event_time - ?) / ? AS INTEGER) AS bucket_index, " + f"COUNT(*) AS raw_count, " + f"COALESCE(SUM(CASE WHEN is_duplicate = 0 THEN 1 ELSE 0 END), 0) AS unique_count, " + f"COALESCE(SUM(has_triage), 0) AS triage_count, " + f"COALESCE(SUM(CASE WHEN has_triage = 1 AND LOWER(verdict) IN " + f"('attack_success', 'attack', 'attack_failed') THEN 1 ELSE 0 END), 0) AS attack_count " + f"FROM {settings['facts_table']} WHERE {where_clause} AND event_time IS NOT NULL " + f"GROUP BY bucket_index ORDER BY bucket_index", + (bucket_start, bucket_seconds, *query_params), + ).fetchall() + raw = [0] * bucket_count + unique = [0] * bucket_count + triage = [0] * bucket_count + attack = [0] * bucket_count + for row in rows: + index = _safe_int(row[0]) + if 0 <= index < bucket_count: + raw[index] = _safe_int(row[1]) + unique[index] = _safe_int(row[2]) + triage[index] = _safe_int(row[3]) + attack[index] = _safe_int(row[4]) + return { + "raw": raw, + "unique": unique, + "triage": triage, + "attack": attack, + "labels": labels, + "window": window, + } + + +def _sqlite_detail_counters(conn, settings, where_clause, query_params): + table = settings["facts_table"] + cache_key = ( + str(settings["db_path"]), + table, + tuple(query_params), + _file_revision(Path(f"{settings['db_path']}-wal")), + ) + revision = conn.execute( + f"SELECT COALESCE(MAX(alert_row_id), 0), COALESCE(MAX(triage_persisted_at), '') " + f"FROM {table} WHERE {where_clause}", + query_params, + ).fetchone() + latest_row_id = _safe_int(revision[0]) + latest_triage_at = str(revision[1] or "") + with _cache_lock: + cached = _denoise_detail_cache.get(cache_key) or {} + if cached: + _denoise_detail_cache.move_to_end(cache_key) + if ( + cached + and latest_row_id == _safe_int(cached.get("lastRowId")) + and latest_triage_at == str(cached.get("lastTriagePersistedAt") or "") + and time.monotonic() - float(cached.get("updatedAt") or 0) < _DENOISE_DETAIL_CACHE_TTL + ): + return ( + {key: Counter(value) for key, value in cached["profileCounters"].items()}, + Counter(cached["threatCounter"]), + ) + + rows = conn.execute( + f"SELECT phase, direction, result, protocol, severity, response_code, " + f"port, threat_name, COUNT(*) AS profile_count FROM {table} " + f"WHERE {where_clause} " + f"GROUP BY 1, 2, 3, 4, 5, 6, 7, 8", + query_params, + ).fetchall() + profile_counters = _new_profile_counters() + profile_keys = ( + "phaseCounter", + "directionCounter", + "resultCounter", + "protocolCounter", + "severityCounter", + "responseCounter", + ) + threat_counter = Counter() + for row in rows: + count = _safe_int(row[8]) + for index, key in enumerate(profile_keys): + profile_counters[key][_norm(row[index])] += count + port_value = row[6] + port = str(_safe_int(port_value)) if _safe_int(port_value) > 0 else _norm(port_value) + profile_counters["portCounter"][port] += count + threat_counter[_norm(row[7])] += count + with _cache_lock: + _denoise_detail_cache[cache_key] = { + "lastRowId": latest_row_id, + "lastTriagePersistedAt": latest_triage_at, + "updatedAt": time.monotonic(), + "profileCounters": {key: Counter(value) for key, value in profile_counters.items()}, + "threatCounter": Counter(threat_counter), + } + _denoise_detail_cache.move_to_end(cache_key) + while len(_denoise_detail_cache) > _DENOISE_DETAIL_CACHE_MAX: + _denoise_detail_cache.popitem(last=False) + return profile_counters, threat_counter + + +def _read_sqlite_triage(paths): + settings = _sqlite_settings() + dates = sorted({path.date for path in paths}) + if not dates: + return None + placeholders = ",".join("?" for _ in dates) + where_clause = f"asset_date IN ({placeholders})" + query_params = list(dates) + start_time = min((path.start_time for path in paths if path.start_time > 0), default=0) + end_time = max((path.end_time for path in paths if path.end_time > 0), default=0) + if start_time > 0 and end_time > 0: + where_clause += " AND event_time BETWEEN ? AND ?" + query_params.extend((start_time, end_time)) + triage_where = f"{where_clause} AND has_triage = 1" + try: + with sqlite3.connect(settings["db_path"]) as conn: + row = conn.execute( + f"SELECT COUNT(*), " + f"COALESCE(SUM(CASE WHEN LOWER(triage_source) = 'cache' " + f"OR LOWER(triage_status) = 'cached' THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN LOWER(triage_source) IN " + f"('follower', 'followers', 'follower_reused') " + f"OR LOWER(triage_status) = 'follower_reused' THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN LOWER(triage_status) IN ('failed', 'error') " + f"THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN LOWER(verdict) = 'attack_success' THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN LOWER(verdict) = 'attack' THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN LOWER(verdict) = 'attack_failed' THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN LOWER(verdict) = 'benign' THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN LOWER(verdict) NOT IN " + f"('attack_success', 'attack', 'attack_failed', 'benign') THEN 1 ELSE 0 END), 0), " + f"COALESCE(SUM(CASE WHEN attack_success = 1 AND LOWER(verdict) <> 'attack_success' " + f"THEN 1 ELSE 0 END), 0), MIN(event_time), MAX(event_time), " + f"COALESCE(ROUND(AVG(CASE WHEN triage_ms > 0 THEN triage_ms END)), 0) " + f"FROM {settings['facts_table']} WHERE {triage_where}", + query_params, + ).fetchone() + source_rows = conn.execute( + f"SELECT source_type, COUNT(*) FROM {settings['facts_table']} " + f"WHERE {triage_where} GROUP BY source_type", + query_params, + ).fetchall() + threat_rows = conn.execute( + f"SELECT threat_name, COUNT(*) FROM {settings['facts_table']} " + f"WHERE {triage_where} GROUP BY threat_name", + query_params, + ).fetchall() + risk_rows = conn.execute( + f"SELECT risk_level, COUNT(*) FROM {settings['facts_table']} " + f"WHERE {triage_where} GROUP BY risk_level", + query_params, + ).fetchall() + status_rows = conn.execute( + f"SELECT COALESCE(NULLIF(triage_status, ''), triage_source), COUNT(*) " + f"FROM {settings['facts_table']} WHERE {triage_where} GROUP BY 1", + query_params, + ).fetchall() + profile_rows = conn.execute( + f"SELECT phase, direction, result, protocol, severity, response_code, port, COUNT(*) " + f"FROM {settings['facts_table']} WHERE {triage_where} " + f"GROUP BY 1, 2, 3, 4, 5, 6, 7", + query_params, + ).fetchall() + except Exception: + return None + + total_records = _safe_int(row[0]) + cache_hit = _safe_int(row[1]) + followers_reused = _safe_int(row[2]) + triage_failed = _safe_int(row[3]) + attack_success = _safe_int(row[4]) + _safe_int(row[9]) + attack = _safe_int(row[5]) + attack_failed = _safe_int(row[6]) + benign = _safe_int(row[7]) + unknown = _safe_int(row[8]) + attack_total = attack_success + attack + attack_failed + avg_triage_ms = _safe_int(row[12]) + profile_counters = _new_profile_counters() + profile_keys = ( + "phaseCounter", + "directionCounter", + "resultCounter", + "protocolCounter", + "severityCounter", + "responseCounter", + ) + for profile_row in profile_rows: + count = _safe_int(profile_row[7]) + for index, key in enumerate(profile_keys): + profile_counters[key][_norm(profile_row[index])] += count + port_value = profile_row[6] + port = str(_safe_int(port_value)) if _safe_int(port_value) > 0 else _norm(port_value) + profile_counters["portCounter"][port] += count + return { + "totalRecords": total_records, + "batchTotal": 0, + "newTriaged": max(total_records - cache_hit - followers_reused - triage_failed, 0), + "cacheHit": cache_hit, + "triageFailed": triage_failed, + "followersReused": followers_reused, + "attackTotal": attack_total, + "attackSuccess": attack_success, + "attack": attack, + "attackFailed": attack_failed, + "benign": benign, + "unknown": unknown, + "attackRate": _ratio(attack_total, total_records), + "successRate": _ratio(attack_success, attack_total), + "cacheRate": _ratio(cache_hit + followers_reused, total_records), + "coverageRate": _ratio(total_records - triage_failed, total_records), + "avgTriageMs": avg_triage_ms, + "headers": 0, + "files": len(paths), + "parseErrors": 0, + "eventStart": _format_event_time(_parse_event_time(row[10])), + "eventEnd": _format_event_time(_parse_event_time(row[11])), + "sourceCounter": Counter({_norm(key): _safe_int(value) for key, value in source_rows}), + "threatCounter": Counter({_norm(key): _safe_int(value) for key, value in threat_rows}), + "riskCounter": Counter({_norm(key): _safe_int(value) for key, value in risk_rows}), + "statusCounter": Counter({_norm(key): _safe_int(value) for key, value in status_rows}), + **profile_counters, + "seriesTotal": [], + "seriesAttack": [], + } + + def _read_triage(paths): + if paths and all(isinstance(path, _RecordSource) and path.data_source == "sqlite" for path in paths): + optimized = _read_sqlite_triage(paths) + if optimized is not None: + return optimized total_records = 0 parse_errors = 0 headers = [] @@ -635,11 +2271,13 @@ def _read_triage(paths): extra_success = 0 series_total = [] series_attack = [] + triage_ms_total = 0 + triage_ms_count = 0 for path in paths: file_total = 0 file_attack = 0 - for obj in _iter_source_records(path): + for obj in _iter_source_records(path, triage_only=True): if obj is None: parse_errors += 1 continue @@ -670,6 +2308,10 @@ def _read_triage(paths): source_counter[source] += 1 threat_counter[_norm(obj.get("_threat_type") or obj.get("threat_name") or obj.get("threat_type"))] += 1 risk_counter[_norm(obj.get("risk_level") or obj.get("threat_level") or obj.get("threat_severity"))] += 1 + triage_ms = _safe_int(obj.get("triage_ms")) + if triage_ms > 0: + triage_ms_total += triage_ms + triage_ms_count += 1 _update_profile_counters(obj, profile_counters) event_start, event_end = _merge_record_time(event_start, event_end, obj) triage_source = _norm(obj.get("triage_source")) @@ -724,6 +2366,7 @@ def _read_triage(paths): "successRate": _ratio(attack_success, attack_total), "cacheRate": _ratio(cache_hit + followers_reused, total_records), "coverageRate": _ratio(total_records - triage_failed, total_records), + "avgTriageMs": round(triage_ms_total / triage_ms_count) if triage_ms_count else 0, "headers": len(headers), "files": len(paths), "parseErrors": parse_errors, @@ -769,42 +2412,10 @@ def _expand_series(values, total, *, seed): total = _safe_int(total) if total <= 0: return [] - if len(values) >= 8: - return values - return _simulate_time_series(total, SIMULATED_TIME_BUCKETS, seed=seed) - - -def _simulate_time_series(total, buckets, *, seed): - if total <= 0 or buckets <= 0: - return [] - spike_a = (seed * 3 + 5) % buckets - spike_b = (seed * 5 + 11) % buckets - weights = [] - for hour in range(buckets): - workday = 1.0 if 8 <= hour <= 22 else 0.34 - wave = 1.0 + 0.46 * math.sin((hour + seed) * 0.68) + 0.22 * math.sin((hour + seed) * 1.31) - spike = 1.0 - if hour == spike_a: - spike += 1.05 - if hour == spike_b: - spike += 0.72 - if 14 <= hour <= 16: - spike += 0.45 - weights.append(max(0.08, workday * wave * spike)) - - weight_sum = sum(weights) or 1 - exact = [total * weight / weight_sum for weight in weights] - series = [int(value) for value in exact] - remainder = total - sum(series) - order = sorted(range(buckets), key=lambda index: exact[index] - series[index], reverse=True) - for index in order[:remainder]: - series[index] += 1 - return series + return values or [total] def _series_labels(length): - if length == SIMULATED_TIME_BUCKETS: - return [f"{hour:02d}:00" for hour in range(SIMULATED_TIME_BUCKETS)] return [f"B{index + 1:02d}" for index in range(length)] @@ -849,7 +2460,7 @@ def _build_closed_loop(triage): def _build_pipeline(denoise, triage): - raw = denoise.get("workflowCallCount") or denoise["totalRaw"] + raw = denoise["totalRaw"] unique = denoise["totalUnique"] triage_total = triage["totalRecords"] attack_total = triage["attackTotal"] @@ -942,39 +2553,50 @@ def _port_label(value): return str(value) -def _iter_source_records(path): +def _iter_source_records(path, *, triage_only=False): if isinstance(path, _RecordSource) and path.data_source == "sqlite": - yield from _iter_sqlite_records(path) + yield from _iter_sqlite_records(path, triage_only=triage_only) return -def _iter_sqlite_records(source): +def _iter_sqlite_records(source, *, triage_only=False): settings = _sqlite_settings() + time_filter = "" + query_params = [source.date] + if source.start_time > 0 and source.end_time > 0: + time_filter = f" AND {settings['event_time_column']} BETWEEN ? AND ?" + query_params.extend((source.start_time, source.end_time)) + triage_filter = "" + if triage_only: + triage_filter = ( + f" AND (NULLIF(json_extract({settings['record_column']}, '$.triage_status'), '') IS NOT NULL " + f"OR NULLIF(json_extract({settings['record_column']}, '$._triage_persisted_at'), '') IS NOT NULL " + f"OR json_extract({settings['record_column']}, '$.triage_report') IS NOT NULL)" + ) query = ( f"SELECT {settings['record_column']} AS record_json " f"FROM {settings['table']} " - f"WHERE {settings['date_column']} = ? " + f"WHERE {settings['date_column']} = ?{time_filter}{triage_filter} " f"ORDER BY {settings['event_time_column']}, rowid" ) try: with sqlite3.connect(settings["db_path"]) as conn: - rows = conn.execute(query, (source.date,)).fetchall() + cursor = conn.execute(query, query_params) + for row in cursor: + try: + payload = json.loads(row[0]) + except Exception: + yield None + continue + if isinstance(payload, dict): + yield payload + elif isinstance(payload, list): + yield from _iter_json_payload(payload) + else: + yield None except Exception: return - for row in rows: - try: - payload = json.loads(row[0]) - except Exception: - yield None - continue - if isinstance(payload, dict): - yield payload - elif isinstance(payload, list): - yield from _iter_json_payload(payload) - else: - yield None - def _iter_json_payload(payload): if isinstance(payload, list): @@ -1012,7 +2634,7 @@ def _without_counters(payload): return { key: value for key, value in payload.items() - if not isinstance(value, Counter) + if not isinstance(value, Counter) and not key.startswith("_") } diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml index 5c0d6b949..8f85fbabb 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/api/routes.yaml @@ -4,3 +4,8 @@ routes: handler: handlers.get_stats timeoutMs: 30000 description: Alert denoise and triage dashboard statistics + - method: GET + path: /activity + handler: handlers.get_activity + timeoutMs: 5000 + description: Incremental alert denoise and triage activity diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx index 7a3642b29..08092a6c4 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx @@ -23,7 +23,20 @@ const EMPTY_STATS = { generatedAt: '', latencyMs: 0, sourceStatus: { workflowRoot: '', denoise: [], triage: [], denoiseFiles: [], triageFiles: [], missing: [] }, - denoise: { totalRaw: 0, totalUnique: 0, duplicates: 0, duplicateRate: 0, uniqueRate: 0, files: 0, parseErrors: 0 }, + denoise: { + totalRaw: 0, + totalNormalized: 0, + afterFilter: 0, + totalUnique: 0, + filterRemoved: 0, + dedupRemoved: 0, + duplicates: 0, + duplicateRate: 0, + dedupRate: 0, + uniqueRate: 0, + files: 0, + parseErrors: 0, + }, triage: { totalRecords: 0, newTriaged: 0, @@ -40,6 +53,7 @@ const EMPTY_STATS = { successRate: 0, cacheRate: 0, coverageRate: 0, + avgTriageMs: 0, files: 0, parseErrors: 0, }, @@ -65,24 +79,296 @@ const EMPTY_STATS = { timeline: { denoiseRaw: [], denoiseUnique: [], triageTotal: [], triageAttack: [] }, }; +const ACTIVITY_QUEUE_LIMIT = 8; +const EVENT_RAIL_TASK_LIMIT = 10; +const ACTIVITY_POLL_MS = 3000; +const ACTIVITY_REPLAY_WINDOW_MS = 10 * 60 * 1000; +const ACTIVITY_SEEN_KEY = 'soc-dashboard-seen-activity-v1'; +const DEFAULT_TIME_RANGE = '7d'; +const TIME_RANGE_OPTIONS = [ + { value: '15m', label: '最近15分钟' }, + { value: '2h', label: '最近2小时' }, + { value: '24h', label: '最近24小时' }, + { value: 'today', label: '今天' }, + { value: '7d', label: '最近7天' }, + { value: '30d', label: '最近30天' }, +]; +const REFRESH_OPTIONS = [ + { value: '5s', label: '5秒' }, + { value: '15s', label: '15秒' }, + { value: '1m', label: '1分钟' }, + { value: '5m', label: '5分钟' }, + { value: '1h', label: '1小时' }, + { value: 'off', label: '关闭' }, +]; +const REFRESH_INTERVAL_MS = { + off: 0, + '5s': 5000, + '15s': 15000, + '1m': 60000, + '5m': 300000, + '1h': 3600000, +}; + +function emptyActivityBatch() { + return { + mode: 'normal', + windowMs: ACTIVITY_POLL_MS, + receivedCount: 0, + duplicateCount: 0, + uniqueCount: 0, + clusterCount: 0, + triageUpdatedCount: 0, + sampledCount: 0, + suppressedCount: 0, + ratePerSecond: 0, + }; +} + +function createActivityState() { + return { + connection: 'initializing', + denoise: { current: null, queue: [], last: null }, + triage: { current: null, queue: [], last: null }, + recent: [], + batch: emptyActivityBatch(), + batchUpdatedAt: 0, + mode: 'normal', + calmPolls: 0, + generatedAt: '', + }; +} + +function activityDuration(event) { + if (!event) return 0; + if (event.stage === 'denoise') { + if (event.playbackMode === 'surge') return 5200; + if (event.playbackMode === 'burst') return 6200; + return 7200; + } + if (event.status === 'failed') return 6000; + if (['cache', 'cached', 'follower', 'follower_reused'].includes(event.result?.triageSource)) return 8000; + return 30000; +} + +function normalizeActivityBatch(raw) { + const batch = { ...emptyActivityBatch(), ...(raw || {}) }; + for (const key of ['windowMs', 'receivedCount', 'duplicateCount', 'uniqueCount', 'clusterCount', 'triageUpdatedCount', 'sampledCount', 'suppressedCount', 'ratePerSecond']) { + batch[key] = Math.max(Number(batch[key] || 0), 0); + } + if (!['normal', 'burst', 'surge'].includes(batch.mode)) batch.mode = 'normal'; + return batch; +} + +function resolveActivityMode(previous, batch) { + const busy = batch.receivedCount > 5; + const calmPolls = busy ? 0 : Math.min(previous.calmPolls + 1, 3); + let mode = batch.mode; + if (mode === 'normal' && previous.mode === 'surge' && calmPolls < 3) mode = 'surge'; + if (mode === 'normal' && previous.mode === 'burst' && calmPolls < 2) mode = 'burst'; + return { mode, calmPolls }; +} + +function enqueueActivity(previous, events, generatedAt, recentEvents, rawBatch) { + const batch = normalizeActivityBatch(rawBatch); + const modeState = resolveActivityMode(previous, batch); + const hasBatch = batch.receivedCount > 0 || batch.triageUpdatedCount > 0; + const incomingEvents = (events || []).filter(Boolean); + const incomingRecentEvents = (recentEvents || []).filter(Boolean); + if ( + !hasBatch + && !incomingEvents.length + && !incomingRecentEvents.length + && previous.connection === 'online' + && previous.mode === modeState.mode + && previous.calmPolls === modeState.calmPolls + ) return previous; + const next = { + ...previous, + connection: 'online', + generatedAt: generatedAt || previous.generatedAt, + denoise: { ...previous.denoise, queue: [...previous.denoise.queue] }, + triage: { ...previous.triage, queue: [...previous.triage.queue] }, + recent: [...previous.recent], + batch: hasBatch ? batch : previous.batch, + batchUpdatedAt: hasBatch ? Date.now() : previous.batchUpdatedAt, + mode: modeState.mode, + calmPolls: modeState.calmPolls, + }; + if (batch.mode !== 'normal' && batch.receivedCount > 0) next.denoise.queue = []; + for (const event of incomingEvents) { + if (!event || !['denoise', 'triage'].includes(event.stage) || !event.eventId) continue; + const enriched = event.stage === 'denoise' + ? { ...event, playbackMode: batch.mode, batch } + : event; + const lane = next[enriched.stage]; + const known = [lane.current, lane.last, ...lane.queue].some((item) => item?.eventId === enriched.eventId); + if (!known) lane.queue.push(enriched); + } + for (const kind of ['denoise', 'triage']) { + const lane = next[kind]; + if (lane.queue.length > ACTIVITY_QUEUE_LIMIT) { + lane.queue = lane.queue.slice(-ACTIVITY_QUEUE_LIMIT); + } + } + const incomingRecent = incomingRecentEvents.length ? incomingRecentEvents : [...incomingEvents].reverse(); + for (const event of [...incomingRecent].reverse()) { + if (!event?.eventId) continue; + const merged = [event, ...next.recent.filter((item) => item.eventId !== event.eventId)]; + next.recent = [ + ...merged.filter((item) => item.stage === 'triage').slice(0, 12), + ...merged.filter((item) => item.stage === 'denoise').slice(0, 12), + ].sort((left, right) => Date.parse(right.occurredAt || '') - Date.parse(left.occurredAt || '')); + } + return next; +} + +function completeActivity(previous, kind, completed) { + const lane = previous[kind]; + if (lane.current?.eventId !== completed.eventId) return previous; + const [current = null, ...queue] = lane.queue; + return { + ...previous, + [kind]: { ...lane, current, queue, last: completed }, + }; +} + +function recentUnseenActivity(events) { + const fresh = (events || []).filter((event) => { + const occurredAt = Date.parse(event?.occurredAt || ''); + return event?.eventId && Number.isFinite(occurredAt) && Date.now() - occurredAt <= ACTIVITY_REPLAY_WINDOW_MS; + }); + if (!fresh.length) return []; + try { + const stored = JSON.parse(window.sessionStorage.getItem(ACTIVITY_SEEN_KEY) || '[]'); + const seen = new Set(Array.isArray(stored) ? stored : []); + const unseen = fresh.filter((event) => !seen.has(event.eventId)); + const nextSeen = [...seen, ...fresh.map((event) => event.eventId)].slice(-120); + window.sessionStorage.setItem(ACTIVITY_SEEN_KEY, JSON.stringify(nextSeen)); + return [...unseen].reverse(); + } catch { + return [...fresh].reverse(); + } +} + function todayLocal() { const now = new Date(); const offset = now.getTimezoneOffset() * 60000; return new Date(now.getTime() - offset).toISOString().slice(0, 10); } +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function toLocalInputValue(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +} + +function parseLocalInputValue(value) { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function startOfToday(now) { + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); +} + +function resolveRelativeWindow(range, now = new Date()) { + const end = new Date(now); + if (range === 'today') return [startOfToday(now), end]; + const spans = { + '15m': 15 * 60 * 1000, + '1h': 60 * 60 * 1000, + '2h': 2 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + }; + return [new Date(end.getTime() - (spans[range] || spans[DEFAULT_TIME_RANGE])), end]; +} + +function createRelativeTimeFilter(range = DEFAULT_TIME_RANGE) { + const [start, end] = resolveRelativeWindow(range); + return { mode: 'relative', range, start: toLocalInputValue(start), end: toLocalInputValue(end) }; +} + +function resolveTimeWindow(filter, now = new Date()) { + if (filter.mode === 'relative') return resolveRelativeWindow(filter.range, now); + const start = parseLocalInputValue(filter.start); + const end = parseLocalInputValue(filter.end); + if (!start || !end) return null; + return start <= end ? [start, end] : [end, start]; +} + +function timeFilterParams(filter) { + const window = resolveTimeWindow(filter); + if (!window) return {}; + if (filter.mode === 'relative') { + const bucketMinutes = { + '15m': 1, + '1h': 1, + '2h': 1, + '24h': 5, + today: 1, + '7d': 15, + '30d': 60, + }[filter.range] || 1; + const bucketSeconds = bucketMinutes * 60; + const endTime = Math.floor(window[1].getTime() / (bucketSeconds * 1000)) * bucketSeconds + bucketSeconds - 1; + if (filter.range === 'today') { + return { + startTime: Math.floor(window[0].getTime() / 1000), + endTime, + }; + } + const spanSeconds = Math.max(Math.round((window[1].getTime() - window[0].getTime()) / 1000), 1); + return { + startTime: endTime - spanSeconds + 1, + endTime, + }; + } + return { + startTime: Math.floor(window[0].getTime() / 1000), + endTime: Math.floor(window[1].getTime() / 1000), + }; +} + +function timeFilterLabel(filter) { + if (filter.mode === 'relative') { + if (filter.range === '1h') return '最近1小时'; + return TIME_RANGE_OPTIONS.find((option) => option.value === filter.range)?.label || '最近7天'; + } + const window = resolveTimeWindow(filter); + if (!window) return '精确时间'; + const format = (date) => `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; + return `${format(window[0])} 至 ${format(window[1])}`; +} + +function refreshLabel(value) { + return REFRESH_OPTIONS.find((option) => option.value === value)?.label || '关闭'; +} + function mergeStats(raw) { + const denoise = { ...EMPTY_STATS.denoise, ...((raw || {}).denoise || {}) }; + const processedTotal = Math.max(Number(denoise.totalRaw || 0), 0); + denoise.totalNormalized = processedTotal; return { ...EMPTY_STATS, ...(raw || {}), sourceStatus: { ...EMPTY_STATS.sourceStatus, ...((raw || {}).sourceStatus || {}) }, - denoise: { ...EMPTY_STATS.denoise, ...((raw || {}).denoise || {}) }, + denoise, triage: { ...EMPTY_STATS.triage, ...((raw || {}).triage || {}) }, pipeline: { ...EMPTY_STATS.pipeline, ...((raw || {}).pipeline || {}) }, closedLoop: { ...EMPTY_STATS.closedLoop, ...((raw || {}).closedLoop || {}) }, dateRange: { ...EMPTY_STATS.dateRange, ...((raw || {}).dateRange || {}) }, eventRange: { ...EMPTY_STATS.eventRange, ...((raw || {}).eventRange || {}) }, timeline: { ...EMPTY_STATS.timeline, ...((raw || {}).timeline || {}) }, + sources: [ + { key: 'ndr', label: 'NDR', value: processedTotal, rate: processedTotal > 0 ? 1 : 0, active: processedTotal > 0 }, + { key: 'other', label: '其他接入', value: 0, rate: 0, active: false }, + ], }; } @@ -91,6 +377,37 @@ function fullNumber(value) { return new Intl.NumberFormat('zh-CN').format(n); } +function workflowDenoiseActivity(callCount, delta, generatedAt, workflowEvent) { + if (workflowEvent) { + return { + ...workflowEvent, + eventId: `workflow-playback:${callCount}:${workflowEvent.eventId}`, + statsDelta: delta, + workflowCallCount: callCount, + }; + } + const occurredAt = generatedAt || new Date().toISOString(); + return { + eventId: `workflow-denoise:${callCount}:${occurredAt}`, + stage: 'denoise', + status: 'completed', + occurredAt, + triggerSource: 'workflow_stats', + statsDelta: delta, + workflowCallCount: callCount, + hiddenFromQueue: true, + sampleCount: delta, + alert: { + sourceType: 'workflow.db', + threatName: '降噪工作流统计更新', + }, + result: { + clusterId: `累计 ${fullNumber(callCount)}`, + isDuplicate: false, + }, + }; +} + function compactNumber(value) { const n = Number(value || 0); if (Math.abs(n) >= 100000000) return `${trim(n / 100000000)}亿`; @@ -98,6 +415,40 @@ function compactNumber(value) { return fullNumber(n); } +function AnimatedNumber({ value, format, tag = 'span', className, duration = 900 }) { + const { useEffect, useRef, useState } = getReact(); + const target = Number(value || 0); + const current = useRef(0); + const [display, setDisplay] = useState(0); + const formatter = format || ((number) => compactNumber(Math.round(number))); + + useEffect(() => { + const from = current.current; + if (!Number.isFinite(target) || from === target) { + current.current = Number.isFinite(target) ? target : 0; + setDisplay(current.current); + return undefined; + } + let frame = 0; + const startedAt = window.performance.now(); + const tick = (now) => { + const progress = Math.min((now - startedAt) / duration, 1); + const eased = 1 - ((1 - progress) ** 3); + const next = from + (target - from) * eased; + current.current = next; + setDisplay(next); + if (progress < 1) frame = window.requestAnimationFrame(tick); + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [target, duration]); + + return h(tag, { + className: cx('animated-number', className), + title: formatter(target), + }, formatter(display)); +} + function trim(value) { return Number(value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)).toString(); } @@ -220,14 +571,14 @@ function SourceColumn({ stats }) { ]), ]), h(Panel, { key: 'efficiency', title: '降噪效率', meta: '压缩收益' }, h('div', { className: 'side-summary' }, [ - h(SummaryTile, { key: 'dup', label: '重复压缩', value: pct(stats.denoise.duplicateRate), sub: `${compactNumber(stats.denoise.duplicates)} 条收敛`, tone: 'green' }), + h(SummaryTile, { key: 'dup', label: '降噪收敛', value: pct(stats.denoise.duplicateRate), sub: `${compactNumber(stats.denoise.duplicates)} 条过滤/收敛`, tone: 'green' }), h(SummaryTile, { key: 'unique', label: '唯一留存', value: pct(stats.denoise.uniqueRate), sub: `${compactNumber(stats.denoise.totalUnique)} 条进入研判`, tone: 'cyan' }), - h(SummaryTile, { key: 'batch', label: '样例批次', value: compactNumber(stats.denoise.files), sub: '资产目录命中', tone: 'violet' }), + h(SummaryTile, { key: 'batch', label: '工作流批次', value: compactNumber(stats.denoise.files), sub: '当前时间范围', tone: 'violet' }), ])), ]); } -function CenterColumn({ stats }) { +function CenterColumn({ stats, activity }) { const radar = [ { label: '降噪', value: 1 - clamp(stats.denoise.duplicateRate) }, { label: '复用', value: stats.pipeline.workloadReuseRate }, @@ -237,22 +588,18 @@ function CenterColumn({ stats }) { ]; return h('div', { className: 'column center-col' }, [ h('div', { className: 'ai-stage', key: 'stage' }, [ - h(StageCard, { + h(ActivityStageCard, { key: 'stage1', - stage: '阶段一', - title: '智能降噪', - value: stats.denoise.totalUnique, - sub: `重复压缩 ${pct(stats.denoise.duplicateRate)}`, - tone: 'green', + kind: 'denoise', + lane: activity.denoise, + stats, }), - h(AiCore, { key: 'core', stats }), - h(StageCard, { + h(AiCore, { key: 'core', stats, activity }), + h(ActivityStageCard, { key: 'stage2', - stage: '阶段二', - title: '智能研判', - value: stats.triage.totalRecords, - sub: `缓存复用 ${pct(stats.triage.cacheRate)}`, - tone: 'violet', + kind: 'triage', + lane: activity.triage, + stats, }), ]), h('div', { className: 'flow-strip', key: 'flow' }, [ @@ -321,8 +668,192 @@ function StageCard({ stage, title, value, sub, tone }) { ]); } -function AiCore({ stats }) { - return h('div', { className: 'ai-core' }, [ +function activityResultText(event) { + if (!event) return ''; + if (event.stage === 'denoise') { + if (event.triggerSource === 'workflow_execution') { + return ''; + } + if (event.triggerSource === 'workflow_stats') { + return `工作流调用 +${fullNumber(event.statsDelta || 1)}`; + } + const count = Math.max(Number(event.sampleCount || 1), 1); + const result = event.result?.isDuplicate ? '重复告警已收敛' : '保留代表告警'; + return count > 1 ? `${result} × ${count}` : result; + } + if (event.status === 'failed') return '研判未完成'; + const risk = { high: '高风险', medium: '中风险', low: '低风险' }[String(event.result?.riskLevel || '').toLowerCase()] + || event.result?.riskLevel + || '风险待确认'; + return `${risk} · ${event.result?.verdictLabel || '待确认'}`; +} + +function activitySourceText(event) { + if (!event) return ''; + if (event.stage === 'denoise') { + if (['workflow_stats', 'workflow_execution'].includes(event.triggerSource)) return 'WORKFLOW.DB'; + return event.alert?.sourceType ? String(event.alert.sourceType).toUpperCase() : '新告警'; + } + const source = event.result?.triageSource; + if (['cache', 'cached'].includes(source)) return '历史研判复用'; + if (['follower', 'follower_reused'].includes(source)) return '同类结论复用'; + const seconds = Math.round(Number(event.result?.durationMs || 0) / 1000); + return seconds > 0 ? `AI 研判 ${seconds}s` : 'AI 研判'; +} + +function ActivityStageCard({ kind, lane, stats }) { + const event = lane.current || lane.last; + if (!event) { + return h(StageCard, { + stage: kind === 'denoise' ? '阶段一' : '阶段二', + title: kind === 'denoise' ? '智能降噪' : '智能研判', + value: kind === 'denoise' ? stats.denoise.totalUnique : stats.triage.totalRecords, + sub: kind === 'denoise' ? `降噪收敛 ${pct(stats.denoise.duplicateRate)}` : `缓存复用 ${pct(stats.triage.cacheRate)}`, + tone: kind === 'denoise' ? 'green' : 'violet', + }); + } + + const active = Boolean(lane.current); + const steps = kind === 'denoise' ? ['告警接入', '特征提取', '相似聚类', '降噪结果'] : ['证据提取', '情报关联', 'AI 推理', '生成结论']; + const endpoint = [event.alert?.srcIp, event.alert?.dstIp].filter(Boolean).join(' → '); + const duration = activityDuration(event); + const outcome = activityResultText(event); + return h('div', { + key: event.eventId, + className: cx('stage-card', 'activity-card', kind === 'denoise' ? 'stage-green' : 'stage-violet', active && 'activity-active', event.status === 'failed' && 'activity-failed'), + style: { '--activity-outcome-delay': `${Math.round(duration * 0.65)}ms` }, + }, [ + h('div', { className: 'activity-card-head', key: 'head' }, [ + h('span', { className: 'stage-label', key: 'stage' }, kind === 'denoise' ? '智能降噪' : '智能研判'), + h('span', { className: cx('activity-live', active && 'live'), key: 'live' }, active ? '处理中' : '最近完成'), + ]), + h('strong', { className: 'activity-title', title: event.alert?.threatName, key: 'title' }, event.alert?.threatName || '未知告警'), + h('div', { className: 'activity-meta', key: 'meta' }, [ + h('span', { key: 'source' }, activitySourceText(event)), + endpoint ? h('span', { title: endpoint, key: 'endpoint' }, endpoint) : null, + ]), + h('div', { className: 'activity-stepper', key: 'steps' }, steps.map((step, index) => h('span', { + className: 'activity-step', + key: step, + style: active ? { animationDelay: `${Math.round(index * duration * 0.18)}ms` } : undefined, + }, [h('i', { key: 'dot' }), step]))), + outcome ? h('div', { + className: cx('activity-outcome', event.status === 'failed' && 'failed'), + key: 'outcome', + }, outcome) : null, + ]); +} + +function AiCore({ stats, activity }) { + const denoiseActive = Boolean(activity.denoise.current); + const triageActive = Boolean(activity.triage.current); + const activeCount = Number(denoiseActive) + Number(triageActive); + const activeEvent = activity.denoise.current || activity.triage.current; + const activeKind = activeEvent?.stage || ''; + const queueCount = activity.denoise.queue.length + activity.triage.queue.length; + const coreLabel = activeCount === 2 + ? '双任务处理中' + : denoiseActive + ? activity.mode === 'surge' ? '降噪洪峰处理中' : activity.mode === 'burst' ? '告警批量降噪中' : '告警降噪中' + : triageActive ? '告警研判中' : '智能研判核心'; + const taskDuration = activityDuration(activeEvent); + const workflowDenoiseActive = activeKind === 'denoise' + && ['workflow_stats', 'workflow_execution'].includes(activeEvent?.triggerSource); + const workflowExecutionActive = workflowDenoiseActive && activeEvent?.triggerSource === 'workflow_execution'; + const workflowMetricsAvailable = workflowExecutionActive && activeEvent?.result?.metricsAvailable; + const alertName = activeEvent?.alert?.threatName || '未知告警'; + const alertSource = activeEvent?.alert?.sourceType || '未知来源'; + const sourceAddress = activeEvent?.alert?.srcIp || '待识别'; + const targetAddress = activeEvent?.alert?.dstIp || '待识别'; + const operations = workflowExecutionActive && !workflowMetricsAvailable + ? [ + `接入告警 ${alertName}`, + `识别来源 ${alertSource}`, + `关联资产 ${sourceAddress} → ${targetAddress}`, + '等待可用降噪结果', + ] + : workflowExecutionActive + ? [ + `接入原始告警 ${fullNumber(activeEvent?.result?.rawCount)}`, + `完成标准化 ${fullNumber(activeEvent?.result?.normalizedCount)}`, + `过滤与收敛 ${fullNumber(activeEvent?.result?.reducedCount)}`, + `留存研判告警 ${fullNumber(activeEvent?.result?.uniqueCount)}`, + ] + : workflowDenoiseActive + ? [ + '检测 workflow.db 统计更新', + `读取降噪调用增量 +${fullNumber(activeEvent?.statsDelta || 1)}`, + '同步降噪处理状态', + `累计调用 ${fullNumber(activeEvent?.workflowCallCount)}`, + ] + : activeKind === 'denoise' + ? [ + `接入 ${activeEvent?.alert?.sourceType || '告警数据'}`, + '提取请求与网络特征', + `匹配相似簇 ${activeEvent?.result?.clusterId || '--'}`, + activeEvent?.result?.isDuplicate ? '输出:重复告警收敛' : '输出:保留代表告警', + ] + : [ + '提取攻击证据', + '关联历史情报与资产', + '执行风险推理', + `生成结论:${activeEvent?.result?.verdictLabel || '待确认'}`, + ]; + const evidenceItems = workflowExecutionActive && !workflowMetricsAvailable + ? [ + { label: '告警名称', value: alertName }, + { label: '来源类型', value: alertSource }, + { label: '源地址', value: sourceAddress }, + { label: '目标地址', value: targetAddress }, + ] + : workflowExecutionActive + ? [ + { label: '原始告警', value: fullNumber(activeEvent?.result?.rawCount) }, + { label: '过滤数量', value: fullNumber(activeEvent?.result?.filterRemovedCount) }, + { label: '去重数量', value: fullNumber(activeEvent?.result?.duplicateCount) }, + { label: '降噪率', value: pct(activeEvent?.result?.reductionRate) }, + ] + : workflowDenoiseActive + ? [ + { label: '本次增量', value: `+${fullNumber(activeEvent?.statsDelta || 1)}` }, + { label: '累计处理', value: fullNumber(activeEvent?.workflowCallCount) }, + { label: '处理模式', value: activity.mode === 'surge' ? '洪峰' : activity.mode === 'burst' ? '批量' : '实时' }, + { label: '当前队列', value: fullNumber(queueCount) }, + ] + : activeEvent ? [ + { label: '攻击源', value: activeEvent.alert?.srcIp || activeEvent.alert?.sourceType || '新告警' }, + { label: '目标资产', value: activeEvent.alert?.dstIp || '待识别资产' }, + { label: activeKind === 'denoise' ? '特征' : '攻击路径', value: activeEvent.alert?.requestUri || activeEvent.alert?.threatName || '特征提取中' }, + { label: activeKind === 'denoise' ? '相似聚类' : '风险判断', value: activeKind === 'denoise' ? `簇 ${activeEvent.result?.clusterId || '--'}` : activityResultText(activeEvent) }, + ] : []; + const statusLabel = activity.connection === 'error' + ? '活动数据等待重连' + : activeCount + ? coreLabel + : activity.connection === 'initializing' + ? '活动通道初始化' + : activity.mode === 'surge' ? '洪峰缓冲处理中' : activity.mode === 'burst' ? '批量任务处理中' : '实时活动待机'; + const visibleBatch = activity.batch?.receivedCount > 0 ? activity.batch : null; + return h('div', { + className: cx('ai-core', activeCount && 'core-processing', activeCount === 2 && 'core-dual', activeEvent && 'core-task-active', activeKind && `core-task-${activeKind}`, `core-load-${activity.mode}`), + style: { + '--core-task-duration': `${taskDuration || 8000}ms`, + '--core-task-accent': activeKind === 'triage' ? '#9b8cff' : '#2be7ff', + }, + }, [ + h('div', { className: cx('core-live-status', `status-${activity.connection}`), key: 'status' }, [ + h('i', { key: 'dot' }), + h('span', { key: 'label' }, statusLabel), + queueCount ? h('b', { key: 'queue' }, `队列 ${queueCount}`) : null, + ]), + activeEvent ? h('svg', { + className: 'ai-task-progress', + viewBox: '0 0 340 340', + key: activeKind === 'denoise' ? 'progress-denoise' : `progress-${activeEvent.eventId}`, + }, [ + h('circle', { className: 'ai-task-progress-base', cx: 170, cy: 170, r: 157, pathLength: 100, key: 'base' }), + h('circle', { className: 'ai-task-progress-value', cx: 170, cy: 170, r: 157, pathLength: 100, key: 'value' }), + ]) : null, h('div', { className: 'energy-ring ring-a', key: 'ring-a' }), h('div', { className: 'energy-ring ring-b', key: 'ring-b' }), h('div', { className: 'scan-line', key: 'scan' }), @@ -330,8 +861,19 @@ function AiCore({ stats }) { h('div', { className: 'orbit orbit-b', key: 'orbit-b' }), h('div', { className: 'ai-sphere', key: 'sphere' }, [ h('span', { key: 'ai' }, 'AI'), - h('small', { key: 'label' }, '智能研判核心'), + h('small', { key: 'label' }, coreLabel), + activeEvent ? h('div', { className: 'ai-operation-window', key: `operation-${activeEvent.eventId}` }, [ + h('div', { className: 'ai-operation-track', key: 'track' }, [...operations, operations[0]].map((operation, index) => h('span', { key: `${operation}-${index}` }, operation))), + ]) : h('div', { className: 'ai-operation-idle', key: 'operation-idle' }, '等待新的处理任务'), ]), + activeEvent ? h('div', { className: 'ai-evidence-field', key: `evidence-${activeEvent.eventId}` }, evidenceItems.map((item, index) => h('div', { + className: `ai-evidence-card evidence-${index + 1}`, + key: item.label, + style: { animationDelay: `${180 + index * 220}ms` }, + }, [ + h('span', { key: 'label' }, item.label), + h('b', { title: item.value, key: 'value' }, item.value), + ]))) : null, h('div', { className: 'core-particle particle-a', key: 'particle-a' }), h('div', { className: 'core-particle particle-b', key: 'particle-b' }), h('div', { className: 'core-particle particle-c', key: 'particle-c' }), @@ -339,6 +881,12 @@ function AiCore({ stats }) { h('div', { key: 'left' }, [h('b', { key: 'v' }, pct(stats.pipeline.coverageRate)), h('span', { key: 'l' }, '覆盖率')]), h('div', { key: 'right' }, [h('b', { key: 'v' }, pct(stats.pipeline.successRate)), h('span', { key: 'l' }, '成功率')]), ]), + visibleBatch ? h('div', { className: 'core-batched', key: `batched-${activity.batchUpdatedAt}` }, [ + h('b', { key: 'rate' }, `${trim(visibleBatch.ratePerSecond)} 条/秒`), + h('span', { key: 'received' }, `本批 ${fullNumber(visibleBatch.receivedCount)}`), + h('span', { key: 'duplicate' }, `收敛 ${fullNumber(visibleBatch.duplicateCount)}`), + h('span', { key: 'cluster' }, `${fullNumber(visibleBatch.clusterCount)} 类`), + ]) : null, ]); } @@ -580,41 +1128,840 @@ function Gauge({ label, value, color }) { ]); } +function formatDurationMs(value) { + const totalSeconds = Math.max(Math.round(Number(value || 0) / 1000), 0); + if (!totalSeconds) return '--'; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +function eventTimeLabel(value) { + if (!value) return '--:--'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '--:--'; + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }); +} + +function eventEndpoint(event) { + return [event?.alert?.srcIp, event?.alert?.dstIp].filter(Boolean).join(' → ') || '未提供网络端点'; +} + +function eventMatchesTimeFilter(event, filter) { + const occurredAt = Date.parse(event?.occurredAt || ''); + const window = resolveTimeWindow(filter); + return Number.isFinite(occurredAt) + && Boolean(window) + && occurredAt >= window[0].getTime() + && occurredAt <= window[1].getTime(); +} + +function TimeRefreshPopover({ value, refreshValue, open, onToggle, onApply, onClose }) { + const { useEffect, useState } = getReact(); + const [tab, setTab] = useState(value.mode === 'custom' ? 'custom' : 'auto'); + const [range, setRange] = useState(value.range); + const [refresh, setRefresh] = useState(refreshValue); + const [start, setStart] = useState(value.start); + const [end, setEnd] = useState(value.end); + + useEffect(() => { + if (!open) return; + const window = resolveTimeWindow(value) || resolveRelativeWindow(value.range); + setTab(value.mode === 'custom' ? 'custom' : 'auto'); + setRange(value.range); + setRefresh(refreshValue); + setStart(toLocalInputValue(window[0])); + setEnd(toLocalInputValue(window[1])); + }, [open, refreshValue, value]); + + const chooseRange = (next) => { + const window = resolveRelativeWindow(next); + setRange(next); + setStart(toLocalInputValue(window[0])); + setEnd(toLocalInputValue(window[1])); + }; + const confirm = () => onApply( + tab === 'custom' ? { mode: 'custom', range, start, end } : createRelativeTimeFilter(range), + refresh, + ); + const optionButton = (option, selected, onClick) => h('button', { + className: cx('command-time-option', selected && 'selected'), + type: 'button', + onClick, + key: option.value, + }, option.label); + + return h('div', { className: 'command-time-filter', 'data-soc-menu-root': 'true' }, [ + h('button', { className: cx('command-time-trigger', open && 'open'), type: 'button', onClick: onToggle, key: 'trigger' }, [ + h('span', { title: timeFilterLabel(value), key: 'range' }, ['时间范围:', h('b', { key: 'value' }, timeFilterLabel(value))]), + h('i', { key: 'divider' }), + h('span', { key: 'refresh' }, ['刷新频率:', h('b', { key: 'value' }, refreshLabel(refreshValue))]), + h('em', { key: 'arrow' }, open ? '⌃' : '⌄'), + ]), + open ? h('div', { className: 'command-time-panel', key: 'panel' }, [ + h('div', { className: 'command-time-tabs', key: 'tabs' }, [ + h('button', { className: tab === 'auto' ? 'active' : '', type: 'button', onClick: () => setTab('auto'), key: 'auto' }, '自动刷新'), + h('button', { className: tab === 'custom' ? 'active' : '', type: 'button', onClick: () => setTab('custom'), key: 'custom' }, '精确时间'), + ]), + h('div', { className: 'command-time-panel-body', key: 'body' }, tab === 'auto' ? [ + h('div', { key: 'ranges' }, [ + h('label', { key: 'label' }, '时间范围'), + h('div', { className: 'command-time-options', key: 'options' }, TIME_RANGE_OPTIONS.map((option) => optionButton(option, range === option.value, () => chooseRange(option.value)))), + ]), + h('div', { key: 'refreshes' }, [ + h('label', { key: 'label' }, '刷新频率'), + h('div', { className: 'command-time-options', key: 'options' }, REFRESH_OPTIONS.map((option) => optionButton(option, refresh === option.value, () => setRefresh(option.value)))), + ]), + ] : [ + h('div', { className: 'command-time-inputs', key: 'inputs' }, [ + h('label', { key: 'start' }, [h('span', { key: 'label' }, '开始时间'), h('input', { type: 'datetime-local', value: start, onChange: (event) => setStart(event.target.value), key: 'input' })]), + h('label', { key: 'end' }, [h('span', { key: 'label' }, '结束时间'), h('input', { type: 'datetime-local', value: end, onChange: (event) => setEnd(event.target.value), key: 'input' })]), + ]), + h('div', { className: 'command-time-shortcuts', key: 'shortcuts' }, [ + { value: '1h', label: '1小时' }, + { value: '24h', label: '24小时' }, + { value: 'today', label: '今天' }, + { value: '7d', label: '最近7天' }, + { value: '30d', label: '最近30天' }, + ].map((option) => h('button', { type: 'button', onClick: () => chooseRange(option.value), key: option.value }, option.label))), + ]), + h('div', { className: 'command-time-panel-actions', key: 'actions' }, [ + h('button', { type: 'button', onClick: onClose, key: 'cancel' }, '取消'), + h('button', { className: 'primary', type: 'button', onClick: confirm, key: 'confirm' }, '确定'), + ]), + ]) : null, + ]); +} + +function CommandHeader({ timeFilter, refreshKey, timeMenuOpen, setTimeMenuOpen, applyTimeRefresh, stats, loading, refresh, activity }) { + const active = activity.denoise.current || activity.triage.current; + const loadActive = activity.mode !== 'normal' && activity.batch?.receivedCount > 0; + const status = activity.connection === 'error' + ? '活动通道重连中' + : activity.mode === 'surge' ? '降噪洪峰处理中' + : activity.mode === 'burst' ? '告警批量处理中' + : active ? 'AI任务处理中' : 'AI运营处理'; + return h('header', { className: 'command-header' }, [ + h('div', { className: 'command-brand', key: 'brand' }, [ + h('div', { className: 'command-logo', key: 'logo' }, 'AI'), + h('div', { key: 'copy' }, [ + h('strong', { key: 'title' }, 'Flocks AI 智能告警态势中心'), + h('span', { key: 'sub' }, '告警汇聚 · 智能降噪 · 自动研判 · 风险聚合'), + ]), + ]), + h('div', { className: cx('command-live', loadActive && `load-${activity.mode}`), key: 'live' }, [ + h('i', { className: activity.connection === 'error' ? 'warn' : '', key: 'dot' }), + h('span', { key: 'text' }, status), + h(AnimatedNumber, { + tag: 'b', + value: loadActive ? activity.batch.ratePerSecond : stats.denoise.totalRaw, + format: (value) => loadActive ? `${trim(value)} 条/秒` : `${compactNumber(Math.round(value))} 条告警`, + key: loadActive ? 'rate' : 'count', + }), + ]), + h('div', { className: 'command-tools', key: 'tools' }, [ + h(TimeRefreshPopover, { + value: timeFilter, + refreshValue: refreshKey, + open: timeMenuOpen, + onToggle: () => setTimeMenuOpen((current) => !current), + onApply: applyTimeRefresh, + onClose: () => setTimeMenuOpen(false), + key: 'time-filter', + }), + h('button', { className: 'command-refresh', type: 'button', disabled: loading, onClick: refresh, key: 'refresh' }, loading ? '刷新中' : '刷新'), + h('div', { className: 'command-clock', key: 'clock' }, [ + h('b', { key: 'time' }, stats.generatedAt ? stats.generatedAt.slice(11, 19) : '--:--:--'), + h('span', { title: timeFilterLabel(timeFilter), key: 'range' }, timeFilterLabel(timeFilter)), + ]), + ]), + ]); +} + +function CommandConnections() { + const sourceTopPath = 'M98 110 C188 110 235 224 330 224'; + const sourceBottomPath = 'M98 348 C188 348 235 224 330 224'; + const denoisePath = 'M330 224 C375 224 410 224 460 224'; + const triageAutoPath = 'M755 224 C800 224 815 128 860 128'; + const triageManualPath = 'M755 224 C800 224 815 311 860 311'; + const severityTargets = [109, 185, 261, 336]; + const connectionPath = (className, path, key) => [ + h('path', { className: `command-link ${className}`, d: path, key: `${key}-band` }), + h('path', { className: `command-link-dots ${className}`, d: path, key: `${key}-dots` }), + ]; + const movingParticle = (className, path, duration, begin, key) => h('circle', { + className: `command-flow-particle ${className}`, + r: className.includes('result') ? 4.5 : 3.5, + key, + }, [ + h('animateMotion', { path, dur: duration, begin, repeatCount: 'indefinite', key: 'motion' }), + h('animate', { + attributeName: 'fill-opacity', + values: '0;1;1;0', + keyTimes: '0;.12;.82;1', + dur: duration, + begin, + repeatCount: 'indefinite', + key: 'opacity', + }), + ]); + const severityPaths = severityTargets.flatMap((targetY, index) => [ + ...connectionPath('risk-link', `M930 128 C975 128 1008 ${targetY} 1058 ${targetY}`, `risk-a-${index}`), + ...connectionPath('risk-link dim', `M930 311 C975 311 1008 ${targetY} 1058 ${targetY}`, `risk-b-${index}`), + ]); + return h('svg', { className: 'command-links', viewBox: '0 0 1200 510', preserveAspectRatio: 'none', 'aria-hidden': 'true' }, [ + ...connectionPath('source-link', sourceTopPath, 'source-a'), + ...connectionPath('source-link', sourceBottomPath, 'source-b'), + ...connectionPath('denoise-link', denoisePath, 'denoise'), + ...connectionPath('triage-link', triageAutoPath, 'triage-a'), + ...connectionPath('triage-link dim', triageManualPath, 'triage-b'), + ...severityPaths, + movingParticle('flow-denoise', sourceTopPath, '2.8s', '0s', 'particle-source-a'), + movingParticle('flow-denoise', sourceBottomPath, '3.1s', '-1.2s', 'particle-source-b'), + movingParticle('flow-denoise result-particle', denoisePath, '1.45s', '-.5s', 'particle-denoise'), + movingParticle('flow-triage', triageAutoPath, '1.9s', '0s', 'particle-triage'), + movingParticle('flow-triage result-particle', 'M930 128 C975 128 1008 185 1058 185', '1.7s', '-.8s', 'particle-result'), + ]); +} + +function CommandActivityLane({ kind, lane }) { + const event = lane.current || lane.last; + const active = Boolean(lane.current); + const steps = kind === 'denoise' ? ['接入', '特征', '聚类', '降噪'] : ['证据', '情报', '推理', '结论']; + const duration = activityDuration(event); + const drumDuration = kind === 'denoise' ? (active ? '2.6s' : '6.6s') : (active ? '7.2s' : '9.2s'); + const drumSteps = [...steps, ...steps]; + const playbackMode = kind === 'denoise' ? event?.playbackMode : 'normal'; + const status = active + ? playbackMode === 'surge' ? '洪峰处理' : playbackMode === 'burst' ? '批量处理' : '处理中' + : event ? '最近完成' : '待机巡航'; + const sampleCount = Math.max(Number(event?.sampleCount || 1), 1); + const eventTitle = event?.alert?.threatName + ? `${event.alert.threatName}${sampleCount > 1 ? ` × ${sampleCount}` : ''}` + : '等待新告警进入'; + const resultText = event ? activityResultText(event) : '自动巡检 · 等待任务'; + return h('div', { + className: cx('command-activity-lane', `lane-${kind}`, active && 'active'), + style: { + '--drum-duration': drumDuration, + '--drum-accent': kind === 'denoise' ? '#2be7ff' : '#9b8cff', + }, + }, [ + h('div', { className: 'command-lane-copy', key: 'copy' }, [ + h('div', { className: 'command-lane-head', key: 'head' }, [ + h('span', { key: 'label' }, kind === 'denoise' ? '智能降噪' : '智能研判'), + h('b', { key: 'status' }, status), + ]), + h('strong', { title: eventTitle, key: 'title' }, eventTitle), + resultText ? h('div', { + className: cx('command-lane-result', !event && 'idle'), + style: active ? { animationDelay: `${Math.round(duration * 0.65)}ms` } : undefined, + key: 'result', + }, resultText) : null, + ]), + h('div', { className: 'command-drum-shell', 'aria-label': steps.join('、'), key: 'drum' }, [ + h('div', { className: 'command-drum-caption', key: 'caption' }, [ + h('b', { key: 'mode' }, active ? '处理' : '巡航'), + ]), + h('div', { className: 'command-drum-window', key: 'window' }, [ + h('div', { className: 'command-drum-track', 'aria-hidden': 'true', key: 'track' }, drumSteps.map((step, index) => h('span', { + className: 'command-drum-step', + key: `${step}-${index}`, + }, [h('i', { key: 'dot' }), h('b', { key: 'label' }, step), h('small', { key: 'index' }, String((index % steps.length) + 1).padStart(2, '0'))]))), + h('div', { className: 'command-drum-focus', key: 'focus' }), + h('i', { className: 'command-drum-scan', key: 'scan' }), + ]), + ]), + ]); +} + +function severityRows(stats) { + return [ + { key: 'critical', label: '严重', value: stats.triage.attackSuccess || 0, tone: 'critical' }, + { key: 'high', label: '高危', value: stats.triage.attack || 0, tone: 'high' }, + { key: 'medium', label: '中危', value: stats.triage.attackFailed || 0, tone: 'medium' }, + { key: 'low', label: '低危', value: stats.triage.benign || 0, tone: 'low' }, + ]; +} + +function CommandGraph({ stats, activity }) { + const denoiseActive = Boolean(activity.denoise.current); + const triageActive = Boolean(activity.triage.current); + const severityToneFor = (event) => { + if (!event) return ''; + if (event.result?.verdict === 'attack_success') return 'critical'; + const risk = String(event.result?.riskLevel || '').toLowerCase(); + if (risk === 'high') return 'high'; + if (risk === 'medium') return 'medium'; + if (risk === 'low' || event.result?.verdict === 'benign') return 'low'; + return ''; + }; + const activeSeverityTone = severityToneFor(activity.triage.current); + const recentSeverityTone = severityToneFor(activity.triage.last); + const activeSources = [...(stats.sources || [])].sort((a, b) => Number(b.value || 0) - Number(a.value || 0)).slice(0, 2); + while (activeSources.length < 2) activeSources.push({ key: `source-${activeSources.length}`, label: activeSources.length ? '备用数据源' : '告警数据源', value: 0 }); + const severities = severityRows(stats); + return h('section', { className: cx('command-graph', denoiseActive && 'denoise-running', triageActive && 'triage-running', `load-${activity.mode}`) }, [ + h(CommandConnections, { key: 'links' }), + h('div', { className: 'source-stack', key: 'sources' }, activeSources.map((source) => h('div', { className: 'command-source', key: source.key }, [ + h('span', { key: 'label' }, source.label), + h(AnimatedNumber, { tag: 'b', value: source.value, key: 'value' }), + h('i', { key: 'port' }), + ]))), + h('div', { className: 'merge-node', key: 'merge' }, [ + h(AnimatedNumber, { tag: 'b', value: stats.denoise.totalNormalized, duration: 1100, key: 'value' }), + h('span', { key: 'label' }, '汇聚告警'), + h('small', { key: 'sub' }, `过滤 ${compactNumber(stats.denoise.filterRemoved)} · 去重 ${compactNumber(stats.denoise.dedupRemoved)}`), + ]), + h('div', { className: 'command-core command-original-core', key: 'core' }, [ + h(AiCore, { stats, activity, key: 'sphere' }), + ]), + h('div', { className: 'outcome-stack', key: 'outcomes' }, [ + h('div', { title: 'AI 新完成且未使用缓存、复用或失败的研判数量', key: 'auto' }, [h(AnimatedNumber, { tag: 'b', value: stats.triage.newTriaged, key: 'value' }), h('span', { key: 'label' }, 'AI自主研判')]), + h('div', { className: cx('primary', triageActive && 'processing'), title: '研判结论为攻击成功、攻击行为或攻击失败的事件数量', key: 'events' }, [h(AnimatedNumber, { tag: 'b', value: stats.triage.attackTotal, key: 'value' }), h('span', { key: 'label' }, triageActive ? '结果生成中' : '安全事件')]), + h('div', { key: 'manual' }, [h(AnimatedNumber, { tag: 'b', value: stats.closedLoop.manualDecision, key: 'value' }), h('span', { key: 'label' }, '人工研判')]), + ]), + h('div', { className: 'severity-stack', key: 'severity' }, severities.map((item) => h('div', { + className: cx(`severity-node severity-${item.tone}`, item.tone === activeSeverityTone && 'active-target', !activeSeverityTone && item.tone === recentSeverityTone && 'recent-target'), + key: `${item.key}-${activity.triage.last?.eventId || 'idle'}`, + }, [ + h('span', { key: 'label' }, item.label), + h(AnimatedNumber, { tag: 'b', value: item.value, key: 'value' }), + ]))), + h('div', { className: 'command-lanes', key: 'lanes' }, [ + h(CommandActivityLane, { kind: 'denoise', lane: activity.denoise, key: 'denoise' }), + h(CommandActivityLane, { kind: 'triage', lane: activity.triage, key: 'triage' }), + ]), + ]); +} + +function CommandMetric({ label, value, format, sub, values, color }) { + return h('div', { className: 'command-metric', style: { '--metric-color': color } }, [ + h('span', { key: 'label' }, label), + h(AnimatedNumber, { tag: 'b', className: 'command-metric-value', value, format, duration: 1200, key: 'value' }), + h('small', { key: 'sub' }, sub), + h(Sparkline, { values, color, key: 'trend' }), + ]); +} + +function CommandMetrics({ stats }) { + return h('section', { className: 'command-metrics' }, [ + h(CommandMetric, { label: '原始告警量', value: stats.denoise.totalRaw, sub: `${compactNumber(stats.denoise.totalUnique)} 条进入研判`, values: stats.timeline.denoiseRaw, color: '#2e72ff', key: 'raw' }), + h(CommandMetric, { label: '安全事件量', value: stats.triage.attackTotal, sub: `${compactNumber(stats.triage.attackSuccess)} 条攻击成功`, values: stats.timeline.triageAttack, color: '#23ca8e', key: 'events' }), + h(CommandMetric, { label: '降噪率', value: stats.denoise.duplicateRate * 100, format: (value) => `${trim(value)}%`, sub: `${compactNumber(stats.denoise.duplicates)} 条告警已过滤/收敛`, values: stats.timeline.denoiseUnique, color: '#21d8a3', key: 'rate' }), + h(CommandMetric, { label: '平均研判时间', value: stats.triage.avgTriageMs, format: (value) => formatDurationMs(value), sub: `${compactNumber(stats.triage.totalRecords)} 条已完成研判`, values: stats.timeline.triageTotal, color: '#ff674d', key: 'mtta' }), + ]); +} + +function activityTimestamp(event) { + const value = Date.parse(event?.occurredAt || ''); + return Number.isFinite(value) ? value : 0; +} + +function activityTaskKey(event) { + const alertId = String(event?.alert?.id || '').trim(); + return alertId || String(event?.eventId || '').trim(); +} + +function buildEventQueueTasks(activity, timeFilter) { + const stateByEventId = new Map(); + for (const kind of ['denoise', 'triage']) { + const lane = activity[kind]; + if (lane.current?.eventId) stateByEventId.set(lane.current.eventId, 'processing'); + for (const event of lane.queue) { + if (event?.eventId) stateByEventId.set(event.eventId, 'waiting'); + } + } + + const allEvents = [ + ...(activity.recent || []), + activity.denoise.last, + activity.triage.last, + ...activity.denoise.queue, + ...activity.triage.queue, + activity.denoise.current, + activity.triage.current, + ].filter((event) => event?.eventId && !event.hiddenFromQueue && eventMatchesTimeFilter(event, timeFilter)); + const taskByKey = new Map(); + for (const event of allEvents) { + const key = activityTaskKey(event); + if (!key) continue; + const task = taskByKey.get(key) || { key, denoise: null, triage: null, latestAt: 0 }; + task[event.stage] = event; + task.latestAt = Math.max(task.latestAt, activityTimestamp(event)); + taskByKey.set(key, task); + } + + const tasks = [...taskByKey.values()].map((task) => { + const denoiseState = stateByEventId.get(task.denoise?.eventId) || ''; + const triageState = stateByEventId.get(task.triage?.eventId) || ''; + let state = 'completed'; + let stage = task.triage ? 'triage' : 'denoise'; + if (triageState === 'processing') { + state = 'processing'; + stage = 'triage'; + } else if (denoiseState === 'processing') { + state = 'processing'; + stage = 'denoise'; + } else if (triageState === 'waiting') { + state = 'waiting'; + stage = 'triage'; + } else if (denoiseState === 'waiting') { + state = 'waiting'; + stage = 'denoise'; + } else if (!task.triage && task.denoise && task.denoise.status !== 'failed' && !task.denoise.result?.isDuplicate) { + state = 'waiting'; + stage = 'triage'; + } + return { + ...task, + state, + stage, + event: stage === 'triage' && task.triage ? task.triage : task.denoise, + }; + }); + + const stateRank = { processing: 0, waiting: 1, completed: 2 }; + tasks.sort((left, right) => { + const stateDelta = stateRank[left.state] - stateRank[right.state]; + if (stateDelta) return stateDelta; + if (left.state === 'waiting') return right.latestAt - left.latestAt; + if (left.state === 'processing' && left.stage !== right.stage) return left.stage === 'triage' ? -1 : 1; + return right.latestAt - left.latestAt; + }); + + return tasks; +} + +function useAnimatedTaskWindow(tasks, transitionKey) { + const { useEffect, useRef, useState } = getReact(); + const sourceTasks = tasks.slice(0, EVENT_RAIL_TASK_LIMIT); + const sourceRef = useRef(sourceTasks); + const initialTasks = useRef(sourceTasks.map((task) => ({ ...task, motion: 'stable' }))); + const [displayedTasks, setDisplayedTasks] = useState(initialTasks.current); + const displayedRef = useRef(displayedTasks); + const exitTimerRef = useRef(0); + const transitionKeyRef = useRef(transitionKey); + const filterTransitionRef = useRef(false); + + if (transitionKeyRef.current !== transitionKey) { + transitionKeyRef.current = transitionKey; + filterTransitionRef.current = true; + } + + sourceRef.current = sourceTasks; + displayedRef.current = displayedTasks; + + const signature = sourceTasks.map((task) => [ + task.key, + task.state, + task.stage, + task.event?.eventId || '', + task.event?.playbackStartedAt || '', + task.latestAt || '', + ].join(':')).join('|'); + + useEffect(() => { + const current = displayedRef.current; + const latest = sourceRef.current; + const latestByKey = new Map(latest.map((task) => [task.key, task])); + const isExiting = (task) => task.motion === 'exit' || task.motion === 'filter-exit'; + const removed = current.some((task) => !isExiting(task) && !latestByKey.has(task.key)); + + if (removed) { + const filterTransition = filterTransitionRef.current; + const exitMotion = filterTransition ? 'filter-exit' : 'exit'; + const exiting = current.map((task) => { + if (isExiting(task)) return task; + const updated = latestByKey.get(task.key); + return updated ? { ...updated, motion: 'stable' } : { ...task, motion: exitMotion }; + }); + displayedRef.current = exiting; + setDisplayedTasks(exiting); + window.clearTimeout(exitTimerRef.current); + exitTimerRef.current = window.setTimeout(() => { + const retainedKeys = new Set(displayedRef.current + .filter((task) => !isExiting(task)) + .map((task) => task.key)); + const filled = sourceRef.current.map((task) => ({ + ...task, + motion: retainedKeys.has(task.key) ? 'stable' : 'enter', + })); + displayedRef.current = filled; + setDisplayedTasks(filled); + filterTransitionRef.current = false; + }, filterTransition ? 240 : 380); + return; + } + + if (current.some(isExiting)) { + const refreshed = current.map((task) => { + if (isExiting(task)) return task; + const updated = latestByKey.get(task.key); + return updated ? { ...updated, motion: 'stable' } : task; + }); + displayedRef.current = refreshed; + setDisplayedTasks(refreshed); + return; + } + + if (!current.length) filterTransitionRef.current = false; + + const currentKeys = new Set(current.map((task) => task.key)); + const reconciled = latest.map((task) => ({ + ...task, + motion: currentKeys.has(task.key) ? 'stable' : 'enter', + })); + displayedRef.current = reconciled; + setDisplayedTasks(reconciled); + }, [signature, transitionKey]); + + useEffect(() => () => window.clearTimeout(exitTimerRef.current), []); + return displayedTasks; +} + +function EventQueueProgress({ event }) { + const { useEffect, useRef, useState } = getReact(); + const duration = Math.max(activityDuration(event), 1); + const start = useRef({ eventId: '', value: 0 }); + if (start.current.eventId !== event.eventId) { + start.current = { + eventId: event.eventId, + value: Number(event.playbackStartedAt || Date.now()), + }; + } + const [now, setNow] = useState(Date.now()); + useEffect(() => { + const update = () => setNow(Date.now()); + update(); + const id = window.setInterval(update, 500); + return () => window.clearInterval(id); + }, [event.eventId]); + const elapsed = Math.min(Math.max(now - start.current.value, 0), duration); + const progress = elapsed / duration; + const elapsedSeconds = Math.min(Math.floor(elapsed / 1000), Math.round(duration / 1000)); + return h('div', { className: 'event-rail-progress', 'aria-label': 'AI 任务处理进度' }, [ + h('span', { className: 'event-rail-progress-track', style: { '--queue-progress': progress }, key: 'track' }, [h('i', { key: 'fill' })]), + h('small', { key: 'duration' }, `${elapsedSeconds}s / ${Math.round(duration / 1000)}s`), + ]); +} + +function CommandEventRail({ activity, timeFilter, collapsed, onToggle }) { + const tasks = buildEventQueueTasks(activity, timeFilter); + const filterTransitionKey = [timeFilter.mode, timeFilter.range, timeFilter.start, timeFilter.end].join('|'); + const visibleTasks = useAnimatedTaskWindow( + tasks.filter((task) => task.state !== 'completed'), + filterTransitionKey, + ); + const counts = { + processing: visibleTasks.filter((task) => task.state === 'processing').length, + waiting: visibleTasks.filter((task) => task.state === 'waiting').length, + }; + const queueCount = visibleTasks.length; + const banner = activity.connection === 'error' + ? '处理任务连接异常,正在重试' + : counts.processing + ? `AI 正在并行处理 ${counts.processing} 个任务` + : counts.waiting ? '最新 10 条待处理任务' : '等待新的降噪或研判任务'; + const content = collapsed ? [] : [ + h('div', { className: 'event-rail-head', key: 'head' }, [ + h('div', { key: 'title' }, [h('strong', { key: 'label' }, 'AI处理任务'), h('span', { key: 'sub' }, '最新 10 条待处理任务')]), + h(AnimatedNumber, { tag: 'b', value: queueCount, duration: 600, key: 'count' }), + ]), + h('div', { className: cx('event-update-banner', activity.connection === 'error' && 'warn'), key: 'banner' }, banner), + h('div', { className: 'event-rail-list', key: 'list' }, visibleTasks.length ? visibleTasks.map((task) => { + const event = task.event; + const sampleCount = Math.max(Number(task.denoise?.sampleCount || 1), 1); + const title = `${event?.alert?.threatName || '未知告警'}${sampleCount > 1 ? ` × ${sampleCount}` : ''}`; + const stageLabel = task.stage === 'triage' + ? task.state === 'waiting' ? '待研判' : '智能研判' + : task.state === 'waiting' ? '待降噪' : '智能降噪'; + const stateLabel = task.state === 'processing' + ? '处理中' + : '等待处理'; + const detail = event?.triggerSource === 'workflow_execution' + ? event.result?.isDuplicate ? '重复告警已收敛' : '降噪处理完成' + : task.state === 'processing' + ? task.stage === 'triage' ? '证据关联与结论生成中' : '特征提取与相似聚类中' + : '等待 AI 处理'; + return h('article', { + className: cx('event-rail-item', `state-${task.state}`, `kind-${task.stage}`, `motion-${task.motion || 'stable'}`), + key: task.key, + }, [ + h('div', { className: 'event-rail-meta', key: 'meta' }, [ + h('span', { className: cx('event-queue-kind', `kind-${task.stage}`), key: 'kind' }, stageLabel), + h('span', { className: 'event-stage', key: 'stage' }, stateLabel), + h('time', { key: 'time' }, eventTimeLabel(event?.occurredAt)), + ]), + h('strong', { title, key: 'title' }, title), + h('span', { title: eventEndpoint(event), key: 'endpoint' }, eventEndpoint(event)), + h('small', { key: 'result' }, detail), + task.state === 'processing' ? h(EventQueueProgress, { event, key: 'progress' }) : null, + ]); + }) : h('div', { className: 'event-rail-empty' }, '等待新的降噪或研判任务')), + ]; + return h('aside', { className: cx('command-event-rail', collapsed && 'collapsed') }, [ + h('button', { + className: 'event-rail-toggle', + type: 'button', + title: collapsed ? '展开 AI处理任务' : '向右折叠', + 'aria-label': collapsed ? '展开 AI处理任务' : '向右折叠 AI处理任务', + onClick: onToggle, + key: 'toggle', + }, collapsed ? h('span', { key: 'label' }, ['任', '务', '面', '板'].map((text) => h('i', { key: text }, text))) : '›'), + ...content, + ]); +} + export default function Page() { - const { useCallback, useEffect, useState } = getReact(); - const [startDate, setStartDate] = useState(todayLocal()); - const [endDate, setEndDate] = useState(todayLocal()); + const { useCallback, useEffect, useRef, useState } = getReact(); + const [timeFilter, setTimeFilter] = useState(() => createRelativeTimeFilter()); + const [refreshKey, setRefreshKey] = useState('off'); + const [timeMenuOpen, setTimeMenuOpen] = useState(false); + const [eventRailCollapsed, setEventRailCollapsed] = useState(false); const [stats, setStats] = useState(EMPTY_STATS); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [activity, setActivity] = useState(createActivityState); + const activityCursor = useRef(''); + const workflowProgressByFilter = useRef(new Map()); + const statsRequestId = useRef(0); + const statsPending = useRef(0); - const refresh = useCallback(async () => { - setLoading(true); + const loadStats = useCallback(async (filter, options = {}) => { + if (options.skipIfBusy && statsPending.current > 0) return; + const requestId = ++statsRequestId.current; + statsPending.current += 1; + if (!options.silent) setLoading(true); try { - const response = await getApi().page.get('/stats', { params: { startDate, endDate } }); + const params = timeFilterParams(filter); + if (options.force) params.force = '1'; + const response = await getApi().page.get('/stats', { params }); + if (requestId !== statsRequestId.current) return; setStats(mergeStats(response.data)); setError(''); } catch (err) { + if (requestId !== statsRequestId.current) return; setError(err instanceof Error ? err.message : 'stats api failed'); } finally { - setLoading(false); + statsPending.current = Math.max(statsPending.current - 1, 0); + if (requestId === statsRequestId.current) setLoading(false); } - }, [startDate, endDate]); + }, []); + + const refresh = useCallback(() => loadStats(timeFilter, { force: true }), [loadStats, timeFilter]); + + useEffect(() => { + void loadStats(timeFilter); + }, [loadStats, timeFilter]); useEffect(() => { - void refresh(); - const id = window.setInterval(() => void refresh(), 30000); + const intervalMs = REFRESH_INTERVAL_MS[refreshKey]; + if (!intervalMs) return undefined; + const id = window.setInterval(() => void loadStats(timeFilter, { skipIfBusy: true }), intervalMs); return () => window.clearInterval(id); - }, [refresh]); + }, [loadStats, refreshKey, timeFilter]); + + useEffect(() => { + if (!timeMenuOpen) return undefined; + const closeOnOutsidePress = (event) => { + const target = event.target; + if (target instanceof Element && target.closest('[data-soc-menu-root="true"]')) return; + setTimeMenuOpen(false); + }; + document.addEventListener('mousedown', closeOnOutsidePress, true); + document.addEventListener('touchstart', closeOnOutsidePress, true); + return () => { + document.removeEventListener('mousedown', closeOnOutsidePress, true); + document.removeEventListener('touchstart', closeOnOutsidePress, true); + }; + }, [timeMenuOpen]); + + const applyTimeRefresh = useCallback((nextTimeFilter, nextRefreshKey) => { + setTimeFilter(nextTimeFilter); + setRefreshKey(nextRefreshKey); + setTimeMenuOpen(false); + }, []); + + useEffect(() => { + let stopped = false; + let timer = 0; + let statsTimer = 0; + let lastStatsRefreshAt = 0; + let retryDelay = ACTIVITY_POLL_MS; + const workflowFilterKey = [timeFilter.mode, timeFilter.range, timeFilter.start, timeFilter.end].join('|'); + activityCursor.current = ''; + setActivity(createActivityState()); + + const schedule = (delay) => { + if (!stopped) timer = window.setTimeout(() => void poll(false), delay); + }; + + const scheduleStatsRefresh = (immediate = false) => { + window.clearTimeout(statsTimer); + const delay = immediate ? 0 : Math.max(5000 - (Date.now() - lastStatsRefreshAt), 0); + statsTimer = window.setTimeout(() => { + if (stopped) return; + lastStatsRefreshAt = Date.now(); + void loadStats(timeFilter, { force: true, silent: true }); + }, delay); + }; + + const poll = async (bootstrap) => { + if (stopped) return; + if (document.hidden) { + schedule(ACTIVITY_POLL_MS); + return; + } + try { + const params = bootstrap || !activityCursor.current + ? { bootstrap: 'latest', ...timeFilterParams(timeFilter) } + : { cursor: activityCursor.current, limit: 40, ...timeFilterParams(timeFilter) }; + const response = await getApi().page.get('/activity', { params }); + const payload = response.data || {}; + if (payload.error) throw new Error(payload.error); + activityCursor.current = payload.cursor || activityCursor.current; + if (!stopped) { + const rawIncomingEvents = bootstrap + ? recentUnseenActivity(payload.recentEvents) + : (payload.events || []); + const incomingEvents = rawIncomingEvents.filter((event) => event?.stage !== 'denoise'); + const workflowEvents = Array.isArray(payload.workflowEvents) ? payload.workflowEvents : []; + const rawCallCount = payload.workflowStats?.callCount; + const hasWorkflowCount = rawCallCount !== null + && rawCallCount !== undefined + && Number.isFinite(Number(rawCallCount)); + let workflowDelta = 0; + let workflowChanged = false; + if (hasWorkflowCount) { + const callCount = Math.max(Math.trunc(Number(rawCallCount)), 0); + const latestStartedAt = Math.max(Math.trunc(Number(payload.workflowStats?.latestStartedAt || 0)), 0); + const previousProgress = workflowProgressByFilter.current.get(workflowFilterKey); + workflowChanged = Boolean(previousProgress) && ( + callCount > previousProgress.callCount + || latestStartedAt > previousProgress.latestStartedAt + ); + if (workflowChanged) { + workflowDelta = Math.max(callCount - previousProgress.callCount, 1); + incomingEvents.push(workflowDenoiseActivity( + callCount, + workflowDelta, + payload.generatedAt, + workflowEvents[0], + )); + } + workflowProgressByFilter.current.set(workflowFilterKey, { callCount, latestStartedAt }); + } + const incomingRecentEvents = bootstrap + ? [...(payload.recentEvents || []), ...workflowEvents] + : workflowChanged + ? [...rawIncomingEvents, ...workflowEvents] + : rawIncomingEvents; + setActivity((previous) => enqueueActivity( + previous, + incomingEvents, + payload.generatedAt, + incomingRecentEvents, + payload.batch, + )); + const batch = normalizeActivityBatch(payload.batch); + const hasStatsChange = workflowChanged + || batch.receivedCount > 0 + || batch.triageUpdatedCount > 0; + if (payload.cursorReset || hasStatsChange) scheduleStatsRefresh(Boolean(payload.cursorReset)); + } + retryDelay = ACTIVITY_POLL_MS; + } catch (activityError) { + if (!stopped) setActivity((previous) => ( + previous.connection === 'error' ? previous : { ...previous, connection: 'error' } + )); + retryDelay = Math.min(retryDelay * 2, 30000); + } + schedule(retryDelay); + }; + + void poll(true); + return () => { + stopped = true; + window.clearTimeout(timer); + window.clearTimeout(statsTimer); + }; + }, [loadStats, timeFilter]); + + useEffect(() => { + setActivity((previous) => { + let changed = false; + const next = { ...previous }; + for (const kind of ['denoise', 'triage']) { + const lane = previous[kind]; + if (!lane.current && lane.queue.length) { + changed = true; + const nextEvent = lane.queue[0]; + next[kind] = { + ...lane, + current: nextEvent.playbackStartedAt + ? nextEvent + : { ...nextEvent, playbackStartedAt: Date.now() }, + queue: lane.queue.slice(1), + }; + } + } + return changed ? next : previous; + }); + }, [activity.denoise.current, activity.denoise.queue.length, activity.triage.current, activity.triage.queue.length]); + + useEffect(() => { + const event = activity.denoise.current; + if (!event) return undefined; + const id = window.setTimeout(() => { + setActivity((previous) => completeActivity(previous, 'denoise', event)); + }, activityDuration(event)); + return () => window.clearTimeout(id); + }, [activity.denoise.current?.eventId]); + + useEffect(() => { + const event = activity.triage.current; + if (!event) return undefined; + const id = window.setTimeout(() => { + setActivity((previous) => completeActivity(previous, 'triage', event)); + }, activityDuration(event)); + return () => window.clearTimeout(id); + }, [activity.triage.current?.eventId]); + + useEffect(() => { + if (!activity.batchUpdatedAt) return undefined; + const id = window.setTimeout(() => { + setActivity((previous) => ({ + ...previous, + batch: emptyActivityBatch(), + batchUpdatedAt: 0, + })); + }, 12000); + return () => window.clearTimeout(id); + }, [activity.batchUpdatedAt]); + + const activityBusy = Boolean( + activity.denoise.current + || activity.triage.current + || activity.denoise.queue.length + || activity.triage.queue.length + || activity.batch?.receivedCount + || activity.batch?.triageUpdatedCount + ); - return h('div', { className: 'adtd-root' }, [ + return h('div', { + className: cx('adtd-root command-root', activityBusy && 'command-is-processing', eventRailCollapsed && 'event-rail-is-collapsed'), + 'data-animations': 'on', + }, [ h('style', { key: 'style' }, CSS), - h(Header, { key: 'header', startDate, endDate, setStartDate, setEndDate, stats, loading, refresh, error }), + h(CommandHeader, { key: 'header', timeFilter, refreshKey, timeMenuOpen, setTimeMenuOpen, applyTimeRefresh, stats, loading, refresh, activity }), error ? h('div', { className: 'error-banner', key: 'error' }, `统计接口异常:${error}`) : null, - h('main', { className: 'screen-grid', key: 'main' }, [ - h(SourceColumn, { key: 'left', stats }), - h(CenterColumn, { key: 'center', stats }), - h(RightColumn, { key: 'right', stats }), + h('main', { className: cx('command-shell', eventRailCollapsed && 'event-rail-collapsed'), key: 'main' }, [ + h('div', { className: 'command-main', key: 'workspace' }, [ + h(CommandGraph, { key: 'graph', stats, activity }), + h(CommandMetrics, { key: 'metrics', stats }), + ]), + h(CommandEventRail, { key: 'events', activity, timeFilter, collapsed: eventRailCollapsed, onToggle: () => setEventRailCollapsed((current) => !current) }), ]), ]); } @@ -1008,33 +2355,204 @@ const CSS = ` .stage-card small { color: rgba(170,222,255,.72); font-size: 12px; } .stage-green b { color: #2ee6a6; } .stage-violet b { color: #9b8cff; } -.ai-core { - position: relative; - min-height: clamp(270px, 30vh, 304px); - display: grid; - place-items: center; - isolation: isolate; +.activity-card { + min-width: 0; + min-height: 230px; + align-content: start; + gap: 9px; + padding: 12px; overflow: hidden; + transition: border-color .35s ease, box-shadow .35s ease; } -.ai-core:before { - content: ""; - position: absolute; - width: 210px; - aspect-ratio: 1; - border-radius: 50%; - background: conic-gradient(from 120deg, rgba(43,231,255,0), rgba(43,231,255,.36), rgba(155,140,255,.34), rgba(255,209,102,.22), rgba(43,231,255,0)); - filter: blur(22px); - opacity: .42; - animation: coreAura 10s linear infinite; - z-index: 0; +.activity-card.activity-active { + border-color: rgba(43,231,255,.62); + box-shadow: inset 0 0 26px rgba(43,231,255,.1), 0 0 22px rgba(43,231,255,.12); + animation: activityCardPulse 2.2s ease-in-out infinite; } -.ai-sphere { - position: relative; - width: clamp(190px, 24vw, 232px); - aspect-ratio: 1; - border-radius: 50%; - display: grid; - place-items: center; +.activity-card.activity-failed { border-color: rgba(255,77,109,.58); } +.activity-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} +.activity-live { + padding: 3px 6px; + border: 1px solid rgba(88,166,255,.24); + border-radius: 999px; + color: rgba(170,222,255,.58); + font-size: 9px; + line-height: 1; +} +.activity-live.live { + border-color: rgba(46,230,166,.42); + color: #8fffd3; + background: rgba(46,230,166,.1); + box-shadow: 0 0 12px rgba(46,230,166,.14); +} +.activity-title { + display: block; + min-width: 0; + color: #f7fdff; + font-size: 14px !important; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.activity-meta { + min-width: 0; + display: grid; + gap: 4px; + color: rgba(170,222,255,.62); + font-size: 9px; +} +.activity-meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.activity-meta span:first-child { color: #2be7ff; font-weight: 800; } +.activity-stepper { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; +} +.activity-step { + min-width: 0; + padding: 5px 4px; + border: 1px solid rgba(88,166,255,.18); + border-radius: 5px; + color: rgba(170,222,255,.62); + background: rgba(88,166,255,.06); + font-size: 9px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.activity-step i { + display: inline-block; + width: 5px; + height: 5px; + margin-right: 4px; + border-radius: 50%; + background: rgba(88,166,255,.38); + box-shadow: 0 0 6px rgba(88,166,255,.24); +} +.activity-active .activity-step { + opacity: .34; + animation: activityStepIn .45s ease forwards; +} +.activity-active .activity-step i, +.stage-green .activity-step i { background: #2ee6a6; box-shadow: 0 0 8px rgba(46,230,166,.58); } +.stage-violet .activity-step i { background: #9b8cff; box-shadow: 0 0 8px rgba(155,140,255,.58); } +.activity-outcome { + min-width: 0; + padding: 7px 8px; + border: 1px solid rgba(46,230,166,.28); + border-radius: 6px; + color: #8fffd3; + background: rgba(46,230,166,.08); + font-size: 10px; + font-weight: 800; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.activity-active .activity-outcome { + opacity: 0; + animation: activityOutcomeIn .5s ease var(--activity-outcome-delay) forwards; +} +.activity-outcome.failed { + border-color: rgba(255,77,109,.38); + color: #ff9caf; + background: rgba(255,77,109,.09); +} +.ai-core { + position: relative; + min-height: clamp(270px, 30vh, 304px); + display: grid; + place-items: center; + isolation: isolate; + overflow: hidden; +} +.core-live-status { + position: absolute; + top: 4px; + left: 50%; + min-width: 128px; + max-width: calc(100% - 20px); + padding: 5px 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + transform: translateX(-50%); + border: 1px solid rgba(88,166,255,.24); + border-radius: 999px; + color: rgba(170,222,255,.72); + background: rgba(5,14,28,.76); + font-size: 9px; + z-index: 5; +} +.core-live-status i { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 50%; + background: #2ee6a6; + box-shadow: 0 0 10px rgba(46,230,166,.72); +} +.core-live-status b { color: #2be7ff; font-size: 9px; } +.core-live-status.status-error { border-color: rgba(255,176,32,.36); color: #ffd166; } +.core-live-status.status-error i { background: #ffb020; box-shadow: 0 0 10px rgba(255,176,32,.68); } +.core-processing .ai-sphere { + border-color: rgba(43,231,255,.62); + box-shadow: + 0 0 68px rgba(43,231,255,.32), + 0 0 108px rgba(111,116,255,.1), + inset 0 0 50px rgba(64,147,255,.18); +} +.core-dual .ai-sphere { + border-color: rgba(155,140,255,.78); + box-shadow: 0 0 78px rgba(155,140,255,.38), inset 0 0 54px rgba(43,231,255,.25); +} +.core-batched { + position: absolute; + top: 36px; + left: 50%; + max-width: calc(100% - 24px); + padding: 4px 7px; + transform: translateX(-50%); + border-radius: 5px; + color: #ffd166; + background: rgba(255,176,32,.08); + font-size: 9px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 5; +} +.ai-core:before { + content: ""; + position: absolute; + width: 210px; + aspect-ratio: 1; + border-radius: 50%; + background: conic-gradient(from 120deg, rgba(43,231,255,0), rgba(43,231,255,.36), rgba(155,140,255,.34), rgba(255,209,102,.22), rgba(43,231,255,0)); + filter: blur(22px); + opacity: .42; + animation: coreAura 10s linear infinite; + z-index: 0; +} +.ai-sphere { + position: relative; + width: clamp(190px, 24vw, 232px); + aspect-ratio: 1; + border-radius: 50%; + display: grid; + place-items: center; align-content: center; border: 1px solid rgba(43,231,255,.46); background: @@ -1045,6 +2563,7 @@ const CSS = ` z-index: 2; overflow: hidden; animation: sphereBreathe 4.6s ease-in-out infinite; + transition: border-color .65s ease, box-shadow .85s ease, filter .85s ease; } .ai-sphere:before { content: ""; @@ -1191,14 +2710,8 @@ const CSS = ` to { transform: rotate(360deg) scale(.96); } } @keyframes sphereBreathe { - 0%, 100% { - transform: scale(1); - box-shadow: 0 0 58px rgba(43,231,255,.28), inset 0 0 46px rgba(43,231,255,.22); - } - 50% { - transform: scale(1.035); - box-shadow: 0 0 78px rgba(43,231,255,.42), inset 0 0 58px rgba(155,140,255,.28); - } + 0%, 100% { transform: scale(.99); filter: brightness(.96) saturate(1); } + 50% { transform: scale(1.025); filter: brightness(1.07) saturate(1.1); } } @keyframes sphereSpin { from { transform: rotate(0deg); } @@ -1242,17 +2755,17 @@ const CSS = ` 0%, 100% { transform: translate(52px, 132px) scale(1); opacity: .62; } 50% { transform: translate(78px, 104px) scale(1.4); opacity: .95; } } -@media (prefers-reduced-motion: reduce) { - .ai-core:before, - .ai-sphere, - .ai-sphere:before, - .ai-sphere:after, - .orbit, - .energy-ring, - .scan-line, - .core-particle { - animation: none; - } +@keyframes activityCardPulse { + 0%, 100% { box-shadow: inset 0 0 22px rgba(43,231,255,.08), 0 0 14px rgba(43,231,255,.08); } + 50% { box-shadow: inset 0 0 32px rgba(43,231,255,.14), 0 0 26px rgba(43,231,255,.18); } +} +@keyframes activityStepIn { + from { opacity: .34; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); border-color: rgba(43,231,255,.34); color: #d9f7ff; } +} +@keyframes activityOutcomeIn { + from { opacity: 0; transform: translateY(5px) scale(.97); } + to { opacity: 1; transform: translateY(0) scale(1); } } .flow-strip { min-height: 54px; @@ -1728,27 +3241,6 @@ const CSS = ` 0%, 100% { opacity: .82; filter: drop-shadow(0 0 7px rgba(46,230,166,.34)); } 50% { opacity: 1; filter: drop-shadow(0 0 14px rgba(46,230,166,.68)); } } -@media (prefers-reduced-motion: reduce) { - .brand-mark:after, - .panel-title i, - .source-node.active, - .source-track span:after, - .spark-line, - .flow-strip span, - .flow-strip i, - .flow-strip i:before, - .donut-arc, - .donut-arc.active, - .radar-grid, - .radar-value, - .profile-track span:after, - .funnel-bar:after, - .loop-diagram:before, - .loop-node.primary, - .gauge-value { - animation: none; - } -} @media (max-width: 1280px) { .screen-grid { grid-template-columns: minmax(0, 1fr) minmax(315px, 350px); @@ -1784,4 +3276,1768 @@ const CSS = ` .right-col .panel:last-child { display: block; } .right-col .panel:last-child .panel-body { display: block; } } + +/* AI command-center layout */ +.command-root { + --command-green: #20d59b; + --command-green-soft: rgba(32, 213, 155, .18); + --command-blue: #3677bc; + --command-border: rgba(255, 255, 255, .09); + position: relative; + height: 100vh; + min-height: 720px; + min-width: 1180px; + margin: 0; + padding: 0; + overflow: hidden; + color: #f0f4f3; + color-scheme: dark; + background: + radial-gradient(circle at 48% 42%, rgba(19, 72, 61, .16), transparent 28%), + linear-gradient(180deg, #0a0d0c 0%, #070908 100%); +} +.command-root:before { + content: ""; + position: absolute; + inset: 68px 330px 142px 0; + pointer-events: none; + opacity: .16; + background-image: + linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); + background-size: 54px 54px; + mask-image: radial-gradient(circle at center, #000, transparent 78%); +} +.command-root.event-rail-is-collapsed:before { right: 0; } +.command-header { + position: relative; + z-index: 10; + display: grid; + grid-template-columns: minmax(350px, 1fr) auto minmax(560px, 1fr); + align-items: center; + gap: 20px; + height: 68px; + padding: 0 20px; + border-bottom: 1px solid var(--command-border); + background: rgba(7, 9, 8, .96); + box-shadow: 0 12px 34px rgba(0, 0, 0, .22); +} +.command-brand, +.command-tools, +.command-live, +.command-brand > div:last-child, +.event-rail-head > div { + display: flex; + align-items: center; +} +.command-brand { gap: 12px; min-width: 0; } +.command-logo { + display: grid; + place-items: center; + flex: 0 0 42px; + height: 42px; + border: 1px solid rgba(32, 213, 155, .66); + border-radius: 9px; + color: #46e4af; + background: linear-gradient(145deg, rgba(29, 128, 100, .34), rgba(8, 23, 19, .8)); + box-shadow: inset 0 0 18px rgba(32, 213, 155, .15), 0 0 18px rgba(32, 213, 155, .09); + font-size: 16px; + font-weight: 800; +} +.command-brand > div:last-child { + align-items: flex-start; + flex-direction: column; + min-width: 0; + line-height: 1.25; +} +.command-brand strong { font-size: 17px; letter-spacing: .02em; } +.command-brand span { margin-top: 3px; color: #788680; font-size: 11px; } +.command-live { + justify-content: center; + gap: 9px; + min-width: 230px; + padding: 8px 16px; + border: 1px solid rgba(32, 213, 155, .25); + border-radius: 999px; + color: #b8c4c0; + background: rgba(17, 31, 27, .5); + font-size: 12px; +} +.command-live i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--command-green); + box-shadow: 0 0 10px var(--command-green); + animation: commandLive 1.6s ease-in-out infinite; +} +.command-live i.warn { background: #ffae34; box-shadow: 0 0 10px #ffae34; } +.command-live b { color: #47ddb0; font-weight: 600; } +.command-tools { justify-content: flex-end; gap: 8px; min-width: 0; } +.command-refresh { + height: 34px; + border: 1px solid rgba(255,255,255,.16); + border-radius: 7px; + color: #d8dedc; + background: #141716; + font: inherit; + font-size: 12px; +} +.command-refresh { padding: 0 15px; cursor: pointer; } +.command-refresh:hover { border-color: rgba(32,213,155,.55); color: #53e0b3; } +.command-refresh:disabled { opacity: .55; cursor: default; } +.command-time-filter { position: relative; z-index: 40; } +.command-time-trigger { + height: 34px; + max-width: 370px; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(88,166,255,.28); + border-radius: 7px; + color: rgba(217,247,255,.72); + background: rgba(10,31,51,.9); + padding: 0 10px; + font: inherit; + font-size: 11px; + cursor: pointer; +} +.command-time-trigger:hover, +.command-time-trigger.open { border-color: rgba(43,231,255,.58); box-shadow: inset 0 0 18px rgba(43,231,255,.06); } +.command-time-trigger span { display: flex; min-width: 0; align-items: center; white-space: nowrap; } +.command-time-trigger span:first-child { max-width: 184px; overflow: hidden; text-overflow: ellipsis; } +.command-time-trigger b { overflow: hidden; color: #70dfff; font-weight: 650; text-overflow: ellipsis; } +.command-time-trigger i { width: 1px; height: 14px; flex: 0 0 1px; background: rgba(88,166,255,.25); } +.command-time-trigger em { color: rgba(170,222,255,.62); font-style: normal; } +.command-time-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 380px; + overflow: hidden; + border: 1px solid rgba(43,231,255,.3); + border-radius: 9px; + background: linear-gradient(155deg, rgba(8,31,51,.99), rgba(5,18,34,.99)); + box-shadow: 0 18px 46px rgba(0,0,0,.48), inset 0 0 28px rgba(43,231,255,.035); +} +.command-time-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + margin: 12px; + border-radius: 6px; + background: rgba(2,12,24,.72); + padding: 3px; +} +.command-time-tabs button, +.command-time-option, +.command-time-shortcuts button, +.command-time-panel-actions button { + border: 1px solid transparent; + border-radius: 5px; + color: rgba(190,222,240,.68); + background: transparent; + font: inherit; + font-size: 12px; + cursor: pointer; +} +.command-time-tabs button { height: 30px; } +.command-time-tabs button.active { color: #73e7ff; background: rgba(28,75,111,.58); box-shadow: inset 0 -1px rgba(43,231,255,.2); } +.command-time-panel-body { display: grid; gap: 13px; border-top: 1px solid rgba(88,166,255,.14); padding: 13px; } +.command-time-panel-body label, +.command-time-inputs span { display: block; margin-bottom: 7px; color: rgba(170,222,255,.62); font-size: 11px; font-weight: 650; } +.command-time-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; } +.command-time-option, +.command-time-shortcuts button { height: 30px; background: rgba(18,48,73,.56); } +.command-time-option:hover, +.command-time-shortcuts button:hover { color: #d9f7ff; background: rgba(29,73,105,.72); } +.command-time-option.selected { border-color: rgba(43,231,255,.52); color: #061725; background: linear-gradient(135deg, #35d4ff, #40e1bd); font-weight: 700; } +.command-time-inputs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; } +.command-time-inputs input { + box-sizing: border-box; + width: 100%; + height: 34px; + border: 1px solid rgba(88,166,255,.3); + border-radius: 5px; + color: #d9f7ff; + background: rgba(4,18,33,.86); + padding: 0 7px; + font: inherit; + font-size: 11px; + outline: none; +} +.command-time-inputs input:focus { border-color: rgba(43,231,255,.7); box-shadow: 0 0 0 2px rgba(43,231,255,.08); } +.command-time-shortcuts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; } +.command-time-shortcuts button { padding: 0 3px; font-size: 10px; } +.command-time-panel-actions { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid rgba(88,166,255,.14); padding: 10px 12px; } +.command-time-panel-actions button { height: 30px; min-width: 64px; border-color: rgba(88,166,255,.28); background: rgba(11,34,53,.8); } +.command-time-panel-actions button.primary { border-color: rgba(43,231,255,.58); color: #061725; background: linear-gradient(135deg, #35d4ff, #40e1bd); font-weight: 700; } +.command-clock { + display: flex; + align-items: flex-end; + flex-direction: column; + min-width: 112px; + margin-left: 7px; + line-height: 1.15; +} +.command-clock b { color: #f7faf9; font-size: 14px; font-variant-numeric: tabular-nums; } +.command-clock span { + max-width: 150px; + margin-top: 4px; + overflow: hidden; + color: #687671; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.command-root > .error-banner { + position: absolute; + z-index: 30; + top: 76px; + left: 50%; + width: min(520px, 60vw); + margin: 0; + transform: translateX(-50%); +} +.command-shell { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + height: calc(100vh - 68px); + min-height: 652px; + transition: grid-template-columns .24s ease; +} +.command-shell.event-rail-collapsed { grid-template-columns: minmax(0, 1fr) 0; } +.command-main { + display: grid; + grid-template-rows: minmax(0, 1fr) 142px; + min-width: 0; + min-height: 0; +} +.command-graph { + position: relative; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--command-border); + background: + radial-gradient(circle at 50% 45%, rgba(22, 96, 76, .09), transparent 34%), + linear-gradient(180deg, rgba(255,255,255,.008), transparent); +} +.command-graph:before, +.command-graph:after { + content: ""; + position: absolute; + z-index: 0; + top: 17%; + bottom: 21%; + width: 1px; + background: linear-gradient(transparent, rgba(32,213,155,.15), transparent); +} +.command-graph:before { left: 34%; } +.command-graph:after { right: 22%; } +.command-links { + position: absolute; + z-index: 1; + inset: 2% 0 13%; + width: 100%; + height: 85%; + overflow: visible; + pointer-events: none; +} +.command-link { + fill: none; + stroke: rgba(47, 102, 157, .43); + stroke-width: 3; + stroke-linecap: round; + stroke-dasharray: 2 10; + filter: drop-shadow(0 0 4px rgba(54, 119, 188, .3)); +} +.command-link.dim { opacity: .55; } +.denoise-running .source-link, +.denoise-running .denoise-link, +.triage-running .triage-link, +.triage-running .risk-link { + stroke: rgba(32, 213, 155, .85); + stroke-dasharray: 9 9; + filter: drop-shadow(0 0 7px rgba(32, 213, 155, .55)); + animation: commandFlow .78s linear infinite; +} +.source-stack { + position: absolute; + z-index: 3; + top: 20%; + bottom: 27%; + left: 2.4%; + display: flex; + justify-content: space-between; + flex-direction: column; + width: 12.5%; +} +.command-source { + position: relative; + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 5px; + padding-left: 6px; +} +.command-source span { color: #7c8783; font-size: 13px; } +.command-source b { color: #eff3f2; font-size: 18px; font-variant-numeric: tabular-nums; } +.command-source i { + position: absolute; + top: 11px; + right: -4px; + width: 4px; + height: 13px; + border-radius: 5px; + background: var(--command-green); + box-shadow: 0 0 11px var(--command-green); +} +.merge-node { + position: absolute; + z-index: 4; + top: 46%; + left: 27.5%; + display: flex; + align-items: center; + flex-direction: column; + width: 105px; + transform: translate(-50%, -50%); + text-align: center; +} +.merge-node:before { + content: ""; + position: absolute; + z-index: -1; + top: -24px; + width: 2px; + height: 90px; + background: linear-gradient(transparent, rgba(54,119,188,.5), transparent); +} +.merge-node b { color: #f4f6f5; font-size: 27px; line-height: 1; } +.merge-node span { margin-top: 7px; color: #8c9692; font-size: 13px; } +.merge-node small { margin-top: 8px; color: #36c998; font-size: 10px; } +.command-core { + position: absolute; + z-index: 5; + top: 44%; + left: 50%; + width: min(25vw, 340px); + height: min(25vw, 340px); + min-width: 282px; + min-height: 282px; + transform: translate(-50%, -50%); +} +.core-ring { + position: absolute; + border-radius: 50%; +} +.command-ring.ring-1 { + inset: 0; + border: 1px solid rgba(32,213,155,.26); + background: + repeating-conic-gradient(from 0deg, rgba(32,213,155,.68) 0deg 1deg, transparent 1deg 7deg); + mask: radial-gradient(circle, transparent 69%, #000 70% 72%, transparent 73%); + animation: commandSpin 24s linear infinite; +} +.command-ring.ring-2 { + inset: 10%; + border: 1px dashed rgba(65,162,126,.52); + box-shadow: inset 0 0 30px rgba(32,213,155,.07), 0 0 25px rgba(32,213,155,.07); + animation: commandSpinReverse 18s linear infinite; +} +.command-ring.ring-3 { + inset: 25%; + border: 10px double rgba(44, 94, 80, .42); + box-shadow: inset 0 0 28px rgba(32,213,155,.1); + animation: commandCorePulse 2.8s ease-in-out infinite; +} +.command-core:before, +.command-core:after { + content: ""; + position: absolute; + inset: 5%; + border-radius: 50%; + border-left: 12px solid rgba(32,213,155,.28); + border-right: 12px solid rgba(32,213,155,.28); + filter: blur(.2px) drop-shadow(0 0 9px rgba(32,213,155,.22)); +} +.command-core:after { inset: 18%; border-width: 2px; border-color: rgba(54,119,188,.44); } +.command-core-hex { + position: absolute; + z-index: 4; + top: 50%; + left: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + width: 92px; + height: 104px; + transform: translate(-50%, -50%); + color: #58e3b7; + background: linear-gradient(145deg, #14251f, #08100d); + clip-path: polygon(25% 6%, 75% 6%, 100% 50%, 75% 94%, 25% 94%, 0 50%); + filter: drop-shadow(0 0 13px rgba(32,213,155,.58)); +} +.command-core-hex:before { + content: ""; + position: absolute; + inset: 4px; + border: 1px solid rgba(32,213,155,.75); + clip-path: inherit; +} +.command-core-hex span { position: relative; font-size: 27px; font-weight: 800; letter-spacing: .07em; } +.command-core-hex small { position: relative; margin-top: 2px; color: #d5e0dc; font-size: 10px; } +.command-core-status { + position: absolute; + z-index: 5; + top: calc(50% + 59px); + left: 50%; + padding: 4px 10px; + transform: translateX(-50%); + border-radius: 12px; + color: #96a49f; + background: rgba(3,8,6,.84); + font-size: 10px; + white-space: nowrap; +} +.agent-badge { + position: absolute; + z-index: 6; + display: grid; + grid-template-columns: 25px auto; + grid-template-rows: auto auto; + min-width: 106px; + padding: 8px 11px; + border: 1px solid rgba(32,213,155,.42); + border-radius: 9px; + background: linear-gradient(145deg, rgba(17,54,43,.9), rgba(6,15,12,.92)); + box-shadow: inset 0 0 18px rgba(32,213,155,.08), 0 0 13px rgba(32,213,155,.09); +} +.agent-badge i { + grid-row: 1 / 3; + align-self: center; + color: #3ce1ae; + font-size: 20px; + font-style: normal; +} +.agent-badge span { color: #35d9a6; font-size: 11px; } +.agent-badge b { margin-top: 2px; color: #e8eeec; font-size: 14px; font-weight: 600; } +.agent-badge.active { + border-color: #3be4af; + box-shadow: inset 0 0 22px rgba(32,213,155,.16), 0 0 20px rgba(32,213,155,.28); + animation: agentPulse 1.2s ease-in-out infinite; +} +.agent-denoise { top: -4%; left: 50%; transform: translate(-50%, -50%); } +.agent-investigate { bottom: -1%; left: 12%; transform: translate(-50%, 50%); } +.agent-aggregate { right: 12%; bottom: -1%; transform: translate(50%, 50%); } +.outcome-stack { + position: absolute; + z-index: 4; + top: 24%; + left: 71.5%; + display: flex; + justify-content: space-between; + flex-direction: column; + height: 42%; +} +.outcome-stack > div { display: flex; flex-direction: column; min-width: 90px; } +.outcome-stack b { color: #eef1f0; font-size: 23px; line-height: 1; } +.outcome-stack span { margin-top: 6px; color: #78837f; font-size: 12px; } +.outcome-stack .primary b { color: #4bdbae; } +.severity-stack { + position: absolute; + z-index: 4; + top: 20%; + right: 2.7%; + display: flex; + justify-content: space-between; + flex-direction: column; + width: 9.5%; + height: 48%; +} +.severity-node { display: flex; align-items: center; gap: 9px; } +.severity-node span { + min-width: 46px; + padding: 5px 8px; + border-radius: 6px; + color: #fff; + text-align: center; + font-size: 12px; +} +.severity-node b { color: #ecf0ee; font-size: 17px; } +.severity-critical span { background: #bd1632; } +.severity-high span { background: #e94747; } +.severity-medium span { background: #ee8d28; } +.severity-low span { background: #d5ad20; } +.command-lanes { + position: absolute; + z-index: 8; + right: 15%; + bottom: 12px; + left: 15%; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.command-activity-lane { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 168px; + align-items: stretch; + gap: 14px; + min-width: 0; + height: 112px; + padding: 10px 12px; + overflow: hidden; + border: 1px solid rgba(255,255,255,.08); + border-radius: 10px; + background: rgba(11, 15, 13, .86); + box-shadow: inset 0 0 22px rgba(255,255,255,.015); +} +.command-activity-lane.active { + border-color: rgba(32,213,155,.5); + box-shadow: inset 0 0 24px rgba(32,213,155,.08), 0 0 12px rgba(32,213,155,.08); +} +.command-lane-copy { min-width: 0; } +.command-lane-head { display: flex; align-items: center; justify-content: space-between; } +.command-lane-head span { color: var(--drum-accent); font-size: 12px; font-weight: 700; } +.command-lane-head b { color: #7890a4; font-size: 11px; font-weight: 600; } +.command-activity-lane.active .command-lane-head b { color: #50e3b5; } +.command-lane-copy > strong { + display: block; + margin-top: 9px; + overflow: hidden; + color: #edf8ff; + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} +.command-lane-result { + max-width: 100%; + margin-top: 12px; + overflow: hidden; + color: #75e8c4; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.command-lane-result.idle { color: rgba(170,222,255,.55); } +.command-activity-lane.active .command-lane-result { opacity: 0; animation: laneResult .35s ease forwards; } +.command-drum-shell { + position: relative; + display: grid; + grid-template-rows: 18px minmax(0, 1fr); + min-width: 0; + padding-left: 12px; + border-left: 1px solid color-mix(in srgb, var(--drum-accent) 34%, transparent); +} +.command-drum-caption { + display: flex; + align-items: center; + justify-content: space-between; + color: rgba(170,222,255,.58); + font-size: 10px; +} +.command-drum-caption b { + color: var(--drum-accent); + font-size: 10px; + font-weight: 700; + text-shadow: 0 0 8px var(--drum-accent); +} +.command-drum-window { + position: relative; + height: 78px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--drum-accent) 34%, transparent); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(2,10,21,.72), rgba(11,36,58,.78) 50%, rgba(2,10,21,.72)), + repeating-linear-gradient(90deg, transparent 0 12px, rgba(43,231,255,.04) 12px 13px); + box-shadow: + inset 0 13px 18px rgba(0,0,0,.58), + inset 0 -13px 18px rgba(0,0,0,.58), + inset 0 0 24px color-mix(in srgb, var(--drum-accent) 10%, transparent), + 0 0 12px color-mix(in srgb, var(--drum-accent) 10%, transparent); + perspective: 220px; +} +.command-drum-window:before { + content: ""; + position: absolute; + z-index: 4; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgba(2,8,18,.92), transparent 30%, transparent 70%, rgba(2,8,18,.92)), + repeating-linear-gradient(180deg, transparent 0 12px, rgba(170,222,255,.12) 12px 13px, transparent 13px 26px); + mask-image: linear-gradient(90deg, #000 0 6px, transparent 6px calc(100% - 6px), #000 calc(100% - 6px)); +} +.command-drum-track { + position: absolute; + z-index: 1; + top: 0; + right: 7px; + left: 7px; + display: flex; + flex-direction: column; + animation: commandDrumRoll var(--drum-duration) linear infinite; + will-change: transform; +} +.command-drum-step { + display: grid; + grid-template-columns: 8px 1fr auto; + align-items: center; + gap: 8px; + flex: 0 0 26px; + height: 26px; + padding: 0 9px; + color: rgba(202,232,246,.62); + transform: perspective(150px) rotateX(-5deg); + transform-origin: center; +} +.command-drum-step i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--drum-accent); + box-shadow: 0 0 8px var(--drum-accent); +} +.command-drum-step b { + font-size: 13px; + font-weight: 800; + letter-spacing: .16em; + text-shadow: 0 0 9px color-mix(in srgb, var(--drum-accent) 55%, transparent); +} +.command-drum-step small { color: rgba(170,222,255,.4); font-size: 9px; font-variant-numeric: tabular-nums; } +.command-drum-focus { + position: absolute; + z-index: 3; + top: 25px; + right: 5px; + left: 5px; + height: 28px; + pointer-events: none; + border-top: 1px solid color-mix(in srgb, var(--drum-accent) 72%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--drum-accent) 72%, transparent); + border-radius: 5px; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--drum-accent) 14%, transparent), transparent); + box-shadow: inset 0 0 12px color-mix(in srgb, var(--drum-accent) 12%, transparent), 0 0 12px color-mix(in srgb, var(--drum-accent) 16%, transparent); + animation: commandDrumFocusPulse 1.7s ease-in-out infinite; +} +.command-drum-scan { + position: absolute; + z-index: 5; + top: 25px; + bottom: 25px; + left: -14%; + width: 20%; + pointer-events: none; + background: linear-gradient(90deg, transparent, rgba(255,255,255,.26), transparent); + filter: blur(1px); + animation: commandDrumScan 2.8s ease-in-out infinite; +} +.command-activity-lane.active .command-drum-window { + border-color: color-mix(in srgb, var(--drum-accent) 72%, transparent); + box-shadow: inset 0 0 26px color-mix(in srgb, var(--drum-accent) 16%, transparent), 0 0 20px color-mix(in srgb, var(--drum-accent) 24%, transparent); +} +.command-metrics { + position: relative; + z-index: 4; + display: grid; + grid-template-columns: repeat(4, 1fr); + min-width: 0; + border-top: 1px solid var(--command-border); + border-right: 1px solid var(--command-border); + background: rgba(7,9,8,.96); +} +.command-metric { + position: relative; + min-width: 0; + padding: 15px 28px 9px; + overflow: hidden; + border-right: 1px solid var(--command-border); +} +.command-metric:last-child { border-right: 0; } +.command-metric > span { display: block; color: #b6bdbb; font-size: 12px; } +.command-metric > b { display: block; margin-top: 7px; color: #f1f4f3; font-size: 25px; line-height: 1; } +.command-metric > small { + position: absolute; + top: 38px; + right: 24px; + max-width: 48%; + overflow: hidden; + color: var(--metric-color); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} +.command-metric .sparkline { width: 100%; height: 43px; margin-top: 6px; opacity: .75; } +.command-metric .spark-grid { opacity: 0; } +.command-metric .spark-line { stroke-width: 4; filter: drop-shadow(0 0 5px var(--metric-color)); } +.command-event-rail { + position: relative; + z-index: 6; + display: grid; + grid-template-rows: 65px auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + border-left: 1px solid rgba(255,255,255,.025); + background: #0b0d0c; +} +.command-event-rail.collapsed { + display: block; + border-left: 0; + background: transparent; + overflow: visible; +} +.event-rail-toggle { + position: absolute; + z-index: 12; + top: 50%; + left: -14px; + width: 28px; + height: 44px; + border: 0; + color: #6ee8ff; + background: transparent; + transform: translateY(-50%); + opacity: .72; + padding: 0; + font-size: 28px; + line-height: 1; + text-shadow: 0 0 9px rgba(43,231,255,.48); + cursor: pointer; + transition: color .16s ease, opacity .16s ease, transform .16s ease; +} +.event-rail-toggle:hover { + color: #fff; + opacity: 1; + transform: translate(2px, -50%); +} +.command-event-rail.collapsed .event-rail-toggle { + left: -40px; + width: 40px; + height: 104px; + border-radius: 12px 0 0 12px; + color: #f4f7f9; + background: rgba(65, 73, 82, .96); + box-shadow: -5px 0 16px rgba(0,0,0,.22); + opacity: .96; + text-shadow: none; +} +.command-event-rail.collapsed .event-rail-toggle:hover { + color: #fff; + background: rgba(78, 88, 98, .98); + transform: translate(-2px, -50%); +} +.command-event-rail.collapsed .event-rail-toggle span { + display: grid; + place-items: center; + gap: 2px; +} +.command-event-rail.collapsed .event-rail-toggle i { + font-style: normal; + font-size: 14px; + font-weight: 700; + line-height: 1.12; +} +.event-rail-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 17px; + border-bottom: 1px solid var(--command-border); +} +.event-rail-head > div { align-items: flex-start; flex-direction: column; } +.event-rail-head strong { color: #e9edeb; font-size: 14px; } +.event-rail-head span { margin-top: 3px; color: #66716d; font-size: 10px; } +.event-rail-head b { + min-width: 26px; + padding: 4px 7px; + border-radius: 12px; + color: #48d8ac; + background: rgba(32,213,155,.1); + text-align: center; + font-size: 11px; +} +.event-update-banner { + margin: 10px 14px 4px; + padding: 10px 12px; + border: 1px solid rgba(58,117,183,.48); + border-radius: 6px; + color: #b8cff0; + background: rgba(45,82,135,.28); + font-size: 11px; +} +.event-update-banner:before { content: "ⓘ"; margin-right: 7px; color: #6ba4fb; } +.event-update-banner.warn { border-color: rgba(255,174,52,.42); color: #f1c67d; background: rgba(139,88,22,.2); } +.event-rail-list { + min-height: 0; + padding: 4px 16px 18px 20px; + overflow: auto; + scrollbar-width: thin; + scrollbar-color: #3a403e transparent; +} +.event-rail-item { + position: relative; + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 5px; + min-height: 112px; + padding: 13px 2px 12px 14px; + border-bottom: 1px solid rgba(255,255,255,.1); +} +.event-rail-item:before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 1px; + width: 1px; + background: rgba(105,129,143,.25); +} +.event-rail-item:after { + content: ""; + position: absolute; + top: 21px; + left: -2px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #466170; + box-shadow: 0 0 7px rgba(70,97,112,.42); +} +.event-rail-item.kind-denoise:after { background: #2be7ff; box-shadow: 0 0 9px rgba(43,231,255,.7); } +.event-rail-item.kind-triage:after { background: #9b8cff; box-shadow: 0 0 9px rgba(155,140,255,.75); } +.event-rail-item.state-processing { + background: linear-gradient(90deg, rgba(35,214,157,.07), transparent 72%); +} +.event-rail-meta { display: flex; align-items: center; width: 100%; gap: 7px; } +.event-rail-meta time { margin-left: auto; color: #646d69; font-size: 10px; } +.event-queue-kind, +.event-stage { padding: 4px 7px; border-radius: 4px; color: #fff; font-size: 10px; } +.event-queue-kind.kind-denoise { color: #55eaff; background: rgba(24,112,137,.38); } +.event-queue-kind.kind-triage { color: #c3baff; background: rgba(83,67,151,.45); } +.event-stage { color: #83aef1; background: rgba(52,86,143,.5); } +.event-rail-item.state-processing .event-stage { color: #57e1b5; background: rgba(23,111,83,.48); } +.event-rail-item.state-waiting .event-stage { color: #d6a95e; background: rgba(120,81,23,.38); } +.event-rail-item > strong, +.event-rail-item > span, +.event-rail-item > small { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.event-rail-item > strong { color: #dce1df; font-size: 12px; font-weight: 600; } +.event-rail-item > span { color: #7b8581; font-size: 10px; } +.event-rail-item > small { color: #4dcfa7; font-size: 9px; } +.event-rail-progress { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + margin-top: 3px; +} +.event-rail-progress-track { + position: relative; + height: 3px; + overflow: hidden; + border-radius: 3px; + background: rgba(127,111,255,.16); + box-shadow: inset 0 0 0 1px rgba(155,140,255,.12); +} +.event-rail-progress-track i { + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(90deg, #716cff 0%, #a98dff 58%, #55e8ff 100%); + box-shadow: 0 0 9px rgba(155,140,255,.82); + transform: scaleX(var(--queue-progress, 0)); + transform-origin: left center; + transition: transform .5s linear; +} +.event-rail-progress-track i:after { + content: ""; + position: absolute; + top: 50%; + right: 0; + width: 7px; + height: 7px; + border-radius: 50%; + background: #bdf7ff; + box-shadow: 0 0 10px #73e9ff; + transform: translate(50%, -50%); +} +.event-rail-progress > small { + color: #9a8eff; + font-size: 9px; + font-variant-numeric: tabular-nums; +} +.event-rail-empty { + display: grid; + place-items: center; + height: 180px; + color: #535c59; + font-size: 11px; +} +@keyframes commandFlow { to { stroke-dashoffset: -36; } } +@keyframes commandSpin { to { transform: rotate(360deg); } } +@keyframes commandSpinReverse { to { transform: rotate(-360deg); } } +@keyframes commandCorePulse { + 0%, 100% { opacity: .62; transform: scale(.97); } + 50% { opacity: 1; transform: scale(1.03); } +} +@keyframes commandLive { + 0%, 100% { opacity: .45; transform: scale(.8); } + 50% { opacity: 1; transform: scale(1.1); } +} +@keyframes agentPulse { + 0%, 100% { filter: brightness(.94); } + 50% { filter: brightness(1.2); } +} +@keyframes laneResult { to { opacity: 1; } } +@media (max-width: 1360px) { + .command-header { grid-template-columns: minmax(330px, 1fr) auto minmax(500px, 1fr); gap: 12px; padding: 0 14px; } + .command-shell { grid-template-columns: minmax(0, 1fr) 292px; } + .command-root:before { right: 292px; } + .command-core { width: 288px; height: 288px; min-width: 288px; min-height: 288px; } + .command-time-trigger { max-width: 320px; } + .command-clock { min-width: 92px; } + .command-lanes { right: 14%; left: 14%; } + .command-metric { padding-right: 18px; padding-left: 18px; } +} +@media (max-width: 1120px) { + .command-root { padding: 0; } + .command-header { grid-template-columns: 330px 1fr; } + .command-live { display: none; } + .command-shell { grid-template-columns: minmax(850px, 1fr) 280px; } + .command-core { width: 270px; height: 270px; min-width: 270px; min-height: 270px; } + .agent-badge { min-width: 96px; padding: 7px 8px; } + .command-lanes { right: 12%; left: 12%; } +} + +/* Align the command layout with the Flocks dark theme. */ +.command-root { + --command-green: #2ee6a6; + --command-green-soft: rgba(46, 230, 166, .16); + --command-blue: #2be7ff; + --command-border: rgba(43, 231, 255, .19); + background: + radial-gradient(circle at 49% 41%, rgba(43, 231, 255, .11), transparent 30%), + radial-gradient(circle at 78% 58%, rgba(155, 140, 255, .08), transparent 32%), + linear-gradient(145deg, #04101d 0%, #07192a 48%, #0b1021 100%); +} +.command-root:before { + opacity: .34; + background-image: + linear-gradient(rgba(43,231,255,.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(43,231,255,.045) 1px, transparent 1px); +} +.command-header { + border-bottom-color: rgba(43,231,255,.22); + background: linear-gradient(180deg, rgba(5,20,34,.98), rgba(4,14,27,.96)); + box-shadow: 0 12px 34px rgba(1, 7, 16, .38), inset 0 -1px rgba(88,166,255,.05); +} +.command-logo { + border-color: rgba(43,231,255,.56); + color: #ffd166; + background: linear-gradient(145deg, rgba(43,231,255,.22), rgba(6,27,44,.9)); + box-shadow: inset 0 0 18px rgba(43,231,255,.15), 0 0 18px rgba(43,231,255,.12); + animation: commandLogoPulse 3.6s ease-in-out infinite; +} +.command-brand span, +.command-clock span { color: rgba(170,222,255,.6); } +.command-live { + border-color: rgba(43,231,255,.28); + color: rgba(217,247,255,.76); + background: rgba(5, 24, 40, .78); + box-shadow: inset 0 0 20px rgba(43,231,255,.06); +} +.command-live b { color: #2ee6a6; } +.command-time-trigger, +.command-refresh { + border-color: rgba(88,166,255,.28); + color: #d9f7ff; + background: rgba(10,31,51,.9); +} +.command-graph { + background: + radial-gradient(circle at 50% 44%, rgba(43,231,255,.1), transparent 35%), + linear-gradient(180deg, rgba(5,21,37,.94), rgba(5,16,31,.92)); +} +.command-graph:before, +.command-graph:after { + background: linear-gradient(transparent, rgba(43,231,255,.22), transparent); +} +.command-link { + stroke: rgba(57, 140, 224, .55); + filter: drop-shadow(0 0 5px rgba(43, 141, 255, .4)); + animation: commandFlow 3s linear infinite; +} +.command-link.risk-link { animation-direction: reverse; animation-duration: 3.8s; } +.triage-running .risk-link { animation-direction: normal; animation-duration: .78s; } +.command-source span, +.merge-node span, +.outcome-stack span { color: rgba(170,222,255,.62); } +.command-source i { + background: #2be7ff; + box-shadow: 0 0 12px rgba(43,231,255,.86); + animation: commandPortPulse 1.8s ease-in-out infinite; +} +.command-source:nth-child(2) i { animation-delay: .7s; } +.merge-node b { + color: #f7fdff; + text-shadow: 0 0 16px rgba(43,231,255,.4); + animation: commandValuePulse 2.6s ease-in-out infinite; +} +.merge-node:before { + background: linear-gradient(transparent, rgba(43,231,255,.62), transparent); + animation: commandMergeScan 2.4s ease-in-out infinite; +} +.command-original-core:before, +.command-original-core:after { display: none; } +.command-original-core > .ai-core { + width: 100%; + height: 100%; + min-height: 0; + overflow: visible; +} +.command-original-core .core-live-status { top: 8px; } +.command-original-core .core-numbers { display: none; } +.command-original-core .agent-denoise { top: -14%; } +.command-original-core .agent-investigate, +.command-original-core .agent-aggregate { bottom: -8%; } +.agent-badge { + border-color: rgba(43,231,255,.38); + background: linear-gradient(145deg, rgba(7,42,62,.94), rgba(5,18,34,.94)); + box-shadow: inset 0 0 18px rgba(43,231,255,.08), 0 0 13px rgba(43,231,255,.1); +} +.agent-badge i, +.agent-badge span { color: #2be7ff; } +.agent-badge.active { + border-color: #2ee6a6; + box-shadow: inset 0 0 22px rgba(46,230,166,.18), 0 0 22px rgba(43,231,255,.28); +} +.outcome-stack .primary { + animation: commandOutcomePulse 2.5s ease-in-out infinite; +} +.outcome-stack .primary b { color: #2ee6a6; text-shadow: 0 0 14px rgba(46,230,166,.42); } +.severity-node { + animation: commandSeverityFloat 4s ease-in-out infinite; +} +.severity-node:nth-child(2) { animation-delay: .55s; } +.severity-node:nth-child(3) { animation-delay: 1.1s; } +.severity-node:nth-child(4) { animation-delay: 1.65s; } +.command-activity-lane { + border-color: rgba(88,166,255,.2); + background: linear-gradient(145deg, rgba(7,28,47,.93), rgba(5,17,32,.92)); + box-shadow: inset 0 0 22px rgba(43,231,255,.025); + animation: commandLaneIdle 4.8s ease-in-out infinite; +} +.command-activity-lane:nth-child(2) { animation-delay: 1.1s; } +.command-activity-lane.active { + border-color: rgba(46,230,166,.62); + animation: activityCardPulse 1.4s ease-in-out infinite; +} +.command-metrics { + border-color: rgba(43,231,255,.2); + background: linear-gradient(180deg, rgba(6,22,38,.98), rgba(4,14,27,.98)); +} +.command-metric { + border-right-color: rgba(43,231,255,.16); + background: radial-gradient(circle at 50% 120%, rgba(43,231,255,.07), transparent 54%); +} +.command-metric .spark-line { + stroke-dashoffset: 0; + animation: commandMetricGlow 2.8s ease-in-out infinite; +} +.command-metric:nth-child(2) .spark-line { animation-delay: .45s; } +.command-metric:nth-child(3) .spark-line { animation-delay: .9s; } +.command-metric:nth-child(4) .spark-line { animation-delay: 1.35s; } +.command-event-rail { + border-left-color: rgba(43,231,255,.16); + background: linear-gradient(180deg, rgba(6,22,38,.99), rgba(5,15,29,.99)); +} +.event-rail-head, +.event-rail-item { border-color: rgba(43,231,255,.15); } +.event-update-banner { + position: relative; + overflow: hidden; + border-color: rgba(88,166,255,.48); + color: #b9d8ff; + background: rgba(31,76,132,.3); +} +.event-update-banner:after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -45%; + width: 36%; + background: linear-gradient(90deg, transparent, rgba(140,203,255,.18), transparent); + animation: commandBannerSweep 3.8s ease-in-out infinite; +} +.event-rail-item.state-processing { + background: linear-gradient(90deg, rgba(43,231,255,.06), transparent 64%); + animation: commandEventActive 1.8s ease-in-out infinite; +} +.event-rail-item.motion-enter { + will-change: opacity, transform; + animation: commandQueueEnter .32s cubic-bezier(.22,.78,.24,1) both; +} +.event-rail-item.state-processing.motion-enter { + animation: + commandQueueEnter .32s cubic-bezier(.22,.78,.24,1) both, + commandEventActive 1.8s .32s ease-in-out infinite; +} +.event-rail-item.motion-exit, +.event-rail-item.state-processing.motion-exit { + overflow: hidden; + pointer-events: none; + transform-origin: 50% 0; + will-change: opacity, transform, min-height, max-height, padding; + animation: commandQueueExit .38s cubic-bezier(.4,0,.2,1) both; +} +.event-rail-item.motion-filter-exit, +.event-rail-item.state-processing.motion-filter-exit { + pointer-events: none; + will-change: opacity, transform; + animation: commandQueueFilterExit .24s cubic-bezier(.4,0,.2,1) both; +} +@keyframes commandLogoPulse { + 0%, 100% { filter: brightness(.92); } + 50% { filter: brightness(1.18); box-shadow: inset 0 0 24px rgba(43,231,255,.22), 0 0 24px rgba(43,231,255,.2); } +} +@keyframes commandPortPulse { + 0%, 100% { opacity: .55; transform: scaleY(.72); } + 50% { opacity: 1; transform: scaleY(1.15); } +} +@keyframes commandValuePulse { + 0%, 100% { opacity: .82; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.06); } +} +@keyframes commandMergeScan { + 0%, 100% { opacity: .25; transform: translateY(-10px); } + 50% { opacity: .9; transform: translateY(10px); } +} +@keyframes commandOutcomePulse { + 0%, 100% { opacity: .72; transform: translateX(0); } + 50% { opacity: 1; transform: translateX(4px); } +} +@keyframes commandSeverityFloat { + 0%, 100% { transform: translateX(0); filter: brightness(.92); } + 50% { transform: translateX(5px); filter: brightness(1.14); } +} +@keyframes commandLaneIdle { + 0%, 100% { box-shadow: inset 0 0 22px rgba(43,231,255,.025); } + 50% { box-shadow: inset 0 0 30px rgba(43,231,255,.075), 0 0 12px rgba(43,231,255,.04); } +} +@keyframes commandDrumRoll { + from { transform: translate3d(0, 0, 0); } + to { transform: translate3d(0, -104px, 0); } +} +@keyframes commandDrumFocusPulse { + 0%, 100% { opacity: .58; filter: brightness(.88); } + 50% { opacity: 1; filter: brightness(1.28); } +} +@keyframes commandDrumScan { + 0%, 22% { transform: translateX(0); opacity: 0; } + 46% { opacity: .78; } + 78%, 100% { transform: translateX(620%); opacity: 0; } +} +@keyframes commandMetricGlow { + 0%, 100% { opacity: .46; filter: drop-shadow(0 0 3px var(--metric-color)); } + 50% { opacity: 1; filter: drop-shadow(0 0 9px var(--metric-color)); } +} +@keyframes commandBannerSweep { + 0%, 30% { transform: translateX(0); opacity: 0; } + 50% { opacity: 1; } + 80%, 100% { transform: translateX(420%); opacity: 0; } +} +@keyframes commandQueueEnter { + from { + opacity: 0; + transform: translate3d(0,-6px,0) scale(.992); + } + to { + opacity: 1; + transform: translate3d(0,0,0) scale(1); + } +} +@keyframes commandQueueExit { + 0% { + opacity: 1; + min-height: 112px; + max-height: 160px; + gap: 5px; + padding-top: 13px; + padding-bottom: 12px; + border-color: rgba(43,231,255,.15); + transform: translate3d(0,0,0) scale(1); + } + 32% { + opacity: 0; + min-height: 112px; + max-height: 160px; + gap: 5px; + padding-top: 13px; + padding-bottom: 12px; + border-color: rgba(43,231,255,.15); + transform: translate3d(6px,-2px,0) scale(.994); + } + 100% { + opacity: 0; + min-height: 0; + max-height: 0; + gap: 0; + padding-top: 0; + padding-bottom: 0; + border-color: transparent; + transform: translate3d(6px,-2px,0) scale(.994); + } +} +@keyframes commandQueueFilterExit { + from { + opacity: 1; + transform: translate3d(0,0,0) scale(1); + } + to { + opacity: 0; + transform: translate3d(8px,-2px,0) scale(.992); + } +} +@keyframes commandEventActive { + 0%, 100% { box-shadow: inset 2px 0 rgba(46,230,166,.18); } + 50% { box-shadow: inset 5px 0 rgba(46,230,166,.38); } +} +.ai-task-progress { + position: absolute; + z-index: 2; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + transform: rotate(-90deg); + filter: drop-shadow(0 0 7px color-mix(in srgb, var(--core-task-accent) 52%, transparent)); +} +.ai-task-progress circle { + fill: none; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} +.ai-task-progress-base { + stroke: color-mix(in srgb, var(--core-task-accent) 18%, transparent); + stroke-dasharray: 2 3; +} +.ai-task-progress-value { + stroke: var(--core-task-accent); + stroke-linecap: round; + stroke-dasharray: 100; + stroke-dashoffset: 100; + animation: aiTaskProgress var(--core-task-duration) linear forwards; +} +.core-task-active .ai-sphere { + box-shadow: + 0 0 74px color-mix(in srgb, var(--core-task-accent) 32%, transparent), + 0 0 116px rgba(111,116,255,.11), + inset 0 0 54px rgba(64,147,255,.19); +} +.core-task-active .ai-sphere > span:first-child { font-size: clamp(48px, 6vw, 68px); } +.ai-operation-window { + position: absolute; + z-index: 4; + right: 23px; + bottom: 27px; + left: 23px; + height: 19px; + overflow: hidden; + border-top: 1px solid color-mix(in srgb, var(--core-task-accent) 30%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--core-task-accent) 30%, transparent); + border-radius: 4px; + background: rgba(3,12,25,.56); + box-shadow: inset 0 0 12px color-mix(in srgb, var(--core-task-accent) 10%, transparent); + animation: aiOperationWindowIn .45s cubic-bezier(.2,.72,.24,1) both; +} +.ai-operation-track { + display: flex; + flex-direction: column; + animation: aiOperationRoll var(--core-task-duration) linear forwards; +} +.ai-operation-track span { + display: block; + flex: 0 0 18px; + height: 18px; + overflow: hidden; + color: #e1f8ff; + font-size: 10px; + font-weight: 700; + line-height: 18px; + letter-spacing: .02em; + text-align: center; + text-overflow: ellipsis; + text-shadow: 0 0 8px var(--core-task-accent); + white-space: nowrap; +} +.ai-operation-idle { + position: absolute; + z-index: 4; + right: 34px; + bottom: 30px; + left: 34px; + color: rgba(170,222,255,.55); + font-size: 9px; + text-align: center; + animation: aiOperationIdleIn .7s ease .25s both; +} +.ai-evidence-field { + position: absolute; + z-index: 7; + inset: 0; + pointer-events: none; +} +.ai-evidence-card { + position: absolute; + display: flex; + flex-direction: column; + width: 104px; + min-height: 42px; + padding: 6px 8px; + opacity: 0; + border: 1px solid color-mix(in srgb, var(--core-task-accent) 48%, transparent); + border-radius: 6px; + background: linear-gradient(145deg, rgba(7,38,61,.94), rgba(4,15,30,.94)); + box-shadow: inset 0 0 15px color-mix(in srgb, var(--core-task-accent) 8%, transparent), 0 0 14px color-mix(in srgb, var(--core-task-accent) 14%, transparent); + animation: aiEvidenceArrive .48s ease forwards, aiEvidenceFloat 3.2s ease-in-out 1s infinite; +} +.ai-evidence-card:after { + content: ""; + position: absolute; + top: 50%; + width: 28px; + height: 1px; + background: linear-gradient(90deg, color-mix(in srgb, var(--core-task-accent) 64%, transparent), transparent); + box-shadow: 0 0 7px var(--core-task-accent); +} +.ai-evidence-card:nth-child(odd):after { right: -29px; } +.ai-evidence-card:nth-child(even):after { left: -29px; transform: rotate(180deg); } +.ai-evidence-card span { color: var(--core-task-accent); font-size: 8px; } +.ai-evidence-card b { + margin-top: 3px; + overflow: hidden; + color: #dff6ff; + font-size: 9px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-evidence-card.evidence-1 { top: 24%; left: -19%; } +.ai-evidence-card.evidence-2 { top: 24%; right: -19%; } +.ai-evidence-card.evidence-3 { bottom: 22%; left: -22%; } +.ai-evidence-card.evidence-4 { right: -22%; bottom: 22%; } +.command-flow-particle { + opacity: .28; + fill: #2be7ff; + filter: drop-shadow(0 0 6px rgba(43,231,255,.88)); + transition: opacity .3s ease; +} +.command-flow-particle.flow-triage { + fill: #9b8cff; + filter: drop-shadow(0 0 7px rgba(155,140,255,.92)); +} +.denoise-running .command-flow-particle.flow-denoise, +.triage-running .command-flow-particle.flow-triage { opacity: 1; } +.denoise-running .command-flow-particle.result-particle, +.triage-running .command-flow-particle.result-particle { + filter: drop-shadow(0 0 11px #fff) drop-shadow(0 0 16px currentColor); +} +.outcome-stack .primary.processing { + padding: 8px 10px; + margin-left: -10px; + border-left: 2px solid #9b8cff; + border-radius: 4px; + background: linear-gradient(90deg, rgba(155,140,255,.13), transparent); + animation: aiOutcomeProcessing 1s ease-in-out infinite; +} +.severity-node.active-target { + animation: aiRiskTarget 1s ease-in-out infinite; +} +.severity-node.recent-target { + animation: aiRiskResult .8s ease 2; +} +.command-metric-value { + transform-origin: center bottom; + animation: aiMetricFlip .58s cubic-bezier(.22,.9,.34,1.2); +} +@keyframes aiTaskProgress { + to { stroke-dashoffset: 0; } +} +@keyframes aiOperationRoll { + 0%, 18% { transform: translateY(0); } + 22%, 38% { transform: translateY(-18px); } + 42%, 68% { transform: translateY(-36px); } + 72%, 94% { transform: translateY(-54px); } + 100% { transform: translateY(-72px); } +} +@keyframes aiOperationWindowIn { + from { opacity: 0; transform: translateY(5px) scale(.96); filter: blur(2px); } + to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } +} +@keyframes aiOperationIdleIn { + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes aiEvidenceArrive { + from { opacity: 0; transform: translateY(8px) scale(.88); filter: blur(3px); } + to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } +} +@keyframes aiEvidenceFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} +@keyframes aiOutcomeProcessing { + 0%, 100% { opacity: .64; box-shadow: 0 0 0 rgba(155,140,255,0); } + 50% { opacity: 1; box-shadow: -6px 0 18px rgba(155,140,255,.2); } +} +@keyframes aiRiskTarget { + 0%, 100% { transform: translateX(0) scale(1); filter: brightness(.9); } + 50% { transform: translateX(7px) scale(1.08); filter: brightness(1.35) drop-shadow(0 0 9px rgba(255,85,85,.52)); } +} +@keyframes aiRiskResult { + 0% { transform: scale(1); } + 45% { transform: scale(1.16); filter: brightness(1.55); } + 100% { transform: scale(1); } +} +@keyframes aiMetricFlip { + from { opacity: .15; transform: perspective(160px) rotateX(-72deg) translateY(5px); } + to { opacity: 1; transform: perspective(160px) rotateX(0) translateY(0); } +} +.animated-number { + display: inline-block; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.command-core { + width: min(30vw, 410px); + height: min(30vw, 410px); + min-width: 330px; + min-height: 330px; +} +.command-original-core .ai-core:before { width: 270px; } +.command-original-core .ai-sphere { width: clamp(238px, 27vw, 286px); } +.command-original-core .orbit-a { width: min(350px, 96%); height: 250px; } +.command-original-core .orbit-b { width: 250px; height: min(350px, 98%); } +.command-original-core .energy-ring { width: 292px; } +.command-original-core .energy-ring.ring-b { width: 350px; } +.command-original-core .scan-line { height: 280px; } +.command-original-core .ai-evidence-card.evidence-1 { top: 10%; left: 0; } +.command-original-core .ai-evidence-card.evidence-2 { top: 10%; right: 0; } +.command-original-core .ai-evidence-card.evidence-3 { bottom: 10%; left: 0; } +.command-original-core .ai-evidence-card.evidence-4 { right: 0; bottom: 10%; } +.command-original-core .ai-core:after { + content: ""; + position: absolute; + z-index: 0; + top: 50%; + left: 50%; + width: 96%; + aspect-ratio: 1; + border-radius: 50%; + pointer-events: none; + opacity: .34; + background: repeating-conic-gradient(from 0deg, color-mix(in srgb, var(--core-task-accent) 62%, transparent) 0deg 1deg, transparent 1deg 8deg); + -webkit-mask: radial-gradient(circle, transparent 0 73%, #000 73.5% 75%, transparent 75.5%); + mask: radial-gradient(circle, transparent 0 73%, #000 73.5% 75%, transparent 75.5%); + animation: coreTickSpin 26s linear infinite; +} +.command-original-core .energy-ring:before { + content: ""; + position: absolute; + inset: -3px; + border-radius: inherit; + pointer-events: none; + opacity: .78; + background: conic-gradient( + from 0deg, + transparent 0 8%, + color-mix(in srgb, var(--core-task-accent) 88%, #fff 12%) 9% 17%, + transparent 18% 44%, + rgba(255,209,102,.78) 45% 48%, + transparent 49% 73%, + rgba(155,140,255,.76) 74% 83%, + transparent 84% 100% + ); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px)); + animation: coreArcSpin 8.8s linear infinite; +} +.command-original-core .energy-ring.ring-b:before { + opacity: .56; + animation-direction: reverse; + animation-duration: 13.6s; +} +.command-original-core .orbit, +.command-original-core .energy-ring, +.command-original-core .ai-core:before, +.command-original-core .ai-core:after, +.command-original-core .scan-line, +.command-drum-track { + will-change: transform, opacity; + backface-visibility: hidden; +} +.command-original-core .orbit-a { + border-top-color: rgba(43,231,255,.72); + border-right-color: transparent; + border-bottom-color: rgba(43,231,255,.16); + animation-duration: 10.4s; +} +.command-original-core .orbit-b { + border-top-color: transparent; + border-right-color: rgba(155,140,255,.5); + border-bottom-color: rgba(155,140,255,.12); + animation-duration: 14.8s; +} +.core-task-denoise .ai-task-progress-value { + stroke-dasharray: 18 82; + stroke-dashoffset: 0; + animation: coreDenoiseProgress 1.15s linear infinite; +} +.source-stack { width: 5.8%; } +.command-source span { white-space: nowrap; } +.command-activity-lane { + grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); + gap: 8px; + border: 0; + border-radius: 0; + background: + radial-gradient(circle at 84% 48%, color-mix(in srgb, var(--drum-accent) 7%, transparent), transparent 46%), + linear-gradient(90deg, rgba(5,25,43,.68), rgba(5,22,39,.38) 76%, transparent); + box-shadow: none; +} +.command-activity-lane:before { + content: ""; + position: absolute; + z-index: 0; + top: 0; + right: 2%; + left: 2%; + height: 1px; + pointer-events: none; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--drum-accent) 46%, transparent), transparent); +} +.command-activity-lane:after { + content: ""; + position: absolute; + z-index: 0; + inset: 0; + pointer-events: none; + background: linear-gradient(90deg, transparent 72%, color-mix(in srgb, var(--drum-accent) 5%, transparent), transparent); +} +.command-activity-lane > * { position: relative; z-index: 1; } +.command-activity-lane.active { border: 0; } +.command-drum-shell { + padding-left: 4px; + border-left: 0; +} +.command-drum-window { + border: 0; + border-radius: 0; + background: + radial-gradient(ellipse at center, color-mix(in srgb, var(--drum-accent) 8%, transparent), transparent 68%), + linear-gradient(90deg, transparent, rgba(4,18,34,.7) 18%, rgba(4,18,34,.7) 82%, transparent); + box-shadow: inset 0 14px 18px rgba(2,8,18,.72), inset 0 -14px 18px rgba(2,8,18,.72); +} +.command-drum-window:before { + background: linear-gradient(180deg, rgba(2,8,18,.86), transparent 30%, transparent 70%, rgba(2,8,18,.86)); + mask-image: none; +} +.command-drum-focus { + border: 0; + border-radius: 0; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--drum-accent) 12%, transparent), transparent); + box-shadow: 0 0 14px color-mix(in srgb, var(--drum-accent) 11%, transparent); +} +.command-activity-lane.active .command-drum-window { + border: 0; + box-shadow: + inset 0 14px 18px rgba(2,8,18,.72), + inset 0 -14px 18px rgba(2,8,18,.72), + 0 0 18px color-mix(in srgb, var(--drum-accent) 12%, transparent); +} +.command-drum-caption { justify-content: flex-end; } +.command-metrics { + border: 0; + background: linear-gradient(180deg, rgba(5,16,31,.92), rgba(4,14,27,.98)); +} +.command-metric { + border-right: 0; + background: radial-gradient(ellipse at 50% 116%, color-mix(in srgb, var(--metric-color) 9%, transparent), transparent 58%); +} +.command-links { + inset: 0; + height: 100%; +} +.command-link { + vector-effect: non-scaling-stroke; + stroke: rgba(24, 64, 105, .68); + stroke-width: 6; + stroke-dasharray: none; + opacity: .58; + filter: drop-shadow(0 0 3px rgba(38,112,181,.36)) drop-shadow(0 0 7px rgba(18,67,112,.18)); + animation: none; +} +.command-link.risk-link { stroke-width: 5; } +.command-link.dim { opacity: .28; } +.command-link-dots { + vector-effect: non-scaling-stroke; + fill: none; + stroke: rgba(87, 160, 226, .72); + stroke-width: 1.35; + stroke-linecap: round; + stroke-dasharray: .7 12; + opacity: .68; + filter: drop-shadow(0 0 2px rgba(91,170,242,.64)); + animation: commandDotFlow 3.2s linear infinite; +} +.command-link-dots.dim { opacity: .3; } +.denoise-running .command-link.source-link, +.denoise-running .command-link.denoise-link { + stroke: rgba(34, 118, 188, .86); + stroke-width: 7; + stroke-dasharray: none; + filter: drop-shadow(0 0 5px rgba(43,174,255,.62)) drop-shadow(0 0 12px rgba(30,115,185,.35)); + animation: none; +} +.triage-running .command-link.triage-link, +.triage-running .command-link.risk-link { + stroke: rgba(83, 91, 178, .82); + stroke-width: 7; + stroke-dasharray: none; + filter: drop-shadow(0 0 5px rgba(155,140,255,.55)) drop-shadow(0 0 12px rgba(79,84,177,.3)); + animation: none; +} +.triage-running .command-link.risk-link { stroke-width: 6; } +.denoise-running .command-link-dots.source-link, +.denoise-running .command-link-dots.denoise-link { + stroke: #7edcff; + stroke-dasharray: 1 9; + opacity: 1; + animation: commandDotFlowActive .78s linear infinite; +} +.triage-running .command-link-dots.triage-link, +.triage-running .command-link-dots.risk-link { + stroke: #b4a9ff; + stroke-dasharray: 1 9; + opacity: 1; + animation: commandDotFlowActive .9s linear infinite; +} +.severity-node, +.severity-node:nth-child(2), +.severity-node:nth-child(3), +.severity-node:nth-child(4) { + transform: none; + animation: none; +} +.severity-node.active-target { animation: aiRiskGlow 1s ease-in-out infinite; } +.severity-node.recent-target { animation: aiRiskResultGlow .8s ease 2; } +.command-metric-value { animation: none; } +@keyframes commandDotFlow { + to { stroke-dashoffset: -50.8; } +} +@keyframes commandDotFlowActive { to { stroke-dashoffset: -50; } } +@keyframes coreDenoiseProgress { to { stroke-dashoffset: -100; } } +@keyframes coreArcSpin { to { transform: rotate(360deg); } } +@keyframes coreTickSpin { + from { transform: translate(-50%, -50%) rotate(0deg); } + to { transform: translate(-50%, -50%) rotate(-360deg); } +} +@keyframes aiRiskGlow { + 0%, 100% { filter: brightness(.9) drop-shadow(0 0 0 rgba(255,85,85,0)); } + 50% { filter: brightness(1.35) drop-shadow(0 0 10px rgba(255,85,85,.58)); } +} +@keyframes aiRiskResultGlow { + 0%, 100% { filter: brightness(1); } + 45% { filter: brightness(1.55) drop-shadow(0 0 9px rgba(255,164,79,.48)); } +} +.core-batched { + top: 37px; + display: flex; + align-items: center; + gap: 7px; + padding: 4px 9px; + border: 1px solid rgba(43,231,255,.2); + border-radius: 999px; + color: rgba(217,247,255,.72); + background: rgba(3,14,28,.78); + box-shadow: 0 0 18px rgba(43,231,255,.09), inset 0 0 12px rgba(43,231,255,.05); + backdrop-filter: blur(5px); +} +.core-batched b { color: #2be7ff; font-size: 10px; } +.core-batched span { color: rgba(170,222,255,.68); font-size: 9px; } +.core-load-burst .ai-sphere { + box-shadow: + 0 0 80px rgba(43,231,255,.36), + 0 0 112px rgba(111,116,255,.12), + inset 0 0 56px rgba(64,147,255,.2); +} +.core-load-surge .ai-sphere { + animation-duration: 3.6s; + box-shadow: + 0 0 88px rgba(43,231,255,.42), + 0 0 126px rgba(155,140,255,.15), + inset 0 0 62px rgba(64,147,255,.22); +} +.core-load-surge .energy-ring:before { animation-duration: 3.8s; } +.core-load-surge .energy-ring.ring-b:before { animation-duration: 5.6s; } +.core-load-surge .core-batched { + border-color: rgba(255,209,102,.38); + box-shadow: 0 0 22px rgba(255,209,102,.11), inset 0 0 14px rgba(43,231,255,.07); +} +.core-load-surge .core-batched b { color: #ffd166; } +.command-live.load-burst, +.command-live.load-surge { border-color: rgba(43,231,255,.48); box-shadow: inset 0 0 24px rgba(43,231,255,.1), 0 0 16px rgba(43,231,255,.08); } +.command-live.load-surge { border-color: rgba(255,209,102,.42); } +.load-surge .command-link-dots.source-link, +.load-surge .command-link-dots.denoise-link { stroke-dasharray: 1 7; animation-duration: .58s; } +@media (max-width: 1360px) { + .command-core { width: 350px; height: 350px; min-width: 350px; min-height: 350px; } + .command-original-core .ai-sphere { width: 250px; } + .command-original-core .orbit-a { width: 310px; height: 218px; } + .command-original-core .orbit-b { width: 218px; height: 310px; } + .command-original-core .energy-ring { width: 260px; } + .command-original-core .energy-ring.ring-b { width: 310px; } +} +@media (max-width: 1120px) { + .command-core { width: 310px; height: 310px; min-width: 310px; min-height: 310px; } + .command-original-core .ai-sphere { width: 232px; } +} +@media (prefers-reduced-motion: no-preference) { + .command-root:not(.command-is-processing) .command-link-dots { + opacity: .4; + filter: none; + animation-duration: 6.4s; + } + .command-root:not(.command-is-processing) .command-flow-particle { + opacity: .16; + filter: none; + } + .command-root:not(.command-is-processing) .command-original-core .energy-ring:before { + animation-duration: 18s; + } + .command-root:not(.command-is-processing) .command-original-core .energy-ring.ring-b:before { + animation-duration: 28s; + } +} +[data-animations="on"], +[data-animations="on"] *, +[data-animations="on"] *::before, +[data-animations="on"] *::after { + animation-play-state: running !important; +} `; diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/guide.md b/.flocks/flockshub/plugins/workflows/stream_alert_triage/guide.md index edd53dd03..69ee83be6 100644 --- a/.flocks/flockshub/plugins/workflows/stream_alert_triage/guide.md +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/guide.md @@ -39,7 +39,7 @@ Rex 引导用户时必须遵守: - 对降噪后的 HTTP 告警做攻击研判。 - 复用 `stream_alert_denoise` 的 `dedup_key` 降低 LLM 调用。 - 按日期重放某天全部去重结果。 -- 默认把研判后的完整告警写入 `~/.flocks/data/soc.db`,并保留 JSONL 可选输出,供归档或下游工作流消费。 +- 默认只把明确 `is_duplicate=false`、包含 `dedup_key` 且批内首次出现的研判告警写入 `~/.flocks/data/soc.db`,并保留 JSONL 可选输出,供归档或下游工作流消费。 本工作流不适合: @@ -118,6 +118,8 @@ Rex 引导用户时必须遵守: - 如果告警缺少 `dedup_key`,该告警会作为独立 work unit 研判,无法和其它告警复用。 - `is_duplicate` 不决定是否研判。缓存命中策略只依赖 `dedup_key`。 +- `is_duplicate` 决定是否允许写入 SOC DB:只有明确为 `false` 的告警才有资格持久化;缺少该字段时按“未证明首见”处理,不写入 SOC DB。 +- SOC DB 持久化要求非空 `dedup_key`:批内只接受第一条,数据库再通过唯一索引保证跨执行全局唯一;无 `dedup_key` 的防御性研判结果不会写入 SOC DB。 - `stream_alert_denoise` 只会把跨批次首见告警写入 JSONL;因此常规情况下本工作流读取到的是适合继续研判的首见告警。 - 如果用户手工构造 JSONL,必须保证每行是独立 JSON 对象,不能是整文件 JSON 数组。 @@ -134,7 +136,7 @@ Rex 引导用户时必须遵守: | `loaded_files` | 实际读取的上游文件 | | `input_date` | 本次读取日期 | | `triage_output_mode` | 本次生效的输出模式:`soc_db` / `jsonl` / `both` / `none` | -| `soc_db_result` / `soc_db_path` | 本次写入的 SOC DB 结果和路径 | +| `soc_db_result` / `soc_db_path` | 本次写入的 SOC DB 结果和路径;结果区分 `inserted_rows` 与 `updated_rows` | | `output_paths` | 本次写入的研判 JSONL 文件列表;未启用 JSONL 时为空 | | `output_dir` | 研判 JSONL 结果目录;未启用 JSONL 时为空 | | `summary_report` | markdown 总览文本 | @@ -174,6 +176,12 @@ alert_records 写入规则: +- SOC DB 只接受明确 `is_duplicate=false`、包含非空 `dedup_key` 且该 key 在本批次首次出现的告警。 +- `is_duplicate=true`、缺少 `is_duplicate`、缺少 `dedup_key` 或批内重复 `dedup_key` 的告警均不会写入 SOC DB。 +- `alert_records.dedup_key` 使用部分唯一索引保证跨批次、跨执行只能存在一条告警记录。 +- 已存在的 `dedup_key` 再次回放时只更新研判字段和研判运行标记,不覆盖首次告警的时间、来源、行号、标识与原始事件字段。 +- SOC DB 建表、迁移或写入失败会使本次工作流失败,不会以“成功但写入 0 条”结束。 +- `soc_db_result` 记录本次持久化总数、新增数与更新数;`triage_stats.soc_db_filter_stats` 记录候选数及各类跳过数。 - `triage_output_mode=soc_db`:默认写入 `soc.db`,不写 JSONL。 - `triage_output_mode=jsonl`:只写 `triage_result_NNN.jsonl`,不写 `soc.db`。 - `triage_output_mode=both`:同时写 `soc.db` 和 JSONL。 @@ -306,7 +314,8 @@ leader/follower 规则: - `triage_stats.total == load_stats.record_count` - `triage_stats.work_units <= triage_stats.total` - `enriched_alerts_with_triage[*].triage_report` 存在于已研判或缓存命中的告警上 -- `soc_db_result.rows` 等于本次尝试写入 `alert_records` 的记录数,除非 `triage_output_mode=jsonl/none` +- `soc_db_result.rows` 等于本次持久化的候选数(`inserted_rows + updated_rows`);应与 `triage_stats.soc_db_first_seen_rows` 一致,除非 `triage_output_mode=jsonl/none` +- `triage_stats.soc_db_skipped_rows` 等于输入总数减去首见唯一告警数,并可通过 `triage_stats.soc_db_filter_stats` 查看具体跳过原因 - `output_paths` 指向当日 `triage_result_NNN.jsonl`,仅在 `triage_output_mode=jsonl/both` 或旧参数 `persist_triage_output=true` 时存在 - `summary_path` 指向 `outputs//artifacts/stream_alert_triage_summary.md` diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.json b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.json index b4ea894be..5ff3479c4 100644 --- a/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.json +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.json @@ -14,8 +14,8 @@ { "id": "concurrent_triage", "type": "python", - "description": "Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。所有 enriched_with_triage alerts 默认写入 `~/.flocks/data/soc.db` 的 `alert_records` 表;可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。", - "code": "\"\"\"\nconcurrent_triage: leader/follower 分组并发研判 + dedup_key 缓存复用(自包含)。\n\n去重模式:\n 1. 输入 alerts 先按 dedup_key 分组 → unique dedup_keys 列表\n 2. 每个 group 只对 leader(首条)做研判;followers 复用 leader 结果,不重复调 LLM\n 3. 无 dedup_key 的 alert 各自独立成 work unit(防御性研判,无法复用)\n\n并发结构:\n 外层 ThreadPoolExecutor(max_workers=concurrency):处理 unique work unit\n (concurrency 取值 1–5,默认 1,由 inputs.concurrency 控制)\n 内层 ThreadPoolExecutor(max_workers=4):单条 alert 内 4 个 LLM 并行\n (survey / cve_related / cve_info / payload_analysis — 保留 tdp_alert_triage\n 的 4 并行分支结构)\n 稳态 LLM 并发峰值 ≈ concurrency × 4(仅 4 并行分支阶段,且分支全部启用时)\n\n研判产物只以字段形式附加到每条 alert(attack_verdict / risk_level /\nreport_title / triage_report ...),**不生成任何独立的 per-alert markdown 报告\n文件**,避免冗余落盘与跨日期路径失效。\n\ndedup_key 缓存(与 stream_alert_denoise 的 LSH 状态文件同根目录,逻辑独立):\n ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl\n - cache 命中:直接复用历史 verdict/title/triage_report,**不调用 LLM**\n - cache 未命中:leader 执行完整内联研判(情报 + 4 并行 LLM + verdict + title + report);\n follower 直接广播 leader 结果\n - 新结果合并写回 cache,FIFO LRU 淘汰,文件锁 + 原子落盘\n\"\"\"\n\nimport ipaddress\nimport json\nimport os\nimport pickle\nimport re\nimport sys\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\n\nIS_WINDOWS = sys.platform == 'win32'\nif IS_WINDOWS:\n import msvcrt # noqa: F401\nelse:\n import fcntl # noqa: F401\n\nWORKFLOW_NAME = 'stream_alert_triage'\n\n# Per-call LLM timeout and retry budget for every analysis branch in this\n# node. Workflow LLM calls share the dedicated ``flocks-workflow-llm-loop``;\n# a single hung call (e.g. provider 504, slow TLS handshake) without a\n# timeout would otherwise pin one of the (already concurrency-limited)\n# worker threads for up to httpx's DEFAULT read timeout (10 min), serially\n# blocking the rest of the alert pipeline. 120s + 1 retry covers normal\n# slow-but-alive responses while still recovering from transient hangs.\nLLM_CALL_TIMEOUT_S = 120.0\nLLM_CALL_MAX_RETRIES = 1\n\nTRIAGE_FIELDS = (\n 'attack_verdict',\n 'risk_level',\n 'report_title',\n 'triage_report',\n 'attack_success',\n)\nVERDICT_LABELS = ('attack_success', 'attack_failed', 'attack', 'unknown', 'benign')\nVERDICT_RISK = {\n 'attack_success': 'High',\n 'attack_failed': 'Medium',\n 'attack': 'Medium',\n 'unknown': 'Medium',\n 'benign': 'Low',\n}\nVERDICT_CN = {\n 'attack_success': '攻击成功',\n 'attack_failed': '攻击失败',\n 'attack': '攻击',\n 'unknown': '未知',\n 'benign': '安全',\n}\nTRIAGE_REPORT_VERSION = 'soc.triage.markdown.v1'\nTRIAGE_REPORT_TAGS = (\n 'report_title',\n 'report_meta',\n 'analysis_steps',\n 'triage_conclusion',\n 'attack_payload',\n 'payload_explanation',\n 'response_evidence',\n 'key_evidence',\n 'disposal_recommendation',\n)\n\n\n# ── Cache persistence ─────────────────────────────────────────────────────────\n\ndef _cache_paths():\n from flocks.config import Config\n flocks_root = Config().get_global().data_dir.parent\n state_dir = flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME\n state_dir.mkdir(parents=True, exist_ok=True)\n return str(state_dir / 'triage_cache.pkl'), str(state_dir / 'triage_cache.lock')\n\n\ndef _acquire_lock(lock_path):\n fh = open(lock_path, 'w+')\n try:\n if IS_WINDOWS:\n fh.write('L'); fh.flush(); fh.seek(0)\n while True:\n try:\n msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1); break\n except OSError:\n continue\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_EX)\n except BaseException:\n try:\n fh.close()\n except Exception:\n pass\n raise\n return fh\n\n\ndef _release_lock(fh):\n try:\n if IS_WINDOWS:\n try:\n fh.seek(0); msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)\n except OSError:\n pass\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_UN)\n finally:\n fh.close()\n\n\ndef _load_cache(cache_path):\n if not os.path.exists(cache_path) or os.path.getsize(cache_path) == 0:\n return {}\n try:\n with open(cache_path, 'rb') as f:\n c = pickle.load(f)\n if not isinstance(c, dict):\n return {}\n print(f'[triage_cache] loaded {len(c)} entries from {cache_path}')\n return c\n except Exception as e:\n print(f'[triage_cache] WARNING: failed to load ({e}), starting fresh')\n return {}\n\n\ndef _save_cache_atomic(cache_path, cache):\n tmp = cache_path + '.tmp'\n try:\n with open(tmp, 'wb') as f:\n pickle.dump(cache, f); f.flush(); os.fsync(f.fileno())\n os.replace(tmp, cache_path)\n print(f'[triage_cache] saved {len(cache)} entries -> {cache_path}')\n except Exception as e:\n print(f'[triage_cache] WARNING: failed to save: {e}')\n if os.path.exists(tmp):\n try: os.remove(tmp)\n except Exception: pass\n\n\ndef _evict_lru(cache, max_keys):\n excess = len(cache) - max_keys\n if excess > 0:\n for k in list(cache.keys())[:excess]:\n del cache[k]\n return excess\n return 0\n\n\n# ── Runtime output config and persistence targets ──────────────────────────────\n#\n# Defaults are read from ~/.flocks/plugins/workflows/stream_alert_triage/config.json.\n# Runtime inputs override config values. The default mode is soc_db so SOC pages\n# read the same DB-backed dataset. JSONL remains available via config/input:\n# triage_output_mode = soc_db | jsonl | both | none\n# persist_triage_output = true (legacy alias that adds JSONL to soc_db)\n\nimport datetime as _datetime\n\nMAX_RECORDS_PER_FILE = 10000\n_TRIAGE_JSONL_PREFIX = 'triage_result'\n_TRIAGE_COUNTER_FILE = '.triage_counter.json'\n_WORKFLOW_CONFIG_PATH = os.path.expanduser('~/.flocks/plugins/workflows/stream_alert_triage/config.json')\n_DEFAULT_SOC_DB_PATH = os.path.expanduser('~/.flocks/data/soc.db')\n\n\ndef _load_workflow_config():\n try:\n with open(_WORKFLOW_CONFIG_PATH, 'r', encoding='utf-8') as f:\n cfg = json.load(f)\n if isinstance(cfg, dict):\n return cfg\n except FileNotFoundError:\n pass\n except Exception as e:\n print(f'[triage_config] WARNING: failed to read {_WORKFLOW_CONFIG_PATH}: {e}')\n return {}\n\n\ndef _configured_value(config, key, default=None):\n if key in inputs:\n value = inputs.get(key)\n if value is not None and not (isinstance(value, str) and not value.strip()):\n return value\n return config.get(key, default)\n\n\ndef _input_bool(value, default=False):\n if value is None:\n return default\n if isinstance(value, bool):\n return value\n if isinstance(value, (int, float)):\n return bool(value)\n text = str(value).strip().lower()\n if text in {'1', 'true', 'yes', 'y', 'on'}:\n return True\n if text in {'0', 'false', 'no', 'n', 'off'}:\n return False\n return default\n\n\ndef _resolve_output_config():\n config = _load_workflow_config()\n raw_mode = str(_configured_value(config, 'triage_output_mode', 'soc_db') or 'soc_db').strip().lower()\n mode_alias = {\n 'db': 'soc_db',\n 'sqlite': 'soc_db',\n 'sqlite_db': 'soc_db',\n 'soc': 'soc_db',\n 'json': 'jsonl',\n 'file': 'jsonl',\n 'files': 'jsonl',\n 'off': 'none',\n 'disabled': 'none',\n }\n requested_mode = mode_alias.get(raw_mode, raw_mode)\n if requested_mode not in {'soc_db', 'jsonl', 'both', 'none'}:\n print(f'[triage_config] WARNING: invalid triage_output_mode={raw_mode!r}; using soc_db')\n requested_mode = 'soc_db'\n\n legacy_jsonl = _input_bool(_configured_value(config, 'persist_triage_output', False), False)\n write_soc_db = requested_mode in {'soc_db', 'both'}\n write_jsonl = requested_mode in {'jsonl', 'both'}\n effective_mode = requested_mode\n if requested_mode == 'soc_db' and legacy_jsonl:\n write_jsonl = True\n effective_mode = 'both'\n if requested_mode == 'none':\n write_soc_db = False\n write_jsonl = False\n effective_mode = 'none'\n\n soc_db_path = os.path.expanduser(str(\n _configured_value(config, 'soc_db_path', _DEFAULT_SOC_DB_PATH) or _DEFAULT_SOC_DB_PATH\n ))\n jsonl_output_dir = _configured_value(config, 'jsonl_output_dir', '') or ''\n jsonl_output_dir = os.path.expanduser(str(jsonl_output_dir)) if jsonl_output_dir else ''\n return {\n 'config_path': _WORKFLOW_CONFIG_PATH,\n 'requested_mode': requested_mode,\n 'mode': effective_mode,\n 'write_soc_db': write_soc_db,\n 'write_jsonl': write_jsonl,\n 'soc_db_path': soc_db_path,\n 'jsonl_output_dir': jsonl_output_dir,\n }\n\n\n# ── Persisted JSONL output (optional; mirrors stream_alert_denoise layout) ─────\n#\n# Directory : ~/.flocks/workspace/workflows/stream_alert_triage//\n# Filename : triage_result_NNN.jsonl (3-digit zero-padded seq)\n# Layout : line 1 = {\"_type\":\"file_header\", ...}, subsequent lines = one\n# enriched_with_triage alert per line.\n# Counter : .triage_counter.json sidecar tracks (seq, count) so we don't\n# rescan every existing file on each run; auto-rolls over to a\n# new file when reaching MAX_RECORDS_PER_FILE.\n\n\ndef _triage_output_dir(configured_dir=''):\n \"\"\"Return output directory for triage_result_*.jsonl.\"\"\"\n if configured_dir:\n out_dir = configured_dir\n os.makedirs(out_dir, exist_ok=True)\n return out_dir\n from flocks.config import Config\n flocks_root = Config().get_global().data_dir.parent\n date_str = _datetime.datetime.now().strftime('%Y-%m-%d')\n out_dir = flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME / date_str\n out_dir.mkdir(parents=True, exist_ok=True)\n return str(out_dir)\n\n\ndef _triage_get_counter(out_dir):\n path = os.path.join(out_dir, _TRIAGE_COUNTER_FILE)\n try:\n with open(path, 'r', encoding='utf-8') as f:\n d = json.load(f)\n return int(d.get('seq', 0)), int(d.get('count', 0))\n except Exception:\n return 0, 0\n\n\ndef _triage_set_counter(out_dir, seq, count):\n path = os.path.join(out_dir, _TRIAGE_COUNTER_FILE)\n tmp = path + '.tmp'\n try:\n with open(tmp, 'w', encoding='utf-8') as f:\n json.dump({'seq': seq, 'count': count}, f)\n os.replace(tmp, path)\n except Exception:\n pass\n\n\ndef _triage_find_active_file(out_dir):\n \"\"\"Locate the active (latest, not-yet-full) jsonl file; create if none.\"\"\"\n seq, count = _triage_get_counter(out_dir)\n if seq > 0:\n path = os.path.join(out_dir, f'{_TRIAGE_JSONL_PREFIX}_{seq:03d}.jsonl')\n if os.path.exists(path):\n return path, count, seq\n import glob as _glob\n existing = sorted(_glob.glob(os.path.join(out_dir, _TRIAGE_JSONL_PREFIX + '_*.jsonl')))\n if not existing:\n return None, 0, 0\n latest = existing[-1]\n try:\n seq = int(os.path.basename(latest).replace(_TRIAGE_JSONL_PREFIX + '_', '').replace('.jsonl', ''))\n except ValueError:\n seq = len(existing)\n count = 0\n try:\n with open(latest, 'r', encoding='utf-8') as f:\n for line in f:\n if line.strip() and '\"_type\"' not in line:\n count += 1\n except Exception:\n pass\n return latest, count, seq\n\n\ndef _triage_write_jsonl(out_dir, alerts, run_id, run_stats):\n \"\"\"Append all alerts to today's triage_result_NNN.jsonl, rolling over at\n MAX_RECORDS_PER_FILE. Returns the list of files that were written to.\"\"\"\n now = _datetime.datetime.now()\n written = []\n active_path, active_count, seq = _triage_find_active_file(out_dir)\n remaining = list(alerts)\n while remaining:\n available = MAX_RECORDS_PER_FILE - active_count\n if available <= 0 or active_path is None:\n seq += 1\n active_path = os.path.join(out_dir, f'{_TRIAGE_JSONL_PREFIX}_{seq:03d}.jsonl')\n active_count = 0\n available = MAX_RECORDS_PER_FILE\n header = {\n '_type': 'file_header',\n 'created_at': now.isoformat(),\n 'date': now.strftime('%Y-%m-%d'),\n 'workflow': WORKFLOW_NAME,\n 'seq': seq,\n 'run_id': run_id,\n 'batch_total': run_stats.get('total'),\n 'batch_triaged': run_stats.get('triaged'),\n 'batch_followers_reused':run_stats.get('followers_reused'),\n 'batch_cache_hit': run_stats.get('cache_hit'),\n 'batch_triage_failed': run_stats.get('triage_failed'),\n }\n with open(active_path, 'w', encoding='utf-8') as hf:\n hf.write(json.dumps(header, ensure_ascii=False) + '\\n')\n batch = remaining[:available]\n remaining = remaining[available:]\n with open(active_path, 'a', encoding='utf-8') as af:\n for alert in batch:\n af.write(json.dumps(alert, ensure_ascii=False) + '\\n')\n active_count += len(batch)\n if active_path not in written:\n written.append(active_path)\n if remaining:\n active_path = None\n active_count = 0\n if written:\n _triage_set_counter(out_dir, seq, active_count)\n return written\n\n\n# ── SOC DB output (default) ───────────────────────────────────────────────────\n\ndef _ensure_soc_db_schema(conn):\n conn.execute(\"\"\"\n CREATE TABLE IF NOT EXISTS alert_records (\n row_id TEXT PRIMARY KEY,\n record_id TEXT,\n asset_date TEXT NOT NULL,\n source_file TEXT NOT NULL,\n line_number INTEGER NOT NULL,\n event_time INTEGER,\n source_type TEXT,\n threat_name TEXT,\n is_duplicate INTEGER NOT NULL DEFAULT 0,\n record_json TEXT NOT NULL\n )\n \"\"\")\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_asset_date ON alert_records(asset_date)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_event_time ON alert_records(event_time)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_source_type ON alert_records(source_type)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_threat_name ON alert_records(threat_name)')\n\n\ndef _event_time_value(alert):\n for key in ('time', 'event_time', 'timestamp', 'timestamp_real', 'occur_time', 'created_at'):\n value = alert.get(key)\n if value in (None, ''):\n continue\n if isinstance(value, (int, float)):\n ts = float(value)\n if ts > 100000000000:\n ts = ts / 1000.0\n return int(ts)\n text = str(value).strip()\n if not text:\n continue\n try:\n ts = float(text)\n if ts > 100000000000:\n ts = ts / 1000.0\n return int(ts)\n except Exception:\n pass\n normalized = text.replace('Z', '+00:00')\n try:\n return int(_datetime.datetime.fromisoformat(normalized).timestamp())\n except Exception:\n pass\n for fmt in ('%Y-%m-%d %H:%M:%S', '%Y/%m/%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y/%m/%d %H:%M'):\n try:\n return int(_datetime.datetime.strptime(text, fmt).timestamp())\n except Exception:\n continue\n return int(time.time())\n\n\ndef _asset_date_value(alert, event_time):\n value = alert.get('asset_date') or alert.get('_asset_date') or alert.get('date')\n if value:\n text = str(value).strip()\n if re.match(r'^\\d{4}-\\d{2}-\\d{2}$', text):\n return text\n try:\n return _datetime.datetime.fromtimestamp(int(event_time)).strftime('%Y-%m-%d')\n except Exception:\n return _datetime.datetime.now().strftime('%Y-%m-%d')\n\n\ndef _source_type_value(alert):\n for key in ('source_type', '_source_type', 'data_source', 'log_type', 'vendor', 'device_type'):\n value = alert.get(key)\n if value not in (None, ''):\n return str(value)\n return ''\n\n\ndef _record_id_value(alert):\n for key in ('record_id', 'id', 'uuid', 'event_id', 'dedup_key'):\n value = alert.get(key)\n if value not in (None, ''):\n return str(value)\n return ''\n\n\ndef _stable_row_id(alert, source_file, line_number, event_time):\n existing = alert.get('row_id') or alert.get('_row_id')\n if existing:\n return str(existing)\n import hashlib as _hashlib\n basis = {\n 'record_id': _record_id_value(alert),\n 'dedup_key': alert.get('dedup_key', ''),\n 'time': event_time,\n 'source_file': source_file,\n 'line_number': line_number,\n 'sip': alert.get('sip', ''),\n 'sport': alert.get('sport', ''),\n 'dip': alert.get('dip', ''),\n 'dport': alert.get('dport', ''),\n 'threat_rule_id': alert.get('threat_rule_id') or alert.get('rule_id') or '',\n }\n raw = json.dumps(basis, sort_keys=True, ensure_ascii=False)\n return _hashlib.sha256(raw.encode('utf-8')).hexdigest()\n\n\ndef _triage_write_soc_db(db_path, alerts, run_id):\n import sqlite3\n\n db_dir = os.path.dirname(db_path)\n if db_dir:\n os.makedirs(db_dir, exist_ok=True)\n default_source_file = ''\n loaded_files = inputs.get('loaded_files') or []\n if isinstance(loaded_files, list) and len(loaded_files) == 1:\n default_source_file = str(loaded_files[0])\n\n persisted_at = _datetime.datetime.now().isoformat()\n rows = []\n for idx, alert in enumerate(alerts, 1):\n record = dict(alert)\n record['_triage_run_id'] = run_id\n record['_triage_persisted_at'] = persisted_at\n source_file = str(\n record.get('source_file')\n or record.get('_source_file')\n or record.get('file_path')\n or default_source_file\n or 'stream_alert_triage'\n )\n try:\n line_number = int(record.get('line_number') or record.get('_line_number') or idx)\n except Exception:\n line_number = idx\n event_time = _event_time_value(record)\n asset_date = _asset_date_value(record, event_time)\n record_id = _record_id_value(record)\n source_type = _source_type_value(record)\n threat_name = str(record.get('threat_name') or record.get('rule_name') or '')\n is_duplicate = 1 if record.get('is_duplicate') else 0\n row_id = _stable_row_id(record, source_file, line_number, event_time)\n rows.append((\n row_id,\n record_id,\n asset_date,\n source_file,\n line_number,\n event_time,\n source_type,\n threat_name,\n is_duplicate,\n json.dumps(record, ensure_ascii=False),\n ))\n\n with sqlite3.connect(db_path) as conn:\n _ensure_soc_db_schema(conn)\n conn.executemany(\"\"\"\n INSERT INTO alert_records (\n row_id, record_id, asset_date, source_file, line_number,\n event_time, source_type, threat_name, is_duplicate, record_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(row_id) DO UPDATE SET\n record_id=excluded.record_id,\n asset_date=excluded.asset_date,\n source_file=excluded.source_file,\n line_number=excluded.line_number,\n event_time=excluded.event_time,\n source_type=excluded.source_type,\n threat_name=excluded.threat_name,\n is_duplicate=excluded.is_duplicate,\n record_json=excluded.record_json\n \"\"\", rows)\n conn.commit()\n return {'path': db_path, 'table': 'alert_records', 'rows': len(rows)}\n\n\n# ── LLM provider warm-up (avoid cold-start race in _parallel_4_branches) ──────\n#\n# Background: when this node starts, the LLM provider (e.g. threatbook-cn-llm)\n# is lazy-initialized on the first call. Inside `_parallel_4_branches` we\n# submit 4 LLM calls to a ThreadPoolExecutor simultaneously; whichever one\n# wins the race may race against provider registration and fail with\n# \"provider 'xxx' not exists\" while subsequent calls succeed. A single\n# synchronous warm-up call before any concurrent fan-out forces the provider\n# to finish registering on the main thread, eliminating the race entirely.\n#\n# Failure of the warm-up is non-fatal — we just log and continue. The first\n# real LLM call will still see the same error and surface it normally.\n\ndef _warmup_llm():\n \"\"\"Force LLM provider lazy-init on the main thread before any fan-out.\"\"\"\n t0 = time.time()\n try:\n llm.ask('ping')\n print(f'[triage] LLM provider warm-up OK in {round((time.time()-t0)*1000)}ms')\n return True\n except Exception as e:\n print(f'[triage] WARNING: LLM warm-up failed ({type(e).__name__}: '\n f'{str(e)[:200]}); proceeding anyway')\n return False\n\n\n# ── Inline triage helpers (mirroring tdp_alert_triage docs version) ───────────\n\ndef _strip_think(text):\n return re.sub(r'[\\s\\S]*?', '', str(text or ''), flags=re.IGNORECASE).strip()\n\n\ndef _is_public_ip(value):\n try:\n ip_obj = ipaddress.ip_address(value)\n except Exception:\n return False\n return not (ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved\n or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_unspecified)\n\n\ndef _pick(*values):\n for v in values:\n if v not in (None, '', [], {}):\n return v\n return ''\n\n\ndef _parse_alert(alert_input):\n \"\"\"Mirrors tdp_alert_triage.receive_alert. Supports three input shapes:\n nested TDP (net.http.url), flat TDP (net_http_url), normalized (req_http_url).\n \"\"\"\n if isinstance(alert_input, str):\n try:\n alert_input = json.loads(alert_input)\n except Exception:\n alert_input = {}\n if isinstance(alert_input, list):\n alert_data = alert_input[0] if alert_input else {}\n elif isinstance(alert_input, dict) and isinstance(alert_input.get('data'), list):\n alert_data = alert_input.get('data', [])[0] if alert_input.get('data') else {}\n else:\n alert_data = alert_input if isinstance(alert_input, dict) else {}\n\n net = alert_data.get('net', {}) or {}\n http = net.get('http', {}) or {}\n threat = alert_data.get('threat', {}) or {}\n assets = alert_data.get('assets', {}) or {}\n\n src_ip = _pick(\n alert_data.get('attacker'), alert_data.get('external_ip'),\n net.get('src_ip'), net.get('flow_src_ip'),\n alert_data.get('net_real_src_ip'),\n alert_data.get('sip'), alert_data.get('src_ip'), alert_data.get('src'),\n )\n dst_ip = _pick(\n alert_data.get('victim'), alert_data.get('machine'),\n alert_data.get('server_ip'), net.get('dest_ip'), net.get('flow_dest_ip'),\n alert_data.get('net_dest_ip'),\n alert_data.get('dip'), alert_data.get('dst_ip'), alert_data.get('dst'),\n )\n src_port = _pick(\n net.get('src_port'), net.get('flow_src_port'),\n alert_data.get('external_port'), alert_data.get('net_src_port'),\n alert_data.get('sport'), alert_data.get('src_port'), 0,\n )\n dst_port = _pick(\n net.get('dest_port'), net.get('flow_dest_port'),\n alert_data.get('server_port'), alert_data.get('machine_port'),\n alert_data.get('net_dest_port'),\n alert_data.get('dport'), alert_data.get('dst_port'), 0,\n )\n protocol = _pick(\n net.get('app_proto'), net.get('type'), net.get('proto'),\n alert_data.get('net_app_proto'), alert_data.get('protocol'),\n alert_data.get('event_type'), 'TCP',\n )\n alert_type = _pick(\n threat.get('name'), alert_data.get('threat_name'),\n alert_data.get('vuln_name'),\n alert_data.get('alert_type'), threat.get('topic'),\n alert_data.get('type'), 'unknown',\n )\n severity = _pick(\n threat.get('severity'), alert_data.get('threat_severity'),\n alert_data.get('severity'), threat.get('level'),\n alert_data.get('level'), 'medium',\n )\n\n req_line = _pick(http.get('reqs_line'), alert_data.get('req_line'), alert_data.get('net_http_reqs_line'))\n req_header = _pick(http.get('reqs_header'), alert_data.get('req_header'), alert_data.get('net_http_reqs_header'))\n req_body = _pick(http.get('req_body'), alert_data.get('req_body'), alert_data.get('net_http_reqs_body'))\n resp_line = _pick(http.get('resp_line'), alert_data.get('rsp_line'), alert_data.get('resp_line'),\n alert_data.get('net_http_resp_line'))\n resp_header = _pick(http.get('resp_header'), alert_data.get('rsp_header'), alert_data.get('resp_header'),\n alert_data.get('net_http_resp_header'))\n resp_body = _pick(http.get('resp_body'), alert_data.get('rsp_body'), alert_data.get('resp_body'),\n alert_data.get('net_http_resp_body'))\n status = _pick(http.get('status'), alert_data.get('http_status'),\n alert_data.get('net_http_status'), alert_data.get('rsp_status_code'), 0)\n\n host = _pick(http.get('reqs_host'), alert_data.get('url_host'), http.get('domain'),\n alert_data.get('req_host'), alert_data.get('net_http_reqs_host'), dst_ip)\n raw_url = _pick(http.get('raw_url'), http.get('url'),\n alert_data.get('url_path'),\n alert_data.get('net_http_url'), alert_data.get('req_http_url'),\n alert_data.get('uri'))\n url = ''\n if host and raw_url:\n scheme = 'https' if net.get('is_https') else 'http'\n url = raw_url if str(raw_url).startswith(('http://', 'https://')) else f'{scheme}://{host}{raw_url}'\n elif raw_url and str(raw_url).startswith(('http://', 'https://')):\n url = raw_url\n elif raw_url:\n url = raw_url\n\n payload = f'请求行: {req_line}\\n请求头: {req_header}\\n请求体: {req_body}'\n response = f'状态行: {resp_line}\\n响应头: {resp_header}\\n响应体: {resp_body}'\n threat_result = _pick(threat.get('result'), alert_data.get('threat_result'))\n threat_msg = _pick(threat.get('msg'), alert_data.get('threat_msg'))\n\n log_text = (\n f'[告警基本信息]\\n'\n f'告警类型: {alert_type}\\n严重级别: {severity}\\n'\n f'源地址: {src_ip}:{src_port}\\n目的地址: {dst_ip}:{dst_port}\\n'\n f'协议: {protocol}\\nURL: {url}\\nHTTP状态码: {status}\\n'\n f'TDP判定: {threat_result}\\nTDP消息: {threat_msg}\\n\\n'\n f'[HTTP请求内容]\\n{payload}\\n\\n'\n f'[HTTP响应内容]\\n{response}'\n )\n\n vuln_text = '\\n'.join(str(item) for item in [\n threat_msg, threat.get('topic', ''),\n alert_data.get('data', ''), url,\n json.dumps(threat.get('tag', []), ensure_ascii=False),\n ] if item)\n vuln_matches = sorted(set(re.findall(r'\\b(?:CVE|CNVD|CNNVD|XVE)-[A-Za-z0-9._-]+\\b', vuln_text, flags=re.I)))\n\n iocs = []\n for candidate in [src_ip, dst_ip]:\n if candidate:\n iocs.append({'type': 'ip', 'value': candidate})\n if url:\n iocs.append({'type': 'url', 'value': url})\n if host and not re.match(r'^\\d{1,3}(?:\\.\\d{1,3}){3}(?::\\d+)?$', str(host)):\n iocs.append({'type': 'domain', 'value': str(host).split(':')[0]})\n\n return {\n 'src_ip': src_ip, 'dst_ip': dst_ip, 'src_port': src_port, 'dst_port': dst_port,\n 'protocol': protocol, 'payload': payload, 'response': response,\n 'url': url, 'status': status,\n 'alert_type': alert_type, 'severity': severity,\n 'vuln_id': vuln_matches[0] if vuln_matches else '',\n 'vuln_candidates': vuln_matches,\n 'threat_result': threat_result, 'threat_msg': threat_msg,\n 'failed_by': threat.get('failed_by', []),\n 'asset_ip': assets.get('ip', ''), 'asset_name': assets.get('name', []),\n 'iocs': iocs, 'log_text': log_text,\n }\n\n\ndef _prepare_intel(parsed):\n \"\"\"Mirrors tdp_alert_triage.prepare_intel. Pre-fetches IP/domain/URL threat intel\n and CVE info so the parallel LLM tasks have concrete context to consume.\n \"\"\"\n iocs = parsed.get('iocs', [])\n intel_results = []\n seen = set()\n for ioc in iocs:\n ioc_type = ioc.get('type', '')\n ioc_value = str(ioc.get('value', '')).strip()\n key = (ioc_type, ioc_value)\n if not ioc_value or key in seen:\n continue\n seen.add(key)\n if ioc_type == 'ip':\n if not _is_public_ip(ioc_value):\n continue\n r = tool.run_safe('threatbook_ip_query', ip=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'ip',\n 'value': ioc_value, 'result': r['text']})\n elif ioc_type == 'domain':\n r = tool.run_safe('threatbook_domain_query', domain=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'domain',\n 'value': ioc_value, 'result': r['text']})\n elif ioc_type == 'url':\n r = tool.run_safe('threatbook_url_query', url=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'url',\n 'value': ioc_value, 'result': r['text']})\n\n vuln_info = {}\n vuln_id = parsed.get('vuln_id', '')\n if vuln_id:\n r = tool.run_safe('__mcp_vuln_query', vuln_id=vuln_id)\n if r['success']:\n try:\n obj = r.get('obj')\n if isinstance(obj, str):\n obj = json.loads(obj)\n vuln_info = obj if isinstance(obj, dict) else {'raw_result': r.get('text', '')}\n except Exception:\n vuln_info = {'raw_result': r.get('text', '')}\n\n intel_content = '\\n'.join(\n f\"[{i['source']}/{i['type']}] {i['value']}\\n{i['result']}\" for i in intel_results\n ) or '(无可用情报数据)'\n vuln_content = (\n json.dumps(vuln_info, ensure_ascii=False, indent=2)\n if vuln_info else '(无可用漏洞情报数据)'\n )\n return intel_results, intel_content, vuln_info, vuln_content\n\n\n# ── 4 LLM analyses (the parallel branches from tdp_alert_triage) ─────────────\n\ndef _ask_llm(prompt):\n \"\"\"Wrap ``llm.ask`` with the workflow-wide timeout + retry budget.\n\n Centralizing this avoids a hung provider request (no TCP timeout from\n upstream) from blocking a worker thread indefinitely. Any call that does\n not need bespoke parameters should go through here.\n \"\"\"\n return llm.ask(\n prompt,\n timeout_s=LLM_CALL_TIMEOUT_S,\n max_retries=LLM_CALL_MAX_RETRIES,\n )\n\n\ndef _llm_survey(log_text, intel_content):\n prompt = f'''你是一个专业的Web日志分析专家。请总结以下IP的情报数据中的空间测绘信息。\n1. 如果该IP没有测绘信息,则不列出。\n2. 如果IP有测绘信息,则以简短的语言对该IP的测绘信息进行总结,关键说明ip的标签和测绘信息显示有哪些服务或者应用资产。\n3. 多个IP的测绘信息以无序列表显示,每个ip数据描述占一行数据。\n4. 不需要生成其他额外的补充信息。\n\n## 情报参考信息\n{intel_content}\n\n## 用户的原始输入日志\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_cve_related(log_text):\n prompt = f'''请从以下的日志数据中提取漏洞编号。\n要求:\n1. 仅从日志文本中识别漏洞编号,不要做任何推测。\n2. 如果日志中存在漏洞编号,则用简短语言描述,如:\"日志中存在漏洞编号:CVE-****-****\"。\n3. 如果日志中不存在漏洞编号,则输出:\"日志中无关联漏洞情报\"。\n\n日志数据如下:\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_cve_info(log_text, vuln_content):\n prompt = f'''你是一个专业的Web日志分析专家。参考情报信息中的漏洞数据,简要说明关联的CVE漏洞信息。\n1. 不要输出任何解释说明,只输出漏洞基本信息。不需要生成漏洞的处置建议或修复措施等。\n\n## 情报参考信息\n{vuln_content}\n\n## 用户的原始输入日志\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_payload_analysis(log_text):\n prompt = f'''你是一个专业的Web日志分析专家。根据用户输入的日志进行攻击负载分析。\n1. 首先分析日志中是否包含攻击负载,并给出判定依据。\n2. 不要进行攻击意图分析、攻击影响分析。\n3. 用简短的语言在一段话中进行描述。\n\n## 用户的原始输入日志:\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _parallel_4_branches(parsed, intel_content, vuln_content):\n \"\"\"4 LLM analyses run in parallel — keeps tdp_alert_triage's fan-out structure.\"\"\"\n log_text = parsed.get('log_text', '')\n with ThreadPoolExecutor(max_workers=4, thread_name_prefix='triage_branch') as pool:\n futs = {\n 'survey_result': pool.submit(_llm_survey, log_text, intel_content),\n 'cve_related_result': pool.submit(_llm_cve_related, log_text),\n 'cve_info_result': pool.submit(_llm_cve_info, log_text, vuln_content),\n 'payload_analysis_result': pool.submit(_llm_payload_analysis, log_text),\n }\n out = {}\n for name, fut in futs.items():\n try:\n out[name] = fut.result()\n except Exception as e:\n print(f'[triage] WARNING: branch {name} failed: {e}')\n out[name] = ''\n return out\n\n\n# ── Join-point LLM analyses (attack_analysis_result -> verdict -> title) ──────\n\ndef _llm_attack_analysis(log_text):\n prompt = f'''你是一名专业且经验丰富的网络安全分析师和Web日志分析专家,你对HTTP协议以及Web攻击有着深入的理解,并且你能够快速识别和应对各种网络威胁。你的任务是对提供的HTTP请求与响应内容进行详细的专业分析,并判断日志请求的攻击状态。\n\n请严格遵循以下指令进行思考和分析:\n1. 攻击状态只能从以下情况中选择一种:[\"攻击成功\", \"攻击失败\", \"攻击\", \"未知\", \"安全\"]。\n2. 从日志中提取出\"HTTP请求内容\"和\"HTTP响应内容\"。请注意,HTTP请求内容和HTTP响应内容是分开的,请不要混淆,有些日志中没有包含HTTP响应内容,请不要将HTTP请求内容和HTTP响应内容混淆。分析后请你记住哪些是HTTP请求内容,哪些是HTTP响应内容。\n3. 请检查HTTP响应状态码,2xx或者3xx状态码都代表本次HTTP请求成功,4xx或者5xx状态码大多数情况下都代表请求失败,只有在请求成功的情况下才能对攻击是否成功进行后续判断。\n\n各攻击状态的定义以及判定标准:\n1. 攻击成功:\n(1) 首先分析日志中是否含有清晰的\"HTTP响应内容\",如果日志中没有\"HTTP响应内容\",则肯定不属于攻击成功。\n(2) 如果日志中未提供\"HTTP响应内容\",即使HTTP请求内容中包含攻击者预期的结果,也不能判定为攻击成功。\n(3) 从日志中提取出\"HTTP请求内容\"和\"HTTP响应内容\"。请深入分析\"HTTP响应内容\",并判定其是否为\"HTTP请求内容\"攻击成功时的预期结果,这是判定攻击成功的强依据。请注意,HTTP响应码200仅表示网络连接成功,不代表攻击攻击成功。\n(4) 分析HTTP请求内容和HTTP响应内容,只有当HTTP响应内容中明确包含攻击载荷在目标机器上成功执行的证据,并且HTTP请求内容中包含攻击载荷的特征,则判定为\"攻击成功\"。\n(5) 请注意:攻击成功的判定必须包含HTTP响应内容。如果不包含HTTP响应内容,则肯定不属于攻击成功。\n(6) 请注意:如果不包含HTTP响应内容,即使HTTP请求内容是攻击,这也不属于攻击成功。\n2. 攻击失败:\n(1) 分析HTTP请求内容和HTTP响应内容,如果HTTP响应内容中明确包含攻击载荷在目标机器上执行失败或者被阻止的证据,并且HTTP请求内容中包含攻击载荷的特征,则判定为\"攻击失败\"。\n(2) 攻击失败的判定必须包含HTTP响应内容。如果不包含HTTP响应内容,则肯定不属于攻击失败。\n3. 攻击:\n(1) 在\"HTTP请求内容\"或\"HTTP响应内容\"中发现任何证明存在攻击意图的证据,即可判定为存在攻击行为。但如果不符合上述的攻击成功或者攻击失败的标准,则\"攻击状态\"为\"攻击\"。\n(2) 请注意:如果日志中只提供了\"HTTP请求内容\",且没有提供\"HTTP响应内容\",且HTTP的请求内容分析中是包含攻击行为的,则\"攻击状态\"为\"攻击\"。\n4. 未知:\n(1) 如果不能100%确定HTTP通信的攻击结果,那么请在\"攻击状态\"处给出\"未知\"。\n(2) 请注意:如果在你给的判定原因中存在\"可能\"等不确定词汇,都代表你不能对你的结论100%确定,那么请在\"攻击状态\"处给出\"未知\"。\n5. 安全:\n(1) 如果\"HTTP请求内容\"和\"HTTP响应内容\"中都没有任何攻击意图的证据,那么请在\"攻击状态\"处给出\"安全\"。\n\n## 日志内容\n{log_text}\n\n## 输出要求\n请按下列结构输出(中文):\n1. 攻击状态: [攻击成功/攻击失败/攻击/未知/安全]\n2. 判定依据: 简要说明请求与响应的关键证据\n3. 详细分析: 不超过200字\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_attack_verdict(attack_analysis_result):\n prompt = f'''你是一个专业的Web日志分析专家。请据参考信息,直接输出攻击判定类别:\nattack_success:表示攻击成功。\nattack_failed:表示攻击失败。\nattack:表示是日志内容是攻击。\nunknown:表示未知。\nbenign:是安全。\n不额外输出任何其他信息,包括解释、判定依据等。\n\n## 日志分析结果:\n{attack_analysis_result}\n'''\n raw = _strip_think(_ask_llm(prompt)).strip().lower()\n return next((v for v in VERDICT_LABELS if v in raw), 'unknown')\n\n\ndef _llm_report_title(alert_type, attack_verdict, attack_analysis_result):\n prompt = f'''你是一个专业的Web日志分析专家。请基于以下分析结果,生成一份不超过 30 字的中文报告标题。\n要求:\n1. 标题必须能体现\"攻击类型\"或\"攻击结果分析的结论\"。\n2. 不要带书名号、引号或其他标点。\n3. 只输出标题本身,不要任何解释或说明。\n\n## 攻击类型\n{alert_type}\n\n## 攻击判定\n{attack_verdict}\n\n## 攻击分析结果\n{attack_analysis_result}\n'''\n raw = _strip_think(_ask_llm(prompt)).strip()\n return raw.splitlines()[0].strip(' \"\\'《》[]【】') if raw else f'{alert_type} - {attack_verdict}'\n\n\ndef _clip_text(value, limit=3000):\n text = str(value or '').strip()\n if len(text) > limit:\n return text[:limit] + '\\n...(已截断)'\n return text or '未提供'\n\n\ndef _fence_text(value):\n text = _clip_text(value, 6000)\n return text.replace('```', '``\\\\u200b`')\n\n\ndef _extract_tagged_triage_report(text):\n text = _strip_think(text)\n m = re.search(r']*>[\\s\\S]*?', text, flags=re.I)\n return m.group(0).strip() if m else text.strip()\n\n\ndef _is_valid_triage_report(markdown):\n text = str(markdown or '')\n if not re.search(r']*version=[\"\\']soc\\.triage\\.markdown\\.v1[\"\\'][^>]*>', text, flags=re.I):\n return False\n if not re.search(r'', text, flags=re.I):\n return False\n for tag in TRIAGE_REPORT_TAGS:\n if not re.search(rf'<{tag}\\b[^>]*>', text, flags=re.I):\n return False\n if not re.search(rf'', text, flags=re.I):\n return False\n return True\n\n\ndef _is_current_triage_fields(fields):\n if not isinstance(fields, dict):\n return False\n return _is_valid_triage_report(fields.get('triage_report'))\n\n\ndef _format_intel_brief(intel_results):\n if not intel_results:\n return '未查询到外部威胁情报。'\n lines = []\n for intel in intel_results[:6]:\n lines.append(f\"- {intel.get('source', 'intel')} / {intel.get('type', 'ioc')}: {intel.get('value', '')} => {_clip_text(intel.get('result'), 500)}\")\n return '\\n'.join(lines)\n\n\ndef _build_default_tagged_triage_report(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict,\n report_title, risk_level):\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n title = report_title or f'{parsed.get(\"alert_type\", \"Web日志告警\")} - {verdict_cn}'\n payload = _fence_text(parsed.get('payload', ''))\n response = _fence_text(parsed.get('response', ''))\n url = parsed.get('url') or '未提供'\n threat_msg = parsed.get('threat_msg') or '未提供'\n status = parsed.get('status') or '未提供'\n survey = _clip_text(branches.get('survey_result'), 1500)\n cve_related = _clip_text(branches.get('cve_related_result'), 1500)\n cve_info = _clip_text(branches.get('cve_info_result'), 1500)\n payload_analysis = _clip_text(branches.get('payload_analysis_result'), 1500)\n attack_analysis = _clip_text(attack_analysis_result, 1500)\n intel_brief = _format_intel_brief(intel_results)\n vuln_brief = _clip_text(json.dumps(vuln_info, ensure_ascii=False, indent=2), 1800) if vuln_info else '未查询到漏洞详情。'\n\n if attack_verdict == 'attack_success':\n recommendation = '立即核查目标资产是否产生异常文件、进程、账号或敏感数据访问记录,并按成功入侵事件升级处置。'\n elif attack_verdict == 'attack_failed':\n recommendation = '保留拦截与响应证据,复核同源后续请求,并确认防护策略是否持续生效。'\n elif attack_verdict == 'benign':\n recommendation = '作为低风险事件留痕,结合资产白名单或业务访问记录确认是否可降噪。'\n else:\n recommendation = '补齐目标 Web 日志、响应体、主机侧进程和文件证据后再确认攻击成功性。'\n\n return f'''\n\n\n# {title}\n\n\n\n- 研判结论:{verdict_cn}\n- 风险等级:{risk_level}\n- 告警类型:{parsed.get('alert_type', 'unknown')}\n- 源 IP:{parsed.get('src_ip', 'N/A')}:{parsed.get('src_port', 'N/A')}\n- 目标资产:{parsed.get('dst_ip', 'N/A')}:{parsed.get('dst_port', 'N/A')}\n- URL:{url}\n- 响应码:{status}\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该告警按 Web 日志处理,已提取 HTTP 请求、响应、源地址、目标资产、URL、响应码和 TDP 判定字段。\n\n### 2. 情报信息\n{intel_brief}\n\n### 3. 测绘信息\n{survey}\n\n### 4. 告警关联漏洞情报\n{cve_related}\n\n### 5. 攻击负载分析\n{payload_analysis}\n\n### 6. 攻击分析结果\n{attack_analysis}\n\n\n\n## 研判结论\n当前研判结论为 **{verdict_cn}**,风险等级为 **{risk_level}**。TDP 消息为:{threat_msg}\n\n\n\n## 攻击payload\n\n```http\n{payload}\n```\n\n\n\n## 具体含义解释\n\n1. 请求命中的告警类型为 {parsed.get('alert_type', 'unknown')}。\n2. 请求 URL 为 {url},需要结合参数、请求体和目标业务判断攻击意图。\n3. Payload 分析结果:{payload_analysis}\n\n\n\n## 响应证据\n\n```http\n{response}\n```\n\n响应码为 {status}。如果响应体未提供或没有执行成功证据,则不能仅凭请求侧 payload 判定攻击成功。\n\n\n\n## 重要证据\n\n1. 源地址:{parsed.get('src_ip', 'N/A')}:{parsed.get('src_port', 'N/A')}。\n2. 目标资产:{parsed.get('dst_ip', 'N/A')}:{parsed.get('dst_port', 'N/A')}。\n3. TDP 判定:{parsed.get('threat_result', '未提供')};TDP 消息:{threat_msg}。\n4. 漏洞详情:{vuln_brief}\n\n\n\n## 处置建议\n\n1. {recommendation}\n2. 检索同源 IP、同一 dedup_key、同一 URL 或同一漏洞特征的横向告警。\n3. 结合目标资产 Web 访问日志、主机审计、EDR 与 WAF 日志补齐证据链。\n\n\n'''\n\n\ndef _llm_triage_report_markdown(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict,\n report_title, risk_level):\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n context = json.dumps({\n 'report_title': report_title,\n 'attack_verdict': attack_verdict,\n 'verdict_cn': verdict_cn,\n 'risk_level': risk_level,\n 'alert': {\n 'alert_type': parsed.get('alert_type'),\n 'severity': parsed.get('severity'),\n 'src_ip': parsed.get('src_ip'),\n 'src_port': parsed.get('src_port'),\n 'dst_ip': parsed.get('dst_ip'),\n 'dst_port': parsed.get('dst_port'),\n 'url': parsed.get('url'),\n 'status': parsed.get('status'),\n 'threat_result': parsed.get('threat_result'),\n 'threat_msg': parsed.get('threat_msg'),\n 'payload': parsed.get('payload'),\n 'response': parsed.get('response'),\n },\n 'survey_result': branches.get('survey_result'),\n 'cve_related_result': branches.get('cve_related_result'),\n 'cve_info_result': branches.get('cve_info_result'),\n 'payload_analysis_result': branches.get('payload_analysis_result'),\n 'attack_analysis_result': attack_analysis_result,\n 'intel_results': intel_results,\n 'vuln_info': vuln_info,\n }, ensure_ascii=False, indent=2)\n\n prompt = f'''你是一名资深 SOC 告警研判分析师。请根据输入上下文,生成一份供前端直接渲染的 SOC 告警研判报告 markdown。\n\n硬性要求:\n1. 只输出带语义标签的 markdown,不要输出 JSON,不要解释规则。\n2. 根标签必须是 。\n3. 必须按顺序输出并完整闭合这些标签:\n 。\n4. 标签外不得输出正文内容。标签内可以使用 markdown 标题、列表、引用、代码块。\n5. 段落标题必须贴近前端展示模板:分析步骤、研判结论、攻击payload、具体含义解释、响应证据、重要证据、处置建议。\n6. 如果没有 HTTP 响应体或没有明确响应证据,不得判定为攻击成功;需要写明“当前日志未提供有效响应证据”。\n7. 不要编造输入中不存在的 IP、域名、URL、CVE、账号、文件路径或响应内容。\n8. 攻击 payload 和响应证据必须分别放在对应标签中,不要混淆请求与响应。\n\nFew-shot 示例 1:攻击成功\n\n\n\n# 敏感文件泄露攻击成功分析报告\n\n\n\n- 研判结论:攻击成功\n- 风险等级:High\n- 告警类型:敏感文件访问\n- 源 IP:203.0.113.10:42131\n- 目标资产:198.51.100.20:80\n- URL:http://example.com/api/.env\n- 响应码:200\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该日志包含 HTTP 请求路径、响应码和响应体,可用于判断敏感文件是否被返回。\n\n### 2. 情报信息\n源 IP 命中扫描源标签,风险高。\n\n### 3. 测绘信息\n目标为公网 Web 服务,存在敏感路径暴露风险。\n\n### 4. 告警关联漏洞情报\n该行为与环境变量文件泄露场景一致。\n\n### 5. 攻击负载分析\n攻击者直接请求 /api/.env,目标是读取环境变量配置。\n\n### 6. 攻击分析结果\n响应码为 200,响应体中出现 DB_PASSWORD,支持攻击成功。\n\n\n\n## 研判结论\n攻击者成功读取敏感配置文件,响应体中包含数据库密码字段,结论为攻击成功。\n\n\n\n## 攻击payload\n\n```http\nGET /api/.env HTTP/1.1\nHost: example.com\n```\n\n\n\n## 具体含义解释\n\n1. /api/.env 是常见环境变量文件路径。\n2. 攻击者通过 GET 请求尝试直接读取配置文件。\n3. 该路径若返回真实内容,通常意味着敏感文件暴露。\n\n\n\n## 响应证据\n\n```http\nHTTP/1.1 200 OK\n\nDB_PASSWORD=example-secret\n```\n\n响应体出现 DB_PASSWORD,证明敏感配置内容已被返回。\n\n\n\n## 重要证据\n\n1. 请求路径为 /api/.env。\n2. 响应码为 200。\n3. 响应体包含 DB_PASSWORD。\n\n\n\n## 处置建议\n\n1. 立即下线或限制敏感文件访问。\n2. 轮换可能泄露的密钥和数据库密码。\n3. 检索同源 IP 和同路径访问记录。\n\n\n\n\nFew-shot 示例 2:攻击失败\n\n\n\n# SQL注入攻击失败分析报告\n\n\n\n- 研判结论:攻击失败\n- 风险等级:Medium\n- 告警类型:SQL注入\n- 源 IP:203.0.113.44:51002\n- 目标资产:198.51.100.30:443\n- URL:https://shop.example.com/item?id=1\n- 响应码:403\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该日志包含请求参数和响应码,能够确认请求侧存在 SQL 注入尝试。\n\n### 2. 情报信息\n源 IP 暂无高置信恶意标签。\n\n### 3. 测绘信息\n目标为公网电商 Web 服务。\n\n### 4. 告警关联漏洞情报\n当前日志未提供可确认具体 CVE 的证据。\n\n### 5. 攻击负载分析\n请求参数中包含 union select,存在明显 SQL 注入意图。\n\n### 6. 攻击分析结果\n响应码为 403,响应体显示请求被阻断,不支持攻击成功。\n\n\n\n## 研判结论\n该请求存在 SQL 注入攻击意图,但响应显示被拒绝,当前判断为攻击失败。\n\n\n\n## 攻击payload\n\n```http\nGET /item?id=1 union select user HTTP/1.1\nHost: shop.example.com\n```\n\n\n\n## 具体含义解释\n\n1. union select 是典型 SQL 注入关键字组合。\n2. 攻击者尝试拼接查询以读取数据库用户信息。\n3. 该 payload 证明攻击意图,但不等同于成功执行。\n\n\n\n## 响应证据\n\n```http\nHTTP/1.1 403 Forbidden\n\nblocked by waf\n```\n\n响应状态和内容说明请求被拦截,未见数据泄露或执行成功证据。\n\n\n\n## 重要证据\n\n1. 请求参数包含 union select。\n2. 响应码为 403。\n3. 响应体显示 blocked by waf。\n\n\n\n## 处置建议\n\n1. 保留 WAF 拦截证据。\n2. 检查同源 IP 是否持续尝试其他注入 payload。\n3. 确认目标接口参数化查询和安全策略仍然有效。\n\n\n\n\n## 待研判上下文\n```json\n{context}\n```\n\n请输出最终报告:\n'''\n return _extract_tagged_triage_report(_ask_llm(prompt))\n\n\ndef _generate_triage_report(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title):\n # Aggregate everything into tagged markdown for frontend rendering.\n # The markdown is returned through `triage_report` and is not written as a\n # per-alert file; leader/follower/cache-hit paths reuse the same field.\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n risk_level = VERDICT_RISK.get(attack_verdict, 'Medium')\n\n if not report_title:\n report_title = f'{parsed.get(\"alert_type\", \"Web日志告警\")} - {verdict_cn}'\n\n try:\n triage_report = _llm_triage_report_markdown(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title, risk_level,\n )\n except Exception as e:\n print(f'[triage] WARNING: triage_report LLM generation failed: {e}')\n triage_report = ''\n\n if not _is_valid_triage_report(triage_report):\n print('[triage] WARNING: triage_report missing required semantic tags; using deterministic fallback')\n triage_report = _build_default_tagged_triage_report(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title, risk_level,\n )\n\n return triage_report, report_title, risk_level\n\n\ndef _triage_single_alert(alert):\n \"\"\"End-to-end inline triage for a single alert. Returns triage_fields dict.\n\n No file I/O — the full markdown report lives in the returned `triage_report` field\n and is broadcast to followers / persisted via `triage_cache.pkl`.\n \"\"\"\n parsed = _parse_alert(alert)\n intel_results, intel_content, vuln_info, vuln_content = _prepare_intel(parsed)\n\n branches = _parallel_4_branches(parsed, intel_content, vuln_content)\n\n attack_analysis_result = _llm_attack_analysis(parsed['log_text'])\n attack_verdict = _llm_attack_verdict(attack_analysis_result)\n report_title = _llm_report_title(parsed.get('alert_type', 'unknown'),\n attack_verdict, attack_analysis_result)\n\n triage_report, report_title, risk_level = _generate_triage_report(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title,\n )\n\n return {\n 'attack_verdict': attack_verdict,\n 'risk_level': risk_level,\n 'report_title': report_title,\n 'triage_report': triage_report,\n 'attack_success': attack_verdict == 'attack_success',\n }\n\n\n# ── Main: leader/follower batch deduplication ────────────────────────────────\n# When the input batch contains multiple alerts sharing the same dedup_key\n# (e.g. upstream emits is_duplicate=True alerts in the same batch, or LSH\n# clustering produces several alerts per cluster), we only triage the LEADER\n# (first occurrence of each dedup_key). All FOLLOWERS in the same group reuse\n# the leader's triage result without invoking the LLM again.\n#\n# Work unit types:\n# ('dk', dedup_key, leader_idx) — group of 1+ alerts sharing dedup_key\n# ('nokey', None, alert_idx) — single alert with no dedup_key\n# (cannot be deduplicated, always triaged)\n\nenriched_alerts = list(inputs.get('enriched_alerts', []) or [])\nconcurrency = min(5, max(1, int(inputs.get('concurrency', 1))))\nmax_triage_cache_size = int(inputs.get('max_triage_cache_size', 100000))\nif max_triage_cache_size < 1:\n max_triage_cache_size = 100000\n\n# Group by dedup_key\ngroups = {} # dedup_key -> [alert_index, ...]\nno_key_indices = [] # alerts with no dedup_key\nfor i, a in enumerate(enriched_alerts):\n dk = a.get('dedup_key', '') if isinstance(a, dict) else ''\n if dk:\n groups.setdefault(dk, []).append(i)\n else:\n no_key_indices.append(i)\n\nwork_units = (\n [('dk', dk, group_indices[0]) for dk, group_indices in groups.items()]\n + [('nokey', None, idx) for idx in no_key_indices]\n)\nfollower_count = sum(len(v) - 1 for v in groups.values())\n\nprint(f'[triage] alerts={len(enriched_alerts)} '\n f'→ {len(groups)} unique dedup_keys ({follower_count} followers) + '\n f'{len(no_key_indices)} no-key alerts '\n f'= {len(work_units)} work units; outer_concurrency={concurrency} '\n f'(per-alert 4 LLM branches run in parallel)')\n\ncache_path, lock_path = _cache_paths()\nlock_fh = _acquire_lock(lock_path)\ntry:\n triage_cache_snapshot = _load_cache(cache_path)\nfinally:\n _release_lock(lock_fh)\n\n# Only warm up the LLM when at least one work unit may actually need it.\n# A unit \"may need\" the LLM if it's a no-key alert OR a dedup_key unit whose\n# entry is not in the cache snapshot. Pure cache-hit batches skip warm-up.\n_needs_llm = any(\n unit_type == 'nokey' or not _is_current_triage_fields(triage_cache_snapshot.get(dk))\n for unit_type, dk, _ in work_units\n)\nif _needs_llm:\n _warmup_llm()\n\nresults_lock = threading.Lock()\nnew_results = {} # dedup_key -> triage_fields (only for freshly computed leaders)\ngroup_outcomes = {} # dedup_key -> (triage_fields, source) for broadcasting to followers\nnokey_outcomes = {} # alert_idx -> (triage_fields, source)\nstats = {\n 'total': len(enriched_alerts),\n 'unique_dedup_keys': len(groups),\n 'followers_reused': follower_count,\n 'no_dedup_key_alerts': len(no_key_indices),\n 'work_units': len(work_units),\n 'cache_hit': 0,\n 'triaged': 0,\n 'triage_failed': 0,\n 'verdict_counts': {},\n 'cache_size_before': len(triage_cache_snapshot),\n 'cache_size_after': 0,\n 'evicted': 0,\n}\n\n\ndef _bump_verdict(verdict):\n with results_lock:\n stats['verdict_counts'][verdict] = stats['verdict_counts'].get(verdict, 0) + 1\n\n\n_UNKNOWN_TRIAGE = {\n 'attack_verdict': 'unknown',\n 'risk_level': 'Medium',\n 'report_title': '',\n 'triage_report': '',\n 'attack_success': False,\n}\n\n\ndef _process_unit(unit_type, dedup_key, leader_idx):\n \"\"\"Triage one unique work unit. Returns (key, triage_fields, source, ms, error).\"\"\"\n leader_alert = enriched_alerts[leader_idx]\n t0 = time.time()\n\n # 1) cache lookup for dedup_key units\n if unit_type == 'dk':\n cached = triage_cache_snapshot.get(dedup_key)\n if cached:\n if _is_current_triage_fields(cached):\n with results_lock:\n stats['cache_hit'] += 1\n _bump_verdict(cached.get('attack_verdict', 'unknown'))\n return dedup_key, cached, 'cache', 0, None\n print(f'[triage_cache] stale entry for dedup_key={dedup_key}: '\n 'missing current triage_report markdown; treating as cache miss')\n\n # 2) cache miss or no-key → run full inline triage on the leader\n try:\n triage_fields = _triage_single_alert(leader_alert)\n ms = round((time.time() - t0) * 1000)\n with results_lock:\n stats['triaged'] += 1\n if dedup_key:\n new_results[dedup_key] = triage_fields\n _bump_verdict(triage_fields.get('attack_verdict', 'unknown'))\n source = 'triaged' if unit_type == 'dk' else 'no_dedup_key_triaged'\n return (dedup_key if unit_type == 'dk' else leader_idx), triage_fields, source, ms, None\n except Exception as e:\n import traceback\n ms = round((time.time() - t0) * 1000)\n with results_lock:\n stats['triage_failed'] += 1\n _bump_verdict('unknown')\n err = str(e)[:500]\n print(f'[triage] leader_idx={leader_idx} FAILED: {e}\\n{traceback.format_exc()}')\n source = 'failed' if unit_type == 'dk' else 'no_dedup_key_failed'\n return (dedup_key if unit_type == 'dk' else leader_idx), dict(_UNKNOWN_TRIAGE), source, ms, err\n\n\nt_start = time.time()\nunit_completions = {} # key -> (triage_fields, source, ms, error)\nif work_units:\n with ThreadPoolExecutor(max_workers=concurrency, thread_name_prefix='stream_triage') as pool:\n futures = [pool.submit(_process_unit, *u) for u in work_units]\n for done_count, fut in enumerate(as_completed(futures), 1):\n try:\n key, triage_fields, source, ms, err = fut.result()\n unit_completions[key] = (triage_fields, source, ms, err)\n if source == 'cache':\n group_outcomes[key] = (triage_fields, source)\n elif source.startswith('no_dedup_key'):\n nokey_outcomes[key] = (triage_fields, source)\n else:\n group_outcomes[key] = (triage_fields, source)\n except Exception as e:\n print(f'[triage] WARNING: unexpected worker exception: {e}')\n if done_count % 5 == 0 or done_count == len(futures):\n print(f'[triage] progress {done_count}/{len(futures)} '\n f'(cache_hit={stats[\"cache_hit\"]} triaged={stats[\"triaged\"]} '\n f'failed={stats[\"triage_failed\"]})')\n\n# ── Apply outcomes back to every alert (broadcast leader → followers) ─────────\nenriched_with_triage = [None] * len(enriched_alerts)\nfor i, alert in enumerate(enriched_alerts):\n out = dict(alert) if isinstance(alert, dict) else {'_raw': alert}\n dk = alert.get('dedup_key', '') if isinstance(alert, dict) else ''\n out['has_dedup_key'] = bool(dk)\n\n if dk:\n triage_fields, source = group_outcomes.get(dk, (dict(_UNKNOWN_TRIAGE), 'failed'))\n is_leader = (groups.get(dk, [i])[0] == i)\n # All triage fields are pure data (no file-path references); broadcast as-is.\n for k, v in triage_fields.items():\n out[k] = v\n if not is_leader and source != 'cache':\n out['triage_source'] = 'follower_reused'\n out['triage_status'] = 'reused_from_leader'\n else:\n out['triage_source'] = source\n out['triage_status'] = 'cached' if source == 'cache' else (\n 'ok' if source == 'triaged' else 'failed'\n )\n completion = unit_completions.get(dk)\n if completion and is_leader:\n _, _, ms, err = completion\n if ms:\n out['triage_ms'] = ms\n if err:\n out['triage_error'] = err\n else:\n # no-key alerts: each is its own unit, keyed by alert idx\n triage_fields, source = nokey_outcomes.get(i, (dict(_UNKNOWN_TRIAGE), 'no_dedup_key_failed'))\n for k, v in triage_fields.items():\n out[k] = v\n out['triage_source'] = source\n out['triage_status'] = 'ok' if source == 'no_dedup_key_triaged' else 'failed'\n completion = unit_completions.get(i)\n if completion:\n _, _, ms, err = completion\n if ms:\n out['triage_ms'] = ms\n if err:\n out['triage_error'] = err\n\n enriched_with_triage[i] = out\n\nelapsed_ms = round((time.time() - t_start) * 1000)\nprint(f'[triage] all done in {elapsed_ms}ms: cache_hit={stats[\"cache_hit\"]} '\n f'triaged={stats[\"triaged\"]} failed={stats[\"triage_failed\"]} '\n f'followers_reused={stats[\"followers_reused\"]} '\n f'no_dedup_key={stats[\"no_dedup_key_alerts\"]}')\n\n# Persist new triage results back to cache (merge with concurrent writers).\nif new_results:\n lock_fh = _acquire_lock(lock_path)\n try:\n cache = _load_cache(cache_path)\n for k, v in new_results.items():\n if k in cache:\n del cache[k] # LRU touch (move to end on rewrite)\n cache[k] = v\n evicted = _evict_lru(cache, max_triage_cache_size)\n if evicted:\n print(f'[triage_cache] LRU eviction: dropped {evicted} entries (max={max_triage_cache_size})')\n _save_cache_atomic(cache_path, cache)\n stats['cache_size_after'] = len(cache)\n stats['evicted'] = evicted\n finally:\n _release_lock(lock_fh)\nelse:\n stats['cache_size_after'] = stats['cache_size_before']\n\ntriage_results = []\nfor a in enriched_with_triage:\n triage_results.append({\n 'dedup_key': a.get('dedup_key', ''),\n 'has_dedup_key': a.get('has_dedup_key', False),\n 'threat_name': a.get('threat_name', ''),\n 'sip': a.get('sip', ''),\n 'dip': a.get('dip', ''),\n 'is_duplicate': a.get('is_duplicate'),\n 'triage_source': a.get('triage_source', ''),\n 'triage_status': a.get('triage_status', ''),\n 'attack_verdict': a.get('attack_verdict', ''),\n 'risk_level': a.get('risk_level', ''),\n 'report_title': a.get('report_title', ''),\n 'triage_ms': a.get('triage_ms'),\n 'triage_error': a.get('triage_error'),\n })\n\nstats['elapsed_ms'] = elapsed_ms\nstats['concurrency'] = concurrency\nstats['max_triage_cache_size'] = max_triage_cache_size\n\n# Persist enriched_with_triage according to workflow config. Default is SOC DB;\n# JSONL is still available by config/input for downstream pipelines or archival.\noutput_cfg = _resolve_output_config()\nrun_id = (inputs.get('_run_id')\n or os.environ.get('FLOCKS_RUN_ID')\n or str(int(time.time() * 1000)))\noutput_paths = []\noutput_dir = ''\nsoc_db_result = {\n 'path': output_cfg.get('soc_db_path', ''),\n 'table': 'alert_records',\n 'rows': 0,\n}\n\nif output_cfg['write_soc_db'] and enriched_with_triage:\n try:\n soc_db_result = _triage_write_soc_db(\n output_cfg['soc_db_path'], enriched_with_triage, run_id,\n )\n print(f'[triage] wrote {soc_db_result.get(\"rows\", 0)} enriched alerts to '\n f'SOC DB {soc_db_result.get(\"path\")}')\n except Exception as e:\n import traceback\n print(f'[triage] WARNING: failed to persist triage results to SOC DB: {e}\\n{traceback.format_exc()}')\nelif not output_cfg['write_soc_db']:\n print(f'[triage] SOC DB output disabled by triage_output_mode={output_cfg[\"requested_mode\"]!r}')\n\nif output_cfg['write_jsonl'] and enriched_with_triage:\n try:\n output_dir = _triage_output_dir(output_cfg.get('jsonl_output_dir', ''))\n output_paths = _triage_write_jsonl(\n output_dir, enriched_with_triage, run_id, stats,\n )\n print(f'[triage] wrote {len(enriched_with_triage)} enriched alerts to '\n f'{len(output_paths)} JSONL file(s) under {output_dir}')\n for p in output_paths:\n print(f' → {p}')\n except Exception as e:\n import traceback\n print(f'[triage] WARNING: failed to persist triage_result JSONL: {e}\\n{traceback.format_exc()}')\nelif not output_cfg['write_jsonl']:\n print(f'[triage] JSONL output disabled by triage_output_mode={output_cfg[\"requested_mode\"]!r}')\n\nstats['output_mode'] = output_cfg['mode']\nstats['requested_output_mode'] = output_cfg['requested_mode']\nstats['output_config_path'] = output_cfg['config_path']\nstats['soc_db_path'] = soc_db_result.get('path', '')\nstats['soc_db_rows'] = soc_db_result.get('rows', 0)\nstats['output_paths'] = output_paths\nstats['output_dir'] = output_dir\n\nprint(f'[triage] stats={json.dumps(stats, ensure_ascii=False)}')\n\noutputs['enriched_alerts_with_triage'] = enriched_with_triage\noutputs['triage_results'] = triage_results\noutputs['triage_stats'] = stats\noutputs['load_stats'] = inputs.get('load_stats', {})\noutputs['loaded_files'] = inputs.get('loaded_files', [])\noutputs['input_date'] = inputs.get('input_date', '')\noutputs['triage_output_mode'] = output_cfg['mode']\noutputs['soc_db_result'] = soc_db_result\noutputs['soc_db_path'] = soc_db_result.get('path', '')\noutputs['output_config_path'] = output_cfg['config_path']\noutputs['output_paths'] = output_paths\noutputs['output_dir'] = output_dir\n" + "description": "Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。SOC DB 只接受明确 `is_duplicate=false`、包含 `dedup_key` 且批内首次出现的告警,并通过数据库唯一索引保证跨执行全局唯一;重复 key 只更新研判字段并保留首次事件元数据,持久化失败会使工作流失败。可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。", + "code": "\"\"\"\nconcurrent_triage: leader/follower 分组并发研判 + dedup_key 缓存复用(自包含)。\n\n去重模式:\n 1. 输入 alerts 先按 dedup_key 分组 → unique dedup_keys 列表\n 2. 每个 group 只对 leader(首条)做研判;followers 复用 leader 结果,不重复调 LLM\n 3. 无 dedup_key 的 alert 各自独立成 work unit(防御性研判,无法复用)\n\n并发结构:\n 外层 ThreadPoolExecutor(max_workers=concurrency):处理 unique work unit\n (concurrency 取值 1–5,默认 1,由 inputs.concurrency 控制)\n 内层 ThreadPoolExecutor(max_workers=4):单条 alert 内 4 个 LLM 并行\n (survey / cve_related / cve_info / payload_analysis — 保留 tdp_alert_triage\n 的 4 并行分支结构)\n 稳态 LLM 并发峰值 ≈ concurrency × 4(仅 4 并行分支阶段,且分支全部启用时)\n\n研判产物只以字段形式附加到每条 alert(attack_verdict / risk_level /\nreport_title / triage_report ...),**不生成任何独立的 per-alert markdown 报告\n文件**,避免冗余落盘与跨日期路径失效。\n\ndedup_key 缓存(与 stream_alert_denoise 的 LSH 状态文件同根目录,逻辑独立):\n ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl\n - cache 命中:直接复用历史 verdict/title/triage_report,**不调用 LLM**\n - cache 未命中:leader 执行完整内联研判(情报 + 4 并行 LLM + verdict + title + report);\n follower 直接广播 leader 结果\n - 新结果合并写回 cache,FIFO LRU 淘汰,文件锁 + 原子落盘\n\"\"\"\n\nimport ipaddress\nimport json\nimport os\nimport pickle\nimport re\nimport sys\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\n\nIS_WINDOWS = sys.platform == 'win32'\nif IS_WINDOWS:\n import msvcrt # noqa: F401\nelse:\n import fcntl # noqa: F401\n\nWORKFLOW_NAME = 'stream_alert_triage'\n\n# Per-call LLM timeout and retry budget for every analysis branch in this\n# node. Workflow LLM calls share the dedicated ``flocks-workflow-llm-loop``;\n# a single hung call (e.g. provider 504, slow TLS handshake) without a\n# timeout would otherwise pin one of the (already concurrency-limited)\n# worker threads for up to httpx's DEFAULT read timeout (10 min), serially\n# blocking the rest of the alert pipeline. 120s + 1 retry covers normal\n# slow-but-alive responses while still recovering from transient hangs.\nLLM_CALL_TIMEOUT_S = 120.0\nLLM_CALL_MAX_RETRIES = 1\n\nTRIAGE_FIELDS = (\n 'attack_verdict',\n 'risk_level',\n 'report_title',\n 'triage_report',\n 'attack_success',\n)\nVERDICT_LABELS = ('attack_success', 'attack_failed', 'attack', 'unknown', 'benign')\nVERDICT_RISK = {\n 'attack_success': 'High',\n 'attack_failed': 'Medium',\n 'attack': 'Medium',\n 'unknown': 'Medium',\n 'benign': 'Low',\n}\nVERDICT_CN = {\n 'attack_success': '攻击成功',\n 'attack_failed': '攻击失败',\n 'attack': '攻击',\n 'unknown': '未知',\n 'benign': '安全',\n}\nTRIAGE_REPORT_VERSION = 'soc.triage.markdown.v1'\nTRIAGE_REPORT_TAGS = (\n 'report_title',\n 'report_meta',\n 'analysis_steps',\n 'triage_conclusion',\n 'attack_payload',\n 'payload_explanation',\n 'response_evidence',\n 'key_evidence',\n 'disposal_recommendation',\n)\n\n\n# ── Cache persistence ─────────────────────────────────────────────────────────\n\ndef _cache_paths():\n from flocks.config import Config\n flocks_root = Config().get_global().data_dir.parent\n state_dir = flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME\n state_dir.mkdir(parents=True, exist_ok=True)\n return str(state_dir / 'triage_cache.pkl'), str(state_dir / 'triage_cache.lock')\n\n\ndef _acquire_lock(lock_path):\n fh = open(lock_path, 'w+')\n try:\n if IS_WINDOWS:\n fh.write('L'); fh.flush(); fh.seek(0)\n while True:\n try:\n msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1); break\n except OSError:\n continue\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_EX)\n except BaseException:\n try:\n fh.close()\n except Exception:\n pass\n raise\n return fh\n\n\ndef _release_lock(fh):\n try:\n if IS_WINDOWS:\n try:\n fh.seek(0); msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)\n except OSError:\n pass\n else:\n fcntl.flock(fh.fileno(), fcntl.LOCK_UN)\n finally:\n fh.close()\n\n\ndef _load_cache(cache_path):\n if not os.path.exists(cache_path) or os.path.getsize(cache_path) == 0:\n return {}\n try:\n with open(cache_path, 'rb') as f:\n c = pickle.load(f)\n if not isinstance(c, dict):\n return {}\n print(f'[triage_cache] loaded {len(c)} entries from {cache_path}')\n return c\n except Exception as e:\n print(f'[triage_cache] WARNING: failed to load ({e}), starting fresh')\n return {}\n\n\ndef _save_cache_atomic(cache_path, cache):\n tmp = cache_path + '.tmp'\n try:\n with open(tmp, 'wb') as f:\n pickle.dump(cache, f); f.flush(); os.fsync(f.fileno())\n os.replace(tmp, cache_path)\n print(f'[triage_cache] saved {len(cache)} entries -> {cache_path}')\n except Exception as e:\n print(f'[triage_cache] WARNING: failed to save: {e}')\n if os.path.exists(tmp):\n try: os.remove(tmp)\n except Exception: pass\n\n\ndef _evict_lru(cache, max_keys):\n excess = len(cache) - max_keys\n if excess > 0:\n for k in list(cache.keys())[:excess]:\n del cache[k]\n return excess\n return 0\n\n\n# ── Runtime output config and persistence targets ──────────────────────────────\n#\n# Defaults are read from ~/.flocks/plugins/workflows/stream_alert_triage/config.json.\n# Runtime inputs override config values. The default mode is soc_db so SOC pages\n# read the same DB-backed dataset. JSONL remains available via config/input:\n# triage_output_mode = soc_db | jsonl | both | none\n# persist_triage_output = true (legacy alias that adds JSONL to soc_db)\n\nimport datetime as _datetime\n\nMAX_RECORDS_PER_FILE = 10000\n_TRIAGE_JSONL_PREFIX = 'triage_result'\n_TRIAGE_COUNTER_FILE = '.triage_counter.json'\n_WORKFLOW_CONFIG_PATH = os.path.expanduser('~/.flocks/plugins/workflows/stream_alert_triage/config.json')\n_DEFAULT_SOC_DB_PATH = os.path.expanduser('~/.flocks/data/soc.db')\n\n\ndef _load_workflow_config():\n try:\n with open(_WORKFLOW_CONFIG_PATH, 'r', encoding='utf-8') as f:\n cfg = json.load(f)\n if isinstance(cfg, dict):\n return cfg\n except FileNotFoundError:\n pass\n except Exception as e:\n print(f'[triage_config] WARNING: failed to read {_WORKFLOW_CONFIG_PATH}: {e}')\n return {}\n\n\ndef _configured_value(config, key, default=None):\n if key in inputs:\n value = inputs.get(key)\n if value is not None and not (isinstance(value, str) and not value.strip()):\n return value\n return config.get(key, default)\n\n\ndef _input_bool(value, default=False):\n if value is None:\n return default\n if isinstance(value, bool):\n return value\n if isinstance(value, (int, float)):\n return bool(value)\n text = str(value).strip().lower()\n if text in {'1', 'true', 'yes', 'y', 'on'}:\n return True\n if text in {'0', 'false', 'no', 'n', 'off'}:\n return False\n return default\n\n\ndef _select_first_seen_soc_alerts(alerts):\n selected = []\n seen_dedup_keys = set()\n stats = {\n 'input_rows': len(alerts),\n 'first_seen_rows': 0,\n 'skipped_not_first_seen_rows': 0,\n 'skipped_missing_dedup_key_rows': 0,\n 'skipped_repeated_dedup_key_rows': 0,\n }\n for alert in alerts:\n if not isinstance(alert, dict) or _input_bool(alert.get('is_duplicate'), True):\n stats['skipped_not_first_seen_rows'] += 1\n continue\n dedup_key = str(alert.get('dedup_key') or '').strip()\n if not dedup_key:\n stats['skipped_missing_dedup_key_rows'] += 1\n continue\n if dedup_key in seen_dedup_keys:\n stats['skipped_repeated_dedup_key_rows'] += 1\n continue\n seen_dedup_keys.add(dedup_key)\n selected.append(alert)\n stats['first_seen_rows'] = len(selected)\n return selected, stats\n\n\ndef _resolve_output_config():\n config = _load_workflow_config()\n raw_mode = str(_configured_value(config, 'triage_output_mode', 'soc_db') or 'soc_db').strip().lower()\n mode_alias = {\n 'db': 'soc_db',\n 'sqlite': 'soc_db',\n 'sqlite_db': 'soc_db',\n 'soc': 'soc_db',\n 'json': 'jsonl',\n 'file': 'jsonl',\n 'files': 'jsonl',\n 'off': 'none',\n 'disabled': 'none',\n }\n requested_mode = mode_alias.get(raw_mode, raw_mode)\n if requested_mode not in {'soc_db', 'jsonl', 'both', 'none'}:\n print(f'[triage_config] WARNING: invalid triage_output_mode={raw_mode!r}; using soc_db')\n requested_mode = 'soc_db'\n\n legacy_jsonl = _input_bool(_configured_value(config, 'persist_triage_output', False), False)\n write_soc_db = requested_mode in {'soc_db', 'both'}\n write_jsonl = requested_mode in {'jsonl', 'both'}\n effective_mode = requested_mode\n if requested_mode == 'soc_db' and legacy_jsonl:\n write_jsonl = True\n effective_mode = 'both'\n if requested_mode == 'none':\n write_soc_db = False\n write_jsonl = False\n effective_mode = 'none'\n\n soc_db_path = os.path.expanduser(str(\n _configured_value(config, 'soc_db_path', _DEFAULT_SOC_DB_PATH) or _DEFAULT_SOC_DB_PATH\n ))\n jsonl_output_dir = _configured_value(config, 'jsonl_output_dir', '') or ''\n jsonl_output_dir = os.path.expanduser(str(jsonl_output_dir)) if jsonl_output_dir else ''\n return {\n 'config_path': _WORKFLOW_CONFIG_PATH,\n 'requested_mode': requested_mode,\n 'mode': effective_mode,\n 'write_soc_db': write_soc_db,\n 'write_jsonl': write_jsonl,\n 'soc_db_path': soc_db_path,\n 'jsonl_output_dir': jsonl_output_dir,\n }\n\n\n# ── Persisted JSONL output (optional; mirrors stream_alert_denoise layout) ─────\n#\n# Directory : ~/.flocks/workspace/workflows/stream_alert_triage//\n# Filename : triage_result_NNN.jsonl (3-digit zero-padded seq)\n# Layout : line 1 = {\"_type\":\"file_header\", ...}, subsequent lines = one\n# enriched_with_triage alert per line.\n# Counter : .triage_counter.json sidecar tracks (seq, count) so we don't\n# rescan every existing file on each run; auto-rolls over to a\n# new file when reaching MAX_RECORDS_PER_FILE.\n\n\ndef _triage_output_dir(configured_dir=''):\n \"\"\"Return output directory for triage_result_*.jsonl.\"\"\"\n if configured_dir:\n out_dir = configured_dir\n os.makedirs(out_dir, exist_ok=True)\n return out_dir\n from flocks.config import Config\n flocks_root = Config().get_global().data_dir.parent\n date_str = _datetime.datetime.now().strftime('%Y-%m-%d')\n out_dir = flocks_root / 'workspace' / 'workflows' / WORKFLOW_NAME / date_str\n out_dir.mkdir(parents=True, exist_ok=True)\n return str(out_dir)\n\n\ndef _triage_get_counter(out_dir):\n path = os.path.join(out_dir, _TRIAGE_COUNTER_FILE)\n try:\n with open(path, 'r', encoding='utf-8') as f:\n d = json.load(f)\n return int(d.get('seq', 0)), int(d.get('count', 0))\n except Exception:\n return 0, 0\n\n\ndef _triage_set_counter(out_dir, seq, count):\n path = os.path.join(out_dir, _TRIAGE_COUNTER_FILE)\n tmp = path + '.tmp'\n try:\n with open(tmp, 'w', encoding='utf-8') as f:\n json.dump({'seq': seq, 'count': count}, f)\n os.replace(tmp, path)\n except Exception:\n pass\n\n\ndef _triage_find_active_file(out_dir):\n \"\"\"Locate the active (latest, not-yet-full) jsonl file; create if none.\"\"\"\n seq, count = _triage_get_counter(out_dir)\n if seq > 0:\n path = os.path.join(out_dir, f'{_TRIAGE_JSONL_PREFIX}_{seq:03d}.jsonl')\n if os.path.exists(path):\n return path, count, seq\n import glob as _glob\n existing = sorted(_glob.glob(os.path.join(out_dir, _TRIAGE_JSONL_PREFIX + '_*.jsonl')))\n if not existing:\n return None, 0, 0\n latest = existing[-1]\n try:\n seq = int(os.path.basename(latest).replace(_TRIAGE_JSONL_PREFIX + '_', '').replace('.jsonl', ''))\n except ValueError:\n seq = len(existing)\n count = 0\n try:\n with open(latest, 'r', encoding='utf-8') as f:\n for line in f:\n if line.strip() and '\"_type\"' not in line:\n count += 1\n except Exception:\n pass\n return latest, count, seq\n\n\ndef _triage_write_jsonl(out_dir, alerts, run_id, run_stats):\n \"\"\"Append all alerts to today's triage_result_NNN.jsonl, rolling over at\n MAX_RECORDS_PER_FILE. Returns the list of files that were written to.\"\"\"\n now = _datetime.datetime.now()\n written = []\n active_path, active_count, seq = _triage_find_active_file(out_dir)\n remaining = list(alerts)\n while remaining:\n available = MAX_RECORDS_PER_FILE - active_count\n if available <= 0 or active_path is None:\n seq += 1\n active_path = os.path.join(out_dir, f'{_TRIAGE_JSONL_PREFIX}_{seq:03d}.jsonl')\n active_count = 0\n available = MAX_RECORDS_PER_FILE\n header = {\n '_type': 'file_header',\n 'created_at': now.isoformat(),\n 'date': now.strftime('%Y-%m-%d'),\n 'workflow': WORKFLOW_NAME,\n 'seq': seq,\n 'run_id': run_id,\n 'batch_total': run_stats.get('total'),\n 'batch_triaged': run_stats.get('triaged'),\n 'batch_followers_reused':run_stats.get('followers_reused'),\n 'batch_cache_hit': run_stats.get('cache_hit'),\n 'batch_triage_failed': run_stats.get('triage_failed'),\n }\n with open(active_path, 'w', encoding='utf-8') as hf:\n hf.write(json.dumps(header, ensure_ascii=False) + '\\n')\n batch = remaining[:available]\n remaining = remaining[available:]\n with open(active_path, 'a', encoding='utf-8') as af:\n for alert in batch:\n af.write(json.dumps(alert, ensure_ascii=False) + '\\n')\n active_count += len(batch)\n if active_path not in written:\n written.append(active_path)\n if remaining:\n active_path = None\n active_count = 0\n if written:\n _triage_set_counter(out_dir, seq, active_count)\n return written\n\n\n# ── SOC DB output (default) ───────────────────────────────────────────────────\n\ndef _ensure_soc_db_schema(conn):\n conn.execute(\"\"\"\n CREATE TABLE IF NOT EXISTS alert_records (\n row_id TEXT PRIMARY KEY,\n record_id TEXT,\n asset_date TEXT NOT NULL,\n source_file TEXT NOT NULL,\n line_number INTEGER NOT NULL,\n event_time INTEGER,\n source_type TEXT,\n threat_name TEXT,\n dedup_key TEXT,\n is_duplicate INTEGER NOT NULL DEFAULT 0,\n record_json TEXT NOT NULL\n )\n \"\"\")\n columns = {row[1] for row in conn.execute('PRAGMA table_info(alert_records)')}\n dedup_key_added = 'dedup_key' not in columns\n if dedup_key_added:\n conn.execute('ALTER TABLE alert_records ADD COLUMN dedup_key TEXT')\n\n unique_index_name = 'idx_alert_records_first_seen_dedup_key'\n indexes = list(conn.execute('PRAGMA index_list(alert_records)'))\n unique_index_ready = any(row[1] == unique_index_name and bool(row[2]) for row in indexes)\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_duplicate ON alert_records(is_duplicate)')\n has_persisted_duplicates = conn.execute(\"\"\"\n SELECT 1 FROM alert_records\n WHERE is_duplicate = 1\n AND dedup_key IS NOT NULL\n AND dedup_key <> ''\n LIMIT 1\n \"\"\").fetchone() is not None\n if not unique_index_ready or has_persisted_duplicates:\n if any(row[1] == unique_index_name for row in indexes):\n conn.execute(f'DROP INDEX {unique_index_name}')\n conn.execute(\"\"\"\n UPDATE alert_records\n SET dedup_key = CASE\n WHEN json_valid(record_json)\n THEN NULLIF(TRIM(CAST(json_extract(record_json, '$.dedup_key') AS TEXT)), '')\n ELSE NULL\n END\n WHERE dedup_key IS NULL OR TRIM(dedup_key) = ''\n \"\"\")\n conn.execute(\"\"\"\n UPDATE alert_records\n SET dedup_key = NULLIF(TRIM(dedup_key), '')\n WHERE dedup_key IS NOT NULL\n \"\"\")\n conn.execute(\"\"\"\n DELETE FROM alert_records\n WHERE dedup_key IS NOT NULL\n AND dedup_key <> ''\n AND rowid NOT IN (\n SELECT MIN(rowid)\n FROM alert_records\n WHERE dedup_key IS NOT NULL AND dedup_key <> ''\n AND is_duplicate = 0\n GROUP BY dedup_key\n )\n \"\"\")\n conn.execute(f\"\"\"\n CREATE UNIQUE INDEX {unique_index_name}\n ON alert_records(dedup_key)\n WHERE dedup_key IS NOT NULL AND dedup_key <> ''\n \"\"\")\n\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_asset_date ON alert_records(asset_date)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_event_time ON alert_records(event_time)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_source_type ON alert_records(source_type)')\n conn.execute('CREATE INDEX IF NOT EXISTS idx_alert_records_threat_name ON alert_records(threat_name)')\n\n\ndef _event_time_value(alert):\n for key in ('time', 'event_time', 'timestamp', 'timestamp_real', 'occur_time', 'created_at'):\n value = alert.get(key)\n if value in (None, ''):\n continue\n if isinstance(value, (int, float)):\n ts = float(value)\n if ts > 100000000000:\n ts = ts / 1000.0\n return int(ts)\n text = str(value).strip()\n if not text:\n continue\n try:\n ts = float(text)\n if ts > 100000000000:\n ts = ts / 1000.0\n return int(ts)\n except Exception:\n pass\n normalized = text.replace('Z', '+00:00')\n try:\n return int(_datetime.datetime.fromisoformat(normalized).timestamp())\n except Exception:\n pass\n for fmt in ('%Y-%m-%d %H:%M:%S', '%Y/%m/%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y/%m/%d %H:%M'):\n try:\n return int(_datetime.datetime.strptime(text, fmt).timestamp())\n except Exception:\n continue\n return int(time.time())\n\n\ndef _asset_date_value(alert, event_time):\n value = alert.get('asset_date') or alert.get('_asset_date') or alert.get('date')\n if value:\n text = str(value).strip()\n if re.match(r'^\\d{4}-\\d{2}-\\d{2}$', text):\n return text\n try:\n return _datetime.datetime.fromtimestamp(int(event_time)).strftime('%Y-%m-%d')\n except Exception:\n return _datetime.datetime.now().strftime('%Y-%m-%d')\n\n\ndef _source_type_value(alert):\n for key in ('source_type', '_source_type', 'data_source', 'log_type', 'vendor', 'device_type'):\n value = alert.get(key)\n if value not in (None, ''):\n return str(value)\n return ''\n\n\ndef _record_id_value(alert):\n for key in ('record_id', 'id', 'uuid', 'event_id', 'dedup_key'):\n value = alert.get(key)\n if value not in (None, ''):\n return str(value)\n return ''\n\n\ndef _stable_row_id(alert, source_file, line_number, event_time):\n existing = alert.get('row_id') or alert.get('_row_id')\n if existing:\n return str(existing)\n import hashlib as _hashlib\n basis = {\n 'record_id': _record_id_value(alert),\n 'dedup_key': alert.get('dedup_key', ''),\n 'time': event_time,\n 'source_file': source_file,\n 'line_number': line_number,\n 'sip': alert.get('sip', ''),\n 'sport': alert.get('sport', ''),\n 'dip': alert.get('dip', ''),\n 'dport': alert.get('dport', ''),\n 'threat_rule_id': alert.get('threat_rule_id') or alert.get('rule_id') or '',\n }\n raw = json.dumps(basis, sort_keys=True, ensure_ascii=False)\n return _hashlib.sha256(raw.encode('utf-8')).hexdigest()\n\n\ndef _load_existing_soc_rows(conn, dedup_keys):\n existing = {}\n unique_keys = list(dict.fromkeys(str(key).strip() for key in dedup_keys if str(key).strip()))\n for start in range(0, len(unique_keys), 500):\n chunk = unique_keys[start:start + 500]\n placeholders = ','.join('?' for _ in chunk)\n rows = conn.execute(f\"\"\"\n SELECT row_id, record_id, asset_date, source_file, line_number,\n event_time, source_type, threat_name, dedup_key,\n is_duplicate, record_json\n FROM alert_records\n WHERE dedup_key IN ({placeholders})\n \"\"\", chunk)\n for row in rows:\n try:\n record = json.loads(row[10])\n except Exception:\n record = {}\n if not isinstance(record, dict):\n record = {}\n existing[row[8]] = {\n 'row_id': row[0],\n 'record_id': row[1],\n 'asset_date': row[2],\n 'source_file': row[3],\n 'line_number': row[4],\n 'event_time': row[5],\n 'source_type': row[6],\n 'threat_name': row[7],\n 'record': record,\n }\n return existing\n\n\ndef _merge_triage_record(existing_record, incoming_record):\n merged = dict(existing_record) if isinstance(existing_record, dict) else {}\n triage_fields = (\n 'has_dedup_key',\n 'triage_source',\n 'triage_status',\n 'attack_verdict',\n 'risk_level',\n 'report_title',\n 'triage_report',\n 'attack_success',\n 'triage_ms',\n 'triage_error',\n '_triage_run_id',\n '_triage_persisted_at',\n )\n for key in triage_fields:\n if key in incoming_record:\n merged[key] = incoming_record[key]\n elif key in {'triage_ms', 'triage_error'}:\n merged.pop(key, None)\n return merged\n\n\ndef _triage_write_soc_db(db_path, alerts, run_id):\n import sqlite3\n\n db_dir = os.path.dirname(db_path)\n if db_dir:\n os.makedirs(db_dir, exist_ok=True)\n default_source_file = ''\n loaded_files = inputs.get('loaded_files') or []\n if isinstance(loaded_files, list) and len(loaded_files) == 1:\n default_source_file = str(loaded_files[0])\n\n persisted_at = _datetime.datetime.now().isoformat()\n candidates = []\n seen_dedup_keys = set()\n for idx, alert in enumerate(alerts, 1):\n if not isinstance(alert, dict):\n continue\n dedup_key = str(alert.get('dedup_key') or '').strip()\n if not dedup_key or dedup_key in seen_dedup_keys:\n continue\n seen_dedup_keys.add(dedup_key)\n record = dict(alert)\n record['dedup_key'] = dedup_key\n record['is_duplicate'] = False\n record['_triage_run_id'] = run_id\n record['_triage_persisted_at'] = persisted_at\n source_file = str(\n record.get('source_file')\n or record.get('_source_file')\n or record.get('file_path')\n or default_source_file\n or 'stream_alert_triage'\n )\n try:\n line_number = int(record.get('line_number') or record.get('_line_number') or idx)\n except Exception:\n line_number = idx\n event_time = _event_time_value(record)\n candidates.append({\n 'row_id': _stable_row_id(record, source_file, line_number, event_time),\n 'record_id': _record_id_value(record),\n 'asset_date': _asset_date_value(record, event_time),\n 'source_file': source_file,\n 'line_number': line_number,\n 'event_time': event_time,\n 'source_type': _source_type_value(record),\n 'threat_name': str(record.get('threat_name') or record.get('rule_name') or ''),\n 'dedup_key': dedup_key,\n 'record': record,\n })\n\n insert_rows = []\n update_rows = []\n with sqlite3.connect(db_path, timeout=30) as conn:\n conn.execute('BEGIN IMMEDIATE')\n _ensure_soc_db_schema(conn)\n existing_by_key = _load_existing_soc_rows(\n conn, [candidate['dedup_key'] for candidate in candidates],\n )\n for candidate in candidates:\n existing = existing_by_key.get(candidate['dedup_key'])\n if existing:\n merged_record = _merge_triage_record(existing['record'], candidate['record'])\n merged_record['dedup_key'] = candidate['dedup_key']\n merged_record['is_duplicate'] = False\n update_rows.append((\n candidate['dedup_key'],\n json.dumps(merged_record, ensure_ascii=False),\n existing['row_id'],\n ))\n continue\n insert_rows.append((\n candidate['row_id'],\n candidate['record_id'],\n candidate['asset_date'],\n candidate['source_file'],\n candidate['line_number'],\n candidate['event_time'],\n candidate['source_type'],\n candidate['threat_name'],\n candidate['dedup_key'],\n 0,\n json.dumps(candidate['record'], ensure_ascii=False),\n ))\n\n if insert_rows:\n conn.executemany(\"\"\"\n INSERT INTO alert_records (\n row_id, record_id, asset_date, source_file, line_number,\n event_time, source_type, threat_name, dedup_key,\n is_duplicate, record_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n \"\"\", insert_rows)\n if update_rows:\n conn.executemany(\"\"\"\n UPDATE alert_records\n SET dedup_key = ?, is_duplicate = 0, record_json = ?\n WHERE row_id = ?\n \"\"\", update_rows)\n conn.commit()\n\n persisted_rows = len(insert_rows) + len(update_rows)\n return {\n 'path': db_path,\n 'table': 'alert_records',\n 'rows': persisted_rows,\n 'inserted_rows': len(insert_rows),\n 'updated_rows': len(update_rows),\n }\n\n\n# ── LLM provider warm-up (avoid cold-start race in _parallel_4_branches) ──────\n#\n# Background: when this node starts, the LLM provider (e.g. threatbook-cn-llm)\n# is lazy-initialized on the first call. Inside `_parallel_4_branches` we\n# submit 4 LLM calls to a ThreadPoolExecutor simultaneously; whichever one\n# wins the race may race against provider registration and fail with\n# \"provider 'xxx' not exists\" while subsequent calls succeed. A single\n# synchronous warm-up call before any concurrent fan-out forces the provider\n# to finish registering on the main thread, eliminating the race entirely.\n#\n# Failure of the warm-up is non-fatal — we just log and continue. The first\n# real LLM call will still see the same error and surface it normally.\n\ndef _warmup_llm():\n \"\"\"Force LLM provider lazy-init on the main thread before any fan-out.\"\"\"\n t0 = time.time()\n try:\n llm.ask('ping')\n print(f'[triage] LLM provider warm-up OK in {round((time.time()-t0)*1000)}ms')\n return True\n except Exception as e:\n print(f'[triage] WARNING: LLM warm-up failed ({type(e).__name__}: '\n f'{str(e)[:200]}); proceeding anyway')\n return False\n\n\n# ── Inline triage helpers (mirroring tdp_alert_triage docs version) ───────────\n\ndef _strip_think(text):\n return re.sub(r'[\\s\\S]*?', '', str(text or ''), flags=re.IGNORECASE).strip()\n\n\ndef _is_public_ip(value):\n try:\n ip_obj = ipaddress.ip_address(value)\n except Exception:\n return False\n return not (ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved\n or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_unspecified)\n\n\ndef _pick(*values):\n for v in values:\n if v not in (None, '', [], {}):\n return v\n return ''\n\n\ndef _parse_alert(alert_input):\n \"\"\"Mirrors tdp_alert_triage.receive_alert. Supports three input shapes:\n nested TDP (net.http.url), flat TDP (net_http_url), normalized (req_http_url).\n \"\"\"\n if isinstance(alert_input, str):\n try:\n alert_input = json.loads(alert_input)\n except Exception:\n alert_input = {}\n if isinstance(alert_input, list):\n alert_data = alert_input[0] if alert_input else {}\n elif isinstance(alert_input, dict) and isinstance(alert_input.get('data'), list):\n alert_data = alert_input.get('data', [])[0] if alert_input.get('data') else {}\n else:\n alert_data = alert_input if isinstance(alert_input, dict) else {}\n\n net = alert_data.get('net', {}) or {}\n http = net.get('http', {}) or {}\n threat = alert_data.get('threat', {}) or {}\n assets = alert_data.get('assets', {}) or {}\n\n src_ip = _pick(\n alert_data.get('attacker'), alert_data.get('external_ip'),\n net.get('src_ip'), net.get('flow_src_ip'),\n alert_data.get('net_real_src_ip'),\n alert_data.get('sip'), alert_data.get('src_ip'), alert_data.get('src'),\n )\n dst_ip = _pick(\n alert_data.get('victim'), alert_data.get('machine'),\n alert_data.get('server_ip'), net.get('dest_ip'), net.get('flow_dest_ip'),\n alert_data.get('net_dest_ip'),\n alert_data.get('dip'), alert_data.get('dst_ip'), alert_data.get('dst'),\n )\n src_port = _pick(\n net.get('src_port'), net.get('flow_src_port'),\n alert_data.get('external_port'), alert_data.get('net_src_port'),\n alert_data.get('sport'), alert_data.get('src_port'), 0,\n )\n dst_port = _pick(\n net.get('dest_port'), net.get('flow_dest_port'),\n alert_data.get('server_port'), alert_data.get('machine_port'),\n alert_data.get('net_dest_port'),\n alert_data.get('dport'), alert_data.get('dst_port'), 0,\n )\n protocol = _pick(\n net.get('app_proto'), net.get('type'), net.get('proto'),\n alert_data.get('net_app_proto'), alert_data.get('protocol'),\n alert_data.get('event_type'), 'TCP',\n )\n alert_type = _pick(\n threat.get('name'), alert_data.get('threat_name'),\n alert_data.get('vuln_name'),\n alert_data.get('alert_type'), threat.get('topic'),\n alert_data.get('type'), 'unknown',\n )\n severity = _pick(\n threat.get('severity'), alert_data.get('threat_severity'),\n alert_data.get('severity'), threat.get('level'),\n alert_data.get('level'), 'medium',\n )\n\n req_line = _pick(http.get('reqs_line'), alert_data.get('req_line'), alert_data.get('net_http_reqs_line'))\n req_header = _pick(http.get('reqs_header'), alert_data.get('req_header'), alert_data.get('net_http_reqs_header'))\n req_body = _pick(http.get('req_body'), alert_data.get('req_body'), alert_data.get('net_http_reqs_body'))\n resp_line = _pick(http.get('resp_line'), alert_data.get('rsp_line'), alert_data.get('resp_line'),\n alert_data.get('net_http_resp_line'))\n resp_header = _pick(http.get('resp_header'), alert_data.get('rsp_header'), alert_data.get('resp_header'),\n alert_data.get('net_http_resp_header'))\n resp_body = _pick(http.get('resp_body'), alert_data.get('rsp_body'), alert_data.get('resp_body'),\n alert_data.get('net_http_resp_body'))\n status = _pick(http.get('status'), alert_data.get('http_status'),\n alert_data.get('net_http_status'), alert_data.get('rsp_status_code'), 0)\n\n host = _pick(http.get('reqs_host'), alert_data.get('url_host'), http.get('domain'),\n alert_data.get('req_host'), alert_data.get('net_http_reqs_host'), dst_ip)\n raw_url = _pick(http.get('raw_url'), http.get('url'),\n alert_data.get('url_path'),\n alert_data.get('net_http_url'), alert_data.get('req_http_url'),\n alert_data.get('uri'))\n url = ''\n if host and raw_url:\n scheme = 'https' if net.get('is_https') else 'http'\n url = raw_url if str(raw_url).startswith(('http://', 'https://')) else f'{scheme}://{host}{raw_url}'\n elif raw_url and str(raw_url).startswith(('http://', 'https://')):\n url = raw_url\n elif raw_url:\n url = raw_url\n\n payload = f'请求行: {req_line}\\n请求头: {req_header}\\n请求体: {req_body}'\n response = f'状态行: {resp_line}\\n响应头: {resp_header}\\n响应体: {resp_body}'\n threat_result = _pick(threat.get('result'), alert_data.get('threat_result'))\n threat_msg = _pick(threat.get('msg'), alert_data.get('threat_msg'))\n\n log_text = (\n f'[告警基本信息]\\n'\n f'告警类型: {alert_type}\\n严重级别: {severity}\\n'\n f'源地址: {src_ip}:{src_port}\\n目的地址: {dst_ip}:{dst_port}\\n'\n f'协议: {protocol}\\nURL: {url}\\nHTTP状态码: {status}\\n'\n f'TDP判定: {threat_result}\\nTDP消息: {threat_msg}\\n\\n'\n f'[HTTP请求内容]\\n{payload}\\n\\n'\n f'[HTTP响应内容]\\n{response}'\n )\n\n vuln_text = '\\n'.join(str(item) for item in [\n threat_msg, threat.get('topic', ''),\n alert_data.get('data', ''), url,\n json.dumps(threat.get('tag', []), ensure_ascii=False),\n ] if item)\n vuln_matches = sorted(set(re.findall(r'\\b(?:CVE|CNVD|CNNVD|XVE)-[A-Za-z0-9._-]+\\b', vuln_text, flags=re.I)))\n\n iocs = []\n for candidate in [src_ip, dst_ip]:\n if candidate:\n iocs.append({'type': 'ip', 'value': candidate})\n if url:\n iocs.append({'type': 'url', 'value': url})\n if host and not re.match(r'^\\d{1,3}(?:\\.\\d{1,3}){3}(?::\\d+)?$', str(host)):\n iocs.append({'type': 'domain', 'value': str(host).split(':')[0]})\n\n return {\n 'src_ip': src_ip, 'dst_ip': dst_ip, 'src_port': src_port, 'dst_port': dst_port,\n 'protocol': protocol, 'payload': payload, 'response': response,\n 'url': url, 'status': status,\n 'alert_type': alert_type, 'severity': severity,\n 'vuln_id': vuln_matches[0] if vuln_matches else '',\n 'vuln_candidates': vuln_matches,\n 'threat_result': threat_result, 'threat_msg': threat_msg,\n 'failed_by': threat.get('failed_by', []),\n 'asset_ip': assets.get('ip', ''), 'asset_name': assets.get('name', []),\n 'iocs': iocs, 'log_text': log_text,\n }\n\n\ndef _prepare_intel(parsed):\n \"\"\"Mirrors tdp_alert_triage.prepare_intel. Pre-fetches IP/domain/URL threat intel\n and CVE info so the parallel LLM tasks have concrete context to consume.\n \"\"\"\n iocs = parsed.get('iocs', [])\n intel_results = []\n seen = set()\n for ioc in iocs:\n ioc_type = ioc.get('type', '')\n ioc_value = str(ioc.get('value', '')).strip()\n key = (ioc_type, ioc_value)\n if not ioc_value or key in seen:\n continue\n seen.add(key)\n if ioc_type == 'ip':\n if not _is_public_ip(ioc_value):\n continue\n r = tool.run_safe('threatbook_ip_query', ip=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'ip',\n 'value': ioc_value, 'result': r['text']})\n elif ioc_type == 'domain':\n r = tool.run_safe('threatbook_domain_query', domain=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'domain',\n 'value': ioc_value, 'result': r['text']})\n elif ioc_type == 'url':\n r = tool.run_safe('threatbook_url_query', url=ioc_value)\n if r['success']:\n intel_results.append({'source': 'threatbook', 'type': 'url',\n 'value': ioc_value, 'result': r['text']})\n\n vuln_info = {}\n vuln_id = parsed.get('vuln_id', '')\n if vuln_id:\n r = tool.run_safe('__mcp_vuln_query', vuln_id=vuln_id)\n if r['success']:\n try:\n obj = r.get('obj')\n if isinstance(obj, str):\n obj = json.loads(obj)\n vuln_info = obj if isinstance(obj, dict) else {'raw_result': r.get('text', '')}\n except Exception:\n vuln_info = {'raw_result': r.get('text', '')}\n\n intel_content = '\\n'.join(\n f\"[{i['source']}/{i['type']}] {i['value']}\\n{i['result']}\" for i in intel_results\n ) or '(无可用情报数据)'\n vuln_content = (\n json.dumps(vuln_info, ensure_ascii=False, indent=2)\n if vuln_info else '(无可用漏洞情报数据)'\n )\n return intel_results, intel_content, vuln_info, vuln_content\n\n\n# ── 4 LLM analyses (the parallel branches from tdp_alert_triage) ─────────────\n\ndef _ask_llm(prompt):\n \"\"\"Wrap ``llm.ask`` with the workflow-wide timeout + retry budget.\n\n Centralizing this avoids a hung provider request (no TCP timeout from\n upstream) from blocking a worker thread indefinitely. Any call that does\n not need bespoke parameters should go through here.\n \"\"\"\n return llm.ask(\n prompt,\n timeout_s=LLM_CALL_TIMEOUT_S,\n max_retries=LLM_CALL_MAX_RETRIES,\n )\n\n\ndef _llm_survey(log_text, intel_content):\n prompt = f'''你是一个专业的Web日志分析专家。请总结以下IP的情报数据中的空间测绘信息。\n1. 如果该IP没有测绘信息,则不列出。\n2. 如果IP有测绘信息,则以简短的语言对该IP的测绘信息进行总结,关键说明ip的标签和测绘信息显示有哪些服务或者应用资产。\n3. 多个IP的测绘信息以无序列表显示,每个ip数据描述占一行数据。\n4. 不需要生成其他额外的补充信息。\n\n## 情报参考信息\n{intel_content}\n\n## 用户的原始输入日志\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_cve_related(log_text):\n prompt = f'''请从以下的日志数据中提取漏洞编号。\n要求:\n1. 仅从日志文本中识别漏洞编号,不要做任何推测。\n2. 如果日志中存在漏洞编号,则用简短语言描述,如:\"日志中存在漏洞编号:CVE-****-****\"。\n3. 如果日志中不存在漏洞编号,则输出:\"日志中无关联漏洞情报\"。\n\n日志数据如下:\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_cve_info(log_text, vuln_content):\n prompt = f'''你是一个专业的Web日志分析专家。参考情报信息中的漏洞数据,简要说明关联的CVE漏洞信息。\n1. 不要输出任何解释说明,只输出漏洞基本信息。不需要生成漏洞的处置建议或修复措施等。\n\n## 情报参考信息\n{vuln_content}\n\n## 用户的原始输入日志\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_payload_analysis(log_text):\n prompt = f'''你是一个专业的Web日志分析专家。根据用户输入的日志进行攻击负载分析。\n1. 首先分析日志中是否包含攻击负载,并给出判定依据。\n2. 不要进行攻击意图分析、攻击影响分析。\n3. 用简短的语言在一段话中进行描述。\n\n## 用户的原始输入日志:\n{log_text}\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _parallel_4_branches(parsed, intel_content, vuln_content):\n \"\"\"4 LLM analyses run in parallel — keeps tdp_alert_triage's fan-out structure.\"\"\"\n log_text = parsed.get('log_text', '')\n with ThreadPoolExecutor(max_workers=4, thread_name_prefix='triage_branch') as pool:\n futs = {\n 'survey_result': pool.submit(_llm_survey, log_text, intel_content),\n 'cve_related_result': pool.submit(_llm_cve_related, log_text),\n 'cve_info_result': pool.submit(_llm_cve_info, log_text, vuln_content),\n 'payload_analysis_result': pool.submit(_llm_payload_analysis, log_text),\n }\n out = {}\n for name, fut in futs.items():\n try:\n out[name] = fut.result()\n except Exception as e:\n print(f'[triage] WARNING: branch {name} failed: {e}')\n out[name] = ''\n return out\n\n\n# ── Join-point LLM analyses (attack_analysis_result -> verdict -> title) ──────\n\ndef _llm_attack_analysis(log_text):\n prompt = f'''你是一名专业且经验丰富的网络安全分析师和Web日志分析专家,你对HTTP协议以及Web攻击有着深入的理解,并且你能够快速识别和应对各种网络威胁。你的任务是对提供的HTTP请求与响应内容进行详细的专业分析,并判断日志请求的攻击状态。\n\n请严格遵循以下指令进行思考和分析:\n1. 攻击状态只能从以下情况中选择一种:[\"攻击成功\", \"攻击失败\", \"攻击\", \"未知\", \"安全\"]。\n2. 从日志中提取出\"HTTP请求内容\"和\"HTTP响应内容\"。请注意,HTTP请求内容和HTTP响应内容是分开的,请不要混淆,有些日志中没有包含HTTP响应内容,请不要将HTTP请求内容和HTTP响应内容混淆。分析后请你记住哪些是HTTP请求内容,哪些是HTTP响应内容。\n3. 请检查HTTP响应状态码,2xx或者3xx状态码都代表本次HTTP请求成功,4xx或者5xx状态码大多数情况下都代表请求失败,只有在请求成功的情况下才能对攻击是否成功进行后续判断。\n\n各攻击状态的定义以及判定标准:\n1. 攻击成功:\n(1) 首先分析日志中是否含有清晰的\"HTTP响应内容\",如果日志中没有\"HTTP响应内容\",则肯定不属于攻击成功。\n(2) 如果日志中未提供\"HTTP响应内容\",即使HTTP请求内容中包含攻击者预期的结果,也不能判定为攻击成功。\n(3) 从日志中提取出\"HTTP请求内容\"和\"HTTP响应内容\"。请深入分析\"HTTP响应内容\",并判定其是否为\"HTTP请求内容\"攻击成功时的预期结果,这是判定攻击成功的强依据。请注意,HTTP响应码200仅表示网络连接成功,不代表攻击攻击成功。\n(4) 分析HTTP请求内容和HTTP响应内容,只有当HTTP响应内容中明确包含攻击载荷在目标机器上成功执行的证据,并且HTTP请求内容中包含攻击载荷的特征,则判定为\"攻击成功\"。\n(5) 请注意:攻击成功的判定必须包含HTTP响应内容。如果不包含HTTP响应内容,则肯定不属于攻击成功。\n(6) 请注意:如果不包含HTTP响应内容,即使HTTP请求内容是攻击,这也不属于攻击成功。\n2. 攻击失败:\n(1) 分析HTTP请求内容和HTTP响应内容,如果HTTP响应内容中明确包含攻击载荷在目标机器上执行失败或者被阻止的证据,并且HTTP请求内容中包含攻击载荷的特征,则判定为\"攻击失败\"。\n(2) 攻击失败的判定必须包含HTTP响应内容。如果不包含HTTP响应内容,则肯定不属于攻击失败。\n3. 攻击:\n(1) 在\"HTTP请求内容\"或\"HTTP响应内容\"中发现任何证明存在攻击意图的证据,即可判定为存在攻击行为。但如果不符合上述的攻击成功或者攻击失败的标准,则\"攻击状态\"为\"攻击\"。\n(2) 请注意:如果日志中只提供了\"HTTP请求内容\",且没有提供\"HTTP响应内容\",且HTTP的请求内容分析中是包含攻击行为的,则\"攻击状态\"为\"攻击\"。\n4. 未知:\n(1) 如果不能100%确定HTTP通信的攻击结果,那么请在\"攻击状态\"处给出\"未知\"。\n(2) 请注意:如果在你给的判定原因中存在\"可能\"等不确定词汇,都代表你不能对你的结论100%确定,那么请在\"攻击状态\"处给出\"未知\"。\n5. 安全:\n(1) 如果\"HTTP请求内容\"和\"HTTP响应内容\"中都没有任何攻击意图的证据,那么请在\"攻击状态\"处给出\"安全\"。\n\n## 日志内容\n{log_text}\n\n## 输出要求\n请按下列结构输出(中文):\n1. 攻击状态: [攻击成功/攻击失败/攻击/未知/安全]\n2. 判定依据: 简要说明请求与响应的关键证据\n3. 详细分析: 不超过200字\n'''\n return _strip_think(_ask_llm(prompt))\n\n\ndef _llm_attack_verdict(attack_analysis_result):\n prompt = f'''你是一个专业的Web日志分析专家。请据参考信息,直接输出攻击判定类别:\nattack_success:表示攻击成功。\nattack_failed:表示攻击失败。\nattack:表示是日志内容是攻击。\nunknown:表示未知。\nbenign:是安全。\n不额外输出任何其他信息,包括解释、判定依据等。\n\n## 日志分析结果:\n{attack_analysis_result}\n'''\n raw = _strip_think(_ask_llm(prompt)).strip().lower()\n return next((v for v in VERDICT_LABELS if v in raw), 'unknown')\n\n\ndef _llm_report_title(alert_type, attack_verdict, attack_analysis_result):\n prompt = f'''你是一个专业的Web日志分析专家。请基于以下分析结果,生成一份不超过 30 字的中文报告标题。\n要求:\n1. 标题必须能体现\"攻击类型\"或\"攻击结果分析的结论\"。\n2. 不要带书名号、引号或其他标点。\n3. 只输出标题本身,不要任何解释或说明。\n\n## 攻击类型\n{alert_type}\n\n## 攻击判定\n{attack_verdict}\n\n## 攻击分析结果\n{attack_analysis_result}\n'''\n raw = _strip_think(_ask_llm(prompt)).strip()\n return raw.splitlines()[0].strip(' \"\\'《》[]【】') if raw else f'{alert_type} - {attack_verdict}'\n\n\ndef _clip_text(value, limit=3000):\n text = str(value or '').strip()\n if len(text) > limit:\n return text[:limit] + '\\n...(已截断)'\n return text or '未提供'\n\n\ndef _fence_text(value):\n text = _clip_text(value, 6000)\n return text.replace('```', '``\\\\u200b`')\n\n\ndef _extract_tagged_triage_report(text):\n text = _strip_think(text)\n m = re.search(r']*>[\\s\\S]*?', text, flags=re.I)\n return m.group(0).strip() if m else text.strip()\n\n\ndef _is_valid_triage_report(markdown):\n text = str(markdown or '')\n if not re.search(r']*version=[\"\\']soc\\.triage\\.markdown\\.v1[\"\\'][^>]*>', text, flags=re.I):\n return False\n if not re.search(r'', text, flags=re.I):\n return False\n for tag in TRIAGE_REPORT_TAGS:\n if not re.search(rf'<{tag}\\b[^>]*>', text, flags=re.I):\n return False\n if not re.search(rf'', text, flags=re.I):\n return False\n return True\n\n\ndef _is_current_triage_fields(fields):\n if not isinstance(fields, dict):\n return False\n return _is_valid_triage_report(fields.get('triage_report'))\n\n\ndef _format_intel_brief(intel_results):\n if not intel_results:\n return '未查询到外部威胁情报。'\n lines = []\n for intel in intel_results[:6]:\n lines.append(f\"- {intel.get('source', 'intel')} / {intel.get('type', 'ioc')}: {intel.get('value', '')} => {_clip_text(intel.get('result'), 500)}\")\n return '\\n'.join(lines)\n\n\ndef _build_default_tagged_triage_report(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict,\n report_title, risk_level):\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n title = report_title or f'{parsed.get(\"alert_type\", \"Web日志告警\")} - {verdict_cn}'\n payload = _fence_text(parsed.get('payload', ''))\n response = _fence_text(parsed.get('response', ''))\n url = parsed.get('url') or '未提供'\n threat_msg = parsed.get('threat_msg') or '未提供'\n status = parsed.get('status') or '未提供'\n survey = _clip_text(branches.get('survey_result'), 1500)\n cve_related = _clip_text(branches.get('cve_related_result'), 1500)\n cve_info = _clip_text(branches.get('cve_info_result'), 1500)\n payload_analysis = _clip_text(branches.get('payload_analysis_result'), 1500)\n attack_analysis = _clip_text(attack_analysis_result, 1500)\n intel_brief = _format_intel_brief(intel_results)\n vuln_brief = _clip_text(json.dumps(vuln_info, ensure_ascii=False, indent=2), 1800) if vuln_info else '未查询到漏洞详情。'\n\n if attack_verdict == 'attack_success':\n recommendation = '立即核查目标资产是否产生异常文件、进程、账号或敏感数据访问记录,并按成功入侵事件升级处置。'\n elif attack_verdict == 'attack_failed':\n recommendation = '保留拦截与响应证据,复核同源后续请求,并确认防护策略是否持续生效。'\n elif attack_verdict == 'benign':\n recommendation = '作为低风险事件留痕,结合资产白名单或业务访问记录确认是否可降噪。'\n else:\n recommendation = '补齐目标 Web 日志、响应体、主机侧进程和文件证据后再确认攻击成功性。'\n\n return f'''\n\n\n# {title}\n\n\n\n- 研判结论:{verdict_cn}\n- 风险等级:{risk_level}\n- 告警类型:{parsed.get('alert_type', 'unknown')}\n- 源 IP:{parsed.get('src_ip', 'N/A')}:{parsed.get('src_port', 'N/A')}\n- 目标资产:{parsed.get('dst_ip', 'N/A')}:{parsed.get('dst_port', 'N/A')}\n- URL:{url}\n- 响应码:{status}\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该告警按 Web 日志处理,已提取 HTTP 请求、响应、源地址、目标资产、URL、响应码和 TDP 判定字段。\n\n### 2. 情报信息\n{intel_brief}\n\n### 3. 测绘信息\n{survey}\n\n### 4. 告警关联漏洞情报\n{cve_related}\n\n### 5. 攻击负载分析\n{payload_analysis}\n\n### 6. 攻击分析结果\n{attack_analysis}\n\n\n\n## 研判结论\n当前研判结论为 **{verdict_cn}**,风险等级为 **{risk_level}**。TDP 消息为:{threat_msg}\n\n\n\n## 攻击payload\n\n```http\n{payload}\n```\n\n\n\n## 具体含义解释\n\n1. 请求命中的告警类型为 {parsed.get('alert_type', 'unknown')}。\n2. 请求 URL 为 {url},需要结合参数、请求体和目标业务判断攻击意图。\n3. Payload 分析结果:{payload_analysis}\n\n\n\n## 响应证据\n\n```http\n{response}\n```\n\n响应码为 {status}。如果响应体未提供或没有执行成功证据,则不能仅凭请求侧 payload 判定攻击成功。\n\n\n\n## 重要证据\n\n1. 源地址:{parsed.get('src_ip', 'N/A')}:{parsed.get('src_port', 'N/A')}。\n2. 目标资产:{parsed.get('dst_ip', 'N/A')}:{parsed.get('dst_port', 'N/A')}。\n3. TDP 判定:{parsed.get('threat_result', '未提供')};TDP 消息:{threat_msg}。\n4. 漏洞详情:{vuln_brief}\n\n\n\n## 处置建议\n\n1. {recommendation}\n2. 检索同源 IP、同一 dedup_key、同一 URL 或同一漏洞特征的横向告警。\n3. 结合目标资产 Web 访问日志、主机审计、EDR 与 WAF 日志补齐证据链。\n\n\n'''\n\n\ndef _llm_triage_report_markdown(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict,\n report_title, risk_level):\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n context = json.dumps({\n 'report_title': report_title,\n 'attack_verdict': attack_verdict,\n 'verdict_cn': verdict_cn,\n 'risk_level': risk_level,\n 'alert': {\n 'alert_type': parsed.get('alert_type'),\n 'severity': parsed.get('severity'),\n 'src_ip': parsed.get('src_ip'),\n 'src_port': parsed.get('src_port'),\n 'dst_ip': parsed.get('dst_ip'),\n 'dst_port': parsed.get('dst_port'),\n 'url': parsed.get('url'),\n 'status': parsed.get('status'),\n 'threat_result': parsed.get('threat_result'),\n 'threat_msg': parsed.get('threat_msg'),\n 'payload': parsed.get('payload'),\n 'response': parsed.get('response'),\n },\n 'survey_result': branches.get('survey_result'),\n 'cve_related_result': branches.get('cve_related_result'),\n 'cve_info_result': branches.get('cve_info_result'),\n 'payload_analysis_result': branches.get('payload_analysis_result'),\n 'attack_analysis_result': attack_analysis_result,\n 'intel_results': intel_results,\n 'vuln_info': vuln_info,\n }, ensure_ascii=False, indent=2)\n\n prompt = f'''你是一名资深 SOC 告警研判分析师。请根据输入上下文,生成一份供前端直接渲染的 SOC 告警研判报告 markdown。\n\n硬性要求:\n1. 只输出带语义标签的 markdown,不要输出 JSON,不要解释规则。\n2. 根标签必须是 。\n3. 必须按顺序输出并完整闭合这些标签:\n 。\n4. 标签外不得输出正文内容。标签内可以使用 markdown 标题、列表、引用、代码块。\n5. 段落标题必须贴近前端展示模板:分析步骤、研判结论、攻击payload、具体含义解释、响应证据、重要证据、处置建议。\n6. 如果没有 HTTP 响应体或没有明确响应证据,不得判定为攻击成功;需要写明“当前日志未提供有效响应证据”。\n7. 不要编造输入中不存在的 IP、域名、URL、CVE、账号、文件路径或响应内容。\n8. 攻击 payload 和响应证据必须分别放在对应标签中,不要混淆请求与响应。\n\nFew-shot 示例 1:攻击成功\n\n\n\n# 敏感文件泄露攻击成功分析报告\n\n\n\n- 研判结论:攻击成功\n- 风险等级:High\n- 告警类型:敏感文件访问\n- 源 IP:203.0.113.10:42131\n- 目标资产:198.51.100.20:80\n- URL:http://example.com/api/.env\n- 响应码:200\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该日志包含 HTTP 请求路径、响应码和响应体,可用于判断敏感文件是否被返回。\n\n### 2. 情报信息\n源 IP 命中扫描源标签,风险高。\n\n### 3. 测绘信息\n目标为公网 Web 服务,存在敏感路径暴露风险。\n\n### 4. 告警关联漏洞情报\n该行为与环境变量文件泄露场景一致。\n\n### 5. 攻击负载分析\n攻击者直接请求 /api/.env,目标是读取环境变量配置。\n\n### 6. 攻击分析结果\n响应码为 200,响应体中出现 DB_PASSWORD,支持攻击成功。\n\n\n\n## 研判结论\n攻击者成功读取敏感配置文件,响应体中包含数据库密码字段,结论为攻击成功。\n\n\n\n## 攻击payload\n\n```http\nGET /api/.env HTTP/1.1\nHost: example.com\n```\n\n\n\n## 具体含义解释\n\n1. /api/.env 是常见环境变量文件路径。\n2. 攻击者通过 GET 请求尝试直接读取配置文件。\n3. 该路径若返回真实内容,通常意味着敏感文件暴露。\n\n\n\n## 响应证据\n\n```http\nHTTP/1.1 200 OK\n\nDB_PASSWORD=example-secret\n```\n\n响应体出现 DB_PASSWORD,证明敏感配置内容已被返回。\n\n\n\n## 重要证据\n\n1. 请求路径为 /api/.env。\n2. 响应码为 200。\n3. 响应体包含 DB_PASSWORD。\n\n\n\n## 处置建议\n\n1. 立即下线或限制敏感文件访问。\n2. 轮换可能泄露的密钥和数据库密码。\n3. 检索同源 IP 和同路径访问记录。\n\n\n\n\nFew-shot 示例 2:攻击失败\n\n\n\n# SQL注入攻击失败分析报告\n\n\n\n- 研判结论:攻击失败\n- 风险等级:Medium\n- 告警类型:SQL注入\n- 源 IP:203.0.113.44:51002\n- 目标资产:198.51.100.30:443\n- URL:https://shop.example.com/item?id=1\n- 响应码:403\n\n\n\n## 分析步骤\n\n### 1. 日志类型分析\n该日志包含请求参数和响应码,能够确认请求侧存在 SQL 注入尝试。\n\n### 2. 情报信息\n源 IP 暂无高置信恶意标签。\n\n### 3. 测绘信息\n目标为公网电商 Web 服务。\n\n### 4. 告警关联漏洞情报\n当前日志未提供可确认具体 CVE 的证据。\n\n### 5. 攻击负载分析\n请求参数中包含 union select,存在明显 SQL 注入意图。\n\n### 6. 攻击分析结果\n响应码为 403,响应体显示请求被阻断,不支持攻击成功。\n\n\n\n## 研判结论\n该请求存在 SQL 注入攻击意图,但响应显示被拒绝,当前判断为攻击失败。\n\n\n\n## 攻击payload\n\n```http\nGET /item?id=1 union select user HTTP/1.1\nHost: shop.example.com\n```\n\n\n\n## 具体含义解释\n\n1. union select 是典型 SQL 注入关键字组合。\n2. 攻击者尝试拼接查询以读取数据库用户信息。\n3. 该 payload 证明攻击意图,但不等同于成功执行。\n\n\n\n## 响应证据\n\n```http\nHTTP/1.1 403 Forbidden\n\nblocked by waf\n```\n\n响应状态和内容说明请求被拦截,未见数据泄露或执行成功证据。\n\n\n\n## 重要证据\n\n1. 请求参数包含 union select。\n2. 响应码为 403。\n3. 响应体显示 blocked by waf。\n\n\n\n## 处置建议\n\n1. 保留 WAF 拦截证据。\n2. 检查同源 IP 是否持续尝试其他注入 payload。\n3. 确认目标接口参数化查询和安全策略仍然有效。\n\n\n\n\n## 待研判上下文\n```json\n{context}\n```\n\n请输出最终报告:\n'''\n return _extract_tagged_triage_report(_ask_llm(prompt))\n\n\ndef _generate_triage_report(parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title):\n # Aggregate everything into tagged markdown for frontend rendering.\n # The markdown is returned through `triage_report` and is not written as a\n # per-alert file; leader/follower/cache-hit paths reuse the same field.\n verdict_cn = VERDICT_CN.get(attack_verdict, attack_verdict)\n risk_level = VERDICT_RISK.get(attack_verdict, 'Medium')\n\n if not report_title:\n report_title = f'{parsed.get(\"alert_type\", \"Web日志告警\")} - {verdict_cn}'\n\n try:\n triage_report = _llm_triage_report_markdown(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title, risk_level,\n )\n except Exception as e:\n print(f'[triage] WARNING: triage_report LLM generation failed: {e}')\n triage_report = ''\n\n if not _is_valid_triage_report(triage_report):\n print('[triage] WARNING: triage_report missing required semantic tags; using deterministic fallback')\n triage_report = _build_default_tagged_triage_report(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title, risk_level,\n )\n\n return triage_report, report_title, risk_level\n\n\ndef _triage_single_alert(alert):\n \"\"\"End-to-end inline triage for a single alert. Returns triage_fields dict.\n\n No file I/O — the full markdown report lives in the returned `triage_report` field\n and is broadcast to followers / persisted via `triage_cache.pkl`.\n \"\"\"\n parsed = _parse_alert(alert)\n intel_results, intel_content, vuln_info, vuln_content = _prepare_intel(parsed)\n\n branches = _parallel_4_branches(parsed, intel_content, vuln_content)\n\n attack_analysis_result = _llm_attack_analysis(parsed['log_text'])\n attack_verdict = _llm_attack_verdict(attack_analysis_result)\n report_title = _llm_report_title(parsed.get('alert_type', 'unknown'),\n attack_verdict, attack_analysis_result)\n\n triage_report, report_title, risk_level = _generate_triage_report(\n parsed, intel_results, vuln_info, branches,\n attack_analysis_result, attack_verdict, report_title,\n )\n\n return {\n 'attack_verdict': attack_verdict,\n 'risk_level': risk_level,\n 'report_title': report_title,\n 'triage_report': triage_report,\n 'attack_success': attack_verdict == 'attack_success',\n }\n\n\n# ── Main: leader/follower batch deduplication ────────────────────────────────\n# When the input batch contains multiple alerts sharing the same dedup_key\n# (e.g. upstream emits is_duplicate=True alerts in the same batch, or LSH\n# clustering produces several alerts per cluster), we only triage the LEADER\n# (first occurrence of each dedup_key). All FOLLOWERS in the same group reuse\n# the leader's triage result without invoking the LLM again.\n#\n# Work unit types:\n# ('dk', dedup_key, leader_idx) — group of 1+ alerts sharing dedup_key\n# ('nokey', None, alert_idx) — single alert with no dedup_key\n# (cannot be deduplicated, always triaged)\n\nenriched_alerts = list(inputs.get('enriched_alerts', []) or [])\nconcurrency = min(5, max(1, int(inputs.get('concurrency', 1))))\nmax_triage_cache_size = int(inputs.get('max_triage_cache_size', 100000))\nif max_triage_cache_size < 1:\n max_triage_cache_size = 100000\n\n# Group by dedup_key\ngroups = {} # dedup_key -> [alert_index, ...]\nno_key_indices = [] # alerts with no dedup_key\nfor i, a in enumerate(enriched_alerts):\n dk = a.get('dedup_key', '') if isinstance(a, dict) else ''\n if dk:\n groups.setdefault(dk, []).append(i)\n else:\n no_key_indices.append(i)\n\nwork_units = (\n [('dk', dk, group_indices[0]) for dk, group_indices in groups.items()]\n + [('nokey', None, idx) for idx in no_key_indices]\n)\nfollower_count = sum(len(v) - 1 for v in groups.values())\n\nprint(f'[triage] alerts={len(enriched_alerts)} '\n f'→ {len(groups)} unique dedup_keys ({follower_count} followers) + '\n f'{len(no_key_indices)} no-key alerts '\n f'= {len(work_units)} work units; outer_concurrency={concurrency} '\n f'(per-alert 4 LLM branches run in parallel)')\n\ncache_path, lock_path = _cache_paths()\nlock_fh = _acquire_lock(lock_path)\ntry:\n triage_cache_snapshot = _load_cache(cache_path)\nfinally:\n _release_lock(lock_fh)\n\n# Only warm up the LLM when at least one work unit may actually need it.\n# A unit \"may need\" the LLM if it's a no-key alert OR a dedup_key unit whose\n# entry is not in the cache snapshot. Pure cache-hit batches skip warm-up.\n_needs_llm = any(\n unit_type == 'nokey' or not _is_current_triage_fields(triage_cache_snapshot.get(dk))\n for unit_type, dk, _ in work_units\n)\nif _needs_llm:\n _warmup_llm()\n\nresults_lock = threading.Lock()\nnew_results = {} # dedup_key -> triage_fields (only for freshly computed leaders)\ngroup_outcomes = {} # dedup_key -> (triage_fields, source) for broadcasting to followers\nnokey_outcomes = {} # alert_idx -> (triage_fields, source)\nstats = {\n 'total': len(enriched_alerts),\n 'unique_dedup_keys': len(groups),\n 'followers_reused': follower_count,\n 'no_dedup_key_alerts': len(no_key_indices),\n 'work_units': len(work_units),\n 'cache_hit': 0,\n 'triaged': 0,\n 'triage_failed': 0,\n 'verdict_counts': {},\n 'cache_size_before': len(triage_cache_snapshot),\n 'cache_size_after': 0,\n 'evicted': 0,\n}\n\n\ndef _bump_verdict(verdict):\n with results_lock:\n stats['verdict_counts'][verdict] = stats['verdict_counts'].get(verdict, 0) + 1\n\n\n_UNKNOWN_TRIAGE = {\n 'attack_verdict': 'unknown',\n 'risk_level': 'Medium',\n 'report_title': '',\n 'triage_report': '',\n 'attack_success': False,\n}\n\n\ndef _process_unit(unit_type, dedup_key, leader_idx):\n \"\"\"Triage one unique work unit. Returns (key, triage_fields, source, ms, error).\"\"\"\n leader_alert = enriched_alerts[leader_idx]\n t0 = time.time()\n\n # 1) cache lookup for dedup_key units\n if unit_type == 'dk':\n cached = triage_cache_snapshot.get(dedup_key)\n if cached:\n if _is_current_triage_fields(cached):\n with results_lock:\n stats['cache_hit'] += 1\n _bump_verdict(cached.get('attack_verdict', 'unknown'))\n return dedup_key, cached, 'cache', 0, None\n print(f'[triage_cache] stale entry for dedup_key={dedup_key}: '\n 'missing current triage_report markdown; treating as cache miss')\n\n # 2) cache miss or no-key → run full inline triage on the leader\n try:\n triage_fields = _triage_single_alert(leader_alert)\n ms = round((time.time() - t0) * 1000)\n with results_lock:\n stats['triaged'] += 1\n if dedup_key:\n new_results[dedup_key] = triage_fields\n _bump_verdict(triage_fields.get('attack_verdict', 'unknown'))\n source = 'triaged' if unit_type == 'dk' else 'no_dedup_key_triaged'\n return (dedup_key if unit_type == 'dk' else leader_idx), triage_fields, source, ms, None\n except Exception as e:\n import traceback\n ms = round((time.time() - t0) * 1000)\n with results_lock:\n stats['triage_failed'] += 1\n _bump_verdict('unknown')\n err = str(e)[:500]\n print(f'[triage] leader_idx={leader_idx} FAILED: {e}\\n{traceback.format_exc()}')\n source = 'failed' if unit_type == 'dk' else 'no_dedup_key_failed'\n return (dedup_key if unit_type == 'dk' else leader_idx), dict(_UNKNOWN_TRIAGE), source, ms, err\n\n\nt_start = time.time()\nunit_completions = {} # key -> (triage_fields, source, ms, error)\nif work_units:\n with ThreadPoolExecutor(max_workers=concurrency, thread_name_prefix='stream_triage') as pool:\n futures = [pool.submit(_process_unit, *u) for u in work_units]\n for done_count, fut in enumerate(as_completed(futures), 1):\n try:\n key, triage_fields, source, ms, err = fut.result()\n unit_completions[key] = (triage_fields, source, ms, err)\n if source == 'cache':\n group_outcomes[key] = (triage_fields, source)\n elif source.startswith('no_dedup_key'):\n nokey_outcomes[key] = (triage_fields, source)\n else:\n group_outcomes[key] = (triage_fields, source)\n except Exception as e:\n print(f'[triage] WARNING: unexpected worker exception: {e}')\n if done_count % 5 == 0 or done_count == len(futures):\n print(f'[triage] progress {done_count}/{len(futures)} '\n f'(cache_hit={stats[\"cache_hit\"]} triaged={stats[\"triaged\"]} '\n f'failed={stats[\"triage_failed\"]})')\n\n# ── Apply outcomes back to every alert (broadcast leader → followers) ─────────\nenriched_with_triage = [None] * len(enriched_alerts)\nfor i, alert in enumerate(enriched_alerts):\n out = dict(alert) if isinstance(alert, dict) else {'_raw': alert}\n dk = alert.get('dedup_key', '') if isinstance(alert, dict) else ''\n out['has_dedup_key'] = bool(dk)\n\n if dk:\n triage_fields, source = group_outcomes.get(dk, (dict(_UNKNOWN_TRIAGE), 'failed'))\n is_leader = (groups.get(dk, [i])[0] == i)\n # All triage fields are pure data (no file-path references); broadcast as-is.\n for k, v in triage_fields.items():\n out[k] = v\n if not is_leader and source != 'cache':\n out['triage_source'] = 'follower_reused'\n out['triage_status'] = 'reused_from_leader'\n else:\n out['triage_source'] = source\n out['triage_status'] = 'cached' if source == 'cache' else (\n 'ok' if source == 'triaged' else 'failed'\n )\n completion = unit_completions.get(dk)\n if completion and is_leader:\n _, _, ms, err = completion\n if ms:\n out['triage_ms'] = ms\n if err:\n out['triage_error'] = err\n else:\n # no-key alerts: each is its own unit, keyed by alert idx\n triage_fields, source = nokey_outcomes.get(i, (dict(_UNKNOWN_TRIAGE), 'no_dedup_key_failed'))\n for k, v in triage_fields.items():\n out[k] = v\n out['triage_source'] = source\n out['triage_status'] = 'ok' if source == 'no_dedup_key_triaged' else 'failed'\n completion = unit_completions.get(i)\n if completion:\n _, _, ms, err = completion\n if ms:\n out['triage_ms'] = ms\n if err:\n out['triage_error'] = err\n\n enriched_with_triage[i] = out\n\nelapsed_ms = round((time.time() - t_start) * 1000)\nprint(f'[triage] all done in {elapsed_ms}ms: cache_hit={stats[\"cache_hit\"]} '\n f'triaged={stats[\"triaged\"]} failed={stats[\"triage_failed\"]} '\n f'followers_reused={stats[\"followers_reused\"]} '\n f'no_dedup_key={stats[\"no_dedup_key_alerts\"]}')\n\n# Persist new triage results back to cache (merge with concurrent writers).\nif new_results:\n lock_fh = _acquire_lock(lock_path)\n try:\n cache = _load_cache(cache_path)\n for k, v in new_results.items():\n if k in cache:\n del cache[k] # LRU touch (move to end on rewrite)\n cache[k] = v\n evicted = _evict_lru(cache, max_triage_cache_size)\n if evicted:\n print(f'[triage_cache] LRU eviction: dropped {evicted} entries (max={max_triage_cache_size})')\n _save_cache_atomic(cache_path, cache)\n stats['cache_size_after'] = len(cache)\n stats['evicted'] = evicted\n finally:\n _release_lock(lock_fh)\nelse:\n stats['cache_size_after'] = stats['cache_size_before']\n\ntriage_results = []\nfor a in enriched_with_triage:\n triage_results.append({\n 'dedup_key': a.get('dedup_key', ''),\n 'has_dedup_key': a.get('has_dedup_key', False),\n 'threat_name': a.get('threat_name', ''),\n 'sip': a.get('sip', ''),\n 'dip': a.get('dip', ''),\n 'is_duplicate': a.get('is_duplicate'),\n 'triage_source': a.get('triage_source', ''),\n 'triage_status': a.get('triage_status', ''),\n 'attack_verdict': a.get('attack_verdict', ''),\n 'risk_level': a.get('risk_level', ''),\n 'report_title': a.get('report_title', ''),\n 'triage_ms': a.get('triage_ms'),\n 'triage_error': a.get('triage_error'),\n })\n\nstats['elapsed_ms'] = elapsed_ms\nstats['concurrency'] = concurrency\nstats['max_triage_cache_size'] = max_triage_cache_size\n\n# Persist enriched_with_triage according to workflow config. Default is SOC DB;\n# JSONL is still available by config/input for downstream pipelines or archival.\noutput_cfg = _resolve_output_config()\nrun_id = (inputs.get('_run_id')\n or os.environ.get('FLOCKS_RUN_ID')\n or str(int(time.time() * 1000)))\noutput_paths = []\noutput_dir = ''\nfirst_seen_soc_alerts, soc_db_filter_stats = _select_first_seen_soc_alerts(\n enriched_with_triage,\n)\nsoc_db_result = {\n 'path': output_cfg.get('soc_db_path', ''),\n 'table': 'alert_records',\n 'rows': 0,\n 'inserted_rows': 0,\n 'updated_rows': 0,\n}\n\nif output_cfg['write_soc_db'] and first_seen_soc_alerts:\n try:\n soc_db_result.update(_triage_write_soc_db(\n output_cfg['soc_db_path'], first_seen_soc_alerts, run_id,\n ))\n print(f'[triage] persisted {soc_db_result.get(\"rows\", 0)} globally unique alerts to '\n f'SOC DB {soc_db_result.get(\"path\")} '\n f'(inserted={soc_db_result.get(\"inserted_rows\", 0)}, '\n f'updated={soc_db_result.get(\"updated_rows\", 0)}); '\n f'filter={soc_db_filter_stats}')\n except Exception as e:\n import traceback\n print(f'[triage] ERROR: failed to persist triage results to SOC DB: {e}\\n{traceback.format_exc()}')\n raise\nelif output_cfg['write_soc_db']:\n print(f'[triage] no verified first-seen unique alerts to persist; filter={soc_db_filter_stats}')\nelse:\n print(f'[triage] SOC DB output disabled by triage_output_mode={output_cfg[\"requested_mode\"]!r}')\n\nif output_cfg['write_jsonl'] and enriched_with_triage:\n try:\n output_dir = _triage_output_dir(output_cfg.get('jsonl_output_dir', ''))\n output_paths = _triage_write_jsonl(\n output_dir, enriched_with_triage, run_id, stats,\n )\n print(f'[triage] wrote {len(enriched_with_triage)} enriched alerts to '\n f'{len(output_paths)} JSONL file(s) under {output_dir}')\n for p in output_paths:\n print(f' → {p}')\n except Exception as e:\n import traceback\n print(f'[triage] WARNING: failed to persist triage_result JSONL: {e}\\n{traceback.format_exc()}')\nelif not output_cfg['write_jsonl']:\n print(f'[triage] JSONL output disabled by triage_output_mode={output_cfg[\"requested_mode\"]!r}')\n\nstats['output_mode'] = output_cfg['mode']\nstats['requested_output_mode'] = output_cfg['requested_mode']\nstats['output_config_path'] = output_cfg['config_path']\nstats['soc_db_path'] = soc_db_result.get('path', '')\nstats['soc_db_rows'] = soc_db_result.get('rows', 0)\nstats['soc_db_inserted_rows'] = soc_db_result.get('inserted_rows', 0)\nstats['soc_db_updated_rows'] = soc_db_result.get('updated_rows', 0)\nstats['soc_db_first_seen_rows'] = soc_db_filter_stats['first_seen_rows']\nstats['soc_db_skipped_rows'] = (\n soc_db_filter_stats['input_rows'] - soc_db_filter_stats['first_seen_rows']\n)\nstats['soc_db_filter_stats'] = soc_db_filter_stats\nstats['output_paths'] = output_paths\nstats['output_dir'] = output_dir\n\nprint(f'[triage] stats={json.dumps(stats, ensure_ascii=False)}')\n\noutputs['enriched_alerts_with_triage'] = enriched_with_triage\noutputs['triage_results'] = triage_results\noutputs['triage_stats'] = stats\noutputs['load_stats'] = inputs.get('load_stats', {})\noutputs['loaded_files'] = inputs.get('loaded_files', [])\noutputs['input_date'] = inputs.get('input_date', '')\noutputs['triage_output_mode'] = output_cfg['mode']\noutputs['soc_db_result'] = soc_db_result\noutputs['soc_db_path'] = soc_db_result.get('path', '')\noutputs['output_config_path'] = output_cfg['config_path']\noutputs['output_paths'] = output_paths\noutputs['output_dir'] = output_dir\n" }, { "id": "summarize", @@ -47,7 +47,7 @@ "_comment_dedup": "同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。", "_comment_cache": "研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 stream_alert_denoise 生成的 MD5(strict_fields + lsh_cluster_id)。", "triage_output_mode": "soc_db", - "_comment_output": "默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。" + "_comment_output": "默认只接受明确 is_duplicate=false、包含 dedup_key 且批内首次出现的告警,并由 soc.db 保证 dedup_key 跨执行全局唯一;如需 JSONL,设置 triage_output_mode=jsonl 或 both。" } }, "triggers": [ @@ -70,7 +70,7 @@ "_comment_dedup": "同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。", "_comment_cache": "研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 stream_alert_denoise 生成的 MD5(strict_fields + lsh_cluster_id)。", "triage_output_mode": "soc_db", - "_comment_output": "默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。" + "_comment_output": "默认只接受明确 is_duplicate=false、包含 dedup_key 且批内首次出现的告警,并由 soc.db 保证 dedup_key 跨执行全局唯一;如需 JSONL,设置 triage_output_mode=jsonl 或 both。" }, "concurrency": { "policy": "allow", @@ -93,7 +93,7 @@ "_comment_dedup": "同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。", "_comment_cache": "研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 stream_alert_denoise 生成的 MD5(strict_fields + lsh_cluster_id)。", "triage_output_mode": "soc_db", - "_comment_output": "默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。" + "_comment_output": "默认只接受明确 is_duplicate=false、包含 dedup_key 且批内首次出现的告警,并由 soc.db 保证 dedup_key 跨执行全局唯一;如需 JSONL,设置 triage_output_mode=jsonl 或 both。" }, "headers": {}, "query": {} diff --git a/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.md b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.md index 11d62c43a..7f9dd0bd9 100644 --- a/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.md +++ b/.flocks/flockshub/plugins/workflows/stream_alert_triage/workflow.md @@ -51,7 +51,7 @@ load_dedup_file -> concurrent_triage -> summarize | 顺序 | 节点 | 做什么 | 下一步 | | --- | --- | --- | --- | | 1 | load_dedup_file | 一次性读取 stream_alert_denoise 写入的 JSONL 文件。输入优先级:input_paths > input_path > input_date(自动遍历该日所有 dedup_result_*.jsonl)> 当日默认。跳过 file_header 行,输出 enriched_alerts (list[dict])。 | concurrent_triage | -| 2 | concurrent_triage | Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。所有 enriched_with_triage alerts 默认写入 `~/.flocks/data/soc.db` 的 `alert_records` 表;可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。 | summarize | +| 2 | concurrent_triage | Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。SOC DB 持久化只接受明确 `is_duplicate=false`、包含 `dedup_key` 且批内首次出现的告警,并通过数据库唯一索引保证跨执行全局唯一;重复 key 只更新研判字段并保留首次事件元数据,持久化失败会使工作流失败。可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。 | summarize | | 3 | summarize | 汇总输出:写 pipeline_summary.md 到 ~/.flocks/workspace/outputs//artifacts/,暴露 top-risk 告警的 verdict/title/triage_report 作为工作流的 final outputs。 | 工作流最终输出 | 编辑流程结构时,要同时确认节点顺序、边关系、字段映射和最终输出是否仍然一致。 @@ -68,7 +68,7 @@ load_dedup_file -> concurrent_triage -> summarize - max_triage_cache_size: 100000 - persist_triage_output: false - triage_output_mode: soc_db -- _comment_output: 默认写入 ~/.flocks/data/soc.db;如需 JSONL,设置 triage_output_mode=jsonl 或 both;旧参数 persist_triage_output=true 仍会额外写 JSONL。 +- _comment_output: 默认只接受明确 is_duplicate=false、包含 dedup_key 且批内首次出现的告警,并由 soc.db 保证 dedup_key 跨执行全局唯一;如需 JSONL,设置 triage_output_mode=jsonl 或 both。 - _comment_dedup: 同批次内多条 alert 共享 dedup_key 时只 LLM 研判 1 次(leader),其余 follower 直接复用结果;跨批次/跨进程的复用由 triage_cache.pkl 提供。 - _comment_cache: 研判缓存位于 ~/.flocks/workspace/workflows/stream_alert_triage/triage_cache.pkl,FIFO LRU,文件锁 + 原子落盘,可跨进程/跨执行复用。dedup_key 即 str... @@ -96,7 +96,7 @@ load_dedup_file -> concurrent_triage -> summarize ### 4.2 concurrent_triage -职责: Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。所有 enriched_with_triage alerts 默认写入 `~/.flocks/data/soc.db` 的 `alert_records` 表;可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。 +职责: Leader/follower 分组并发研判节点(自包含,内联 tdp_alert_triage 逻辑)。先按 dedup_key 把 alerts 分组:每组只对 leader 研判,follower 复用 leader 结果。外层 ThreadPoolExecutor(concurrency) 处理 unique work units(concurrency 取值 1–5,默认 1),内层 ThreadPoolExecutor(4) 并行 survey / cve_related / cve_info / payload_analysis。dedup_key 在 triage_cache.pkl 命中时直接复用历史 verdict/title/triage_report;未命中则 leader 执行完整研判(情报查询 + 4 并行 LLM + attack_analysis + verdict + title + 聚合 markdown),完整研判 markdown 仅写入 alert 的 `triage_report` 字段,**不生成任何独立报告文件**。新结果合并写回 cache(FIFO LRU + 文件锁 + 原子落盘)。SOC DB 持久化只接受明确 `is_duplicate=false`、包含 `dedup_key` 且批内首次出现的告警,并通过数据库唯一索引保证跨执行全局唯一;重复 key 只更新研判字段并保留首次事件元数据,持久化失败会使工作流失败。可通过工作流目录 `config.json` 或运行输入将 `triage_output_mode` 切换为 `jsonl` / `both` / `none`,保留 `triage_result_NNN.jsonl` 可选输出。 - 节点类型: Python - 输入来源: load_dedup_file diff --git a/.flocks/plugins/skills/browser-use/SKILL.md b/.flocks/plugins/skills/browser-use/SKILL.md index f409db0ab..b993f4e6e 100644 --- a/.flocks/plugins/skills/browser-use/SKILL.md +++ b/.flocks/plugins/skills/browser-use/SKILL.md @@ -79,7 +79,7 @@ flocks browser --doctor 2. `cdp-headless` 是唯一例外:先读取 `references/cdp-headless.md` 完成浏览器启动与连接,再读取 `references/cdp-direct.md` 执行通用页面操作。 3. 在 `cdp-headless` 中,如果当前任务自己启动了专用浏览器实例,必须记录 PID / 日志 / 专用 profile,并只在任务结束或明确放弃后清理自己启动的实例;不要关闭用户提供的远程浏览器。 4. 不要同时加载 `references/cdp-direct.md` 和 `references/agent-browser.md`。 -5. `flocks browser` 的 daemon 文件固定放在 `~/.flocks/browser/`,例如 `bu.sock`、`bu.log`、`bu.pid`、`bu.port`。 +5. `flocks browser` 的 daemon 文件默认放在 `~/.flocks/browser/`(设置 `FLOCKS_ROOT` 时改为 `$FLOCKS_ROOT/browser/`)。默认 session 使用 `bu.*`;命名 session 使用 `bu-.*`。`.lock` 是启动互斥文件,可能在 daemon 退出后继续存在,不要手工删除 socket、port、PID 或 lock 文件,应使用 `flocks browser --reload`。 6. 基础操作能力(打开、观察、点击、输入、滚动、截图、提取、等待、关闭)优先按 `references/cdp-direct.md` 的“基础操作速查”执行 ## 产品经验Skill diff --git a/.flocks/plugins/skills/browser-use/references/cdp-headless.md b/.flocks/plugins/skills/browser-use/references/cdp-headless.md index 9c6852709..4439611f8 100644 --- a/.flocks/plugins/skills/browser-use/references/cdp-headless.md +++ b/.flocks/plugins/skills/browser-use/references/cdp-headless.md @@ -164,7 +164,7 @@ macOS / Linux: ```bash export BU_CDP_URL="http://127.0.0.1:$BU_HEADLESS_PORT" -rm -f "$HOME/.flocks/browser/bu.sock" +flocks browser --reload flocks browser --setup flocks browser -c 'print(page_info())' ``` @@ -188,7 +188,7 @@ flocks browser -c 'print(page_info())' - `flocks browser` daemon 生命周期 - 专用 headless 浏览器进程生命周期 -两者不是同一个东西。`restart_daemon()` 只会重启 daemon,不会替你重启或关闭 headless 浏览器。 +两者不是同一个东西。`restart_daemon()` / `flocks browser --reload` 会安全停止 daemon,并由下一次 browser 命令重新拉起;它们不会替你重启或关闭 headless 浏览器进程。 推荐约定: @@ -227,6 +227,6 @@ fi - 这种场景必须显式设置 `BU_CDP_WS` 或 `BU_CDP_URL`,让 `flocks browser` 直连你启动的 headless 实例 - 如果 `9222` 已被其他浏览器实例占用,不要复用它;改用新的专用端口,并同步更新浏览器启动参数与 `BU_CDP_URL` / `BU_CDP_WS` - 如果显式切换到了新的 `BU_CDP_URL` / `BU_CDP_WS`,但当前 `BU_NAME` 下还有旧 daemon,优先让 `flocks browser --setup` 重新建连;若仍异常,再执行 `flocks browser -c 'restart_daemon()'` -- `flocks browser` 的 daemon 文件固定放在 `~/.flocks/browser/`,例如 `bu.sock`、`bu.log`、`bu.pid`、`bu.port` -- 当 daemon 已经死掉但 POSIX socket 文件还留在 `~/.flocks/browser/` 时,再手动删除 `~/.flocks/browser/bu.sock`;Windows 不需要这一步 +- daemon 文件默认位于 `~/.flocks/browser/`;默认 session 使用 `bu.*`,命名 session 使用 `bu-.*`,并各自持有 `.lock` +- 不要手工删除 socket、port、PID 或 lock 文件;daemon 异常或 endpoint 切换时使用 `flocks browser --reload`,它会按身份和锁状态安全停止或清理 - 如果失败并出现 `HTTP 403`,优先回头检查 headless Chrome 是否带了 `--remote-allow-origins=*` diff --git a/.flocks/plugins/skills/onesig-use/SKILL.md b/.flocks/plugins/skills/onesig-use/SKILL.md index e1d39c563..9a4eafcaf 100644 --- a/.flocks/plugins/skills/onesig-use/SKILL.md +++ b/.flocks/plugins/skills/onesig-use/SKILL.md @@ -1,80 +1,79 @@ --- name: onesig-use -description: 用于处理 OneSIG(安全互联网网关 / Secure Internet Gateway)相关任务,适合通过 API 或者结合浏览器进行以下任务:威胁监控(仪表盘、防护大屏、失陷主机、入站/出站威胁事件、报告管理)、防护策略(全局白/黑名单、多维封锁、IPS、HTTP 黑名单、高危端口防护、API 联动、Syslog 自动封禁、FTP/SFTP 联动)、资产管理、平台管理(告警/审计/用户、HTTPS 解密、网口路由 DNS、HA、OneCC、设备升级与备份、license、MDR、诊断)、登录会话与改密、帮助文档。只要用户提到 OneSIG、SIG、安全互联网网关、微步互联网网关等相关操作时,必须先加载本 skill。本 skill 是 OneSIG 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `onesig_*` tool。 +description: 用于处理 OneSIG(安全互联网网关 / Secure Internet Gateway)相关任务,当前项目内优先适配 OneSIG Strategy API v2.5.3(`onesig_strategy_api_query` / `onesig_strategy_api_ops`):设备状态、资产、策略、全局白名单、全局黑名单、封禁白名单、HTTP 黑名单的查询与写操作。只要用户提到 OneSIG、SIG、安全互联网网关、微步互联网网关等相关操作时,必须先加载本 skill。本 skill 是 OneSIG 平台操作的唯一决策入口:在未阅读本 skill 并完成模式判断前,不要直接调用任何 `onesig_*` tool,也不要把 OneSEC 的调用约定套用到 OneSIG。 --- # OneSIG Use ## First -操作模式 API V.S Browser +先判断操作模式:API V.S Browser。 ### 何时使用 API -- 默认模式,默认使用 API -- !!! important: 如果已经进入了浏览器模式,就不要走 API 了 -- 查询类请求与处置 / 配置写入类请求要严格区分;用户没有明确要求执行写操作时,默认只使用只读查询能力 +- 默认先判断当前诉求是否被新版 Strategy API 覆盖;覆盖时优先使用 API +- 查询类请求与配置写入类请求必须严格区分;用户没有明确要求写操作时,默认只使用只读查询能力 +- 当前项目内最新工具包是 `.flocks/plugins/tools/device/onesig_v2_5_3`,只暴露两个 grouped tool: + - `onesig_strategy_api_query`:只读查询,`requires_confirmation=false` + - `onesig_strategy_api_ops`:写操作,`requires_confirmation=true` + +当前 Strategy API 覆盖的能力: + +- 设备状态:平台状态、系统状态、网络状态 +- 资产:资产组、资产列表、资产类型,以及资产 / 资产组的新增、更新、删除 +- 防护策略:策略列表、策略更新、策略删除 +- 全局白名单、全局黑名单、封禁白名单 +- HTTP 黑名单 ### 何时使用浏览器 -- 现有 API 没有覆盖目标能力 -- 未检测到对应 API 工具 -- API 当前不可用,例如未配置、未开通、无权限、认证失败、Cookie 过期、SSL 验证失败或服务不可达 +- 当前 Strategy API 没有覆盖目标能力,例如威胁监控、仪表盘、失陷主机、入站 / 出站事件、报告、IPS、Syslog 自动封禁、FTP/SFTP 联动、高危端口防护、用户管理、HTTPS 解密、网口 / 路由 / DNS、HA、OneCC、升级备份、license、MDR、诊断、帮助文档 +- API 工具未检测到、未配置、无权限、ApiKey / Secret 缺失、认证失败、SSL 验证失败或服务不可达 - 任务必须查看页面级详情、攻击链、威胁图、报表预览或人工确认弹窗 - 页面需要人工登录、图形验证码、TOTP、强制改密或页面级确认 - 用户明确要求使用浏览器,或者已经在浏览器操作过程中 ### 请求确认 -除非是用户要求使用浏览器,否则提示用户 API 不可用,请检查 API 配置或直接使用浏览器模式。 +除非用户要求使用浏览器,否则如果 API 不可用,应提示用户检查 OneSIG Strategy API 配置,或确认是否改用浏览器模式。 当确定操作模式后: -- API 模式:请阅读 API 模式使用指南 -- 浏览器模式:请阅读浏览器模式使用指南 +- API 模式:必须阅读 API 模式使用指南 +- 浏览器模式:必须阅读浏览器模式使用指南 ## API 模式使用指南 -OneSIG 一共 6 个 grouped tool,按用户语义分流: +必须阅读: -- 用户说"仪表盘""威胁防护大屏""失陷主机""入站/出站威胁事件""设备状态""报告""导出报表"时,优先走 `onesig_monitoring` -- 用户说"白名单""黑名单""多维封锁""API 联动密钥""Syslog 自动封禁""FTP/SFTP 联动""IPS 规则""HTTP 黑名单""端口防护组""一键 bypass""防护策略"时,优先走 `onesig_strategy` -- 用户说"资产""资产组""资产类型""资产导入/导出"时,优先走 `onesig_assets` -- 用户说"告警/通知配置""管理日志/审计""用户管理/登录策略""HTTPS 解密""网口/部署引导""路由 / DNS / 代理""HA 高可用""集中管控/OneCC""设备升级/重启/备份/恢复""日志外发""license""MDR""coredump""pcap""设备诊断"时,优先走 `onesig_device` -- 用户说"登录""退出""改密""账户信息""图形验证码""TOTP""恢复码""产品动态红点"时,优先走 `onesig_login` -- 用户说"帮助文档""产品版本""提交问题反馈"时,优先走 `onesig_helper` +各 grouped tool 与 action 的详细说明、最小调用示例、失败处理见 [references/api-reference.md](references/api-reference.md)。 -OneSIG 与 OneSEC 在调用约定上有几个**关键差异**,agent 不要混用: +核心调用约定: -- 时间字段是 `startTime` / `endTime`(不是 OneSEC 的 `time_from` / `time_to`),单位 **Unix 秒** -- 分页字段是 `pageNo` / `pageSize`(不是 `cur_page` / `page_size`,也不是 `page_items_num`) -- OneSIG 主要走 **Cookie 会话** + RSA-OAEP 加密密码登录,处理器会自动登录、会话过期会自动重登;只有当设备启用了图形验证码或 TOTP 时才需要显式调 `onesig_login` 的 `login` 动作,并把 `captcha` / `totp` 作为入参传入 -- 每个 grouped tool 的 action 除了 `action` 都按业务键平铺;POST 类 action 多数走 passthrough body,把所有筛选 / 分页字段直接放在请求体顶层即可 +- `onesig_strategy_api_query` 用于所有只读查询 +- `onesig_strategy_api_ops` 用于所有写操作;调用前必须确认影响范围 +- 入参优先使用 `{ "action": "...", "body": { ... } }` +- Strategy API 认证是 `ApiKey + Secret` 的 HMAC-SHA1 签名,handler 会自动生成 `apikey` / `timestamp` / `sign`,agent 不要手工拼签名 +- Strategy API 走 `/api/v3/...` 路径;不要混用旧控制台 Cookie 版 `/v3/...` 工具说明 +- 分页字段使用 `pageNo` / `pageSize`;不要传 OneSEC 的 `cur_page` / `page_size` / `page_items_num` +- 当前 Strategy API action 没有 OneSEC 风格的 `time_from` / `time_to`;不要把 OneSEC 时间参数套进 OneSIG +- 查询单条或写操作前,如果缺少业务 ID,应先调用对应 list action 获取主键,再执行下一步 高风险写操作要特别谨慎,例如: -- 设备级动作:`device_onekey_bypass` / `device_quick_bypass`(流量直通)、`device_reboot` / `device_shutdown` / `device_reinit`、`device_upgrade` / `device_upgrade_upload` / `system_upgrade` -- 用户与口令:`user_create` / `user_delete` / `user_update` / `user_secret_reset`、`change_password`、`regenerate_recovery_code` -- 黑白名单与封禁:`whitelist_*` / `blacklist_*` 系列写操作、`multiblock_rule_create` / `multiblock_rule_update` / `multiblock_rule_delete` / `multiblock_rule_active`、`auto_blacklist_create` / `auto_blacklist_update` / `auto_blacklist_delete` -- IPS / HTTP / 端口防护:`ips_rule_create` / `ips_rule_apply` / `ips_rule_all`、`ips_ruleset_create` / `ips_ruleset_update` / `ips_ruleset_delete`、`http_blacklist_*` 写操作、`port_protect_group_*` / `port_protect_port_*` 写操作、`protection_policy_update` / `protection_policy_delete` -- HTTPS 解密:`tls_decrypt_policy_create` / `_update` / `_enable` / `_delete` / `_batch`、`tls_cert_create` / `_update` / `_delete` / `_set_default`、`tls_detect_delete` / `set_decrypt_config` -- 网口 / 路由 / 网络:`interface_update`(涉及启停时需 password)、`interface_*_create/update/delete`(virtualLine / listen / bridge)、`route_static_*` / `ipv6_route_static_*` 写操作、`set_dns_config` / `set_proxy_config`、`hosts_create/update/delete` -- 高可用 / 集中管控:`set_ha_config` / `ha_switching` / `ha_sync_config`、`set_onecc_config` / `set_onecc_status` -- 备份与升级:`backup_create` / `backup_recover` / `backup_delete` / `backup_import` / `backup_update`、`device_download_package` -- 离线情报库 / 授权:`basic_information_import` / `basic_information_enable`、`basic_license_upload`、`mdr_service_enable` -- 诊断写操作:`device_pcap_set`、`device_coredump_delete` / `device_pcap_file_delete` -- 报表与日志外发:`report_form_create` / `report_form_delete`、`report_task_create` / `_update` / `_delete` / `_test`、`logaccess_create` / `_update` / `_delete`、`set_dnslog_config`、`set_clean_config` / `set_storage_config` -- API Key 与密钥:`apikey_create` / `apikey_update` / `apikey_delete`、`apikey_secret`(会回吐明文 secret,需当前用户密码二次校验) - -必须阅读: - -各 grouped tool 与 action 的详细说明、必填参数、最小调用示例见 [references/api-reference.md](references/api-reference.md)。 +- `asset_group_create` / `asset_group_update` / `asset_group_delete` +- `asset_create` / `asset_update` / `asset_delete` +- `protection_policy_update` / `protection_policy_delete` +- `whitelist_create` / `whitelist_update` / `whitelist_delete` / `whitelist_remove` +- `blacklist_create` / `blacklist_update` / `blacklist_delete` / `blacklist_remove` +- `banned_whitelist_create` / `banned_whitelist_update` / `banned_whitelist_delete` +- `http_blacklist_create` / `http_blacklist_update` / `http_blacklist_enable` / `http_blacklist_delete` ## 浏览器模式使用指南 -- ⚠️ 如果 OneSIG 设备的访问地址不清楚,请先询问用户,不要擅自填写域名。 -- ⚠️ 用 `--headed` 打开浏览器,人工完成登录(OneSIG 多数部署启用了图形验证码 / TOTP / 强制改密策略)。 -- ⚠️ OneSIG 控制台与 OneSEC / 青藤是不同产品;不要把 OneSEC 的页面路径或 OneSEC 的 API 套用到 OneSIG。 +- 如果 OneSIG 设备的访问地址不清楚,请先询问用户,不要擅自填写域名 +- 用 `--headed` 打开浏览器,人工完成登录(OneSIG 部署可能启用了图形验证码 / TOTP / 强制改密策略) +- OneSIG 控制台与 OneSEC / 青藤是不同产品;不要把 OneSEC 的页面路径或 OneSEC 的 API 套用到 OneSIG 只要进入浏览器模式,就请阅读并按照 browser-workflow 操作,不要直接跳过本 skill 去套用其他通用浏览器 skill。 diff --git a/.flocks/plugins/skills/onesig-use/references/api-reference.md b/.flocks/plugins/skills/onesig-use/references/api-reference.md index 80bb8316c..9fc3b6c82 100644 --- a/.flocks/plugins/skills/onesig-use/references/api-reference.md +++ b/.flocks/plugins/skills/onesig-use/references/api-reference.md @@ -1,695 +1,318 @@ -# OneSIG API 调用指南 +# OneSIG Strategy API 调用指南 -OneSIG 当前的 6 个 grouped tool(`onesig_login` / `onesig_assets` / `onesig_device` / `onesig_helper` / `onesig_monitoring` / `onesig_strategy`)都遵循"`action` + 业务键平铺"的调用模式。处理器会自动登录、自动续会话、自动按需做 RSA-OAEP 密码加密,所以业务调用通常**不需要**先单独调登录接口。 +当前项目内最新 OneSIG 工具包来自 `.flocks/plugins/tools/device/onesig_v2_5_3`,它不是旧控制台 Cookie 登录版的 6 个 grouped tool,而是 OneSIG v2.5.3 第三方 Strategy API 接入: + +- `onesig_strategy_api_query`:只读查询 +- `onesig_strategy_api_ops`:写操作 + +这两个工具使用 `ApiKey + Secret` 做 HMAC-SHA1 签名,调用 `/api/v3/...` 接口。不要把旧控制台插件的 `onesig_monitoring` / `onesig_strategy` / `onesig_assets` / `onesig_device` / `onesig_login` / `onesig_helper` 文档套到当前 Strategy API 工具上。 ## 先看这张路由表 -| 用户意图 | 推荐 tool | 推荐 action | 必备参数 | +| 用户意图 | 推荐 tool | 推荐 action | body 说明 | |---|---|---|---| -| 看仪表盘总览 / 出入站 / 零日 | `onesig_monitoring` | `dashboard_overview` / `dashboard_outbound` / `dashboard_inbound` / `dashboard_zeroday` | 通常空参,部分需 `startTime`/`endTime` | -| 看威胁防护大屏(事件/资产/趋势/占比) | `onesig_monitoring` | `overview_*` 系列 | 多数必填 `startTime`+`endTime`,部分要 `incIntervalSec` 或 `interval` | -| 看入站/出站威胁事件列表与详情 | `onesig_monitoring` | `event_inbound_*` / `event_outbound_*` | 列表与详情类必填 `startTime`+`endTime` | -| 看失陷主机统计 / 列表 / 详情 | `onesig_monitoring` | `alert_host_*` | 列表与详情类必填 `startTime`+`endTime`,详情类还要 `source` | -| 看 / 改报表 | `onesig_monitoring` | `report_form_*` / `report_task_*` | 下载需 `uniqueId`+`fileName`,删除需 `uniqueId` | -| 看设备状态(CPU/内存/网口/平台) | `onesig_monitoring` | `device_platform_status` / `device_system_status` / `device_network_status` / `common_interface_list` / `basic_cpu_attr` | `device_system_status` 必填 `time` | -| 改 / 查白黑名单 | `onesig_strategy` | `whitelist_*` / `blacklist_*` | 列表用 `*_list`,删除用 `uniqueIds` | -| 多维封锁规则 | `onesig_strategy` | `multiblock_rule_*` / `multiblock_executelog_*` | 单条规则用 `name` 做主键 | -| API 联动密钥 | `onesig_strategy` | `apikey_*` | `apikey_secret` 必填 `key`+`password` | -| Syslog 自动封禁 | `onesig_strategy` | `auto_blacklist_*` | `auto_blacklist_trend`/`_sample` 必填 `srcIp`(+组合键) | -| FTP/SFTP 联动 | `onesig_strategy` | `linkage_*` | `linkage_info` 必填 `uniqueId` | -| IPS 规则 / 规则集 | `onesig_strategy` | `ips_rule_*` / `ips_ruleset_*` / `ips_threat_types` | 单条规则集查询用 `name`,引用关系用 `ruleId`+`assetIp` | -| HTTP 黑名单 / 高级 / XFF | `onesig_strategy` | `http_blacklist_*` / `*_advanced_config` / `*_xff_config` | 列表 `_list`、写操作走 `_create` / `_update` / `_delete` | -| 高危端口防护 | `onesig_strategy` | `port_protect_group_*` / `port_protect_port_*` | 端口列表 `port_protect_port_list` 必填 `groupName`+`pageNo`+`pageSize` | -| 防护策略首页 | `onesig_strategy` | `protection_policy_*` / `device_onekey_bypass` | `protection_policy_get` 必填 `uniqueId` | -| 资产 / 资产组 / 资产类型 | `onesig_assets` | `asset_*` / `asset_group_*` / `asset_type_*` / `common_asset_group_tree` | 资产用 `uniqueId`(删除还需 `password`);资产组写操作用 `uid`(新增是 `pid`+`name`);导入用 `file_path` | -| 告警 / 通知策略 | `onesig_device` | `alert_policy_*` / `*_notice_config` / `test_email/syslog/webhook` | `alert_policy_find_by_config` 必填 `search`+`type` | -| 管理日志(审计) | `onesig_device` | `aclog_*` / `*_clean_config` | 列表 / 导出常需 `startTime`+`endTime`;删除需 `password` | -| 用户与登录策略 | `onesig_device` | `user_*` / `*_login_config` | 改密 / 删除 / 重置密码需 `password`(自动 RSA 加密) | -| HTTPS 解密 | `onesig_device` | `tls_decrypt_policy_*` / `tls_cert_*` / `tls_detect_*` | TLS 详情必填 `server`+`port`+`orderBy`+`sortBy` | -| 网口 / 部署引导 | `onesig_device` | `interface_*` | `interface_select_list` 必填 `workMode` | -| 路由(v4/v6) | `onesig_device` | `route_*` / `ipv6_route_*` | 写操作要带完整路由项 | -| DNS / 代理 / 网络测试 | `onesig_device` | `*_dns_config` / `hosts_*` / `*_proxy_config` / `test_network` / `test_proxy` | hosts 写操作走 create/update/delete | -| 高可用 HA | `onesig_device` | `ha_*` / `*_ha_config` | `ha_sync_status` 必填 `syncId`(前置 `ha_sync_config` 返回) | -| 集中管控 OneCC | `onesig_device` | `*_onecc_config` / `onecc_status` / `set_onecc_status` / `test_onecc` | 写操作前先看 status | -| 设备升级 / 重启 / 备份 / 恢复 | `onesig_device` | `device_upgrade*` / `device_reboot/shutdown/reinit` / `backup_*` / `system_upgrade` | 升级 / 上传备份用 `file_path`,敏感写操作需 `password` | -| 日志外发 | `onesig_device` | `logaccess_*` | 列表必填 `pageNo`+`pageSize`+`type`;样例必填 `srcIp`+`protocol`+`type` | -| 基本信息 / license / MDR | `onesig_device` | `basic_*` / `mdr_service_*` | license / 离线情报库导入用 `file_path` | -| 设备诊断 | `onesig_device` | `device_coredump_*` / `device_pcap_*` | coredump/pcap 删除走 DELETE | -| 帮助文档 / 产品反馈 | `onesig_helper` | `document_list` / `document_preview` / `product_*` | `document_preview` 必填 `id`(来自 `document_list`) | -| 登录 / 退出 / 改密 / 账户 | `onesig_login` | `login` / `logout` / `change_password` / `get_account` 等 | 改密必填 `new_password`,启用 captcha/TOTP 时 `login` 要传 `captcha`/`totp` | +| 查平台运行状态 | `onesig_strategy_api_query` | `platform_status` | 通常空对象 | +| 查系统状态 | `onesig_strategy_api_query` | `system_status` | 通常空对象 | +| 查网络状态 | `onesig_strategy_api_query` | `network_status` | 通常空对象 | +| 查资产组 | `onesig_strategy_api_query` | `asset_group_list` | GET 类接口,通常空对象 | +| 查资产列表 | `onesig_strategy_api_query` | `asset_list` | 可传 `pageNo`、`pageSize`、`search` 等厂商文档字段 | +| 查资产类型 | `onesig_strategy_api_query` | `asset_type_list` | GET 类接口,通常空对象 | +| 查防护策略 | `onesig_strategy_api_query` | `protection_policy_list` | 可传分页 / 筛选字段 | +| 查全局白名单 | `onesig_strategy_api_query` | `whitelist_list` | 可传分页 / 筛选字段 | +| 查全局黑名单 | `onesig_strategy_api_query` | `blacklist_list` | 可传分页 / 筛选字段 | +| 查封禁白名单 | `onesig_strategy_api_query` | `banned_whitelist_list` | 可传分页 / 筛选字段 | +| 查 HTTP 黑名单 | `onesig_strategy_api_query` | `http_blacklist_list` | 可传分页 / 筛选字段 | +| 新建 / 更新 / 删除资产组 | `onesig_strategy_api_ops` | `asset_group_create` / `asset_group_update` / `asset_group_delete` | 按 api-onesig-2.5.3 文档填写 | +| 新建 / 更新 / 删除资产 | `onesig_strategy_api_ops` | `asset_create` / `asset_update` / `asset_delete` | 按 api-onesig-2.5.3 文档填写 | +| 更新 / 删除防护策略 | `onesig_strategy_api_ops` | `protection_policy_update` / `protection_policy_delete` | 先查策略列表拿业务 ID | +| 新建 / 更新 / 删除 / 移除全局白名单 | `onesig_strategy_api_ops` | `whitelist_create` / `whitelist_update` / `whitelist_delete` / `whitelist_remove` | 写入前先确认方向、条件和影响范围 | +| 新建 / 更新 / 删除 / 移除全局黑名单 | `onesig_strategy_api_ops` | `blacklist_create` / `blacklist_update` / `blacklist_delete` / `blacklist_remove` | 写入前先确认对象和影响范围 | +| 新建 / 更新 / 删除封禁白名单 | `onesig_strategy_api_ops` | `banned_whitelist_create` / `banned_whitelist_update` / `banned_whitelist_delete` | 写入前先确认对象和影响范围 | +| 新建 / 更新 / 启停 / 删除 HTTP 黑名单 | `onesig_strategy_api_ops` | `http_blacklist_create` / `http_blacklist_update` / `http_blacklist_enable` / `http_blacklist_delete` | `enable` 也属于写操作 | ## 通用规则 -- OneSIG 全部 grouped tool 的入参形态:`{action: "...", ...业务字段}`(不像 TDP 把所有筛选放进统一 `body`,也不像 OneSEC 用 `time_from`/`time_to`) -- 时间字段统一是 **Unix 秒**:`startTime` / `endTime` / `time`,没有"recent"系列接口 -- 分页字段统一是 `pageNo`(默认 1)+ `pageSize`(默认 20);不要传 `cur_page` / `page_size` / `page_items_num` -- 排序统一是 `sortBy`(字段名)+ `orderBy`(`asc` / `desc`) -- POST 类 action 多数 passthrough body —— 任何过滤字段直接放在请求体顶层即可,与 Web 控制台抓包字段一一对应 -- 查询类 action 默认优先;写操作只有用户明确授权时才执行(特别是黑白名单批量写、设备升级、HTTPS 解密策略、HA / OneCC 配置) -- 业务键参考第二节的"业务 ID 字段";如果没有 ID,应先调对应列表 / namelist / tree 接口拿主键 - -## 业务 ID 字段对照(`required` 主键) - -OneSIG 不同模块用不同字段做"主键",没有 ID 是无法调单条接口的。如果 agent 拿不到主键,应该**先调对应的列表 / namelist / tree** action 取得主键,再调单条接口。 - -| 字段 | 用在哪些 action | 含义 | -|---|---|---| -| `id` | `document_preview` | 帮助文档主键(先 `document_list`) | -| `key` + `password` | `apikey_secret` | API Key 名称 + 当前用户登录密码(自动 RSA 加密) | -| `uniqueId` | `asset_update` / `asset_delete`(+`password`)/ `linkage_info` / `protection_policy_get` / `report_form_download` / 多数 `*_delete` 单条目 / 多数全局白黑名单 `*_update` | 资源唯一 ID,先调列表/树拿到 | -| `uniqueIds` | 批量删除(`whitelist_remove_batch` 等于 `globalWhitelist` DELETE、`blacklist_remove_batch` 等于 `globalBlacklist` DELETE、`*_delete` 批量) | 数组形式,多个资源一起删 | -| `uid` | `asset_group_update` / `asset_group_delete`(+`password`) | **资产组**主键(不是 `uniqueId`!) | -| `pid` + `name` | `asset_group_create` | 父级资产组 ID(根 = `0`) + 新组名 | -| `syncId` | `ha_sync_status` | HA 配置同步任务 ID(由前置 `ha_sync_config` 返回) | -| `name` | `multiblock_rule_get` / `multiblock_rule_preview` / `multiblock_executelog_list` / `ips_ruleset_info` / `ips_ruleset_delete` / `asset_type_delete` | OneSIG 用对象名当主键,没有独立数字 ID | -| `groupId` | `port_protect_group_update` / `_delete` / `_clone` 的 `fromGroupId`、`port_protect_port_*` 写操作、`port_protect_portinfo` | 端口防护**组**主键(写操作用) | -| `groupName` | `port_protect_port_list`(端口列表)/ `port_protect_port_export` | 端口防护组**名**(仅查列表 / 导出用,注意与 `groupId` 区分) | -| `ruleId` | `ips_rule_create`(**实际是查单条规则详情**,文档名为「新增 IPS 自定义规则」与实际语义不符)、`ips_ruleset_referred` 的一半 | IPS 规则 ID | -| `ruleId` + `assetIp` | `ips_ruleset_referred` | 单条 IPS 规则 + 涉事资产 IP,反查规则在哪台资产上命中 | -| `srcIp`(+`protocol`+`direction`) | `auto_blacklist_trend` / `auto_blacklist_sample` | 来自 syslog 自动黑名单的源 IP 复合主键 | -| `server` + `port` + `orderBy` + `sortBy` | `tls_detect_list_detail` | TLS 解密目标"主机:端口"复合键 | -| `workMode` | `interface_select_list` | 网口选择列表必须先选定工作模式(内联/旁路) | -| `search` + `type` | `alert_policy_find_by_config` | 按配置反查告警策略 | -| `time` | `device_system_status` | 系统资源采样时间窗(Unix 秒) | -| `incIntervalSec` / `interval` | `overview_asset_brief` / `overview_event_trend` / `overview_stat` | 聚合粒度(秒) | -| `key` (Body) + Query `type=physical` | `apikey_delete` / `_update`,新增 `apikey_create` 也要 `type=physical` | API 联动密钥的接口要求 Query `?type=physical`(厂商文档强制)—— 当前 handler 走 passthrough body,按目前实测把 `type:"physical"` 一起放进 body 即可,但若服务端严格要求 query,请改用浏览器流程 | - -## 时间窗口与时区 - -- 时间戳一律 Unix **秒**(不是毫秒) -- 文档示例按 `Asia/Shanghai`,实际调用时按业务时区计算 -- OneSIG 没有"最近 24 小时增量"专用接口;要"最近一段"统一用 `now - 24*3600`、`now - 7*86400` 之类自行算 -- 未传时间走服务端默认窗口,仅作兜底,不推荐依赖 - -## 高频场景 - -### 1. 看入站 / 出站威胁事件 - -推荐:`onesig_monitoring` + `event_inbound_list` 或 `event_outbound_list`。 - -最小示例(查最近 24 小时入站事件): +- 入参优先使用: ```json { - "action": "event_inbound_list", - "startTime": 1745683200, - "endTime": 1745769600, - "pageNo": 1, - "pageSize": 20 + "action": "asset_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -后续动作: +- handler 也兼容把业务字段平铺在 `action` 同级,但文档和新请求默认使用 `body`,避免和旧控制台工具混淆。 +- `GET` 类 action 只发送签名 query,不发送 JSON body;不要依赖 `body` 给 `asset_group_list` / `asset_type_list` 传筛选条件。 +- 分页字段使用 `pageNo` / `pageSize`。 +- 排序 / 筛选字段按厂商 `api-onesig-2.5.3` 文档填写;如果不确定字段名,先用最小参数查询,不要猜旧控制台字段。 +- 当前 Strategy API action 没有 `startTime` / `endTime`,也没有 OneSEC 的 `time_from` / `time_to`;不要混用时间字段。 +- 查询优先;写操作必须在用户明确授权后执行。 +- 写操作前应先通过 list action 获取当前对象和业务 ID,确认不会误改 / 误删。 +- 当 `delete` 与 `remove` 的语义不确定时,不要猜;先查厂商文档或只执行只读查询。 -- 拿到事件后调 `event_inbound_detail` / `_detail_trend` / `_detail_list` 看详情、趋势、关联记录 -- 导出文件用 `event_inbound_export` / `event_inbound_detail_export`(返回的是文件,不是 JSON) +## 认证与配置 -返回结果重点关注: +`onesig_v2_5_3` 的 provider 信息: -- 事件 `uniqueId`、源/目的 IP、威胁类型 `threatType`、威胁等级 `severity`、威胁标签 `threatLabel`、是否 TLS `isTls` +- `service_id`: `onesig_api` +- `version`: `2.5.3` +- `base_url`: OneSIG 设备地址;缺少协议时 handler 自动补 `https://` +- `api_key`: ApiKey,优先读服务配置,也支持 secret `onesig_v2_5_3_api_key` +- `secret`: Secret,优先读服务配置,也支持 secret `onesig_v2_5_3_secret` +- 环境变量兜底:`ONESIG_V2_5_3_BASE_URL`、`ONESIG_V2_5_3_API_KEY`、`ONESIG_V2_5_3_SECRET` +- `verify_ssl`: 默认 `false`,适合自签证书设备;需要严格校验证书时在服务配置中开启 -何时回退浏览器:需要事件原始报文、PCAP 取证、详情页攻击链。 +handler 会自动把以下签名参数追加到请求 query: -### 2. 失陷主机调查 +```text +apikey= +timestamp= +sign= +``` -推荐:`onesig_monitoring` + `alert_host_*`。 +agent 不需要、也不应该手工生成签名。 -```json -{ - "action": "alert_host_list", - "startTime": 1745683200, - "endTime": 1745769600, - "pageNo": 1, - "pageSize": 20 -} -``` +## 只读查询示例 -详情维度: +### 1. 查设备平台状态 ```json { - "action": "alert_host_detail", - "startTime": 1745683200, - "endTime": 1745769600, - "source": "10.0.0.5" + "action": "platform_status", + "body": {} } ``` -`source` 是失陷主机的 IP(必填)。`alert_host_detail_list` / `_export` 同步要求 `startTime` / `endTime` / `source`。 - -### 3. 看仪表盘 / 大屏 - -仪表盘:`dashboard_overview` / `dashboard_outbound` / `dashboard_inbound` / `dashboard_zeroday` / `dashboard_status` / `dashboard_ioc_type_sum` —— 通常空参或只需时间窗。 - -威胁防护大屏(`overview_*`)—— 多数需要时间窗 + 聚合粒度: +### 2. 查系统状态 ```json { - "action": "overview_asset_brief", - "startTime": 1745683200, - "endTime": 1745769600, - "incIntervalSec": 3600 + "action": "system_status", + "body": {} } ``` -```json -{ - "action": "overview_event_trend", - "startTime": 1745683200, - "endTime": 1745769600, - "interval": 3600 -} -``` +### 3. 查网络状态 ```json { - "action": "overview_event_inbound_agg", - "startTime": 1745683200, - "endTime": 1745769600, - "type": "threatType", - "pageNo": 1, - "pageSize": 20 + "action": "network_status", + "body": {} } ``` -注意:`overview_*_export` 系列返回文件,不要拿来在 chat 里展开。 - -### 4. 资产管理 - -推荐:`onesig_assets`。 - -查列表: +### 4. 查资产列表 ```json { "action": "asset_list", - "pageNo": 1, - "pageSize": 20, - "search": "10.0.0." + "body": { + "pageNo": 1, + "pageSize": 20, + "search": "10.0.0." + } } ``` -新增 / 更新 / 删除(参数严格按厂商 `assetsSegment.md`): +返回后重点关注资产唯一标识、资产名称、IP / 网段、资产组和资产类型字段。字段名以设备实际返回为准。 + +### 5. 查资产组与资产类型 ```json { - "action": "asset_create", - "name": "host-1", - "type": "服务器", - "groupId": 101, - "ip": ["10.0.0.1", "10.0.0.2"], - "remark": "生产环境" + "action": "asset_group_list", + "body": {} } ``` -`type` 是字符串字段(不是 `assetType`),`ip` 必须是**字符串数组**(支持单 IP / 网段 / 区间),`groupId` 是数字(先调 `asset_group_get` 拿)。 - ```json { - "action": "asset_update", - "uniqueId": "asset-id-1", - "name": "host-1-renamed", - "type": "服务器", - "groupId": 101, - "ip": ["10.0.0.1"] + "action": "asset_type_list", + "body": {} } ``` -更新接口必填 `uniqueId` + `name` + `type` + `groupId` + `ip`(厂商文档要求**全量**字段,不是部分更新)。 - -```json -{ "action": "asset_delete", "uniqueId": "asset-id-1", "password": "<当前登录用户密码>" } -``` - -⚠️ **删除资产是单条调用**(仅 `uniqueId`),并且**必须带 `password`**(当前登录用户密码),处理器会自动 RSA-OAEP 加密。要批量删,请按行循环调用。 - -资产组:`asset_group_get`(拿整树)/ `asset_group_create`(`pid`+`name`)/ `asset_group_update`(`uid`+`name`)/ `asset_group_delete`(`uid`+`password`)。注意资产组主键叫 `uid`,**不是** `uniqueId`。整树缓存用 `common_asset_group_tree`。 - -资产导入: - -```json -{ "action": "asset_import", "file_path": "/abs/path/to/assets.csv" } -``` - -`asset_template` / `asset_export` 返回文件(CSV)。 +资产写操作前,先查这两个接口确认可用分组和类型。 -### 5. 黑白名单 / 多维封锁 / API 联动 - -白名单(写)—— 注意 `condition` / `comments` 与 `whiteList` **平级**,不是嵌进数组元素: +### 6. 查防护策略列表 ```json { - "action": "whitelist_add", - "whiteList": [{ "direction": "inbound" }], - "condition": [{ "type": "srcIp", "cond": "equal", "value": "10.0.0.1" }], - "comments": "策略备注" + "action": "protection_policy_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -新增时 `whiteList` 数组每项**只含 `direction`**(值为 `inbound` / `outbound` / `both`),`condition` 与 `comments` 在顶层。 +策略更新 / 删除前,必须先用列表结果确认目标策略 ID、名称、作用范围和当前状态。 + +### 7. 查全局白名单 / 黑名单 / 封禁白名单 ```json { - "action": "whitelist_update", - "uniqueId": "id-1", - "direction": "inbound", - "condition": [{ "type": "srcIp", "cond": "equal", "value": "10.0.0.1" }], - "comments": "更新备注" + "action": "whitelist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -更新时**没有** `whiteList` 字段,只有 `uniqueId` + 单值 `direction` + `condition` + `comments`。 - -```json -{ "action": "whitelist_remove_batch", "uniqueIds": ["id-1", "id-2"] } -``` - -黑名单(同 OneSIG `globalBlacklist`): - ```json { - "action": "blacklist_add", - "blackList": [ - { "object": "1.1.1.1", "direction": "inbound", "threatName": "", "sLifeCycle": "", "comments": "" } - ] + "action": "blacklist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -新增时整批用 `blackList` 数组;写入前可先 `blacklist_check`(IP 类冲突校验)。`_update` / `_delete`(按 `uniqueIds` 批量)/ `_list` 用法与白名单类似。 - -多维封锁: - ```json { - "action": "multiblock_executelog_list", - "name": "rule-A", - "startTime": 1745683200, - "endTime": 1745769600, - "pageNo": 1, - "pageSize": 20 + "action": "banned_whitelist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -`multiblock_rule_get` / `_preview` 必填 `name`;`_create` / `_update` 是写操作,要谨慎。 - -API 联动: - -```json -{ "action": "apikey_list", "pageNo": 1, "pageSize": 20 } -``` - -`apikey_list` 必填 `pageNo`+`pageSize`,否则服务端回 `responseCode=1004`。 - -```json -{ "action": "apikey_secret", "key": "my-key", "password": "<当前登录密码>" } -``` - -`apikey_secret` 会回吐明文 secret,处理器自动用 RSA-OAEP 加密 `password` 后发送(注意:`password` 在该接口走 Query 而非 Body)—— 不要替用户保存这个值,更不要回显在 chat 里。 - -写操作(创建 / 更新 / 删除):厂商接口要求带 Query `?type=physical`。当前 handler 走 passthrough body,请把 `type:"physical"` 一起放进 body 顶层(实测多数实例服务端会接受),如果遇到 `responseCode=1004` 提示 `type` 缺失则回退浏览器模式: - -```json -{ "action": "apikey_create", "type": "physical", "name": "soc-bridge" } -``` - -```json -{ "action": "apikey_update", "type": "physical", "name": "soc-bridge", "key": "k1", "status": 1 } -``` - -```json -{ "action": "apikey_delete", "type": "physical", "key": "k1" } -``` - -### 6. IPS 规则 / 规则集 - -```json -{ "action": "ips_ruleset_namelist" } -``` - -```json -{ "action": "ips_ruleset_info", "name": "default-ruleset" } -``` - -```json -{ "action": "ips_ruleset_referred", "ruleId": "rule-001", "assetIp": "10.0.0.5" } -``` - -⚠️ 注意 `ips_rule_create`(厂商 `POST /v3/ips/rule`)**实际语义是「查单条规则详情」**,body 必填 `ruleId`,不会真的"新增"自定义规则;这是 yaml/handler 命名遗留问题。需要新增/编辑规则集请走 `ips_ruleset_create` / `_update`。`ips_rule_apply` 才是"应用规则变更到目标规则集"的写操作(body:`rulesetsName`数组+`rules`数组),调用前确认变更已就绪。 - -### 7. HTTP 黑名单 / 端口防护 - -HTTP 黑:`http_blacklist_list` / `_create` / `_update` / `_delete` / `_enable` / `_export`。 - -端口防护组: - -```json -{ "action": "port_protect_group_list_full" } -``` +### 8. 查 HTTP 黑名单 ```json { - "action": "port_protect_port_list", - "groupName": "默认高危端口组", - "pageNo": 1, - "pageSize": 20, - "sortBy": "updateTime", - "orderBy": "desc" + "action": "http_blacklist_list", + "body": { + "pageNo": 1, + "pageSize": 20 + } } ``` -⚠️ action 名是 `port_protect_port_list`(**不是** `port_protect_group_port_list`),必填 `groupName`+`pageNo`+`pageSize`;响应里总数字段叫 `data.totalCount`(不是 `data.total`)。 - -写操作: +## 写操作示例 -- `port_protect_group_create` Body `groupName`;`_update` Body `groupId`+`groupName`;`_delete` Body `groupId`;`_clone` Body `fromGroupId`+`groupName` -- 端口规则:`port_protect_port_create` / `_update` Body `groupId`+`ports`(字符串)+`serviceName`(可选)+`comments`(可选);`_delete` Body `groupId`+`ports`(数组);`_onekey_import` Body `groupId`+`ports`(对象数组 `[{ports, serviceName, comments}]`) -- 端口占用预查:`port_protect_portinfo` Body `groupId`+`ports` +写操作统一调用 `onesig_strategy_api_ops`,且必须确认用户授权。下面示例只展示调用形态;业务字段必须按设备版本对应的 `api-onesig-2.5.3` 文档填写,不要从旧控制台插件文档里硬搬字段。 -### 8. Syslog 自动封禁 - -```json -{ "action": "auto_blacklist_list", "pageNo": 1, "pageSize": 20 } -``` - -```json -{ "action": "auto_blacklist_trend", "srcIp": "1.2.3.4", "startTime": 1745683200, "endTime": 1745769600 } -``` - -```json -{ "action": "auto_blacklist_sample", "srcIp": "1.2.3.4", "protocol": "tcp", "direction": "inbound" } -``` - -`auto_blacklist_check`(必填 `name` / `port` / `srcIp` / `protocol` / `direction`)是冲突校验,写入前调用。 - -### 9. 用户管理与改密 - -查询:`user_list` / `user_export`。 - -新增用户(写,敏感)—— 字段严格按 `deviceLoginManagement.md`: +### 1. 新建资产组 ```json { - "action": "user_create", - "username": "alice", - "role": 2, - "nickname": "", - "phone": "", - "expireTime": 0, - "loginLimit": [], - "password": "<明文新密码>", - "dupPassword": "<明文新密码>" + "action": "asset_group_create", + "body": { + "...": "按厂商文档填写资产组字段" + } } ``` -字段说明: - -- `username`(必填,**不是 `name`**):1~20 字符,符合 `userEditor` 用户名正则 -- `role`(必填):数字角色(如 `0`/`2`/`3`/`4`,`role=3` 跳审计页、`role=4` 跳大屏) -- `expireTime`(必填):到期时间 Unix 秒;选「长期」时填 `0` -- `loginLimit`(必填):可登录 IP 字符串数组,ALL 时填 `[]` -- `password` / `dupPassword`(必填):明文新密码,处理器自动 RSA-OAEP 加密。注意 **handler 只对 `password` 与 `dupPassword`(驼峰)做加密**,不要传 `dup_password`(蛇形不会被加密) - -更新用户用 `user_update`,必填 `username`(定位主键,**不可改**)+ `expireTime` + `loginLimit`,可选 `nickname` / `phone`;改密**不在** `user_update` 路径,要走 `Main/changePwdModalData`(即顶栏改密弹窗,agent 用 `change_password` 走 `onesig_login`)。 - -改密(当前用户)走 `onesig_login`: +### 2. 更新资产 ```json { - "action": "change_password", - "old_password": "<明文旧>", - "new_password": "<明文新>", - "dup_password": "<明文新>" + "action": "asset_update", + "body": { + "...": "先从 asset_list 获取目标资产 ID,再按厂商文档填写完整更新字段" + } } ``` -`user_secret_reset` / `user_delete` / `aclog_delete` / `interface_update`(启停场景)/ `device_upgrade*` 同样要传 `password`,规则一致。 - -如果 `POST /v3/login` 返回 responseCode `1009` / `1017` 但密码确实正确,先把 `oaep_hash` 切到 `sha256` 重试 —— OneSIG v2.5.x 多数走 SHA-1(JSEncrypt 默认),但有个别部署是 SHA-256。 - -### 10. HTTPS 解密 - -策略:`tls_decrypt_policy_list` / `_create` / `_update` / `_enable` / `_delete` / `_batch`。 - -证书:`tls_cert_list` / `_create`(multipart:`file_path` 指向 `.crt`/`.pem`)/ `_update` / `_delete` / `_set_default`。 - -检测对象: +### 3. 更新防护策略 ```json { - "action": "tls_detect_list_detail", - "server": "internal-svc.example.com", - "port": 443, - "orderBy": "desc", - "sortBy": "lastSeenTime" + "action": "protection_policy_update", + "body": { + "...": "先从 protection_policy_list 获取目标策略,再按厂商文档填写" + } } ``` -`server` + `port` + `orderBy` + `sortBy` 这 4 个是必填的复合主键。 - -### 11. 网口 / 部署引导 / 路由 / DNS - -网口:`interface_list` / `interface_select_list`(必填 `workMode`,"内联"/"旁路"二选一)/ `interface_update`(启停时 `password` 必填)/ `interface_check_loop` / `interface_relation_list`。 - -虚拟线 / 监听 / 桥:`interface_virtual_line_*` / `interface_listen_*` / `interface_bridge_*`。 - -路由:`route_outif_list` / `route_static_list` / `_create` / `_update` / `_delete` / `route_table_list`,IPv6 同名加 `ipv6_` 前缀。 - -DNS:`get_dns_config` / `set_dns_config` / `hosts_get` / `hosts_create` / `_update` / `_delete` / `test_network`。 - -### 12. HA 高可用 - -```json -{ "action": "ha_status" } -``` - -```json -{ "action": "ha_sync_config" } -``` - -```json -{ "action": "ha_sync_status", "syncId": "<上一步返回的 syncId>" } -``` - -`ha_switching` 是主备切换,**强破坏性写操作**,没有用户明确授权不要调。 - -### 13. 集中管控 OneCC - -```json -{ "action": "onecc_status" } -``` - -```json -{ "action": "test_onecc", "...": "..." } -``` - -`set_onecc_config` / `set_onecc_status` 都会改设备纳管状态,调用前先用 `get_onecc_config` / `onecc_status` 看清当前形态。 - -### 14. 设备配置 / 升级 / 备份 - -升级: - -- `basic_version` / `device_upgrade_info` —— 看版本与可升级状态 -- `get_upgrade_config` / `set_upgrade_config` —— 在线升级源配置 -- `device_download_package` —— 触发后台下载升级包(写) -- `device_upgrade` —— 已下载的包升级(写,敏感,`password` 必填) -- `device_upgrade_upload` —— 本地上传升级包(multipart:`file_path`,`password` 必填) -- `system_upgrade` —— 系统级升级(multipart:`file_path`) - -备份: - -```json -{ "action": "backup_list", "pageNo": 1, "pageSize": 20 } -``` - -```json -{ "action": "backup_create", "name": "manual-2026-04-27" } -``` - -```json -{ "action": "backup_recover", "uniqueId": "" } -``` - -```json -{ "action": "backup_import", "file_path": "/abs/path/to/backup.tar" } -``` - -`backup_recover` / `backup_import` / `backup_delete` 都强破坏性,确认后再调。 - -设备其它:`device_quick_bypass` / `device_onekey_bypass`(流量直通)、`device_reboot` / `device_shutdown` / `device_reinit`(重启 / 关机 / 出厂重置)—— 全是高风险,不要在不需要的时候触发。 - -### 15. 日志外发 +### 4. 新建全局黑名单 ```json { - "action": "logaccess_list", - "pageNo": 1, - "pageSize": 20, - "type": "syslog" + "action": "blacklist_create", + "body": { + "...": "按厂商文档填写黑名单对象、方向、备注等字段" + } } ``` +### 5. 启停 HTTP 黑名单 + ```json { - "action": "logaccess_sample", - "srcIp": "10.0.0.1", - "protocol": "udp", - "type": "syslog" + "action": "http_blacklist_enable", + "body": { + "...": "按厂商文档填写目标 ID 与启停状态" + } } ``` -`logaccess_check` 用来诊断外发链路是否正常。 - -### 16. 基本信息 / license / MDR - -- `basic_information` —— 设备基础信息 -- `basic_information_enable` —— 启用模块(写) -- `basic_information_import` —— 离线情报库更新(multipart:`file_path` + Query 需 `name`) -- `basic_license_get` —— 查 license -- `basic_license_upload` —— 授权 license 文件上传(multipart:`file_path`) -- `mdr_service_status` / `mdr_service_enable` —— MDR 服务 - -### 17. 设备诊断 - -- `device_coredump_list` / `_download` / `_delete` -- `device_pcap_get` / `_set` / `_file_list` / `_download` / `_file_delete` - -抓包写 (`device_pcap_set`) 与删除 (`*_delete`) 是写操作。 - -### 18. 帮助文档与产品反馈 - -```json -{ "action": "document_list", "pageNo": 1, "pageSize": 20, "search": "ips" } -``` - -```json -{ "action": "document_preview", "id": "<文档列表项里的 id>" } -``` - -⚠️ `document_preview` 必填 Query `id`(**不是 `fileName`**),其值来自 `document_list` 返回的 `data.list[].id`。返回的 `data` 是**路径字符串**(不是对象),前端会拼到 `window.location.origin` 后用 `window.open` 打开 —— agent 拿到这个路径后通常需要走浏览器模式才能查看 PDF/HTML 内容。 - -`product_news_get` / `product_news_mark_read` / `product_version` / `product_issue` 用于看红点 / 标已读 / 看版本 / 提反馈。其中 `product_news_mark_read` 的 body 应是先 `product_news_get` 返回对象的副本,再把要清除的标记位(如 `documentUpdate`)置 `false`,其它字段保持原值。 - -### 19. 登录 / 会话 - -绝大多数业务场景**不需要**显式登录 —— 处理器会按需自动登录、Cookie 过期会自动重登。下面这些场景才需要主动调 `onesig_login`: - -- 设备启用了图形验证码(`GET /v3/captcha` 返回 `enableCaptcha=true`): - - ```json - { "action": "login", "captcha": "xyzw" } - ``` - - 前端校验长度恰为 4。 - -- 设备启用了 TOTP / 双因素(`enableTotp=true` 或 `POST /v3/login` 返回 `responseCode=1012` 进入扫码页): - - ```json - { "action": "login", "totp": "123456" } - ``` - - 注意:handler 入参统一叫 `totp`(**不要传 `checksum`**),底层会按场景自动映射 —— inline 模式(`/v3/login` 同屏 TOTP)作为 `checksum` 字段拼进登录 body;扫码模式(先 `/v3/login` 拿到 `responseCode=1012`、再补一次 `/v3/login/totp`)作为 `{"checksum": "..."}` 体提交。也可以传恢复码(最长 12 字符)。 - -- 想立刻拿账户信息: - - ```json - { "action": "get_account" } - ``` - -- 改当前用户密码: - - ```json - { - "action": "change_password", - "old_password": "<明文旧>", - "new_password": "<明文新>", - "dup_password": "<明文新>" - } - ``` - -- 显式退出(调试 / 切换账号): - - ```json - { "action": "logout" } - ``` - -注意: - -- `logout` 会清空内存里的会话**和**已落盘的 Cookie 持久化(`~/.flocks/config/.secret.json` 中以 `onesig_session_cookie__` 命名的条目),下次调用会从 captcha → pubkey → /v3/login 重走完整链路 -- `regenerate_recovery_code` 一旦调用,旧恢复码立即失效,请在用户明确授权后再用 -- `get_pubkey` / `get_captcha` 通常不需要手动调 —— 处理器在登录前与每次发送 RSA 加密字段前都会自动拉 - -## 文件类返回 / 上传 - -下列 action 返回的是**二进制文件**(导出 / 模板 / 下载),不应该在 chat 里直接展开: - -- 导出:`asset_export` / `whitelist_export` / `blacklist_export` / `http_blacklist_export` / `port_protect_port_export` / `event_inbound_export` / `event_inbound_detail_export` / `event_outbound_export` / `event_outbound_detail_export` / `alert_host_export` / `alert_host_detail_export` / `tls_detect_group_export` / `tls_detect_list_export` / `aclog_export` / `user_export` / `alert_policy_export` / `multiblock_executelog_export` / `overview_export_*` -- 模板:`asset_template` / `whitelist_template` / `blacklist_template` / `linkage_template` -- 下载:`backup_download` / `report_form_download`(必填 `uniqueId`+`fileName`)/ `device_coredump_download` / `device_pcap_download` - -下列 action 是 **multipart 上传**,需要 `file_path` 指向本地绝对路径: - -- `asset_import`(CSV) -- `tls_cert_create` / `tls_cert_update`(`.crt`/`.pem`) -- `basic_information_import`(离线情报库包,Query 需 `name`) -- `basic_license_upload` / `system_upgrade` / `device_upgrade_upload`(升级 / 授权包) -- `backup_import`(备份包) +`http_blacklist_enable` 会改变防护行为,视为写操作。 ## 高风险写操作清单 -以下 action 默认视为高风险,agent 在执行前**必须**先确认用户授权: - -- `device_onekey_bypass` / `device_quick_bypass` -- `device_reboot` / `device_shutdown` / `device_reinit` -- `device_upgrade` / `device_upgrade_upload` / `system_upgrade` -- `ha_switching` / `ha_sync_config`(同步会改对端) -- `set_ha_config` / `set_onecc_config` / `set_onecc_status` -- `user_create` / `user_delete` / `user_update` / `user_secret_reset` / `change_password` -- `aclog_delete` -- `whitelist_add` / `whitelist_update` / `whitelist_delete` / `whitelist_remove_batch` / `whitelist_import` -- `blacklist_add` / `blacklist_update` / `blacklist_delete` / `blacklist_remove_batch` / `blacklist_import` -- `multiblock_rule_create` / `multiblock_rule_update` / `multiblock_rule_delete` / `multiblock_rule_active` -- `auto_blacklist_create` / `auto_blacklist_update` / `auto_blacklist_delete` -- `linkage_create` / `linkage_update` / `linkage_delete` / `linkage_enable` -- `ips_rule_create` / `ips_rule_apply` / `ips_rule_all` / `ips_ruleset_create` / `ips_ruleset_update` / `ips_ruleset_delete` -- `http_blacklist_create` / `http_blacklist_update` / `http_blacklist_delete` / `http_blacklist_enable` -- `port_protect_group_create` / `_update` / `_delete` / `_clone`、`port_protect_port_create` / `_update` / `_delete` / `_onekey_import` -- `protection_policy_update` / `protection_policy_delete` -- `tls_decrypt_policy_create` / `_update` / `_enable` / `_delete` / `_batch` -- `tls_cert_create` / `_update` / `_delete` / `_set_default`、`tls_detect_delete` -- `set_decrypt_config` / `set_detect_config` -- `interface_update`(启停)/ `interface_*_create/update/delete` -- `route_static_create` / `_update` / `_delete`、`ipv6_route_static_create` / `_update` / `_delete` -- `set_dns_config` / `hosts_create` / `_update` / `_delete`、`set_proxy_config` -- `backup_create` / `backup_recover` / `backup_delete` / `backup_update` / `backup_import` -- `set_storage_config` / `set_clean_config` / `set_dnslog_config` -- `logaccess_create` / `_update` / `_delete` -- `basic_information_enable` / `basic_information_import` / `basic_license_upload` -- `mdr_service_enable` -- `device_pcap_set` / `device_coredump_delete` / `device_pcap_file_delete` -- `apikey_create` / `apikey_update` / `apikey_delete` / `apikey_secret`(回吐明文 secret,需 `password`) -- `regenerate_recovery_code` -- `report_form_create` / `_delete`、`report_task_create` / `_update` / `_delete` / `_test` -- `set_advanced_config` / `set_xff_config` / `set_scan_config` / `set_login_config` / `set_upgrade_config` / `set_overview_config` / `set_custom_config` / `web_custom_column_set` - -## 常见失败原因 - -- 时间戳传成毫秒(要用秒) -- 错传 `cur_page` / `page_size` / `page_items_num`(应为 `pageNo` / `pageSize`) -- 漏传 OneSIG 必填的"业务 ID"主键 —— `name` / `uniqueId` / `srcIp` / `groupName` / `server`+`port` / `ruleId`+`assetIp` 任意一个缺失都会回 `responseCode=1004`(请求数据非法) -- 单条 GET 类接口忘了先调列表拿主键 -- 改密 / 删除 / 启停接口手动加密了 `password` —— 处理器会自己加密,要传**明文** -- `oaep_hash` 配置和设备实际不匹配(多数 v2.5.x 走 SHA-1,少数走 SHA-256),登录返回 `responseCode=1009`/`1017` 时优先怀疑这里 -- `api_prefix` 与设备反代部署形态不一致 —— 厂商前端代码里写的是 `/api/v3/...`,但接口规范文档里去掉了 `/api`。我们 v2.5.x 实测的实例多数是直连后端(`/v3/...`),所以 handler 的 `DEFAULT_API_PREFIX=""`;如果换到一个走 nginx 代理的实例,需要把 `api_prefix` 设成 `"/api"`。**现象**:登录前的 `/v3/pubkey` 直接 404,handler 会在错误信息里给出对应提示 -- `verify_ssl` 与设备证书不匹配(OneSIG 多用自签证书,默认应**关闭**)—— 现象是 SSL handshake 失败 -- 报错"Cookie 过期":调一下 `onesig_login` 的 `logout` 再让处理器自动重登 -- API Key 写操作返回 `responseCode=1004` 提示 `type` 缺失:服务端要求 Query `type=physical`;先尝试把 `type:"physical"` 放进 body 透传,如果仍然 1004 请回退浏览器模式 +以下 action 默认视为高风险,agent 在执行前必须先确认用户授权、对象、范围和回滚预案: + +- `asset_group_create` +- `asset_group_update` +- `asset_group_delete` +- `asset_create` +- `asset_update` +- `asset_delete` +- `protection_policy_update` +- `protection_policy_delete` +- `whitelist_create` +- `whitelist_update` +- `whitelist_delete` +- `whitelist_remove` +- `blacklist_create` +- `blacklist_update` +- `blacklist_delete` +- `blacklist_remove` +- `banned_whitelist_create` +- `banned_whitelist_update` +- `banned_whitelist_delete` +- `http_blacklist_create` +- `http_blacklist_update` +- `http_blacklist_enable` +- `http_blacklist_delete` + +## 返回与错误处理 + +- handler 会把成功响应中的 `data` 解包为 tool output;如果响应不是对象,会原样返回。 +- 如果响应包含 `response_code` 且不是 `0`,handler 会把结果标记为失败,并返回 `verbose_msg` 或原始响应。 +- HTTP `4xx` / `5xx` 会返回状态码和前 500 字符响应体。 +- `OneSIG Strategy API base_url is not configured.` 表示服务地址未配置。 +- `OneSIG Strategy API ApiKey and Secret are required.` 表示 ApiKey / Secret 未配置或 secret 引用无法解析。 +- `Request failed` 常见原因是设备网络不可达、证书验证配置不匹配或代理 / 防火墙拦截。 + +## 当前 Strategy API 不覆盖的场景 + +以下任务不要调用当前两个 Strategy API 工具硬凑,应改用浏览器模式,或在确认存在额外旧控制台工具并阅读对应文档后再处理: + +- 威胁监控、仪表盘、威胁防护大屏 +- 入站 / 出站威胁事件、失陷主机、报告管理、导出报表 +- 多维封锁、API 联动密钥管理、Syslog 自动封禁、FTP/SFTP 联动 +- IPS 规则 / 规则集、高危端口防护 +- 告警通知、审计日志、用户管理、登录 / 改密 +- HTTPS 解密、网口、部署引导、路由、DNS、代理 +- HA、OneCC、设备升级 / 重启 / 备份 / 恢复 +- 日志外发、license、MDR、coredump、pcap、帮助文档、产品反馈 +- 图形验证码、TOTP、强制改密等人工交互 ## 何时回退浏览器 以下情况优先回退浏览器(参考 [browser-workflow.md](browser-workflow.md)): -- 需要威胁防护大屏的可视化、IOC 关系图、攻击链 -- 需要看事件 / 失陷主机的页面级详情、报文 hex view、PCAP 在线播放 -- 需要点表格右侧抽屉、复杂筛选弹窗、一些深层报表预览 +- 当前 Strategy API 没有覆盖用户目标能力 +- 需要页面级图表、详情抽屉、攻击链、威胁图、报表预览或文件下载 - 需要图形验证码 / TOTP / 强制改密之类的人工交互 -- 需要看 Web 控制台原生导出文件并下载 +- API 未配置、认证失败、设备不可达或反复返回权限 / 签名错误 +- 用户明确要求使用浏览器 diff --git a/.flocks/plugins/skills/onesig-use/references/browser-workflow.md b/.flocks/plugins/skills/onesig-use/references/browser-workflow.md index 30ee77dfd..dbf3f1590 100644 --- a/.flocks/plugins/skills/onesig-use/references/browser-workflow.md +++ b/.flocks/plugins/skills/onesig-use/references/browser-workflow.md @@ -2,12 +2,12 @@ 只在以下情况进入浏览器模式: -- API 不可用(未配置 / 未开通 / 认证失败 / Cookie 持久反复过期 / SSL 校验失败 / 网络不通) +- API 不可用(未配置 / 未开通 / ApiKey 或 Secret 缺失 / 签名失败 / SSL 校验失败 / 网络不通) - 任务必须看页面级详情(攻击链、威胁图、报表预览、报文 hex view、PCAP 在线播放) - 需要图形验证码 / TOTP / 强制改密之类的人工交互 - 用户明确要求使用浏览器,或者已经在浏览器操作过程中 -如果走 API 能完成,请回到 [api-reference.md](api-reference.md) —— 浏览器操作不稳定、不可批量、字段不一定完整。 +如果当前 Strategy API 能完成,请回到 [api-reference.md](api-reference.md);浏览器操作不稳定、不可批量、字段不一定完整。 > ⚠️ OneSIG 的 Web 控制台与 OneSEC、青藤是不同产品;不要把 OneSEC / 青藤的页面路径或 API 套用到 OneSIG。 > 本文档统一按 `browser-use` 的 `cdp-direct` 流程执行:先 `flocks browser --doctor`,doctor 通过后只使用 `flocks browser`。 @@ -145,14 +145,17 @@ print(js("document.body.innerText.slice(0, 2000)")) ## 二、浏览器与 API 的互补建议 -进入浏览器模式后,对于"查询类"诉求,应该**优先回到 API**(除非 API 真的不可用): +进入浏览器模式后,只有当前 Strategy API 覆盖的诉求才优先回到 API;未覆盖的控制台能力继续走浏览器: | 任务 | 优先方案 | |---|---| -| 列威胁事件 / 失陷主机 / 趋势数据 | API(`onesig_monitoring`) —— 浏览器只在需要威胁图、报文 hex 时用 | -| 增删改黑白名单 / IPS 规则 / 多维封锁 | API(`onesig_strategy`),写操作前要二次确认 | -| 看资产清单、增改资产 | API(`onesig_assets`) —— 浏览器仅做导入文件预览 | -| 设备升级 / 重启 / HA 切换 | 浏览器 + API 并用:先在浏览器里走完确认弹窗、备份提示,再用 API 触发;或者全程在浏览器里完成(更稳) | +| 看设备平台 / 系统 / 网络状态 | API(`onesig_strategy_api_query`) | +| 看资产组、资产列表、资产类型 | API(`onesig_strategy_api_query`) | +| 增删改资产或资产组 | API(`onesig_strategy_api_ops`),写操作前要二次确认 | +| 看 / 改防护策略、全局白名单、全局黑名单、封禁白名单、HTTP 黑名单 | API(`onesig_strategy_api_query` / `onesig_strategy_api_ops`),写操作前要二次确认 | +| 列威胁事件 / 失陷主机 / 趋势数据 | 浏览器(当前 Strategy API 不覆盖) | +| IPS 规则、多维封锁、Syslog 自动封禁、FTP/SFTP 联动、高危端口防护 | 浏览器(当前 Strategy API 不覆盖) | +| 设备升级 / 重启 / HA 切换 | 浏览器;强破坏性动作必须显式确认 | | 看证书 / 解密策略详情 | 浏览器(API 返回的字段比页面少) | | 看页面级图表 / 攻击链 / IOC 关联 | 浏览器(API 没有) | | 处理图形验证码 / TOTP / 强制改密 | 浏览器(API 不能完成人工交互) | @@ -175,10 +178,9 @@ print(js("document.body.innerText.slice(0, 2000)")) ## 四、文件下载与导出 -OneSIG 控制台的导出按钮(资产 / 黑白名单 / 报表 / 审计 / coredump / pcap)多数会触发浏览器下载。优先顺序如下: +OneSIG 控制台的导出按钮(资产 / 黑白名单 / 报表 / 审计 / coredump / pcap)多数会触发浏览器下载。当前 Strategy API 工具没有导出 / 下载 action;需要导出文件时走浏览器页面。 -1. **优先用 API 的导出 / 下载 action**(参见 [api-reference.md](api-reference.md) "文件类返回 / 上传"小节)。 -2. 如果必须走页面下载,先用 `js(...)` 或稳定 selector 触发下载,再用浏览器系统的下载目录或页面提示确认下载已开始;不要在 skill 里承诺不存在的“等待下载完成”专有命令。 +先用 `js(...)` 或稳定 selector 触发下载,再用浏览器系统的下载目录或页面提示确认下载已开始;不要在 skill 里承诺不存在的“等待下载完成”专有命令。 页面下载最小模板: diff --git a/.flocks/plugins/skills/tdp-use/SKILL.md b/.flocks/plugins/skills/tdp-use/SKILL.md index fb5cfa16b..bcf4bab74 100644 --- a/.flocks/plugins/skills/tdp-use/SKILL.md +++ b/.flocks/plugins/skills/tdp-use/SKILL.md @@ -39,13 +39,14 @@ API 参数和适用场景见 [references/api-reference.md](references/api-refere - 用户说“告警”“告警记录”“告警日志”“明细记录”“查某 IP 的告警”时,默认走 `tdp_log_search` - 用户说“看板”“概览”“趋势”“统计”时,先用 `tdp_dashboard_status` - 用户说“威胁事件”“外部攻击”“攻击事件”“事件总览”“事件趋势”时,优先用 `tdp_incident_list` 或 `tdp_threat_inbound_attack` +- 用户说“实时监控”“威胁监控列表”时,优先用正式 API 工具 `tdp_threat_monitor_list`;不要用 Cookie 认证的旧 Web CLI 代替 - 用户说“告警主机”“受害主机”“主机下的事件”时,优先用 `tdp_host_threat_list` - 用户说“系统状态”“核心服务状态”“数据库状态”时,优先用 `tdp_system_status` - 用户说“MDR”“研判结果”“研判统计”时,优先用 `tdp_mdr_alert_list` - 用户说“脆弱性”“弱口令”“登录入口”“上传接口”“API 风险”“隐私数据”时,走对应资产或风险类工具 - 用户说“云服务”“云实例”“访问云服务的源主机”时,优先用 `tdp_cloud_facilities` - 用户说“下载 PCAP”“下载恶意文件”时,优先走下载类 API;下载前先确认用户确实要下载 -- 用户说“白名单”“资产配置”“策略配置”“联动阻断”“处置状态修改”时,先判断是否涉及写操作;只有用户明确授权后才调用配置类工具 +- 用户说“白名单”“资产配置”“自定义规则”“策略配置”“联动阻断”“处置状态修改”时,先判断是否涉及写操作;只有用户明确授权后才调用配置类工具 ## 浏览器模式使用指南 diff --git a/.flocks/plugins/skills/tdp-use/references/api-reference.md b/.flocks/plugins/skills/tdp-use/references/api-reference.md index 4c4f66bf9..3bd3bef99 100644 --- a/.flocks/plugins/skills/tdp-use/references/api-reference.md +++ b/.flocks/plugins/skills/tdp-use/references/api-reference.md @@ -10,6 +10,7 @@ | 查原始告警日志 | `tdp_log_search` | `search` | `time_from`、`time_to`、`sql` | | 查字段聚合统计 | `tdp_log_search` | `terms` | `time_from`、`time_to`、`term` | | 查威胁事件列表 | `tdp_incident_list` | `search` | `time_from`、`time_to`;可补 `severity`、`phase`、`result`、`keyword`、分页参数 | +| 查威胁实时监控列表 | `tdp_threat_monitor_list` | 默认 | 时间范围不超过 24 小时;可补 `sql`、`net_data_type`、`assets_group`、分页参数 | | 看事件时间线 / 结果分布 / 攻击者明细 | `tdp_incident_list` | `timeline` / `result_distribution` / `attacker_ip_detail` | 通常先要 `incident_id` | | 查外部攻击严重性分布 | `tdp_threat_inbound_attack` | 默认 | `time_from`、`time_to`;可补 `severity`、`result_list`、`keyword` | | 查告警主机汇总 / 主机下事件 | `tdp_host_threat_list` | `summary` / `events` | `summary` 可补 `severity`、`direction`、`threat_type`、`keyword`;`events` 至少要 `asset_machine` | @@ -24,7 +25,7 @@ | 查 MDR 研判列表 / 指标 | `tdp_mdr_alert_list` | `list` / `indicator` | 常见补时间范围、`section_list`、`threat_severity`、`judge_result_status`、`keyword` | | 查系统状态 | `tdp_system_status` | `all` / `core` / `database` 等 | 通常空参 | | 下载 PCAP / 恶意文件 | `tdp_pcap_download` / `tdp_file_download` | 默认 | `alert_id + occ_time` 或 `hash` | -| 管理平台配置 / 策略 | `tdp_platform_config` / `tdp_policy_settings` | 多 action | 写操作,必须先获用户确认 | +| 管理平台配置 / 自定义规则 / 策略 | `tdp_platform_config` / `tdp_policy_settings` | 多 action | 写操作,必须先获用户确认 | ## 时间参数注意事项(重点) @@ -109,6 +110,7 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") | 用户实际要查什么 | 推荐 tool | 说明 | |---|---|---| | 威胁事件 / 攻击事件 / 事件总览 | `tdp_incident_list` / `tdp_threat_inbound_attack` | 事件维度,平台已聚合 | +| 实时监控 / 威胁监控列表 | `tdp_threat_monitor_list` | 实时威胁监控列表,单次时间范围不得超过 24 小时 | | 告警 / 告警日志 / 原始检测记录 | `tdp_log_search` | 告警维度,一条就是一条原始记录 | | 告警主机 / 受害主机 / 主机下事件 | `tdp_host_threat_list` | 主机维度,按主机聚合 | | 漏洞、弱口令、登录入口、API 风险等 | 对应资产或风险类 tool | 资产/风险维度,不要混进日志查询 | @@ -117,6 +119,7 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") - 提到“告警”“最近一小时告警”“查某 IP 的告警”时,默认优先 `tdp_log_search` - 提到“威胁事件”“攻击事件”“看下最近有什么事件”时,优先 `tdp_incident_list` +- 提到“实时监控”“威胁监控列表”时,优先 `tdp_threat_monitor_list` - 提到“哪些主机被打了”“告警主机”“受害主机”时,优先 `tdp_host_threat_list` - 用户没说清时,默认把“明细”理解为告警日志,把“总览/聚合”理解为事件 @@ -648,6 +651,22 @@ PCAP 下载: 处置日志可直接走 `tdp_platform_config(action="disposal_log_list")`;handler 会补默认时间范围、分页和 `cts` 倒序。 +查询自定义规则: + +```json +{ + "action": "custom_rule_list", + "custom_rule_status": [1], + "custom_rule_keyword": "SQL", + "cur_page": 1, + "page_size": 20 +} +``` + +新增、编辑、删除自定义规则分别使用 `custom_rule_add`、`custom_rule_update`、 +`custom_rule_delete`。这些动作会修改检测策略,必须先获得用户明确授权;新增和编辑通过 +`custom_rule` 传完整规则对象,删除通过 `custom_rule_ids` 传规则 `suuid` 列表。 + ## 高风险与低风险 TDP 这里大多数调查类 tool 是读操作,但以下情况仍要谨慎: diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml index 8f7fc28e9..07b38cd26 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/_provider.yaml @@ -4,11 +4,12 @@ service_id: tdp_api version: "3.3.10" integration_type: device description: > - TDP monitoring platform API for dashboard, threat investigation, asset risk, - log search, MDR analysis, and system status queries. + TDP monitoring platform API for dashboard, real-time threat monitoring, + threat investigation, asset risk, log search, MDR analysis, system status, + and custom rule management. description_cn: > - TDP 监测平台接口,提供监控看板、威胁调查、资产风险、日志检索、MDR、告警调查 - 研判和系统运行状态查询能力。 + TDP 监测平台接口,提供监控看板、威胁实时监控、威胁调查、资产风险、日志检索、 + MDR 告警研判、系统运行状态查询和自定义规则管理能力。 docs_url: "https://agentflocks.github.io/flocks-docs/md/modules/devices/tdp-integration" auth: type: custom diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml index d6ff6eed9..1eae486e1 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml @@ -39,6 +39,15 @@ fixtures: params: {} assert: success: true + - label: "List custom rules" + label_cn: "查询自定义规则列表" + tags: [configuration, rules] + params: + action: custom_rule_list + cur_page: 1 + page_size: 20 + assert: + success: true tdp_incident_list: - label: "Search incidents (page 1)" @@ -144,6 +153,16 @@ fixtures: tags: [smoke, threats] params: {} + tdp_threat_monitor_list: + - label: "List real-time threats (last 24 hours)" + label_cn: "查询威胁实时监控列表(最近 24 小时)" + tags: [smoke, threats] + params: + cur_page: 1 + page_size: 20 + assert: + success: true + tdp_login_weakpwd_list: - label: "List weak-password login risks (page 1)" label_cn: "查询弱密码登录风险(第 1 页)" diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py index 4ae242d42..1e39d02b2 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py @@ -66,6 +66,7 @@ CLOUD_ACCESS_FUZZY_FIELDS = ["machine", "cloud_instance"] CLOUD_INSTANCE_ACCESS_FUZZY_FIELDS = ["cloud_instance", "external_ip", "machine"] MDR_FUZZY_FIELDS = ["task_id", "machine", "asset_info", "threat_name"] +CUSTOM_RULE_FUZZY_FIELDS = ["threat_name", "threat_msg"] SYSTEM_STATUS_ENDPOINTS = { "core": "/api/v1/core-status", "ioc_update": "/api/v1/ioc-update-status", @@ -550,6 +551,21 @@ def _inbound_attack_body(body: dict[str, Any]) -> dict[str, Any]: ) +def _threat_monitor_body(body: dict[str, Any]) -> dict[str, Any]: + time_from, time_to = _default_time_range(days=1) + defaults = { + "condition": { + "time_from": time_from, + "time_to": time_to, + "sql": "", + "size": 1000, + "refresh_rate": -1, + }, + "page": {"cur_page": 1, "page_size": 20}, + } + return _deep_merge(defaults, body) + + def _login_entry_body(body: dict[str, Any]) -> dict[str, Any]: defaults = _body_with_condition_time( { @@ -721,6 +737,10 @@ def _custom_intel_delete_body(body: Any) -> dict[str, Any]: return _deep_merge({"action": "delete", "data": {}}, _dict_body(body)) +def _custom_rule_list_body(body: Any) -> dict[str, Any]: + return _paged_body(body, sort_by="updated_time") + + def _ip_reputation_list_body(body: Any) -> dict[str, Any]: return _paged_body(body, sort_by="updated_at") @@ -903,6 +923,10 @@ def _passthrough_body(body: Any) -> Any: _cascade_children_body, "device_cascade_platform_children", ), + "custom_rule_list": ("/api/v1/customRule/list", _custom_rule_list_body, "custom_rule_list"), + "custom_rule_add": ("/api/v1/customRule/add", _passthrough_body, "custom_rule_add"), + "custom_rule_update": ("/api/v1/customRule/update", _passthrough_body, "custom_rule_update"), + "custom_rule_delete": ("/api/v1/customRule/delete", _passthrough_body, "custom_rule_delete"), "disposal_log_list": ("/api/v1/disposal/log/list", _disposal_log_list_body, "disposal_log_list"), } @@ -1062,6 +1086,27 @@ def _validate_log_sql_expression(action: str, sql: str | None) -> ToolResult | N return None +def _validate_threat_monitor_time_range(time_from: int | None, time_to: int | None) -> ToolResult | None: + if (time_from is None) != (time_to is None): + return ToolResult( + success=False, + error="TDP threat monitor requires time_from and time_to together.", + ) + if time_from is None or time_to is None: + return None + if time_from > time_to: + return ToolResult( + success=False, + error="TDP threat monitor requires time_from to be less than or equal to time_to.", + ) + if time_to - time_from > 24 * 60 * 60: + return ToolResult( + success=False, + error="TDP threat monitor time range cannot exceed 24 hours.", + ) + return None + + def _dict_copy(value: Any) -> dict[str, Any]: if isinstance(value, dict): return dict(value) @@ -1585,6 +1630,46 @@ async def inbound_attack( ) +async def threat_monitor_list( + context: ToolContext, + condition: dict[str, Any] | None = None, + page: dict[str, Any] | None = None, + time_from: int | None = None, + time_to: int | None = None, + sql: str | None = None, + size: int | None = None, + assets_group: list[Any] | None = None, + net_data_type: list[Any] | None = None, + refresh_rate: int | None = None, + cur_page: int | None = None, + page_size: int | None = None, +) -> ToolResult: + del context + validation_error = _validate_threat_monitor_time_range(time_from, time_to) + if validation_error: + return validation_error + validation_error = _validate_log_sql_expression("threat_monitor_list", sql) + if validation_error: + return validation_error + + body = _compose_payload(condition=condition, page=page) + condition_dict = body.setdefault("condition", {}) + _set_if_present(condition_dict, "time_from", time_from) + _set_if_present(condition_dict, "time_to", time_to) + _set_if_present(condition_dict, "sql", sql) + _set_if_present(condition_dict, "size", size) + _set_list_if_present(condition_dict, "assets_group", assets_group) + _set_list_if_present(condition_dict, "net_data_type", net_data_type) + _set_if_present(condition_dict, "refresh_rate", refresh_rate) + _set_page_overrides(body, cur_page=cur_page, page_size=page_size) + return await _run_json_tool( + "threat_monitor_list", + "/api/v1/monitor/threat/list", + _threat_monitor_body, + body, + ) + + async def login_entry_list( context: ToolContext, action: str = "list", @@ -1891,6 +1976,17 @@ async def platform_config( rule: dict[str, Any] | None = None, ips: list[Any] | None = None, keyword: str | None = None, + custom_rule: dict[str, Any] | None = None, + custom_rule_ids: list[Any] | None = None, + delete_alert_data: int | None = None, + custom_rule_status: list[Any] | None = None, + custom_rule_severity: list[Any] | None = None, + custom_rule_result: list[Any] | None = None, + custom_rule_keyword: str | None = None, + cur_page: int | None = None, + page_size: int | None = None, + sort_by: str | None = None, + sort_order: str | None = None, ) -> ToolResult: del context selected_action = _normalize_action(action, "asset_list") @@ -1906,6 +2002,27 @@ async def platform_config( body["lock"] = dict(lock) elif selected_action.startswith("white_rule_") and selected_action != "white_rule_search": body = _dict_copy(rule) + elif selected_action == "custom_rule_list": + body = _compose_payload(condition=condition, page=page) + condition_dict = body.setdefault("condition", {}) + _set_list_if_present(condition_dict, "status", custom_rule_status) + _set_list_if_present(condition_dict, "threat_severity", custom_rule_severity) + _set_list_if_present(condition_dict, "threat_result", custom_rule_result) + _set_keyword_fuzzy(condition_dict, custom_rule_keyword, CUSTOM_RULE_FUZZY_FIELDS) + _set_page_overrides( + body, + cur_page=cur_page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) + elif selected_action in {"custom_rule_add", "custom_rule_update"}: + body = _dict_copy(custom_rule) + elif selected_action == "custom_rule_delete": + body = {} + if custom_rule_ids is not None: + body["suuid"] = list(custom_rule_ids) + _set_if_present(body, "delete_alert_data", delete_alert_data) elif selected_action == "cascade_children": body = {} _set_if_present(body, "keyword", keyword) @@ -1924,6 +2041,32 @@ async def platform_config( validation_error = _validate_required_body_fields(selected_action, body, "id") if validation_error: return validation_error + elif selected_action in {"custom_rule_add", "custom_rule_update"}: + required_fields = [ + "threat_name", + "threat_msg", + "threat_severity", + "threat_type", + "threat_result", + "directions", + "body", + "attacker", + "status", + ] + if selected_action == "custom_rule_update": + required_fields.insert(0, "suuid") + validation_error = _validate_required_body_fields(selected_action, body, *required_fields) + if validation_error: + return validation_error + elif selected_action == "custom_rule_delete": + validation_error = _validate_non_empty_list_body( + selected_action, + body, + fallback_keys=("suuid",), + label="custom rule suuid list", + ) + if validation_error: + return validation_error return await _run_action_json_tool( PLATFORM_CONFIG_ACTIONS, default_action="asset_list", diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml index dae0f2cab..dfd7c12f9 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_platform_config.yaml @@ -1,9 +1,10 @@ name: tdp_platform_config description: > - 管理 TDP 平台配置相关接口,覆盖资产配置、白名单过滤和级联平台管理。 - 通过 action 可切换到资产列表、资产增删改、白名单搜索与维护、子节点列表和处置日志等子接口; + 管理 TDP 平台配置相关接口,覆盖资产配置、白名单过滤、级联平台管理和自定义规则。 + 通过 action 可切换到资产列表、资产增删改、白名单搜索与维护、子节点列表、自定义规则和处置日志等子接口; 其中 `asset_add`、`asset_update`、`asset_delete`、`white_rule_add`、 - `white_rule_update`、`white_rule_delete` 属于写操作,调用前应确认影响范围。 + `white_rule_update`、`white_rule_delete`、`custom_rule_add`、`custom_rule_update`、 + `custom_rule_delete` 属于写操作,调用前应确认影响范围。 category: custom enabled: true requires_confirmation: true @@ -17,7 +18,9 @@ inputSchema: description: > 平台配置子接口动作。`asset_list` / `white_rule_search` 会补默认分页; `asset_delete` 需要非空资产 IP 列表;`cascade_children` - 使用根级 `keyword` 做子节点模糊查询;其余动作使用对应的显式对象参数。 + 使用根级 `keyword` 做子节点模糊查询;`custom_rule_list` 使用独立的 + `custom_rule_*` 筛选参数,新增与编辑通过 `custom_rule` 传完整规则对象, + 删除通过 `custom_rule_ids` 传规则标识列表。 enum: - asset_list - asset_add @@ -28,6 +31,10 @@ inputSchema: - white_rule_update - white_rule_delete - cascade_children + - custom_rule_list + - custom_rule_add + - custom_rule_update + - custom_rule_delete - disposal_log_list default: asset_list condition: @@ -53,6 +60,55 @@ inputSchema: keyword: type: string description: 级联平台子节点搜索关键词,适用于 `cascade_children`。 + custom_rule: + type: object + description: > + 自定义规则对象,适用于 `custom_rule_add`、`custom_rule_update`。新增必填字段为 + `threat_name`、`threat_msg`、`threat_severity`、`threat_type`、`threat_result`、 + `directions`、`body`、`attacker`、`status`;编辑还必须包含 `suuid`。 + custom_rule_ids: + type: array + items: + type: string + description: 自定义规则 `suuid` 列表,适用于 `custom_rule_delete`。 + delete_alert_data: + type: integer + enum: [0, 1] + description: 删除自定义规则时是否同时删除关联告警数据;0=否,1=是。 + custom_rule_status: + type: array + items: + type: integer + enum: [0, 1] + description: 自定义规则状态筛选;0=禁用,1=启用,适用于 `custom_rule_list`。 + custom_rule_severity: + type: array + items: + type: integer + enum: [1, 2, 3, 4] + description: 自定义规则严重级别筛选;1=低危,2=中危,3=高危,4=严重。 + custom_rule_result: + type: array + items: + type: string + enum: [success, failed, unknown] + description: 自定义规则检出结果筛选。 + custom_rule_keyword: + type: string + description: 自定义规则关键词,handler 会在 `threat_name`、`threat_msg` 字段中模糊查询。 + cur_page: + type: integer + description: 当前页码,适用于 `custom_rule_list`。 + page_size: + type: integer + description: 每页条数,适用于 `custom_rule_list`。 + sort_by: + type: string + description: 排序字段,适用于 `custom_rule_list`,默认 `updated_time`。 + sort_order: + type: string + enum: [asc, desc] + description: 排序方向,适用于 `custom_rule_list`,默认 `desc`。 required: [] handler: type: script diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml new file mode 100644 index 000000000..2830e3a1f --- /dev/null +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml @@ -0,0 +1,57 @@ +name: tdp_threat_monitor_list +description: > + 查询 TDP 威胁-实时监控列表,对应正式 API `/api/v1/monitor/threat/list`。 + 查询时间范围不得超过 24 小时;未传时间时 handler 动态使用最近 24 小时。 + `sql` 是 TDP 过滤表达式,不是包含 SELECT/FROM 的完整 SQL。 +category: custom +enabled: true +requires_confirmation: false +provider: tdp_api + +inputSchema: + type: object + properties: + time_from: + type: integer + description: 开始时间戳,Unix 秒级时间戳;与 `time_to` 同时传入,范围不得超过 24 小时。 + time_to: + type: integer + description: 截止时间戳,Unix 秒级时间戳;与 `time_from` 同时传入,范围不得超过 24 小时。 + sql: + type: string + description: > + 告警过滤表达式,例如 `threat.type = 'c2'`。不得使用 SELECT、FROM 等完整 SQL 语法。 + size: + type: integer + description: 返回的最大记录条数;未传时使用文档默认值 1000。 + assets_group: + type: array + items: + type: integer + description: 业务组 ID 列表。 + net_data_type: + type: array + items: + type: string + enum: [attack, risk, action] + description: 日志类型列表;attack=攻击、risk=风险、action=敏感行为。 + refresh_rate: + type: integer + description: 自动刷新间隔;`-1` 表示关闭自动刷新,其他值可能使响应返回 `new_alert_count`。 + cur_page: + type: integer + description: 当前页码,默认 1。 + page_size: + type: integer + description: 每页条数,默认 20。 + condition: + type: object + description: 高级兼容参数,仅在顶层语义化参数未覆盖目标字段时使用。 + page: + type: object + description: 高级兼容分页参数,仅在需要自定义底层分页结构时使用。 + required: [] +handler: + type: script + script_file: tdp.handler.py + function: threat_monitor_list diff --git a/README.md b/README.md index fe03c6307..32e0acd68 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@

Docs - Release + License

- License English Chinese

@@ -125,10 +124,9 @@ flocks restart flocks stop ``` -The default service URLs are: -- Backend API: `http://127.0.0.1:8000` by default -- WebUI: `http://127.0.0.1:5173` by default -- Remote access configurable via `flocks start --webui-host ` +The default service address is: +- Flocks WebUI: `http://127.0.0.1:5173` +- Remote access configurable via `flocks start --host ` Flocks CLI usage: `flocks --help` @@ -213,16 +211,13 @@ sudo chown -R : ~/.flocks ### 4.3 Remote Access to Flocks Service ```bash -__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS= \ -flocks start --webui-host 0.0.0.0 -# windows powershell -# $env:__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="your_domain"; flocks start --webui-host 0.0.0.0 +flocks start --host 0.0.0.0 ``` If remote access from a virtual machine fails, please specify the host as the virtual machine's IP. -The WebUI now defaults to same-origin `/api` proxy mode even when the backend -binds to a non-loopback IP. This keeps browser cookies and SSE on a single -origin, which is the safest choice for LAN access and reverse proxies. +The WebUI and API share the same service address and port. API requests use the +same-origin `/api` path, keeping browser cookies and SSE on a single origin for +LAN access and reverse-proxy deployments. Only enable direct browser-to-backend URLs when you explicitly need them: diff --git a/README_zh.md b/README_zh.md index 0c05f3966..84ef87929 100644 --- a/README_zh.md +++ b/README_zh.md @@ -6,11 +6,10 @@

文档 - Release + License

- License English 中文

@@ -125,9 +124,8 @@ flocks stop ``` 默认服务地址: -- 后端 API:默认 `http://127.0.0.1:8000` -- WebUI:默认 `http://127.0.0.1:5173` -- 远程访问可通过 `flocks start --webui-host ` 配置 +- Flocks WebUI:`http://127.0.0.1:5173` +- 远程访问可通过 `flocks start --host ` 配置 更多 CLI 命令使用 `flocks --help` @@ -214,14 +212,11 @@ sudo chown -R : ~/.flocks ### 4.3 远程访问 Flocks 服务 ```bash -__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS= \ -flocks start --webui-host 0.0.0.0 -# Windows PowerShell -# $env:__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="your_domain"; flocks start --webui-host 0.0.0.0 +flocks start --host 0.0.0.0 ``` 若从虚拟机远程访问失败,请将 host 指定为虚拟机的 IP。 -WebUI 在后端绑定到非回环 IP 时,默认仍使用同源 `/api` 代理模式。这样浏览器 Cookie 与 SSE 保持在同一源,是局域网访问与反向代理场景下更安全的选择。 +WebUI 与 API 共用同一个服务地址和端口,API 请求使用同源 `/api` 路径。这样浏览器 Cookie 与 SSE 保持在同一源,适用于局域网访问与反向代理场景。 仅在确实需要浏览器直连后端 URL 时,再显式启用: diff --git a/flocks/agent/agents/rex/prompt_builder.py b/flocks/agent/agents/rex/prompt_builder.py index 4435432c0..281234bbc 100644 --- a/flocks/agent/agents/rex/prompt_builder.py +++ b/flocks/agent/agents/rex/prompt_builder.py @@ -432,5 +432,5 @@ def _build_security_priority_section(available_agents: List["AvailableAgent"]) - def _build_im_send_pointer_section() -> str: return """### IM Messaging -When the user wants to send a message to an IM platform, call `im_send_message`. -When creating a scheduled task that sends to an IM platform later, resolve the target `session_id` with `im_send_message(resolve_only=true)` before calling `schedule_task_create`.""" +When the user wants to send a message to a connected messaging channel (including IM platforms and email), call `im_send_message`. +When creating a scheduled task that sends to a connected messaging channel later, resolve the target `session_id` with `im_send_message(resolve_only=true)` before calling `schedule_task_create`.""" diff --git a/flocks/agent/registry.py b/flocks/agent/registry.py index c2b001985..b5b004b46 100644 --- a/flocks/agent/registry.py +++ b/flocks/agent/registry.py @@ -252,7 +252,7 @@ async def _load_agents() -> Dict[str, AgentInfo]: from flocks.tool.registry import ToolRegistry cfg = await Config.get() - ToolRegistry.init() + await ToolRegistry.init_async() # ── ① Collect context ───────────────────────────────────────────── available_tools = [t.name for t in ToolRegistry.list_tools() if t.enabled] @@ -303,7 +303,7 @@ async def _load_agents() -> Dict[str, AgentInfo]: log.warn("agent.logic.deprecated", {"message": "non-rex agent logic is deprecated; using rex"}) # ── ② YAML agents ───────────────────────────────────────────────── - result = scan_and_load() + result = await asyncio.to_thread(scan_and_load) # Apply global base permissions on top of per-agent YAML permissions. # For agents with explicit tool lists, their deny-all baseline is kept; diff --git a/flocks/browser/_ipc.py b/flocks/browser/_ipc.py index c124664c3..06a0abecd 100644 --- a/flocks/browser/_ipc.py +++ b/flocks/browser/_ipc.py @@ -1,58 +1,149 @@ -"""Daemon IPC plumbing. AF_UNIX on POSIX, TCP loopback on Windows.""" +"""Authenticated daemon IPC over AF_UNIX on POSIX and loopback TCP on Windows.""" + +from __future__ import annotations import asyncio +import json import os -import re +import secrets import socket import subprocess import sys +import tempfile +from collections.abc import Awaitable, Callable from pathlib import Path +from typing import Any + +from .paths import BrowserRuntimePaths, browser_dir, resolve_name, validate_name IS_WINDOWS = sys.platform == "win32" -BU_TMP_DIR = str(Path.home() / ".flocks" / "browser") -_TMP = Path(BU_TMP_DIR).expanduser() -_TMP.mkdir(parents=True, exist_ok=True) -_NAME_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") +PROTOCOL_VERSION = 1 +MAX_RESPONSE_BYTES = 64 * 1024 * 1024 +LOCK_BUSY_EXIT_CODE = 75 + +# Compatibility aliases for callers that only need the default location. Runtime +# operations resolve paths lazily so FLOCKS_ROOT changes remain testable. +BU_TMP_DIR = str(browser_dir()) + + +class IPCProtocolError(RuntimeError): + """Raised when an endpoint does not speak the current daemon protocol.""" + + +class LegacyProtocolError(IPCProtocolError): + """Raised when a responsive daemon explicitly lacks the ping protocol.""" + +class EndpointRecordError(RuntimeError): + """Raised when a local Windows endpoint record is malformed.""" -def _check(name: str) -> str: - """Validate daemon names used in socket paths and filenames.""" - if not _NAME_RE.match(name or ""): - raise ValueError(f"invalid BU_NAME {name!r}: must match [A-Za-z0-9_-]{{1,64}}") - return name +class DaemonLock: + """Cross-platform advisory lock held for a daemon's entire lifetime.""" -def _stem(name: str) -> str: - """Return the daemon file stem for the given browser session name.""" - _check(name) - return "bu" if BU_TMP_DIR else f"bu-{name}" + def __init__(self, name: str | None = None) -> None: + self.paths = BrowserRuntimePaths.resolve(name) + self._file = None + self.acquired = False + def acquire(self, blocking: bool = False) -> bool: + if self.acquired: + return True + self.paths.ensure_root() + handle = self.paths.lock.open("a+b") + try: + os.chmod(self.paths.lock, 0o600) + except OSError: + pass + try: + if IS_WINDOWS: + import msvcrt + + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK + msvcrt.locking(handle.fileno(), mode, 1) + else: + import fcntl + + flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) + fcntl.flock(handle.fileno(), flags) + except (OSError, BlockingIOError): + handle.close() + return False + self._file = handle + self.acquired = True + return True + + def release(self) -> None: + if not self.acquired or self._file is None: + return + try: + if IS_WINDOWS: + import msvcrt -def log_path(name: str) -> Path: - return _TMP / f"{_stem(name)}.log" + self._file.seek(0) + msvcrt.locking(self._file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(self._file.fileno(), fcntl.LOCK_UN) + finally: + self._file.close() + self._file = None + self.acquired = False -def pid_path(name: str) -> Path: - return _TMP / f"{_stem(name)}.pid" + def __enter__(self) -> DaemonLock: + if not self.acquire(): + raise BlockingIOError(f"browser daemon lock is held: {self.paths.lock}") + return self + def __exit__(self, _exc_type, _exc, _traceback) -> None: + self.release() -def port_path(name: str) -> Path: - return _TMP / f"{_stem(name)}.port" +def runtime_paths(name: str | None = None) -> BrowserRuntimePaths: + return BrowserRuntimePaths.resolve(name) -def _sock_path(name: str) -> Path: - return _TMP / f"{_stem(name)}.sock" +def runtime_dir() -> Path: + return browser_dir() -def sock_addr(name: str) -> str: + +def log_path(name: str | None = None) -> Path: + return runtime_paths(name).log + + +def pid_path(name: str | None = None) -> Path: + return runtime_paths(name).pid + + +def port_path(name: str | None = None) -> Path: + return runtime_paths(name).port + + +def screenshot_path(name: str | None = None) -> Path: + return runtime_paths(name).screenshot + + +def debug_screenshot_path(sequence: int, name: str | None = None) -> Path: + return runtime_paths(name).debug_screenshot(sequence) + + +def sock_addr(name: str | None = None) -> str: """Return a human-readable endpoint address for logs.""" + paths = runtime_paths(name) if not IS_WINDOWS: - return str(_sock_path(name)) + return str(paths.socket) try: - return f"127.0.0.1:{port_path(name).read_text().strip()}" - except FileNotFoundError: - return f"tcp:{_stem(name)}" + port, _token = _read_port_record(paths.port) + return f"127.0.0.1:{port}" + except (FileNotFoundError, EndpointRecordError): + return f"tcp:{paths.stem}" def spawn_kwargs() -> dict[str, object]: @@ -62,51 +153,236 @@ def spawn_kwargs() -> dict[str, object]: return {"start_new_session": True} -def connect(name: str, timeout: float = 1.0) -> socket.socket: - """Connect to a browser daemon endpoint.""" +def _read_port_record(path: Path) -> tuple[int, str | None]: + raw = path.read_text(encoding="utf-8-sig", errors="replace").strip() + try: + record = json.loads(raw) + except json.JSONDecodeError: + record = raw + if isinstance(record, int) and not isinstance(record, bool): + port = record + token = None + elif isinstance(record, str): + try: + port = int(record) + except ValueError as error: + raise EndpointRecordError(f"invalid browser daemon port file: {path}") from error + token = None + elif isinstance(record, dict): + port = record.get("port") + token = record.get("token") + if type(port) is not int or not isinstance(token, str) or not token: + raise EndpointRecordError(f"invalid browser daemon port record: {path}") + else: + raise EndpointRecordError(f"invalid browser daemon port record: {path}") + if not 0 < port < 65536: + raise EndpointRecordError(f"invalid browser daemon port: {port}") + return port, token + + +def _connect_with_token(name: str | None, timeout: float) -> tuple[socket.socket, str | None]: + paths = runtime_paths(name) if not IS_WINDOWS: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(timeout) - sock.connect(str(_sock_path(name))) - return sock - try: - port = int(port_path(name).read_text().strip()) - except (FileNotFoundError, ValueError) as error: - raise FileNotFoundError(str(port_path(name))) from error + try: + sock.connect(str(paths.socket)) + except Exception: + sock.close() + raise + return sock, None + port, token = _read_port_record(paths.port) sock = socket.create_connection(("127.0.0.1", port), timeout=timeout) sock.settimeout(timeout) + return sock, token + + +def connect(name: str | None = None, timeout: float = 1.0) -> socket.socket: + """Connect to a browser daemon endpoint.""" + sock, _token = _connect_with_token(name, timeout) return sock -async def serve(name: str, handler) -> None: - """Serve daemon requests until cancelled.""" - if not IS_WINDOWS: - path = str(_sock_path(name)) - if os.path.exists(path): - os.unlink(path) - server = await asyncio.start_unix_server(handler, path=path) - os.chmod(path, 0o600) - async with server: - await asyncio.Event().wait() - return +def request(name: str | None, payload: dict[str, Any], timeout: float = 3.0) -> dict[str, Any]: + """Send one JSON request and return one JSON response.""" + sock, token = _connect_with_token(name, timeout) + try: + outgoing = dict(payload) + if token: + outgoing["_ipc_token"] = token + sock.sendall((json.dumps(outgoing, separators=(",", ":")) + "\n").encode("utf-8")) + data = bytearray() + while not data.endswith(b"\n"): + chunk = sock.recv(1 << 16) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_RESPONSE_BYTES: + raise IPCProtocolError("browser daemon response exceeded size limit") + if not data: + raise IPCProtocolError("browser daemon returned an empty response") + response = json.loads(data.decode("utf-8", errors="replace")) + if not isinstance(response, dict): + raise IPCProtocolError("browser daemon response must be a JSON object") + return response + except json.JSONDecodeError as error: + raise IPCProtocolError("browser daemon returned invalid JSON") from error + finally: + sock.close() + - server = await asyncio.start_server(handler, "127.0.0.1", 0) - port_file = port_path(name) - port_file.write_text(str(server.sockets[0].getsockname()[1])) +def identify(name: str | None = None, timeout: float = 1.0) -> dict[str, Any]: + """Return a strictly validated identity for a current daemon.""" + expected_name = resolve_name(name) + response = request(expected_name, {"meta": "ping"}, timeout=timeout) + pid = response.get("pid") + protocol_version = response.get("protocol_version") + instance_id = response.get("instance_id") + browser_kind = response.get("browser_kind") + if response.get("pong") is not True: + error = str(response.get("error") or "") + if error.startswith("unknown meta") or error in {"'method'", "method"}: + raise LegacyProtocolError("browser daemon predates the ping protocol") + raise IPCProtocolError("browser daemon does not support ping") + if response.get("name") != expected_name: + raise IPCProtocolError("browser daemon session identity mismatch") + if type(pid) is not int or pid <= 0: + raise IPCProtocolError("browser daemon returned an invalid PID") + if type(protocol_version) is not int or protocol_version < 1: + raise IPCProtocolError("browser daemon returned an invalid protocol version") + if not isinstance(instance_id, str) or not instance_id: + raise IPCProtocolError("browser daemon returned an invalid instance ID") + if browser_kind not in {"local", "cdp"}: + raise IPCProtocolError("browser daemon returned an invalid browser kind") + return response + + +def endpoint_reachable(name: str | None = None, timeout: float = 1.0) -> bool: try: - async with server: - await asyncio.Event().wait() + sock = connect(name, timeout=timeout) + except ( + EndpointRecordError, + IPCProtocolError, + FileNotFoundError, + ConnectionRefusedError, + TimeoutError, + socket.timeout, + OSError, + ): + return False + sock.close() + return True + + +def atomic_write_text(path: Path, text: str) -> None: + """Atomically replace a private UTF-8 text file.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temp_name, 0o600) + os.replace(temp_name, path) finally: try: - port_file.unlink() + os.unlink(temp_name) except FileNotFoundError: pass -def cleanup_endpoint(name: str) -> None: - """Best-effort daemon endpoint cleanup.""" - path = _sock_path(name) if not IS_WINDOWS else port_path(name) +def write_pid(name: str | None, pid: int) -> None: + paths = runtime_paths(name) + paths.ensure_root() + atomic_write_text(paths.pid, str(pid)) + + +async def serve( + name: str | None, + handler: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]], + lock: DaemonLock, +) -> None: + """Serve authenticated JSON requests while the caller owns ``lock``.""" + paths = runtime_paths(name) + if not lock.acquired or lock.paths != paths: + raise RuntimeError("browser daemon endpoint requires its acquired session lock") + token = secrets.token_urlsafe(32) if IS_WINDOWS else None + + async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + line = await reader.readline() + if not line: + return + req = json.loads(line.decode("utf-8", errors="replace")) + if not isinstance(req, dict): + raise IPCProtocolError("request must be a JSON object") + supplied_token = req.pop("_ipc_token", None) + if token is not None and not secrets.compare_digest(str(supplied_token or ""), token): + response = {"error": "unauthorized browser daemon request"} + else: + response = await handler(req) + writer.write((json.dumps(response, default=str) + "\n").encode("utf-8")) + await writer.drain() + except Exception as error: + try: + writer.write((json.dumps({"error": str(error)}) + "\n").encode("utf-8")) + await writer.drain() + except Exception: + pass + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + server = None try: - path.unlink() - except FileNotFoundError: - pass + paths.ensure_root() + if not IS_WINDOWS: + paths.socket.unlink(missing_ok=True) + old_umask = os.umask(0o077) + try: + server = await asyncio.start_unix_server(handle_client, path=str(paths.socket)) + finally: + os.umask(old_umask) + os.chmod(paths.socket, 0o600) + else: + server = await asyncio.start_server(handle_client, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + atomic_write_text(paths.port, json.dumps({"port": port, "token": token})) + async with server: + await server.serve_forever() + finally: + if server is not None: + server.close() + await server.wait_closed() + cleanup_endpoint(name, lock) + + +def cleanup_endpoint(name: str | None, lock: DaemonLock) -> None: + """Remove an endpoint only while holding its session lock.""" + paths = runtime_paths(name) + if not lock.acquired or lock.paths != paths: + raise RuntimeError("browser daemon endpoint cleanup requires its acquired session lock") + path = paths.port if IS_WINDOWS else paths.socket + path.unlink(missing_ok=True) + + +def discover_names() -> list[str]: + """Return session names with endpoint or PID artifacts.""" + root = runtime_dir() + if not root.is_dir(): + return [] + names: set[str] = set() + for suffix in {".port", ".pid"} if IS_WINDOWS else {".sock", ".pid"}: + if (root / f"bu{suffix}").exists(): + names.add("default") + for path in root.glob(f"bu-*{suffix}"): + raw = path.name[3 : -len(suffix)] + try: + names.add(validate_name(raw)) + except ValueError: + continue + return sorted(names, key=lambda name: (name != "default", name)) diff --git a/flocks/browser/admin.py b/flocks/browser/admin.py index 03535e459..0ed418e08 100644 --- a/flocks/browser/admin.py +++ b/flocks/browser/admin.py @@ -5,14 +5,18 @@ import socket import tempfile import time +import urllib.parse +import urllib.request from pathlib import Path +from websockets.sync.client import connect as websocket_connect + from . import BROWSER_LABEL, PROJECT_ROOT, get_browser_version from . import _ipc as ipc +from . import lifecycle from .utils import load_env_file -NAME = os.environ.get("BU_NAME", "default") VERSION_CACHE = Path(tempfile.gettempdir()) / "flocks-browser-version-cache.json" VERSION_CACHE_TTL = 24 * 3600 DOCTOR_TEXT_LIMIT = 140 @@ -31,12 +35,15 @@ def _load_env() -> None: continue load_env_file(path) + _load_env() +NAME = ipc.runtime_paths().name + def _log_tail(name: str | None): try: - return ipc.log_path(name or NAME).read_text().strip().splitlines()[-1] + return ipc.log_path(name or NAME).read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1] except (FileNotFoundError, IndexError): return None @@ -63,46 +70,66 @@ def _configured_cdp_endpoint(env: dict | None = None) -> tuple[str | None, str | return None, None +def _probe_cdp_websocket(url: str, timeout: float = 3.0) -> tuple[bool, str]: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in {"ws", "wss"} or not parsed.hostname: + return False, "invalid WebSocket URL" + try: + with websocket_connect(url, open_timeout=timeout, close_timeout=timeout) as websocket: + websocket.send(json.dumps({"id": 1, "method": "Browser.getVersion"})) + response = json.loads(websocket.recv(timeout=timeout)) + if not isinstance(response, dict) or response.get("id") != 1 or not isinstance(response.get("result"), dict): + return False, "Browser.getVersion returned an invalid response" + except Exception as error: + return False, f"connection failed ({type(error).__name__})" + return True, "CDP handshake succeeded" + + +def _probe_cdp_endpoint(name: str, value: str, timeout: float = 3.0) -> tuple[bool, str]: + """Verify that an explicit CDP endpoint is reachable and speaks CDP.""" + if name == "BU_CDP_WS": + return _probe_cdp_websocket(value, timeout) + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return False, "invalid HTTP URL" + try: + with urllib.request.urlopen(f"{value.rstrip('/')}/json/version", timeout=timeout) as response: + payload = json.loads(response.read()) + websocket_url = payload.get("webSocketDebuggerUrl") + if not isinstance(websocket_url, str): + return False, "/json/version did not return webSocketDebuggerUrl" + except Exception as error: + return False, f"connection failed ({type(error).__name__})" + return _probe_cdp_websocket(websocket_url, timeout) + + def _is_local_chrome_mode(env: dict | None = None) -> bool: return _configured_cdp_endpoint(env)[0] is None def daemon_alive(name: str | None = None) -> bool: try: - conn = ipc.connect(name or NAME, timeout=1.0) - conn.close() + ipc.identify(name or NAME, timeout=1.0) return True - except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError): + except ( + ipc.EndpointRecordError, + ipc.IPCProtocolError, + FileNotFoundError, + ConnectionRefusedError, + TimeoutError, + socket.timeout, + OSError, + ): return False def _daemon_endpoint_names() -> list[str]: - suffix = ".port" if ipc.IS_WINDOWS else ".sock" - if ipc.BU_TMP_DIR: - return [NAME] if (ipc._TMP / f"bu{suffix}").exists() else [] - names: list[str] = [] - for path in sorted(ipc._TMP.glob(f"bu-*{suffix}")): - raw = path.name[3 : -len(suffix)] - try: - ipc._check(raw) - except ValueError: - continue - names.append(raw) - return names + return ipc.discover_names() def _daemon_browser_connection(name: str): - conn = None try: - conn = ipc.connect(name, timeout=1.0) - conn.sendall(b'{"meta":"connection_status"}\n') - data = b"" - while not data.endswith(b"\n"): - chunk = conn.recv(1 << 16) - if not chunk: - break - data += chunk - response = json.loads(data) + response = ipc.request(name, {"meta": "connection_status"}, timeout=1.0) if "error" in response: return None page = response.get("page") @@ -110,6 +137,8 @@ def _daemon_browser_connection(name: str): page = {"title": page.get("title") or "(untitled)", "url": page.get("url") or ""} return {"name": name, "page": page} except ( + ipc.EndpointRecordError, + ipc.IPCProtocolError, FileNotFoundError, ConnectionRefusedError, TimeoutError, @@ -120,9 +149,12 @@ def _daemon_browser_connection(name: str): json.JSONDecodeError, ): return None - finally: - if conn: - conn.close() + + +def daemon_browser_connected(name: str | None = None) -> bool: + """Return whether the selected daemon still has a live browser connection.""" + effective_name = ipc.runtime_paths(name or NAME).name + return _daemon_browser_connection(effective_name) is not None def browser_connections() -> list[dict]: @@ -140,18 +172,11 @@ def active_browser_connections() -> int: def _daemon_probe(name: str | None, req: dict) -> dict | None: - conn = None try: - conn = ipc.connect(name or NAME, timeout=3.0) - conn.sendall((json.dumps(req) + "\n").encode()) - data = b"" - while not data.endswith(b"\n"): - chunk = conn.recv(1 << 16) - if not chunk: - break - data += chunk - return json.loads(data) + return ipc.request(name or NAME, req, timeout=3.0) except ( + ipc.EndpointRecordError, + ipc.IPCProtocolError, FileNotFoundError, ConnectionRefusedError, TimeoutError, @@ -162,9 +187,6 @@ def _daemon_probe(name: str | None, req: dict) -> dict | None: json.JSONDecodeError, ): return None - finally: - if conn: - conn.close() def _daemon_has_current_protocol(name: str | None = None) -> bool: @@ -178,9 +200,12 @@ def _daemon_has_current_protocol(name: str | None = None) -> bool: def stop_all_daemons() -> list[str]: """Stop all browser daemons visible from the current environment.""" - names = _daemon_endpoint_names() - for name in names: - restart_daemon(name) + names, errors = lifecycle.stop_all_daemons() + if errors: + import sys + + for name, error in errors.items(): + print(f"{BROWSER_LABEL}: failed to stop browser session {name!r}: {error}", file=sys.stderr) return names @@ -194,36 +219,60 @@ def ensure_daemon( wait: float = 60.0, name: str | None = None, env: dict | None = None, _open_inspect: bool = True ) -> None: """Ensure a healthy daemon is running, restarting stale sessions when needed.""" - if daemon_alive(name): - if _daemon_has_current_protocol(name): + effective_name = ipc.runtime_paths(name or NAME).name + if ipc.endpoint_reachable(effective_name): + if daemon_alive(effective_name) and _daemon_has_current_protocol(effective_name): return - restart_daemon(name) + lifecycle.stop_daemon(effective_name) import subprocess import sys local = _is_local_chrome_mode(env) for attempt in (0, 1): - merged_env = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})} - proc = subprocess.Popen( - [sys.executable, "-m", "flocks.browser.daemon"], - env=merged_env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - **ipc.spawn_kwargs(), - ) + merged_env = { + **os.environ, + **(env or {}), + "BU_NAME": effective_name, + "PYTHONIOENCODING": "utf-8:replace", + } + paths = ipc.runtime_paths(effective_name) + paths.ensure_root() + + def spawn_daemon(): + with paths.log.open("ab", buffering=0) as daemon_log: + return subprocess.Popen( + [sys.executable, "-m", "flocks.browser.daemon"], + env=merged_env, + stdout=daemon_log, + stderr=subprocess.STDOUT, + **ipc.spawn_kwargs(), + ) + + proc = spawn_daemon() deadline = time.time() + wait while time.time() < deadline: - if daemon_alive(name): + if daemon_alive(effective_name): return - if proc.poll() is not None: + return_code = proc.poll() + if return_code == ipc.LOCK_BUSY_EXIT_CODE: + retry_lock = ipc.DaemonLock(effective_name) + if retry_lock.acquire(): + retry_lock.release() + proc = spawn_daemon() + else: + time.sleep(0.2) + continue + if return_code is not None: break time.sleep(0.2) - msg = _log_tail(name) or "" + msg = _log_tail(effective_name) or "" if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg): - restart_daemon(name) + restart_daemon(effective_name) if not _open_inspect: - raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}") + raise RuntimeError( + msg or f"daemon {effective_name} didn't come up -- check {ipc.log_path(effective_name)}" + ) _open_browser_inspect() print( f"{BROWSER_LABEL}: click Allow on your browser's inspect page " @@ -231,42 +280,12 @@ def ensure_daemon( file=sys.stderr, ) continue - raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}") + raise RuntimeError(msg or f"daemon {effective_name} didn't come up -- check {ipc.log_path(effective_name)}") def restart_daemon(name: str | None = None) -> None: - """Best-effort daemon shutdown and endpoint cleanup.""" - import signal - - pid_file = str(ipc.pid_path(name or NAME)) - try: - conn = ipc.connect(name or NAME, timeout=5.0) - conn.sendall(b'{"meta":"shutdown"}\n') - conn.recv(1024) - conn.close() - except Exception: - pass - try: - pid = int(Path(pid_file).read_text()) - except (FileNotFoundError, ValueError): - pid = None - if pid: - for _ in range(75): - try: - os.kill(pid, 0) - time.sleep(0.2) - except (ProcessLookupError, OSError, SystemError): - break - else: - try: - os.kill(pid, signal.SIGTERM) - except (ProcessLookupError, OSError, SystemError): - pass - ipc.cleanup_endpoint(name or NAME) - try: - os.unlink(pid_file) - except FileNotFoundError: - pass + """Safely stop a daemon and clean only artifacts protected by its lock.""" + lifecycle.stop_daemon(name or NAME) def _version() -> str: @@ -288,14 +307,14 @@ def _install_mode() -> str: def _cache_read() -> dict: try: - return json.loads(VERSION_CACHE.read_text()) + return json.loads(VERSION_CACHE.read_text(encoding="utf-8-sig", errors="replace")) except (FileNotFoundError, ValueError): return {} def _cache_write(data: dict) -> None: try: - VERSION_CACHE.write_text(json.dumps(data)) + ipc.atomic_write_text(VERSION_CACHE, json.dumps(data)) except OSError: pass @@ -460,7 +479,10 @@ def run_doctor() -> int: current = _version() mode = _install_mode() browser_running = _chrome_running() - endpoint_name, _endpoint_value = _configured_cdp_endpoint() + endpoint_name, endpoint_value = _configured_cdp_endpoint() + endpoint_ok, endpoint_detail = (True, "") + if endpoint_name and endpoint_value: + endpoint_ok, endpoint_detail = _probe_cdp_endpoint(endpoint_name, endpoint_value) daemon = daemon_alive() connections = browser_connections() latest = _latest_release_tag() @@ -471,8 +493,10 @@ def row(label: str, ok: bool, detail: str = "") -> None: mark = "ok " if ok else "FAIL" print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}") - target_available = browser_running or bool(endpoint_name) - if connections: + target_available = endpoint_ok if endpoint_name else browser_running + if endpoint_name and not endpoint_ok: + next_action = f"fix {endpoint_name}, then run `flocks browser --setup`" + elif connections: next_action = "ready; use `flocks browser -c 'print(page_info())'`" elif target_available and daemon: next_action = "attach; run `flocks browser -c 'print(page_info())'` before setup" @@ -490,7 +514,10 @@ def row(label: str, ok: bool, detail: str = "") -> None: else: print(" latest release (not configured)") if endpoint_name: - row("browser target", True, f"configured via {endpoint_name}") + detail = ( + f"configured via {endpoint_name}" if endpoint_ok else f"{endpoint_name} is unreachable: {endpoint_detail}" + ) + row("browser target", endpoint_ok, detail) else: row( "browser running", diff --git a/flocks/browser/daemon.py b/flocks/browser/daemon.py index 836baa823..c735a6d50 100644 --- a/flocks/browser/daemon.py +++ b/flocks/browser/daemon.py @@ -3,10 +3,16 @@ import asyncio import json import os +import platform +import signal import socket import sys import time +import traceback +import urllib.error +import urllib.parse import urllib.request +import uuid from collections import deque from pathlib import Path @@ -18,37 +24,7 @@ AGENT_WORKSPACE = Path(os.environ.get("BH_AGENT_WORKSPACE", DEFAULT_AGENT_WORKSPACE)).expanduser() -NAME = os.environ.get("BU_NAME", "default") -SOCK = ipc.sock_addr(NAME) -LOG = str(ipc.log_path(NAME)) -PID = str(ipc.pid_path(NAME)) BUF = 500 -PROFILES = [ - Path.home() / "Library/Application Support/Google/Chrome", - Path.home() / "Library/Application Support/Comet", - Path.home() / "Library/Application Support/Arc/User Data", - Path.home() / "Library/Application Support/Microsoft Edge", - Path.home() / "Library/Application Support/Microsoft Edge Beta", - Path.home() / "Library/Application Support/Microsoft Edge Dev", - Path.home() / "Library/Application Support/Microsoft Edge Canary", - Path.home() / "Library/Application Support/BraveSoftware/Brave-Browser", - Path.home() / ".config/google-chrome", - Path.home() / ".config/chromium", - Path.home() / ".config/chromium-browser", - Path.home() / ".config/microsoft-edge", - Path.home() / ".config/microsoft-edge-beta", - Path.home() / ".config/microsoft-edge-dev", - Path.home() / ".var/app/org.chromium.Chromium/config/chromium", - Path.home() / ".var/app/com.google.Chrome/config/google-chrome", - Path.home() / ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser", - Path.home() / ".var/app/com.microsoft.Edge/config/microsoft-edge", - Path.home() / "AppData/Local/Google/Chrome/User Data", - Path.home() / "AppData/Local/Chromium/User Data", - Path.home() / "AppData/Local/Microsoft/Edge/User Data", - Path.home() / "AppData/Local/Microsoft/Edge Beta/User Data", - Path.home() / "AppData/Local/Microsoft/Edge Dev/User Data", - Path.home() / "AppData/Local/Microsoft/Edge SxS/User Data", -] INTERNAL = INTERNAL_URL_PREFIXES MARKER = "🟢" @@ -59,11 +35,79 @@ def _load_env() -> None: continue load_env_file(path) + _load_env() +NAME = ipc.runtime_paths().name +SOCK = ipc.sock_addr(NAME) +LOG = str(ipc.log_path(NAME)) +PID = str(ipc.pid_path(NAME)) + + +def profile_dirs( + system: str | None = None, + home: Path | None = None, + environ: dict[str, str] | None = None, +) -> list[Path]: + """Return only the browser profile directories for the active OS.""" + system = system or platform.system() + home = home or Path.home() + environ = os.environ if environ is None else environ + if system == "Darwin": + support = home / "Library/Application Support" + return [ + support / "Google/Chrome", + support / "Google/Chrome Canary", + support / "Comet", + support / "Arc/User Data", + support / "Microsoft Edge", + support / "Microsoft Edge Beta", + support / "Microsoft Edge Dev", + support / "Microsoft Edge Canary", + support / "BraveSoftware/Brave-Browser", + support / "Chromium", + ] + if system == "Windows": + local = Path(environ.get("LOCALAPPDATA", str(home / "AppData/Local"))).expanduser() + return [ + local / "Google/Chrome/User Data", + local / "Google/Chrome Beta/User Data", + local / "Google/Chrome Dev/User Data", + local / "Google/Chrome SxS/User Data", + local / "Chromium/User Data", + local / "Microsoft/Edge/User Data", + local / "Microsoft/Edge Beta/User Data", + local / "Microsoft/Edge Dev/User Data", + local / "Microsoft/Edge SxS/User Data", + local / "BraveSoftware/Brave-Browser/User Data", + local / "BraveSoftware/Brave-Browser-Beta/User Data", + local / "BraveSoftware/Brave-Browser-Nightly/User Data", + ] + return [ + home / ".config/google-chrome", + home / ".config/google-chrome-beta", + home / ".config/google-chrome-unstable", + home / ".config/chromium", + home / ".config/chromium-browser", + home / ".config/microsoft-edge", + home / ".config/microsoft-edge-beta", + home / ".config/microsoft-edge-dev", + home / ".config/BraveSoftware/Brave-Browser", + home / ".var/app/org.chromium.Chromium/config/chromium", + home / ".var/app/com.google.Chrome/config/google-chrome", + home / ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser", + home / ".var/app/com.microsoft.Edge/config/microsoft-edge", + ] + def log(msg: str) -> None: - Path(LOG).open("a", encoding="utf-8").write(f"{msg}\n") + paths = ipc.runtime_paths(NAME) + try: + paths.ensure_root() + with paths.log.open("a", encoding="utf-8", errors="replace") as handle: + handle.write(f"{msg}\n") + except OSError: + print(msg, file=sys.stderr) async def _silent(coro) -> None: @@ -73,6 +117,30 @@ async def _silent(coro) -> None: pass +def _local_cdp_ws_url(port: int, devtools_path: str | None = None) -> str: + """Return a validated local browser WebSocket URL from profile metadata.""" + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1) as response: + payload = json.loads(response.read()) + if not isinstance(payload, dict): + raise ValueError("invalid CDP version response") + websocket_url = payload.get("webSocketDebuggerUrl") + if not isinstance(websocket_url, str): + raise ValueError("invalid webSocketDebuggerUrl") + except urllib.error.HTTPError: + if not devtools_path or not devtools_path.startswith("/"): + raise + websocket_url = f"ws://127.0.0.1:{port}{devtools_path}" + parsed = urllib.parse.urlparse(websocket_url) + if ( + parsed.scheme not in {"ws", "wss"} + or parsed.hostname not in {"127.0.0.1", "localhost", "::1"} + or parsed.port != port + ): + raise ValueError("invalid webSocketDebuggerUrl") + return websocket_url + + def get_ws_url() -> str: if url := os.environ.get("BU_CDP_WS"): return url @@ -90,38 +158,53 @@ def get_ws_url() -> str: raise RuntimeError( f"BU_CDP_URL={url} unreachable after 30s: {last_err} -- is the dedicated automation browser running?" ) - for base in PROFILES: + profiles = profile_dirs() + profile_candidates = [] + profile_errors = [] + for base in profiles: try: - port, path = (base / "DevToolsActivePort").read_text().strip().split("\n", 1) + port, path = ( + (base / "DevToolsActivePort").read_text(encoding="utf-8-sig", errors="replace").strip().split("\n", 1) + ) except (FileNotFoundError, NotADirectoryError): continue - deadline = time.time() + 30 - while True: - probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - probe.settimeout(1) + except ValueError: + profile_errors.append(f"{base}: invalid DevToolsActivePort") + continue + try: + port_number = int(port.strip()) + if not 1 <= port_number <= 65535 or not path.strip(): + raise ValueError + except ValueError: + profile_errors.append(f"{base}: invalid DevToolsActivePort") + continue + profile_candidates.append((base, port_number, path.strip())) + + deadline = time.time() + 30 + while profile_candidates: + for base, port, devtools_path in profile_candidates: try: - probe.connect(("127.0.0.1", int(port.strip()))) - break - except OSError: - if time.time() >= deadline: - raise RuntimeError( - "The browser's remote-debugging page is open, but DevTools is not live yet on " - f"127.0.0.1:{port.strip()} — if the browser opened a profile picker, choose your normal " - "profile first, then tick the checkbox and click Allow if shown" - ) - time.sleep(1) - finally: - probe.close() - return f"ws://127.0.0.1:{port.strip()}{path.strip()}" + return _local_cdp_ws_url(port, devtools_path) + except (OSError, ValueError, json.JSONDecodeError) as error: + profile_errors.append(f"{base}: 127.0.0.1:{port} ({error})") + if time.time() >= deadline: + break + time.sleep(1) for probe_port in (9222, 9223): try: - with urllib.request.urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as response: - return json.loads(response.read())["webSocketDebuggerUrl"] - except (OSError, KeyError, ValueError): + return _local_cdp_ws_url(probe_port) + except (OSError, ValueError, json.JSONDecodeError): continue + if profile_errors: + details = "; ".join(dict.fromkeys(profile_errors)) + raise RuntimeError( + "The browser's remote-debugging page is open, but DevTools is not live yet for any detected profile: " + f"{details} — if the browser opened a profile picker, choose your normal profile first, then tick the " + "checkbox and click Allow if shown" + ) raise RuntimeError( "DevToolsActivePort not found in " - f"{[str(path) for path in PROFILES]} — enable your browser's remote-debugging page " + f"{[str(path) for path in profiles]} — enable your browser's remote-debugging page " "(for example chrome://inspect/#remote-debugging or edge://inspect/#remote-debugging), " "or set BU_CDP_WS for a remote browser" ) @@ -134,14 +217,17 @@ def is_real_page(target: dict) -> bool: class Daemon: """Long-lived CDP client that serves simple JSON IPC requests.""" - def __init__(self) -> None: + def __init__(self, name: str = NAME) -> None: + self.name = ipc.runtime_paths(name).name + self.instance_id = uuid.uuid4().hex + self.browser_kind = "cdp" if os.environ.get("BU_CDP_WS") or os.environ.get("BU_CDP_URL") else "local" self.cdp = None self.session = None self.target_id = None self.managed_tabs = {} self.events = deque(maxlen=BUF) self.dialog = None - self.stop = None + self.stop = asyncio.Event() async def _enable_session_domains(self) -> None: for domain in ("Page", "DOM", "Runtime", "Network"): @@ -151,9 +237,9 @@ async def _enable_session_domains(self) -> None: log(f"enable {domain}: {error}") async def _attach_target(self, target_id: str) -> dict: - self.session = ( - await self.cdp.send_raw("Target.attachToTarget", {"targetId": target_id, "flatten": True}) - )["sessionId"] + self.session = (await self.cdp.send_raw("Target.attachToTarget", {"targetId": target_id, "flatten": True}))[ + "sessionId" + ] self.target_id = target_id try: info = (await self.cdp.send_raw("Target.getTargetInfo", {"targetId": target_id}))["targetInfo"] @@ -173,7 +259,6 @@ async def attach_first_page(self): return await self._attach_target(pages[0]["targetId"]) async def start(self) -> None: - self.stop = asyncio.Event() url = get_ws_url() log(f"connecting to {url}") self.cdp = CDPClient(url) @@ -222,6 +307,15 @@ async def tap(method, params, session_id=None): async def handle(self, req: dict) -> dict: meta = req.get("meta") + if meta == "ping": + return { + "pong": True, + "protocol_version": ipc.PROTOCOL_VERSION, + "name": self.name, + "pid": os.getpid(), + "instance_id": self.instance_id, + "browser_kind": self.browser_kind, + } if meta == "drain_events": output = list(self.events) self.events.clear() @@ -322,27 +416,20 @@ async def handle(self, req: dict) -> dict: return {"result": await self.cdp.send_raw(method, params, session_id=self.session)} return {"error": msg} - -async def serve(daemon: Daemon) -> None: - async def handler(reader, writer): + async def close(self) -> None: + """Close the CDP client and its background tasks.""" + if self.cdp is None: + return try: - line = await reader.readline() - if not line: - return - resp = await daemon.handle(json.loads(line)) - writer.write((json.dumps(resp, default=str) + "\n").encode()) - await writer.drain() + await self.cdp.stop() except Exception as error: - log(f"conn: {error}") - try: - writer.write((json.dumps({"error": str(error)}) + "\n").encode()) - await writer.drain() - except Exception: - pass + log(f"CDP client stop failed: {error}") finally: - writer.close() + self.cdp = None - serve_task = asyncio.create_task(ipc.serve(NAME, handler)) + +async def serve(daemon: Daemon, lock: ipc.DaemonLock) -> None: + serve_task = asyncio.create_task(ipc.serve(NAME, daemon.handle, lock)) stop_task = asyncio.create_task(daemon.stop.wait()) await asyncio.sleep(0.05) log(f"listening on {ipc.sock_addr(NAME)} (name={NAME})") @@ -357,13 +444,24 @@ async def handler(reader, writer): await task except (asyncio.CancelledError, Exception): pass - ipc.cleanup_endpoint(NAME) -async def main() -> None: +async def main(lock: ipc.DaemonLock) -> None: daemon = Daemon() - await daemon.start() - await serve(daemon) + loop = asyncio.get_running_loop() + for signal_number in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(signal_number, daemon.stop.set) + except (NotImplementedError, RuntimeError, ValueError): + try: + signal.signal(signal_number, lambda _signum, _frame: loop.call_soon_threadsafe(daemon.stop.set)) + except (OSError, RuntimeError, ValueError): + pass + try: + await daemon.start() + await serve(daemon, lock) + finally: + await daemon.close() def already_running() -> bool: @@ -371,25 +469,57 @@ def already_running() -> bool: sock = ipc.connect(NAME, timeout=1.0) sock.close() return True - except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError): + except ( + ipc.EndpointRecordError, + FileNotFoundError, + ConnectionRefusedError, + TimeoutError, + socket.timeout, + OSError, + ): return False +def _configure_stdio() -> None: + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure: + reconfigure(encoding="utf-8", errors="replace") + + +def _remove_own_pid() -> None: + path = ipc.pid_path(NAME) + try: + recorded_pid = int(path.read_text(encoding="utf-8", errors="replace").strip()) + except (FileNotFoundError, ValueError): + return + if recorded_pid == os.getpid(): + path.unlink(missing_ok=True) + + if __name__ == "__main__": - if already_running(): - print(f"daemon already running on {SOCK}", file=sys.stderr) - sys.exit(0) - Path(LOG).write_text("", encoding="utf-8") - Path(PID).write_text(str(os.getpid()), encoding="utf-8") + _configure_stdio() + daemon_lock = ipc.DaemonLock(NAME) + owns_runtime = False + if not daemon_lock.acquire(): + print(f"daemon startup already in progress for {NAME!r}", file=sys.stderr) + sys.exit(ipc.LOCK_BUSY_EXIT_CODE) try: - asyncio.run(main()) + if already_running(): + print(f"daemon already running on {SOCK}", file=sys.stderr) + sys.exit(0) + log(f"--- starting browser daemon name={NAME} pid={os.getpid()} ---") + ipc.write_pid(NAME, os.getpid()) + owns_runtime = True + asyncio.run(main(daemon_lock)) except KeyboardInterrupt: pass - except Exception as error: - log(f"fatal: {error}") + except Exception: + traceback.print_exc() + log(f"fatal:\n{traceback.format_exc()}") sys.exit(1) finally: - try: - os.unlink(PID) - except FileNotFoundError: - pass + if owns_runtime: + _remove_own_pid() + ipc.cleanup_endpoint(NAME, daemon_lock) + daemon_lock.release() diff --git a/flocks/browser/helpers.py b/flocks/browser/helpers.py index ce9e91ba5..0729a5f23 100644 --- a/flocks/browser/helpers.py +++ b/flocks/browser/helpers.py @@ -20,7 +20,6 @@ AGENT_WORKSPACE = Path(os.environ.get("BH_AGENT_WORKSPACE", DEFAULT_AGENT_WORKSPACE)).expanduser() -NAME = os.environ.get("BU_NAME", "default") INTERNAL = INTERNAL_URL_PREFIXES # Legacy compatibility for external imports; cookie export no longer uses site-root guessing. _COMMON_SECOND_LEVEL_SUFFIXES = {"ac", "co", "com", "edu", "gov", "mil", "net", "org"} @@ -48,20 +47,14 @@ def _load_env() -> None: continue load_env_file(path) + _load_env() +NAME = ipc.runtime_paths().name + def _send(req: dict) -> dict: - sock = ipc.connect(NAME, timeout=5.0) - sock.sendall((json.dumps(req) + "\n").encode()) - data = b"" - while not data.endswith(b"\n"): - chunk = sock.recv(1 << 20) - if not chunk: - break - data += chunk - sock.close() - response = json.loads(data) + response = ipc.request(NAME, req, timeout=5.0) if "error" in response: raise RuntimeError(response["error"]) return response @@ -509,7 +502,7 @@ def click_at_xy(x: int | float, y: int | float, button: str = "left", clicks: in from PIL import Image, ImageDraw dpr = js("window.devicePixelRatio") or 1 - path = capture_screenshot(str(ipc._TMP / f"debug_click_{_debug_click_counter}.png")) + path = capture_screenshot(str(ipc.debug_screenshot_path(_debug_click_counter, NAME))) image = Image.open(path) draw = ImageDraw.Draw(image) px, py = int(x * dpr), int(y * dpr) @@ -572,7 +565,8 @@ def scroll(x: int | float, y: int | float, dy: int | float = -300, dx: int | flo def capture_screenshot(path: str | None = None, full: bool = False, max_dim: int | None = None) -> str: """Save a PNG of the current viewport.""" - path = path or str(ipc._TMP / "shot.png") + path = path or str(ipc.screenshot_path(NAME)) + Path(path).parent.mkdir(parents=True, exist_ok=True, mode=0o700) response = cdp("Page.captureScreenshot", format="png", captureBeyondViewport=full) Path(path).write_bytes(base64.b64decode(response["data"])) if max_dim: diff --git a/flocks/browser/lifecycle.py b/flocks/browser/lifecycle.py new file mode 100644 index 000000000..44ef46880 --- /dev/null +++ b/flocks/browser/lifecycle.py @@ -0,0 +1,101 @@ +"""Minimal, non-destructive browser daemon lifecycle operations.""" + +from __future__ import annotations + +import socket +import time + +from . import _ipc as ipc + + +def _wait_until(predicate, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.2) + return predicate() + + +def _cleanup_stale(name: str, lock_timeout: float = 0.0) -> None: + """Clean unreachable runtime artifacts only while holding the session lock.""" + lock = ipc.DaemonLock(name) + deadline = time.monotonic() + lock_timeout + acquired = lock.acquire() + while not acquired and time.monotonic() < deadline: + time.sleep(0.1) + acquired = lock.acquire() + if not acquired: + raise RuntimeError(f"browser daemon {name!r} is locked but not responding; endpoint was preserved") + try: + if ipc.endpoint_reachable(name, timeout=0.2): + raise RuntimeError(f"browser daemon {name!r} became reachable while cleaning stale state") + ipc.cleanup_endpoint(name, lock) + ipc.pid_path(name).unlink(missing_ok=True) + finally: + lock.release() + + +def _is_legacy_status(response: dict) -> bool: + if response.get("error") in {"not_attached", "cdp_disconnected"}: + return True + return "target_id" in response and "session_id" in response and "page" in response + + +def _stop_legacy_default(timeout: float) -> None: + """Gracefully stop the single legacy endpoint used before named sessions.""" + try: + status = ipc.request("default", {"meta": "connection_status"}, timeout=1.0) + except (ipc.EndpointRecordError, ipc.IPCProtocolError, OSError) as error: + raise RuntimeError(f"legacy browser daemon could not be confirmed; endpoint was preserved: {error}") from error + if not _is_legacy_status(status): + raise RuntimeError("legacy browser daemon could not be confirmed; endpoint was preserved") + try: + ipc.request("default", {"meta": "shutdown"}, timeout=3.0) + except (ipc.IPCProtocolError, TimeoutError, socket.timeout, OSError): + pass + if not _wait_until(lambda: not ipc.endpoint_reachable("default", timeout=0.2), timeout): + raise RuntimeError("legacy browser daemon 'default' refused graceful shutdown; endpoint was preserved") + _cleanup_stale("default", lock_timeout=min(timeout, 3.0)) + + +def stop_daemon(name: str | None = None, timeout: float = 15.0) -> None: + """Gracefully stop one daemon without reading or signalling an on-disk PID.""" + effective_name = ipc.runtime_paths(name).name + try: + ipc.identify(effective_name, timeout=1.0) + except ipc.LegacyProtocolError: + if effective_name != "default": + raise RuntimeError(f"legacy named daemon {effective_name!r} is unsupported; endpoint was preserved") + _stop_legacy_default(timeout) + return + except ipc.EndpointRecordError: + _cleanup_stale(effective_name) + return + except ipc.IPCProtocolError as error: + raise RuntimeError( + f"browser daemon {effective_name!r} returned an invalid identity; endpoint was preserved: {error}" + ) from error + except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError): + _cleanup_stale(effective_name) + return + + try: + ipc.request(effective_name, {"meta": "shutdown"}, timeout=3.0) + except (ipc.IPCProtocolError, TimeoutError, socket.timeout, OSError): + pass + if not _wait_until(lambda: not ipc.endpoint_reachable(effective_name, timeout=0.2), timeout): + raise RuntimeError(f"browser daemon {effective_name!r} refused graceful shutdown; endpoint was preserved") + _cleanup_stale(effective_name, lock_timeout=min(timeout, 3.0)) + + +def stop_all_daemons(timeout: float = 15.0) -> tuple[list[str], dict[str, str]]: + """Attempt every visible session and collect failures without stopping early.""" + names = ipc.discover_names() + errors: dict[str, str] = {} + for name in names: + try: + stop_daemon(name, timeout=timeout) + except RuntimeError as error: + errors[name] = str(error) + return names, errors diff --git a/flocks/browser/paths.py b/flocks/browser/paths.py new file mode 100644 index 000000000..d16f1be69 --- /dev/null +++ b/flocks/browser/paths.py @@ -0,0 +1,94 @@ +"""Runtime paths for browser daemon sessions.""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + + +_NAME_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") + + +def validate_name(name: str) -> str: + """Validate and return a browser session name.""" + if not _NAME_RE.fullmatch(name or ""): + raise ValueError(f"invalid BU_NAME {name!r}: must match [A-Za-z0-9_-]{{1,64}}") + return name + + +def resolve_name(name: str | None = None, env: Mapping[str, str] | None = None) -> str: + """Resolve the effective browser session name.""" + effective_env = os.environ if env is None else env + if name is not None: + return validate_name(name) + return validate_name(effective_env.get("BU_NAME") or "default") + + +def flocks_root(env: Mapping[str, str] | None = None) -> Path: + """Return the configured Flocks data root.""" + effective_env = os.environ if env is None else env + return Path(effective_env.get("FLOCKS_ROOT", str(Path.home() / ".flocks"))).expanduser() + + +def browser_dir(env: Mapping[str, str] | None = None) -> Path: + """Return the browser runtime directory without creating it.""" + return flocks_root(env) / "browser" + + +@dataclass(frozen=True) +class BrowserRuntimePaths: + """All runtime artifacts owned by one browser daemon session.""" + + root: Path + name: str + + @classmethod + def resolve( + cls, + name: str | None = None, + env: Mapping[str, str] | None = None, + ) -> BrowserRuntimePaths: + return cls(root=browser_dir(env), name=resolve_name(name, env)) + + @property + def stem(self) -> str: + return "bu" if self.name == "default" else f"bu-{self.name}" + + @property + def socket(self) -> Path: + return self.root / f"{self.stem}.sock" + + @property + def port(self) -> Path: + return self.root / f"{self.stem}.port" + + @property + def pid(self) -> Path: + return self.root / f"{self.stem}.pid" + + @property + def log(self) -> Path: + return self.root / f"{self.stem}.log" + + @property + def lock(self) -> Path: + return self.root / f"{self.stem}.lock" + + @property + def screenshot(self) -> Path: + return self.root / f"{self.stem}-shot.png" + + def debug_screenshot(self, sequence: int) -> Path: + return self.root / f"{self.stem}-debug-click-{sequence}.png" + + def ensure_root(self) -> Path: + """Create the private runtime directory when a write is required.""" + self.root.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + self.root.chmod(0o700) + except OSError: + pass + return self.root diff --git a/flocks/browser/run.py b/flocks/browser/run.py index 05a21f34b..746c0dfc0 100644 --- a/flocks/browser/run.py +++ b/flocks/browser/run.py @@ -8,6 +8,7 @@ from .admin import ( _version, + daemon_browser_connected, ensure_daemon, print_update_banner, restart_daemon, @@ -100,10 +101,12 @@ def _run_state_command(args: list[str]) -> None: url, no_reload = _parse_state_options(rest, allow_no_reload=action == "load") print_update_banner() - ensure_daemon() if action == "save": + if not daemon_browser_connected(): + ensure_daemon() result = save_state(path, url=url) else: + ensure_daemon() result = load_state(path, url=url, reload=not no_reload) print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/flocks/channel/base.py b/flocks/channel/base.py index 5d58315d9..67e96148c 100644 --- a/flocks/channel/base.py +++ b/flocks/channel/base.py @@ -278,6 +278,7 @@ def reset_status(self, channel_id: str, attempt: int = 0) -> None: def mark_connected(self) -> None: self._status.connected = True + self._status.last_error = None def mark_disconnected(self, error: Optional[str] = None) -> None: self._status.connected = False diff --git a/flocks/channel/builtin/email/__init__.py b/flocks/channel/builtin/email/__init__.py new file mode 100644 index 000000000..361b0601c --- /dev/null +++ b/flocks/channel/builtin/email/__init__.py @@ -0,0 +1,5 @@ +"""Built-in Email channel.""" + +from .channel import EmailChannel + +__all__ = ["EmailChannel"] diff --git a/flocks/channel/builtin/email/channel.py b/flocks/channel/builtin/email/channel.py new file mode 100644 index 000000000..2b242cd50 --- /dev/null +++ b/flocks/channel/builtin/email/channel.py @@ -0,0 +1,545 @@ +"""Email ChannelPlugin using IMAP for inbound and SMTP for outbound.""" + +from __future__ import annotations + +import asyncio +import email as email_lib +import imaplib +import smtplib +import socket +import ssl +import uuid +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formatdate +from flocks.channel.media_filename import sanitize_filename +from pathlib import Path +from typing import Any, Awaitable, Callable, Optional + +from flocks.channel.base import ( + ChannelCapabilities, + ChannelMeta, + ChannelPlugin, + ChatType, + DeliveryResult, + InboundMessage, + OutboundContext, +) +from flocks.utils.log import Log + +from .config import ( + is_valid_email, + normalize_email_address, + parse_allowed_senders, + resolved_config, +) +from .inbound import ( + build_inbound_message, + is_automated_sender, + verify_sender_authentication, +) + +log = Log.create(service="channel.email") + +SMTP_CONNECT_TIMEOUT = 30 + + +class EmailChannel(ChannelPlugin): + """Single-mailbox Email channel.""" + + def __init__(self) -> None: + super().__init__() + self._resolved: dict[str, Any] = {} + self._seen_uids: set[bytes] = set() + self._seen_uids_max = 5000 + self._thread_context: dict[str, dict[str, str]] = {} + + def meta(self) -> ChannelMeta: + return ChannelMeta( + id="email", + label="Email", + aliases=["mail", "imap", "smtp"], + order=60, + ) + + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT], + media=True, + threads=True, + reactions=False, + edit=False, + rich_text=False, + ) + + @property + def text_chunk_limit(self) -> int: + return 50_000 + + @property + def rate_limit(self) -> tuple[float, int]: + return (0.2, 2) + + def validate_config(self, config: dict) -> Optional[str]: + cfg = resolved_config(config) + missing = [ + name + for name in ("address", "password", "imapHost", "smtpHost") + if not cfg.get(name) + ] + if missing: + return "Missing required config: " + ", ".join(missing) + if not is_valid_email(cfg["address"]): + return "Invalid email address" + if cfg["imapPort"] <= 0 or cfg["smtpPort"] <= 0: + return "IMAP/SMTP ports must be positive integers" + if cfg["imapSecurity"] not in {"ssl", "starttls", "insecure"}: + return "IMAP security must be one of: ssl, starttls, insecure" + if cfg["smtpSecurity"] not in {"ssl", "starttls", "insecure"}: + return "SMTP security must be one of: ssl, starttls, insecure" + if cfg["imapSecurity"] == "insecure" and not cfg["allowInsecureConnections"]: + return "IMAP insecure mode requires allowInsecureConnections=true" + if cfg["smtpSecurity"] == "insecure" and not cfg["allowInsecureConnections"]: + return "SMTP insecure mode requires allowInsecureConnections=true" + allowed = parse_allowed_senders(cfg) + if not cfg["allowAll"] and not allowed: + return "Email channel requires allowFrom or allowAll=true" + if ( + not cfg["allowAll"] + and allowed + and cfg["requireAuthenticatedSender"] + and not cfg["authservId"] + ): + return "Email channel requires authservId when requireAuthenticatedSender=true and allowFrom is configured" + return None + + def normalize_target(self, raw: str) -> Optional[str]: + target = normalize_email_address(raw) + return target if is_valid_email(target) else None + + def target_hint(self) -> str: + return "user@example.com" + + async def start( + self, + config: dict, + on_message: Callable[[InboundMessage], Awaitable[None]], + abort_event: Optional[asyncio.Event] = None, + ) -> None: + self._config = config + self._resolved = resolved_config(config) + self._on_message = on_message + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._test_connections) + + abort = abort_event or asyncio.Event() + while not abort.is_set(): + try: + messages = await loop.run_in_executor(None, self._fetch_new_messages) + for uid, message in messages: + if abort.is_set(): + break + try: + await on_message(message) + self._mark_seen(uid) + except Exception as exc: + log.warning("email.message.dispatch_failed", {"error": str(exc), "uid": uid.decode(errors="replace")}) + self.mark_connected() + except asyncio.CancelledError: + raise + except Exception as exc: + self.mark_disconnected(str(exc)) + log.warning("email.poll.failed", {"error": str(exc)}) + + try: + await asyncio.wait_for( + abort.wait(), + timeout=float(self._resolved["pollIntervalSeconds"]), + ) + except asyncio.TimeoutError: + continue + + async def stop(self) -> None: + self.mark_disconnected() + + async def send_text(self, ctx: OutboundContext) -> DeliveryResult: + target = self.normalize_target(ctx.to) + if not target: + return DeliveryResult( + channel_id="email", + message_id="", + success=False, + error="Email send requires a valid recipient email address", + ) + + try: + loop = asyncio.get_running_loop() + message_id = await loop.run_in_executor( + None, + self._send_email, + target, + ctx.text, + ctx.reply_to_id, + ctx.thread_id, + None, + ) + self.record_message() + return DeliveryResult( + channel_id="email", + message_id=message_id, + chat_id=target, + success=True, + ) + except Exception as exc: + return DeliveryResult( + channel_id="email", + message_id="", + success=False, + error=f"Email send failed: {exc}", + retryable=self._is_retryable(exc), + ) + + async def send_media(self, ctx: OutboundContext) -> DeliveryResult: + target = self.normalize_target(ctx.to) + if not target: + return DeliveryResult( + channel_id="email", + message_id="", + success=False, + error="Email send_media requires a valid recipient email address", + ) + if not ctx.media_url: + return await self.send_text(ctx) + + path = self._local_media_path(ctx.media_url) + if path is None: + return await self.send_text( + OutboundContext( + **{ + **vars(ctx), + "text": f"{ctx.text}\n\nAttachment/link: {ctx.media_url}".strip(), + } + ) + ) + + try: + loop = asyncio.get_running_loop() + message_id = await loop.run_in_executor( + None, + self._send_email, + target, + ctx.text, + ctx.reply_to_id, + ctx.thread_id, + path, + ) + self.record_message() + return DeliveryResult( + channel_id="email", + message_id=message_id, + chat_id=target, + success=True, + ) + except Exception as exc: + return DeliveryResult( + channel_id="email", + message_id="", + success=False, + error=f"Email send_media failed: {exc}", + retryable=self._is_retryable(exc), + ) + + def _test_connections(self) -> None: + cfg = self._resolved + imap = self._connect_imap() + try: + imap.login(cfg["address"], cfg["password"]) + imap.select("INBOX") + if cfg["skipExistingOnStart"]: + status, data = imap.uid("search", None, "ALL") + if status == "OK" and data and data[0]: + self._seen_uids.update(data[0].split()) + self._trim_seen_uids() + finally: + try: + imap.logout() + except Exception: + pass + + smtp = self._connect_smtp() + try: + smtp.login(cfg["address"], cfg["password"]) + finally: + try: + smtp.quit() + except Exception: + smtp.close() + + def _fetch_new_messages(self) -> list[tuple[bytes, InboundMessage]]: + cfg = self._resolved + parsed_messages: list[tuple[bytes, InboundMessage]] = [] + imap = self._connect_imap() + try: + imap.login(cfg["address"], cfg["password"]) + imap.select("INBOX") + status, data = imap.uid("search", None, "UNSEEN") + if status != "OK" or not data or not data[0]: + return parsed_messages + + for uid in data[0].split(): + if uid in self._seen_uids: + continue + status, msg_data = imap.uid("fetch", uid, "(BODY.PEEK[])") + if status != "OK": + continue + try: + raw_email = msg_data[0][1] + except (IndexError, TypeError): + log.warning("email.imap.malformed_response", {"uid": uid.decode(errors="replace")}) + continue + if not isinstance(raw_email, (bytes, bytearray)): + log.warning("email.imap.non_bytes_payload", {"uid": uid.decode(errors="replace")}) + continue + + try: + message = email_lib.message_from_bytes(raw_email) + except Exception: + log.warning("email.imap.parse_failed", {"uid": uid.decode(errors="replace")}) + continue + + parsed, should_mark_seen = self._parse_and_authorize_with_tracking( + message, + uid.decode(errors="replace"), + ) + if parsed is None: + if should_mark_seen: + self._mark_seen(uid) + continue + parsed_messages.append((uid, parsed)) + finally: + try: + imap.logout() + except Exception: + pass + return parsed_messages + + def _parse_and_authorize( + self, + message: email_lib.message.Message, + uid: str, + ) -> Optional[InboundMessage]: + parsed, _ = self._parse_and_authorize_with_tracking(message, uid) + return parsed + + def _parse_and_authorize_with_tracking( + self, + message: email_lib.message.Message, + uid: str, + ) -> tuple[Optional[InboundMessage], bool]: + cfg = self._resolved + parsed = build_inbound_message( + message, + uid=uid, + account_id="default", + skip_attachments=bool(cfg["skipAttachments"]), + ) + if parsed is None: + return None, False + + inbound = parsed.inbound + sender = inbound.sender_id + if sender == cfg["address"]: + return None, True + if is_automated_sender(sender, dict(message.items())): + return None, True + + allowed = parse_allowed_senders(cfg) + if not cfg["allowAll"]: + if not allowed or sender not in allowed: + log.debug("email.sender.blocked", {"sender": sender}) + return None, True + + if cfg["requireAuthenticatedSender"] and allowed and not cfg["allowAll"]: + if not cfg["authservId"]: + log.warning("email.sender.authserv_id_missing", {"sender": sender}) + return None, True + authenticated, reason = verify_sender_authentication( + message, + sender, + authserv_id=str(cfg["authservId"]), + ) + if not authenticated: + log.warning("email.sender.unauthenticated", { + "sender": sender, + "reason": reason, + }) + return None, True + + context = { + "subject": parsed.subject, + "message_id": parsed.message_id, + "references": parsed.references, + } + self._thread_context[sender] = context + for key in {parsed.message_id, parsed.inbound.thread_id or "", parsed.inbound.reply_to_id or ""}: + if key: + self._thread_context[key] = context + return inbound, False + + def _mark_seen(self, uid: bytes) -> None: + self._seen_uids.add(uid) + self._trim_seen_uids() + + def _connect_smtp(self) -> smtplib.SMTP: + cfg = self._resolved + context = ssl.create_default_context() + if cfg["smtpSecurity"] == "ssl": + return smtplib.SMTP_SSL( + cfg["smtpHost"], + int(cfg["smtpPort"]), + timeout=SMTP_CONNECT_TIMEOUT, + context=context, + ) + smtp = smtplib.SMTP( + cfg["smtpHost"], + int(cfg["smtpPort"]), + timeout=SMTP_CONNECT_TIMEOUT, + ) + if cfg["smtpSecurity"] == "starttls": + code, response = smtp.starttls(context=context) + if code != 220: + smtp.close() + raise RuntimeError(f"SMTP STARTTLS not available: {response}") + return smtp + + def _connect_imap(self) -> imaplib.IMAP4: + cfg = self._resolved + context = ssl.create_default_context() + if cfg["imapSecurity"] == "ssl": + return imaplib.IMAP4_SSL( + cfg["imapHost"], + int(cfg["imapPort"]), + timeout=30, + ssl_context=context, + ) + + imap = imaplib.IMAP4(cfg["imapHost"], int(cfg["imapPort"]), timeout=30) + if cfg["imapSecurity"] != "starttls": + return imap + + code, response = imap.starttls(ssl_context=context) + if code != "OK": + imap.close() + raise RuntimeError(f"IMAP STARTTLS not available: {response}") + return imap + + def _send_email( + self, + to_addr: str, + body: str, + reply_to_msg_id: Optional[str], + thread_id: Optional[str], + attachment_path: Optional[Path], + ) -> str: + cfg = self._resolved + msg = MIMEMultipart() + msg["From"] = cfg["address"] + msg["To"] = to_addr + + ctx = self._resolve_thread_context(to_addr, reply_to_msg_id, thread_id) + subject = ctx.get("subject") or cfg["defaultSubject"] + if not subject.lower().startswith("re:"): + subject = f"Re: {subject}" + msg["Subject"] = subject + + original_id = reply_to_msg_id or ctx.get("message_id") + references = thread_id or ctx.get("references") or original_id + if original_id: + msg["In-Reply-To"] = original_id + if references: + msg["References"] = references + + msg["Date"] = formatdate(localtime=True) + message_id = f"" + msg["Message-ID"] = message_id + msg.attach(MIMEText(body or "", "plain", "utf-8")) + + if attachment_path is not None: + with attachment_path.open("rb") as handle: + part = MIMEBase("application", "octet-stream") + part.set_payload(handle.read()) + encoders.encode_base64(part) + safe_name = sanitize_filename(attachment_path.name, fallback="attachment") + part.add_header( + "Content-Disposition", + "attachment", + filename=safe_name, + ) + msg.attach(part) + + smtp = self._connect_smtp() + try: + smtp.login(cfg["address"], cfg["password"]) + smtp.send_message(msg) + finally: + try: + smtp.quit() + except Exception: + smtp.close() + return message_id + + def _resolve_thread_context( + self, + to_addr: str, + reply_to_msg_id: Optional[str], + thread_id: Optional[str], + ) -> dict[str, str]: + for key in (thread_id, reply_to_msg_id, to_addr): + if key and key in self._thread_context: + return self._thread_context[key] + return {} + + def _message_id_domain(self) -> str: + address = str(self._resolved.get("address") or "") + if "@" not in address: + return "localhost" + return address.rsplit("@", 1)[-1] or "localhost" + + def _trim_seen_uids(self) -> None: + if len(self._seen_uids) <= self._seen_uids_max: + return + try: + sorted_uids = sorted(self._seen_uids, key=lambda item: int(item)) + self._seen_uids = set(sorted_uids[-self._seen_uids_max // 2:]) + except (TypeError, ValueError): + keep = list(self._seen_uids)[-self._seen_uids_max // 2:] + self._seen_uids = set(keep) + + @staticmethod + def _local_media_path(media_url: str) -> Optional[Path]: + value = str(media_url or "").strip() + if value.startswith("file://"): + from urllib.parse import unquote + value = unquote(value[len("file://"):]) + if value.startswith(("http://", "https://")): + return None + path = Path(value) + return path if path.is_file() else None + + @staticmethod + def _is_retryable(exc: Exception) -> bool: + return isinstance( + exc, + ( + TimeoutError, + ConnectionError, + OSError, + smtplib.SMTPServerDisconnected, + smtplib.SMTPConnectError, + smtplib.SMTPHeloError, + socket.timeout, + ), + ) diff --git a/flocks/channel/builtin/email/config.py b/flocks/channel/builtin/email/config.py new file mode 100644 index 000000000..c23c7a5e9 --- /dev/null +++ b/flocks/channel/builtin/email/config.py @@ -0,0 +1,136 @@ +"""Configuration helpers for the Email channel.""" + +from __future__ import annotations + +import re +from typing import Any + + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def coerce_str(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def coerce_int(value: Any, default: int) -> int: + if value is None or isinstance(value, bool): + return default + try: + return int(str(value).strip(), 10) + except (TypeError, ValueError): + return default + + +def coerce_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return default + + +def coerce_security_mode(value: Any) -> str: + mode = coerce_str(value).lower().strip() + if mode in {"ssl", "starttls", "insecure"}: + return mode + return "" + + +def default_security(port: int, protocol: str) -> str: + if protocol == "imap": + if port == 993: + return "ssl" + if port == 143: + return "starttls" + elif protocol == "smtp": + if port == 465: + return "ssl" + if port == 587: + return "starttls" + return "" + + +def normalize_email_address(raw: str) -> str: + value = coerce_str(raw).lower() + if value.startswith("mailto:"): + value = value[len("mailto:"):].strip() + if "<" in value and ">" in value: + value = value.split("<", 1)[1].split(">", 1)[0].strip() + return value + + +def parse_allowed_senders(config: dict[str, Any]) -> set[str]: + raw = config.get("allowFrom") + if raw is None: + raw = config.get("allowedSenders") + if raw is None: + raw = config.get("allowedUsers") + + if isinstance(raw, str): + values = raw.split(",") + elif isinstance(raw, list): + values = raw + else: + values = [] + + return { + normalize_email_address(str(item)) + for item in values + if normalize_email_address(str(item)) + } + + +def is_valid_email(raw: str) -> bool: + return bool(EMAIL_RE.fullmatch(normalize_email_address(raw))) + + +def resolved_config(config: dict[str, Any]) -> dict[str, Any]: + """Return normalized Email channel config with defaults applied.""" + imap_port = coerce_int(config.get("imapPort") or config.get("imap_port"), 993) + smtp_port = coerce_int(config.get("smtpPort") or config.get("smtp_port"), 587) + imap_security = coerce_security_mode(config.get("imapSecurity") or config.get("imap_security")) + smtp_security = coerce_security_mode(config.get("smtpSecurity") or config.get("smtp_security")) + if not imap_security: + imap_security = default_security(imap_port, "imap") + if not smtp_security: + smtp_security = default_security(smtp_port, "smtp") + + return { + **config, + "address": normalize_email_address(coerce_str(config.get("address"))), + "password": coerce_str(config.get("password")), + "imapHost": coerce_str(config.get("imapHost") or config.get("imap_host")), + "imapPort": imap_port, + "imapSecurity": imap_security, + "smtpHost": coerce_str(config.get("smtpHost") or config.get("smtp_host")), + "smtpPort": smtp_port, + "smtpSecurity": smtp_security, + "pollIntervalSeconds": max( + coerce_int( + config.get("pollIntervalSeconds") or config.get("pollInterval") or config.get("poll_interval"), + 15, + ), + 5, + ), + "skipExistingOnStart": coerce_bool(config.get("skipExistingOnStart"), True), + "skipAttachments": coerce_bool(config.get("skipAttachments"), True), + "allowAll": coerce_bool(config.get("allowAll"), False), + "allowInsecureConnections": coerce_bool(config.get("allowInsecureConnections"), False), + "requireAuthenticatedSender": coerce_bool( + config.get("requireAuthenticatedSender"), + True, + ), + "authservId": coerce_str(config.get("authservId") or config.get("authserv_id")), + "defaultSubject": coerce_str(config.get("defaultSubject")) or "Flocks Agent", + } diff --git a/flocks/channel/builtin/email/inbound.py b/flocks/channel/builtin/email/inbound.py new file mode 100644 index 000000000..bf5aea2a5 --- /dev/null +++ b/flocks/channel/builtin/email/inbound.py @@ -0,0 +1,261 @@ +"""Inbound email parsing and sender safety helpers.""" + +from __future__ import annotations + +import email as email_lib +import re +from dataclasses import dataclass +from email.header import decode_header +from typing import Any, Optional + +from flocks.channel.base import ChatType, InboundMessage +from flocks.channel.media_filename import sanitize_filename + +from .config import normalize_email_address + + +_NOREPLY_PATTERNS = ( + "noreply", "no-reply", "no_reply", "donotreply", "do-not-reply", + "mailer-daemon", "postmaster", "bounce", "notifications@", + "automated@", "auto-confirm", "auto-reply", "automailer", +) + +_AUTOMATED_HEADERS = { + "Auto-Submitted": lambda v: v.lower() != "no", + "Precedence": lambda v: v.lower() in {"bulk", "list", "junk"}, + "X-Auto-Response-Suppress": lambda v: bool(v), + "List-Unsubscribe": lambda v: bool(v), +} + +_AUTH_METHOD_RE = re.compile(r"\b(dmarc|dkim|spf)\s*=\s*([a-z]+)", re.IGNORECASE) +_AUTH_PROP_RE = re.compile( + r"\b(header\.from|header\.d|smtp\.mailfrom|smtp\.from|envelope-from)\s*=\s*([^\s;]+)", + re.IGNORECASE, +) + + +@dataclass +class ParsedEmail: + inbound: InboundMessage + subject: str + message_id: str + references: str + + +def decode_header_value(raw: str) -> str: + parts = decode_header(raw or "") + decoded: list[str] = [] + for part, charset in parts: + if isinstance(part, bytes): + decoded.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(part) + return " ".join(decoded).strip() + + +def extract_email_address(raw: str) -> str: + match = re.search(r"<([^>]+)>", raw or "") + if match: + return normalize_email_address(match.group(1)) + return normalize_email_address(raw or "") + + +def extract_sender_name(raw: str, fallback: str) -> str: + decoded = decode_header_value(raw) + if "<" in decoded: + decoded = decoded.split("<", 1)[0].strip().strip('"') + return decoded or fallback + + +def strip_html(html: str) -> str: + text = re.sub(r"", "\n", html, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + replacements = { + " ": " ", + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + } + for old, new in replacements.items(): + text = text.replace(old, new) + return re.sub(r"\n{3,}", "\n\n", text).strip() + + +def extract_text_body(msg: email_lib.message.Message) -> str: + if msg.is_multipart(): + for preferred in ("text/plain", "text/html"): + for part in msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get("Content-Disposition", "")) + if "attachment" in disposition or content_type != preferred: + continue + payload = part.get_payload(decode=True) + if not payload: + continue + charset = part.get_content_charset() or "utf-8" + text = payload.decode(charset, errors="replace") + return strip_html(text) if preferred == "text/html" else text + return "" + + payload = msg.get_payload(decode=True) + if not payload: + return "" + charset = msg.get_content_charset() or "utf-8" + text = payload.decode(charset, errors="replace") + return strip_html(text) if msg.get_content_type() == "text/html" else text + + +def attachment_summaries( + msg: email_lib.message.Message, + *, + skip_attachments: bool, +) -> list[str]: + if skip_attachments or not msg.is_multipart(): + return [] + + summaries: list[str] = [] + for part in msg.walk(): + disposition = str(part.get("Content-Disposition", "")) + if "attachment" not in disposition and "inline" not in disposition: + continue + content_type = part.get_content_type() + if content_type in {"text/plain", "text/html"} and "attachment" not in disposition: + continue + filename = part.get_filename() + filename = decode_header_value(filename) if filename else "attachment" + filename = sanitize_filename(filename, fallback="attachment") + summaries.append(f"{filename} ({content_type})") + return summaries + + +def is_automated_sender(address: str, headers: dict[str, str]) -> bool: + addr = normalize_email_address(address) + if any(pattern in addr for pattern in _NOREPLY_PATTERNS): + return True + for header, check in _AUTOMATED_HEADERS.items(): + value = headers.get(header, "") + if value and check(value): + return True + return False + + +def _domain_of(address: str) -> str: + _, _, domain = normalize_email_address(address).rpartition("@") + return domain.strip().lower() + + +def _domains_aligned(a: str, b: str) -> bool: + a = (a or "").strip().lower().rstrip(".") + b = (b or "").strip().lower().rstrip(".") + if not a or not b: + return False + return a == b or a.endswith("." + b) or b.endswith("." + a) + + +def _normalize_authserv_id(raw: str) -> str: + value = (raw or "").strip() + if not value: + return "" + + first = value.split(";", 1)[0].strip() + if " " in first: + first = first.split(" ", 1)[0].strip() + return first.lower().rstrip(".") + + +def verify_sender_authentication( + msg: email_lib.message.Message, + from_addr: str, + *, + authserv_id: str = "", +) -> tuple[bool, str]: + from_domain = _domain_of(from_addr) + if not from_domain: + return False, "missing From domain" + + headers = msg.get_all("Authentication-Results") or [] + if not headers: + return False, "no Authentication-Results header" + + normalized_expected = _normalize_authserv_id(authserv_id) + if not normalized_expected: + return False, "no authserv-id configured" + + trusted = None + for raw in headers: + value = " ".join(str(raw).split()) + serv = _normalize_authserv_id(value) + if serv != normalized_expected: + continue + trusted = value + break + if trusted is None: + return False, "no Authentication-Results from trusted authserv-id" + + methods = {m.lower(): r.lower() for m, r in _AUTH_METHOD_RE.findall(trusted)} + props = {p.lower(): v.strip().strip('"') for p, v in _AUTH_PROP_RE.findall(trusted)} + + if methods.get("dmarc") == "pass": + return True, "dmarc=pass" + + if methods.get("spf") == "pass": + spf_domain = _domain_of(props.get("smtp.mailfrom", "")) or props.get("smtp.from", "") or props.get("envelope-from", "") + spf_domain = _domain_of(spf_domain) if "@" in spf_domain else spf_domain + if _domains_aligned(spf_domain, from_domain): + return True, "spf=pass aligned" + + if methods.get("dkim") == "pass": + dkim_domain = props.get("header.d", "") or _domain_of(props.get("header.from", "")) + if _domains_aligned(dkim_domain, from_domain): + return True, "dkim=pass aligned" + + return False, f"authentication failed ({trusted[:120]})" + + +def build_inbound_message( + msg: email_lib.message.Message, + *, + uid: str, + account_id: str, + skip_attachments: bool, +) -> Optional[ParsedEmail]: + sender_raw = msg.get("From", "") + sender_addr = extract_email_address(sender_raw) + if not sender_addr: + return None + + subject = decode_header_value(msg.get("Subject", "(no subject)")) or "(no subject)" + message_id = str(msg.get("Message-ID", "")).strip() or f"" + in_reply_to = str(msg.get("In-Reply-To", "")).strip() + references = str(msg.get("References", "")).strip() + body = extract_text_body(msg).strip() + attachments = attachment_summaries(msg, skip_attachments=skip_attachments) + + text = body or "(empty email)" + if attachments: + text = f"{text}\n\n[Attachments]\n" + "\n".join(f"- {item}" for item in attachments) + if subject and not subject.lower().startswith("re:"): + text = f"[Subject: {subject}]\n\n{text}" + + return ParsedEmail( + inbound=InboundMessage( + channel_id="email", + account_id=account_id, + message_id=message_id, + sender_id=sender_addr, + sender_name=extract_sender_name(sender_raw, sender_addr), + chat_id=sender_addr, + chat_type=ChatType.DIRECT, + text=text, + reply_to_id=in_reply_to or None, + thread_id=references or in_reply_to or message_id, + raw={"uid": uid, "date": msg.get("Date", ""), "subject": subject}, + ), + subject=subject, + message_id=message_id, + references=references, + ) diff --git a/flocks/channel/builtin/whatsapp/__init__.py b/flocks/channel/builtin/whatsapp/__init__.py new file mode 100644 index 000000000..4b4679c2e --- /dev/null +++ b/flocks/channel/builtin/whatsapp/__init__.py @@ -0,0 +1,6 @@ +"""WhatsApp built-in channel.""" + +from .channel import WhatsAppChannel + +CHANNELS = [WhatsAppChannel()] + diff --git a/flocks/channel/builtin/whatsapp/bridge/bridge.js b/flocks/channel/builtin/whatsapp/bridge/bridge.js new file mode 100644 index 000000000..0d8d66ed9 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge/bridge.js @@ -0,0 +1,500 @@ +#!/usr/bin/env node + +import { + DisconnectReason, + downloadMediaMessage, + fetchLatestBaileysVersion, + makeWASocket, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; +import { Boom } from '@hapi/boom'; +import express from 'express'; +import pino from 'pino'; +import path from 'path'; +import { createHash, randomBytes } from 'crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { tmpdir } from 'os'; + +const args = process.argv.slice(2); + +function getArg(name, defaultValue) { + const idx = args.indexOf(`--${name}`); + return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultValue; +} + +function envFlag(name, defaultValue = false) { + const raw = process.env[name]; + if (raw === undefined) return defaultValue; + return ['1', 'true', 'yes', 'on'].includes(String(raw).toLowerCase()); +} + +const PORT = Number.parseInt(getArg('port', '3100'), 10); +const SESSION_DIR = getArg('session', path.join(process.env.HOME || '.', '.flocks', 'workspace', 'channels', 'whatsapp', 'session')); +const MEDIA_DIR = process.env.FLOCKS_WHATSAPP_MEDIA_DIR + || path.join(process.env.HOME || '.', '.flocks', 'workspace', 'channels', 'whatsapp', 'media'); +const MODE = getArg('mode', process.env.FLOCKS_WHATSAPP_MODE || 'bot'); +const REPLY_PREFIX = process.env.FLOCKS_WHATSAPP_REPLY_PREFIX || ''; +const CHUNK_DELAY_MS = Number.parseInt(process.env.FLOCKS_WHATSAPP_CHUNK_DELAY_MS || '300', 10); +const SEND_TIMEOUT_MS = Number.parseInt(process.env.FLOCKS_WHATSAPP_SEND_TIMEOUT_MS || '60000', 10); +const BRIDGE_TOKEN = process.env.FLOCKS_WHATSAPP_BRIDGE_TOKEN || ''; +const CONFIG_HASH = process.env.FLOCKS_WHATSAPP_CONFIG_HASH || ''; +const ALLOWED_MEDIA_ROOTS = String(process.env.FLOCKS_WHATSAPP_ALLOWED_MEDIA_ROOTS || '') + .split(path.delimiter) + .map(item => item.trim()) + .filter(Boolean) + .map(item => path.resolve(item)); +const PAIR_ONLY = args.includes('--pair-only'); +const PAIR_JSON = args.includes('--pair-json'); +const PAIR_TIMEOUT_MS = Number.parseInt(process.env.FLOCKS_WHATSAPP_PAIR_TIMEOUT_MS || '120000', 10); +const DEBUG = envFlag('FLOCKS_WHATSAPP_DEBUG'); + +mkdirSync(SESSION_DIR, { recursive: true }); +mkdirSync(MEDIA_DIR, { recursive: true }); + +let scriptHash = ''; +let tokenHash = ''; +try { + scriptHash = createHash('sha256') + .update(readFileSync(fileURLToPath(import.meta.url))) + .digest('hex') + .slice(0, 16); +} catch {} +if (BRIDGE_TOKEN) { + tokenHash = createHash('sha256').update(BRIDGE_TOKEN).digest('hex').slice(0, 12); +} + +let sock = null; +let connectionState = 'disconnected'; +let messageQueue = []; +const messageStore = new Map(); +const recentlySent = new Map(); +let sendQueue = Promise.resolve(); + +const logger = pino({ level: DEBUG ? 'debug' : 'silent' }); + +function emitPairEvent(event) { + if (!PAIR_JSON) return; + try { + console.log(JSON.stringify({ ts: Date.now(), ...event })); + } catch {} +} + +function rememberSent(id) { + if (!id) return; + recentlySent.set(id, Date.now()); + if (recentlySent.size > 1000) { + const cutoff = Date.now() - 10 * 60 * 1000; + for (const [key, ts] of recentlySent.entries()) { + if (ts < cutoff) recentlySent.delete(key); + } + } +} + +function normalizeJid(value) { + if (!value) return ''; + return String(value).replace(/:\d+@/, '@'); +} + +function stripJid(value) { + const jid = normalizeJid(value); + if (!jid) return ''; + if (jid.includes('@')) return jid.split('@', 1)[0].split(':', 1)[0]; + return jid.replace(/^\+/, ''); +} + +function jidAliases(...values) { + const aliases = []; + const seen = new Set(); + const add = (value) => { + if (Array.isArray(value)) { + for (const item of value) add(item); + return; + } + if (!value) return; + const raw = String(value).trim(); + if (!raw) return; + for (const alias of [raw, normalizeJid(raw), stripJid(raw)]) { + if (alias && !seen.has(alias)) { + aliases.push(alias); + seen.add(alias); + } + } + }; + for (const value of values) add(value); + return aliases; +} + +function splitLongMessage(message, limit = 4096) { + const text = String(message || ''); + if (!text) return []; + if (text.length <= limit) return [text]; + const chunks = []; + let remaining = text; + while (remaining.length > limit) { + let splitAt = remaining.lastIndexOf('\n', limit); + if (splitAt < Math.floor(limit / 2)) splitAt = remaining.lastIndexOf(' ', limit); + if (splitAt < 1) splitAt = limit; + chunks.push(remaining.slice(0, splitAt).trimEnd()); + remaining = remaining.slice(splitAt).trimStart(); + } + if (remaining) chunks.push(remaining); + return chunks; +} + +function enqueueSend(fn) { + const task = sendQueue.then(() => fn(), () => fn()); + sendQueue = task.catch(() => {}); + return task; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function sendWithTimeout(chatId, payload, options = {}) { + return enqueueSend(() => { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`sendMessage timed out after ${SEND_TIMEOUT_MS}ms`)), SEND_TIMEOUT_MS); + }); + return Promise.race([ + sock.sendMessage(chatId, payload, options), + timeout, + ]).finally(() => clearTimeout(timer)); + }); +} + +function getMessageContent(msg) { + const message = msg?.message || {}; + return message.ephemeralMessage?.message + || message.viewOnceMessage?.message + || message.viewOnceMessageV2?.message + || message; +} + +function extractText(content) { + return content.conversation + || content.extendedTextMessage?.text + || content.imageMessage?.caption + || content.videoMessage?.caption + || content.documentMessage?.caption + || ''; +} + +function detectMediaBlock(content) { + if (content.imageMessage) return { block: content.imageMessage, type: 'image', ext: '.jpg' }; + if (content.videoMessage) return { block: content.videoMessage, type: 'video', ext: '.mp4' }; + if (content.audioMessage) return { block: content.audioMessage, type: content.audioMessage.ptt ? 'ptt' : 'audio', ext: '.ogg' }; + if (content.documentMessage) return { block: content.documentMessage, type: 'document', ext: path.extname(content.documentMessage.fileName || '') || '.bin' }; + if (content.stickerMessage) return { block: content.stickerMessage, type: 'sticker', ext: '.webp' }; + return null; +} + +function safeExt(ext) { + const cleaned = String(ext || '').toLowerCase().replace(/[^a-z0-9.]/g, ''); + return cleaned && cleaned.startsWith('.') ? cleaned : '.bin'; +} + +async function cacheInboundMedia(msg, mediaInfo) { + try { + const buffer = await downloadMediaMessage( + msg, + 'buffer', + {}, + { logger, reuploadRequest: sock.updateMediaMessage }, + ); + const fileName = `${mediaInfo.type}_${randomBytes(8).toString('hex')}${safeExt(mediaInfo.ext)}`; + const filePath = path.join(MEDIA_DIR, fileName); + writeFileSync(filePath, buffer); + return filePath; + } catch (err) { + console.warn('[whatsapp-bridge] failed to cache media:', err.message); + return ''; + } +} + +function buildBridgeEvent({ + msg, + chatId, + chatAltId, + senderId, + senderAltId, + isGroup, + body, + mediaInfo, + mediaPath, + fromOwner, +}) { + const content = getMessageContent(msg); + const contextInfo = content.extendedTextMessage?.contextInfo + || content.imageMessage?.contextInfo + || content.videoMessage?.contextInfo + || content.documentMessage?.contextInfo + || {}; + const botIds = new Set([ + normalizeJid(sock?.user?.id), + normalizeJid(sock?.user?.lid), + ].filter(Boolean)); + const mentionedJids = (contextInfo.mentionedJid || []).map(normalizeJid); + const mentioned = mentionedJids.some(jid => botIds.has(jid)); + const quotedParticipant = normalizeJid(contextInfo.participant || ''); + const isReplyToBot = Boolean(quotedParticipant && botIds.has(quotedParticipant)); + return { + messageId: msg.key.id || '', + chatId, + chatAltId, + senderId, + senderAltId, + senderAliases: jidAliases(senderId, senderAltId), + chatAliases: jidAliases(chatId, chatAltId), + senderName: msg.pushName || '', + isGroup, + body, + hasMedia: Boolean(mediaPath), + mediaUrls: mediaPath ? [mediaPath] : [], + mediaType: mediaInfo?.type || '', + mime: mediaInfo?.block?.mimetype || '', + quotedMessageId: contextInfo.stanzaId || '', + quotedParticipant, + mentioned, + isReplyToBot, + fromOwner: Boolean(fromOwner), + }; +} + +async function startSocket() { + const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); + const { version } = await fetchLatestBaileysVersion(); + sock = makeWASocket({ + version, + auth: state, + logger, + printQRInTerminal: false, + browser: ['Flocks', 'Chrome', '120.0'], + syncFullHistory: false, + markOnlineOnConnect: false, + getMessage: async (key) => messageStore.get(key?.id) || { conversation: '' }, + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + if (qr) { + emitPairEvent({ event: 'qr', qr }); + if (!PAIR_JSON) console.log(qr); + } + if (connection === 'close') { + const reason = new Boom(lastDisconnect?.error)?.output?.statusCode; + connectionState = 'disconnected'; + if (reason === DisconnectReason.loggedOut) { + emitPairEvent({ event: 'error', error: 'logged_out', reason }); + process.exit(1); + } + emitPairEvent({ event: 'disconnected', reason }); + setTimeout(startSocket, reason === 515 ? 1000 : 3000); + } else if (connection === 'open') { + connectionState = 'connected'; + emitPairEvent({ + event: 'connected', + user: sock?.user ? { id: sock.user.id || null, name: sock.user.name || sock.user.verifiedName || null } : null, + }); + if (PAIR_ONLY) setTimeout(() => process.exit(0), 1500); + } + }); + + sock.ev.on('messages.upsert', async ({ messages, type }) => { + if (type !== 'notify' && type !== 'append') return; + for (const msg of messages || []) { + if (!msg?.message) continue; + const chatId = normalizeJid(msg.key.remoteJid); + const chatAltId = normalizeJid(msg.key.remoteJidAlt || ''); + const participantId = normalizeJid(msg.key.participant || ''); + const participantAltId = normalizeJid(msg.key.participantAlt || ''); + const senderId = normalizeJid(participantId || chatId); + const senderAltId = normalizeJid(participantAltId || (!chatId.endsWith('@g.us') ? chatAltId : '')); + const isGroup = chatId.endsWith('@g.us'); + const fromMe = Boolean(msg.key.fromMe); + + if (fromMe) { + if (recentlySent.has(msg.key.id)) continue; + if (MODE === 'bot') continue; + const selfIds = new Set([ + normalizeJid(sock?.user?.id).replace(/:\d+@/, '@'), + normalizeJid(sock?.user?.lid).replace(/:\d+@/, '@'), + ].filter(Boolean)); + if (!selfIds.has(chatId)) continue; + } else if (MODE === 'self-chat') { + continue; + } + + const content = getMessageContent(msg); + const body = extractText(content); + const mediaInfo = detectMediaBlock(content); + const mediaPath = mediaInfo ? await cacheInboundMedia(msg, mediaInfo) : ''; + if (!body && !mediaPath) continue; + const event = buildBridgeEvent({ + msg, + chatId, + chatAltId, + senderId, + senderAltId, + isGroup, + body, + mediaInfo, + mediaPath, + fromOwner: fromMe, + }); + messageStore.set(msg.key.id, msg); + messageQueue.push(event); + if (messageQueue.length > 1000) messageQueue.shift(); + } + }); +} + +function validateHost(req, res, next) { + const raw = String(req.headers.host || '').trim(); + const host = raw.includes(':') ? raw.slice(0, raw.lastIndexOf(':')) : raw; + const normalized = host.replace(/^\[|\]$/g, '').toLowerCase(); + if (!['localhost', '127.0.0.1', '::1'].includes(normalized)) { + return res.status(400).json({ error: 'Invalid Host header' }); + } + return next(); +} + +function validateToken(req, res, next) { + const auth = String(req.headers.authorization || ''); + const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''; + const token = String(req.headers['x-flocks-bridge-token'] || bearer); + if (!BRIDGE_TOKEN || token !== BRIDGE_TOKEN) { + return res.status(401).json({ error: 'Unauthorized' }); + } + return next(); +} + +function isAllowedMediaPath(filePath) { + if (ALLOWED_MEDIA_ROOTS.length === 0) return false; + const resolved = path.resolve(filePath); + return ALLOWED_MEDIA_ROOTS.some(root => resolved === root || resolved.startsWith(`${root}${path.sep}`)); +} + +function inferMediaPayload(filePath, mediaType, caption) { + const buffer = readFileSync(filePath); + const ext = path.extname(filePath).slice(1).toLowerCase(); + const type = mediaType || ( + ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext) ? 'image' + : ['mp4', 'mov', 'mkv', 'webm'].includes(ext) ? 'video' + : ['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext) ? 'audio' + : 'document' + ); + if (type === 'image') return { image: buffer, caption: caption || undefined }; + if (type === 'video') return { video: buffer, caption: caption || undefined }; + if (type === 'audio') { + let audioBuffer = buffer; + let audioExt = ext; + let tmpPath = null; + if (!['ogg', 'opus'].includes(ext)) { + tmpPath = path.join(tmpdir(), `flocks_voice_${randomBytes(6).toString('hex')}.ogg`); + try { + execFileSync('ffmpeg', ['-y', '-i', filePath, '-ar', '48000', '-ac', '1', '-c:a', 'libopus', tmpPath], { timeout: 30000, stdio: 'pipe' }); + audioBuffer = readFileSync(tmpPath); + audioExt = 'ogg'; + } catch { + audioBuffer = buffer; + } finally { + try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch {} + } + } + return { audio: audioBuffer, mimetype: audioExt === 'ogg' || audioExt === 'opus' ? 'audio/ogg; codecs=opus' : 'audio/mpeg', ptt: audioExt === 'ogg' || audioExt === 'opus' }; + } + return { + document: buffer, + fileName: path.basename(filePath), + mimetype: 'application/octet-stream', + caption: caption || undefined, + }; +} + +if (PAIR_ONLY) { + emitPairEvent({ event: 'started', session: SESSION_DIR }); + setTimeout(() => { + emitPairEvent({ event: 'error', error: 'pairing_timeout' }); + process.exit(2); + }, PAIR_TIMEOUT_MS); + await startSocket(); +} else { + await startSocket(); + const app = express(); + app.use(express.json({ limit: '2mb' })); + app.use(validateHost); + app.use(validateToken); + + app.get('/messages', (_req, res) => { + const messages = messageQueue.splice(0, messageQueue.length); + res.json(messages); + }); + + app.post('/send', async (req, res) => { + if (!sock || connectionState !== 'connected') return res.status(503).json({ error: 'Not connected to WhatsApp' }); + const { chatId, message, replyTo } = req.body || {}; + if (!chatId || !message) return res.status(400).json({ error: 'chatId and message are required' }); + try { + const text = REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : String(message); + const chunks = splitLongMessage(text); + const messageIds = []; + for (let i = 0; i < chunks.length; i += 1) { + const quoted = replyTo && i === 0 ? messageStore.get(replyTo) : null; + const options = quoted ? { quoted } : {}; + const sent = await sendWithTimeout(chatId, { text: chunks[i] }, options); + rememberSent(sent?.key?.id); + if (sent?.key?.id) messageIds.push(sent.key.id); + if (i < chunks.length - 1) await sleep(CHUNK_DELAY_MS); + } + res.json({ success: true, messageId: messageIds[messageIds.length - 1] || '', messageIds }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + app.post('/send-media', async (req, res) => { + if (!sock || connectionState !== 'connected') return res.status(503).json({ error: 'Not connected to WhatsApp' }); + const { chatId, filePath, mediaType, caption } = req.body || {}; + if (!chatId || !filePath) return res.status(400).json({ error: 'chatId and filePath are required' }); + if (!existsSync(filePath)) return res.status(404).json({ error: `File not found: ${filePath}` }); + if (!isAllowedMediaPath(filePath)) return res.status(403).json({ error: `File path is not allowed: ${filePath}` }); + try { + const payload = inferMediaPayload(filePath, mediaType, caption); + const sent = await sendWithTimeout(chatId, payload); + rememberSent(sent?.key?.id); + res.json({ success: true, messageId: sent?.key?.id || '' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + app.get('/health', (_req, res) => { + res.json({ + status: connectionState, + queueLength: messageQueue.length, + uptime: process.uptime(), + scriptHash, + sessionPath: path.resolve(SESSION_DIR), + mediaDir: path.resolve(MEDIA_DIR), + mode: MODE, + configHash: CONFIG_HASH, + tokenHash, + }); + }); + + app.listen(PORT, '127.0.0.1', () => { + console.log(`[whatsapp-bridge] listening on 127.0.0.1:${PORT}`); + }); +} diff --git a/flocks/channel/builtin/whatsapp/bridge/package-lock.json b/flocks/channel/builtin/whatsapp/bridge/package-lock.json new file mode 100644 index 000000000..b4fc7d4b6 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge/package-lock.json @@ -0,0 +1,2184 @@ +{ + "name": "flocks-whatsapp-bridge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flocks-whatsapp-bridge", + "version": "1.0.0", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "7.0.0-rc13", + "express": "^4.21.2", + "pino": "^9.6.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@whiskeysockets/baileys": { + "version": "7.0.0-rc13", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc13.tgz", + "integrity": "sha512-8JPc8gaaCRykkjW2jxLGQ7/RZGrc7awO7WU+QJocf58eSUI9jAdcuYLynzhAbyU4UWvJJsHImZ+5E/JaZj5ypA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "libsignal": "^6.0.0", + "lru-cache": "^11.1.0", + "music-metadata": "^11.12.3", + "p-queue": "^9.0.0", + "pino": "^9.6", + "protobufjs": "^7.5.6", + "whatsapp-rust-bridge": "0.5.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.1", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/libsignal": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/libsignal/-/libsignal-6.0.0.tgz", + "integrity": "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "^7.5.5" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/music-metadata": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz", + "integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "file-type": "^21.3.4", + "media-typer": "^2.0.0", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/music-metadata/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/music-metadata/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/music-metadata/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-queue": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.1.tgz", + "integrity": "sha512-POWdiIPmsUPGwb4FeQ4OBg46aqmcInSWe45CKDsGHiOBiVQM9chqfQTuqhuTzcg2Vz9faTI65at0KkVyVEiCHw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/whatsapp-rust-bridge": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz", + "integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==", + "license": "MIT" + }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/flocks/channel/builtin/whatsapp/bridge/package.json b/flocks/channel/builtin/whatsapp/bridge/package.json new file mode 100644 index 000000000..ffb826619 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge/package.json @@ -0,0 +1,20 @@ +{ + "name": "flocks-whatsapp-bridge", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Local WhatsApp bridge for Flocks using Baileys", + "scripts": { + "start": "node bridge.js" + }, + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "7.0.0-rc13", + "express": "^4.21.2", + "pino": "^9.6.0" + }, + "overrides": { + "protobufjs": "^7.5.5" + } +} + diff --git a/flocks/channel/builtin/whatsapp/bridge_runtime.py b/flocks/channel/builtin/whatsapp/bridge_runtime.py new file mode 100644 index 000000000..f15d22eb6 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/bridge_runtime.py @@ -0,0 +1,80 @@ +"""Runtime helpers for the bundled WhatsApp bridge.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import shlex +import subprocess +from pathlib import Path +from typing import Any + +from .config import coerce_int + +NODE_USE_BUNDLED_CA_OPTION = "--use-bundled-ca" + + +def file_hash(path: Path) -> str: + try: + return hashlib.sha256(path.read_bytes()).hexdigest()[:16] + except OSError: + return "" + + +def config_hash(values: dict[str, Any]) -> str: + payload = json.dumps(values, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def append_node_options(env: dict[str, str], *options: str) -> None: + raw = str(env.get("NODE_OPTIONS") or "").strip() + try: + current = shlex.split(raw) if raw else [] + except ValueError: + current = raw.split() + + additions = [option for option in options if option and option not in current] + if not additions: + return + env["NODE_OPTIONS"] = " ".join([part for part in [raw, *additions] if part]).strip() + + +async def ensure_bridge_deps(bridge_dir: Path) -> None: + package_json = bridge_dir / "package.json" + if not package_json.exists(): + raise RuntimeError(f"WhatsApp bridge package.json not found: {package_json}") + node_modules = bridge_dir / "node_modules" + stamp = node_modules / ".flocks-pkg-hash" + pkg_hash = file_hash(package_json) + deps_fresh = False + if node_modules.exists(): + try: + deps_fresh = stamp.read_text(encoding="utf-8").strip() == pkg_hash and bool(pkg_hash) + except OSError: + deps_fresh = False + if deps_fresh: + return + + from .config import find_executable + + npm = find_executable("npm") + if not npm: + raise RuntimeError("npm is required to install WhatsApp bridge dependencies") + install_cmd = [npm, "ci", "--silent"] if (bridge_dir / "package-lock.json").exists() else [npm, "install", "--silent"] + result = await asyncio.to_thread( + subprocess.run, + install_cmd, + cwd=str(bridge_dir), + capture_output=True, + text=True, + timeout=coerce_int(os.getenv("FLOCKS_WHATSAPP_NPM_INSTALL_TIMEOUT"), 300), + ) + if result.returncode != 0: + raise RuntimeError(f"WhatsApp bridge npm install failed: {result.stderr or result.stdout}") + if pkg_hash: + try: + stamp.write_text(pkg_hash, encoding="utf-8") + except OSError: + pass diff --git a/flocks/channel/builtin/whatsapp/channel.py b/flocks/channel/builtin/whatsapp/channel.py new file mode 100644 index 000000000..8bc58bbd5 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/channel.py @@ -0,0 +1,648 @@ +"""WhatsApp ChannelPlugin implementation.""" + +from __future__ import annotations + +import asyncio +import json +import os +import signal +import secrets +import subprocess +import time +from pathlib import Path +from typing import Any, Awaitable, Callable, Optional +from urllib.parse import unquote, urlparse + +import aiohttp + +from flocks.channel.base import ( + ChannelCapabilities, + ChannelMeta, + ChannelPlugin, + ChatType, + DeliveryResult, + InboundMessage, + OutboundContext, +) +from flocks.utils.log import Log + +from .config import ( + DEFAULT_BRIDGE_PORT, + DEFAULT_MESSAGE_LIMIT, + DEFAULT_SEND_CHUNK_DELAY_MS, + DEFAULT_SEND_TIMEOUT_MS, + DEFAULT_TEXT_BATCH_DELAY_SECONDS, + VALID_DM_POLICIES, + VALID_GROUP_POLICIES, + VALID_GROUP_TRIGGERS, + VALID_MODES, + coerce_float, + coerce_int, + coerce_list, + coerce_str, + default_bridge_dir, + default_media_cache_dir, + default_session_path, + find_executable, + format_env_list, + matches_identifier, + parse_target, +) +from .bridge_runtime import NODE_USE_BUNDLED_CA_OPTION, append_node_options, config_hash, ensure_bridge_deps, file_hash +from .format import format_for_whatsapp +from .inbound import build_inbound_message + +log = Log.create(service="channel.whatsapp") + + +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _path_from_media_url(media_url: str) -> Optional[Path]: + parsed = urlparse(media_url) + if parsed.scheme == "file": + return Path(unquote(parsed.path)) + if parsed.scheme in {"", None}: + return Path(media_url) + return None + + +def _media_type_for_path(path: Path) -> str: + suffix = path.suffix.lower() + if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif"}: + return "image" + if suffix in {".mp4", ".mov", ".mkv", ".webm"}: + return "video" + if suffix in {".ogg", ".opus", ".mp3", ".wav", ".m4a"}: + return "audio" + return "document" + + +def _raw_reply_id(reply_to_id: Optional[str]) -> Optional[str]: + if not reply_to_id: + return None + value = coerce_str(reply_to_id) + if "@" in value and ":" in value: + return value.rsplit(":", 1)[-1] or value + return value + + +class WhatsAppChannel(ChannelPlugin): + """WhatsApp personal-account channel via a local Baileys bridge.""" + + def __init__(self) -> None: + super().__init__() + self._account_id = "default" + self._bridge_port = DEFAULT_BRIDGE_PORT + self._bridge_dir = default_bridge_dir() + self._session_path = default_session_path() + self._media_cache_dir = default_media_cache_dir() + self._mode = "bot" + self._dm_policy = "allowlist" + self._group_policy = "disabled" + self._group_trigger = "mention" + self._allow_from: list[str] = [] + self._group_allow_from: list[str] = [] + self._text_batch_delay = DEFAULT_TEXT_BATCH_DELAY_SECONDS + self._send_chunk_delay_ms = DEFAULT_SEND_CHUNK_DELAY_MS + self._send_timeout_ms = DEFAULT_SEND_TIMEOUT_MS + self._reply_prefix = "" + self._http: Optional[aiohttp.ClientSession] = None + self._bridge_token = secrets.token_urlsafe(32) + self._bridge_process: Optional[subprocess.Popen] = None + self._poll_task: Optional[asyncio.Task] = None + self._bridge_log_fh = None + self._bridge_log_path: Optional[Path] = None + self._pending_text: dict[str, InboundMessage] = {} + self._pending_tasks: dict[str, asyncio.Task] = {} + self._shutting_down = False + + def meta(self) -> ChannelMeta: + return ChannelMeta( + id="whatsapp", + label="WhatsApp", + aliases=["wa"], + order=45, + ) + + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT, ChatType.GROUP], + media=True, + threads=False, + reactions=False, + edit=False, + rich_text=True, + ) + + def validate_config(self, config: dict) -> Optional[str]: + mode = coerce_str(config.get("mode") or "bot") + if mode not in VALID_MODES: + return "WhatsApp mode must be 'bot' or 'self-chat'" + dm_policy = coerce_str(config.get("dmPolicy") or "allowlist") + if dm_policy not in VALID_DM_POLICIES: + return "WhatsApp dmPolicy must be 'open', 'allowlist', or 'disabled'" + group_policy = coerce_str(config.get("groupPolicy") or "disabled") + if group_policy not in VALID_GROUP_POLICIES: + return "WhatsApp groupPolicy must be 'open', 'allowlist', or 'disabled'" + group_trigger = coerce_str(config.get("groupTrigger") or "mention") + if group_trigger not in VALID_GROUP_TRIGGERS: + return "WhatsApp groupTrigger must be 'mention' or 'all'" + bridge_port = coerce_int(config.get("bridgePort"), DEFAULT_BRIDGE_PORT) + if bridge_port <= 0 or bridge_port > 65535: + return "WhatsApp bridgePort must be between 1 and 65535" + if not find_executable("node"): + return "WhatsApp channel requires Node.js" + session_path = Path(coerce_str(config.get("sessionPath")) or default_session_path()).expanduser() + if not (session_path / "creds.json").exists(): + return "WhatsApp is not paired yet. Use QR pairing before enabling the channel." + return None + + @property + def text_chunk_limit(self) -> int: + return DEFAULT_MESSAGE_LIMIT + + @property + def rate_limit(self) -> tuple[float, int]: + return (3.0, 2) + + def format_message(self, text: str, format_hint: str = "markdown") -> str: + if format_hint == "plain": + return text + return format_for_whatsapp(text) + + def normalize_target(self, raw: str) -> Optional[str]: + target = parse_target(raw) + return target or None + + def target_hint(self) -> str: + return " / @s.whatsapp.net / @g.us" + + async def start( + self, + config: dict, + on_message: Callable[[InboundMessage], Awaitable[None]], + abort_event: Optional[asyncio.Event] = None, + ) -> None: + self._config = config + self._on_message = on_message + self._load_config(config) + self._shutting_down = False + + await self._ensure_bridge_deps() + await self._start_bridge() + self._http = aiohttp.ClientSession() + self.mark_connected() + log.info("whatsapp.connected", { + "bridge_port": self._bridge_port, + "session_path": str(self._session_path), + }) + + _abort = abort_event if abort_event is not None else asyncio.Event() + self._poll_task = asyncio.create_task(self._poll_messages(_abort)) + abort_task = asyncio.create_task(_abort.wait()) + try: + done, _pending = await asyncio.wait( + {abort_task, self._poll_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if self._poll_task in done: + exc = self._poll_task.exception() + if exc: + raise exc + finally: + abort_task.cancel() + await asyncio.gather(abort_task, return_exceptions=True) + await self.stop() + + async def stop(self) -> None: + self._shutting_down = True + for task in list(self._pending_tasks.values()): + task.cancel() + if self._pending_tasks: + await asyncio.gather(*self._pending_tasks.values(), return_exceptions=True) + self._pending_tasks.clear() + self._pending_text.clear() + + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + await asyncio.gather(self._poll_task, return_exceptions=True) + self._poll_task = None + + if self._http is not None: + await self._http.close() + self._http = None + + if self._bridge_process is not None: + proc = self._bridge_process + if proc.poll() is None: + try: + if os.name == "nt": + proc.terminate() + else: + os.killpg(proc.pid, signal.SIGTERM) + await asyncio.to_thread(proc.wait, 5) + except Exception: + try: + proc.kill() + except Exception: + pass + self._bridge_process = None + + if self._bridge_log_fh: + try: + self._bridge_log_fh.close() + except Exception: + pass + self._bridge_log_fh = None + self.mark_disconnected() + + async def send_text(self, ctx: OutboundContext) -> DeliveryResult: + if self._http is None: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error="WhatsApp bridge is not connected", + retryable=True, + ) + chat_id = parse_target(ctx.to) + if not chat_id: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error="Invalid WhatsApp target", + ) + + try: + async with self._http.post( + self._url("/send"), + json={ + "chatId": chat_id, + "message": ctx.text, + "replyTo": _raw_reply_id(ctx.reply_to_id), + }, + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=max(self._send_timeout_ms / 1000, 1)), + ) as resp: + data = await resp.json(content_type=None) + if resp.status >= 400 or data.get("success") is False: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=str(data.get("error") or f"HTTP {resp.status}"), + retryable=resp.status >= 500, + ) + except Exception as exc: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=f"WhatsApp send failed: {exc}", + retryable=True, + ) + + self.record_message() + return DeliveryResult( + channel_id="whatsapp", + message_id=coerce_str(data.get("messageId")), + chat_id=chat_id, + success=True, + ) + + async def send_media(self, ctx: OutboundContext) -> DeliveryResult: + if not ctx.media_url: + return await self.send_text(ctx) + if self._http is None: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error="WhatsApp bridge is not connected", + retryable=True, + ) + chat_id = parse_target(ctx.to) + path = _path_from_media_url(ctx.media_url) + if not chat_id: + return DeliveryResult(channel_id="whatsapp", message_id="", success=False, error="Invalid WhatsApp target") + if path is None or not path.is_file(): + return DeliveryResult(channel_id="whatsapp", message_id="", success=False, error="WhatsApp media must be a local file") + + try: + async with self._http.post( + self._url("/send-media"), + json={ + "chatId": chat_id, + "filePath": str(path), + "mediaType": _media_type_for_path(path), + "caption": ctx.text or "", + "replyTo": _raw_reply_id(ctx.reply_to_id), + }, + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=max(self._send_timeout_ms / 1000, 1)), + ) as resp: + data = await resp.json(content_type=None) + if resp.status >= 400 or data.get("success") is False: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=str(data.get("error") or f"HTTP {resp.status}"), + retryable=resp.status >= 500, + ) + except Exception as exc: + return DeliveryResult( + channel_id="whatsapp", + message_id="", + success=False, + error=f"WhatsApp send_media failed: {exc}", + retryable=True, + ) + + self.record_message() + return DeliveryResult( + channel_id="whatsapp", + message_id=coerce_str(data.get("messageId")), + chat_id=chat_id, + success=True, + ) + + def _load_config(self, config: dict) -> None: + self._account_id = coerce_str(config.get("accountId")) or "default" + self._bridge_port = coerce_int(config.get("bridgePort"), DEFAULT_BRIDGE_PORT) + self._bridge_dir = Path(coerce_str(config.get("bridgeDir")) or default_bridge_dir()).expanduser() + self._session_path = Path(coerce_str(config.get("sessionPath")) or default_session_path()).expanduser() + self._media_cache_dir = Path(coerce_str(config.get("mediaCacheDir")) or default_media_cache_dir()).expanduser() + self._mode = coerce_str(config.get("mode") or "bot") + self._dm_policy = coerce_str(config.get("dmPolicy") or "allowlist") + self._group_policy = coerce_str(config.get("groupPolicy") or "disabled") + self._group_trigger = coerce_str(config.get("groupTrigger") or "mention") + self._allow_from = coerce_list(config.get("allowFrom")) + self._group_allow_from = coerce_list(config.get("groupAllowFrom")) + self._text_batch_delay = coerce_float(config.get("textBatchDelaySeconds"), DEFAULT_TEXT_BATCH_DELAY_SECONDS) + self._send_chunk_delay_ms = coerce_int(config.get("sendChunkDelayMs"), DEFAULT_SEND_CHUNK_DELAY_MS) + self._send_timeout_ms = coerce_int(config.get("sendTimeoutMs"), DEFAULT_SEND_TIMEOUT_MS) + self._reply_prefix = coerce_str(config.get("replyPrefix")) + + async def _ensure_bridge_deps(self) -> None: + await ensure_bridge_deps(self._bridge_dir) + + async def _start_bridge(self) -> None: + node = find_executable("node") + if not node: + raise RuntimeError("Node.js is required for WhatsApp channel") + script = self._bridge_dir / "bridge.js" + if not script.exists(): + raise RuntimeError(f"WhatsApp bridge script not found: {script}") + self._session_path.mkdir(parents=True, exist_ok=True) + self._media_cache_dir.mkdir(parents=True, exist_ok=True) + + if await self._reuse_or_stop_existing_bridge(script): + return + + self._bridge_log_path = self._session_path.parent / "bridge.log" + self._bridge_log_path.parent.mkdir(parents=True, exist_ok=True) + self._bridge_log_fh = open(self._bridge_log_path, "a", encoding="utf-8") + env = os.environ.copy() + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + env["FLOCKS_WHATSAPP_MEDIA_DIR"] = str(self._media_cache_dir) + env["FLOCKS_WHATSAPP_BRIDGE_TOKEN"] = self._bridge_token + env["FLOCKS_WHATSAPP_CONFIG_HASH"] = self._bridge_config_hash() + env["FLOCKS_WHATSAPP_ALLOWED_MEDIA_ROOTS"] = os.pathsep.join(self._allowed_media_roots()) + env["FLOCKS_WHATSAPP_MODE"] = self._mode + env["FLOCKS_WHATSAPP_DM_POLICY"] = self._dm_policy + env["FLOCKS_WHATSAPP_ALLOWED_USERS"] = format_env_list(self._allow_from) + env["FLOCKS_WHATSAPP_REPLY_PREFIX"] = self._reply_prefix + env["FLOCKS_WHATSAPP_CHUNK_DELAY_MS"] = str(self._send_chunk_delay_ms) + env["FLOCKS_WHATSAPP_SEND_TIMEOUT_MS"] = str(self._send_timeout_ms) + + self._bridge_process = subprocess.Popen( + [ + node, + str(script), + "--port", + str(self._bridge_port), + "--session", + str(self._session_path), + ], + cwd=str(self._bridge_dir), + stdout=self._bridge_log_fh, + stderr=self._bridge_log_fh, + start_new_session=(os.name != "nt"), + env=env, + ) + await self._wait_for_bridge(script) + + async def _reuse_or_stop_existing_bridge(self, script: Path) -> bool: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + self._url("/health"), + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=2), + ) as resp: + data = await resp.json(content_type=None) + if resp.status == 200 and data.get("status") == "connected" and self._bridge_identity_matches(data, script): + self._bridge_process = None + return True + if resp.status == 200 and data.get("scriptHash") == file_hash(script): + if coerce_str(data.get("sessionPath")) != str(self._session_path): + raise RuntimeError( + f"WhatsApp bridge port {self._bridge_port} is already used by another session" + ) + except RuntimeError: + raise + except Exception: + pass + pid_file = self._session_path / "bridge.pid" + try: + pid = int(pid_file.read_text(encoding="utf-8").strip().splitlines()[0]) + if _pid_exists(pid): + os.kill(pid, signal.SIGTERM) + await asyncio.sleep(1) + except Exception: + pass + return False + + async def _wait_for_bridge(self, script: Path) -> None: + deadline = time.monotonic() + 30 + last_status = "unknown" + while time.monotonic() < deadline: + if self._bridge_process is not None and self._bridge_process.poll() is not None: + raise RuntimeError( + f"WhatsApp bridge exited early with code {self._bridge_process.returncode}; log: {self._bridge_log_path}" + ) + try: + async with aiohttp.ClientSession() as session: + async with session.get( + self._url("/health"), + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=2), + ) as resp: + data = await resp.json(content_type=None) + last_status = coerce_str(data.get("status")) or last_status + if resp.status == 200 and self._bridge_identity_matches(data, script): + if data.get("status") == "connected": + if self._bridge_process is not None: + try: + (self._session_path / "bridge.pid").write_text( + str(self._bridge_process.pid), + encoding="utf-8", + ) + except OSError: + pass + return + except Exception: + pass + await asyncio.sleep(1) + raise RuntimeError(f"WhatsApp bridge did not connect in time (last status: {last_status}); log: {self._bridge_log_path}") + + async def _poll_messages(self, abort_event: asyncio.Event) -> None: + consecutive_failures = 0 + while not abort_event.is_set() and not self._shutting_down: + if self._http is None: + return + if self._bridge_process is not None and self._bridge_process.poll() is not None: + raise RuntimeError(f"WhatsApp bridge exited with code {self._bridge_process.returncode}") + try: + async with self._http.get( + self._url("/messages"), + headers=self._bridge_headers(), + timeout=aiohttp.ClientTimeout(total=35), + ) as resp: + if resp.status != 200: + raise RuntimeError(f"WhatsApp bridge /messages returned HTTP {resp.status}") + events = await resp.json(content_type=None) + consecutive_failures = 0 + if isinstance(events, list): + for event in events: + await self._handle_bridge_event(event) + except asyncio.CancelledError: + break + except Exception as exc: + consecutive_failures += 1 + log.warning("whatsapp.poll.error", {"error": str(exc)}) + if consecutive_failures >= 3: + self.mark_disconnected(str(exc)) + raise RuntimeError(f"WhatsApp bridge polling failed {consecutive_failures} times: {exc}") from exc + try: + await asyncio.wait_for(abort_event.wait(), timeout=5) + except asyncio.TimeoutError: + pass + + async def _handle_bridge_event(self, event: Any) -> None: + if not isinstance(event, dict): + return + inbound = build_inbound_message(event, self._account_id) + if inbound is None or not self._is_allowed(inbound): + return + if inbound.chat_type == ChatType.GROUP and not self._passes_group_trigger(inbound): + return + await self._enqueue_or_dispatch(inbound) + + def _is_allowed(self, msg: InboundMessage) -> bool: + if msg.chat_type == ChatType.DIRECT: + if self._dm_policy == "disabled": + return False + if self._dm_policy == "open": + return True + sender_aliases = [] + if isinstance(msg.raw, dict): + raw_aliases = msg.raw.get("senderAliases") + if isinstance(raw_aliases, list): + sender_aliases = raw_aliases + return matches_identifier([msg.sender_id, *sender_aliases], self._allow_from) + if self._group_policy == "disabled": + return False + if self._group_policy == "open": + return True + chat_aliases = [] + if isinstance(msg.raw, dict): + raw_aliases = msg.raw.get("chatAliases") + if isinstance(raw_aliases, list): + chat_aliases = raw_aliases + return matches_identifier([msg.chat_id, *chat_aliases], self._group_allow_from) + + def _passes_group_trigger(self, msg: InboundMessage) -> bool: + if self._group_trigger == "all": + return True + return msg.mentioned + + async def _enqueue_or_dispatch(self, msg: InboundMessage) -> None: + if msg.media_url or self._text_batch_delay <= 0: + if self._on_message: + await self._on_message(msg) + return + key = f"{msg.account_id}:{msg.chat_id}" + existing = self._pending_text.get(key) + if existing is None: + self._pending_text[key] = msg + else: + existing.text = f"{existing.text}\n{msg.text}".strip() + existing.message_id = msg.message_id + existing.raw = msg.raw + prior = self._pending_tasks.get(key) + if prior and not prior.done(): + prior.cancel() + self._pending_tasks[key] = asyncio.create_task(self._flush_text_batch(key)) + + async def _flush_text_batch(self, key: str) -> None: + current = asyncio.current_task() + try: + await asyncio.sleep(self._text_batch_delay) + msg = self._pending_text.pop(key, None) + if msg and self._on_message: + await self._on_message(msg) + except asyncio.CancelledError: + pass + finally: + if self._pending_tasks.get(key) is current: + self._pending_tasks.pop(key, None) + + def _url(self, path: str) -> str: + return f"http://127.0.0.1:{self._bridge_port}{path}" + + def _bridge_headers(self) -> dict[str, str]: + return {"X-Flocks-Bridge-Token": self._bridge_token} + + def _bridge_config_hash(self) -> str: + return config_hash({ + "sessionPath": str(self._session_path), + "mediaDir": str(self._media_cache_dir), + "mode": self._mode, + "replyPrefix": self._reply_prefix, + "sendChunkDelayMs": self._send_chunk_delay_ms, + "sendTimeoutMs": self._send_timeout_ms, + }) + + def _bridge_identity_matches(self, data: dict[str, Any], script: Path) -> bool: + return ( + data.get("scriptHash") == file_hash(script) + and coerce_str(data.get("sessionPath")) == str(self._session_path) + and coerce_str(data.get("mediaDir")) == str(self._media_cache_dir) + and coerce_str(data.get("mode")) == self._mode + and coerce_str(data.get("configHash")) == self._bridge_config_hash() + ) + + def _allowed_media_roots(self) -> list[str]: + roots = [ + self._media_cache_dir, + Path.home() / ".flocks" / "workspace", + Path(os.getenv("TMPDIR") or "/tmp"), + ] + extra = coerce_list(self._config.get("mediaAllowedRoots") if hasattr(self, "_config") else None) + roots.extend(Path(item).expanduser() for item in extra) + resolved: list[str] = [] + for root in roots: + try: + resolved.append(str(root.resolve())) + except OSError: + resolved.append(str(root.expanduser())) + return list(dict.fromkeys(resolved)) diff --git a/flocks/channel/builtin/whatsapp/config.py b/flocks/channel/builtin/whatsapp/config.py new file mode 100644 index 000000000..40383932a --- /dev/null +++ b/flocks/channel/builtin/whatsapp/config.py @@ -0,0 +1,149 @@ +"""Configuration helpers for the WhatsApp channel.""" + +from __future__ import annotations + +import os +import re +import shutil +from pathlib import Path +from typing import Any, Iterable, Optional + +DEFAULT_BRIDGE_PORT = 3100 +DEFAULT_TEXT_BATCH_DELAY_SECONDS = 3.0 +DEFAULT_SEND_CHUNK_DELAY_MS = 300 +DEFAULT_MESSAGE_LIMIT = 4096 +DEFAULT_SEND_TIMEOUT_MS = 60000 + +VALID_MODES = {"bot", "self-chat"} +VALID_DM_POLICIES = {"open", "allowlist", "disabled"} +VALID_GROUP_POLICIES = {"open", "allowlist", "disabled"} +VALID_GROUP_TRIGGERS = {"mention", "all"} + +_PHONE_RE = re.compile(r"^\+?\d{6,20}$") +_JID_RE = re.compile(r"^[\w.+-]+@(?:s\.whatsapp\.net|g\.us|lid|broadcast|newsletter)$") + + +def coerce_str(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def coerce_int(value: Any, default: int) -> int: + if isinstance(value, bool): + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed + + +def coerce_float(value: Any, default: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + if parsed < 0: + return default + return parsed + + +def coerce_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [part.strip() for part in str(value).split(",") if part.strip()] + + +def default_state_dir() -> Path: + raw = os.getenv("FLOCKS_WHATSAPP_DATA_DIR") + if raw: + return Path(raw).expanduser() + return Path.home() / ".flocks" / "workspace" / "channels" / "whatsapp" + + +def default_session_path() -> Path: + return default_state_dir() / "session" + + +def default_media_cache_dir() -> Path: + return default_state_dir() / "media" + + +def default_bridge_dir() -> Path: + return Path(__file__).resolve().parent / "bridge" + + +def find_executable(name: str) -> Optional[str]: + return shutil.which(name) + + +def normalize_jid(value: str) -> str: + raw = coerce_str(value) + if not raw: + return "" + if ":" in raw and "@" in raw: + raw = re.sub(r":\d+@", "@", raw, count=1) + if _JID_RE.fullmatch(raw): + return raw + phone = raw.lstrip("+").replace(" ", "") + if _PHONE_RE.fullmatch(phone): + return f"{phone}@s.whatsapp.net" + return raw + + +def strip_jid(value: str) -> str: + raw = normalize_jid(value) + if "@" in raw: + return raw.split("@", 1)[0].split(":", 1)[0] + return raw.lstrip("+") + + +def identifier_aliases(*values: Any) -> list[str]: + """Return stable WhatsApp identity aliases while preserving order.""" + aliases: list[str] = [] + seen: set[str] = set() + for value in values: + if isinstance(value, (list, tuple, set)): + items: Iterable[Any] = value + else: + items = (value,) + for item in items: + raw = coerce_str(item) + if not raw: + continue + normalized = normalize_jid(raw) + stripped = strip_jid(normalized) + for alias in (raw, normalized, stripped): + if alias and alias not in seen: + aliases.append(alias) + seen.add(alias) + return aliases + + +def parse_target(raw: str) -> str: + value = coerce_str(raw) + for prefix in ("whatsapp:", "wa:", "user:", "group:"): + if value.lower().startswith(prefix): + value = value[len(prefix):].strip() + break + return normalize_jid(value) + + +def matches_identifier(candidate: str | list[str], allowed: list[str]) -> bool: + if not allowed: + return False + if "*" in allowed: + return True + aliases = set(identifier_aliases(candidate)) + for entry in allowed: + aliases_for_entry = set(identifier_aliases(entry)) + if aliases & aliases_for_entry: + return True + return False + + +def format_env_list(items: list[str]) -> str: + return ",".join(item for item in items if item) diff --git a/flocks/channel/builtin/whatsapp/format.py b/flocks/channel/builtin/whatsapp/format.py new file mode 100644 index 000000000..8570d81b5 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/format.py @@ -0,0 +1,23 @@ +"""WhatsApp text formatting helpers.""" + +from __future__ import annotations + +import re + + +_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)") + + +def format_for_whatsapp(text: str) -> str: + """Convert generic Markdown into WhatsApp-friendly plain markdown. + + WhatsApp supports lightweight emphasis but not Markdown links. Keeping + code fences intact gives readable monospace blocks on most clients. + """ + if not text: + return "" + out = _LINK_RE.sub(r"\1: \2", text) + out = re.sub(r"(? tuple[Optional[str], Optional[str]]: + urls = event.get("mediaUrls") + if isinstance(urls, list) and urls: + url = coerce_str(urls[0]) + if url: + return url, coerce_str(event.get("mime")) or None + url = coerce_str(event.get("mediaUrl")) + if url: + return url, coerce_str(event.get("mime")) or None + return None, None + + +def _is_group_jid(jid: str) -> bool: + return jid.endswith("@g.us") + + +def _resolve_mentioned(event: dict[str, Any], text: str) -> tuple[bool, str]: + if event.get("mentioned") is True: + mention_text = coerce_str(event.get("mentionText")) or text + return True, mention_text.strip() + if event.get("isGroup") and event.get("isReplyToBot"): + return True, text.strip() + return False, "" + + +def build_inbound_message(event: dict[str, Any], account_id: str = "default") -> Optional[InboundMessage]: + chat_id = normalize_jid(coerce_str(event.get("chatId"))) + sender_id = normalize_jid(coerce_str(event.get("senderId")) or chat_id) + sender_alt_id = normalize_jid(coerce_str(event.get("senderAltId"))) + chat_alt_id = normalize_jid(coerce_str(event.get("chatAltId"))) + message_id = coerce_str(event.get("messageId")) + if not chat_id or not message_id: + return None + + text = coerce_str(event.get("body") or event.get("text")) + media_url, media_mime = _first_media(event) + if not text and not media_url: + return None + + is_group = bool(event.get("isGroup")) or _is_group_jid(chat_id) + chat_type = ChatType.GROUP if is_group else ChatType.DIRECT + sender_name = coerce_str(event.get("senderName")) or None + + mentioned, mention_text = _resolve_mentioned(event, text) + sender_aliases = identifier_aliases(sender_id, sender_alt_id, event.get("senderAliases")) + chat_aliases = identifier_aliases(chat_id, chat_alt_id, event.get("chatAliases")) + raw = {**event, "senderAliases": sender_aliases, "chatAliases": chat_aliases} + + return InboundMessage( + channel_id="whatsapp", + account_id=account_id, + message_id=f"{chat_id}:{message_id}", + sender_id=strip_jid(sender_id) or sender_id, + sender_name=sender_name, + chat_id=chat_id, + chat_type=chat_type, + text=text, + media_url=media_url, + media_mime=media_mime, + reply_to_id=coerce_str(event.get("quotedMessageId")) or None, + thread_id=None, + mentioned=mentioned, + mention_text=mention_text, + raw=raw, + ) diff --git a/flocks/channel/builtin/whatsapp/pairing.py b/flocks/channel/builtin/whatsapp/pairing.py new file mode 100644 index 000000000..b58b10040 --- /dev/null +++ b/flocks/channel/builtin/whatsapp/pairing.py @@ -0,0 +1,212 @@ +"""QR pairing helpers for the WhatsApp channel.""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from flocks.utils.log import Log + +from .bridge_runtime import NODE_USE_BUNDLED_CA_OPTION, append_node_options, ensure_bridge_deps +from .config import default_bridge_dir, default_session_path, find_executable + +log = Log.create(service="channel.whatsapp.pairing") + + +@dataclass +class PairingSession: + id: str + session_path: Path + process: asyncio.subprocess.Process + qr: Optional[str] = None + status: str = "starting" + error: Optional[str] = None + user: Optional[dict] = None + created_at: float = field(default_factory=time.time) + finished_at: Optional[float] = None + _reader_task: Optional[asyncio.Task] = field(default=None, repr=False) + + +_sessions: dict[str, PairingSession] = {} +_active_session_paths: set[str] = set() +_active_session_paths_lock = asyncio.Lock() + + +def _session_key(path: Path) -> str: + try: + return str(path.resolve()) + except OSError: + return str(path.expanduser()) + + +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _cleanup_pairings(ttl_seconds: int = 600) -> None: + now = time.time() + stale = [ + pairing_id + for pairing_id, pairing in _sessions.items() + if pairing.finished_at is not None and now - pairing.finished_at > ttl_seconds + ] + for pairing_id in stale: + _sessions.pop(pairing_id, None) + + +def _release_session(pairing: PairingSession) -> None: + _active_session_paths.discard(_session_key(pairing.session_path)) + pairing.finished_at = pairing.finished_at or time.time() + + +def _backup_session_dir(session_path: Path) -> Optional[Path]: + if not session_path.exists() or not any(session_path.iterdir()): + return None + backup_path = session_path.with_name(f"{session_path.name}.backup.{int(time.time())}.{uuid.uuid4().hex[:8]}") + shutil.move(str(session_path), str(backup_path)) + log.info("whatsapp.pairing.session_backed_up", { + "session_path": str(session_path), + "backup_path": str(backup_path), + }) + return backup_path + + +async def _read_pair_output(pairing: PairingSession) -> None: + assert pairing.process.stdout is not None + while True: + line = await pairing.process.stdout.readline() + if not line: + break + raw = line.decode("utf-8", errors="replace").strip() + if not raw: + continue + try: + event = json.loads(raw) + except json.JSONDecodeError: + continue + name = str(event.get("event") or "") + if name == "qr": + pairing.qr = str(event.get("qr") or "") + pairing.status = "qr" + elif name == "connected": + pairing.user = event.get("user") if isinstance(event.get("user"), dict) else None + pairing.status = "connected" + elif name == "error": + pairing.error = str(event.get("error") or "WhatsApp pairing failed") + pairing.status = "error" + elif name == "disconnected" and pairing.status not in {"qr", "connected"}: + pairing.status = "starting" + + code = await pairing.process.wait() + if pairing.status == "connected" and code == 0: + pairing.status = "complete" + elif pairing.status not in {"complete", "connected", "error", "cancelled"}: + pairing.status = "error" + pairing.error = f"WhatsApp pairing bridge exited with code {code}" + _release_session(pairing) + + +async def start_pairing( + *, + session_path: Optional[str] = None, + bridge_dir: Optional[str] = None, + reset_session: bool = False, +) -> PairingSession: + _cleanup_pairings() + node = find_executable("node") + if not node: + raise RuntimeError("Node.js is required for WhatsApp QR pairing") + + bridge_root = Path(bridge_dir).expanduser() if bridge_dir else default_bridge_dir() + script = bridge_root / "bridge.js" + if not script.exists(): + raise RuntimeError(f"WhatsApp bridge script not found: {script}") + + sess = Path(session_path).expanduser() if session_path else default_session_path() + key = _session_key(sess) + + async with _active_session_paths_lock: + if key in _active_session_paths: + raise RuntimeError("WhatsApp pairing is already running for this session") + _active_session_paths.add(key) + + try: + await ensure_bridge_deps(bridge_root) + + pid_file = sess / "bridge.pid" + try: + pid = int(pid_file.read_text(encoding="utf-8").strip().splitlines()[0]) + if _pid_exists(pid): + raise RuntimeError("WhatsApp channel is already running for this session; stop it before pairing") + except FileNotFoundError: + pass + except ValueError: + pass + + if reset_session: + _backup_session_dir(sess) + sess.mkdir(parents=True, exist_ok=True) + + pairing_id = uuid.uuid4().hex + env = os.environ.copy() + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + env.setdefault("FLOCKS_WHATSAPP_PAIR_TIMEOUT_MS", "120000") + + proc = await asyncio.create_subprocess_exec( + node, + str(script), + "--pair-only", + "--pair-json", + "--session", + str(sess), + cwd=str(bridge_root), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=env, + ) + except Exception: + _active_session_paths.discard(key) + raise + + pairing = PairingSession( + id=pairing_id, + session_path=sess, + process=proc, + ) + pairing._reader_task = asyncio.create_task(_read_pair_output(pairing)) + _sessions[pairing_id] = pairing + log.info("whatsapp.pairing.started", {"pairing_id": pairing_id}) + return pairing + + +def get_pairing(pairing_id: str) -> Optional[PairingSession]: + _cleanup_pairings() + return _sessions.get(pairing_id) + + +async def cancel_pairing(pairing_id: str) -> bool: + pairing = _sessions.pop(pairing_id, None) + if pairing is None: + return False + pairing.status = "cancelled" + _release_session(pairing) + if pairing.process.returncode is None: + pairing.process.terminate() + try: + await asyncio.wait_for(pairing.process.wait(), timeout=5) + except asyncio.TimeoutError: + pairing.process.kill() + if pairing._reader_task: + pairing._reader_task.cancel() + return True diff --git a/flocks/channel/inbound/dispatcher.py b/flocks/channel/inbound/dispatcher.py index 37f38a242..16ff95321 100644 --- a/flocks/channel/inbound/dispatcher.py +++ b/flocks/channel/inbound/dispatcher.py @@ -135,6 +135,22 @@ def _evict_expired(self, now: float) -> None: # Allowlist / authorisation check # ===================================================================== +def _matches_allow_from(msg: InboundMessage, allow_from: list[str]) -> bool: + if msg.channel_id == "whatsapp": + try: + from flocks.channel.builtin.whatsapp.config import matches_identifier + + aliases = [msg.sender_id] + if isinstance(msg.raw, dict): + raw_aliases = msg.raw.get("senderAliases") + if isinstance(raw_aliases, list): + aliases.extend(str(alias) for alias in raw_aliases if str(alias).strip()) + return matches_identifier(aliases, allow_from) + except Exception: + log.warning("allowlist.whatsapp_match_failed", {"sender": msg.sender_id}) + return msg.sender_id in allow_from + + def _check_allowlist(msg: InboundMessage, config: ChannelConfig) -> bool: """Return True if the message is allowed through. @@ -156,12 +172,12 @@ def _check_allowlist(msg: InboundMessage, config: ChannelConfig) -> bool: if not allow_from: log.debug("allowlist.dm_blocked_no_list", {"sender": msg.sender_id}) return False - return msg.sender_id in allow_from + return _matches_allow_from(msg, allow_from) if dm_policy == "pairing": return True return True - if allow_from and msg.sender_id not in allow_from: + if allow_from and not _matches_allow_from(msg, allow_from): log.debug("allowlist.group_blocked", { "sender": msg.sender_id, "chat_id": msg.chat_id, }) diff --git a/flocks/channel/inbound/session_binding.py b/flocks/channel/inbound/session_binding.py index 906f913c1..c9e49cd2b 100644 --- a/flocks/channel/inbound/session_binding.py +++ b/flocks/channel/inbound/session_binding.py @@ -135,6 +135,9 @@ async def _get_db() -> aiosqlite.Connection: """ global _db_conn, _db_ready, _db_owner_pid + from flocks.storage.storage import Storage + + await Storage._ensure_init() current_pid = os.getpid() if ( _db_conn is not None @@ -169,7 +172,6 @@ async def _get_db() -> aiosqlite.Connection: _db_ready = False _db_owner_pid = None - from flocks.storage.storage import Storage db_path = Storage.get_db_path() db_path.parent.mkdir(parents=True, exist_ok=True) @@ -230,17 +232,28 @@ async def _migrate_legacy_binding_agent_ids(db: aiosqlite.Connection) -> None: }) -async def close_binding_db() -> None: - """Close the persistent connection (call during shutdown).""" +async def _close_binding_db_locked(*, suppress_errors: bool) -> None: + """Invalidate and close ``_db_conn`` while ``_init_lock`` is held.""" + global _db_conn, _db_ready, _db_owner_pid - if _db_conn is not None: + + conn = _db_conn + _db_conn = None + _db_ready = False + _db_owner_pid = None + if conn is not None: try: - await _db_conn.close() + await conn.close() except Exception: - pass - _db_conn = None - _db_ready = False - _db_owner_pid = None + if not suppress_errors: + raise + + +async def close_binding_db() -> None: + """Close the persistent connection (call during shutdown).""" + + async with _init_lock: + await _close_binding_db_locked(suppress_errors=True) class SessionBindingService: diff --git a/flocks/channel/media_filename.py b/flocks/channel/media_filename.py index 0f181bc5f..8dbe73c5e 100644 --- a/flocks/channel/media_filename.py +++ b/flocks/channel/media_filename.py @@ -11,6 +11,7 @@ def sanitize_filename(name: str, *, fallback: str = "attachment", max_chars: int = 120) -> str: """Return a filesystem-safe filename while preserving Unicode text.""" cleaned = unicodedata.normalize("NFC", str(name or "")).strip() + cleaned = cleaned.replace("\\", "/").split("/")[-1].strip() cleaned = _INVALID_FILENAME_CHARS_RE.sub("_", cleaned) cleaned = re.sub(r"\s+", " ", cleaned).strip() if cleaned in {".", ".."}: diff --git a/flocks/channel/registry.py b/flocks/channel/registry.py index c782f937a..e3a138438 100644 --- a/flocks/channel/registry.py +++ b/flocks/channel/registry.py @@ -91,15 +91,19 @@ def reset(self) -> None: def _register_builtin_channels(self) -> None: from flocks.channel.builtin.dingtalk.channel import DingTalkChannel + from flocks.channel.builtin.email.channel import EmailChannel from flocks.channel.builtin.feishu.channel import FeishuChannel from flocks.channel.builtin.telegram.channel import TelegramChannel from flocks.channel.builtin.wecom.channel import WeComChannel + from flocks.channel.builtin.whatsapp.channel import WhatsAppChannel from flocks.channel.builtin.weixin.channel import WeixinChannel self.register(FeishuChannel()) self.register(WeComChannel()) self.register(TelegramChannel()) + self.register(WhatsAppChannel()) self.register(DingTalkChannel()) self.register(WeixinChannel()) + self.register(EmailChannel()) def _register_plugin_extension_point(self) -> None: from flocks.plugin import PluginLoader, ExtensionPoint diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index cc4ce8d44..46134d52f 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -838,6 +838,8 @@ def port_is_in_use(port: int, listeners: Sequence[int] | None = None) -> bool: current_listeners = list(listeners) if listeners is not None else port_owner_pids(port) if current_listeners: return True + if sys.platform == "win32": + return not _bind_port_available(port) if _port_owner_lookup_available(): return False return not _bind_port_available(port) @@ -1042,17 +1044,6 @@ def _is_running_status_response(response: httpx.Response) -> bool: return isinstance(payload, dict) and payload.get("status") == "running" -def _is_healthy_status_response(response: httpx.Response) -> bool: - """Return True when the backend health endpoint reports healthy.""" - if response.status_code != 200: - return False - try: - payload = response.json() - except ValueError: - return False - return isinstance(payload, dict) and payload.get("status") == "healthy" - - def wait_for_http( urls: Sequence[str], name: str, @@ -1086,10 +1077,6 @@ def print(self, *args, **_kwargs) -> None: sys.stdout.flush() -def _backend_health_url(host: str, port: int) -> str: - return f"http://{_format_host_for_url(access_host(host))}:{port}/api/health" - - def _terminate_process( process: subprocess.Popen | None, name: str, @@ -2184,7 +2171,7 @@ def _run_windows_netstat(port: int) -> str: return "" target = f":{port}" lines = [] - for line in completed.stdout.splitlines(): + for line in (completed.stdout or "").splitlines(): if "LISTENING" not in line.upper(): continue if target not in line: diff --git a/flocks/cli/service_process.py b/flocks/cli/service_process.py index d6407417e..a64d82810 100644 --- a/flocks/cli/service_process.py +++ b/flocks/cli/service_process.py @@ -7,10 +7,6 @@ from dataclasses import dataclass from typing import Protocol -import httpx - -from flocks.cli.service_config import ServiceConfig - @dataclass(frozen=True) class ServiceProbeResult: @@ -59,29 +55,7 @@ def probe(self, process: subprocess.Popen | None, host: str, port: int) -> Servi ) if not tcp_port_accepts_connections(host, port): return ServiceProbeResult(healthy=False, reason=f"port {port} is not listening", restart=True) - - from flocks.cli.service_manager import _backend_health_url, _is_healthy_status_response, backend_access_base_url - - url = _backend_health_url(host, port) - try: - with httpx.Client(timeout=2.0, trust_env=False) as client: - response = client.get(url) - root_response = client.get( - backend_access_base_url(ServiceConfig(backend_host=host, backend_port=port)), - headers={"Accept": "text/html"}, - ) - healthy = _is_healthy_status_response(response) and _is_static_webui_response(root_response) - reason = f"health status={response.status_code}, root status={root_response.status_code}" - except Exception as exc: - healthy = False - reason = f"health failed: {exc}" - return ServiceProbeResult(healthy=healthy, reason=reason) - - -def _is_static_webui_response(response: httpx.Response) -> bool: - """Return True only when the unified service serves the SPA index.""" - content_type = response.headers.get("content-type", "").lower() - return response.status_code == 200 and "text/html" in content_type + return ServiceProbeResult(healthy=True, reason="liveness check passed") def tcp_port_accepts_connections(host: str, port: int) -> bool: diff --git a/flocks/contracts/webui/api_runtime.py b/flocks/contracts/webui/api_runtime.py index 70cedbe97..1b4e2efd7 100644 --- a/flocks/contracts/webui/api_runtime.py +++ b/flocks/contracts/webui/api_runtime.py @@ -226,18 +226,22 @@ def _compile_runtime( if spec is None or spec.loader is None: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to load handlers.py") module = importlib.util.module_from_spec(spec) - guarded_import = self._create_guarded_import(api_root=handlers_path.parent) - original_import = builtins.__import__ + page_builtins_module = ModuleType("builtins") + page_builtins_module.__dict__.update(vars(builtins)) + guarded_import = self._create_guarded_import( + api_root=handlers_path.parent, + original_import=builtins.__import__, + builtins_module=page_builtins_module, + ) + page_builtins_module.__dict__["__import__"] = guarded_import + module.__dict__["__builtins__"] = dict(vars(page_builtins_module)) try: - builtins.__import__ = guarded_import # type: ignore[assignment] spec.loader.exec_module(module) except Exception as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed to load handlers.py: {exc}", ) from exc - finally: - builtins.__import__ = original_import # type: ignore[assignment] routes: dict[tuple[str, str], _RouteEntry] = {} for item in route_items: @@ -292,11 +296,12 @@ def _compile_runtime( loaded_at=int(time.time() * 1000), ) - def _create_guarded_import(self, *, api_root: Path): + def _create_guarded_import(self, *, api_root: Path, original_import, builtins_module: ModuleType): api_root_resolved = api_root.resolve() - original_import = builtins.__import__ def _guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if level == 0 and name == "builtins": + return builtins_module module = original_import(name, globals, locals, fromlist, level) modules_to_check = [module] if fromlist: diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index bb08cb066..d82e001f5 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -5,6 +5,7 @@ import json import platform import re +import threading from functools import lru_cache from pathlib import Path from typing import Any, Iterable, Optional @@ -31,6 +32,7 @@ _TOOL_TYPE_DIRS = frozenset({"api", "device", "python", "mcp", "generated"}) _SKIP_PLUGIN_DIRS = frozenset({"__pycache__"}) _PATH_SIGNATURE_MISSING = -1 +_CATALOG_ENTRIES_LOCK = threading.Lock() def _read_json(path: Path) -> dict: @@ -594,6 +596,86 @@ def _bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: } +def _installed_plugins_cache_key() -> tuple[tuple[str, int, int], ...]: + signature: list[tuple[str, int, int]] = [_path_signature(local._record_path())] + + for plugin_type in ("skill", "agent", "workflow", "webui", "component"): + for scope in ("global", "project"): + base = local.install_root(plugin_type, scope) + signature.append(_path_signature(base)) + if not base.is_dir(): + continue + for child in sorted(base.iterdir(), key=lambda item: item.name): + if not child.is_dir(): + continue + signature.append(_path_signature(child)) + signature.extend(_plugin_manifest_signature(plugin_type, child)) + + for scope in ("global", "project"): + base = local.install_root("tool", scope) + signature.append(_path_signature(base)) + if not base.is_dir(): + continue + for directory in _iter_tool_plugin_dirs(base): + signature.append(_path_signature(directory)) + signature.extend(_plugin_manifest_signature("tool", directory)) + + return tuple(signature) + + +def _catalog_entries_cache_key() -> tuple[tuple[str, int, int], ...]: + root = get_bundled_hub_root() + return ( + _path_signature(root / "index.json"), + _path_signature(root / "taxonomy.json"), + *_installed_plugins_cache_key(), + *_system_plugin_roots_cache_key(), + *_bundled_tool_roots_cache_key(), + ) + + +@lru_cache(maxsize=8) +def _cached_catalog_entries( + _signature: tuple[tuple[str, int, int], ...], +) -> tuple[HubCatalogEntry, ...]: + records = local.load_installed_records() + inferred_installs = local.infer_local_installs() + entries = [ + _entry_from_index(item, records, inferred_installs) + for item in load_index().plugins + ] + seen: set[tuple[PluginType, str]] = {(entry.type, entry.id) for entry in entries} + for (system_type, system_id), root in _system_plugin_roots().items(): + if (system_type, system_id) in seen: + continue + manifest = _manifest_for_system_root(system_type, system_id, root) + if manifest: + entries.append(_entry_from_system_manifest(manifest, root)) + seen.add((system_type, system_id)) + + # FlocksHub-bundled tool plugins. The runtime ``ToolRegistry`` only + # discovers tools that have been installed into ``/tools``, + # so the catalog needs an explicit pass to surface bundled-but-not- + # yet-installed plugins like ``onesig_v2_5_3_D20250710``. These show + # up as ``state="available"`` until the user installs them, at which + # point the standard ``records``/``inferred_installs`` flow upgrades + # them to ``state="installed"`` (see :func:`_entry_from_bundled_tool`). + for (bundled_type, bundled_id), root in _bundled_tool_roots().items(): + if (bundled_type, bundled_id) in seen: + continue + manifest = _manifest_for_system_root(bundled_type, bundled_id, root) + if manifest: + entries.append(_entry_from_bundled_tool(manifest, root, records, inferred_installs)) + seen.add((bundled_type, bundled_id)) + return tuple(entries) + + +def _catalog_entries_snapshot() -> tuple[HubCatalogEntry, ...]: + signature = _catalog_entries_cache_key() + with _CATALOG_ENTRIES_LOCK: + return _cached_catalog_entries(signature) + + def clear_catalog_caches() -> None: """Clear Hub filesystem discovery caches after installs or explicit refreshes.""" load_index.cache_clear() @@ -601,6 +683,7 @@ def clear_catalog_caches() -> None: _manifest_path_lookup.cache_clear() _cached_system_plugin_roots.cache_clear() _cached_bundled_tool_roots.cache_clear() + _cached_catalog_entries.cache_clear() def system_plugin_root(plugin_type: PluginType, plugin_id: str) -> Optional[Path]: @@ -868,7 +951,8 @@ def _contains_any(values: Iterable[str], selected: Optional[list[str]]) -> bool: return any(item.lower() in value_set for item in selected) -def list_catalog( +def filter_catalog_entries( + entries: Iterable[HubCatalogEntry], *, plugin_type: Optional[PluginType] = None, category: Optional[list[str]] = None, @@ -879,35 +963,6 @@ def list_catalog( risk: Optional[list[str]] = None, q: Optional[str] = None, ) -> list[HubCatalogEntry]: - records = local.load_installed_records() - inferred_installs = local.infer_local_installs() - entries = [ - _entry_from_index(item, records, inferred_installs) - for item in load_index().plugins - ] - seen: set[tuple[PluginType, str]] = {(entry.type, entry.id) for entry in entries} - for (system_type, system_id), root in _system_plugin_roots().items(): - if (system_type, system_id) in seen: - continue - manifest = _manifest_for_system_root(system_type, system_id, root) - if manifest: - entries.append(_entry_from_system_manifest(manifest, root)) - seen.add((system_type, system_id)) - - # FlocksHub-bundled tool plugins. The runtime ``ToolRegistry`` only - # discovers tools that have been installed into ``/tools``, - # so the catalog needs an explicit pass to surface bundled-but-not- - # yet-installed plugins like ``onesig_v2_5_3_D20250710``. These show - # up as ``state="available"`` until the user installs them, at which - # point the standard ``records``/``inferred_installs`` flow upgrades - # them to ``state="installed"`` (see :func:`_entry_from_bundled_tool`). - for (bundled_type, bundled_id), root in _bundled_tool_roots().items(): - if (bundled_type, bundled_id) in seen: - continue - manifest = _manifest_for_system_root(bundled_type, bundled_id, root) - if manifest: - entries.append(_entry_from_bundled_tool(manifest, root, records, inferred_installs)) - seen.add((bundled_type, bundled_id)) query = (q or "").strip().lower() def keep(entry: HubCatalogEntry) -> bool: @@ -944,6 +999,30 @@ def keep(entry: HubCatalogEntry) -> bool: return [entry for entry in entries if keep(entry)] +def list_catalog( + *, + plugin_type: Optional[PluginType] = None, + category: Optional[list[str]] = None, + tags: Optional[list[str]] = None, + use_cases: Optional[list[str]] = None, + state: Optional[list[str]] = None, + trust: Optional[list[str]] = None, + risk: Optional[list[str]] = None, + q: Optional[str] = None, +) -> list[HubCatalogEntry]: + return filter_catalog_entries( + _catalog_entries_snapshot(), + plugin_type=plugin_type, + category=category, + tags=tags, + use_cases=use_cases, + state=state, + trust=trust, + risk=risk, + q=q, + ) + + def category_counts() -> dict: taxonomy = load_taxonomy().model_dump(mode="json") entries = list_catalog() diff --git a/flocks/mcp/__init__.py b/flocks/mcp/__init__.py index 3472e96f6..87e90c48c 100644 --- a/flocks/mcp/__init__.py +++ b/flocks/mcp/__init__.py @@ -87,6 +87,12 @@ async def status(cls) -> Dict[str, McpStatusInfo]: """ manager = get_manager() return await manager.status() + + @classmethod + def status_snapshot(cls) -> tuple[Dict[str, McpStatusInfo], bool]: + """Return current MCP status without starting uninitialized servers.""" + manager = get_manager() + return manager.status_snapshot() @classmethod async def get_server_info(cls, name: str) -> Optional[McpServerInfo]: diff --git a/flocks/mcp/server.py b/flocks/mcp/server.py index 6ef4eaa63..b7a20cd10 100644 --- a/flocks/mcp/server.py +++ b/flocks/mcp/server.py @@ -331,6 +331,10 @@ async def status(self) -> Dict[str, McpStatusInfo]: return self._status.copy() + def status_snapshot(self) -> tuple[Dict[str, McpStatusInfo], bool]: + """Return current status without triggering MCP initialization.""" + return self._status.copy(), self._initialized + async def get_server_info(self, name: str) -> Optional[McpServerInfo]: """ Get detailed server information diff --git a/flocks/permission/next.py b/flocks/permission/next.py index 5fc7c041f..18bfc51af 100644 --- a/flocks/permission/next.py +++ b/flocks/permission/next.py @@ -238,10 +238,12 @@ async def _apply_reply_without_future( cls._permanent_rules[permission] = "deny" await cls._persist_permanent_rule(permission, "deny") return - if reply == "allow_session": + if reply in {"allow_session", "deny_session"}: if resolved_session_id not in cls._session_permissions: cls._session_permissions[resolved_session_id] = {} - cls._session_permissions[resolved_session_id][permission] = "allow" + cls._session_permissions[resolved_session_id][permission] = ( + "allow" if reply == "allow_session" else "deny" + ) await cls._persist_session_rules(resolved_session_id) @classmethod @@ -376,11 +378,14 @@ async def ask( cls._permanent_rules[permission] = "deny" cls._schedule_persist(cls._persist_permanent_rule(permission, "deny")) raise DeniedError([]) - if reply == "allow_session": + if reply in {"allow_session", "deny_session"}: if session_id not in cls._session_permissions: cls._session_permissions[session_id] = {} - cls._session_permissions[session_id][permission] = "allow" + action = "allow" if reply == "allow_session" else "deny" + cls._session_permissions[session_id][permission] = action cls._schedule_persist(cls._persist_session_rules(session_id)) + if action == "deny": + raise DeniedError([]) return raise PermissionError(f"Unknown permission reply: {reply}") diff --git a/flocks/plugin/loader.py b/flocks/plugin/loader.py index b0144a178..d0bbf59ea 100644 --- a/flocks/plugin/loader.py +++ b/flocks/plugin/loader.py @@ -147,8 +147,8 @@ class ExtensionPoint: subdir: str """Subdirectory under the plugin root, e.g. ``"agents"``.""" - consumer: Callable[[List[Any], str], None] - """Callback ``(items, source_path) -> None`` that receives validated items.""" + consumer: Callable[[List[Any], str], Optional[List[str]]] + """Callback receiving validated items and optionally returning errors.""" item_type: Optional[type] = None """If set, only items that are ``isinstance(item, item_type)`` are kept.""" @@ -255,7 +255,7 @@ def load_extension( project_dir: Optional[Path] = None, *, load_entry_points: bool = False, - ) -> None: + ) -> List[str]: """Load one registered extension point using normal plugin scan rules. This is the scoped counterpart to :meth:`load_all`. It scans the same @@ -269,16 +269,20 @@ def load_extension( ext = cls._extension_points.get(attr_name) if ext is None: log.warn("plugin.ext_point.not_found", {"attr": attr_name}) - return + return [f"extension point not found: {attr_name}"] + + errors: List[str] = [] cls._load_extension_point( ext, extra_sources=extra_sources, project_dir=project_dir or Path.cwd(), log_scope="load_extension", + errors=errors, ) if load_entry_points: - cls._load_entry_points() + cls._load_entry_points(errors=errors) + return errors @classmethod def load_for_extension( @@ -299,9 +303,12 @@ def load_for_extension( collected: List[Any] = [] original_consumer = ext.consumer - def _collecting_consumer(items: List[Any], source: str) -> None: + def _collecting_consumer( + items: List[Any], + source: str, + ) -> Optional[List[str]]: collected.extend(items) - original_consumer(items, source) + return original_consumer(items, source) ext.consumer = _collecting_consumer ext._seen_keys = set() @@ -352,6 +359,7 @@ def _load_extension_point( extra_sources: Optional[List[str]], project_dir: Path, log_scope: str, + errors: Optional[List[str]] = None, ) -> None: """Scan and load one registered extension point.""" if ext.load_once and ext._loaded: @@ -382,7 +390,7 @@ def _load_extension_point( "files": [Path(s).name for s in default_sources], }, ) - cls._load_sources_for_ext(ext, default_sources, subdir_path) + cls._load_sources_for_ext(ext, default_sources, subdir_path, errors=errors) # 2. Project-level plugin subdirectory (/.flocks/plugins/{subdir}/) project_subdir_path = project_plugin_root / ext.subdir @@ -402,17 +410,22 @@ def _load_extension_point( "files": [Path(s).name for s in project_sources], }, ) - cls._load_sources_for_ext(ext, project_sources, project_subdir_path) + cls._load_sources_for_ext( + ext, + project_sources, + project_subdir_path, + errors=errors, + ) # 3. Explicit sources from cfg.plugin if extra_sources: - cls._load_sources_for_ext(ext, extra_sources, project_dir) + cls._load_sources_for_ext(ext, extra_sources, project_dir, errors=errors) if ext.load_once: ext._loaded = True @classmethod - def _load_entry_points(cls) -> None: + def _load_entry_points(cls, errors: Optional[List[str]] = None) -> None: """ Load installed package entry-points under ``flocks.plugins``. @@ -425,6 +438,8 @@ def _load_entry_points(cls) -> None: eps = importlib.metadata.entry_points().select(group=group) except Exception as e: log.debug("plugin.entrypoints.scan_failed", {"group": group, "error": str(e)}) + if errors is not None: + errors.append(f"entry point scan: {type(e).__name__}: {e}") return for ep in eps: @@ -432,10 +447,14 @@ def _load_entry_points(cls) -> None: target = ep.load() except Exception as e: log.warning("plugin.entrypoint.load_failed", {"name": ep.name, "error": str(e)}) + if errors is not None: + errors.append(f"entry point {ep.name}: {e}") continue if not callable(target): log.warning("plugin.entrypoint.not_callable", {"name": ep.name}) + if errors is not None: + errors.append(f"entry point {ep.name}: target is not callable") continue try: @@ -447,6 +466,8 @@ def _load_entry_points(cls) -> None: log.info("plugin.entrypoint.loaded", {"name": ep.name, "group": group}) except Exception as e: log.warning("plugin.entrypoint.invoke_failed", {"name": ep.name, "error": str(e)}) + if errors is not None: + errors.append(f"entry point {ep.name}: {e}") @classmethod def _load_sources_for_ext( @@ -454,12 +475,13 @@ def _load_sources_for_ext( ext: ExtensionPoint, sources: List[str], base_dir: Path, + errors: Optional[List[str]] = None, ) -> None: """Load each source module and dispatch matching items to *ext.consumer*.""" for source in sources: source_path = Path(source) if source_path.suffix in (".yaml", ".yml"): - cls._load_yaml_source(ext, source_path) + cls._load_yaml_source(ext, source_path, errors=errors) continue try: @@ -473,6 +495,8 @@ def _load_sources_for_ext( "type": type(e).__name__, }, ) + if errors is not None: + errors.append(f"{source}: {type(e).__name__}: {e}") continue raw = getattr(module, ext.attr_name, None) @@ -487,11 +511,29 @@ def _load_sources_for_ext( "attr": ext.attr_name, }, ) + if errors is not None: + errors.append(f"{source}: {ext.attr_name} must be a list") continue - items = cls._validate_and_dedup(ext, list(raw), source) + items = cls._validate_and_dedup(ext, list(raw), source, errors=errors) if items: - ext.consumer(items, source) + try: + consumer_errors = ext.consumer(items, source) + except Exception as e: + log.error( + "plugin.consumer_failed", + { + "source": source, + "attr": ext.attr_name, + "error": str(e), + }, + ) + if errors is None: + raise + errors.append(f"{source}: consumer: {type(e).__name__}: {e}") + continue + if errors is not None and consumer_errors: + errors.extend(f"{source}: {error}" for error in consumer_errors) log.info( "plugin.dispatched", { @@ -502,7 +544,12 @@ def _load_sources_for_ext( ) @classmethod - def _load_yaml_source(cls, ext: ExtensionPoint, yaml_path: Path) -> None: + def _load_yaml_source( + cls, + ext: ExtensionPoint, + yaml_path: Path, + errors: Optional[List[str]] = None, + ) -> None: """Load a YAML config file and dispatch via the extension point's factory.""" if ext.yaml_item_factory is None: log.warn( @@ -513,27 +560,60 @@ def _load_yaml_source(cls, ext: ExtensionPoint, yaml_path: Path) -> None: "hint": f"Extension point '{ext.attr_name}' has no yaml_item_factory; skipping YAML file", }, ) + if errors is not None: + errors.append(f"{yaml_path}: YAML is not supported for {ext.attr_name}") return try: raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) except Exception as e: log.error("plugin.yaml_parse_failed", {"path": str(yaml_path), "error": str(e)}) + if errors is not None: + errors.append(f"{yaml_path}: {e}") return if not isinstance(raw, dict): log.warn("plugin.yaml_invalid", {"path": str(yaml_path), "hint": "Expected a YAML mapping"}) + if errors is not None: + errors.append(f"{yaml_path}: expected a YAML mapping") return try: item = ext.yaml_item_factory(raw, yaml_path) except Exception as e: log.error("plugin.yaml_factory_failed", {"path": str(yaml_path), "error": str(e)}) + if errors is not None: + errors.append(f"{yaml_path}: {e}") return - items = cls._validate_and_dedup(ext, [item], str(yaml_path)) + items = cls._validate_and_dedup( + ext, + [item], + str(yaml_path), + errors=errors, + ) if items: - ext.consumer(items, str(yaml_path)) + try: + consumer_errors = ext.consumer(items, str(yaml_path)) + except Exception as e: + log.error( + "plugin.consumer_failed", + { + "source": str(yaml_path), + "attr": ext.attr_name, + "error": str(e), + }, + ) + if errors is None: + raise + errors.append( + f"{yaml_path}: consumer: {type(e).__name__}: {e}" + ) + return + if errors is not None and consumer_errors: + errors.extend( + f"{yaml_path}: {error}" for error in consumer_errors + ) log.debug( "plugin.yaml_dispatched", { @@ -549,20 +629,27 @@ def _validate_and_dedup( ext: ExtensionPoint, raw_items: List[Any], source: str, + errors: Optional[List[str]] = None, ) -> List[Any]: """Type-check and deduplicate items for an extension point.""" if ext.item_type is not None: valid = [it for it in raw_items if isinstance(it, ext.item_type)] if len(valid) < len(raw_items): + invalid_count = len(raw_items) - len(valid) log.warn( "plugin.invalid_entries", { "source": source, "attr": ext.attr_name, "expected_type": ext.item_type.__name__, - "invalid_count": len(raw_items) - len(valid), + "invalid_count": invalid_count, }, ) + if errors is not None: + errors.append( + f"{source}: {invalid_count} invalid {ext.attr_name} " + f"entries; expected {ext.item_type.__name__}" + ) else: valid = raw_items diff --git a/flocks/server/app.py b/flocks/server/app.py index f557c79e8..78271d130 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -11,13 +11,15 @@ from dataclasses import dataclass from types import SimpleNamespace from pathlib import Path -from typing import Any, Callable, Optional +from typing import Any, AsyncGenerator, Callable, Optional from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError +from starlette.datastructures import MutableHeaders from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.types import ASGIApp, Message, Receive, Scope, Send from flocks.utils.log import Log, LogLevel from flocks.config.config import Config @@ -814,132 +816,277 @@ async def __call__(self, scope, receive, send): await self._inner(scope, receive, send) -@app.middleware("http") -async def security_headers_middleware(request: Request, call_next): - """Attach baseline browser security headers to every HTTP response.""" - response = await call_next(request) - for name, value in _SECURITY_HEADERS.items(): - response.headers.setdefault(name, value) - return response +class _SecurityHeadersMiddleware: + """Attach baseline browser security headers without spawning request tasks.""" + def __init__(self, app: ASGIApp) -> None: + self.app = app -@app.middleware("http") -async def instance_context_middleware(request: Request, call_next): - """ - Provide Instance context for all requests (except global routes) - - Middleware that wraps all routes with Instance.provide(). - Gets directory from: - 1. Query parameter 'directory' - 2. Header 'x-flocks-directory' - 3. Falls back to current working directory - """ - import os - from urllib.parse import unquote - from flocks.project.instance import Instance - from flocks.project.bootstrap import instance_bootstrap - - # Skip instance context for global routes, static files, and simple endpoints - skip_prefixes = { - "/global", "/docs", "/redoc", "/openapi.json", "/health", - "/path", "/permission", "/question", "/tui", - } - - if any(request.url.path.startswith(prefix) for prefix in skip_prefixes): - return await call_next(request) - - # Get directory from query param, header, or use cwd - # Support both x-flocks-directory (native) and x-flocks-directory (TUI compatibility) - directory = request.query_params.get("directory") - if not directory: - directory = request.headers.get("x-flocks-directory") - if not directory: - directory = request.headers.get("x-flocks-directory") - if not directory: - directory = os.getcwd() - - # Decode URL-encoded directory - try: - directory = unquote(directory) - except Exception: - pass # Use original value if decode fails - - # Provide instance context for the request - async def handle_request(): - return await call_next(request) - - return await Instance.provide( - directory=directory, - init=instance_bootstrap, - fn=handle_request + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + async def send_with_security_headers(message: Message) -> None: + if message["type"] == "http.response.start": + headers = MutableHeaders(scope=message) + for name, value in _SECURITY_HEADERS.items(): + headers.setdefault(name, value) + await send(message) + + await self.app(scope, receive, send_with_security_headers) + + +app.add_middleware(_SecurityHeadersMiddleware) + + +class _InstanceContextMiddleware: + """Provide the project instance context without buffering the response body.""" + + _SKIP_PREFIXES = ( + "/global", + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/path", + "/permission", + "/question", + "/tui", ) + def __init__(self, app: ASGIApp) -> None: + self.app = app -# Request Logging Middleware -@app.middleware("http") -async def log_requests(request: Request, call_next): - """Log one completion line for useful requests; suppress successful polling noise.""" - path = request.url.path - started_at = time.monotonic() + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return - try: - response = await call_next(request) - except Exception as exc: - duration_ms = int((time.monotonic() - started_at) * 1000) - log.error("request.error", { - "method": request.method, - "path": path, - "duration": duration_ms, - "error": str(exc), - }) - raise + path = scope.get("path", "") + if path.startswith(self._SKIP_PREFIXES): + await self.app(scope, receive, send) + return - if _should_log_request(path, response.status_code): - duration_ms = int((time.monotonic() - started_at) * 1000) - log.info("request.complete", { - "method": request.method, - "path": path, - "status": response.status_code, - "duration": duration_ms, - }) + from urllib.parse import unquote + from flocks.project.instance import Instance + from flocks.project.bootstrap import instance_bootstrap - return response + request = Request(scope, receive=receive) + directory = request.query_params.get("directory") + if not directory: + directory = request.headers.get("x-flocks-directory") + if not directory: + directory = os.getcwd() + try: + directory = unquote(directory) + except Exception: + pass -@app.middleware("http") -async def auth_guard_middleware(request: Request, call_next): - """Guard requests with local account auth, except public endpoints.""" - try: - await _run_http_middleware_hooks(request, {"stage": "before_auth"}) - _blocked, token, _user = await apply_auth_for_request(request) - except StarletteHTTPException as exc: - return JSONResponse( - status_code=exc.status_code, - content={"error": "AuthError", "message": exc.detail}, - ) - except Exception as exc: - log.error("auth.middleware.unexpected", { - "path": request.url.path, - "error": repr(exc), - }) - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"error": "InternalError", "message": "鉴权处理异常,请稍后重试"}, + async def handle_request() -> None: + await self.app(scope, receive, send) + + await Instance.provide( + directory=directory, + init=instance_bootstrap, + fn=handle_request, ) - try: - return await call_next(request) - finally: - clear_auth_context(token) + +app.add_middleware(_InstanceContextMiddleware) + + +class _RequestLoggingMiddleware: + """Log response start without retaining an extra task for streaming bodies.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + method = scope.get("method", "") + started_at = time.monotonic() + response_started = False + + async def send_with_request_log(message: Message) -> None: + nonlocal response_started + if message["type"] == "http.response.start" and not response_started: + response_started = True + status_code = int(message["status"]) + if _should_log_request(path, status_code): + duration_ms = int((time.monotonic() - started_at) * 1000) + log.info( + "request.complete", + { + "method": method, + "path": path, + "status": status_code, + "duration": duration_ms, + }, + ) + await send(message) + + try: + await self.app(scope, receive, send_with_request_log) + except Exception as exc: + if not response_started: + duration_ms = int((time.monotonic() - started_at) * 1000) + log.error( + "request.error", + { + "method": method, + "path": path, + "duration": duration_ms, + "error": str(exc), + }, + ) + raise + + +app.add_middleware(_RequestLoggingMiddleware) + + +class _HookRequest(Request): + """Preserve request-receive semantics while auth hooks inspect the body. + + A body read is replayed once to the endpoint, a fully consumed stream is + replayed as an empty request, and a partially consumed stream continues + from upstream. Once a disconnect is observed, downstream reads keep seeing + the disconnect instead of polling upstream again. + """ + + def __init__(self, scope: Scope, receive: Receive) -> None: + self._flocks_upstream_receive = receive + self._flocks_request_complete = False + self._flocks_disconnected = False + self._flocks_cached_body: Optional[bytes] = None + self._flocks_stream_consumed = False + super().__init__(scope, receive=self._receive_for_hook) + + async def _receive_for_hook(self) -> Message: + message = await self._flocks_upstream_receive() + if message["type"] == "http.request": + if not message.get("more_body", False): + self._flocks_request_complete = True + elif message["type"] == "http.disconnect": + self._flocks_disconnected = True + return message + + async def body(self) -> bytes: + body = await super().body() + self._flocks_cached_body = body + return body + + async def stream(self) -> AsyncGenerator[bytes, None]: + async for chunk in super().stream(): + if self._flocks_request_complete: + self._flocks_stream_consumed = True + yield chunk + self._flocks_stream_consumed = True + + def downstream_receive(self) -> Receive: + """Return the receive callable that preserves the hook's body reads.""" + has_replay = self._flocks_cached_body is not None or self._flocks_stream_consumed + if not has_replay and not self._flocks_disconnected: + return self._flocks_upstream_receive + + replay_pending = has_replay + replay_body = self._flocks_cached_body if self._flocks_cached_body is not None else b"" + + async def receive_for_downstream() -> Message: + nonlocal replay_pending + if replay_pending: + replay_pending = False + return { + "type": "http.request", + "body": replay_body, + "more_body": False, + } + if self._flocks_disconnected: + return {"type": "http.disconnect"} + + message = await self._flocks_upstream_receive() + if message["type"] == "http.disconnect": + self._flocks_disconnected = True + return message + if has_replay: + raise RuntimeError(f"Unexpected message received: {message['type']}") + return message + + return receive_for_downstream + + +class _AuthGuardMiddleware: + """Bind request auth context in the same ASGI task as the endpoint.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = _HookRequest(scope, receive) + try: + await _run_http_middleware_hooks(request, {"stage": "before_auth"}) + _blocked, token, _user = await apply_auth_for_request(request) + except StarletteHTTPException as exc: + response = JSONResponse( + status_code=exc.status_code, + content={"error": "AuthError", "message": exc.detail}, + ) + await response(scope, receive, send) + return + except Exception as exc: + log.error( + "auth.middleware.unexpected", + { + "path": request.url.path, + "error": repr(exc), + }, + ) + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"error": "InternalError", "message": "鉴权处理异常,请稍后重试"}, + ) + await response(scope, receive, send) + return + + try: + await self.app(scope, request.downstream_receive(), send) + finally: + clear_auth_context(token) + + +app.add_middleware(_AuthGuardMiddleware) + + +class _StaticWebUIMiddleware: + """Serve the SPA shell before auth without a BaseHTTPMiddleware task.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + static_response = await maybe_serve_static_webui(request) + if static_response is not None: + await static_response(scope, receive, send) + return + await self.app(scope, receive, send) -@app.middleware("http") -async def static_webui_middleware(request: Request, call_next): - """Serve the SPA shell before auth for browser navigations.""" - static_response = await maybe_serve_static_webui(request) - if static_response is not None: - return static_response - return await call_next(request) +app.add_middleware(_StaticWebUIMiddleware) # Error Handlers @@ -1059,6 +1206,8 @@ async def general_exception_handler(request: Request, exc: Exception): from flocks.server.routes.update import router as update_router # Log viewing from flocks.server.routes.logs import router as logs_router +from flocks.server.routes.monitoring import router as monitoring_router +from flocks.server.routes.stats import router as stats_router from flocks.server.routes.auth import router as auth_router from flocks.server.routes.admin_users import router as admin_users_router from flocks.server.routes.notifications import router as notifications_router @@ -1120,6 +1269,8 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(update_router, prefix="/api/update", tags=["Update"]) # Log viewing routes app.include_router(logs_router, prefix="/api/logs", tags=["Logs"]) +app.include_router(stats_router, prefix="/api/stats", tags=["Stats"]) +app.include_router(monitoring_router, prefix="/api/monitoring", tags=["Monitoring"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(admin_users_router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) @@ -1182,6 +1333,7 @@ async def general_exception_handler(request: Request, exc: Exception): # Permission routes (/permission) app.include_router(permission_router, prefix="/permission", tags=["Permission"]) +app.include_router(permission_router, prefix="/api/permission", tags=["Permission"]) # Question routes (/question) app.include_router(question_router, prefix="/question", tags=["Question"]) diff --git a/flocks/server/auth.py b/flocks/server/auth.py index 593508eeb..046fd32f2 100644 --- a/flocks/server/auth.py +++ b/flocks/server/auth.py @@ -18,6 +18,7 @@ SESSION_COOKIE_NAME = "flocks_session" API_TOKEN_SECRET_ID = "server_api_token" +API_TOKEN_SERVICE_USER_ID = "api-token-service" # Paths that never require auth. Everything else is protected by default. PUBLIC_PATHS = frozenset({ @@ -228,8 +229,8 @@ def _is_valid_api_token(token: Optional[str]) -> bool: def _build_api_token_user() -> AuthUser: """Synthetic service identity for API token clients.""" return AuthUser( - id="api-token-service", - username="api-token-service", + id=API_TOKEN_SERVICE_USER_ID, + username=API_TOKEN_SERVICE_USER_ID, role="admin", status="active", must_reset_password=False, diff --git a/flocks/server/routes/channel.py b/flocks/server/routes/channel.py index 518e3fcbd..02349aaff 100644 --- a/flocks/server/routes/channel.py +++ b/flocks/server/routes/channel.py @@ -380,6 +380,85 @@ async def weixin_qr_login_status(qrcode: str, baseUrl: Optional[str] = None): raise HTTPException(status_code=502, detail=str(exc)) +# --------------------------------------------------------------------------- +# WhatsApp QR pairing +# --------------------------------------------------------------------------- + +class WhatsAppPairStartRequest(BaseModel): + sessionPath: Optional[str] = None + bridgeDir: Optional[str] = None + resetSession: bool = False + + +@router.post("/whatsapp/pair/start") +async def whatsapp_pair_start(req: WhatsAppPairStartRequest): + """Start a WhatsApp Web QR pairing process. + + The helper starts the bundled Node bridge in pair-only mode and returns a + pairing_id. The frontend polls /whatsapp/pair/{pairing_id}/status until a + QR code is available or pairing completes. + """ + from flocks.channel.builtin.whatsapp.pairing import start_pairing + + try: + pairing = await start_pairing( + session_path=req.sessionPath, + bridge_dir=req.bridgeDir, + reset_session=req.resetSession, + ) + return { + "ok": True, + "pairing_id": pairing.id, + "status": pairing.status, + "session_path": str(pairing.session_path), + } + except Exception as exc: + log.error("whatsapp.pair.start_failed", {"error": str(exc)}) + raise HTTPException(status_code=502, detail=str(exc)) + + +@router.get("/whatsapp/session-status") +async def whatsapp_session_status(sessionPath: Optional[str] = None): + """Return whether the configured WhatsApp session contains credentials.""" + from pathlib import Path + + from flocks.channel.builtin.whatsapp.config import default_session_path + + path = Path(sessionPath).expanduser() if sessionPath else default_session_path() + return { + "session_path": str(path), + "paired": (path / "creds.json").exists(), + } + + +@router.get("/whatsapp/pair/{pairing_id}/status") +async def whatsapp_pair_status(pairing_id: str): + from flocks.channel.builtin.whatsapp.pairing import get_pairing + + pairing = get_pairing(pairing_id) + if pairing is None: + raise HTTPException(status_code=404, detail="WhatsApp pairing session not found") + return { + "ok": True, + "pairing_id": pairing.id, + "status": pairing.status, + "qr": pairing.qr, + "error": pairing.error, + "user": pairing.user, + "session_path": str(pairing.session_path), + } + + +@router.post("/whatsapp/pair/{pairing_id}/cancel") +async def whatsapp_pair_cancel(pairing_id: str): + from flocks.channel.builtin.whatsapp.pairing import cancel_pairing + + cancelled = await cancel_pairing(pairing_id) + if not cancelled: + raise HTTPException(status_code=404, detail="WhatsApp pairing session not found") + return {"ok": True, "pairing_id": pairing_id} + + # --------------------------------------------------------------------------- # Telegram pairing # --------------------------------------------------------------------------- diff --git a/flocks/server/routes/event.py b/flocks/server/routes/event.py index b5b5b5350..9f427cb67 100644 --- a/flocks/server/routes/event.py +++ b/flocks/server/routes/event.py @@ -14,15 +14,21 @@ import asyncio import json import os -from typing import AsyncGenerator, Optional +from collections import deque +from typing import AsyncGenerator, Optional, TYPE_CHECKING, cast from datetime import datetime from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse +from flocks.server.auth import require_user from flocks.utils.log import Log from flocks.utils.id import Identifier +if TYPE_CHECKING: + from flocks.auth.context import AuthUser + from flocks.session.session import SessionInfo + router = APIRouter() log = Log.create(service="event-routes") @@ -33,12 +39,184 @@ "context.", "permission.", ) +EVENT_QUEUE_MAXSIZE = max(100, int(os.getenv("FLOCKS_EVENT_QUEUE_MAXSIZE", "1000"))) +EVENT_QUEUE_DROP_TO = max(0, min( + EVENT_QUEUE_MAXSIZE - 1, + int(os.getenv("FLOCKS_EVENT_QUEUE_DROP_TO", str(EVENT_QUEUE_MAXSIZE // 2))), +)) +EVENT_QUEUE_COALESCE_AT = max(1, min( + EVENT_QUEUE_MAXSIZE, + int(os.getenv("FLOCKS_EVENT_QUEUE_COALESCE_AT", str(min(EVENT_QUEUE_MAXSIZE, 64)))), +)) # Current directory context for SSE events _current_directory: str = os.getcwd() +def _event_session_id(event: dict) -> Optional[str]: + """Extract the session id from the event shapes emitted by the runtime.""" + properties = event.get("properties") + if not isinstance(properties, dict): + return None + + for key in ("sessionID", "session_id"): + value = properties.get(key) + if isinstance(value, str) and value: + return value + + for container_name in ("part", "info"): + container = properties.get(container_name) + if not isinstance(container, dict): + continue + for key in ("sessionID", "session_id"): + value = container.get(key) + if isinstance(value, str) and value: + return value + + event_type = event.get("type") + runtime_type = properties.get("runtimeType") + semantic_type = runtime_type if event_type == "runtime.event" else event_type + if isinstance(semantic_type, str) and semantic_type.startswith("session."): + value = properties.get("id") + if isinstance(value, str) and value: + return value + info = properties.get("info") + if isinstance(info, dict): + value = info.get("id") + if isinstance(value, str) and value: + return value + return None + + +async def _get_event_session(session_id: str) -> Optional["SessionInfo"]: + """Load a session without inheriting the publisher's auth context.""" + from flocks.auth.context import reset_current_auth_user, set_current_auth_user + from flocks.session.session import Session, SessionInfo + from flocks.storage.storage import Storage + + token = set_current_auth_user(None) + try: + session = await Session.get_by_id(session_id) + if session is not None: + return session + + cached_sessions = getattr(Session, "_all_sessions_cache", None) or [] + cached = next((item for item in cached_sessions if item.id == session_id), None) + if cached is not None: + return cached + + for key in await Storage.list_keys(prefix="session:"): + if key.endswith(f":{session_id}"): + return await Storage.get(key, SessionInfo) + return None + finally: + reset_current_auth_user(token) + + +def _snapshot_key(event: dict) -> Optional[tuple[str, str, str]]: + """Return the identity of a coalescible accumulated-text snapshot.""" + if event.get("type") != "message.part.updated": + return None + properties = event.get("properties") + part = properties.get("part") if isinstance(properties, dict) else None + if not isinstance(part, dict) or part.get("type") not in {"text", "reasoning", "thinking"}: + return None + session_id = part.get("sessionID") + message_id = part.get("messageID") + part_id = part.get("id") + if not isinstance(session_id, str) or not session_id: + return None + if not isinstance(message_id, str) or not message_id: + return None + if not isinstance(part_id, str) or not part_id: + return None + return session_id, message_id, part_id + + +def _merge_snapshots(previous: dict, current: dict) -> dict: + """Keep the latest full part while preserving all unconsumed deltas.""" + previous_properties = previous.get("properties") + current_properties = current.get("properties") + if not isinstance(previous_properties, dict) or not isinstance(current_properties, dict): + return current + + previous_delta = previous_properties.get("delta") + current_delta = current_properties.get("delta") + if not isinstance(previous_delta, str): + return current + + merged_properties = dict(current_properties) + merged_properties["delta"] = previous_delta + (current_delta if isinstance(current_delta, str) else "") + return {**current, "properties": merged_properties} + + +class EventQueue(asyncio.Queue[dict]): + """Queue with slow-client compaction for accumulated part snapshots.""" + + def __init__(self, maxsize: int = 0) -> None: + super().__init__(maxsize=maxsize) + self._pending_drop_count = 0 + + def _pending_events(self) -> deque[dict]: + return cast(deque[dict], self.__dict__["_queue"]) + + def coalesce_tail_snapshot(self, event: dict) -> bool: + """Merge only with the immediately preceding snapshot. + + Control events form an ordering barrier: snapshots on opposite sides of + one must remain separate even while a client is backpressured. + """ + key = _snapshot_key(event) + pending = self._pending_events() + if key is None or not pending: + return False + queued = pending[-1] + if _snapshot_key(queued) != key: + return False + pending[-1] = _merge_snapshots(queued, event) + return True + + def drop_snapshots_until(self, target_size: int) -> int: + pending = self._pending_events() + dropped = 0 + while len(pending) > target_size: + snapshot_index = next( + (index for index, queued in enumerate(pending) if _snapshot_key(queued) is not None), + None, + ) + if snapshot_index is None: + break + del pending[snapshot_index] + dropped += 1 + return dropped + + def report_dropped(self, count: int) -> None: + """Emit a recovery marker now or defer it until capacity is freed.""" + if count <= 0: + return + self._pending_drop_count += count + self._flush_drop_marker() + + def _flush_drop_marker(self) -> None: + if self._pending_drop_count <= 0 or self.full(): + return + dropped = self._pending_drop_count + self._pending_drop_count = 0 + super().put_nowait(create_event("server.events_dropped", { + "dropped": dropped, + "reason": "client_backpressure", + })) + + async def get(self) -> dict: + event = await super().get() + # A queue containing only control events has no safe item to evict for + # a marker. Once the client consumes one, append the deferred marker + # without sacrificing those control events. + self._flush_drop_marker() + return event + + def set_event_directory(directory: str): """Set the current directory for SSE events""" global _current_directory @@ -56,9 +234,29 @@ class EventBroadcaster: _instance: Optional["EventBroadcaster"] = None - def __init__(self): - self._clients: list[asyncio.Queue] = [] + def __init__( + self, + queue_maxsize: int = EVENT_QUEUE_MAXSIZE, + queue_drop_to: Optional[int] = None, + queue_coalesce_at: Optional[int] = None, + ) -> None: + self._clients: dict[EventQueue, Optional["AuthUser"]] = {} self._lock = asyncio.Lock() + self._queue_maxsize = max(1, queue_maxsize) + self._queue_drop_to = max( + 0, + min( + self._queue_maxsize - 1, + EVENT_QUEUE_DROP_TO if queue_drop_to is None else queue_drop_to, + ), + ) + self._queue_coalesce_at = max( + 1, + min( + self._queue_maxsize, + EVENT_QUEUE_COALESCE_AT if queue_coalesce_at is None else queue_coalesce_at, + ), + ) @classmethod def get(cls) -> "EventBroadcaster": @@ -67,42 +265,127 @@ def get(cls) -> "EventBroadcaster": cls._instance = EventBroadcaster() return cls._instance - async def subscribe(self) -> asyncio.Queue: + async def subscribe(self, user: Optional["AuthUser"] = None) -> EventQueue: """Subscribe a new client""" - queue: asyncio.Queue = asyncio.Queue() + queue = EventQueue(maxsize=self._queue_maxsize) async with self._lock: - self._clients.append(queue) + self._clients[queue] = user return queue - async def unsubscribe(self, queue: asyncio.Queue): + async def unsubscribe(self, queue: EventQueue) -> None: """Unsubscribe a client""" async with self._lock: - if queue in self._clients: - self._clients.remove(queue) + self._clients.pop(queue, None) - async def publish(self, event: dict): + async def publish(self, event: dict) -> None: """Publish event to all clients""" async with self._lock: - for queue in self._clients: + clients = list(self._clients.items()) + + session = None + session_id = _event_session_id(event) + if session_id: + try: + session = await _get_event_session(session_id) + except Exception as exc: + log.warning("event.session_access_lookup_failed", { + "session_id": session_id, + "event_type": event.get("type"), + "error": str(exc), + }) + return + if session is None: + log.debug("event.session_access_missing", { + "session_id": session_id, + "event_type": event.get("type"), + }) + return + + if session is not None: + from flocks.session.policy import SessionPolicy + + for queue, user in clients: + if session is not None and user is not None and not SessionPolicy.can_read(session, user): + continue + self._publish_to_queue(queue, event) + + def _publish_to_queue(self, queue: EventQueue, event: dict) -> None: + # Preserve the event cadence for healthy clients. Once a subscriber is + # materially behind, compact only adjacent snapshots for the same part; + # this bounds accumulated full-text payloads without crossing control + # events or changing their relative order. + if ( + queue.qsize() >= self._queue_coalesce_at + and queue.coalesce_tail_snapshot(event) + ): + return + + try: + queue.put_nowait(event) + return + except asyncio.QueueFull: + pass + + is_snapshot = _snapshot_key(event) is not None + marker_target = min(self._queue_drop_to, max(0, self._queue_maxsize - 2)) + dropped = queue.drop_snapshots_until(marker_target) + + if is_snapshot and queue.qsize() > marker_target: + # A text snapshot must never evict permission/question/session + # control events. If removing older snapshots only leaves room for + # the recovery marker, keep that marker and discard this ordinary + # snapshot so the client knows it must reconcile over REST. + # Include the incoming snapshot itself. If the queue consists only + # of control events, defer the marker until the client frees one + # slot rather than evicting a permission/question/session event. + dropped += 1 + queue.report_dropped(dropped) + log.debug("event.queue.snapshot_dropped", { + "queue_size": queue.qsize(), + "queue_maxsize": self._queue_maxsize, + "event_type": event.get("type"), + }) + return + else: + while queue.qsize() > marker_target: try: - await queue.put(event) - except Exception: - pass # Ignore errors for disconnected clients + queue.get_nowait() + dropped += 1 + except asyncio.QueueEmpty: + break + + reported_drops = 0 + if dropped > 0 and queue.qsize() <= self._queue_maxsize - 2: + queue.report_dropped(dropped) + reported_drops = dropped + + try: + queue.put_nowait(event) + except asyncio.QueueFull: + dropped += 1 + + if dropped > reported_drops: + queue.report_dropped(dropped - reported_drops) + + if dropped > 0: + log.debug("event.queue.overflow", { + "dropped": dropped, + "queue_size": queue.qsize(), + "queue_maxsize": self._queue_maxsize, + "event_type": event.get("type"), + }) @property def client_count(self) -> int: """Number of connected clients""" return len(self._clients) - async def shutdown(self): + async def shutdown(self) -> None: """Notify all clients that the server is shutting down, then clear.""" shutdown_event = create_event("server.shutting_down", {}) async with self._lock: for queue in self._clients: - try: - await queue.put(shutdown_event) - except Exception: - pass + self._publish_to_queue(queue, shutdown_event) self._clients.clear() log.info("event.broadcaster.shutdown", {"clients_notified": True}) @@ -186,7 +469,7 @@ async def publish_runtime_event(runtime_type: str, properties: dict = None, dire async def sse_generator( - queue: asyncio.Queue, + queue: EventQueue, request: Request, directory: str = None, ) -> AsyncGenerator[str, None]: @@ -238,7 +521,8 @@ async def subscribe_events(request: Request): Returns: StreamingResponse with SSE events """ - queue = await EventBroadcaster.get().subscribe() + user = require_user(request) + queue = await EventBroadcaster.get().subscribe(user) log.info("event.subscribe", { "clients": EventBroadcaster.get().client_count, diff --git a/flocks/server/routes/global_.py b/flocks/server/routes/global_.py index 75dfc45a1..afe3fd518 100644 --- a/flocks/server/routes/global_.py +++ b/flocks/server/routes/global_.py @@ -18,6 +18,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel +from flocks.server.auth import require_user from flocks.utils.log import Log from flocks.server.routes.event import EventBroadcaster, sse_generator @@ -54,7 +55,8 @@ async def get_global_events(request: Request): This is the main event endpoint that Flocks TUI uses. """ - queue = await EventBroadcaster.get().subscribe() + user = require_user(request) + queue = await EventBroadcaster.get().subscribe(user) log.info("global.event.subscribe", { "clients": EventBroadcaster.get().client_count, diff --git a/flocks/server/routes/hub.py b/flocks/server/routes/hub.py index ba37eef83..92da6378d 100644 --- a/flocks/server/routes/hub.py +++ b/flocks/server/routes/hub.py @@ -3,13 +3,21 @@ from __future__ import annotations import asyncio -from typing import Optional +from typing import Optional, Union -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from flocks.hub.catalog import category_counts, clear_catalog_caches, legacy_removed_plugin_message, list_catalog, load_manifest +from flocks.hub.catalog import ( + category_counts, + clear_catalog_caches, + filter_catalog_entries, + legacy_removed_plugin_message, + list_catalog, + load_manifest, + load_taxonomy, +) from flocks.hub.files import file_tree, read_file_content from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin from flocks.hub.models import ( @@ -21,6 +29,7 @@ InstalledPluginRecord, PluginType, ) +from flocks.server.auth import require_admin from flocks.utils.log import Log @@ -32,6 +41,24 @@ class HubInstallRequest(BaseModel): scope: str = Field(default="global", description="'global' only") +class HubCatalogFacets(BaseModel): + type: dict[str, int] = Field(default_factory=dict) + category: dict[str, int] = Field(default_factory=dict) + tags: dict[str, int] = Field(default_factory=dict) + useCases: dict[str, int] = Field(default_factory=dict) + state: dict[str, int] = Field(default_factory=dict) + trust: dict[str, int] = Field(default_factory=dict) + riskLevel: dict[str, int] = Field(default_factory=dict) + + +class HubCatalogPageResponse(BaseModel): + items: list[HubCatalogEntry] = Field(default_factory=list) + total: int = Field(0) + offset: int = Field(0) + limit: int = Field(25) + facets: HubCatalogFacets = Field(default_factory=HubCatalogFacets) + + def _split_csv(value: Optional[str | list[str]]) -> Optional[list[str]]: if value is None: return None @@ -59,7 +86,46 @@ def _clear_hub_runtime_caches() -> None: pass -@router.get("/hub/catalog", response_model=list[HubCatalogEntry]) +def _count_hub_catalog_facet( + items: list[HubCatalogEntry], + attribute: str, +) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + values = getattr(item, attribute) + if isinstance(values, str): + values = [values] + for value in values: + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _build_hub_catalog_facets_for_filters( + all_entries: list[HubCatalogEntry], + filters: dict[str, object], +) -> HubCatalogFacets: + """Build each facet with every active filter except that facet itself.""" + dimensions = { + "type": ("plugin_type", "type"), + "category": ("category", "category"), + "tags": ("tags", "tags"), + "useCases": ("use_cases", "useCases"), + "state": ("state", "state"), + "trust": ("trust", "trust"), + "riskLevel": ("risk", "riskLevel"), + } + counts: dict[str, dict[str, int]] = {} + for response_field, (query_field, item_attribute) in dimensions.items(): + facet_filters = dict(filters) + facet_filters[query_field] = None + counts[response_field] = _count_hub_catalog_facet( + filter_catalog_entries(all_entries, **facet_filters), + item_attribute, + ) + return HubCatalogFacets(**counts) + + +@router.get("/hub/catalog", response_model=Union[list[HubCatalogEntry], HubCatalogPageResponse]) async def hub_catalog( type: Optional[PluginType] = Query(default=None), # noqa: A002 - API field name category: Optional[str] = None, @@ -69,22 +135,45 @@ async def hub_catalog( trust: Optional[str] = None, risk: Optional[str] = None, q: Optional[str] = None, + offset: int = Query(0, ge=0), + limit: Optional[int] = Query(default=None, ge=1, le=200), ): - return await asyncio.to_thread( - list_catalog, - plugin_type=type, - category=_split_csv(category), - tags=_split_csv(tags), - use_cases=_split_csv(useCases), - state=_split_csv(state), - trust=_split_csv(trust), - risk=_split_csv(risk), - q=q, + filters: dict[str, object] = { + "plugin_type": type, + "category": _split_csv(category), + "tags": _split_csv(tags), + "use_cases": _split_csv(useCases), + "state": _split_csv(state), + "trust": _split_csv(trust), + "risk": _split_csv(risk), + "q": q, + } + if limit is None and offset == 0: + return await asyncio.to_thread(list_catalog, **filters) + + def load_page() -> tuple[list[HubCatalogEntry], HubCatalogFacets]: + all_entries = list_catalog() + entries = filter_catalog_entries(all_entries, **filters) + facets = _build_hub_catalog_facets_for_filters(all_entries, filters) + return entries, facets + + entries, facets = await asyncio.to_thread(load_page) + + page_limit = limit or 25 + total = len(entries) + return HubCatalogPageResponse( + items=entries[offset:offset + page_limit], + total=total, + offset=offset, + limit=page_limit, + facets=facets, ) @router.get("/hub/categories") -async def hub_categories(): +async def hub_categories(include_counts: bool = Query(True)): + if not include_counts: + return load_taxonomy().model_dump(mode="json") return await asyncio.to_thread(category_counts) @@ -120,7 +209,12 @@ async def hub_plugin_file_content(plugin_type: PluginType, plugin_id: str, path: @router.post("/hub/plugins/{plugin_type}/{plugin_id}/install", response_model=InstalledPluginRecord) -async def hub_install_plugin(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): +async def hub_install_plugin( + plugin_type: PluginType, + plugin_id: str, + req: HubInstallRequest = HubInstallRequest(), + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) try: return await install_plugin(plugin_type, plugin_id, scope=req.scope) @@ -130,7 +224,12 @@ async def hub_install_plugin(plugin_type: PluginType, plugin_id: str, req: HubIn @router.post("/hub/plugins/{plugin_type}/{plugin_id}/install/stream") -async def hub_install_plugin_stream(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): +async def hub_install_plugin_stream( + plugin_type: PluginType, + plugin_id: str, + req: HubInstallRequest = HubInstallRequest(), + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) if plugin_type != "component": raise HTTPException(status_code=400, detail="Streaming install progress is only supported for components.") @@ -179,7 +278,12 @@ async def run_install() -> None: @router.post("/hub/plugins/{plugin_type}/{plugin_id}/update", response_model=InstalledPluginRecord) -async def hub_update_plugin(plugin_type: PluginType, plugin_id: str, req: HubInstallRequest = HubInstallRequest()): +async def hub_update_plugin( + plugin_type: PluginType, + plugin_id: str, + req: HubInstallRequest = HubInstallRequest(), + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) try: return await update_plugin(plugin_type, plugin_id, scope=req.scope) @@ -189,7 +293,11 @@ async def hub_update_plugin(plugin_type: PluginType, plugin_id: str, req: HubIns @router.delete("/hub/plugins/{plugin_type}/{plugin_id}") -async def hub_uninstall_plugin(plugin_type: PluginType, plugin_id: str): +async def hub_uninstall_plugin( + plugin_type: PluginType, + plugin_id: str, + _admin: object = Depends(require_admin), +): _guard_legacy_removed_plugin(plugin_type, plugin_id) try: removed = await uninstall_plugin(plugin_type, plugin_id) @@ -199,6 +307,6 @@ async def hub_uninstall_plugin(plugin_type: PluginType, plugin_id: str): @router.post("/hub/refresh") -async def hub_refresh(): +async def hub_refresh(_admin: object = Depends(require_admin)): _clear_hub_runtime_caches() return {"count": len(await asyncio.to_thread(list_catalog))} diff --git a/flocks/server/routes/monitoring.py b/flocks/server/routes/monitoring.py new file mode 100644 index 000000000..9a43dcdfc --- /dev/null +++ b/flocks/server/routes/monitoring.py @@ -0,0 +1,225 @@ +""" +Lightweight WebUI monitoring routes. + +These endpoints back the existing Monitoring page without forcing the page to +poll heavier session/tool list APIs. Detailed historical sampling can be added +later without changing the frontend contract. +""" + +from __future__ import annotations + +import time +import threading +from typing import Any, Literal + +from fastapi import APIRouter, Query +from pydantic import BaseModel, Field + +from flocks.agent import registry as agent_registry +from flocks.mcp import MCP +from flocks.session.session import Session +from flocks.utils.log import Log +from flocks.utils.monitor import get_monitor + +router = APIRouter() +log = Log.create(service="monitoring-routes") +_started_at = time.monotonic() + + +class SystemStatusResponse(BaseModel): + status: Literal["healthy", "degraded", "down"] + uptime: int + activeSessions: int | None + activeAgents: int | None + mcpServers: dict[str, str] + timestamp: int + + +class MetricsSnapshotResponse(BaseModel): + timestamp: int + messageRate: float | None = Field( + default=None, + description="Messages per minute. None because message throughput is not collected yet.", + ) + toolCallRate: float | None = Field( + default=None, + description="Parsed tool calls per minute.", + ) + errorRate: float | None = Field( + default=None, + description="System-wide error rate. None until a global error metric is available.", + ) + toolParseFailureRate: float | None = Field( + default=None, + description="Failed tool-call parses per minute.", + ) + avgResponseTime: float | None = Field( + default=None, + description="Average response time in milliseconds. None until latency is collected.", + ) + activeRequests: int | None = Field( + default=None, + description="Active request count. None until request concurrency is collected.", + ) + + +class PerformanceDataResponse(BaseModel): + category: Literal["llm", "tool", "api"] + name: str + avgDuration: float + p50: float | None = None + p95: float | None = None + p99: float | None = None + count: int + errors: int + + +def _timestamp_ms() -> int: + return int(time.time() * 1000) + + +async def _active_session_count() -> int | None: + cached_sessions = getattr(Session, "_all_sessions_cache", None) + if cached_sessions is None: + return None + return sum(1 for session in cached_sessions if getattr(session, "status", None) == "active") + + +async def _active_agent_count() -> int | None: + agents = getattr(agent_registry, "_agents_ref", None) + if agents is None: + return None + return sum(1 for agent in agents.values() if not getattr(agent, "hidden", False)) + + +async def _mcp_server_statuses() -> tuple[dict[str, str], bool]: + try: + statuses, initialized = MCP.status_snapshot() + except Exception as exc: + log.warning("monitoring.mcp.status_failed", {"error": str(exc)}) + return {}, False + return ({ + name: str(info.status.value if hasattr(info.status, "value") else info.status) + for name, info in statuses.items() + }, initialized) + + +_metrics_sample_lock = threading.Lock() +_METRICS_RATE_WINDOW_SECONDS = 60.0 +_metrics_rate_state: tuple[ + float, + float, + float, + float | None, + float | None, +] | None = None + + +def _metrics_clock() -> float: + return time.monotonic() + + +def _sample_tool_rates(total_calls: float, failed_calls: float) -> tuple[float | None, float | None]: + """Return a stable process-wide rate snapshot for the current time window.""" + global _metrics_rate_state + + now = _metrics_clock() + with _metrics_sample_lock: + state = _metrics_rate_state + if state is None: + _metrics_rate_state = (now, total_calls, failed_calls, None, None) + return None, None + + sampled_at, previous_calls, previous_failures, call_rate, failure_rate = state + elapsed_seconds = now - sampled_at + counters_reset = total_calls < previous_calls or failed_calls < previous_failures + if counters_reset: + _metrics_rate_state = (now, total_calls, failed_calls, None, None) + return None, None + if elapsed_seconds < _METRICS_RATE_WINDOW_SECONDS: + return call_rate, failure_rate + + elapsed_minutes = elapsed_seconds / 60 + call_rate = (total_calls - previous_calls) / elapsed_minutes + failure_rate = (failed_calls - previous_failures) / elapsed_minutes + _metrics_rate_state = (now, total_calls, failed_calls, call_rate, failure_rate) + return call_rate, failure_rate + + +@router.get("/status", response_model=SystemStatusResponse) +async def get_status() -> SystemStatusResponse: + mcp_servers, mcp_status_available = await _mcp_server_statuses() + unhealthy_mcp = any( + status.strip().lower() not in {"connected", "disabled"} + for status in mcp_servers.values() + ) + return SystemStatusResponse( + status="degraded" if not mcp_status_available or unhealthy_mcp else "healthy", + uptime=max(0, int(time.monotonic() - _started_at)), + activeSessions=await _active_session_count(), + activeAgents=await _active_agent_count(), + mcpServers=mcp_servers, + timestamp=_timestamp_ms(), + ) + + +@router.get("/metrics", response_model=MetricsSnapshotResponse) +async def get_metrics() -> MetricsSnapshotResponse: + metrics = get_monitor().get_metrics().get("global", {}) + total_calls = float(metrics.get("total_calls") or 0) + failed_calls = float(metrics.get("failed_parses") or 0) + tool_call_rate, tool_parse_failure_rate = _sample_tool_rates(total_calls, failed_calls) + return MetricsSnapshotResponse( + timestamp=_timestamp_ms(), + messageRate=None, + toolCallRate=tool_call_rate, + errorRate=None, + toolParseFailureRate=tool_parse_failure_rate, + avgResponseTime=None, + activeRequests=None, + ) + + +@router.get("/metrics/history") +async def get_metrics_history( + duration: int = Query(300, ge=1), + interval: int = Query(5, ge=1), +) -> dict[str, Any]: + return {"duration": duration, "interval": interval, "items": []} + + +@router.get("/performance", response_model=list[PerformanceDataResponse]) +async def get_performance( + category: Literal["llm", "tool", "api"] | None = None, +) -> list[PerformanceDataResponse]: + return [] + + +@router.get("/performance/llm", response_model=list[PerformanceDataResponse]) +async def get_llm_performance() -> list[PerformanceDataResponse]: + return [] + + +@router.get("/performance/tool", response_model=list[PerformanceDataResponse]) +async def get_tool_performance() -> list[PerformanceDataResponse]: + return [] + + +@router.get("/api-stats") +async def get_api_stats() -> dict[str, Any]: + return {"items": []} + + +@router.get("/api-stats/history") +async def get_api_stats_history(duration: int = Query(300, ge=1)) -> dict[str, Any]: + return {"duration": duration, "items": []} + + +@router.get("/events") +async def get_events( + level: Literal["info", "warn", "error"] | None = None, + service: str | None = None, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> list[dict[str, Any]]: + return [] diff --git a/flocks/server/routes/permission.py b/flocks/server/routes/permission.py index 4162c7d9b..073652b27 100644 --- a/flocks/server/routes/permission.py +++ b/flocks/server/routes/permission.py @@ -11,9 +11,13 @@ from typing import Dict, List, Any, Optional from datetime import datetime -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user +from flocks.session.policy import SessionPolicy +from flocks.session.session import Session from flocks.utils.log import Log from flocks.utils.id import Identifier from flocks.permission.next import PermissionNext @@ -119,21 +123,49 @@ async def remove_permission(permission_id: str) -> bool: return True +async def _can_access_permission_session( + session_id: str, + user: AuthUser, + *, + write: bool, +) -> bool: + session = await Session.get_by_id(session_id) + if session is None: + return False + return SessionPolicy.can_write(session, user) if write else SessionPolicy.can_read(session, user) + + +async def _require_permission_session_access( + session_id: str, + user: AuthUser, + *, + write: bool, +) -> None: + if not await _can_access_permission_session(session_id, user, write=write): + detail = "Only the session owner can reply to permissions" if write else "Permission not found" + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + @router.get( "", response_model=List[PermissionInfo], summary="List permissions", description="Get all pending permission requests" ) -async def list_permissions() -> List[PermissionInfo]: +async def list_permissions( + user: AuthUser = Depends(require_user), +) -> List[PermissionInfo]: """ List all pending permission requests. Flocks TUI uses this to display permission prompts. """ pending_infos = await PermissionNext.list_pending_infos() - return [ - PermissionInfo( + result: List[PermissionInfo] = [] + for info in pending_infos: + if not await _can_access_permission_session(info.session_id, user, write=False): + continue + result.append(PermissionInfo( id=info.id, sessionID=info.session_id, messageID=info.metadata.get("messageID", ""), @@ -143,9 +175,8 @@ async def list_permissions() -> List[PermissionInfo]: always=info.always, metadata=info.metadata, time=info.time, - ) - for info in pending_infos - ] + )) + return result @router.get( @@ -154,11 +185,15 @@ async def list_permissions() -> List[PermissionInfo]: summary="Get permission", description="Get a specific permission request" ) -async def get_permission_by_id(permission_id: str) -> PermissionInfo: +async def get_permission_by_id( + permission_id: str, + user: AuthUser = Depends(require_user), +) -> PermissionInfo: """Get a specific permission request""" info = await PermissionNext.get_pending_info(permission_id) if not info: raise HTTPException(status_code=404, detail="Permission not found") + await _require_permission_session_access(info.session_id, user, write=False) return PermissionInfo( id=info.id, sessionID=info.session_id, @@ -180,6 +215,7 @@ async def get_permission_by_id(permission_id: str) -> PermissionInfo: async def reply_permission( permission_id: str, request: PermissionReplyRequest, + user: AuthUser = Depends(require_user), ) -> Dict[str, bool]: """ Reply to a permission request. @@ -194,6 +230,7 @@ async def reply_permission( info = await PermissionNext.get_pending_info(permission_id) if not info: raise HTTPException(status_code=404, detail="Permission not found") + await _require_permission_session_access(info.session_id, user, write=True) log.info("permission.reply", { "id": permission_id, @@ -201,7 +238,13 @@ async def reply_permission( "always": request.always, }) - reply = "always" if request.allow and request.always else "allow" if request.allow else "never" if request.always else "deny" + if request.always: + if SessionPolicy.is_admin(user): + reply = "always" if request.allow else "never" + else: + reply = "allow_session" if request.allow else "deny_session" + else: + reply = "allow" if request.allow else "deny" await PermissionNext.reply(permission_id, reply, session_id=info.session_id) return {"success": True} diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 19c8e8397..79e4d7e37 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -6,16 +6,20 @@ temperature, tool_call, limit, etc. """ -import time +import asyncio +import json import re +import threading +import time from pathlib import Path -from typing import List, Optional, Dict, Any -from fastapi import APIRouter, Body, HTTPException, Query, status +from typing import Any, Dict, List, Optional +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from pydantic import BaseModel, Field, ConfigDict from flocks.utils.log import Log from flocks.provider.provider import Provider, ModelInfo as ProviderModelInfo from flocks.security.secrets import SecretManager +from flocks.server.auth import require_admin from flocks.config.config import Config from flocks.config.config_writer import ConfigWriter from flocks.storage.storage import Storage @@ -42,6 +46,62 @@ router = APIRouter() log = Log.create(service="provider-routes") +_api_service_summary_metadata_cache_lock = threading.Lock() +_api_service_summary_metadata_cache: Dict[str, tuple[tuple[Any, ...], Optional[Dict[str, Any]]]] = {} +_provider_initialization_lock = asyncio.Lock() +_provider_initialization_task: asyncio.Task[None] | None = None +_dynamic_provider_load_tasks: set[asyncio.Task[None]] = set() + + +async def _run_provider_initialization() -> None: + async with _provider_initialization_lock: + if Provider._initialized: + return + await asyncio.to_thread(Provider._ensure_initialized) + + +async def _ensure_provider_initialized() -> None: + """Run synchronous provider discovery without blocking the event loop.""" + global _provider_initialization_task + + if Provider._initialized: + return + + task = _provider_initialization_task + if task is None or task.done(): + task = asyncio.create_task(_run_provider_initialization()) + _provider_initialization_task = task + + def clear_completed(completed: asyncio.Task[None]) -> None: + global _provider_initialization_task + if _provider_initialization_task is completed: + _provider_initialization_task = None + if not completed.cancelled(): + completed.exception() + + task.add_done_callback(clear_completed) + + await asyncio.shield(task) + + +async def _run_dynamic_provider_load() -> None: + async with _provider_initialization_lock: + await asyncio.to_thread(Provider._load_dynamic_providers) + + +async def _load_dynamic_providers() -> None: + """Queue one serialized config scan per trigger without blocking the event loop.""" + task = asyncio.create_task(_run_dynamic_provider_load()) + _dynamic_provider_load_tasks.add(task) + + def clear_completed(completed: asyncio.Task[None]) -> None: + _dynamic_provider_load_tasks.discard(completed) + if not completed.cancelled(): + completed.exception() + + task.add_done_callback(clear_completed) + + await asyncio.shield(task) def _load_provider_yaml_metadata(provider_id: str) -> Optional[Dict[str, Any]]: @@ -71,6 +131,118 @@ def _load_api_service_metadata_data(provider_id: str) -> Optional[Dict[str, Any] return merged or None +def _clear_api_service_summary_metadata_cache(provider_id: Optional[str] = None) -> None: + with _api_service_summary_metadata_cache_lock: + if provider_id is None: + _api_service_summary_metadata_cache.clear() + else: + _api_service_summary_metadata_cache.pop(provider_id, None) + + +def _provider_yaml_cache_key(provider_id: str) -> tuple[str, int]: + descriptor = _find_api_service_descriptor(provider_id) + if descriptor is None: + return "", 0 + try: + return str(descriptor.provider_yaml), descriptor.provider_yaml.stat().st_mtime_ns + except OSError: + return str(descriptor.provider_yaml), 0 + + +def _legacy_metadata_cache_key(provider_id: str) -> tuple[str, int]: + meta_file = api_service_schema_helpers._LEGACY_METADATA_DIR / f"{provider_id}.json" + if not meta_file.is_file(): + return "", 0 + try: + return str(meta_file), meta_file.stat().st_mtime_ns + except OSError: + return str(meta_file), 0 + + +def _api_service_summary_metadata_cache_key(provider_id: str) -> tuple[Any, ...]: + config_data = ConfigWriter.get_api_service_raw(provider_id) or {} + return ( + json.dumps(config_data, sort_keys=True, default=str), + _legacy_metadata_cache_key(provider_id), + _provider_yaml_cache_key(provider_id), + ) + + +def _find_api_service_descriptor(provider_id: str): + from flocks.config.api_versioning import discover_api_service_descriptors + + return next( + ( + d + for d in discover_api_service_descriptors() + if provider_id in (d.storage_key, d.service_id) + ), + None, + ) + + +def _load_provider_yaml_summary_metadata(provider_id: str) -> Optional[Dict[str, Any]]: + descriptor = _find_api_service_descriptor(provider_id) + if descriptor is None: + return None + try: + prov = api_service_schema_helpers.yaml.safe_load( + descriptor.provider_yaml.read_text(encoding="utf-8") + ) + except Exception as e: + log.debug("api_service.summary_metadata.provider_yaml_failed", { + "provider_id": provider_id, + "path": str(descriptor.provider_yaml), + "error": str(e), + }) + return None + if not isinstance(prov, dict): + return None + return { + "name": prov.get("name", provider_id), + "service_id": prov.get("service_id", provider_id), + "version": extract_provider_version(prov), + "description": prov.get("description"), + "description_cn": prov.get("description_cn"), + "defaults": prov.get("defaults", {}), + "integration_type": prov.get("integration_type"), + "vendor": prov.get("vendor"), + } + + +def _load_api_service_summary_metadata_data(provider_id: str) -> Optional[Dict[str, Any]]: + """Load only metadata needed by the API service list endpoint.""" + cache_key = _api_service_summary_metadata_cache_key(provider_id) + with _api_service_summary_metadata_cache_lock: + cached = _api_service_summary_metadata_cache.get(provider_id) + if cached and cached[0] == cache_key: + return dict(cached[1]) if isinstance(cached[1], dict) else cached[1] + + merged: Dict[str, Any] = {} + config_data = ConfigWriter.get_api_service_raw(provider_id) + if isinstance(config_data, dict): + merged.update(config_data) + + meta_file = api_service_schema_helpers._LEGACY_METADATA_DIR / f"{provider_id}.json" + if meta_file.is_file(): + with open(meta_file, "r", encoding="utf-8") as f: + metadata_data = api_service_schema_helpers.json.load(f) + if isinstance(metadata_data, dict): + merged = {**metadata_data, **merged} + + yaml_data = _load_provider_yaml_summary_metadata(provider_id) + if isinstance(yaml_data, dict): + merged = {**yaml_data, **merged} + + result = merged or None + with _api_service_summary_metadata_cache_lock: + _api_service_summary_metadata_cache[provider_id] = ( + cache_key, + dict(result) if isinstance(result, dict) else result, + ) + return result + + _EMAIL_KEY_PAIR_PATTERN = re.compile( r"([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\s*:\s*([^\s]+)" ) @@ -353,8 +525,9 @@ async def list_providers() -> ProviderListResponse: List of providers with their models """ try: - # Ensure providers are initialized - Provider._ensure_initialized() + # Initial discovery imports provider modules and uses a threading lock. + # Keep that synchronous work off the server event loop. + await _ensure_provider_initialized() # Get config for enabled/disabled providers try: @@ -545,7 +718,8 @@ async def get_all_provider_auth() -> Dict[str, List[Any]]: ) async def oauth_authorize( provider_id: str, - method: int = 0 + method: int = 0, + _admin: object = Depends(require_admin), ) -> Optional[Dict[str, Any]]: """ Initiate OAuth authorization @@ -568,7 +742,8 @@ async def oauth_authorize( async def oauth_callback( provider_id: str, method: int, - code: Optional[str] = None + code: Optional[str] = None, + _admin: object = Depends(require_admin), ) -> bool: """ Handle OAuth callback @@ -598,6 +773,7 @@ async def list_api_services_route(): async def update_api_service_route( provider_id: str, request: Dict[str, Any] = Body(...), + _admin: object = Depends(require_admin), ): return await update_api_service(provider_id, APIServiceUpdateRequest.model_validate(request)) @@ -608,7 +784,10 @@ async def update_api_service_route( summary="Delete API service", description="Delete an API service configuration and its stored credential." ) -async def delete_api_service_route(provider_id: str): +async def delete_api_service_route( + provider_id: str, + _admin: object = Depends(require_admin), +): return await delete_api_service(provider_id) @@ -629,7 +808,7 @@ async def get_provider(provider_id: str) -> ProviderInfo: Provider information """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -686,7 +865,7 @@ async def list_models(provider_id: str) -> List[Dict[str, Any]]: List of models in Flocks-compatible format """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() if provider_id not in ConfigWriter.list_provider_ids(): return [] config = await Config.get() @@ -719,7 +898,11 @@ class ProviderConfigRequest(BaseModel): summary="Configure provider", description="Configure provider with API key and settings" ) -async def configure_provider(provider_id: str, config: ProviderConfigRequest) -> ProviderInfo: +async def configure_provider( + provider_id: str, + config: ProviderConfigRequest, + _admin: object = Depends(require_admin), +) -> ProviderInfo: """ Configure provider @@ -731,7 +914,7 @@ async def configure_provider(provider_id: str, config: ProviderConfigRequest) -> Updated provider information """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -770,7 +953,10 @@ async def configure_provider(provider_id: str, config: ProviderConfigRequest) -> summary="Test provider", description="Test provider connection" ) -async def test_provider(provider_id: str) -> Dict[str, Any]: +async def test_provider( + provider_id: str, + _admin: object = Depends(require_admin), +) -> Dict[str, Any]: """ Test provider connection @@ -781,7 +967,7 @@ async def test_provider(provider_id: str) -> Dict[str, Any]: Test result """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -825,14 +1011,18 @@ async def test_provider(provider_id: str) -> Dict[str, Any]: summary="Update provider", description="Update provider configuration" ) -async def update_provider(provider_id: str, config: ProviderConfigRequest) -> ProviderInfo: +async def update_provider( + provider_id: str, + config: ProviderConfigRequest, + _admin: object = Depends(require_admin), +) -> ProviderInfo: """ Update provider configuration Updates the provider's API key, base URL, and custom settings. """ try: - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if not provider: @@ -857,7 +1047,6 @@ async def update_provider(provider_id: str, config: ProviderConfigRequest) -> Pr config_key = f"provider_config/{provider_id}" await Storage.write(config_key, { "provider_id": provider_id, - "api_key": config.api_key, "base_url": config.base_url, "custom_settings": config.custom_settings, }) @@ -989,6 +1178,16 @@ def _set_api_service_tools_enabled(provider_id: str, enabled: bool) -> int: matched_tools = _get_api_service_tool_infos(provider_id) for tool_info in matched_tools: tool_info.enabled = enabled + if matched_tools: + try: + from flocks.server.routes.tool import _invalidate_tool_summary_cache + + _invalidate_tool_summary_cache() + except Exception as e: + log.debug("tool.summary_cache.invalidate_failed", { + "provider_id": provider_id, + "error": str(e), + }) return len(matched_tools) @@ -1031,7 +1230,7 @@ def _build_api_service_summary( raw_statuses: Dict[str, Any], ) -> APIServiceSummary: """Build API service summary used by the Tool API page.""" - meta = _load_api_service_metadata_data(provider_id) or {} + meta = _load_api_service_summary_metadata_data(provider_id) or {} enabled = _get_api_service_enabled(provider_id) cached_status = raw_statuses.get(provider_id) or {} matched_tools = _get_api_service_tool_infos(provider_id) @@ -1177,6 +1376,7 @@ async def update_api_service(provider_id: str, request: APIServiceUpdateRequest) if request.verify_ssl is not None: existing["verify_ssl"] = request.verify_ssl ConfigWriter.set_api_service(provider_id, existing) + _clear_api_service_summary_metadata_cache(provider_id) matched_count = _set_api_service_tools_enabled(provider_id, request.enabled) @@ -1306,6 +1506,7 @@ async def delete_api_service(provider_id: str) -> Dict[str, Any]: backing_plugin = _find_user_installed_tool_plugin_for(provider_id) removed_config = ConfigWriter.remove_api_service(provider_id) + _clear_api_service_summary_metadata_cache(provider_id) secrets = get_secret_manager() deleted_secret = False @@ -1461,7 +1662,10 @@ class ProviderCredentialResponse(BaseModel): summary="Get provider credentials (masked)", description="Get masked credential information for a registered LLM provider." ) -async def get_provider_credentials(provider_id: str): +async def get_provider_credentials( + provider_id: str, + _admin: object = Depends(require_admin), +): """Get credential info for an LLM provider (_llm_key convention). - api_key: from .secret.json (_llm_key first, legacy _api_key fallback) @@ -1503,10 +1707,10 @@ async def get_provider_credentials(provider_id: str): return ProviderCredentialResponse( secret_id=secret_id if api_key else None, - api_key=ui_api_key, + api_key=None, api_key_masked=( - SecretManager.mask(api_key) - if api_key and not is_placeholder + SecretManager.mask(ui_api_key) + if ui_api_key else None ), base_url=base_url, @@ -1523,7 +1727,11 @@ async def get_provider_credentials(provider_id: str): summary="Set provider credentials", description="Set authentication credentials for a provider or API service." ) -async def set_provider_credentials(provider_id: str, request: ProviderCredentialRequest): +async def set_provider_credentials( + provider_id: str, + request: ProviderCredentialRequest, + _admin: object = Depends(require_admin), +): """Set credentials for a provider. - api_key → .secret.json @@ -1537,37 +1745,66 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential try: # OpenAI-compatible endpoints (e.g. internal LLM gateways without auth) # and user-defined custom-* providers may legitimately have no API key. - # For other providers (OpenAI, Anthropic, etc.) the key is still required. + # For an already configured provider, an omitted/blank key means "keep + # the existing secret" so callers can safely update only the base URL + # or model selection without receiving the original key back first. api_key_optional = ( provider_id == "openai-compatible" or provider_id.startswith("custom-") ) + secrets = get_secret_manager() + secret_id = request.secret_id or f"{provider_id}_llm_key" effective_api_key = (request.api_key or "").strip() + preserve_existing_secret = False + if not effective_api_key: - if not api_key_optional: - raise HTTPException(status_code=400, detail="API key required") - # Persist a sentinel value so downstream OpenAI SDK clients (which - # require a non-empty key argument) keep working transparently. - # The GET endpoint masks this back to ``None`` for the UI. - effective_api_key = _NO_API_KEY_PLACEHOLDER + existing_secret_ids = [secret_id] + legacy_secret_id = f"{provider_id}_api_key" + if request.secret_id is None and legacy_secret_id not in existing_secret_ids: + existing_secret_ids.append(legacy_secret_id) + + for candidate in existing_secret_ids: + existing_value = secrets.get(candidate) + if isinstance(existing_value, str) and existing_value.strip(): + secret_id = candidate + effective_api_key = existing_value.strip() + preserve_existing_secret = True + break - secrets = get_secret_manager() + if not effective_api_key: + inline_api_key = _get_inline_provider_api_key(provider_id) + if inline_api_key: + effective_api_key = inline_api_key + preserve_existing_secret = True + + if not effective_api_key: + if not api_key_optional: + raise HTTPException(status_code=400, detail="API key required") + # Persist a sentinel value so downstream OpenAI SDK clients + # (which require a non-empty key argument) keep working. + effective_api_key = _NO_API_KEY_PLACEHOLDER # 1. Save API key to .secret.json using _llm_key convention for LLM providers - secret_id = request.secret_id or f"{provider_id}_llm_key" - secrets.set(secret_id, effective_api_key) - if _is_placeholder_api_key(effective_api_key): - masked = _NO_API_KEY_LOG_MARKER - elif len(effective_api_key) > 8: - masked = f"{effective_api_key[:4]}***{effective_api_key[-4:]}" + if preserve_existing_secret: + log.info("provider.credentials.preserved", { + "provider_id": provider_id, + "secret_id": secret_id, + "base_url": request.base_url, + }) else: - masked = "***" - log.info("provider.credentials.saving", { - "provider_id": provider_id, - "secret_id": secret_id, - "api_key_masked": masked, - "api_key_optional": api_key_optional, - "base_url": request.base_url, - }) + secrets.set(secret_id, effective_api_key) + if _is_placeholder_api_key(effective_api_key): + masked = _NO_API_KEY_LOG_MARKER + elif len(effective_api_key) > 8: + masked = f"{effective_api_key[:4]}***{effective_api_key[-4:]}" + else: + masked = "***" + log.info("provider.credentials.saving", { + "provider_id": provider_id, + "secret_id": secret_id, + "api_key_masked": masked, + "api_key_optional": api_key_optional, + "base_url": request.base_url, + }) # 2. Ensure provider entry exists in flocks.json and update base_url / name raw_provider = ConfigWriter.get_provider_raw(provider_id) @@ -1577,10 +1814,13 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential ConfigWriter.update_provider_field( provider_id, "options.baseURL", request.base_url ) - # Ensure apiKey reference is set - ConfigWriter.update_provider_field( - provider_id, "options.apiKey", f"{{secret:{provider_id}_llm_key}}" - ) + if not preserve_existing_secret: + # A newly supplied key uses the canonical secret reference. + # When preserving a stored or inline key, leave its existing + # config reference untouched. + ConfigWriter.update_provider_field( + provider_id, "options.apiKey", f"{{secret:{secret_id}}}" + ) if request.provider_name: ConfigWriter.update_provider_field( provider_id, "name", request.provider_name @@ -1633,7 +1873,7 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential ConfigWriter.add_provider(provider_id, config_dict) # 3. Configure the provider runtime so is_configured() reflects the change - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if provider: # Preserve the existing base_url from flocks.json when not explicitly provided, @@ -1683,7 +1923,10 @@ async def set_provider_credentials(provider_id: str, request: ProviderCredential summary="Delete provider credentials", description="Delete stored credentials for a provider or API service." ) -async def delete_provider_credentials(provider_id: str): +async def delete_provider_credentials( + provider_id: str, + _admin: object = Depends(require_admin), +): """Delete a provider: remove from flocks.json and .secret.json.""" from flocks.security import get_secret_manager @@ -1706,7 +1949,7 @@ async def delete_provider_credentials(provider_id: str): raise HTTPException(status_code=404, detail="No credentials found for this provider") # 4. Clear provider runtime config - Provider._ensure_initialized() + await _ensure_provider_initialized() provider = Provider.get(provider_id) if provider: provider._config = None @@ -1738,7 +1981,10 @@ async def delete_provider_credentials(provider_id: str): summary="Get API service credentials (masked)", description="Get masked credential information for an API service (tool integrations)." ) -async def get_service_credentials(provider_id: str): +async def get_service_credentials( + provider_id: str, + _admin: object = Depends(require_admin), +): """Get credential info for an API service (_api_key convention). Reads the secret_id from flocks.json api_services.{id}.secret first, @@ -1795,15 +2041,30 @@ async def get_service_credentials(provider_id: str): field_values["api_key"] = split_api_key field_values["secret"] = split_secret + sensitive_field_names = { + field.key + for field in schema + if field.sensitive or field.storage == "secret" + } + sensitive_field_names.update(secret_ids) + safe_field_values = { + field_name: ( + SecretManager.mask(value) + if value and field_name in sensitive_field_names + else value + ) + for field_name, value in field_values.items() + } + return ProviderCredentialResponse( secret_id=secret_ids.get("api_key"), - api_key=field_values.get("api_key"), + api_key=None, api_key_masked=SecretManager.mask(field_values["api_key"]) if field_values.get("api_key") else None, - secret=field_values.get("secret"), + secret=None, secret_masked=SecretManager.mask(field_values["secret"]) if field_values.get("secret") else None, base_url=field_values.get("base_url"), username=field_values.get("username"), - fields=field_values or None, + fields=safe_field_values or None, secret_ids=secret_ids or None, has_credential=bool(any(value for value in field_values.values())), ) @@ -1818,7 +2079,11 @@ async def get_service_credentials(provider_id: str): summary="Set API service credentials", description="Set authentication credentials for an API service." ) -async def set_service_credentials(provider_id: str, request: ProviderCredentialRequest): +async def set_service_credentials( + provider_id: str, + request: ProviderCredentialRequest, + _admin: object = Depends(require_admin), +): """Set credentials for an API service (_api_key convention). Saves to .secret.json AND writes the secret reference into flocks.json @@ -1935,6 +2200,7 @@ async def set_service_credentials(provider_id: str, request: ProviderCredentialR if field.key == "base_url": existing.pop("baseUrl", None) ConfigWriter.set_api_service(provider_id, existing) + _clear_api_service_summary_metadata_cache(provider_id) log.info( "service.credentials.set", @@ -1971,7 +2237,11 @@ class TestCredentialRequest(BaseModel): summary="Test provider credentials", description="Test if the stored credentials are valid by making a real chat API call." ) -async def test_provider_credentials(provider_id: str, body: Optional[TestCredentialRequest] = None): +async def test_provider_credentials( + provider_id: str, + body: Optional[TestCredentialRequest] = None, + _admin: object = Depends(require_admin), +): """Test credentials for a provider or API service by making a real API call""" from flocks.security import get_secret_manager @@ -2013,12 +2283,12 @@ async def test_provider_credentials(provider_id: str, body: Optional[TestCredent await _save_api_service_status_if_configured(provider_id, response) return response - Provider._ensure_initialized() + await _ensure_provider_initialized() # Re-run dynamic provider loading in case this provider was created after # the server started (e.g. user just added azure-openai via the UI). # _load_dynamic_providers skips already-registered providers, so it is safe # to call multiple times. - Provider._load_dynamic_providers() + await _load_dynamic_providers() # Apply config to ensure _config_models (user-defined models) are loaded config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) @@ -2588,7 +2858,9 @@ async def get_api_services_status(): summary="Refresh API service connectivity status", description="Manually trigger a refresh of all API service connectivity statuses." ) -async def refresh_api_services_status(): +async def refresh_api_services_status( + _admin: object = Depends(require_admin), +): """Refresh all API service connectivity statuses and cache them.""" try: await Storage.init() diff --git a/flocks/server/routes/question.py b/flocks/server/routes/question.py index 4696cb722..ef33b301a 100644 --- a/flocks/server/routes/question.py +++ b/flocks/server/routes/question.py @@ -9,9 +9,13 @@ from typing import Dict, List, Any, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user +from flocks.session.policy import SessionPolicy +from flocks.session.session import Session from flocks.utils.log import Log @@ -76,6 +80,21 @@ def clear_request_state(request_id: str) -> None: _request_rejected.discard(request_id) +async def _require_question_session_access( + session_id: str, + user: AuthUser, + *, + write: bool, +) -> None: + session = await Session.get_by_id(session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + allowed = SessionPolicy.can_write(session, user) if write else SessionPolicy.can_read(session, user) + if not allowed: + detail = "Only the session owner can answer questions" if write else "Session access denied" + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + async def reject_session_questions(session_id: str) -> int: """Reject all pending question requests for a session. @@ -126,7 +145,11 @@ def has_pending_questions(session_id: str) -> bool: summary="List pending questions for a session", description="Return all unanswered Question requests for the given session", ) -async def get_pending_question_requests(session_id: str) -> List[Dict[str, Any]]: +async def get_pending_question_requests( + session_id: str, + user: AuthUser = Depends(require_user), +) -> List[Dict[str, Any]]: + await _require_question_session_access(session_id, user, write=False) return list_question_requests(session_id) @@ -138,9 +161,16 @@ async def get_pending_question_requests(session_id: str) -> List[Dict[str, Any]] async def reply_question_request( request_id: str, request: QuestionRequestReply, + user: AuthUser = Depends(require_user), ) -> Dict[str, Any]: - if request_id not in _question_requests: + question_request = _question_requests.get(request_id) + if question_request is None: raise HTTPException(status_code=404, detail="Question request not found") + await _require_question_session_access( + question_request.get("sessionID", ""), + user, + write=True, + ) log.info("question.request.reply", { "request_id": request_id, @@ -150,7 +180,6 @@ async def reply_question_request( try: from flocks.server.routes.event import publish_event - question_request = _question_requests[request_id] await publish_event("question.replied", { "sessionID": question_request.get("sessionID", ""), "requestID": request_id, @@ -159,7 +188,7 @@ async def reply_question_request( except Exception as e: log.error("question.replied.event.failed", {"error": str(e)}) - del _question_requests[request_id] + _question_requests.pop(request_id, None) return {"success": True} @@ -168,16 +197,24 @@ async def reply_question_request( summary="Reject question request", description="Reject a QuestionRequest (user doesn't want to answer)", ) -async def reject_question_request(request_id: str) -> Dict[str, bool]: - if request_id not in _question_requests: +async def reject_question_request( + request_id: str, + user: AuthUser = Depends(require_user), +) -> Dict[str, bool]: + question_request = _question_requests.get(request_id) + if question_request is None: raise HTTPException(status_code=404, detail="Question request not found") + await _require_question_session_access( + question_request.get("sessionID", ""), + user, + write=True, + ) log.info("question.request.reject", {"request_id": request_id}) _request_rejected.add(request_id) try: from flocks.server.routes.event import publish_event - question_request = _question_requests[request_id] await publish_event("question.rejected", { "sessionID": question_request.get("sessionID", ""), "requestID": request_id, @@ -185,7 +222,7 @@ async def reject_question_request(request_id: str) -> Dict[str, bool]: except Exception as e: log.error("question.rejected.event.failed", {"error": str(e)}) - del _question_requests[request_id] + _question_requests.pop(request_id, None) return {"success": True} diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index 7d7858b2d..3cf75b890 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -28,7 +28,7 @@ from flocks.utils.log import Log from flocks.utils.json_repair import parse_json_robust, repair_truncated_json from flocks.utils.monitor import get_monitor -from flocks.server.auth import require_user +from flocks.server.auth import API_TOKEN_SERVICE_USER_ID, require_user router = APIRouter() log = Log.create(service="session-routes") @@ -37,6 +37,10 @@ DEFAULT_AGENT = "rex" DEFAULT_MESSAGE_PAGE_LIMIT = 50 _DESCENDANT_ABORT_SCAN_LIMIT = 3 +_CONTEXT_USAGE_CACHE_TTL_SECONDS = 5.0 +_context_usage_cache: Dict[Tuple[str, int], Tuple[float, ContextUsageSnapshot]] = {} +_context_usage_inflight: Dict[Tuple[str, int], asyncio.Task[ContextUsageSnapshot]] = {} +_context_usage_cache_lock = asyncio.Lock() # File extensions that are safe to persist when materialising data-URL uploads. # Intentionally narrow: any extension outside this set is rejected to prevent @@ -45,6 +49,59 @@ # ".exe"). _UPLOAD_SAFE_EXTS = frozenset({"png", "jpg", "jpeg", "gif", "webp", "bmp", "pdf"}) + +def _context_usage_cache_key(session_id: str, session: SessionModel) -> Tuple[str, int]: + return session_id, int(getattr(session.time, "updated", 0) or 0) + + +async def _build_context_usage_for_cache( + key: Tuple[str, int], + session_id: str, + *, + session: SessionModel, +) -> ContextUsageSnapshot: + current_task = asyncio.current_task() + try: + snapshot = await build_context_usage_snapshot(session_id, session=session) + async with _context_usage_cache_lock: + if _context_usage_inflight.get(key) is not current_task: + return snapshot + for cache_key in [item for item in _context_usage_cache if item[0] == session_id and item[1] < key[1]]: + _context_usage_cache.pop(cache_key, None) + has_newer_cache = any(item[0] == session_id and item[1] > key[1] for item in _context_usage_cache) + if not has_newer_cache: + _context_usage_cache[key] = ( + time.monotonic() + _CONTEXT_USAGE_CACHE_TTL_SECONDS, + snapshot.model_copy(deep=True), + ) + return snapshot + finally: + async with _context_usage_cache_lock: + if _context_usage_inflight.get(key) is current_task: + _context_usage_inflight.pop(key, None) + + +async def _cached_context_usage_snapshot( + session_id: str, + *, + session: SessionModel, +) -> ContextUsageSnapshot: + key = _context_usage_cache_key(session_id, session) + now = time.monotonic() + + async with _context_usage_cache_lock: + cached = _context_usage_cache.get(key) + if cached and cached[0] > now: + return cached[1].model_copy(deep=True) + + task = _context_usage_inflight.get(key) + if task is None: + task = asyncio.create_task(_build_context_usage_for_cache(key, session_id, session=session)) + _context_usage_inflight[key] = task + + snapshot = await asyncio.shield(task) + return snapshot.model_copy(deep=True) + # ============================================================================= # Request/Response Models - API Compatible (camelCase) # ============================================================================= @@ -527,6 +584,8 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR ) for p in request.permission ] + + is_api_token_client = current_user.id == API_TOKEN_SERVICE_USER_ID session = await Session.create( project_id=project_id, @@ -534,7 +593,8 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR title=request.title, parent_id=request.parentID, permission=permission, - owner_user_id=current_user.id, + owner_user_id=None if is_api_token_client else current_user.id, + owner_username=None if is_api_token_client else current_user.username, **({"category": request.category} if request.category else {}), ) @@ -549,7 +609,7 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR "user_name": current_user.username, "username": current_user.username, "session_id": session.id, - "owner_user_id": current_user.id, + "owner_user_id": session.owner_user_id, "project_id": session.project_id, }, ) @@ -597,7 +657,7 @@ async def get_session_context_usage(sessionID: str, request: Request) -> Context detail=f"Session {sessionID} not found", ) _require_session_read_access(session, current_user) - snapshot = await build_context_usage_snapshot(sessionID, session=session) + snapshot = await _cached_context_usage_snapshot(sessionID, session=session) log_route_timing(log, "session.context_usage.complete", started_at=started_at, extra={ "sessionID": sessionID, "source": snapshot.source, diff --git a/flocks/server/routes/stats.py b/flocks/server/routes/stats.py new file mode 100644 index 000000000..af2e74766 --- /dev/null +++ b/flocks/server/routes/stats.py @@ -0,0 +1,150 @@ +""" +Lightweight WebUI summary stats. + +The home page needs counts, not the full agent/workflow/skill/tool payloads. +Keeping this aggregation on the server avoids serialising and parsing large +lists on every first paint. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, Literal + +from fastapi import APIRouter +from pydantic import BaseModel + +from flocks.agent.registry import Agent +from flocks.provider.provider import Provider +from flocks.server.routes.provider import list_providers +from flocks.server.routes.workflow import _list_workflows_from_fs, _migrate_storage_to_filesystem +from flocks.skill.skill import Skill +from flocks.task.manager import TaskManager +from flocks.tool.registry import ToolRegistry +from flocks.utils.log import Log + +router = APIRouter() +log = Log.create(service="stats-routes") + + +class CountResponse(BaseModel): + total: int + + +class TaskStatsResponse(BaseModel): + week: int + scheduledActive: int + + +class SystemHealthResponse(BaseModel): + status: Literal["healthy", "warning", "error"] + message: str + + +class SystemStatsResponse(BaseModel): + tasks: TaskStatsResponse + agents: CountResponse + workflows: CountResponse + skills: CountResponse + tools: CountResponse + models: CountResponse + system: SystemHealthResponse + + +async def _safe_count(name: str, loader: Callable[[], Awaitable[int]], failures: list[str]) -> int: + try: + return await loader() + except Exception as exc: + failures.append(name) + log.warning("stats.summary.source_failed", {"source": name, "error": str(exc)}) + return 0 + + +def _should_count_agent(agent: Any) -> bool: + if getattr(agent, "hidden", False): + return False + if getattr(agent, "mode", None) == "primary": + return True + tags = getattr(agent, "tags", None) + return not isinstance(tags, list) or "system" not in tags + + +async def _task_dashboard() -> dict[str, Any]: + return await TaskManager.dashboard() + + +async def _safe_dashboard(failures: list[str]) -> dict[str, Any]: + try: + return await _task_dashboard() + except Exception as exc: + failures.append("tasks") + log.warning("stats.summary.source_failed", {"source": "tasks", "error": str(exc)}) + return {} + + +async def _count_agents() -> int: + agents = await Agent.list() + return sum(1 for agent in agents if _should_count_agent(agent)) + + +async def _count_workflows() -> int: + await _migrate_storage_to_filesystem() + return await asyncio.to_thread(lambda: len(_list_workflows_from_fs())) + + +async def _count_skills() -> int: + skills = await Skill.all() + return sum( + 1 + for skill in skills + if getattr(skill, "category", None) != "system" + ) + + +async def _count_tools() -> int: + await ToolRegistry.init_async() + return len(ToolRegistry.all_tool_ids()) + + +async def _count_models() -> int: + await asyncio.to_thread(Provider._ensure_initialized) + providers = await list_providers() + connected = set(providers.connected or []) + return sum( + len(provider.models or {}) + for provider in providers.all + if provider.id in connected + ) + + +@router.get("/summary", response_model=SystemStatsResponse) +async def get_system_stats_summary() -> SystemStatsResponse: + failures: list[str] = [] + + # Agent and Skill cold loading now wait on their registries without + # blocking the event loop, so all independent summary sources can stay + # concurrent instead of paying a separate registry warm-up phase. + dashboard, agents, workflows, skills, tools, models = await asyncio.gather( + _safe_dashboard(failures), + _safe_count("agents", _count_agents, failures), + _safe_count("workflows", _count_workflows, failures), + _safe_count("skills", _count_skills, failures), + _safe_count("tools", _count_tools, failures), + _safe_count("models", _count_models, failures), + ) + + system_status: Literal["healthy", "warning"] = "warning" if failures else "healthy" + message = "部分统计加载失败" if failures else "所有服务运行正常" + + return SystemStatsResponse( + tasks=TaskStatsResponse( + week=int(dashboard.get("completed_week") or 0) + int(dashboard.get("failed_week") or 0), + scheduledActive=int(dashboard.get("scheduled_active") or 0), + ), + agents=CountResponse(total=agents), + workflows=CountResponse(total=workflows), + skills=CountResponse(total=skills), + tools=CountResponse(total=tools), + models=CountResponse(total=models), + system=SystemHealthResponse(status=system_status, message=message), + ) diff --git a/flocks/server/routes/tool.py b/flocks/server/routes/tool.py index 658bc0897..e217cbf3f 100644 --- a/flocks/server/routes/tool.py +++ b/flocks/server/routes/tool.py @@ -3,8 +3,10 @@ """ import asyncio +from dataclasses import dataclass +import threading import time -from typing import Annotated, List, Optional, Dict, Any +from typing import Annotated, List, Optional, Dict, Any, Literal, Sequence from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field @@ -15,6 +17,7 @@ from flocks.permission.next import DeniedError, PermissionNext from flocks.tool.registry import ( ToolRegistry, + ToolRefreshError, ToolInfo, ToolSchema, ToolResult, @@ -39,6 +42,7 @@ class ToolInfoResponse(BaseModel): source_name: Optional[str] = Field(None, description="Source detail, e.g. MCP server name or API module name") vendor: Optional[str] = Field(None, description="Manufacturer key for device tools (e.g. threatbook, qianxin, sangfor, qingteng)") parameters: List[Dict[str, Any]] = Field(default_factory=list, description="Tool parameters") + parameters_count: int = Field(0, description="Number of tool parameters") enabled: bool = Field(True, description="Effective enabled state (overlay applied, ANDed with API service flag)") enabled_default: bool = Field(True, description="Factory default from the YAML/registration source (no overlay)") enabled_customized: bool = Field(False, description="True if a user setting is recorded in flocks.json tool_settings") @@ -116,6 +120,24 @@ class BatchExecuteResponse(BaseModel): results: List[ToolExecuteResponse] = Field(..., description="Execution results") +class ToolListFacets(BaseModel): + """Facet counts for server-side tool list filtering.""" + category: Dict[str, int] = Field(default_factory=dict) + source: Dict[str, int] = Field(default_factory=dict) + source_groups: Dict[str, int] = Field(default_factory=dict) + source_name: Dict[str, int] = Field(default_factory=dict) + enabled: Dict[str, int] = Field(default_factory=dict) + + +class ToolListPageResponse(BaseModel): + """Paginated tool list response.""" + items: List[ToolInfoResponse] = Field(default_factory=list) + total: int = Field(0) + offset: int = Field(0) + limit: int = Field(25) + facets: ToolListFacets = Field(default_factory=ToolListFacets) + + # Helper: determine tool source _BUILTIN_CATEGORIES = { @@ -133,6 +155,63 @@ class BatchExecuteResponse(BaseModel): ) +@dataclass(frozen=True) +class ToolListIndexItem: + name: str + description: str + description_cn: Optional[str] + category: str + source: str + source_name: Optional[str] + vendor: Optional[str] + parameters_count: int + enabled: bool + enabled_default: bool + enabled_customized: bool + requires_confirmation: bool + + +_TOOL_SUMMARY_CACHE_TTL_SECONDS = 5.0 +_tool_summary_cache_lock = threading.Lock() +_tool_summary_cache_key: tuple[int, tuple[str, ...]] | None = None +_tool_summary_cache_expires_at = 0.0 +_tool_summary_cache_items: tuple[ToolListIndexItem, ...] = () + + +def _invalidate_tool_summary_cache() -> None: + """Clear the lightweight list snapshot used by ``GET /api/tools/page``.""" + global _tool_summary_cache_key, _tool_summary_cache_expires_at, _tool_summary_cache_items + with _tool_summary_cache_lock: + _tool_summary_cache_key = None + _tool_summary_cache_expires_at = 0.0 + _tool_summary_cache_items = () + + +def _tool_summary_cache_current_key() -> tuple[int, tuple[str, ...]]: + return ToolRegistry.snapshot_identity() + + +def _get_tool_summary_items() -> List[ToolListIndexItem]: + """Return cached, lightweight tool summaries for list filtering.""" + global _tool_summary_cache_key, _tool_summary_cache_expires_at, _tool_summary_cache_items + + now = time.monotonic() + cache_key = _tool_summary_cache_current_key() + with _tool_summary_cache_lock: + if ( + _tool_summary_cache_items + and _tool_summary_cache_key == cache_key + and now < _tool_summary_cache_expires_at + ): + return list(_tool_summary_cache_items) + + items = tuple(_build_tool_index_item(t) for t in ToolRegistry.list_tools()) + _tool_summary_cache_key = cache_key + _tool_summary_cache_expires_at = now + _TOOL_SUMMARY_CACHE_TTL_SECONDS + _tool_summary_cache_items = items + return list(items) + + def _get_tool_source(tool_info: ToolInfo) -> tuple: """ Determine tool source type and source name. @@ -175,12 +254,13 @@ def _get_tool_source(tool_info: ToolInfo) -> tuple: return "custom", None -def _build_tool_response(t: ToolInfo) -> ToolInfoResponse: +def _build_tool_response(t: ToolInfo, *, include_parameters: bool = True) -> ToolInfoResponse: """Build ToolInfoResponse with source info and overlay metadata.""" source, source_name = _get_tool_source(t) setting = ConfigWriter.get_tool_setting(t.name) or {} customized = "enabled" in setting enabled_default = _get_default_enabled(t) + parameters = [p.model_dump() for p in t.parameters] if include_parameters else [] return ToolInfoResponse( name=t.name, description=t.description, @@ -189,14 +269,142 @@ def _build_tool_response(t: ToolInfo) -> ToolInfoResponse: source=source, source_name=source_name, vendor=t.vendor, - parameters=[p.model_dump() for p in t.parameters], - enabled=_get_effective_tool_enabled(t), + parameters=parameters, + parameters_count=len(t.parameters), + enabled=_get_effective_tool_enabled(t, source=source, source_name=source_name), enabled_default=enabled_default, enabled_customized=customized, requires_confirmation=t.requires_confirmation, ) +def _build_tool_index_item(t: ToolInfo) -> ToolListIndexItem: + """Build the lightweight index row used by the paged list endpoint.""" + source, source_name = _get_tool_source(t) + setting = ConfigWriter.get_tool_setting(t.name) or {} + return ToolListIndexItem( + name=t.name, + description=t.description, + description_cn=t.description_cn, + category=t.category.value, + source=source, + source_name=source_name, + vendor=t.vendor, + parameters_count=len(t.parameters), + enabled=_get_effective_tool_enabled(t, source=source, source_name=source_name), + enabled_default=_get_default_enabled(t), + enabled_customized="enabled" in setting, + requires_confirmation=t.requires_confirmation, + ) + + +def _tool_index_item_to_response(item: ToolListIndexItem) -> ToolInfoResponse: + return ToolInfoResponse( + name=item.name, + description=item.description, + description_cn=item.description_cn, + category=item.category, + source=item.source, + source_name=item.source_name, + vendor=item.vendor, + parameters=[], + parameters_count=item.parameters_count, + enabled=item.enabled, + enabled_default=item.enabled_default, + enabled_customized=item.enabled_customized, + requires_confirmation=item.requires_confirmation, + ) + + +def _split_csv_filter(value: Optional[str]) -> Optional[set[str]]: + if value is None: + return None + parts = {part.strip() for part in value.split(",") if part and part.strip()} + return parts or None + + +def _matches_tool_query(tool: ToolInfoResponse | ToolListIndexItem, query: str) -> bool: + if not query: + return True + haystack = " ".join([ + tool.name, + tool.description, + tool.description_cn or "", + tool.category, + tool.source, + tool.source_name or "", + tool.vendor or "", + ]).lower() + return query in haystack + + +def _build_tool_facets(items: Sequence[ToolInfoResponse | ToolListIndexItem]) -> ToolListFacets: + facets = ToolListFacets() + for item in items: + facets.category[item.category] = facets.category.get(item.category, 0) + 1 + facets.source[item.source] = facets.source.get(item.source, 0) + 1 + source_name = item.source_name or "Flocks" + facets.source_name[source_name] = facets.source_name.get(source_name, 0) + 1 + enabled_key = str(item.enabled).lower() + facets.enabled[enabled_key] = facets.enabled.get(enabled_key, 0) + 1 + return facets + + +def _build_source_group_counts( + items: Sequence[ToolInfoResponse | ToolListIndexItem], +) -> Dict[str, int]: + groups: Dict[str, set[str]] = {} + for item in items: + if not item.source_name: + continue + groups.setdefault(item.source, set()).add(item.source_name) + return {source: len(source_names) for source, source_names in groups.items()} + + +def _sort_tool_items( + items: Sequence[ToolInfoResponse | ToolListIndexItem], + sort_by: Literal["category", "source", "source_name", "enabled", "name"], + sort_dir: Literal["asc", "desc"], +) -> List[ToolInfoResponse | ToolListIndexItem]: + reverse = sort_dir == "desc" + + def sort_key(item: ToolInfoResponse): + if sort_by == "enabled": + return 0 if item.enabled else 1 + if sort_by == "source_name": + return (item.source_name or "Flocks").lower() + return getattr(item, sort_by) + + return sorted(items, key=sort_key, reverse=reverse) + + +def _filter_tool_items( + items: Sequence[ToolInfoResponse | ToolListIndexItem], + *, + category_filter: Optional[set[str]], + source_filter: Optional[set[str]], + source_name_filter: Optional[set[str]], + enabled_filter: Optional[set[str]], + query: str, + include_category: bool = True, + include_source: bool = True, + include_source_name: bool = True, + include_enabled: bool = True, +) -> List[ToolInfoResponse | ToolListIndexItem]: + result = list(items) + if include_category and category_filter: + result = [tool for tool in result if tool.category in category_filter] + if include_source and source_filter: + result = [tool for tool in result if tool.source in source_filter] + if include_source_name and source_name_filter: + result = [tool for tool in result if (tool.source_name or "Flocks") in source_name_filter] + if include_enabled and enabled_filter: + result = [tool for tool in result if str(tool.enabled).lower() in enabled_filter] + if query: + result = [tool for tool in result if _matches_tool_query(tool, query)] + return result + + def _requires_session_backed_context(tool_info: ToolInfo) -> bool: """Return True when a tool must be anchored to a real session/message context.""" source, _ = _get_tool_source(tool_info) @@ -413,9 +621,15 @@ def _service_allows_enable(t: ToolInfo) -> bool: return bool(svc.get("enabled", False)) -def _get_effective_tool_enabled(tool_info: ToolInfo) -> bool: +def _get_effective_tool_enabled( + tool_info: ToolInfo, + *, + source: Optional[str] = None, + source_name: Optional[str] = None, +) -> bool: """Compute tool enabled state without mutating the registry object.""" - source, source_name = _get_tool_source(tool_info) + if source is None: + source, source_name = _get_tool_source(tool_info) if source not in ("api", "device") or not source_name: return tool_info.enabled from flocks.server.routes.provider import _get_api_service_enabled @@ -505,6 +719,112 @@ async def list_tools( return result +@router.get( + "/page", + response_model=ToolListPageResponse, + summary="List tools with server-side pagination", +) +async def list_tools_page( + category: Optional[str] = None, + source: Optional[str] = None, + source_name: Optional[str] = None, + enabled: Optional[str] = None, + q: Optional[str] = None, + sort_by: Literal["category", "source", "source_name", "enabled", "name"] = "source", + sort_dir: Literal["asc", "desc"] = "asc", + offset: int = Query(0, ge=0), + limit: int = Query(25, ge=1, le=200), +): + """ + List tools with server-side search/filter/sort/pagination. + + The paged list omits full parameter definitions and exposes + `parameters_count`; call `GET /api/tools/{tool_name}` for details. + """ + started_at = time.perf_counter() + await ToolRegistry.init_async() + + category_filter = _split_csv_filter(category) + source_filter = _split_csv_filter(source) + source_name_filter = _split_csv_filter(source_name) + enabled_filter = _split_csv_filter(enabled) + query = (q or "").strip().lower() + + all_items = _get_tool_summary_items() + + result = _filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + ) + source_facet_items = _filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_source=False, + ) + facets = ToolListFacets( + category=_build_tool_facets(_filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_category=False, + )).category, + source=_build_tool_facets(source_facet_items).source, + source_groups=_build_source_group_counts(source_facet_items), + source_name=_build_tool_facets(_filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_source_name=False, + )).source_name, + enabled=_build_tool_facets(_filter_tool_items( + all_items, + category_filter=category_filter, + source_filter=source_filter, + source_name_filter=source_name_filter, + enabled_filter=enabled_filter, + query=query, + include_enabled=False, + )).enabled, + ) + result = _sort_tool_items(result, sort_by, sort_dir) + total = len(result) + items = [ + _tool_index_item_to_response(item) if isinstance(item, ToolListIndexItem) else item + for item in result[offset:offset + limit] + ] + + log_route_timing(log, "tools.list_page.complete", started_at=started_at, extra={ + "count": len(items), + "total": total, + "category": category, + "source": source, + "q": q, + "offset": offset, + "limit": limit, + }) + return ToolListPageResponse( + items=items, + total=total, + offset=offset, + limit=limit, + facets=facets, + ) + + @router.get( "/{tool_name}", response_model=ToolInfoResponse, @@ -617,10 +937,12 @@ async def update_tool( # The in-memory ToolInfo.enabled reflects global state. Per-device # enabled=True is not a supported override; switch-on means clear the # per-device disable and follow the global tool setting. + _invalidate_tool_summary_cache() return _build_tool_response(tool.info) # --- Global mode (original behaviour) --- _set_global_tool_enabled(tool, desired) + _invalidate_tool_summary_cache() return _build_tool_response(tool.info) @@ -651,6 +973,7 @@ async def reset_tool_setting(tool_name: str, _admin: object = Depends(require_ad default = _get_default_enabled(tool.info) new_enabled = default and _service_allows_enable(tool.info) tool.info.enabled = new_enabled + _invalidate_tool_summary_cache() log.info("tool.setting.reset", { "name": tool_name, @@ -816,9 +1139,11 @@ async def execute_batch(request: BatchExecuteRequest): class RefreshResponse(BaseModel): """Tool refresh response""" - status: str = Field(..., description="Operation status") + status: Literal["success", "partial", "error"] = Field(..., description="Operation status") tool_count: int = Field(..., description="Total registered tool count after refresh") message: str = Field("", description="Human-readable summary") + stages: Dict[str, Literal["success", "error"]] = Field(default_factory=dict) + errors: List[str] = Field(default_factory=list) @router.post( @@ -837,38 +1162,49 @@ async def refresh_tools(_admin: object = Depends(require_admin)): ToolRegistry.init() errors: list[str] = [] + stages: dict[str, Literal["success", "error"]] = {} - # 1. Reload generated tools (generated/) - try: - ToolRegistry.refresh_dynamic_tools() - except Exception as e: - log.error("tools.refresh.dynamic_error", {"error": str(e)}) - errors.append(f"dynamic: {e}") - - # 2. Reload plugin tools (api/, python/) — unregisters stale entries first - try: - ToolRegistry.refresh_plugin_tools() - except Exception as e: - log.error("tools.refresh.plugin_error", {"error": str(e)}) - errors.append(f"plugin: {e}") - + for stage, refresh in ( + ("dynamic", ToolRegistry.refresh_dynamic_tools), + ("plugin", ToolRegistry.refresh_plugin_tools), + ): + try: + refresh() + stages[stage] = "success" + except ToolRefreshError as exc: + stages[stage] = "error" + stage_errors = [f"{stage}: {error}" for error in exc.errors] + errors.extend(stage_errors) + log.error(f"tools.refresh.{stage}_error", {"errors": exc.errors}) + except Exception as exc: + stages[stage] = "error" + errors.append(f"{stage}: {exc}") + log.error(f"tools.refresh.{stage}_error", {"error": str(exc)}) + + _invalidate_tool_summary_cache() tool_count = len(ToolRegistry.all_tool_ids()) log_route_timing(log, "tools.refresh.done", started_at=started_at, extra={ "tool_count": tool_count, "errors": len(errors), }) - if errors: - return RefreshResponse( - status="partial", - tool_count=tool_count, - message=f"Refreshed with {len(errors)} error(s): {'; '.join(errors)}", - ) + failed_stages = sum(status == "error" for status in stages.values()) + if failed_stages == 0: + outcome: Literal["success", "partial", "error"] = "success" + message = f"All tools refreshed successfully ({tool_count} tools registered)" + elif failed_stages == len(stages): + outcome = "error" + message = f"Tool refresh failed: {'; '.join(errors)}" + else: + outcome = "partial" + message = f"Tool refresh completed with errors: {'; '.join(errors)}" return RefreshResponse( - status="success", + status=outcome, tool_count=tool_count, - message=f"All tools refreshed successfully ({tool_count} tools registered)", + message=message, + stages=stages, + errors=errors, ) @@ -1082,6 +1418,7 @@ async def create_tool(request: CreateToolRequest, _admin: object = Depends(requi ToolRegistry.register(tool) if tool.info.name not in ToolRegistry._plugin_tool_names: ToolRegistry._plugin_tool_names.append(tool.info.name) + _invalidate_tool_summary_cache() except Exception as e: log.error("tool.create.register_error", {"error": str(e), "name": request.name}) raise HTTPException( @@ -1155,12 +1492,14 @@ async def update_plugin_tool(name: str, request: UpdateToolRequest, _admin: obje if not tool.info.source: tool.info.source = "plugin_yaml" ToolRegistry.register(tool) + _invalidate_tool_summary_cache() return _build_tool_response(tool.info) except Exception as e: log.error("tool.update.reload_error", {"error": str(e), "name": name}) existing = ToolRegistry.get(name) if existing: + _invalidate_tool_summary_cache() return _build_tool_response(existing.info) raise HTTPException(status_code=500, detail="Tool updated but reload failed") @@ -1200,12 +1539,38 @@ async def delete_tool(name: str, _admin: object = Depends(require_admin)): detail=f"Plugin tool not found: {name}", ) - # Refresh plugin tools so stale decorator-registered python tools are removed too. - ToolRegistry.refresh_plugin_tools() + # The file deletion is already committed at this point. Keep the Hub + # installation record consistent even when the in-memory refresh fails. + cleanup_errors: List[str] = [] + try: + ToolRegistry.refresh_plugin_tools() + except Exception as e: + refresh_error = str(e) + cleanup_errors.append(f"registry refresh: {refresh_error}") + log.warning("tool.delete.refresh_failed", { + "error": refresh_error, + "name": name, + }) + _invalidate_tool_summary_cache() from flocks.hub import local as hub_local - hub_local.remove_installed_record("tool", name) + try: + hub_local.remove_installed_record("tool", name) + except Exception as e: + hub_error = str(e) + cleanup_errors.append(f"Hub record cleanup: {hub_error}") + log.warning("tool.delete.hub_cleanup_failed", { + "error": hub_error, + "name": name, + }) + + if cleanup_errors: + return { + "status": "partial", + "message": f"Tool {name} deleted, but cleanup was incomplete", + "errors": cleanup_errors, + } return {"status": "success", "message": f"Tool {name} deleted"} @@ -1239,6 +1604,7 @@ async def reload_tool(name: str, _admin: object = Depends(require_admin)): if not tool.info.source: tool.info.source = "plugin_yaml" ToolRegistry.register(tool) + _invalidate_tool_summary_cache() log.info("tool.reloaded", {"name": name}) return _build_tool_response(tool.info) except Exception as e: diff --git a/flocks/server/routes/update.py b/flocks/server/routes/update.py index 9836dfe0b..d559fbe72 100644 --- a/flocks/server/routes/update.py +++ b/flocks/server/routes/update.py @@ -4,7 +4,8 @@ import asyncio import json -from typing import Literal +import time +from typing import Annotated, Literal from fastapi import APIRouter, Query, Request from fastapi.responses import StreamingResponse @@ -17,6 +18,72 @@ router = APIRouter() log = Log.create(service="update-routes") +_UPDATE_CHECK_CACHE_TTL_SECONDS = 600.0 +_UPDATE_CHECK_ERROR_CACHE_TTL_SECONDS = 60.0 +_update_check_cache: dict[tuple[str, str], tuple[float, VersionInfo]] = {} +_update_check_inflight: dict[tuple[str, str], asyncio.Task[VersionInfo]] = {} +_update_check_lock = asyncio.Lock() + + +def _update_cache_key(locale: str | None, edition: str) -> tuple[str, str]: + return (locale or "", edition) + + +def clear_update_check_cache() -> None: + _update_check_cache.clear() + _update_check_inflight.clear() + + +async def _run_update_check_for_cache( + key: tuple[str, str], + *, + locale: str | None, + edition: Literal["flocks", "flockspro"], +) -> VersionInfo: + current_task = asyncio.current_task() + try: + info = await check_update(locale=locale, force_console_manifest=(edition == "flockspro")) + ttl = _UPDATE_CHECK_ERROR_CACHE_TTL_SECONDS if info.error else _UPDATE_CHECK_CACHE_TTL_SECONDS + async with _update_check_lock: + if _update_check_inflight.get(key) is current_task: + _update_check_cache[key] = (time.monotonic() + ttl, info.model_copy(deep=True)) + return info + finally: + async with _update_check_lock: + if _update_check_inflight.get(key) is current_task: + _update_check_inflight.pop(key, None) + + +async def _check_update_cached( + *, + locale: str | None, + edition: Literal["flocks", "flockspro"], + force: bool, +) -> VersionInfo: + key = _update_cache_key(locale, edition) + now = time.monotonic() + task: asyncio.Task[VersionInfo] + async with _update_check_lock: + if not force: + cached = _update_check_cache.get(key) + if cached and cached[0] > now: + return cached[1].model_copy(deep=True) + + # A forced check bypasses only the completed-result cache. Reuse any + # same-key request already reaching the upstream service so concurrent + # manual refreshes cannot fan out into an update-check storm. + existing_task = _update_check_inflight.get(key) + if existing_task is None: + task = asyncio.create_task( + _run_update_check_for_cache(key, locale=locale, edition=edition) + ) + _update_check_inflight[key] = task + else: + task = existing_task + + info = await asyncio.shield(task) + return info.model_copy(deep=True) + @router.get( "/check", @@ -33,10 +100,13 @@ async def check_version( default="flocks", description="Version channel to check. flockspro checks the Console Pro bundle manifest.", ), + force: Annotated[bool, Query( + description="Bypass the short server-side cache for an explicit manual check.", + )] = False, ) -> VersionInfo: if edition == "flockspro": require_admin(request) - return await check_update(locale=locale, force_console_manifest=(edition == "flockspro")) + return await _check_update_cached(locale=locale, edition=edition, force=force) @router.post( diff --git a/flocks/server/static_webui.py b/flocks/server/static_webui.py index dede61320..20c70a0d6 100644 --- a/flocks/server/static_webui.py +++ b/flocks/server/static_webui.py @@ -14,6 +14,12 @@ _ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" _STATIC_CACHE_CONTROL = "no-cache" _FINGERPRINT_RE = re.compile(r"(?:^|[.-])[0-9a-f]{8,}(?:[.-]|$)", re.IGNORECASE) +_STATIC_MEDIA_TYPES = { + ".css": "text/css", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".wasm": "application/wasm", +} _PROTECTED_PREFIXES = ( "/api", "/event", @@ -107,7 +113,7 @@ def _resolve_existing_static_file(dist_dir: Path, path: str) -> Path | None: def _file_response(path: Path, *, cache_control: str) -> FileResponse: headers = {"Cache-Control": cache_control} - return FileResponse(path, headers=headers) + return FileResponse(path, headers=headers, media_type=_STATIC_MEDIA_TYPES.get(path.suffix.lower())) def _cache_control_for_file(path: str, file_path: Path) -> str: diff --git a/flocks/skill/skill.py b/flocks/skill/skill.py index 2bdb8f3cc..db5f2e1e5 100644 --- a/flocks/skill/skill.py +++ b/flocks/skill/skill.py @@ -6,6 +6,7 @@ """ import json +import asyncio import os import glob import re @@ -204,6 +205,45 @@ class Skill: """ _cache: Optional[Dict[str, SkillInfo]] = None + _cache_lock = threading.Lock() + _cache_generation = 0 + _cache_loads: Dict[int, threading.Event] = {} + + @classmethod + def _all_sync(cls) -> List[SkillInfo]: + """Discover skills once per cache generation without stale refills.""" + while True: + with cls._cache_lock: + if cls._cache is not None: + return list(cls._cache.values()) + generation = cls._cache_generation + completion = cls._cache_loads.get(generation) + should_discover = completion is None + if completion is None: + completion = threading.Event() + cls._cache_loads[generation] = completion + + if not should_discover: + completion.wait() + continue + + try: + discovered = cls._discover() + except BaseException: + with cls._cache_lock: + cls._cache_loads.pop(generation, None) + completion.set() + raise + + with cls._cache_lock: + if generation == cls._cache_generation and cls._cache is None: + cls._cache = discovered + cls._cache_loads.pop(generation, None) + completion.set() + if generation == cls._cache_generation and cls._cache is not None: + return list(cls._cache.values()) + # The cache was invalidated while discovery was running. Repeat + # against the new generation instead of publishing stale data. @classmethod def _parse_skill_md(cls, filepath: str, source: Optional[str] = None) -> Optional[SkillInfo]: @@ -460,10 +500,7 @@ async def all(cls) -> List[SkillInfo]: Returns: List of all discovered skills """ - if cls._cache is None: - cls._cache = cls._discover() - - return list(cls._cache.values()) + return await asyncio.to_thread(cls._all_sync) @classmethod async def list_enabled(cls) -> List[SkillInfo]: @@ -495,10 +532,8 @@ async def get(cls, name: str) -> Optional[SkillInfo]: Returns: SkillInfo or None if not found """ - if cls._cache is None: - cls._cache = cls._discover() - - skill = cls._cache.get(name) + skills = await cls.all() + skill = next((item for item in skills if item.name == name), None) if not skill: return None return skill @@ -506,7 +541,9 @@ async def get(cls, name: str) -> Optional[SkillInfo]: @classmethod def clear_cache(cls) -> None: """Clear the skill cache (for testing or forced refresh)""" - cls._cache = None + with cls._cache_lock: + cls._cache = None + cls._cache_generation += 1 log.info("skill.cache.cleared") @classmethod diff --git a/flocks/storage/storage.py b/flocks/storage/storage.py index 4a1b1ed41..debf1bf16 100644 --- a/flocks/storage/storage.py +++ b/flocks/storage/storage.py @@ -8,6 +8,7 @@ import os import shutil import subprocess +import uuid from contextlib import asynccontextmanager from pathlib import Path @@ -71,6 +72,7 @@ class Storage: _log = Log.create(service="storage") _db_path: Optional[Path] = None _initialized = False + _db_identity: Optional[Tuple[int, int]] = None # PID of the process that called ``init()``. Used by ``_ensure_init`` to # detect ``fork()`` (uvicorn ``--reload`` / multiprocessing workers) and # re-initialise per-process state so the parent's open SQLite file @@ -149,6 +151,34 @@ def get_db_path(cls) -> Path: data_dir = Config.get_data_path() return data_dir / "flocks.db" + @staticmethod + def _file_identity(db_path: Path) -> Optional[Tuple[int, int]]: + """Return the filesystem identity used to detect an online DB replacement.""" + + try: + stat_result = db_path.stat() + except OSError: + return None + return stat_result.st_dev, stat_result.st_ino + + @classmethod + def _assert_active_db_identity(cls, db_path: Path) -> None: + """Reject direct connections after the active primary DB was moved or replaced.""" + + if ( + not cls._initialized + or cls._init_pid != os.getpid() + or cls._db_path is None + or cls._db_identity is None + or db_path.resolve() != cls._db_path.resolve() + ): + return + if cls._file_identity(db_path) != cls._db_identity: + raise StorageError( + f"The active SQLite database changed on disk: {db_path}. " + "Refusing to open a connection to a different file identity; restart Flocks." + ) + @classmethod def get_workflow_db_path(cls) -> Path: """Return the SQLite database path for workflow-domain KV data.""" @@ -264,9 +294,10 @@ def _quarantine_corrupt_db(cls, db_path: Path) -> Optional[Path]: Returns the new location of the main file so callers can surface it in logs or recovery instructions. Returns ``None`` when there was - nothing to quarantine (no main file present), or when the rename - failed — in which case the caller must propagate the original error - because we cannot safely recreate the file in place. + nothing to quarantine (no main file present), or when the main file + and its data-bearing WAL cannot be kept together. In that case the + caller must propagate the original error because recovery without the + matching WAL could silently omit committed transactions. """ db_path = Path(db_path) if not db_path.exists(): @@ -276,12 +307,18 @@ def _quarantine_corrupt_db(cls, db_path: Path) -> Optional[Path]: timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S") suffix = f".corrupt.{timestamp}" + sidecar_suffixes = ("-wal", "-shm") new_main = db_path.with_name(db_path.name + suffix) # Avoid collision if multiple corruptions happen within the same - # second — fall back to a counter suffix. + # second — fall back to a counter suffix. Reserve the paired WAL/SHM + # names as well so every quarantined main file keeps the exact sidecar + # names SQLite expects: ``-wal`` and ``-shm``. counter = 1 - while new_main.exists(): + while new_main.exists() or any( + new_main.with_name(new_main.name + sidecar_suffix).exists() + for sidecar_suffix in sidecar_suffixes + ): new_main = db_path.with_name(f"{db_path.name}{suffix}.{counter}") counter += 1 @@ -297,20 +334,34 @@ def _quarantine_corrupt_db(cls, db_path: Path) -> Optional[Path]: ) return None - for sidecar_name in (f"{db_path.name}-wal", f"{db_path.name}-shm"): - side_path = db_path.with_name(sidecar_name) + for sidecar_suffix in sidecar_suffixes: + side_path = db_path.with_name(db_path.name + sidecar_suffix) if not side_path.exists(): continue try: - side_path.rename(side_path.with_name(sidecar_name + suffix)) + side_path.rename(new_main.with_name(new_main.name + sidecar_suffix)) except OSError as exc: - cls._log.warn( + log_method = cls._log.error if sidecar_suffix == "-wal" else cls._log.warn + log_method( "storage.quarantine.sidecar_rename_failed", { "path": str(side_path), "error": str(exc), }, ) + if sidecar_suffix == "-wal": + try: + new_main.rename(db_path) + except OSError as rollback_exc: + cls._log.error( + "storage.quarantine.rollback_failed", + { + "original_path": str(db_path), + "quarantined_path": str(new_main), + "error": str(rollback_exc), + }, + ) + return None cls._log.error( "storage.corruption.quarantined", @@ -353,15 +404,37 @@ async def _assert_integrity_check_ok(cls, db_path: Path) -> None: f"PRAGMA integrity_check failed for {db_path}: {detail}" ) + @classmethod + def _sqlite_recover_capability_sync(cls, sqlite_bin: str) -> tuple[bool, str]: + """Return whether *sqlite_bin* was built with support for ``.recover``.""" + + try: + completed = subprocess.run( + [ + sqlite_bin, + ":memory:", + "SELECT sqlite_compileoption_used('ENABLE_DBPAGE_VTAB');", + ], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + timeout=cls._sqlite_recover_timeout_s, + ) + except Exception as exc: + return False, str(exc) + + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + return False, detail or f"capability probe exited with {completed.returncode}" + if completed.stdout.strip() != "1": + return False, "SQLite CLI was built without SQLITE_ENABLE_DBPAGE_VTAB" + return True, "" + @classmethod def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> Optional[Path]: - """Try SQLite's lightweight `.recover` and install the recovered DB. + """Recover from an isolated copy without allowing SQLite to mutate evidence.""" - This intentionally avoids the heavier raw-page/WAL reconstruction script. - It handles the common case where SQLite can still scan a malformed DB - enough to emit recoverable SQL. Failure is non-fatal; callers fall back - to bootstrapping an empty database. - """ sqlite_bin = shutil.which("sqlite3") if sqlite_bin is None: cls._log.warn( @@ -374,6 +447,79 @@ def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> ) return None + supported, reason = cls._sqlite_recover_capability_sync(sqlite_bin) + if not supported: + cls._log.warn( + "storage.corruption.recovery.skipped", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "reason": f"sqlite3 .recover is unavailable: {reason}", + }, + ) + return None + + working_path = quarantined_path.with_name( + f".{quarantined_path.name}.recovery-source-{uuid.uuid4().hex}" + ) + working_files = [ + working_path, + working_path.with_name(working_path.name + "-wal"), + working_path.with_name(working_path.name + "-shm"), + working_path.with_name(working_path.name + "-journal"), + ] + try: + shutil.copy2(quarantined_path, working_path) + working_path.chmod(0o600) + for suffix in ("-wal", "-shm"): + evidence_sidecar = quarantined_path.with_name(quarantined_path.name + suffix) + if not evidence_sidecar.exists(): + continue + working_sidecar = working_path.with_name(working_path.name + suffix) + shutil.copy2(evidence_sidecar, working_sidecar) + working_sidecar.chmod(0o600) + return cls._try_sqlite_recover_working_copy_sync( + working_path, + quarantined_path, + target_path, + sqlite_bin, + ) + except OSError as exc: + cls._log.warn( + "storage.corruption.recovery.failed", + { + "db_path": str(target_path), + "quarantined_path": str(quarantined_path), + "stage": "copy_evidence", + "error": str(exc), + }, + ) + return None + finally: + for path in working_files: + try: + path.unlink(missing_ok=True) + except OSError as exc: + cls._log.warn( + "storage.corruption.recovery.temp_cleanup_failed", + {"path": str(path), "error": str(exc)}, + ) + + @classmethod + def _try_sqlite_recover_working_copy_sync( + cls, + working_path: Path, + quarantined_path: Path, + target_path: Path, + sqlite_bin: str, + ) -> Optional[Path]: + """Try SQLite's lightweight `.recover` and install the recovered DB. + + This intentionally avoids the heavier raw-page/WAL reconstruction script. + It handles the common case where SQLite can still scan a malformed DB + enough to emit recoverable SQL. Failure is non-fatal; callers fall back + to bootstrapping an empty database. + """ recovered_path = target_path.with_name(target_path.name + ".recovered") sql_path = target_path.with_name(target_path.name + ".recover.sql") for path in (recovered_path, sql_path): @@ -383,8 +529,13 @@ def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> pass try: + lost_and_found_table = f"flocks_recovery_lost_{uuid.uuid4().hex}" completed = subprocess.run( - [sqlite_bin, str(quarantined_path), ".recover"], + [ + sqlite_bin, + str(working_path), + f".recover --lost-and-found {lost_and_found_table}", + ], check=False, capture_output=True, text=True, @@ -405,7 +556,7 @@ def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> recover_sql = completed.stdout or "" sql_path.write_text(recover_sql, encoding="utf-8") - if completed.returncode != 0 and not recover_sql.strip(): + if completed.returncode != 0: cls._log.warn( "storage.corruption.recovery.failed", { @@ -493,6 +644,42 @@ def _try_sqlite_recover_sync(cls, quarantined_path: Path, target_path: Path) -> ) return target_path + @staticmethod + async def _wait_for_recovery_worker_on_cancel( + worker: "asyncio.Task[Optional[Path]]", + ) -> Optional[Path]: + """Do not let a cancelled coroutine leave a recovery thread mutating files.""" + + try: + return await asyncio.shield(worker) + except asyncio.CancelledError as cancelled: + while not worker.done(): + try: + await asyncio.shield(worker) + except asyncio.CancelledError: + continue + except BaseException: + break + if worker.done() and not worker.cancelled(): + try: + worker.result() + except BaseException: + pass + raise cancelled + + @classmethod + async def _run_sqlite_recover_async( + cls, + quarantined_path: Path, + target_path: Path, + ) -> Optional[Path]: + """Run blocking sqlite recovery without outliving a cancelled startup task.""" + + worker = asyncio.create_task( + asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined_path, target_path) + ) + return await cls._wait_for_recovery_worker_on_cancel(worker) + @classmethod async def recover_corrupt_db( cls, @@ -515,6 +702,26 @@ async def recover_corrupt_db( if generation is not None and generation != cls._corruption_recovery_generation: return False + is_primary_db = cls._db_path == db_path or db_path.name == "flocks.db" + is_live_process = ( + cls._initialized + and cls._init_pid is not None + and cls._init_pid == os.getpid() + ) + if is_primary_db and is_live_process: + cls._log.error( + "storage.corruption.online_recovery_refused", + { + "db_path": str(db_path), + "action": action, + "hint": "Restart Flocks so recovery runs before request connections open.", + }, + ) + raise StorageError( + "Refusing to replace the primary SQLite database while Flocks is running; " + "restart Flocks to recover it safely." + ) from exc + cls._log.error( "storage.corruption.detected", { @@ -527,15 +734,16 @@ async def recover_corrupt_db( quarantined = cls._quarantine_corrupt_db(db_path) if quarantined is None: raise exc - await asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined, db_path) + await cls._run_sqlite_recover_async(quarantined, db_path) if cls._db_path == db_path: cls._initialized = False cls._init_pid = None + cls._db_identity = None if reinitialize is not None: await reinitialize() - elif cls._db_path == db_path or db_path.name == "flocks.db": + elif is_primary_db: await cls.init(db_path) cls._corruption_recovery_generation += 1 @@ -557,19 +765,29 @@ async def _run_with_corruption_recovery( db_path: Path, action: str, ) -> R: - generation = cls._corruption_recovery_generation + """Run a request operation without replacing its SQLite file in-place. + + Runtime corruption is deliberately deferred to the next process start, + where no request handlers or persistent connections can still reference + the old inode. Primary-DB online recovery is explicitly refused. + """ + try: return await operation() except Exception as exc: if not cls._is_db_corruption_error(exc): raise - await cls.recover_corrupt_db( - db_path, - action=action, - exc=exc, - generation=generation, + cls._log.error( + "storage.corruption.deferred_to_restart", + { + "db_path": str(db_path), + "action": action, + "error": str(exc), + "error_type": type(exc).__name__, + "hint": "Restart Flocks to run startup database recovery safely.", + }, ) - return await operation() + raise @classmethod def _is_sqlite_busy_error(cls, exc: Exception) -> bool: @@ -666,9 +884,11 @@ async def _run_write_with_retry( async def connect(cls, db_path: Optional[Path] = None) -> AsyncIterator[aiosqlite.Connection]: """Open a configured async SQLite connection for the active storage DB.""" target = Path(db_path) if db_path is not None else cls.get_db_path() + cls._assert_active_db_identity(target) target.parent.mkdir(parents=True, exist_ok=True) conn = await aiosqlite.connect(target, timeout=cls._sqlite_timeout_s) try: + cls._assert_active_db_identity(target) await cls.configure_connection(conn) yield conn finally: @@ -678,10 +898,16 @@ async def connect(cls, db_path: Optional[Path] = None) -> AsyncIterator[aiosqlit def connect_sync(cls, db_path: Optional[Path] = None) -> sqlite3.Connection: """Open a configured sync SQLite connection for the active storage DB.""" target = Path(db_path) if db_path is not None else cls.get_db_path() + cls._assert_active_db_identity(target) target.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(target, timeout=cls._sqlite_timeout_s) - conn.row_factory = sqlite3.Row - return cls.configure_sync_connection(conn) + try: + cls._assert_active_db_identity(target) + conn.row_factory = sqlite3.Row + return cls.configure_sync_connection(conn) + except BaseException: + conn.close() + raise @classmethod def register_ddl(cls, ddl: DDLScript) -> None: @@ -815,13 +1041,59 @@ async def init(cls, db_path: Optional[Path] = None) -> None: db_path = data_dir / "flocks.db" db_path = Path(db_path) + current_pid = os.getpid() + same_process = cls._init_pid is None or cls._init_pid == current_pid + if cls._initialized and same_process and cls._db_path is not None: + if cls._db_path != db_path: + # An explicit path switch is allowed for tests and embedded + # callers, but the process-owned persistent binding handle + # must not silently remain attached to the previous DB. + from flocks.channel.inbound.session_binding import close_binding_db + + await close_binding_db() + cls._initialized = False + cls._init_pid = None + cls._db_identity = None + elif not db_path.exists(): + from flocks.channel.inbound.session_binding import close_binding_db + + await close_binding_db() + cls._log.error( + "storage.live_path_disappeared", + { + "db_path": str(db_path), + "hint": "Restart Flocks after restoring or recovering the database file.", + }, + ) + raise StorageError( + f"The active SQLite database disappeared: {db_path}. " + "Refusing to create a second database while existing handles may reference " + "the old file; restart Flocks." + ) + elif ( + cls._db_identity is not None + and cls._file_identity(db_path) != cls._db_identity + ): + from flocks.channel.inbound.session_binding import close_binding_db + + await close_binding_db() + cls._log.error( + "storage.live_path_replaced", + { + "db_path": str(db_path), + "hint": "Restart Flocks; never replace SQLite files while it is running.", + }, + ) + raise StorageError( + f"The active SQLite database was replaced on disk: {db_path}. " + "Refusing to mix connections to different file identities; restart Flocks." + ) + # Tests and short-lived processes may initialize Storage against a # temporary database that later disappears. We also force a # reinit after ``fork()`` (detected via PID mismatch) to avoid the # child silently reusing the parent's open SQLite handle. - current_pid = os.getpid() same_path = cls._db_path == db_path and db_path.exists() - same_process = cls._init_pid is None or cls._init_pid == current_pid if cls._initialized and same_path and same_process: return @@ -879,7 +1151,7 @@ async def init(cls, db_path: Optional[Path] = None) -> None: quarantined = cls._quarantine_corrupt_db(cls._db_path) if quarantined is None: raise - await asyncio.to_thread(cls._try_sqlite_recover_sync, quarantined, cls._db_path) + await cls._run_sqlite_recover_async(quarantined, cls._db_path) await cls._bootstrap_schema() # Drain any residual WAL frames left by the previous process so the @@ -893,6 +1165,7 @@ async def init(cls, db_path: Optional[Path] = None) -> None: cls._log.warn("storage.startup_checkpoint.failed", {"error": str(exc)}) cls._init_pid = os.getpid() + cls._db_identity = cls._file_identity(cls._db_path) cls._initialized = True cls._log.info( "storage.initialized", @@ -979,6 +1252,19 @@ async def shutdown(cls) -> None: """ if not cls._initialized: return + if ( + cls._db_path is not None + and cls._db_identity is not None + and cls._file_identity(cls._db_path) != cls._db_identity + ): + cls._log.warn( + "storage.shutdown.checkpoint.skipped_replaced_path", + {"db_path": str(cls._db_path)}, + ) + cls._initialized = False + cls._init_pid = None + cls._db_identity = None + return attempts = max(int(cls._shutdown_checkpoint_attempts), 1) backoff = max(float(cls._shutdown_checkpoint_backoff_s), 0.0) @@ -1046,6 +1332,7 @@ async def shutdown(cls) -> None: finally: cls._initialized = False cls._init_pid = None + cls._db_identity = None @classmethod async def _bootstrap_schema(cls) -> None: @@ -1197,8 +1484,20 @@ async def _ensure_init(cls) -> None: ) cls._initialized = False cls._init_pid = None + cls._db_identity = None - if not cls._initialized or cls._db_path is None or not cls._db_path.exists(): + identity_changed = ( + cls._initialized + and cls._db_path is not None + and cls._db_identity is not None + and cls._file_identity(cls._db_path) != cls._db_identity + ) + if ( + not cls._initialized + or cls._db_path is None + or not cls._db_path.exists() + or identity_changed + ): await cls.init(cls._db_path) @classmethod diff --git a/flocks/tool/channel/channel_message.py b/flocks/tool/channel/channel_message.py index acc5e1c20..6a908e8e1 100644 --- a/flocks/tool/channel/channel_message.py +++ b/flocks/tool/channel/channel_message.py @@ -1,8 +1,9 @@ """ -channel_message tool — sends a message to the IM channel bound to a given session. +channel_message tool — sends a message to the messaging channel bound to a given session. Looks up the SessionBinding for the given session_id to automatically resolve -the target channel (WeCom / Feishu / DingTalk) and chat_id, so the caller +the target channel (WeCom / Weixin / Feishu / DingTalk / Telegram / WhatsApp / Email) +and chat_id, so the caller does not need to specify them manually. The optional channel_type parameter selects a specific channel when a session @@ -25,6 +26,9 @@ "weixin": ["weixin", "微信", "wechat", "wx"], "feishu": ["feishu", "飞书", "lark"], "dingtalk": ["dingtalk", "钉钉", "dingding", "dingtalk-connector"], + "telegram": ["telegram", "tg", "tele"], + "whatsapp": ["whatsapp", "wa"], + "email": ["email", "mail", "邮件", "imap", "smtp"], } @@ -130,8 +134,9 @@ async def _http_session_send( @ToolRegistry.register_function( name="channel_message", description=( - "Send a message to the IM channel bound to a session. " - "Channel types: WeCom/企业微信=wecom, Weixin/微信=weixin, Feishu=feishu, DingTalk=dingtalk. " + "Send a message to the messaging channel bound to a session. " + "Channel types: WeCom/企业微信=wecom, Weixin/微信=weixin, Feishu=feishu, DingTalk=dingtalk, " + "Telegram=telegram, WhatsApp=whatsapp, Email/邮件=email. " "Resolves the target channel and chat automatically from session_id. " "Use channel_type to target a specific channel when the session has multiple bindings." ), @@ -153,9 +158,24 @@ async def _http_session_send( name="channel_type", type=ParameterType.STRING, required=False, - enum=["wecom", "weixin", "feishu", "dingtalk", "企微", "企业微信", "微信", "飞书", "钉钉"], + enum=[ + "wecom", + "weixin", + "feishu", + "dingtalk", + "telegram", + "whatsapp", + "email", + "企微", + "企业微信", + "微信", + "飞书", + "钉钉", + "邮件", + ], description=( - "Target channel: wecom=企业微信, weixin=微信, feishu=飞书, or dingtalk=钉钉. " + "Target channel: wecom=企业微信, weixin=微信, feishu=飞书, dingtalk=钉钉, " + "telegram=Telegram, whatsapp=WhatsApp, or email=邮件. " "Chinese aliases are accepted. " "If omitted and the session has only one binding, that channel is used automatically. " "If omitted and the session has multiple bindings, the message is sent to all of them." diff --git a/flocks/tool/channel/im_send_message.py b/flocks/tool/channel/im_send_message.py index 68fbc1078..834218ada 100644 --- a/flocks/tool/channel/im_send_message.py +++ b/flocks/tool/channel/im_send_message.py @@ -20,6 +20,9 @@ "weixin": ["weixin", "微信", "wechat", "wx"], "feishu": ["feishu", "飞书", "lark"], "dingtalk": ["dingtalk", "钉钉", "dingding", "dingtalk-connector"], + "telegram": ["telegram", "tg", "tele"], + "whatsapp": ["whatsapp", "wa"], + "email": ["email", "mail", "邮件", "imap", "smtp"], } @@ -200,10 +203,12 @@ async def _resolve_target( @ToolRegistry.register_function( name="im_send_message", description=( - "Resolve an IM target session and optionally send a message. " - "Use this for WeCom/企业微信, Weixin/微信, Feishu, DingTalk, or custom channel sessions when the user asks to send an IM message. " - "Use channel_type=wecom for 企业微信 and channel_type=weixin for 微信. " - "If session_id is omitted, it uses the current IM session when available, otherwise asks the user to pick one." + "Resolve a messaging channel target session and optionally send a message. " + "Use this for WeCom/企业微信, Weixin/微信, Feishu, DingTalk, Telegram, WhatsApp, Email/邮件, " + "or custom channel sessions when the user asks to send a message through a connected channel. " + "Use channel_type=wecom for 企业微信, channel_type=weixin for 微信, channel_type=telegram for Telegram, " + "channel_type=whatsapp for WhatsApp, and channel_type=email for 邮件. " + "If session_id is omitted, it uses the current channel session when available, otherwise asks the user to pick one." ), category=ToolCategory.SYSTEM, parameters=[ @@ -225,7 +230,7 @@ async def _resolve_target( required=False, description=( "Optional channel filter, such as wecom=企业微信, weixin=微信, " - "feishu, dingtalk, telegram, or a custom channel id." + "feishu, dingtalk, telegram, whatsapp, email=邮件, or a custom channel id." ), ), ToolParameter( diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index 0f11995e7..d348a8387 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -25,6 +25,15 @@ log = Log.create(service="tool-registry") +class ToolRefreshError(RuntimeError): + """A refresh stage failed and the previous registry state was restored.""" + + def __init__(self, stage: str, errors: List[str]): + self.stage = stage + self.errors = list(errors) + super().__init__(f"{stage}: {'; '.join(self.errors)}") + + class ToolCategory(str, Enum): """Tool categories""" FILE = "file" @@ -602,6 +611,7 @@ class ToolRegistry: _failure_state: Dict[str, Dict[str, Any]] = {} _failure_disable_threshold: int = 3 _init_lock = threading.Lock() + _refresh_lock = threading.RLock() _initializing_thread_id: Optional[int] = None # Snapshot of every tool's factory-default ``enabled`` flag — captured @@ -646,8 +656,9 @@ def register(cls, tool: Tool) -> None: # ``refresh_plugin_tools`` cycle — must refresh the snapshot so # ``enabled_default`` / ``reset`` reflect the current source of # truth instead of the first value ever observed. - cls._enabled_defaults[tool.info.name] = bool(tool.info.enabled) - cls._tools[tool.info.name] = tool + with cls._refresh_lock: + cls._enabled_defaults[tool.info.name] = bool(tool.info.enabled) + cls._tools[tool.info.name] = tool log.debug("tool.registered", { "name": tool.info.name, "category": tool.info.category.value, @@ -660,7 +671,8 @@ def revision(cls) -> int: The revision is bumped when plugin or dynamic tools are reloaded so long-lived session caches can detect toolset changes. """ - return cls._revision + with cls._refresh_lock: + return cls._revision @classmethod def _bump_revision(cls, reason: str) -> None: @@ -728,7 +740,8 @@ def decorator(func: ToolHandler) -> ToolHandler: @classmethod def unregister(cls, name: str) -> bool: """Unregister a tool by name. Returns True if the tool was found and removed.""" - removed = cls._tools.pop(name, None) + with cls._refresh_lock: + removed = cls._tools.pop(name, None) if removed: log.debug("tool.unregistered", {"name": name}) return removed is not None @@ -743,18 +756,18 @@ def _ensure_initialized(cls) -> None: def get(cls, name: str) -> Optional[Tool]: """Get a tool by name""" cls._ensure_initialized() - return cls._tools.get(name) + with cls._refresh_lock: + return cls._tools.get(name) @classmethod def list_tools(cls, category: Optional[ToolCategory] = None) -> List[ToolInfo]: """List all registered tools, optionally filtered by category""" cls._ensure_initialized() - tools = list(cls._tools.values()) - - if category: - tools = [t for t in tools if t.info.category == category] - - return [t.info for t in tools] + with cls._refresh_lock: + tools = list(cls._tools.values()) + if category: + tools = [t for t in tools if t.info.category == category] + return [t.info for t in tools] @classmethod def get_schema(cls, name: str) -> Optional[ToolSchema]: @@ -983,13 +996,25 @@ async def execute_batch( def all_tool_ids(cls) -> List[str]: """Get all registered tool IDs""" cls._ensure_initialized() - return list(cls._tools.keys()) + with cls._refresh_lock: + return list(cls._tools.keys()) + + @classmethod + def snapshot_identity(cls) -> tuple[int, tuple[str, ...]]: + """Return a revision/tool-id identity from one consistent registry state.""" + cls._ensure_initialized() + with cls._refresh_lock: + return cls._revision, tuple(cls._tools.keys()) @classmethod def get_dynamic_tools_by_module(cls) -> Dict[str, List[str]]: """Return a copy of the dynamic-module → tool-names mapping.""" cls._ensure_initialized() - return dict(cls._dynamic_tools_by_module) + with cls._refresh_lock: + return { + module_name: list(tool_names) + for module_name, tool_names in cls._dynamic_tools_by_module.items() + } @classmethod def get_api_service_ids(cls) -> set: @@ -1039,7 +1064,7 @@ async def init_async(cls) -> None: await asyncio.to_thread(cls.init) @classmethod - def _load_plugin_tools(cls) -> None: + def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: """Load plugin tools from both user-level and project-level plugin dirs on init. Without this, YAML/Python plugin tools only appear after an explicit @@ -1057,9 +1082,13 @@ def _load_plugin_tools(cls) -> None: try: from flocks.plugin import PluginLoader - PluginLoader.load_extension("TOOLS", load_entry_points=True) + load_errors = PluginLoader.load_extension("TOOLS", load_entry_points=True) + if errors is not None: + errors.extend(load_errors) except Exception as e: log.warn("tool_registry.plugin_load_failed", {"error": str(e)}) + if errors is not None: + errors.append(f"plugin loader: {e}") after = set(cls._tools.keys()) new_plugin_tools = sorted(after - before) python_tool_sources: Dict[str, Path] = {} @@ -1102,6 +1131,8 @@ def _load_plugin_tools(cls) -> None: except ValueError: tool.info.native = True cls._plugin_tool_names = sorted(set(new_plugin_tools) | python_plugin_names) + if errors: + return cls._bootstrap_user_api_services() # Defence-in-depth: ``register()`` is the canonical writer for # ``_enabled_defaults`` but this catches any tool that landed in @@ -1265,7 +1296,8 @@ def get_default_enabled(cls, name: str) -> Optional[bool]: (e.g. dynamic tools registered after init). Callers should fall back to the live ``ToolInfo.enabled`` in that case. """ - return cls._enabled_defaults.get(name) + with cls._refresh_lock: + return cls._enabled_defaults.get(name) @classmethod def _apply_tool_settings(cls) -> None: @@ -1351,8 +1383,9 @@ def _is_native_source(source: str) -> bool: except ValueError: return True # Under /.flocks/plugins/ or elsewhere → project-level → native - def _consume_tools(items: list, source: str) -> None: + def _consume_tools(items: list, source: str) -> Optional[List[str]]: is_native = _is_native_source(source) + errors: List[str] = [] for spec in items: # YAML factory produces Tool instances directly if isinstance(spec, Tool): @@ -1380,6 +1413,7 @@ def _consume_tools(items: list, source: str) -> None: if not isinstance(spec, dict): log.warn("plugin.tool.invalid_spec", {"source": source}) + errors.append("tool definition must be a Tool or mapping") continue name = spec.get("name") handler = spec.get("handler") @@ -1388,6 +1422,7 @@ def _consume_tools(items: list, source: str) -> None: "source": source, "spec_keys": list(spec.keys()), }) + errors.append("tool definition requires name and handler") continue existing = cls._tools.get(name) if existing is not None: @@ -1411,8 +1446,19 @@ def _consume_tools(items: list, source: str) -> None: log.warn("plugin.tool.handler_not_found", { "source": source, "handler": spec.get("handler"), }) + errors.append( + f"tool {name}: handler {spec.get('handler')!r} not found" + ) continue + if not callable(handler): + log.warn("plugin.tool.handler_not_callable", { + "source": source, + "name": name, + }) + errors.append(f"tool {name}: handler must be callable") + continue + params = [ ToolParameter(**p) if isinstance(p, dict) else p for p in spec.get("parameters", []) @@ -1429,6 +1475,7 @@ def _consume_tools(items: list, source: str) -> None: native=is_native, ) cls.register(Tool(info=info, handler=handler)) + return errors def _dedup_key(item: Any) -> str: if isinstance(item, Tool): @@ -1572,18 +1619,68 @@ def refresh_plugin_tools(cls) -> List[str]: correctly removed from the registry. """ cls._ensure_initialized() - cls._unregister_plugin_tools() - cls._load_plugin_tools() - cls._bump_revision("plugin_refresh") - return cls.all_tool_ids() + with cls._refresh_lock: + tools_before = cls._tools.copy() + defaults_before = cls._enabled_defaults.copy() + plugin_names_before = list(cls._plugin_tool_names) + load_errors: List[str] = [] + + try: + cls._unregister_plugin_tools() + cls._load_plugin_tools(load_errors) + except Exception: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._plugin_tool_names = plugin_names_before + raise + if load_errors: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._plugin_tool_names = plugin_names_before + raise ToolRefreshError("plugin", load_errors) + + cls._bump_revision("plugin_refresh") + return cls.all_tool_ids() @classmethod def refresh_dynamic_tools(cls) -> List[str]: """Reload dynamically generated tools and return tool ids.""" cls._ensure_initialized() - cls._register_dynamic_tools() - cls._bump_revision("dynamic_refresh") - return cls.all_tool_ids() + with cls._refresh_lock: + tools_before = cls._tools.copy() + defaults_before = cls._enabled_defaults.copy() + dynamic_modules_before = cls._dynamic_modules.copy() + dynamic_tools_before = { + name: list(tool_names) + for name, tool_names in cls._dynamic_tools_by_module.items() + } + load_errors: List[str] = [] + + try: + cls._register_dynamic_tools(load_errors) + except Exception: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._dynamic_modules = dynamic_modules_before + cls._dynamic_tools_by_module = dynamic_tools_before + raise + if load_errors: + cls._tools.clear() + cls._tools.update(tools_before) + cls._enabled_defaults.clear() + cls._enabled_defaults.update(defaults_before) + cls._dynamic_modules = dynamic_modules_before + cls._dynamic_tools_by_module = dynamic_tools_before + raise ToolRefreshError("dynamic", load_errors) + + cls._bump_revision("dynamic_refresh") + return cls.all_tool_ids() @classmethod def _reset_failure_state(cls, tool_name: str) -> None: @@ -1717,7 +1814,7 @@ def _unregister_dynamic_tools(cls, module_name: str) -> None: }) @classmethod - def _register_dynamic_tools(cls) -> None: + def _register_dynamic_tools(cls, errors: Optional[List[str]] = None) -> None: """Register dynamically generated tools by importing modules.""" modules = cls._discover_dynamic_modules() @@ -1743,6 +1840,8 @@ def _register_dynamic_tools(cls) -> None: "module": module_name, "error": str(e), }) + if errors is not None: + errors.append(f"{path}: {e}") continue after = set(cls._tools.keys()) diff --git a/flocks/tool/task/schedule_task_center.py b/flocks/tool/task/schedule_task_center.py index ec2a3ab14..aa1c6775e 100644 --- a/flocks/tool/task/schedule_task_center.py +++ b/flocks/tool/task/schedule_task_center.py @@ -128,12 +128,15 @@ def _normalize_schedule_task_create_inputs( "'一次', '这次', specific date like '明天下午3点', '下周五晚上', '2024-01-15 18:00'\n" "Queue-only (use type=queued, no schedule): " "'等会', '稍后', '待会', '有空时', '不着急'\n\n" - "IMPORTANT — IM session resolution before creating:\n" - "If the task involves sending a message to an IM platform " - "(企业微信/WeCom、微信/Weixin/WeChat、飞书/Feishu、钉钉/DingTalk), " + "IMPORTANT — Messaging channel session resolution before creating:\n" + "If the task involves sending a message to a connected messaging channel " + "(企业微信/WeCom、微信/Weixin/WeChat、飞书/Feishu、钉钉/DingTalk、" + "Telegram、WhatsApp、邮件/Email), " "you MUST resolve the target session_id and channel_type with im_send_message(resolve_only=true) " "BEFORE calling this tool. " - "Use channel_type=wecom for 企业微信 and channel_type=weixin for 微信. " + "Use channel_type=wecom for 企业微信, channel_type=weixin for 微信, " + "channel_type=telegram for Telegram, channel_type=whatsapp for WhatsApp, " + "and channel_type=email for 邮件. " "Embed both into description and user_prompt. " "If the user cannot provide a session_id, do NOT create the task." ), @@ -150,9 +153,12 @@ def _normalize_schedule_task_create_inputs( type=ParameterType.STRING, description=( "Detailed task description. " - "If the task involves sending a message to an IM platform (WeCom/Weixin/Feishu/DingTalk), " + "If the task involves sending a message to a connected messaging channel " + "(WeCom/Weixin/Feishu/DingTalk/Telegram/WhatsApp/Email), " "MUST include the resolved channel_type and session_id here. " - "Use channel_type=wecom for 企业微信 and channel_type=weixin for 微信. " + "Use channel_type=wecom for 企业微信, channel_type=weixin for 微信, " + "channel_type=telegram for Telegram, channel_type=whatsapp for WhatsApp, " + "and channel_type=email for 邮件. " "Example: '每天早上8点向飞书群发送日报 channel_type=feishu session_id=ses_abc123'" ), required=True, diff --git a/pyproject.toml b/pyproject.toml index e68732a07..783712fde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flocks" -version = "v2026.7.8" +version = "v2026.7.15" description = "AI-Native SecOps platform with multi-agent collaboration" authors = [ {name = "Flocks Team", email = "team@example.com"} @@ -109,6 +109,11 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["flocks"] +artifacts = [ + "flocks/channel/builtin/whatsapp/bridge/*.js", + "flocks/channel/builtin/whatsapp/bridge/package.json", + "flocks/channel/builtin/whatsapp/bridge/package-lock.json", +] [tool.ruff] line-length = 120 diff --git a/scripts/recover_raw_flocks_db.py b/scripts/recover_raw_flocks_db.py index b77088240..fe1a18b95 100644 --- a/scripts/recover_raw_flocks_db.py +++ b/scripts/recover_raw_flocks_db.py @@ -1,4 +1,8 @@ -"""One-click recovery for a damaged Flocks SQLite database.""" +"""Offline recovery for a damaged Flocks SQLite database. + +The helper writes recovery artifacts only. It never deletes SQLite sidecars or +installs a recovered database over the live Flocks store. +""" from __future__ import annotations @@ -9,13 +13,19 @@ import struct import subprocess import shutil +import uuid +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Dict, Sequence +from dotenv import load_dotenv + WAL_MAGIC = 0x377F0682 +WAL_MAGIC_BIG_ENDIAN_CHECKSUM = 0x377F0683 +WAL_MAGIC_VALUES = {WAL_MAGIC, WAL_MAGIC_BIG_ENDIAN_CHECKSUM} WAL_VERSION = 3007000 -COMMON_SQLITE_PAGE_SIZES = (4096, 8192, 2048, 1024, 16384, 32768, 65536) +COMMON_SQLITE_PAGE_SIZES = (4096, 8192, 2048, 1024, 512, 16384, 32768, 65536) STORAGE_DDL = """ CREATE TABLE IF NOT EXISTS storage ( @@ -276,12 +286,76 @@ class RecoveryArtifacts: extracted_db: Path recovered_db: Path summary_path: Path + lost_and_found_table: str pagesize: int wal_frames: int wal_final_db_pages: int copied_rows: Dict[str, int] +def _cleanup_temporary_sqlite_files(path: Path) -> None: + """Remove only temporary files created by the current recovery operation.""" + + for candidate in ( + path, + path.with_name(f"{path.name}-journal"), + path.with_name(f"{path.name}-wal"), + path.with_name(f"{path.name}-shm"), + ): + candidate.unlink(missing_ok=True) + + +def _publish_file_exclusive(temporary_path: Path, destination: Path) -> None: + """Atomically publish a sibling temporary file without replacing a destination.""" + + destination.parent.mkdir(parents=True, exist_ok=True) + if os.name == "nt": + try: + os.rename(temporary_path, destination) + except FileExistsError: + raise FileExistsError(f"Refusing to overwrite existing recovery file: {destination}") + return + try: + os.link(temporary_path, destination, follow_symlinks=False) + except FileExistsError: + raise FileExistsError(f"Refusing to overwrite existing recovery file: {destination}") + temporary_path.unlink() + + +@contextmanager +def _atomic_output_path(destination: Path): + """Yield a sibling temporary path and publish it only after successful completion.""" + + destination.parent.mkdir(parents=True, exist_ok=True) + temporary_path = destination.with_name(f".{destination.name}.tmp-{uuid.uuid4().hex}") + try: + yield temporary_path + if not temporary_path.is_file(): + raise RuntimeError(f"Recovery output was not created: {temporary_path}") + unexpected_sidecars = [ + candidate + for candidate in ( + temporary_path.with_name(f"{temporary_path.name}-journal"), + temporary_path.with_name(f"{temporary_path.name}-wal"), + temporary_path.with_name(f"{temporary_path.name}-shm"), + ) + if candidate.exists() + ] + if unexpected_sidecars: + raise RuntimeError( + "Recovery staging file still has SQLite sidecars: " + + ", ".join(str(path) for path in unexpected_sidecars) + ) + # Windows maps fsync() to _commit(), which requires a writable file + # descriptor. ``r+b`` works on every supported platform and keeps the + # durability step consistent before the no-replace publish. + with temporary_path.open("r+b") as handle: + os.fsync(handle.fileno()) + _publish_file_exclusive(temporary_path, destination) + finally: + _cleanup_temporary_sqlite_files(temporary_path) + + def _quote_identifier(value: str) -> str: return '"' + value.replace('"', '""') + '"' @@ -294,44 +368,100 @@ def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool: return row is not None -def _table_columns(conn: sqlite3.Connection, table_name: str) -> list[str]: - rows = conn.execute(f"PRAGMA table_info({_quote_identifier(table_name)})").fetchall() - return [str(row[1]) for row in rows] +def _wal_checksum( + data: bytes, + *, + byte_order: str, + state: tuple[int, int] = (0, 0), +) -> tuple[int, int]: + """Return SQLite's rolling WAL checksum for an 8-byte-aligned payload.""" + + if len(data) % 8 != 0: + raise ValueError("WAL checksum input must be aligned to 8 bytes.") + words = struct.unpack(f"{byte_order}{len(data) // 4}I", data) + checksum_1, checksum_2 = state + for index in range(0, len(words), 2): + checksum_1 = (checksum_1 + words[index] + checksum_2) & 0xFFFFFFFF + checksum_2 = (checksum_2 + words[index + 1] + checksum_1) & 0xFFFFFFFF + return checksum_1, checksum_2 def _parse_wal_frames(wal_bytes: bytes) -> tuple[int, int, Dict[int, bytes], int]: - """Return WAL page size, final db pages, latest page map, and frame count.""" + """Return committed, checksum-valid WAL pages through the last transaction.""" if len(wal_bytes) < 32: raise ValueError("WAL file is too small to contain a valid header.") - magic, version, pagesize, _, _, _, _, _ = struct.unpack(">8I", wal_bytes[:32]) - if magic != WAL_MAGIC: + magic, version, pagesize, _, salt_1, salt_2, stored_1, stored_2 = struct.unpack( + ">8I", wal_bytes[:32] + ) + if magic not in WAL_MAGIC_VALUES: raise ValueError(f"Unexpected WAL magic: 0x{magic:08x}") if version != WAL_VERSION: raise ValueError(f"Unexpected WAL version: {version}") - if pagesize <= 0: + if pagesize < 512 or pagesize > 65536 or pagesize & (pagesize - 1): raise ValueError(f"Invalid WAL pagesize: {pagesize}") + byte_order = "<" if magic == WAL_MAGIC else ">" + checksum = _wal_checksum(wal_bytes[:24], byte_order=byte_order) + if checksum != (stored_1, stored_2): + raise ValueError("WAL header checksum is invalid.") + frame_size = 24 + pagesize payload = len(wal_bytes) - 32 - if payload < 0 or payload % frame_size != 0: - raise ValueError("WAL payload is not aligned to complete frames.") - - frame_count = payload // frame_size - latest_pages: Dict[int, bytes] = {} + available_frames = payload // frame_size + valid_frames: list[tuple[int, bytes]] = [] + last_commit_frame_count = 0 final_db_pages = 0 - for frame_index in range(frame_count): + for frame_index in range(available_frames): offset = 32 + frame_index * frame_size - page_no, db_page_count, *_ = struct.unpack(">6I", wal_bytes[offset : offset + 24]) - latest_pages[page_no] = wal_bytes[offset + 24 : offset + 24 + pagesize] + frame_header = wal_bytes[offset : offset + 24] + page = wal_bytes[offset + 24 : offset + 24 + pagesize] + page_no, db_page_count, frame_salt_1, frame_salt_2, frame_sum_1, frame_sum_2 = ( + struct.unpack(">6I", frame_header) + ) + if page_no == 0 or (frame_salt_1, frame_salt_2) != (salt_1, salt_2): + break + + next_checksum = _wal_checksum( + frame_header[:8], + byte_order=byte_order, + state=checksum, + ) + next_checksum = _wal_checksum( + page, + byte_order=byte_order, + state=next_checksum, + ) + if next_checksum != (frame_sum_1, frame_sum_2): + break + + checksum = next_checksum + valid_frames.append((page_no, page)) if db_page_count: final_db_pages = db_page_count + last_commit_frame_count = len(valid_frames) - if final_db_pages <= 0: + if final_db_pages <= 0 or last_commit_frame_count == 0: raise ValueError("WAL does not contain any committed frames.") - return pagesize, final_db_pages, latest_pages, frame_count + latest_pages: Dict[int, bytes] = {} + for page_no, page in valid_frames[:last_commit_frame_count]: + latest_pages[page_no] = page + + return pagesize, final_db_pages, latest_pages, last_commit_frame_count + + +def _read_header_pagesize(raw_bytes: bytes) -> int | None: + """Return a valid SQLite page size from the database header, if present.""" + + if len(raw_bytes) < 100 or not raw_bytes.startswith(b"SQLite format 3\x00"): + return None + encoded = int.from_bytes(raw_bytes[16:18], "big") + pagesize = 65536 if encoded == 1 else encoded + if pagesize < 512 or pagesize > 65536 or pagesize & (pagesize - 1): + return None + return pagesize def _guess_raw_pagesize(raw_bytes: bytes) -> int: @@ -363,14 +493,14 @@ def _build_synthetic_page1(pagesize: int, total_pages: int) -> bytes: page1[40:44] = (1).to_bytes(4, "big") page1[44:48] = (4).to_bytes(4, "big") page1[56:60] = (1).to_bytes(4, "big") - page1[96] = 0x0D - page1[97] = 0x00 - page1[98:100] = (0).to_bytes(2, "big") + page1[92:96] = (1).to_bytes(4, "big") + # Page 1's b-tree header begins after the 100-byte database header. + page1[100] = 0x0D + page1[101:103] = (0).to_bytes(2, "big") + page1[103:105] = (0).to_bytes(2, "big") cell_content_area = 0 if pagesize == 65536 else pagesize - page1[100:102] = cell_content_area.to_bytes(2, "big") - page1[102] = 0 - page1[103] = 0 - page1[104:108] = (0).to_bytes(4, "big") + page1[105:107] = cell_content_area.to_bytes(2, "big") + page1[107] = 0 return bytes(page1) @@ -387,43 +517,49 @@ def reconstruct_sqlite_candidate( if wal_path is not None: wal_bytes = wal_path.read_bytes() pagesize, final_db_pages, latest_pages, frame_count = _parse_wal_frames(wal_bytes) + header_pagesize = _read_header_pagesize(raw_bytes) + if header_pagesize is not None and header_pagesize != pagesize: + raise ValueError( + "WAL page size does not match the SQLite database header: " + f"{pagesize} != {header_pagesize}." + ) if len(raw_bytes) % pagesize != 0: raise ValueError( f"Raw file size {len(raw_bytes)} is not aligned to WAL pagesize {pagesize}." ) else: - pagesize = _guess_raw_pagesize(raw_bytes) + pagesize = _read_header_pagesize(raw_bytes) or _guess_raw_pagesize(raw_bytes) final_db_pages = len(raw_bytes) // pagesize latest_pages = {} frame_count = 0 - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("wb") as handle: - if wal_path is None and not raw_has_header: - handle.write(_build_synthetic_page1(pagesize, final_db_pages)) - start_page = 2 - else: - start_page = 1 - - if wal_path is None and raw_has_header: - handle.write(raw_bytes) - return { - "pagesize": pagesize, - "wal_frames": 0, - "wal_final_db_pages": final_db_pages, - "wal_pages_used": 0, - } - - for page_no in range(1, final_db_pages + 1): - if wal_path is None and page_no < start_page: - continue - if page_no in latest_pages: - handle.write(latest_pages[page_no]) - continue - - offset = (page_no - 1) * pagesize - page = raw_bytes[offset : offset + pagesize] - handle.write(page if len(page) == pagesize else (b"\x00" * pagesize)) + with _atomic_output_path(output_path) as temporary_path: + with temporary_path.open("xb") as handle: + if wal_path is None and not raw_has_header: + handle.write(_build_synthetic_page1(pagesize, final_db_pages)) + start_page = 2 + else: + start_page = 1 + + if wal_path is None and raw_has_header: + handle.write(raw_bytes) + return { + "pagesize": pagesize, + "wal_frames": 0, + "wal_final_db_pages": final_db_pages, + "wal_pages_used": 0, + } + + for page_no in range(1, final_db_pages + 1): + if wal_path is None and page_no < start_page: + continue + if page_no in latest_pages: + handle.write(latest_pages[page_no]) + continue + + offset = (page_no - 1) * pagesize + page = raw_bytes[offset : offset + pagesize] + handle.write(page if len(page) == pagesize else (b"\x00" * pagesize)) return { "pagesize": pagesize, @@ -433,31 +569,50 @@ def reconstruct_sqlite_candidate( } -def _run_sqlite_recover(candidate_db: Path, recover_sql_path: Path) -> None: - """Write `sqlite3 .recover` output to a SQL file.""" +def _sqlite_recover_capability(sqlite_bin: str = "sqlite3") -> tuple[bool, str]: + """Return whether *sqlite_bin* was built with support for ``.recover``.""" - completed = subprocess.run( - ["sqlite3", str(candidate_db), ".recover"], - check=False, - capture_output=True, - text=True, - encoding="utf-8", - ) - recover_sql_path.write_text(completed.stdout or "", encoding="utf-8") - if completed.returncode != 0 and not completed.stdout.strip(): - stderr = completed.stderr.strip() or completed.stdout.strip() - raise RuntimeError(f"sqlite3 .recover failed: {stderr or completed.returncode}") + try: + completed = subprocess.run( + [ + sqlite_bin, + ":memory:", + "SELECT sqlite_compileoption_used('ENABLE_DBPAGE_VTAB');", + ], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + except OSError as exc: + return False, str(exc) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + return False, detail or f"capability probe exited with {completed.returncode}" + if completed.stdout.strip() != "1": + return False, "SQLite CLI was built without SQLITE_ENABLE_DBPAGE_VTAB" + return True, "" -def _materialize_recovered_sql(recover_sql_path: Path, extracted_db_path: Path) -> None: - """Execute recovered SQL into a scratch SQLite database.""" - if extracted_db_path.exists(): - extracted_db_path.unlink() +def _run_sqlite_recover( + candidate_db: Path, + recover_sql_path: Path, + *, + lost_and_found_table: str, +) -> None: + """Write `sqlite3 .recover` output to a SQL file.""" + + supported, reason = _sqlite_recover_capability() + if not supported: + raise RuntimeError(f"sqlite3 .recover is unavailable: {reason}") completed = subprocess.run( - ["sqlite3", str(extracted_db_path)], - input=recover_sql_path.read_text(encoding="utf-8"), + [ + "sqlite3", + str(candidate_db), + f".recover --lost-and-found {lost_and_found_table}", + ], check=False, capture_output=True, text=True, @@ -465,222 +620,178 @@ def _materialize_recovered_sql(recover_sql_path: Path, extracted_db_path: Path) ) if completed.returncode != 0: stderr = completed.stderr.strip() or completed.stdout.strip() - raise RuntimeError(f"Failed to materialize recovered SQL: {stderr}") - - -def _ensure_recovered_schema(output_db_path: Path) -> None: - """Create the normalized target schema.""" + raise RuntimeError(f"sqlite3 .recover failed: {stderr or completed.returncode}") + with _atomic_output_path(recover_sql_path) as temporary_path: + temporary_path.write_text(completed.stdout or "", encoding="utf-8") - if output_db_path.exists(): - output_db_path.unlink() - output_db_path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(output_db_path) - try: - conn.executescript(STORAGE_DDL) - conn.executescript(USAGE_RECORDS_DDL) - conn.executescript(TASKS_DDL) - conn.executescript(CHANNEL_BINDINGS_DDL) - for stmt in (*USAGE_INDEX_STMTS, *TASK_INDEX_STMTS): - conn.execute(stmt) - conn.commit() - finally: - conn.close() +def _materialize_recovered_sql(recover_sql_path: Path, extracted_db_path: Path) -> None: + """Execute recovered SQL into a scratch SQLite database.""" + with _atomic_output_path(extracted_db_path) as temporary_path: + completed = subprocess.run( + ["sqlite3", str(temporary_path)], + input=recover_sql_path.read_text(encoding="utf-8"), + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + if completed.returncode != 0: + stderr = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError(f"Failed to materialize recovered SQL: {stderr}") -def _insert_rows( - target_conn: sqlite3.Connection, - table_name: str, - columns: Sequence[str], - rows: Sequence[Sequence[object]], -) -> int: - """Insert rows into a table with INSERT OR REPLACE.""" - - if not rows: - return 0 - - quoted_columns = ", ".join(_quote_identifier(column) for column in columns) - placeholders = ", ".join("?" for _ in columns) - target_conn.executemany( - ( - f"INSERT OR REPLACE INTO {_quote_identifier(table_name)} " - f"({quoted_columns}) VALUES ({placeholders})" - ), - rows, - ) - return len(rows) +def _schema_manifest(conn: sqlite3.Connection) -> dict[tuple[str, str], tuple[str, str | None]]: + """Return logical schema objects, excluding SQLite-managed internal objects.""" -def _copy_table_rows( - source_conn: sqlite3.Connection, - target_conn: sqlite3.Connection, - table_name: str, -) -> int: - """Copy shared columns from one table to another.""" - - if not _table_exists(source_conn, table_name) or not _table_exists(target_conn, table_name): - return 0 - - source_columns = _table_columns(source_conn, table_name) - target_columns = set(_table_columns(target_conn, table_name)) - copy_columns = [column for column in source_columns if column in target_columns] - if not copy_columns: - return 0 - - quoted_columns = ", ".join(_quote_identifier(column) for column in copy_columns) - rows = source_conn.execute( - f"SELECT {quoted_columns} FROM {_quote_identifier(table_name)}" + rows = conn.execute( + """ + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name + """ ).fetchall() - if not rows: - return 0 - - return _insert_rows(target_conn, table_name, copy_columns, rows) + return { + (str(row[0]), str(row[1])): (str(row[2]), None if row[3] is None else str(row[3])) + for row in rows + } -def _copy_lost_and_found_rows( +def _validate_recovered_db( source_conn: sqlite3.Connection, target_conn: sqlite3.Connection, - table_name: str, -) -> int: - """Recover business rows from `.recover` lost_and_found output.""" - - if not _table_exists(source_conn, "lost_and_found"): - return 0 - - if table_name == "storage": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4 - FROM lost_and_found - WHERE nfield = 5 - AND c0 NOT IN ('table', 'index') - AND typeof(c0) = 'text' - AND typeof(c1) = 'text' - AND typeof(c2) = 'text' - AND typeof(c3) = 'text' - AND typeof(c4) = 'text' - """ - ).fetchall() - if ":" in str(row[0]) or "/" in str(row[0]) - ] - return _insert_rows( - target_conn, - "storage", - ("key", "value", "type", "created_at", "updated_at"), - rows, +) -> None: + """Fail closed if the published backup changed recovered business data.""" + + source_manifest = _schema_manifest(source_conn) + target_manifest = _schema_manifest(target_conn) + if target_manifest != source_manifest: + changed_keys = sorted( + key + for key in source_manifest.keys() | target_manifest.keys() + if source_manifest.get(key) != target_manifest.get(key) + ) + changed = changed_keys[0] if changed_keys else ("schema", "unknown") + raise sqlite3.DatabaseError( + f"Recovered DB schema object was not preserved: {changed[0]} {changed[1]}" ) - if table_name == "usage_records": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, - c10, c11, c12, c13, c14, c15, c16, c17, c18, c19 - FROM lost_and_found - WHERE nfield = 20 - AND typeof(c0) = 'text' - AND typeof(c1) = 'text' - AND typeof(c2) = 'text' - """ - ).fetchall() - ] - return _insert_rows(target_conn, "usage_records", USAGE_RECORD_COLUMNS, rows) - - if table_name == "task_schedulers": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, - c10, c11, c12, c13, c14, c15, c16, c17, c18, c19, c20 - FROM lost_and_found - WHERE nfield = 21 - AND typeof(c0) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("tsk_") - ] - return _insert_rows(target_conn, "task_schedulers", TASK_SCHEDULER_COLUMNS, rows) - - if table_name == "task_executions": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, - c10, c11, c12, c13, c14, c15, c16, c17, c18, c19, - c20, c21, c22, c23 - FROM lost_and_found - WHERE nfield = 24 - AND typeof(c0) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("txe_") - ] - return _insert_rows(target_conn, "task_executions", TASK_EXECUTION_COLUMNS, rows) - - if table_name == "task_execution_queue_refs": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4 - FROM lost_and_found - WHERE nfield = 5 - AND typeof(c0) = 'text' - AND typeof(c1) = 'text' - AND typeof(c2) = 'text' - AND typeof(c3) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("tqref_") - ] - return _insert_rows(target_conn, "task_execution_queue_refs", QUEUE_REF_COLUMNS, rows) - - if table_name == "channel_bindings": - rows = [ - tuple(row) - for row in source_conn.execute( - """ - SELECT c0, c1, c2, c3, c4, c5, c6, c7, c8, c9 - FROM lost_and_found - WHERE nfield = 10 - AND typeof(c0) = 'text' - """ - ).fetchall() - if str(row[0]).startswith("chb_") - ] - return _insert_rows(target_conn, "channel_bindings", CHANNEL_BINDING_COLUMNS, rows) + source_tables = { + name for object_type, name in source_manifest if object_type == "table" + } + virtual_tables = { + name + for (object_type, name), (_, sql) in source_manifest.items() + if object_type == "table" + and sql is not None + and sql.lstrip().upper().startswith("CREATE VIRTUAL TABLE") + } + missing_row = object() + for table_name in sorted(source_tables - virtual_tables): + quoted_table = _quote_identifier(table_name) + table_sql = source_manifest[("table", table_name)][1] or "" + column_names = { + str(row[1]).casefold() + for row in source_conn.execute(f"PRAGMA table_info({quoted_table})").fetchall() + } + rowid_alias = next( + ( + alias + for alias in ("rowid", "_rowid_", "oid") + if alias.casefold() not in column_names + ), + None, + ) + include_rowid = rowid_alias is not None and "WITHOUT ROWID" not in table_sql.upper() + projection = f"{_quote_identifier(rowid_alias)}, *" if include_rowid else "*" + try: + source_rows = source_conn.execute(f"SELECT {projection} FROM {quoted_table}") + target_rows = target_conn.execute(f"SELECT {projection} FROM {quoted_table}") + row_number = 0 + while True: + source_row = source_rows.fetchone() + target_row = target_rows.fetchone() + if source_row is None and target_row is None: + break + row_number += 1 + source_value = missing_row if source_row is None else tuple(source_row) + target_value = missing_row if target_row is None else tuple(target_row) + if source_value != target_value: + raise sqlite3.DatabaseError( + f"Recovered DB row content changed for {table_name} at row {row_number}" + ) + except sqlite3.DatabaseError: + raise + except sqlite3.Error as exc: + raise sqlite3.DatabaseError( + f"Recovered DB table could not be compared: {table_name}: {exc}" + ) from exc + + for pragma_name in ("application_id", "user_version"): + source_value = source_conn.execute(f"PRAGMA {pragma_name}").fetchone()[0] + target_value = target_conn.execute(f"PRAGMA {pragma_name}").fetchone()[0] + if target_value != source_value: + raise sqlite3.DatabaseError( + f"Recovered DB PRAGMA {pragma_name} changed: {source_value} -> {target_value}" + ) - return 0 + integrity_rows = target_conn.execute("PRAGMA integrity_check").fetchall() + if integrity_rows != [("ok",)]: + detail = "; ".join(str(row[0]) for row in integrity_rows) + raise sqlite3.DatabaseError(f"Recovered DB integrity check failed: {detail}") + foreign_key_rows = target_conn.execute("PRAGMA foreign_key_check").fetchall() + if foreign_key_rows: + raise sqlite3.IntegrityError( + f"Recovered DB foreign key check failed: {foreign_key_rows[0]}" + ) def build_normalized_recovery_db( extracted_db_path: Path, output_db_path: Path, + *, + lost_and_found_table: str = "lost_and_found", ) -> Dict[str, int]: - """Copy supported recovered tables into a clean output database.""" + """Publish an exact recovered backup and retain unresolved rows for inspection. - _ensure_recovered_schema(output_db_path) + ``sqlite3 .recover`` stores rows it cannot attribute confidently in its + lost-and-found table. Guessing their destination from field count or value + prefixes can silently inject another table's data into Flocks, so this + function deliberately leaves those rows untouched. + """ copied_rows: Dict[str, int] = {} - source_conn = sqlite3.connect(extracted_db_path) - target_conn = sqlite3.connect(output_db_path) - try: - for table_name in SUPPORTED_COPY_TABLES: - direct_rows = _copy_table_rows(source_conn, target_conn, table_name) - if direct_rows == 0: - _copy_lost_and_found_rows(source_conn, target_conn, table_name) - target_conn.commit() - copied_rows[table_name] = target_conn.execute( - f"SELECT COUNT(*) FROM {_quote_identifier(table_name)}" - ).fetchone()[0] - finally: - source_conn.close() - target_conn.close() + with _atomic_output_path(output_db_path) as temporary_path: + source_conn = sqlite3.connect(extracted_db_path) + target_conn = sqlite3.connect(temporary_path) + try: + source_conn.backup(target_conn) + finally: + source_conn.close() + target_conn.close() + + source_conn = sqlite3.connect(extracted_db_path) + target_conn = sqlite3.connect(temporary_path) + try: + journal_mode = target_conn.execute("PRAGMA journal_mode=DELETE").fetchone()[0] + if str(journal_mode).lower() != "delete": + raise sqlite3.DatabaseError( + f"Could not switch recovered DB to DELETE journal mode: {journal_mode}" + ) + for table_name in SUPPORTED_COPY_TABLES: + copied_rows[table_name] = ( + target_conn.execute( + f"SELECT COUNT(*) FROM {_quote_identifier(table_name)}" + ).fetchone()[0] + if _table_exists(target_conn, table_name) + else 0 + ) + _validate_recovered_db(source_conn, target_conn) + finally: + source_conn.close() + target_conn.close() return copied_rows @@ -695,6 +806,7 @@ def _render_summary(artifacts: RecoveryArtifacts) -> str: f"extracted_db={artifacts.extracted_db}", f"recovered_db={artifacts.recovered_db}", f"summary_path={artifacts.summary_path}", + f"lost_and_found_table={artifacts.lost_and_found_table}", f"pagesize={artifacts.pagesize}", f"wal_frames={artifacts.wal_frames}", f"wal_final_db_pages={artifacts.wal_final_db_pages}", @@ -704,6 +816,34 @@ def _render_summary(artifacts: RecoveryArtifacts) -> str: return "\n".join(lines) + "\n" +def _recovery_artifact_paths( + recovery_dir: Path, + prefix: str, +) -> tuple[Path, Path, Path, Path, Path]: + """Return every file path written by one recovery run.""" + + return ( + recovery_dir / f"{prefix}.candidate.db", + recovery_dir / f"{prefix}.recover.sql", + recovery_dir / f"{prefix}.extracted.db", + recovery_dir / f"{prefix}.db", + recovery_dir / f"{prefix}.summary.txt", + ) + + +def _recovery_sqlite_sidecar_paths( + artifact_paths: tuple[Path, Path, Path, Path, Path], +) -> tuple[Path, ...]: + """Return sidecar paths SQLite may create while processing DB artifacts.""" + + database_paths = (artifact_paths[0], artifact_paths[2], artifact_paths[3]) + return tuple( + database_path.with_name(f"{database_path.name}{suffix}") + for database_path in database_paths + for suffix in ("-journal", "-wal", "-shm") + ) + + def recover_raw_storage_db( raw_path: Path, wal_path: Path | None, @@ -713,17 +853,32 @@ def recover_raw_storage_db( ) -> RecoveryArtifacts: """Recover a damaged raw SQLite file into a normalized database.""" + candidate_db, recover_sql, extracted_db, recovered_db, summary_path = _recovery_artifact_paths( + recovery_dir, + prefix, + ) + _validate_recovery_plan( + raw_path=raw_path, + wal_path=wal_path, + recovery_dir=recovery_dir, + output_db=recovered_db, + prefix=prefix, + ) recovery_dir.mkdir(parents=True, exist_ok=True) - candidate_db = recovery_dir / f"{prefix}.candidate.db" - recover_sql = recovery_dir / f"{prefix}.recover.sql" - extracted_db = recovery_dir / f"{prefix}.extracted.db" - recovered_db = recovery_dir / f"{prefix}.db" - summary_path = recovery_dir / f"{prefix}.summary.txt" + lost_and_found_table = f"flocks_recovery_lost_{uuid.uuid4().hex}" candidate_stats = reconstruct_sqlite_candidate(raw_path, wal_path, candidate_db) - _run_sqlite_recover(candidate_db, recover_sql) + _run_sqlite_recover( + candidate_db, + recover_sql, + lost_and_found_table=lost_and_found_table, + ) _materialize_recovered_sql(recover_sql, extracted_db) - copied_rows = build_normalized_recovery_db(extracted_db, recovered_db) + copied_rows = build_normalized_recovery_db( + extracted_db, + recovered_db, + lost_and_found_table=lost_and_found_table, + ) artifacts = RecoveryArtifacts( recovery_dir=recovery_dir, @@ -732,12 +887,14 @@ def recover_raw_storage_db( extracted_db=extracted_db, recovered_db=recovered_db, summary_path=summary_path, + lost_and_found_table=lost_and_found_table, pagesize=candidate_stats["pagesize"], wal_frames=candidate_stats["wal_frames"], wal_final_db_pages=candidate_stats["wal_final_db_pages"], copied_rows=copied_rows, ) - summary_path.write_text(_render_summary(artifacts), encoding="utf-8") + with _atomic_output_path(summary_path) as temporary_path: + temporary_path.write_text(_render_summary(artifacts), encoding="utf-8") return artifacts @@ -760,45 +917,196 @@ def _resolve_raw_path(args: argparse.Namespace) -> Path: def _detect_wal_path(raw_path: Path) -> Path | None: """Try to find a matching WAL file next to the damaged DB.""" - bases = {raw_path.name, raw_path.stem} - suffixes = {"", raw_path.suffix} + candidates: list[Path] = [] seen: set[Path] = set() - for base in bases: - for suffix in suffixes: + def add_candidate(name: str) -> None: + candidate = raw_path.with_name(name) + if candidate not in seen: + seen.add(candidate) + candidates.append(candidate) + + add_candidate(f"{raw_path.name}-wal") + add_candidate(f"{raw_path.name}.wal") + + marker = ".corrupt." + if marker in raw_path.name: + original_name, quarantine_suffix = raw_path.name.split(marker, 1) + add_candidate(f"{original_name}-wal{marker}{quarantine_suffix}") + suffix_base, separator, counter = quarantine_suffix.rpartition(".") + if separator and counter.isdigit(): + add_candidate(f"{original_name}-wal{marker}{suffix_base}") + + for base in (raw_path.stem, raw_path.name): + for suffix in ("", raw_path.suffix): for wal_suffix in ("-wal", ".wal"): - candidate = raw_path.with_name(f"{base}{wal_suffix}{suffix}") - if candidate in seen: - continue - seen.add(candidate) - if candidate.exists(): - return candidate.resolve() + add_candidate(f"{base}{wal_suffix}{suffix}") + + for candidate in candidates: + # A clean ``wal_checkpoint(TRUNCATE)`` commonly leaves a zero-length + # WAL beside a complete main database. Auto-detection should treat it + # as no pending WAL; an explicitly supplied ``--wal`` remains strict. + if candidate.is_file() and candidate.stat().st_size > 0: + return candidate.resolve() return None def _default_artifacts_dir(raw_path: Path) -> Path: - """Create the default workspace output directory for recovery artifacts.""" + """Return the default workspace output directory for recovery artifacts.""" - workspace_dir = Path(os.getenv("FLOCKS_WORKSPACE_DIR", Path.home() / ".flocks" / "workspace")) + workspace_value = _getenv_case_insensitive("FLOCKS_WORKSPACE_DIR") + workspace_dir = Path( + workspace_value + if workspace_value is not None + else Path.home() / ".flocks" / "workspace" + ) today = dt.date.today().isoformat() run_name = _sanitize_name(raw_path.name) - output_dir = workspace_dir / "outputs" / today / f"db-recovery-{run_name}" - output_dir.mkdir(parents=True, exist_ok=True) - return output_dir + run_id = uuid.uuid4().hex + return workspace_dir / "outputs" / today / f"db-recovery-{run_name}-{run_id}" + + +def _getenv_case_insensitive(name: str) -> str | None: + """Read a Flocks setting with the same case-insensitive key semantics as BaseSettings.""" + + result: str | None = None + for key, value in os.environ.items(): + if key.lower() == name.lower(): + result = value + return result + + +def _resolve_live_data_dir() -> Path: + """Resolve the live Flocks data directory without importing the application.""" + + data_dir = _getenv_case_insensitive("FLOCKS_DATA_DIR") + if data_dir is not None: + return Path(data_dir).resolve() + + xdg_data = _getenv_case_insensitive("XDG_DATA_HOME") + if xdg_data: + return (Path(xdg_data) / "flocks").resolve() + + flocks_root = _getenv_case_insensitive("FLOCKS_ROOT") + if flocks_root: + return (Path(flocks_root) / "data").resolve() + + return (Path.home() / ".flocks" / "data").resolve() + + +def _same_file_or_path(first: Path, second: Path) -> bool: + """Return whether two paths resolve to the same path or existing inode.""" + + if first.resolve() == second.resolve(): + return True + try: + return first.samefile(second) + except OSError: + return False + + +def _path_collision_key(path: Path) -> str: + """Return a conservative path key for case-insensitive filesystems.""" + return os.path.normcase(str(path.resolve())).casefold() -def _cleanup_live_sqlite_sidecars() -> list[Path]: - """Delete stale live WAL/SHM sidecar files under `~/.flocks/data/`.""" - data_dir = Path(os.getenv("FLOCKS_DATA_DIR", Path.home() / ".flocks" / "data")) - removed: list[Path] = [] - for name in ("flocks.db-shm", "flocks.db-wal"): - candidate = data_dir / name - if candidate.exists(): - candidate.unlink() - removed.append(candidate) - return removed +def _is_within(path: Path, directory: Path) -> bool: + """Return whether a resolved path is inside a resolved directory.""" + + path_key = _path_collision_key(path) + directory_key = _path_collision_key(directory).rstrip(os.sep) + return path_key == directory_key or path_key.startswith(f"{directory_key}{os.sep}") + + +def _validate_recovery_plan( + *, + raw_path: Path, + wal_path: Path | None, + recovery_dir: Path, + output_db: Path, + prefix: str, +) -> None: + """Reject recovery plans that could mutate live data or source evidence.""" + + live_data_dir = _resolve_live_data_dir() + live_db = live_data_dir / "flocks.db" + live_sidecars = (live_data_dir / "flocks.db-wal", live_data_dir / "flocks.db-shm") + artifact_paths = _recovery_artifact_paths(recovery_dir, prefix) + sqlite_sidecar_paths = _recovery_sqlite_sidecar_paths(artifact_paths) + recovered_db = artifact_paths[3] + + if _same_file_or_path(raw_path, live_db): + raise ValueError( + "Refusing to recover the live Flocks database directly. " + "Stop Flocks, copy the database and matching WAL to a separate path, " + "then recover that copy." + ) + + if wal_path is not None and any( + _same_file_or_path(wal_path, live_sidecar) for live_sidecar in live_sidecars + ): + raise ValueError( + "Refusing to read a live Flocks SQLite sidecar. " + "Stop Flocks and copy the database with its matching WAL before recovery." + ) + + output_key = _path_collision_key(output_db) + recovered_key = _path_collision_key(recovered_db) + if output_db.resolve() != recovered_db.resolve() and ( + output_key == recovered_key or _same_file_or_path(output_db, recovered_db) + ): + raise ValueError( + "Recovery output has an ambiguous case-insensitive collision with the recovered DB: " + f"{output_db}" + ) + + intermediate_paths = ( + *(path for path in artifact_paths if path != recovered_db), + *sqlite_sidecar_paths, + ) + colliding_artifact = next( + ( + artifact_path + for artifact_path in intermediate_paths + if ( + output_key == _path_collision_key(artifact_path) + or _same_file_or_path(output_db, artifact_path) + ) + ), + None, + ) + if colliding_artifact is not None: + raise ValueError( + f"Recovery output collides with an intermediate artifact: {colliding_artifact}" + ) + + write_paths = [*artifact_paths, *sqlite_sidecar_paths, output_db] + + source_paths = [raw_path] + if wal_path is not None: + source_paths.append(wal_path) + for write_path in write_paths: + if any(_same_file_or_path(write_path, source_path) for source_path in source_paths): + raise ValueError( + f"Refusing to overwrite recovery source evidence: {write_path}" + ) + + if any(_same_file_or_path(write_path, live_db) for write_path in write_paths): + raise ValueError( + "Refusing to write recovery output to the live Flocks database. " + "Generate a recovery artifact, stop Flocks, then install it explicitly." + ) + + for write_path in write_paths: + if _is_within(write_path, live_data_dir): + raise ValueError( + "Refusing to write recovery files inside the live Flocks data directory: " + f"{write_path}" + ) + if write_path.exists() or write_path.is_symlink(): + raise FileExistsError(f"Refusing to overwrite existing recovery file: {write_path}") def _resolve_output_paths(raw_path: Path, args: argparse.Namespace) -> tuple[Path, Path, str]: @@ -819,18 +1127,26 @@ def _resolve_output_paths(raw_path: Path, args: argparse.Namespace) -> tuple[Pat ) output_db = artifacts_dir / f"{_sanitize_name(raw_path.name)}.recovered.db" - prefix = args.prefix or output_db.stem + prefix = _sanitize_name(args.prefix or output_db.stem) return artifacts_dir, output_db, prefix +def _copy_file_exclusive(source: Path, destination: Path) -> None: + """Copy a file without ever replacing a destination created after validation.""" + + with _atomic_output_path(destination) as temporary_path: + with source.open("rb") as source_handle, temporary_path.open("xb") as destination_handle: + shutil.copyfileobj(source_handle, destination_handle) + + def build_parser() -> argparse.ArgumentParser: """Build the CLI parser.""" parser = argparse.ArgumentParser( description=( - "Recover a damaged Flocks SQLite DB in one command. The script can " + "Recover an offline copy of a damaged Flocks SQLite DB. The script can " "auto-detect a sibling WAL file and writes the repaired DB plus " - "intermediate artifacts." + "intermediate artifacts without modifying the live store." ) ) parser.add_argument( @@ -855,7 +1171,7 @@ def build_parser() -> argparse.ArgumentParser: "--output", type=Path, default=None, - help="Optional final output DB file path.", + help="Optional new output DB path. Existing files and the live Flocks DB are rejected.", ) parser.add_argument( "--artifacts-dir", @@ -881,14 +1197,31 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: """Run recovery and print artifact locations.""" + # Match the normal ``flocks`` CLI: environment variables already exported + # by the operator win, and missing values are filled from the current + # project's .env before resolving the protected live data directory. + current_env = Path.cwd() / ".env" + if current_env.is_file(): + load_dotenv(current_env) + project_env = Path(__file__).resolve().parent.parent / ".env" + if project_env.is_file() and project_env != current_env: + load_dotenv(project_env) args = build_parser().parse_args(argv) - removed_sidecars = _cleanup_live_sqlite_sidecars() raw_path = _resolve_raw_path(args) if not raw_path.exists(): raise FileNotFoundError(f"Damaged DB file does not exist: {raw_path}") wal_path = args.wal.expanduser().resolve() if args.wal is not None else _detect_wal_path(raw_path) + if wal_path is not None and not wal_path.is_file(): + raise FileNotFoundError(f"WAL file does not exist or is not a file: {wal_path}") artifacts_dir, output_db, prefix = _resolve_output_paths(raw_path, args) + _validate_recovery_plan( + raw_path=raw_path, + wal_path=wal_path, + recovery_dir=artifacts_dir, + output_db=output_db, + prefix=prefix, + ) artifacts = recover_raw_storage_db( raw_path=raw_path, @@ -897,20 +1230,14 @@ def main(argv: Sequence[str] | None = None) -> int: prefix=prefix, ) - if artifacts.recovered_db != output_db: - output_db.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(artifacts.recovered_db, output_db) + if not _same_file_or_path(artifacts.recovered_db, output_db): + _copy_file_exclusive(artifacts.recovered_db, output_db) print(f"input_db={raw_path}") print(f"wal_path={wal_path if wal_path is not None else 'none'}") - print( - "removed_sidecars=" - + ( - ",".join(str(path) for path in removed_sidecars) - if removed_sidecars - else "none" - ) - ) + # Preserve the original machine-readable field for callers while making + # the non-destructive behavior explicit. + print("removed_sidecars=none") print(f"artifacts_dir={artifacts_dir}") print(f"recovered_db={output_db}") print(f"summary_path={artifacts.summary_path}") @@ -920,4 +1247,3 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/tests/browser/test_admin.py b/tests/browser/test_admin.py index ac701077e..52c786431 100644 --- a/tests/browser/test_admin.py +++ b/tests/browser/test_admin.py @@ -1,23 +1,6 @@ from flocks.browser import admin -class FakeSocket: - def __init__(self, response=b'{"target_id":"target-1","session_id":"session-1","page":null}\n'): - self.response = response - self.closed = False - self.sent = b"" - - def sendall(self, data): - self.sent += data - - def recv(self, _size): - output, self.response = self.response, b"" - return output - - def close(self): - self.closed = True - - def test_local_chrome_mode_is_false_when_env_provides_remote_cdp() -> None: assert not admin._is_local_chrome_mode({"BU_CDP_WS": "ws://example.test/devtools/browser/1"}) @@ -74,21 +57,21 @@ def test_generic_remote_debugging_message_triggers_prompt() -> None: assert admin._needs_chrome_remote_debugging_prompt(msg) -def test_daemon_endpoint_names_with_bu_tmp_dir_returns_local_name_when_sock_exists(tmp_path, monkeypatch) -> None: +def test_daemon_endpoint_names_discovers_default_and_named_sessions(tmp_path, monkeypatch) -> None: monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False) - monkeypatch.setattr(admin.ipc, "BU_TMP_DIR", str(tmp_path)) - monkeypatch.setattr(admin.ipc, "_TMP", tmp_path) - monkeypatch.setattr(admin, "NAME", "session-xyz") - (tmp_path / "bu.sock").touch() + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + browser_dir = tmp_path / "browser" + browser_dir.mkdir() + (browser_dir / "bu.sock").touch() + (browser_dir / "bu-session-xyz.sock").touch() + (browser_dir / "bu-stale.pid").touch() - assert admin._daemon_endpoint_names() == ["session-xyz"] + assert admin._daemon_endpoint_names() == ["default", "session-xyz", "stale"] -def test_daemon_endpoint_names_with_bu_tmp_dir_returns_empty_when_sock_missing(tmp_path, monkeypatch) -> None: +def test_daemon_endpoint_names_returns_empty_when_runtime_dir_is_missing(tmp_path, monkeypatch) -> None: monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False) - monkeypatch.setattr(admin.ipc, "BU_TMP_DIR", str(tmp_path)) - monkeypatch.setattr(admin.ipc, "_TMP", tmp_path) - monkeypatch.setattr(admin, "NAME", "session-xyz") + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) assert admin._daemon_endpoint_names() == [] @@ -96,36 +79,43 @@ def test_daemon_endpoint_names_with_bu_tmp_dir_returns_empty_when_sock_missing(t def test_active_browser_connections_counts_only_healthy_daemons(monkeypatch) -> None: monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default", "stale", "remote"]) - def fake_connect(name, timeout=1.0): + def fake_request(name, payload, timeout=1.0): + assert payload == {"meta": "connection_status"} if name == "stale": raise ConnectionRefusedError() if name == "remote": - return FakeSocket(b'{"error":"no close frame received or sent"}\n') - return FakeSocket() + return {"error": "no close frame received or sent"} + return {"target_id": "target-1", "session_id": "session-1", "page": None} - monkeypatch.setattr(admin.ipc, "connect", fake_connect) + monkeypatch.setattr(admin.ipc, "request", fake_request) assert admin.active_browser_connections() == 1 def test_active_browser_connections_skips_daemons_reporting_cdp_disconnected(monkeypatch) -> None: monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default", "stale"]) - def fake_connect(name, timeout=1.0): + def fake_request(name, payload, timeout=1.0): + assert payload == {"meta": "connection_status"} if name == "stale": - return FakeSocket(b'{"error":"cdp_disconnected"}\n') - return FakeSocket() + return {"error": "cdp_disconnected"} + return {"target_id": "target-1", "session_id": "session-1", "page": None} - monkeypatch.setattr(admin.ipc, "connect", fake_connect) + monkeypatch.setattr(admin.ipc, "request", fake_request) assert admin.active_browser_connections() == 1 def test_browser_connections_returns_attached_page(monkeypatch) -> None: monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default"]) - response = ( - b'{"target_id":"target-1","session_id":"session-1",' - b'"page":{"targetId":"target-1","title":"Cat - Wikipedia","url":"https://en.wikipedia.org/wiki/Cat"}}\n' - ) - monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout=1.0: FakeSocket(response)) + response = { + "target_id": "target-1", + "session_id": "session-1", + "page": { + "targetId": "target-1", + "title": "Cat - Wikipedia", + "url": "https://en.wikipedia.org/wiki/Cat", + }, + } + monkeypatch.setattr(admin.ipc, "request", lambda name, payload, timeout=1.0: response) assert admin.browser_connections() == [ { @@ -136,36 +126,69 @@ def test_browser_connections_returns_attached_page(monkeypatch) -> None: def test_stop_all_daemons_restarts_every_visible_endpoint(monkeypatch) -> None: - monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default", "remote_1"]) - restarted = [] - monkeypatch.setattr(admin, "restart_daemon", lambda name=None: restarted.append(name)) + monkeypatch.setattr(admin.lifecycle, "stop_all_daemons", lambda: (["default", "remote_1"], {})) assert admin.stop_all_daemons() == ["default", "remote_1"] - assert restarted == ["default", "remote_1"] def test_daemon_protocol_probe_accepts_current_daemon(monkeypatch) -> None: - responses = [b'{"result":{"targetInfos":[]}}\n', b'{"tabs":[]}\n'] + responses = [{"result": {"targetInfos": []}}, {"tabs": []}] - def fake_connect(name, timeout=3.0): - return FakeSocket(responses.pop(0)) + def fake_request(name, payload, timeout=3.0): + return responses.pop(0) - monkeypatch.setattr(admin.ipc, "connect", fake_connect) + monkeypatch.setattr(admin.ipc, "request", fake_request) assert admin._daemon_has_current_protocol() def test_daemon_protocol_probe_rejects_old_managed_tab_protocol(monkeypatch) -> None: - responses = [b'{"result":{"targetInfos":[]}}\n', b'{"error":"\'method\'"}\n'] + responses = [{"result": {"targetInfos": []}}, {"error": "'method'"}] - def fake_connect(name, timeout=3.0): - return FakeSocket(responses.pop(0)) + def fake_request(name, payload, timeout=3.0): + return responses.pop(0) - monkeypatch.setattr(admin.ipc, "connect", fake_connect) + monkeypatch.setattr(admin.ipc, "request", fake_request) assert not admin._daemon_has_current_protocol() +def test_ensure_daemon_retries_after_lock_holder_exits_without_endpoint(tmp_path, monkeypatch) -> None: + spawned = [] + + class FakeProcess: + def __init__(self, return_code): + self.return_code = return_code + + def poll(self): + return self.return_code + + class FakeLock: + def __init__(self, name): + assert name == "retry-session" + + def acquire(self): + return True + + def release(self): + pass + + def fake_popen(*args, **kwargs): + spawned.append((args, kwargs)) + return FakeProcess(admin.ipc.LOCK_BUSY_EXIT_CODE if len(spawned) == 1 else None) + + daemon_states = iter([False, True]) + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setattr(admin.ipc, "endpoint_reachable", lambda name: False) + monkeypatch.setattr(admin.ipc, "DaemonLock", FakeLock) + monkeypatch.setattr(admin, "daemon_alive", lambda name: next(daemon_states)) + monkeypatch.setattr("subprocess.Popen", fake_popen) + + admin.ensure_daemon(wait=1.0, name="retry-session") + + assert len(spawned) == 2 + + def test_run_doctor_prints_active_browser_connections_and_active_pages(monkeypatch, capsys) -> None: monkeypatch.setattr(admin, "_version", lambda: "0.1.0") monkeypatch.setattr(admin, "_install_mode", lambda: "git") @@ -345,7 +368,9 @@ def test_run_setup_allows_explicit_remote_cdp_without_local_browser(monkeypatch, def test_run_setup_restarts_existing_daemon_for_explicit_remote_cdp(monkeypatch, capsys) -> None: monkeypatch.setenv("BU_CDP_URL", "http://127.0.0.1:19222") monkeypatch.setattr(admin, "daemon_alive", lambda: True) - monkeypatch.setattr(admin, "_chrome_running", lambda: (_ for _ in ()).throw(AssertionError("should not probe browser"))) + monkeypatch.setattr( + admin, "_chrome_running", lambda: (_ for _ in ()).throw(AssertionError("should not probe browser")) + ) restarted = [] ensure_calls = [] monkeypatch.setattr(admin, "restart_daemon", lambda name=None: restarted.append(name)) @@ -385,6 +410,7 @@ def test_run_doctor_accepts_explicit_remote_cdp_without_local_browser(monkeypatc monkeypatch.setattr(admin, "daemon_alive", lambda: True) monkeypatch.setattr(admin, "browser_connections", lambda: []) monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0") + monkeypatch.setattr(admin, "_probe_cdp_endpoint", lambda _name, _value: (True, "CDP handshake succeeded")) assert admin.run_doctor() == 0 @@ -394,6 +420,100 @@ def test_run_doctor_accepts_explicit_remote_cdp_without_local_browser(monkeypatc assert "next action attach; run `flocks browser -c 'print(page_info())'` before setup" in out +def test_run_doctor_rejects_unreachable_explicit_remote_cdp(monkeypatch, capsys) -> None: + monkeypatch.setenv("BU_CDP_URL", "http://127.0.0.1:19222") + monkeypatch.setattr(admin, "_version", lambda: "0.1.0") + monkeypatch.setattr(admin, "_install_mode", lambda: "git") + monkeypatch.setattr(admin, "_chrome_running", lambda: False) + monkeypatch.setattr(admin, "daemon_alive", lambda: True) + monkeypatch.setattr(admin, "browser_connections", lambda: []) + monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0") + monkeypatch.setattr( + admin, + "_probe_cdp_endpoint", + lambda _name, _value: (False, "connection refused"), + ) + + assert admin.run_doctor() == 1 + + out = capsys.readouterr().out + assert "[FAIL] browser target — BU_CDP_URL is unreachable: connection refused" in out + assert "next action fix BU_CDP_URL, then run `flocks browser --setup`" in out + + +def test_probe_cdp_endpoint_rejects_invalid_websocket_url() -> None: + assert admin._probe_cdp_endpoint("BU_CDP_WS", "not-a-websocket") == (False, "invalid WebSocket URL") + + +def test_probe_cdp_url_discovers_websocket_and_performs_cdp_handshake(monkeypatch) -> None: + sent_messages = [] + + class FakeHttpResponse: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def read(self) -> bytes: + return b'{"webSocketDebuggerUrl":"ws://browser.test/devtools/browser/1"}' + + class FakeWebSocket: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def send(self, message: str) -> None: + sent_messages.append(message) + + def recv(self, timeout: float) -> str: + assert timeout == 3.0 + return '{"id":1,"result":{"product":"Chrome/1"}}' + + def fake_urlopen(url: str, timeout: float): + assert url == "http://browser.test/json/version" + assert timeout == 3.0 + return FakeHttpResponse() + + def fake_websocket_connect(url: str, **kwargs): + assert url == "ws://browser.test/devtools/browser/1" + assert kwargs == {"open_timeout": 3.0, "close_timeout": 3.0} + return FakeWebSocket() + + monkeypatch.setattr(admin.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(admin, "websocket_connect", fake_websocket_connect) + + assert admin._probe_cdp_endpoint("BU_CDP_URL", "http://browser.test") == ( + True, + "CDP handshake succeeded", + ) + assert sent_messages == ['{"id": 1, "method": "Browser.getVersion"}'] + + +def test_probe_cdp_websocket_rejects_non_cdp_response(monkeypatch) -> None: + class FakeWebSocket: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def send(self, _message: str) -> None: + pass + + def recv(self, timeout: float) -> str: + return "[]" + + monkeypatch.setattr(admin, "websocket_connect", lambda *_args, **_kwargs: FakeWebSocket()) + + assert admin._probe_cdp_endpoint("BU_CDP_WS", "ws://browser.test/devtools/browser/1") == ( + False, + "Browser.getVersion returned an invalid response", + ) + + def test_chrome_running_on_windows_handles_non_utf8_tasklist_output(monkeypatch) -> None: monkeypatch.setattr("platform.system", lambda: "Windows") monkeypatch.setattr( diff --git a/tests/browser/test_daemon.py b/tests/browser/test_daemon.py index b96634dbb..b791cb869 100644 --- a/tests/browser/test_daemon.py +++ b/tests/browser/test_daemon.py @@ -1,5 +1,9 @@ +import urllib.error +from pathlib import Path + import pytest +from flocks.browser import daemon from flocks.browser.daemon import Daemon @@ -7,7 +11,9 @@ async def test_daemon_managed_tab_registry_round_trip() -> None: daemon = Daemon() - registered = await daemon.handle({"meta": "register_managed_tab", "target_id": "target-1", "url": "https://example.com"}) + registered = await daemon.handle( + {"meta": "register_managed_tab", "target_id": "target-1", "url": "https://example.com"} + ) assert registered["tab"]["targetId"] == "target-1" assert registered["tab"]["url"] == "https://example.com" assert registered["tab"]["current_url"] == "https://example.com" @@ -69,7 +75,81 @@ async def fail_if_called(): assert response == {"result": {"frameId": "frame-1"}} assert daemon.session == "session-2" assert daemon.target_id == "target-1" -from flocks.browser import daemon + + +@pytest.mark.asyncio +async def test_daemon_ping_reports_strict_session_identity() -> None: + browser_daemon = Daemon(name="test-session") + + response = await browser_daemon.handle({"meta": "ping"}) + + assert response["pong"] is True + assert response["name"] == "test-session" + assert type(response["pid"]) is int + assert response["instance_id"] + assert response["protocol_version"] == 1 + + +@pytest.mark.asyncio +async def test_attach_first_page_does_not_register_automatic_blank_tab() -> None: + browser_daemon = Daemon() + + class FakeCDP: + async def send_raw(self, method, params=None, session_id=None): + if method == "Target.getTargets": + return {"targetInfos": []} + if method == "Target.createTarget": + return {"targetId": "bootstrap-1"} + if method == "Target.attachToTarget": + return {"sessionId": "session-1"} + if method == "Target.getTargetInfo": + return {"targetInfo": {"targetId": "bootstrap-1", "url": "about:blank", "type": "page"}} + if method.endswith(".enable"): + return {} + raise AssertionError((method, params, session_id)) + + browser_daemon.cdp = FakeCDP() + await browser_daemon.attach_first_page() + + assert browser_daemon.managed_tabs == {} + + +@pytest.mark.asyncio +async def test_daemon_close_stops_cdp_client() -> None: + browser_daemon = Daemon() + + class FakeCDP: + def __init__(self) -> None: + self.stopped = False + + async def stop(self) -> None: + self.stopped = True + + fake_cdp = FakeCDP() + browser_daemon.cdp = fake_cdp + + await browser_daemon.close() + + assert fake_cdp.stopped is True + assert browser_daemon.cdp is None + + +@pytest.mark.asyncio +async def test_daemon_close_logs_stop_failure_without_hiding_startup_error(monkeypatch) -> None: + browser_daemon = Daemon() + messages = [] + + class FakeCDP: + async def stop(self) -> None: + raise RuntimeError("stop failed") + + browser_daemon.cdp = FakeCDP() + monkeypatch.setattr(daemon, "log", messages.append) + + await browser_daemon.close() + + assert messages == ["CDP client stop failed: stop failed"] + assert browser_daemon.cdp is None def test_is_real_page_filters_edge_internal_pages() -> None: @@ -80,6 +160,148 @@ def test_is_real_page_accepts_normal_https_pages() -> None: assert daemon.is_real_page({"type": "page", "url": "https://example.com"}) +def test_profile_dirs_only_returns_paths_for_requested_os() -> None: + home = Path.home() / "profile-test-home" + local_app_data = home / "AppData/Local" + + mac_profiles = daemon.profile_dirs(system="Darwin", home=home, environ={}) + linux_profiles = daemon.profile_dirs(system="Linux", home=home, environ={}) + windows_profiles = daemon.profile_dirs( + system="Windows", + home=home, + environ={"LOCALAPPDATA": str(local_app_data)}, + ) + + assert all("Library" in path.parts and "Application Support" in path.parts for path in mac_profiles) + assert all(".config" in path.parts or ".var" in path.parts for path in linux_profiles) + assert all(path.is_relative_to(local_app_data) for path in windows_profiles) + + +def test_get_ws_url_skips_unreachable_profile_and_uses_next_candidate(tmp_path, monkeypatch) -> None: + stale_profile = tmp_path / "stale" + healthy_profile = tmp_path / "healthy" + stale_profile.mkdir() + healthy_profile.mkdir() + (stale_profile / "DevToolsActivePort").write_text( + "1111\n/devtools/browser/stale\n", + encoding="utf-8", + ) + (healthy_profile / "DevToolsActivePort").write_text( + "2222\n/devtools/browser/healthy\n", + encoding="utf-8", + ) + + class FakeHttpResponse: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def read(self) -> bytes: + return b'{"webSocketDebuggerUrl":"ws://127.0.0.1:2222/devtools/browser/healthy"}' + + def fake_urlopen(url: str, timeout: float): + if ":1111/" in url: + raise OSError("connection refused") + assert url == "http://127.0.0.1:2222/json/version" + return FakeHttpResponse() + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setattr(daemon, "profile_dirs", lambda: [stale_profile, healthy_profile]) + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + + assert daemon.get_ws_url() == "ws://127.0.0.1:2222/devtools/browser/healthy" + + +def test_get_ws_url_skips_malformed_profile_and_uses_next_candidate(tmp_path, monkeypatch) -> None: + malformed_profile = tmp_path / "malformed" + healthy_profile = tmp_path / "healthy" + malformed_profile.mkdir() + healthy_profile.mkdir() + (malformed_profile / "DevToolsActivePort").write_text("not-a-port-record\n", encoding="utf-8") + (healthy_profile / "DevToolsActivePort").write_text( + "2222\n/devtools/browser/healthy\n", + encoding="utf-8", + ) + + class FakeHttpResponse: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def read(self) -> bytes: + return b'{"webSocketDebuggerUrl":"ws://127.0.0.1:2222/devtools/browser/healthy"}' + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setattr(daemon, "profile_dirs", lambda: [malformed_profile, healthy_profile]) + monkeypatch.setattr(daemon.urllib.request, "urlopen", lambda _url, timeout: FakeHttpResponse()) + + assert daemon.get_ws_url() == "ws://127.0.0.1:2222/devtools/browser/healthy" + + +def test_get_ws_url_skips_open_non_cdp_port_and_uses_next_candidate(tmp_path, monkeypatch) -> None: + stale_profile = tmp_path / "stale" + healthy_profile = tmp_path / "healthy" + stale_profile.mkdir() + healthy_profile.mkdir() + (stale_profile / "DevToolsActivePort").write_text( + "1111\n/devtools/browser/stale\n", + encoding="utf-8", + ) + (healthy_profile / "DevToolsActivePort").write_text( + "2222\n/devtools/browser/healthy\n", + encoding="utf-8", + ) + + class FakeHttpResponse: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def read(self) -> bytes: + return b'{"webSocketDebuggerUrl":"ws://127.0.0.1:2222/devtools/browser/healthy"}' + + def fake_urlopen(url: str, timeout: float): + if ":1111/" in url: + raise OSError("not a CDP endpoint") + assert url == "http://127.0.0.1:2222/json/version" + return FakeHttpResponse() + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setattr(daemon, "profile_dirs", lambda: [stale_profile, healthy_profile]) + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + + assert daemon.get_ws_url() == "ws://127.0.0.1:2222/devtools/browser/healthy" + + +def test_get_ws_url_uses_devtools_active_port_path_when_version_endpoint_returns_404(tmp_path, monkeypatch) -> None: + profile = tmp_path / "chrome" + profile.mkdir() + (profile / "DevToolsActivePort").write_text( + "9222\n/devtools/browser/current\n", + encoding="utf-8", + ) + + def fake_urlopen(url: str, timeout: float): + raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setattr(daemon, "profile_dirs", lambda: [profile]) + monkeypatch.setattr(daemon.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(daemon.time, "sleep", lambda _seconds: pytest.fail("404 response must not be retried")) + + assert daemon.get_ws_url() == "ws://127.0.0.1:9222/devtools/browser/current" + + def test_load_env_uses_shared_loader_for_existing_files(tmp_path, monkeypatch) -> None: workspace = tmp_path / "workspace" workspace.mkdir() diff --git a/tests/browser/test_helpers.py b/tests/browser/test_helpers.py index 6bf28271b..74355c565 100644 --- a/tests/browser/test_helpers.py +++ b/tests/browser/test_helpers.py @@ -158,6 +158,31 @@ def fake_cdp(method, **kwargs): assert {"meta": "register_managed_tab", "target_id": "target-1", "url": "https://example.com"} in sent +def test_new_tab_does_not_reuse_bootstrap_tab() -> None: + calls = [] + + def fake_cdp(method, **kwargs): + calls.append((method, kwargs)) + if method == "Target.createTarget": + return {"targetId": "new-1"} + if method == "Target.attachToTarget": + return {"sessionId": "session-1"} + return {} + + with ( + patch( + "flocks.browser.helpers.managed_tabs", + return_value=[{"targetId": "bootstrap-1", "url": "about:blank", "bootstrap": True}], + ), + patch("flocks.browser.helpers.cdp", side_effect=fake_cdp), + patch("flocks.browser.helpers._send", return_value={"session_id": "session-1"}), + ): + assert helpers.new_tab("https://example.com", activate=True) == "new-1" + + assert ("Target.createTarget", {"url": "about:blank"}) in calls + assert ("Target.activateTarget", {"targetId": "new-1"}) in calls + + def test_close_tab_rejects_unmanaged_tabs_by_default() -> None: with ( patch("flocks.browser.helpers._send", return_value={"tabs": []}), diff --git a/tests/browser/test_ipc.py b/tests/browser/test_ipc.py index 5369c8b77..f6fad2b56 100644 --- a/tests/browser/test_ipc.py +++ b/tests/browser/test_ipc.py @@ -1,5 +1,11 @@ +import asyncio +import json +import stat +import tempfile from pathlib import Path +import pytest + from flocks.browser import _ipc as ipc @@ -14,3 +20,151 @@ def test_browser_ipc_files_live_under_flocks_browser_dir() -> None: assert ipc.sock_addr("default") == "tcp:bu" else: assert ipc.sock_addr("default") == str(expected_dir / "bu.sock") + + +def test_named_sessions_have_isolated_runtime_artifacts(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + + default = ipc.runtime_paths("default") + alpha = ipc.runtime_paths("alpha") + beta = ipc.runtime_paths("beta_2") + + assert default.socket == tmp_path / "browser/bu.sock" + assert default.lock == tmp_path / "browser/bu.lock" + assert alpha.socket == tmp_path / "browser/bu-alpha.sock" + assert alpha.pid != beta.pid + assert not (tmp_path / "browser").exists() + + +@pytest.mark.parametrize("name", ["", "has space", "../escape", "x" * 65]) +def test_invalid_session_names_are_rejected(name) -> None: + with pytest.raises(ValueError, match="invalid BU_NAME"): + ipc.runtime_paths(name) + + +def test_runtime_root_honors_flocks_root(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / "custom")) + + assert ipc.runtime_dir() == tmp_path / "custom/browser" + + +def test_daemon_lock_serializes_same_name_but_not_different_names(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + first = ipc.DaemonLock("alpha") + same_name = ipc.DaemonLock("alpha") + other_name = ipc.DaemonLock("beta") + try: + assert first.acquire() + assert not same_name.acquire() + assert other_name.acquire() + finally: + first.release() + same_name.release() + other_name.release() + + +def test_identify_rejects_boolean_pid(monkeypatch) -> None: + monkeypatch.setattr( + ipc, + "request", + lambda name, payload, timeout=1.0: { + "pong": True, + "protocol_version": 1, + "name": "default", + "pid": True, + "instance_id": "instance-1", + "browser_kind": "local", + }, + ) + + with pytest.raises(ipc.IPCProtocolError, match="invalid PID"): + ipc.identify("default") + + +def test_identify_classifies_explicit_unknown_ping_as_legacy(monkeypatch) -> None: + monkeypatch.setattr( + ipc, + "request", + lambda name, payload, timeout=1.0: {"error": "unknown meta request: ping"}, + ) + + with pytest.raises(ipc.LegacyProtocolError, match="predates"): + ipc.identify("default") + + +def test_read_port_record_supports_legacy_and_authenticated_formats(tmp_path) -> None: + path = tmp_path / "bu.port" + path.write_text("9222", encoding="utf-8") + assert ipc._read_port_record(path) == (9222, None) + + path.write_text(json.dumps({"port": 9333, "token": "secret"}), encoding="utf-8") + assert ipc._read_port_record(path) == (9333, "secret") + + +def test_read_port_record_rejects_malformed_record(tmp_path) -> None: + path = tmp_path / "bu.port" + path.write_text('{"port":"9222","token":"secret"}', encoding="utf-8") + + with pytest.raises(ipc.EndpointRecordError, match="invalid browser daemon port record"): + ipc._read_port_record(path) + + +def test_request_injects_windows_endpoint_token(monkeypatch) -> None: + class FakeSocket: + def __init__(self) -> None: + self.sent = b"" + self.response = b'{"ok":true}\n' + self.closed = False + + def sendall(self, data) -> None: + self.sent += data + + def recv(self, _size) -> bytes: + response, self.response = self.response, b"" + return response + + def close(self) -> None: + self.closed = True + + sock = FakeSocket() + monkeypatch.setattr(ipc, "_connect_with_token", lambda name, timeout: (sock, "secret")) + + assert ipc.request("default", {"meta": "ping"}) == {"ok": True} + assert json.loads(sock.sent) == {"meta": "ping", "_ipc_token": "secret"} + assert sock.closed is True + + +@pytest.mark.asyncio +@pytest.mark.skipif(ipc.IS_WINDOWS, reason="POSIX socket permissions") +async def test_posix_server_uses_private_socket_and_cleans_it_up(monkeypatch) -> None: + with tempfile.TemporaryDirectory(prefix="flocks-ipc-", dir="/tmp") as root: + monkeypatch.setenv("FLOCKS_ROOT", root) + lock = ipc.DaemonLock("test") + assert lock.acquire() + + async def handler(request): + return {"echo": request["value"]} + + task = asyncio.create_task(ipc.serve("test", handler, lock)) + socket_path = ipc.runtime_paths("test").socket + try: + for _ in range(100): + if socket_path.exists() or task.done(): + break + await asyncio.sleep(0.01) + if task.done(): + try: + await task + except PermissionError: + pytest.skip("sandbox does not permit Unix socket binding") + assert socket_path.exists() + assert stat.S_IMODE(socket_path.stat().st_mode) == 0o600 + assert await asyncio.to_thread(ipc.request, "test", {"value": "ok"}) == {"echo": "ok"} + finally: + if not task.done(): + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + lock.release() + + assert not socket_path.exists() diff --git a/tests/browser/test_lifecycle.py b/tests/browser/test_lifecycle.py new file mode 100644 index 000000000..18e4af31c --- /dev/null +++ b/tests/browser/test_lifecycle.py @@ -0,0 +1,193 @@ +import pytest + +from flocks.browser import _ipc as ipc +from flocks.browser import lifecycle + + +def _identity(pid: int = 1234) -> dict: + return { + "pong": True, + "name": "default", + "pid": pid, + "instance_id": "instance-1", + "browser_kind": "local", + "protocol_version": 1, + } + + +def test_stale_pid_file_is_never_used_as_process_identity(monkeypatch) -> None: + cleaned = [] + monkeypatch.setattr(ipc, "identify", lambda name, timeout=1.0: (_ for _ in ()).throw(FileNotFoundError())) + monkeypatch.setattr(lifecycle, "_cleanup_stale", lambda name, lock_timeout=0.0: cleaned.append(name)) + + lifecycle.stop_daemon("default") + + assert cleaned == ["default"] + + +def test_current_daemon_refusal_preserves_endpoint_without_signalling(monkeypatch) -> None: + monkeypatch.setattr(ipc, "identify", lambda name, timeout=1.0: _identity()) + monkeypatch.setattr(ipc, "request", lambda name, payload, timeout=3.0: {"ok": True}) + monkeypatch.setattr(lifecycle, "_wait_until", lambda predicate, timeout: False) + monkeypatch.setattr( + lifecycle, + "_cleanup_stale", + lambda name, lock_timeout=0.0: pytest.fail("live endpoint must be preserved"), + ) + with pytest.raises(RuntimeError, match="refused graceful shutdown"): + lifecycle.stop_daemon("default", timeout=0) + + +def test_current_daemon_stops_gracefully_and_cleans_after_lock_release(monkeypatch) -> None: + requests = [] + cleaned = [] + monkeypatch.setattr(ipc, "identify", lambda name, timeout=1.0: _identity()) + monkeypatch.setattr( + ipc, + "request", + lambda name, payload, timeout=3.0: requests.append(payload) or {"ok": True}, + ) + monkeypatch.setattr(lifecycle, "_wait_until", lambda predicate, timeout: True) + monkeypatch.setattr( + lifecycle, + "_cleanup_stale", + lambda name, lock_timeout=0.0: cleaned.append((name, lock_timeout)), + ) + + lifecycle.stop_daemon("default", timeout=10.0) + + assert requests == [{"meta": "shutdown"}] + assert cleaned == [("default", 3.0)] + + +def test_malformed_endpoint_record_is_cleaned_as_stale_state(monkeypatch) -> None: + cleaned = [] + monkeypatch.setattr( + ipc, + "identify", + lambda name, timeout=1.0: (_ for _ in ()).throw(ipc.EndpointRecordError("invalid port record")), + ) + monkeypatch.setattr( + lifecycle, + "_cleanup_stale", + lambda name, lock_timeout=0.0: cleaned.append(name) or True, + ) + + lifecycle.stop_daemon("default") + + assert cleaned == ["default"] + + +def test_legacy_daemon_only_receives_graceful_shutdown(monkeypatch) -> None: + requests = [] + monkeypatch.setattr( + ipc, + "identify", + lambda name, timeout=1.0: (_ for _ in ()).throw(ipc.LegacyProtocolError("no ping")), + ) + + def fake_request(name, payload, timeout=1.0): + requests.append(payload) + if payload == {"meta": "connection_status"}: + return {"target_id": "target-1", "session_id": "session-1", "page": None} + return {"ok": True} + + monkeypatch.setattr(ipc, "request", fake_request) + monkeypatch.setattr(lifecycle, "_wait_until", lambda predicate, timeout: True) + monkeypatch.setattr(lifecycle, "_cleanup_stale", lambda name, lock_timeout=0.0: None) + + lifecycle.stop_daemon("default", timeout=0) + + assert requests == [{"meta": "connection_status"}, {"meta": "shutdown"}] + + +def test_legacy_daemon_refusal_preserves_endpoint(monkeypatch) -> None: + monkeypatch.setattr( + ipc, + "identify", + lambda name, timeout=1.0: (_ for _ in ()).throw(ipc.LegacyProtocolError("no ping")), + ) + monkeypatch.setattr( + ipc, + "request", + lambda name, payload, timeout=1.0: {"target_id": "target-1", "session_id": "session-1", "page": None}, + ) + monkeypatch.setattr(lifecycle, "_wait_until", lambda predicate, timeout: False) + monkeypatch.setattr( + lifecycle, + "_cleanup_stale", + lambda name, lock_timeout=0.0: pytest.fail("live legacy endpoint must be preserved"), + ) + + with pytest.raises(RuntimeError, match="refused graceful shutdown"): + lifecycle.stop_daemon("default", timeout=0) + + +def test_legacy_named_daemon_is_preserved(monkeypatch) -> None: + monkeypatch.setattr( + ipc, + "identify", + lambda name, timeout=1.0: (_ for _ in ()).throw(ipc.LegacyProtocolError("no ping")), + ) + monkeypatch.setattr( + ipc, + "request", + lambda name, payload, timeout=1.0: pytest.fail("legacy named daemon must not be stopped"), + ) + + with pytest.raises(RuntimeError, match="legacy named daemon.*unsupported"): + lifecycle.stop_daemon("named") + + +def test_locked_unresponsive_session_is_not_cleaned(monkeypatch) -> None: + class FakeLock: + def __init__(self, name): + self.name = name + + def acquire(self): + return False + + monkeypatch.setattr(ipc, "DaemonLock", FakeLock) + monkeypatch.setattr( + ipc, + "cleanup_endpoint", + lambda name, lock: pytest.fail("locked endpoint must not be removed"), + ) + + with pytest.raises(RuntimeError, match="locked but not responding"): + lifecycle._cleanup_stale("default") + + +def test_invalid_current_identity_is_not_treated_as_legacy(monkeypatch) -> None: + monkeypatch.setattr( + ipc, + "identify", + lambda name, timeout=1.0: (_ for _ in ()).throw(ipc.IPCProtocolError("session mismatch")), + ) + monkeypatch.setattr( + ipc, + "request", + lambda name, payload, timeout=1.0: pytest.fail("invalid identity must not receive legacy shutdown"), + ) + + with pytest.raises(RuntimeError, match="invalid identity"): + lifecycle.stop_daemon("default") + + +def test_stop_all_continues_after_one_session_fails(monkeypatch) -> None: + stopped = [] + monkeypatch.setattr(ipc, "discover_names", lambda: ["default", "broken", "other"]) + + def fake_stop(name, timeout=15.0): + stopped.append(name) + if name == "broken": + raise RuntimeError("locked") + return None + + monkeypatch.setattr(lifecycle, "stop_daemon", fake_stop) + + names, errors = lifecycle.stop_all_daemons() + + assert names == ["default", "broken", "other"] + assert stopped == names + assert errors == {"broken": "locked"} diff --git a/tests/browser/test_run.py b/tests/browser/test_run.py index d9003ef8f..2912c5834 100644 --- a/tests/browser/test_run.py +++ b/tests/browser/test_run.py @@ -46,10 +46,11 @@ def test_state_show_prints_summary_without_daemon() -> None: mock_daemon.assert_not_called() -def test_state_save_ensures_daemon_and_prints_result() -> None: +def test_state_save_reuses_connected_daemon_and_prints_result() -> None: stdout = StringIO() with ( patch("flocks.browser.run.save_state", return_value={"path": "/tmp/auth-state.json", "cookies": 2}), + patch("flocks.browser.run.daemon_browser_connected", return_value=True), patch("flocks.browser.run.ensure_daemon") as mock_daemon, patch("flocks.browser.run.print_update_banner") as mock_banner, patch("sys.stdout", stdout), @@ -58,6 +59,18 @@ def test_state_save_ensures_daemon_and_prints_result() -> None: assert '"cookies": 2' in stdout.getvalue() mock_banner.assert_called_once() + mock_daemon.assert_not_called() + + +def test_state_save_ensures_daemon_when_browser_is_disconnected() -> None: + with ( + patch("flocks.browser.run.save_state", return_value={"path": "/tmp/auth-state.json"}), + patch("flocks.browser.run.daemon_browser_connected", return_value=False), + patch("flocks.browser.run.ensure_daemon") as mock_daemon, + patch("flocks.browser.run.print_update_banner"), + ): + run.main(["state", "save", "/tmp/auth-state.json"]) + mock_daemon.assert_called_once() @@ -65,7 +78,7 @@ def test_state_load_passes_optional_flags() -> None: stdout = StringIO() with ( patch("flocks.browser.run.load_state", return_value={"finalUrl": "https://example.com"}) as mock_load, - patch("flocks.browser.run.ensure_daemon"), + patch("flocks.browser.run.ensure_daemon") as mock_daemon, patch("flocks.browser.run.print_update_banner"), patch("sys.stdout", stdout), ): @@ -85,6 +98,7 @@ def test_state_load_passes_optional_flags() -> None: url="https://example.com/dashboard", reload=False, ) + mock_daemon.assert_called_once() assert '"finalUrl": "https://example.com"' in stdout.getvalue() diff --git a/tests/browser/test_skill_docs.py b/tests/browser/test_skill_docs.py new file mode 100644 index 000000000..508214e07 --- /dev/null +++ b/tests/browser/test_skill_docs.py @@ -0,0 +1,14 @@ +from pathlib import Path + + +_ROOT = Path(__file__).resolve().parents[2] +_SKILL_ROOT = _ROOT / ".flocks/plugins/skills/browser-use" + + +def test_browser_skill_documents_isolated_runtime_artifacts_and_safe_cleanup() -> None: + skill = (_SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8") + headless = (_SKILL_ROOT / "references/cdp-headless.md").read_text(encoding="utf-8") + + assert "命名 session 使用 `bu-.*`" in skill + assert "`flocks browser --reload`" in skill + assert 'rm -f "$HOME/.flocks/browser/bu.sock"' not in headless diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index 065325060..ca43b75fb 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -130,8 +130,11 @@ def test_reset_status(self): def test_mark_connected(self): ch = _StubChannel() ch.reset_status("feishu") + ch.mark_disconnected(error="timeout") ch.mark_connected() assert ch.status.connected is True + assert ch.status.last_error is None + assert ch.status.error_count == 1 def test_mark_disconnected_without_error(self): ch = _StubChannel() @@ -1059,10 +1062,10 @@ def test_max_size_eviction(self): # ===================================================================== class TestCheckAllowlist: - def _make_msg(self, *, chat_type=ChatType.DIRECT, sender_id="u1"): + def _make_msg(self, *, chat_type=ChatType.DIRECT, sender_id="u1", channel_id="test", raw=None): return InboundMessage( - channel_id="test", account_id="acc", message_id="m1", - sender_id=sender_id, chat_type=chat_type, + channel_id=channel_id, account_id="acc", message_id="m1", + sender_id=sender_id, chat_type=chat_type, raw=raw, ) def test_dm_open_allows_all(self): @@ -1097,6 +1100,28 @@ def test_group_no_allow_from_passes(self): msg = self._make_msg(chat_type=ChatType.GROUP) assert _check_allowlist(msg, cfg) is True + def test_whatsapp_dm_allowlist_matches_lid_alias(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(dm_policy="allowlist", allow_from=["25482795991095@lid"]) + msg = self._make_msg(channel_id="whatsapp", sender_id="25482795991095") + assert _check_allowlist(msg, cfg) is True + + def test_whatsapp_group_allowlist_matches_lid_alias(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(allow_from=["25482795991095@lid"]) + msg = self._make_msg(channel_id="whatsapp", chat_type=ChatType.GROUP, sender_id="25482795991095") + assert _check_allowlist(msg, cfg) is True + + def test_whatsapp_dm_allowlist_matches_sender_aliases(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(dm_policy="allowlist", allow_from=["8618803405095"]) + msg = self._make_msg( + channel_id="whatsapp", + sender_id="25482795991095", + raw={"senderAliases": ["25482795991095@lid", "8618803405095@s.whatsapp.net"]}, + ) + assert _check_allowlist(msg, cfg) is True + class TestFeishuGroupContext: def test_group_override_can_disable_top_level_context_cache(self): @@ -1423,7 +1448,7 @@ def test_sanitize_filename_preserves_unicode_names(self): def test_sanitize_filename_removes_path_separators(self): from flocks.channel.media_filename import sanitize_filename - assert sanitize_filename("../report.bin") == ".._report.bin" + assert sanitize_filename("../report.bin") == "report.bin" # ------------------------------------------------------------------ diff --git a/tests/channel/test_email.py b/tests/channel/test_email.py new file mode 100644 index 000000000..c3f853a68 --- /dev/null +++ b/tests/channel/test_email.py @@ -0,0 +1,690 @@ +from __future__ import annotations + +import asyncio +import uuid +import email as email_lib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from unittest.mock import MagicMock + +import pytest + +from flocks.channel.base import ChatType, OutboundContext +from flocks.channel.builtin.email.channel import EmailChannel +from flocks.channel.builtin.email.config import parse_allowed_senders, resolved_config +from flocks.channel.builtin.email.inbound import ( + build_inbound_message, + decode_header_value, + extract_text_body, + strip_html, + verify_sender_authentication, +) + + +def _raw_email( + *, + sender: str = "User ", + subject: str = "Hello", + body: str = "Test body", + auth_results: str | None = None, +) -> bytes: + msg = MIMEText(body, "plain", "utf-8") + msg["From"] = sender + msg["Subject"] = subject + msg["Message-ID"] = f"<{uuid.uuid4().hex[:8]}@example.com>" + if auth_results: + msg["Authentication-Results"] = auth_results + return msg.as_bytes() + + +def test_email_channel_meta_and_validate_config() -> None: + plugin = EmailChannel() + + assert plugin.meta().id == "email" + assert "mail" in plugin.meta().aliases + assert ChatType.DIRECT in plugin.capabilities().chat_types + assert "address" in (plugin.validate_config({}) or "") + + error = plugin.validate_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + }) + assert error and "allowFrom" in error + + assert plugin.validate_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowFrom": ["user@example.com"], + "authservId": "mx.example.com", + }) is None + + error = plugin.validate_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowFrom": ["user@example.com"], + }) + assert error and "authservId" in error + + +def test_email_channel_registered_as_builtin() -> None: + from flocks.channel.registry import ChannelRegistry + + registry = ChannelRegistry() + registry._register_builtin_channels() + + assert registry.get("email") is not None + assert registry.get("mail") is registry.get("email") + + +def test_config_normalizes_allowed_senders() -> None: + cfg = resolved_config({ + "address": "Agent@Example.COM", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowFrom": "User , second@example.com", + }) + + assert cfg["address"] == "agent@example.com" + assert parse_allowed_senders(cfg) == {"user@example.com", "second@example.com"} + + +def test_email_parsing_helpers() -> None: + assert decode_header_value("=?utf-8?B?TWVyaGFiYQ==?=") == "Merhaba" + assert strip_html("

Hello
world & team

") == "Hello\nworld & team" + + msg = MIMEMultipart("alternative") + msg.attach(MIMEText("

HTML

", "html", "utf-8")) + msg.attach(MIMEText("Plain", "plain", "utf-8")) + assert extract_text_body(msg) == "Plain" + + +def test_build_inbound_message_includes_subject_and_attachment_names() -> None: + msg = MIMEMultipart() + msg["From"] = "User " + msg["Subject"] = "Investigate" + msg["Message-ID"] = "" + msg.attach(MIMEText("Please check this.", "plain", "utf-8")) + + attachment = MIMEText("payload", "plain", "utf-8") + attachment.add_header("Content-Disposition", "attachment", filename="report.txt") + msg.attach(attachment) + + parsed = build_inbound_message(msg, uid="1", account_id="default", skip_attachments=False) + + assert parsed is not None + inbound = parsed.inbound + assert inbound.channel_id == "email" + assert inbound.chat_type == ChatType.DIRECT + assert inbound.sender_id == "user@example.com" + assert inbound.chat_id == "user@example.com" + assert "[Subject: Investigate]" in inbound.text + assert "report.txt" in inbound.text + + +def test_sender_authentication_accepts_dmarc_pass() -> None: + msg = MIMEText("hi", "plain", "utf-8") + msg["Authentication-Results"] = "mx.example.com; dmarc=pass header.from=example.com" + + assert verify_sender_authentication( + msg, + "user@example.com", + authserv_id="mx.example.com", + )[0] is True + + +def test_sender_authentication_uses_only_trusted_authserv_id() -> None: + msg = MIMEText("hi", "plain", "utf-8") + msg["Authentication-Results"] = "attacker.example; dmarc=pass header.from=example.com" + msg["Authentication-Results"] = "mx.example.com; dmarc=fail header.from=example.com" + + assert verify_sender_authentication( + msg, + "user@example.com", + authserv_id="mx.example.com", + )[0] is False + + +def test_sender_authentication_rejects_without_authserv_id() -> None: + msg = MIMEText("hi", "plain", "utf-8") + msg["Authentication-Results"] = "mx.example.com; dmarc=pass header.from=example.com" + + assert verify_sender_authentication(msg, "user@example.com")[0] is False + + +def test_sender_authentication_rejects_subdomain_authserv_id() -> None: + msg = MIMEText("hi", "plain", "utf-8") + msg["Authentication-Results"] = "evil.mx.example.com; dmarc=pass header.from=example.com" + + assert not verify_sender_authentication( + msg, + "user@example.com", + authserv_id="mx.example.com", + )[0] + + +def test_build_resolved_defaults_apply_ssl_and_starttls_modes() -> None: + cfg = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "imapPort": 143, + "smtpPort": 465, + }) + + assert cfg["imapSecurity"] == "starttls" + assert cfg["smtpSecurity"] == "ssl" + + +def test_custom_ports_require_explicit_security_mode() -> None: + plugin = EmailChannel() + assert ( + plugin.validate_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "imapPort": 1143, + "smtpPort": 1993, + }) + == "IMAP security must be one of: ssl, starttls, insecure" + ) + + +def test_validate_insecure_requires_explicit_risk_confirmation() -> None: + plugin = EmailChannel() + assert plugin.validate_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "imapPort": 1143, + "smtpPort": 1993, + "imapSecurity": "insecure", + "smtpSecurity": "insecure", + }) == "IMAP insecure mode requires allowInsecureConnections=true" + + trusted_pass = MIMEText("hi", "plain", "utf-8") + trusted_pass["Authentication-Results"] = "attacker.example; dmarc=fail header.from=example.com" + trusted_pass["Authentication-Results"] = "mx.example.com; dmarc=pass header.from=example.com" + + assert verify_sender_authentication( + trusted_pass, + "user@example.com", + authserv_id="mx.example.com", + )[0] is True + + +def test_parse_and_authorize_blocks_unallowlisted_sender() -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowFrom": ["allowed@example.com"], + }) + msg = MIMEText("hello", "plain", "utf-8") + msg["From"] = "user@example.com" + msg["Subject"] = "Hello" + msg["Message-ID"] = "" + + assert plugin._parse_and_authorize(msg, "1") is None + + +def test_parse_and_authorize_requires_authenticated_sender_for_allowlist() -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowFrom": ["user@example.com"], + "authservId": "mx.example.com", + }) + msg = MIMEText("hello", "plain", "utf-8") + msg["From"] = "user@example.com" + msg["Subject"] = "Hello" + msg["Message-ID"] = "" + + assert plugin._parse_and_authorize(msg, "1") is None + + msg["Authentication-Results"] = "mx.example.com; dmarc=pass header.from=example.com" + inbound = plugin._parse_and_authorize(msg, "2") + assert inbound is not None + assert inbound.sender_id == "user@example.com" + + +def test_send_email_threads_reply(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowAll": True, + }) + plugin._thread_context["user@example.com"] = { + "subject": "Question", + "message_id": "", + "references": "", + } + + sent = {} + + class FakeSMTP: + def login(self, address, password): + sent["login"] = (address, password) + + def send_message(self, msg): + sent["msg"] = msg + + def quit(self): + sent["quit"] = True + + monkeypatch.setattr(plugin, "_connect_smtp", lambda: FakeSMTP()) + + message_id = plugin._send_email("user@example.com", "reply", None, None, None) + + assert message_id.startswith("" + assert msg["References"] == "" + + +def test_send_email_prefers_requested_thread_context(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowAll": True, + }) + plugin._thread_context["user@example.com"] = { + "subject": "Second question", + "message_id": "", + "references": "", + } + plugin._thread_context[""] = { + "subject": "First question", + "message_id": "", + "references": " ", + } + + sent = {} + + class FakeSMTP: + def login(self, address, password): + pass + + def send_message(self, msg): + sent["msg"] = msg + + def quit(self): + pass + + monkeypatch.setattr(plugin, "_connect_smtp", lambda: FakeSMTP()) + + plugin._send_email( + "user@example.com", + "reply", + "", + "", + None, + ) + + msg = sent["msg"] + assert msg["Subject"] == "Re: First question" + assert msg["In-Reply-To"] == "" + assert msg["References"] == "" + + +def test_email_password_is_extracted_to_secret(monkeypatch: pytest.MonkeyPatch) -> None: + from flocks.security.channel_secrets import extract_channel_secrets + + class FakeSecretManager: + def __init__(self) -> None: + self.values = {} + + def set(self, key: str, value: str) -> None: + self.values[key] = value + + fake = FakeSecretManager() + monkeypatch.setattr("flocks.security.secrets.get_secret_manager", lambda: fake) + + result = extract_channel_secrets({ + "email": { + "enabled": True, + "address": "agent@example.com", + "password": "plain-password", + } + }) + + assert result["email"]["password"] == "{secret:channel_email_password}" + assert fake.values["channel_email_password"] == "plain-password" + + +@pytest.mark.asyncio +async def test_send_text_rejects_invalid_target() -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowAll": True, + }) + + result = await plugin.send_text( + OutboundContext(channel_id="email", to="not-an-email", text="hello") + ) + + assert result.success is False + assert "valid recipient" in (result.error or "") + + +def test_fetch_new_messages_skips_malformed_imap_response(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowAll": True, + }) + + fetch_calls = iter([ + ("OK", [None]), + ("OK", [(b"2 (RFC822 {123}", _raw_email(auth_results="mx.example.com; dmarc=pass header.from=example.com"))]), + ]) + + fake_imap = MagicMock() + fake_imap.uid.side_effect = lambda command, *args: ( + ("OK", [b"1 2"]) if command == "search" else next(fetch_calls) + ) + + monkeypatch.setattr( + "flocks.channel.builtin.email.channel.imaplib.IMAP4_SSL", + lambda *args, **kwargs: fake_imap, + ) + + messages = plugin._fetch_new_messages() + + assert len(messages) == 1 + _, inbound = messages[0] + assert inbound.sender_id == "user@example.com" + + +def test_connect_smtp_starttls_fails_when_not_supported(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeSMTP: + def __init__(self, *args, **kwargs): + self.started = False + + def starttls(self, context=None): + self.started = True + return 421, b"not-supported" + + def login(self, *_args, **_kwargs): + pass + + def send_message(self, _msg): + pass + + def quit(self): + pass + + def close(self): + pass + + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "smtpSecurity": "starttls", + "smtpPort": 587, + }) + fake = FakeSMTP() + monkeypatch.setattr("flocks.channel.builtin.email.channel.smtplib.SMTP", lambda *args, **kwargs: fake) + + with pytest.raises(RuntimeError, match="SMTP STARTTLS not available"): + plugin._connect_smtp() + + +def test_connect_imap_insecure_does_not_call_starttls(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeIMAP: + def __init__(self, *args, **kwargs): + self.started = False + + def starttls(self, ssl_context=None): + self.started = True + return ("OK", b"") + + def close(self): + pass + + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "imapSecurity": "insecure", + "imapPort": 1143, + "allowInsecureConnections": True, + }) + plugin._resolved["smtpSecurity"] = "starttls" + plugin._resolved["smtpPort"] = 587 + fake = FakeIMAP() + monkeypatch.setattr("flocks.channel.builtin.email.channel.imaplib.IMAP4", lambda *args, **kwargs: fake) + + conn = plugin._connect_imap() + assert conn is fake + assert fake.started is False + + +def test_connect_imap_starttls_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeIMAP: + def __init__(self, *args, **kwargs): + pass + + def starttls(self, ssl_context=None): + return ("NO", b"not supported") + + def close(self): + pass + + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "imapSecurity": "starttls", + "imapPort": 143, + "smtpSecurity": "starttls", + "smtpPort": 587, + }) + monkeypatch.setattr("flocks.channel.builtin.email.channel.imaplib.IMAP4", FakeIMAP) + + with pytest.raises(RuntimeError, match="IMAP STARTTLS not available"): + plugin._connect_imap() + + +def test_build_inbound_message_sanitizes_attachment_filename() -> None: + from email.mime.multipart import MIMEMultipart + + msg = MIMEMultipart() + msg["From"] = "User " + msg["Subject"] = "Case" + msg["Message-ID"] = "" + msg.attach(MIMEText("See attachment", "plain", "utf-8")) + + attachment = MIMEText("payload", "plain", "utf-8") + attachment.add_header("Content-Disposition", 'attachment; filename="../../report.exe"') + msg.attach(attachment) + + parsed = build_inbound_message(msg, uid="1", account_id="default", skip_attachments=False) + + assert parsed is not None + assert ".._" not in parsed.inbound.text + assert "../" not in parsed.inbound.text + assert "report" in parsed.inbound.text + + +def test_fetch_new_messages_marks_seen_for_rejected_sender(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowFrom": ["allowed@example.com"], + "allowAll": False, + }) + + fake_imap = MagicMock() + monkeypatch.setattr( + "flocks.channel.builtin.email.channel.imaplib.IMAP4_SSL", + lambda *args, **kwargs: fake_imap, + ) + fake_imap.uid.side_effect = lambda command, *args: ( + ("OK", [b"1"]) if command == "search" else ("OK", [(b"1 (BODY.PEEK[] {123})", _raw_email(sender="user@example.com", auth_results="mx.example.com; dmarc=pass header.from=example.com"))]) + ) + + messages = plugin._fetch_new_messages() + + assert messages == [] + assert b"1" in plugin._seen_uids + + +def test_fetch_new_messages_does_not_mark_seen_when_parse_fails(monkeypatch: pytest.MonkeyPatch) -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowAll": True, + }) + + fake_imap = MagicMock() + fake_imap.uid.side_effect = lambda command, *args: ( + ("OK", [b"1"]) if command == "search" else ("OK", [(b"1 (BODY.PEEK[] {123})", b"invalid-bytes")]) + ) + monkeypatch.setattr( + "flocks.channel.builtin.email.channel.imaplib.IMAP4_SSL", + lambda *args, **kwargs: fake_imap, + ) + monkeypatch.setattr( + "flocks.channel.builtin.email.channel.email_lib.message_from_bytes", + lambda _raw: (_ for _ in ()).throw(ValueError("parse error")), + ) + + messages = plugin._fetch_new_messages() + + assert messages == [] + assert b"1" not in plugin._seen_uids + + +@pytest.mark.asyncio +async def test_start_marks_channel_connected_after_successful_poll( + monkeypatch: pytest.MonkeyPatch, +) -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowAll": True, + "pollIntervalSeconds": 1, + }) + plugin._test_connections = lambda: None + + parsed = build_inbound_message( + email_lib.message_from_bytes( + _raw_email( + sender="user@example.com", + auth_results="mx.example.com; dmarc=pass header.from=example.com", + ) + ), + uid="1", + account_id="default", + skip_attachments=True, + ) + assert parsed is not None + + async def on_message(_: InboundMessage) -> None: + return None + + async def _delay_set(event: asyncio.Event) -> None: + await asyncio.sleep(0.01) + event.set() + + monkeypatch.setattr(plugin, "_fetch_new_messages", lambda: [(b"1", parsed.inbound)]) + + abort_event = asyncio.Event() + loop = asyncio.get_running_loop() + loop.create_task(_delay_set(abort_event)) + + await plugin.start({}, on_message, abort_event) + + assert plugin.status.connected is True + assert b"1" in plugin._seen_uids + + +@pytest.mark.asyncio +async def test_start_keeps_uid_unseen_when_dispatch_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + plugin = EmailChannel() + plugin._resolved = resolved_config({ + "address": "agent@example.com", + "password": "pw", + "imapHost": "imap.example.com", + "smtpHost": "smtp.example.com", + "allowAll": True, + "pollIntervalSeconds": 1, + }) + plugin._test_connections = lambda: None + + parsed = build_inbound_message( + email_lib.message_from_bytes( + _raw_email( + sender="user@example.com", + auth_results="mx.example.com; dmarc=pass header.from=example.com", + ) + ), + uid="2", + account_id="default", + skip_attachments=True, + ) + assert parsed is not None + messages = [(b"2", parsed.inbound)] + monkeypatch.setattr(plugin, "_fetch_new_messages", lambda: messages) + + async def on_message(_: InboundMessage) -> None: + raise RuntimeError("dispatch fail") + + async def _delay_set(event: asyncio.Event) -> None: + await asyncio.sleep(0.01) + event.set() + + abort_event = asyncio.Event() + loop = asyncio.get_running_loop() + loop.create_task(_delay_set(abort_event)) + + await plugin.start({}, on_message, abort_event) + + assert b"2" not in plugin._seen_uids diff --git a/tests/channel/test_whatsapp.py b/tests/channel/test_whatsapp.py new file mode 100644 index 000000000..b4beea23c --- /dev/null +++ b/tests/channel/test_whatsapp.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from flocks.channel.base import ChatType, OutboundContext +from flocks.channel.builtin.whatsapp.channel import WhatsAppChannel +from flocks.channel.builtin.whatsapp.config import ( + identifier_aliases, + matches_identifier, + normalize_jid, + parse_target, + strip_jid, +) +from flocks.channel.builtin.whatsapp.bridge_runtime import NODE_USE_BUNDLED_CA_OPTION, append_node_options +from flocks.channel.builtin.whatsapp.inbound import build_inbound_message +from flocks.channel.builtin.whatsapp import pairing +from flocks.channel.registry import ChannelRegistry + + +def test_whatsapp_channel_metadata_and_capabilities(): + channel = WhatsAppChannel() + + assert channel.meta().id == "whatsapp" + assert "wa" in channel.meta().aliases + assert channel.capabilities().media is True + assert channel.capabilities().chat_types == [ChatType.DIRECT, ChatType.GROUP] + + +def test_registry_registers_builtin_whatsapp_channel(): + registry = ChannelRegistry() + registry.init() + + plugin = registry.get("whatsapp") + assert plugin is not None + assert plugin.meta().label == "WhatsApp" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("+15551234567", "15551234567@s.whatsapp.net"), + ("15551234567", "15551234567@s.whatsapp.net"), + ("120363001234@g.us", "120363001234@g.us"), + ("abc123@lid", "abc123@lid"), + ("whatsapp:+15551234567", "15551234567@s.whatsapp.net"), + ("15551234567:12@s.whatsapp.net", "15551234567@s.whatsapp.net"), + ], +) +def test_normalize_and_parse_targets(raw: str, expected: str): + assert parse_target(raw) == expected + assert normalize_jid(raw.replace("whatsapp:", "")) == expected + + +def test_strip_jid_and_allowlist_alias_matching(): + assert strip_jid("15551234567@s.whatsapp.net") == "15551234567" + assert matches_identifier("15551234567@s.whatsapp.net", ["+15551234567"]) + assert matches_identifier("abc123@lid", ["abc123"]) + assert matches_identifier(["25482795991095@lid", "8618803405095@s.whatsapp.net"], ["8618803405095"]) + assert matches_identifier("120363001234@g.us", ["120363001234@g.us"]) + assert not matches_identifier("15550000000@s.whatsapp.net", ["15551234567"]) + + +def test_identifier_aliases_preserves_lid_and_phone_aliases(): + assert identifier_aliases("25482795991095@lid", "8618803405095@s.whatsapp.net") == [ + "25482795991095@lid", + "25482795991095", + "8618803405095@s.whatsapp.net", + "8618803405095", + ] + + +def test_build_inbound_dm_message(): + msg = build_inbound_message({ + "messageId": "m1", + "chatId": "15551234567@s.whatsapp.net", + "senderId": "15551234567@s.whatsapp.net", + "senderName": "Alice", + "body": "hello", + "isGroup": False, + }) + + assert msg is not None + assert msg.channel_id == "whatsapp" + assert msg.chat_type == ChatType.DIRECT + assert msg.sender_id == "15551234567" + assert msg.chat_id == "15551234567@s.whatsapp.net" + assert msg.text == "hello" + assert msg.message_id == "15551234567@s.whatsapp.net:m1" + + +def test_build_inbound_message_preserves_sender_alt_aliases(): + msg = build_inbound_message({ + "messageId": "m1", + "chatId": "25482795991095@lid", + "chatAltId": "8618803405095@s.whatsapp.net", + "senderId": "25482795991095@lid", + "senderAltId": "8618803405095@s.whatsapp.net", + "body": "hello", + "isGroup": False, + }) + + assert msg is not None + assert msg.sender_id == "25482795991095" + assert msg.raw["senderAliases"] == [ + "25482795991095@lid", + "25482795991095", + "8618803405095@s.whatsapp.net", + "8618803405095", + ] + assert msg.raw["chatAliases"] == [ + "25482795991095@lid", + "25482795991095", + "8618803405095@s.whatsapp.net", + "8618803405095", + ] + + +def test_build_inbound_group_mention_message(): + msg = build_inbound_message({ + "messageId": "m2", + "chatId": "120363001234@g.us", + "senderId": "15551234567@s.whatsapp.net", + "body": "@bot investigate this", + "isGroup": True, + "mentioned": True, + "mentionText": "investigate this", + "quotedMessageId": "q1", + }) + + assert msg is not None + assert msg.chat_type == ChatType.GROUP + assert msg.mentioned is True + assert msg.mention_text == "investigate this" + assert msg.reply_to_id == "q1" + + +def test_build_inbound_media_message(tmp_path: Path): + media = tmp_path / "image.jpg" + media.write_bytes(b"jpg") + + msg = build_inbound_message({ + "messageId": "m3", + "chatId": "15551234567@s.whatsapp.net", + "senderId": "15551234567@s.whatsapp.net", + "body": "", + "mediaUrls": [str(media)], + "mime": "image/jpeg", + }) + + assert msg is not None + assert msg.media_url == str(media) + assert msg.media_mime == "image/jpeg" + + +def test_whatsapp_channel_allowlist_matches_sender_alt_alias(): + channel = WhatsAppChannel() + channel._dm_policy = "allowlist" # type: ignore[attr-defined] + channel._allow_from = ["8618803405095"] # type: ignore[attr-defined] + msg = build_inbound_message({ + "messageId": "m1", + "chatId": "25482795991095@lid", + "senderId": "25482795991095@lid", + "senderAltId": "8618803405095@s.whatsapp.net", + "body": "hello", + }) + + assert msg is not None + assert channel._is_allowed(msg) is True # type: ignore[attr-defined] + + +def test_validate_config_requires_valid_mode_and_pairing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + channel = WhatsAppChannel() + monkeypatch.setattr("flocks.channel.builtin.whatsapp.channel.find_executable", lambda name: "/usr/bin/node") + + assert channel.validate_config({"mode": "bad"}) == "WhatsApp mode must be 'bot' or 'self-chat'" + + session = tmp_path / "session" + session.mkdir() + assert "not paired" in (channel.validate_config({"sessionPath": str(session)}) or "") + + (session / "creds.json").write_text("{}", encoding="utf-8") + assert channel.validate_config({"sessionPath": str(session), "bridgePort": 3100}) is None + + +@pytest.mark.asyncio +async def test_send_text_posts_to_bridge(monkeypatch: pytest.MonkeyPatch): + channel = WhatsAppChannel() + channel._http = _FakeSession({"success": True, "messageId": "wa1"}) # type: ignore[attr-defined] + channel._send_timeout_ms = 1000 # type: ignore[attr-defined] + channel._bridge_port = 3100 # type: ignore[attr-defined] + + result = await channel.send_text( + OutboundContext(channel_id="whatsapp", to="+15551234567", text="hello", reply_to_id="q1") + ) + + assert result.success is True + assert result.message_id == "wa1" + assert result.chat_id == "15551234567@s.whatsapp.net" + assert channel._http.calls == [ # type: ignore[attr-defined] + ( + "http://127.0.0.1:3100/send", + { + "chatId": "15551234567@s.whatsapp.net", + "message": "hello", + "replyTo": "q1", + }, + {"X-Flocks-Bridge-Token": channel._bridge_token}, # type: ignore[attr-defined] + ) + ] + + +@pytest.mark.asyncio +async def test_send_text_strips_composite_reply_id(): + channel = WhatsAppChannel() + channel._http = _FakeSession({"success": True, "messageId": "wa1"}) # type: ignore[attr-defined] + channel._send_timeout_ms = 1000 # type: ignore[attr-defined] + channel._bridge_port = 3100 # type: ignore[attr-defined] + + result = await channel.send_text( + OutboundContext( + channel_id="whatsapp", + to="+15551234567", + text="hello", + reply_to_id="15551234567@s.whatsapp.net:q1", + ) + ) + + assert result.success is True + assert channel._http.calls[0][1]["replyTo"] == "q1" # type: ignore[attr-defined] + + +def test_bridge_identity_requires_matching_session_and_config(tmp_path: Path): + channel = WhatsAppChannel() + channel._session_path = tmp_path / "session" # type: ignore[attr-defined] + channel._media_cache_dir = tmp_path / "media" # type: ignore[attr-defined] + channel._mode = "bot" # type: ignore[attr-defined] + channel._reply_prefix = "" # type: ignore[attr-defined] + channel._send_chunk_delay_ms = 300 # type: ignore[attr-defined] + channel._send_timeout_ms = 60000 # type: ignore[attr-defined] + script = tmp_path / "bridge.js" + script.write_text("console.log('bridge')", encoding="utf-8") + + matching = { + "scriptHash": "placeholder", + "sessionPath": str(channel._session_path), # type: ignore[attr-defined] + "mediaDir": str(channel._media_cache_dir), # type: ignore[attr-defined] + "mode": "bot", + "configHash": channel._bridge_config_hash(), # type: ignore[attr-defined] + } + from flocks.channel.builtin.whatsapp.bridge_runtime import file_hash + + matching["scriptHash"] = file_hash(script) + assert channel._bridge_identity_matches(matching, script) is True # type: ignore[attr-defined] + + mismatched = {**matching, "sessionPath": str(tmp_path / "other")} + assert channel._bridge_identity_matches(mismatched, script) is False # type: ignore[attr-defined] + + +def test_append_node_options_preserves_existing_and_dedupes(): + env = {"NODE_OPTIONS": "--trace-warnings"} + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + + assert env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + append_node_options(env, NODE_USE_BUNDLED_CA_OPTION) + + assert env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + +@pytest.mark.asyncio +async def test_channel_bridge_process_uses_bundled_ca_node_option( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + + channel = WhatsAppChannel() + channel._bridge_dir = bridge_dir # type: ignore[attr-defined] + channel._session_path = tmp_path / "session" # type: ignore[attr-defined] + channel._media_cache_dir = tmp_path / "media" # type: ignore[attr-defined] + captured_env: dict[str, str] = {} + + async def fake_reuse(script: Path) -> bool: + return False + + async def fake_wait(script: Path) -> None: + return None + + class FakePopen: + def __init__(self, *args, **kwargs): + captured_env.update(kwargs.get("env") or {}) + + monkeypatch.setenv("NODE_OPTIONS", "--trace-warnings") + monkeypatch.setattr(channel, "_reuse_or_stop_existing_bridge", fake_reuse) + monkeypatch.setattr(channel, "_wait_for_bridge", fake_wait) + monkeypatch.setattr("flocks.channel.builtin.whatsapp.channel.find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr("flocks.channel.builtin.whatsapp.channel.subprocess.Popen", FakePopen) + + await channel._start_bridge() # type: ignore[attr-defined] + if channel._bridge_log_fh: # type: ignore[attr-defined] + channel._bridge_log_fh.close() # type: ignore[attr-defined] + + assert captured_env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + +@pytest.mark.asyncio +async def test_pairing_rejects_running_session_after_dependency_bootstrap( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + session = tmp_path / "session" + session.mkdir() + (session / "bridge.pid").write_text(str(__import__("os").getpid()), encoding="utf-8") + calls: list[Path] = [] + + async def fake_ensure(path: Path) -> None: + calls.append(path) + + monkeypatch.setattr(pairing, "find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr(pairing, "ensure_bridge_deps", fake_ensure) + + with pytest.raises(RuntimeError, match="already running"): + await pairing.start_pairing(session_path=str(session), bridge_dir=str(bridge_dir)) + + assert calls == [bridge_dir] + + +@pytest.mark.asyncio +async def test_pairing_rejects_concurrent_same_session_before_dependency_bootstrap( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + session = tmp_path / "session" + entered_ensure = asyncio.Event() + release_ensure = asyncio.Event() + + class FakeStream: + async def readline(self) -> bytes: + return b"" + + class FakeProcess: + stdout = FakeStream() + returncode = 0 + + async def wait(self) -> int: + return 0 + + def terminate(self) -> None: + pass + + def kill(self) -> None: + pass + + async def fake_create_subprocess_exec(*args, **kwargs): + return FakeProcess() + + async def fake_ensure(path: Path) -> None: + entered_ensure.set() + await release_ensure.wait() + + monkeypatch.setattr(pairing, "find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr(pairing, "ensure_bridge_deps", fake_ensure) + monkeypatch.setattr(pairing.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + first = asyncio.create_task(pairing.start_pairing(session_path=str(session), bridge_dir=str(bridge_dir))) + await entered_ensure.wait() + + with pytest.raises(RuntimeError, match="already running"): + await pairing.start_pairing(session_path=str(session), bridge_dir=str(bridge_dir)) + + release_ensure.set() + session_info = await first + await pairing.cancel_pairing(session_info.id) + + +@pytest.mark.asyncio +async def test_pairing_reset_session_backs_up_existing_credentials( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("console.log('bridge')", encoding="utf-8") + session = tmp_path / "session" + session.mkdir() + (session / "creds.json").write_text("old", encoding="utf-8") + captured_env: dict[str, str] = {} + + class FakeStream: + async def readline(self) -> bytes: + return b"" + + class FakeProcess: + stdout = FakeStream() + returncode = 0 + + async def wait(self) -> int: + return 0 + + def terminate(self) -> None: + pass + + def kill(self) -> None: + pass + + async def fake_create_subprocess_exec(*args, **kwargs): + captured_env.update(kwargs.get("env") or {}) + return FakeProcess() + + async def fake_ensure(path: Path) -> None: + pass + + monkeypatch.setattr(pairing, "find_executable", lambda name: "/usr/bin/node") + monkeypatch.setattr(pairing, "ensure_bridge_deps", fake_ensure) + monkeypatch.setattr(pairing.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setenv("NODE_OPTIONS", "--trace-warnings") + + session_info = await pairing.start_pairing( + session_path=str(session), + bridge_dir=str(bridge_dir), + reset_session=True, + ) + + backups = list(tmp_path.glob("session.backup.*")) + assert len(backups) == 1 + assert (backups[0] / "creds.json").read_text(encoding="utf-8") == "old" + assert session.exists() + assert not (session / "creds.json").exists() + assert captured_env["NODE_OPTIONS"] == "--trace-warnings --use-bundled-ca" + + await pairing.cancel_pairing(session_info.id) + + +class _FakeResponse: + def __init__(self, payload: dict, status: int = 200): + self._payload = payload + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def json(self, content_type=None): + return self._payload + + +class _FakeSession: + def __init__(self, payload: dict): + self.payload = payload + self.calls: list[tuple[str, dict, dict | None]] = [] + + def post(self, url: str, *, json: dict, headers=None, timeout=None): + self.calls.append((url, json, headers)) + return _FakeResponse(self.payload) diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index cef7885e4..d75c1cac0 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -512,6 +512,16 @@ def test_parse_windows_netstat_output_extracts_unique_pids() -> None: assert service_manager._parse_windows_netstat_output(output) == [1234, 5678] +def test_run_windows_netstat_handles_missing_stdout(monkeypatch) -> None: + monkeypatch.setattr( + service_manager.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=None), + ) + + assert service_manager._run_windows_netstat(5173) == "" + + def test_port_owner_pids_warns_when_no_tool_found(monkeypatch) -> None: monkeypatch.setattr(service_manager.sys, "platform", "linux") monkeypatch.setattr(service_manager, "which", lambda _name: None) @@ -529,6 +539,18 @@ def test_port_is_in_use_falls_back_to_bind_when_pid_lookup_unavailable(monkeypat assert service_manager.port_is_in_use(5173) is True +@pytest.mark.parametrize(("port_available", "expected"), [(True, False), (False, True)]) +def test_windows_port_is_in_use_confirms_empty_pid_lookup_with_bind( + monkeypatch, + port_available: bool, + expected: bool, +) -> None: + monkeypatch.setattr(service_manager.sys, "platform", "win32") + monkeypatch.setattr(service_manager, "_bind_port_available", lambda _port: port_available) + + assert service_manager.port_is_in_use(5173, listeners=[]) is expected + + def test_bind_port_available_checks_all_ipv4_interfaces(monkeypatch) -> None: binds: list[tuple[str, int]] = [] sockopts: list[tuple[object, ...]] = [] @@ -1522,70 +1544,37 @@ def test_supervisor_recovers_backend_when_port_disappears(monkeypatch, tmp_path: assert daemon.backend.pid == 333 -def test_supervisor_waits_for_second_backend_health_failure(monkeypatch, tmp_path: Path) -> None: +def test_supervisor_liveness_probe_keeps_backend_healthy(monkeypatch, tmp_path: Path) -> None: + """Liveness-only probe: process alive + TCP port open = healthy, no HTTP check needed.""" paths = _make_runtime_paths(tmp_path) calls: list[str] = [] monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) daemon = service_supervisor.SupervisorDaemon( service_manager.ServiceConfig(backend_port=9995, frontend_port=9996), - failure_threshold=2, ) daemon.paths = paths daemon.backend.process = _fake_process(111, ["backend"]) - class FakeClient: - def __init__(self, *_args, **_kwargs) -> None: - pass - - def __enter__(self): - return self - - def __exit__(self, *_args) -> None: - return None - - def get(self, _url, **_kwargs): - return httpx.Response(503, json={"status": "unhealthy"}) - - monkeypatch.setattr(service_process.httpx, "Client", FakeClient) monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda *_args: True) monkeypatch.setattr(service_manager, "_terminate_process", lambda _process, name, _console: calls.append(f"stop:{name}")) - monkeypatch.setattr( - service_manager, - "_start_backend_process", - lambda *_args, **_kwargs: calls.append("start:backend") or _fake_process(333, ["backend-new"]), - ) daemon.tick() assert calls == [] - assert daemon.backend.state == "degraded" + assert daemon.backend.state == "healthy" daemon.tick() - assert calls == ["stop:后端", "start:backend"] - - -def test_backend_probe_rejects_api_root_when_static_webui_missing(monkeypatch) -> None: - class FakeClient: - def __init__(self, *_args, **_kwargs) -> None: - pass - - def __enter__(self): - return self - - def __exit__(self, *_args) -> None: - return None + assert calls == [] + assert daemon.backend.state == "healthy" - def get(self, url, **_kwargs): - if str(url).endswith("/api/health"): - return httpx.Response(200, json={"status": "healthy"}) - return httpx.Response(200, json={"status": "running"}) +def test_backend_probe_liveness_only_accepts_alive_process(monkeypatch) -> None: + """Liveness-only probe: process alive + TCP port open = healthy regardless of HTTP response.""" monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda *_args: True) - monkeypatch.setattr(service_process.httpx, "Client", FakeClient) result = service_process.BackendProcessAdapter().probe(_fake_process(111, ["backend"]), "127.0.0.1", 5173) - assert result.healthy is False - assert result.reason == "health status=200, root status=200" + assert result.healthy is True + assert result.reason == "liveness check passed" def test_supervisor_reports_webui_as_static_endpoint(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/contracts/webui/test_api_runtime.py b/tests/contracts/webui/test_api_runtime.py index 7af873f53..9cecad60d 100644 --- a/tests/contracts/webui/test_api_runtime.py +++ b/tests/contracts/webui/test_api_runtime.py @@ -1,3 +1,7 @@ +import asyncio +import builtins +import threading + import pytest from fastapi import FastAPI, Request from httpx import AsyncClient, ASGITransport @@ -215,3 +219,173 @@ async def _dispatch(page_id: str, api_path: str, request: Request): async with AsyncClient(transport=ASGITransport(app=runtime_app), base_url="http://test") as client: resp = await client.get("/api/contracts/webui/pages/runtime-page/api/unsafe") assert resp.status_code == 500 + + +@pytest.mark.asyncio +async def test_api_runtime_blocks_builtins_import_bypass( + runtime_store: WebUIPagesStore, + runtime_app: FastAPI, +): + runtime_store.save_source_file( + "runtime-page", + "api/routes.yaml", + ( + "routes:\n" + " - method: GET\n" + " path: /builtins-bypass\n" + " handler: handlers.builtins_bypass\n" + ), + ) + runtime_store.save_source_file( + "runtime-page", + "api/handlers.py", + ( + "import builtins\n" + "def builtins_bypass(ctx, request):\n" + " module = builtins.__import__(\n" + " 'flocks.config.api_versioning', globals(), locals(),\n" + " ('discover_api_service_descriptors',), 0,\n" + " )\n" + " return {'result': 'BYPASSED', 'module': module.__name__}\n" + ), + ) + runtime = WebUIPageApiRuntime(runtime_store) + + @runtime_app.get("/api/contracts/webui/pages/{page_id}/api/{api_path:path}") + async def _dispatch(page_id: str, api_path: str, request: Request): + return await runtime.dispatch(page_id, api_path, request, {"role": "admin"}) + + async with AsyncClient(transport=ASGITransport(app=runtime_app), base_url="http://test") as client: + resp = await client.get("/api/contracts/webui/pages/runtime-page/api/builtins-bypass") + + assert resp.status_code == 500 + + +def test_api_runtime_import_guard_does_not_leak_to_other_threads( + runtime_store: WebUIPagesStore, + monkeypatch: pytest.MonkeyPatch, +): + started = threading.Event() + release = threading.Event() + monkeypatch.setattr(builtins, "_flocks_page_import_started", started, raising=False) + monkeypatch.setattr(builtins, "_flocks_page_import_release", release, raising=False) + runtime_store.save_source_file( + "runtime-page", + "api/routes.yaml", + ( + "routes:\n" + " - method: GET\n" + " path: /waiting\n" + " handler: handlers.waiting\n" + ), + ) + runtime_store.save_source_file( + "runtime-page", + "api/handlers.py", + ( + "import builtins\n" + "builtins._flocks_page_import_started.set()\n" + "builtins._flocks_page_import_release.wait(timeout=2)\n" + "def waiting(ctx, request):\n" + " return {'ok': True}\n" + ), + ) + runtime = WebUIPageApiRuntime(runtime_store) + reload_errors = [] + + def _reload_page() -> None: + try: + asyncio.run(runtime.reload_page("runtime-page")) + except Exception as exc: + reload_errors.append(exc) + + reload_thread = threading.Thread(target=_reload_page) + reload_thread.start() + try: + assert started.wait(timeout=2) + imported = builtins.__import__( + "flocks.config.api_versioning", + globals(), + locals(), + ("discover_api_service_descriptors",), + 0, + ) + assert imported.__name__ == "flocks.config.api_versioning" + finally: + release.set() + reload_thread.join(timeout=2) + + assert not reload_thread.is_alive() + assert reload_errors == [] + + +def test_api_runtime_import_guard_applies_to_page_worker_threads(runtime_store: WebUIPagesStore): + runtime_store.save_source_file( + "runtime-page", + "api/routes.yaml", + ( + "routes:\n" + " - method: GET\n" + " path: /worker\n" + " handler: handlers.worker\n" + ), + ) + runtime_store.save_source_file( + "runtime-page", + "api/handlers.py", + ( + "import threading\n" + "worker_errors = []\n" + "def load_external():\n" + " try:\n" + " from flocks.config import api_versioning\n" + " except Exception as exc:\n" + " worker_errors.append(type(exc).__name__)\n" + "thread = threading.Thread(target=load_external)\n" + "thread.start()\n" + "thread.join()\n" + "if worker_errors != ['ImportError']:\n" + " raise RuntimeError(f'import guard bypassed: {worker_errors}')\n" + "def worker(ctx, request):\n" + " return {'ok': True}\n" + ), + ) + + runtime = WebUIPageApiRuntime(runtime_store) + + asyncio.run(runtime.reload_page("runtime-page")) + + +@pytest.mark.asyncio +async def test_api_runtime_blocks_non_local_imports_during_handler_execution( + runtime_store: WebUIPagesStore, + runtime_app: FastAPI, +): + runtime_store.save_source_file( + "runtime-page", + "api/routes.yaml", + ( + "routes:\n" + " - method: GET\n" + " path: /deferred-unsafe\n" + " handler: handlers.deferred_unsafe\n" + ), + ) + runtime_store.save_source_file( + "runtime-page", + "api/handlers.py", + ( + "def deferred_unsafe(ctx, request):\n" + " from flocks.config import api_versioning\n" + " return {'path': api_versioning.__file__}\n" + ), + ) + runtime = WebUIPageApiRuntime(runtime_store) + + @runtime_app.get("/api/contracts/webui/pages/{page_id}/api/{api_path:path}") + async def _dispatch(page_id: str, api_path: str, request: Request): + return await runtime.dispatch(page_id, api_path, request, {"role": "admin"}) + + async with AsyncClient(transport=ASGITransport(app=runtime_app), base_url="http://test") as client: + resp = await client.get("/api/contracts/webui/pages/runtime-page/api/deferred-unsafe") + assert resp.status_code == 500 diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 4921d8930..f26ec863e 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -75,6 +75,30 @@ def test_bundled_hub_catalog_loads(): assert {entry.type for entry in entries} >= {"skill", "agent", "tool", "device", "workflow", "webui", "component"} +def test_hub_catalog_snapshot_reuses_manifest_parse_for_counts(monkeypatch: pytest.MonkeyPatch): + from flocks.hub import catalog as catalog_module + + catalog_module.clear_catalog_caches() + original_read_yaml = catalog_module._read_yaml + calls = 0 + + def counted_read_yaml(path: Path): + nonlocal calls + calls += 1 + return original_read_yaml(path) + + monkeypatch.setattr(catalog_module, "_read_yaml", counted_read_yaml) + + assert catalog_module.list_catalog() + initial_calls = calls + assert initial_calls > 0 + + catalog_module.category_counts() + catalog_module.list_catalog(plugin_type="device") + + assert calls == initial_calls + + def test_workflow_catalog_exposes_chinese_names(): entries = {entry.id: entry for entry in list_catalog(plugin_type="workflow")} @@ -590,15 +614,36 @@ async def test_catalog_clears_stale_skill_record_after_external_delete(isolated_ def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): + from flocks.auth.context import AuthUser + from flocks.server.auth import require_admin from flocks.server.routes.hub import router app = FastAPI() + app.dependency_overrides[require_admin] = lambda: AuthUser( + id="hub-test-admin", + username="hub-test-admin", + role="admin", + status="active", + must_reset_password=False, + ) app.include_router(router, prefix="/api") client = TestClient(app, raise_server_exceptions=True) catalog = client.get("/api/hub/catalog").json() assert any(item["id"] == "ndr-alert-analysis" for item in catalog) + catalog_page = client.get("/api/hub/catalog", params={"limit": 1, "offset": 0}).json() + assert isinstance(catalog_page, dict) + assert len(catalog_page["items"]) == 1 + assert catalog_page["total"] == len(catalog) + assert catalog_page["limit"] == 1 + assert catalog_page["facets"]["type"] + assert catalog_page["facets"]["state"] + + taxonomy = client.get("/api/hub/categories", params={"include_counts": False}).json() + assert taxonomy["tags"] + assert "counts" not in taxonomy + detail = client.get("/api/hub/plugins/skill/ndr-alert-analysis").json() assert detail["id"] == "ndr-alert-analysis" @@ -631,6 +676,68 @@ def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): assert any(item["id"] == "ndr-alert-analysis" for item in available_catalog) +def test_hub_paginated_facets_exclude_their_own_filter(): + from flocks.hub.models import HubCatalogEntry + from flocks.server.routes import hub as hub_routes + + entries = [ + HubCatalogEntry( + id="skill-installed", + type="skill", + name="Installed skill", + category="security", + tags=["triage"], + useCases=["incident-response"], + trust="official", + riskLevel="low", + state="installed", + manifestPath="skills/installed.json", + ), + HubCatalogEntry( + id="agent-installed", + type="agent", + name="Installed agent", + category="security", + tags=["triage"], + useCases=["incident-response"], + trust="official", + riskLevel="low", + state="installed", + manifestPath="agents/installed.json", + ), + HubCatalogEntry( + id="skill-available", + type="skill", + name="Available skill", + category="productivity", + tags=["automation"], + useCases=["operations"], + trust="community", + riskLevel="medium", + state="available", + manifestPath="skills/available.json", + ), + ] + + facets = hub_routes._build_hub_catalog_facets_for_filters( + entries, + { + "plugin_type": "skill", + "category": None, + "tags": None, + "use_cases": None, + "state": ["installed"], + "trust": None, + "risk": None, + "q": None, + }, + ) + + assert facets.type == {"skill": 1, "agent": 1} + assert facets.state == {"installed": 1, "available": 1} + assert facets.category == {"security": 1} + + def test_hub_refresh_clears_catalog_and_device_template_caches(monkeypatch): from flocks.server.routes import hub as hub_routes @@ -647,6 +754,8 @@ def test_hub_refresh_clears_catalog_and_device_template_caches(monkeypatch): def test_hub_component_install_stream_reports_child_progress(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): + from flocks.auth.context import AuthUser + from flocks.server.auth import require_admin from flocks.server.routes.hub import router async def noop_refresh(_plugin_type): @@ -656,6 +765,13 @@ async def noop_refresh(_plugin_type): _patch_webui_bundle_build(monkeypatch) app = FastAPI() + app.dependency_overrides[require_admin] = lambda: AuthUser( + id="hub-test-admin", + username="hub-test-admin", + role="admin", + status="active", + must_reset_password=False, + ) app.include_router(router, prefix="/api") client = TestClient(app, raise_server_exceptions=True) diff --git a/tests/hub/test_soc_dashboard_schema.py b/tests/hub/test_soc_dashboard_schema.py new file mode 100644 index 000000000..fc4257162 --- /dev/null +++ b/tests/hub/test_soc_dashboard_schema.py @@ -0,0 +1,539 @@ +import importlib.util +import json +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + + +def _load_dashboard_handlers(): + handler_path = ( + Path(__file__).resolve().parents[2] + / ".flocks" + / "flockshub" + / "plugins" + / "webuis" + / "soc_ui" + / "soc_dashboard" + / "api" + / "handlers.py" + ) + spec = importlib.util.spec_from_file_location("soc_dashboard_schema_test", handler_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + previous = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous + return module + + +def test_soc_dashboard_migrates_legacy_alert_records_schema(tmp_path: Path): + db_path = tmp_path / "soc.db" + first_record = { + "_source_type": "tdp", + "threat_name": "SQL injection", + "triage_status": "ok", + "_triage_persisted_at": "2026-07-14T13:00:00", + "attack_verdict": "attack", + } + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + CREATE TABLE alert_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_json TEXT NOT NULL, + asset_date TEXT NOT NULL, + event_time INTEGER NOT NULL + ) + """ + ) + conn.execute( + "INSERT INTO alert_records(record_json, asset_date, event_time) VALUES (?, ?, ?)", + (json.dumps(first_record), "2026-07-14", 1784014800), + ) + conn.execute( + "CREATE TABLE soc_dashboard_meta (meta_key TEXT PRIMARY KEY, meta_value TEXT NOT NULL)" + ) + conn.execute("INSERT INTO soc_dashboard_meta VALUES ('schema_version', '1')") + conn.execute( + "CREATE TABLE soc_dashboard_alert_facts " + "(alert_row_id INTEGER PRIMARY KEY, row_key TEXT)" + ) + conn.execute("INSERT INTO soc_dashboard_alert_facts VALUES (999, 'stale')") + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.DEFAULT_SQLITE_DB = db_path + handlers._schema_ready.clear() + + assert handlers._ensure_sqlite_schema() is True + + second_record = { + "_source_type": "hids", + "threat_name": "Malware download", + "triage_status": "ok", + "_triage_persisted_at": "2026-07-14T13:01:00", + } + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT INTO alert_records(record_json, asset_date, event_time) VALUES (?, ?, ?)", + (json.dumps(second_record), "2026-07-14", 1784014860), + ) + conn.commit() + + handlers._schema_ready.clear() + assert handlers._ensure_sqlite_schema() is True + + updated_first_record = { + **first_record, + "_triage_persisted_at": "2026-07-14T13:02:00", + "triage_report": "# Updated report", + } + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + INSERT INTO alert_records ( + row_id, record_id, asset_date, source_file, line_number, + event_time, source_type, threat_name, is_duplicate, record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(row_id) DO UPDATE SET + record_id=excluded.record_id, + asset_date=excluded.asset_date, + source_file=excluded.source_file, + line_number=excluded.line_number, + event_time=excluded.event_time, + source_type=excluded.source_type, + threat_name=excluded.threat_name, + is_duplicate=excluded.is_duplicate, + record_json=excluded.record_json + """, + ( + "1", + "1", + "2026-07-14", + "triage-replay.jsonl", + 1, + 1784014800, + "tdp", + "SQL injection", + 0, + json.dumps(updated_first_record), + ), + ) + conn.commit() + columns = {row[1] for row in conn.execute("PRAGMA table_info(alert_records)")} + indexes = {row[1] for row in conn.execute("PRAGMA index_list(alert_records)")} + facts = conn.execute( + "SELECT alert_row_id, row_key, source_type, threat_name, has_triage " + "FROM soc_dashboard_alert_facts ORDER BY alert_row_id" + ).fetchall() + source_rows = conn.execute( + "SELECT id, row_id, is_duplicate FROM alert_records ORDER BY id" + ).fetchall() + trigger_sql = conn.execute( + "SELECT sql FROM sqlite_master " + "WHERE type='trigger' AND name='soc_dashboard_fact_insert'" + ).fetchone()[0] + schema_version = conn.execute( + "SELECT meta_value FROM soc_dashboard_meta WHERE meta_key='schema_version'" + ).fetchone()[0] + updated_fact = conn.execute( + "SELECT triage_persisted_at, verdict FROM soc_dashboard_alert_facts " + "WHERE row_key = '1'" + ).fetchone() + + assert { + "row_id", + "record_id", + "source_file", + "line_number", + "source_type", + "threat_name", + "is_duplicate", + } <= columns + assert { + "idx_alert_records_asset_date", + "idx_alert_records_event_time", + "idx_alert_records_source_type", + "idx_alert_records_threat_name", + "idx_alert_records_row_id", + } <= indexes + assert source_rows == [(1, "1", 0), (2, "2", 0)] + assert facts == [ + (1, "1", "tdp", "SQL injection", 1), + (2, "2", "hids", "Malware download", 1), + ] + assert "COALESCE(NULLIF(NEW.row_id, ''), CAST(NEW.rowid AS TEXT))" in trigger_sql + assert updated_fact == ("2026-07-14T13:02:00", "attack") + assert schema_version == "2" + + +def test_soc_dashboard_activity_exposes_live_denoise_workflow_progress(tmp_path: Path): + workflow_db = tmp_path / "workflow.db" + with sqlite3.connect(workflow_db) as conn: + conn.execute( + """ + CREATE TABLE workflow_stats ( + workflow_id TEXT PRIMARY KEY, + call_count INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO workflow_stats VALUES (?, ?)", + ("stream_alert_denoise", 42), + ) + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.WORKFLOW_DB = workflow_db + handlers.DEFAULT_SQLITE_DB = tmp_path / "missing-soc.db" + handlers._activity_pruned_at = float("inf") + + payload = handlers._get_activity({"bootstrap": "latest"}) + + assert payload["workflowStats"] == {"callCount": 42, "latestStartedAt": 0} + assert payload["workflowEvents"] == [] + assert payload["events"] == [] + + with sqlite3.connect(workflow_db) as conn: + conn.execute( + "UPDATE workflow_stats SET call_count = 43 WHERE workflow_id = ?", + ("stream_alert_denoise",), + ) + conn.commit() + + assert handlers._get_activity({"bootstrap": "latest"})["workflowStats"] == { + "callCount": 43, + "latestStartedAt": 0, + } + + +def test_soc_dashboard_workflow_progress_marks_unavailable_database(tmp_path: Path): + handlers = _load_dashboard_handlers() + handlers.WORKFLOW_DB = tmp_path / "missing-workflow.db" + + assert handlers._get_workflow_progress("stream_alert_denoise") == { + "callCount": None, + "latestStartedAt": None, + } + + +def test_soc_dashboard_corrects_legacy_single_alert_duplicate_metrics(): + handlers = _load_dashboard_handlers() + output = { + "stats": { + "raw_count": 1, + "normalized_count": 1, + "after_filter_count": 1, + "after_dedup_count": 1, + "dedup_removed_count": 0, + }, + "is_duplicate": True, + } + + metrics = handlers._workflow_execution_metrics(json.dumps(output)) + + assert metrics["uniqueCount"] == 0 + assert metrics["duplicateCount"] == 1 + assert metrics["reducedCount"] == 1 + assert metrics["reductionRate"] == 1 + assert metrics["dedupRate"] == 1 + + flag_only_metrics = handlers._workflow_execution_metrics( + json.dumps({"is_duplicate": True}) + ) + + assert flag_only_metrics["metricsAvailable"] is True + assert flag_only_metrics["rawCount"] == 1 + assert flag_only_metrics["uniqueCount"] == 0 + assert flag_only_metrics["duplicateCount"] == 1 + assert flag_only_metrics["reductionRate"] == 1 + + output["is_duplicate"] = False + first_seen_metrics = handlers._workflow_execution_metrics(json.dumps(output)) + + assert first_seen_metrics["uniqueCount"] == 1 + assert first_seen_metrics["duplicateCount"] == 0 + assert first_seen_metrics["reductionRate"] == 0 + + output["is_duplicate"] = True + output["stats"].update( + raw_count=2, + normalized_count=2, + after_filter_count=2, + after_dedup_count=2, + ) + batch_metrics = handlers._workflow_execution_metrics(json.dumps(output)) + + assert batch_metrics["uniqueCount"] == 2 + assert batch_metrics["duplicateCount"] == 0 + + +def test_soc_dashboard_uses_workflow_stats_and_soc_unique_for_reduction(tmp_path: Path): + start_time = 1783987200 + end_time = start_time + 3600 + asset_date = datetime.fromtimestamp(start_time).strftime("%Y-%m-%d") + soc_db = tmp_path / "soc.db" + with sqlite3.connect(soc_db) as conn: + conn.execute( + """ + CREATE TABLE alert_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_json TEXT NOT NULL, + asset_date TEXT NOT NULL, + event_time INTEGER NOT NULL, + is_duplicate INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + for offset, is_duplicate in ((60, 0), (120, 1)): + conn.execute( + "INSERT INTO alert_records(record_json, asset_date, event_time, is_duplicate) " + "VALUES (?, ?, ?, ?)", + ( + json.dumps({"_source_type": "soc"}), + asset_date, + start_time + offset, + is_duplicate, + ), + ) + conn.commit() + + workflow_db = tmp_path / "workflow.db" + with sqlite3.connect(workflow_db) as conn: + conn.execute( + """ + CREATE TABLE workflow_executions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + status TEXT NOT NULL, + input_params TEXT NOT NULL, + output_results TEXT NOT NULL, + started_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE workflow_stats ( + workflow_id TEXT PRIMARY KEY, + call_count INTEGER NOT NULL, + success_count INTEGER NOT NULL, + error_count INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE soc_dashboard_workflow_stats_samples ( + workflow_id TEXT NOT NULL, + sampled_at INTEGER NOT NULL, + call_count INTEGER NOT NULL, + success_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (workflow_id, sampled_at) + ) + """ + ) + conn.execute( + "INSERT INTO workflow_stats VALUES (?, ?, ?, ?, ?)", + ("stream_alert_denoise", 100, 99, 1, (start_time + 200) * 1000), + ) + conn.executemany( + "INSERT INTO soc_dashboard_workflow_stats_samples VALUES (?, ?, ?, ?, ?)", + [ + ("stream_alert_denoise", (start_time + 100) * 1000, 60, 60, 0), + ("stream_alert_denoise", (start_time + 200) * 1000, 100, 99, 1), + ], + ) + + def insert_execution( + execution_id, + started_at, + raw, + normalized, + filtered, + unique, + source, + threat, + *, + summarized_source_counts=False, + empty_output=False, + ): + source_counts = ( + {"_type": "dict", "keys": [source, "skyeye"]} + if summarized_source_counts + else {source: normalized} + ) + output = { + "unique_alerts": [{"threat_name": threat, "_source_type": source}], + "stats": { + "raw_count": raw, + "normalized_count": normalized, + "after_filter_count": filtered, + "after_dedup_count": unique, + "filter_removed_count": max(normalized - filtered, 0), + "dedup_removed_count": max(filtered - unique, 0), + "normalize_type_counts": source_counts, + "lsh_total_clusters": 3, + }, + } + conn.execute( + "INSERT INTO workflow_executions VALUES (?, ?, 'success', ?, ?, ?)", + ( + execution_id, + "stream_alert_denoise", + json.dumps({"source_log_type": source}), + json.dumps({} if empty_output else output), + started_at * 1000, + ), + ) + + insert_execution("outside", start_time - 60, 100, 100, 90, 80, "tdp", "Outside") + insert_execution( + "first", + start_time + 100, + 10, + 9, + 8, + 6, + "tdp", + "SQL injection", + summarized_source_counts=True, + empty_output=True, + ) + insert_execution("second", start_time + 200, 5, 5, 5, 4, "skyeye", "Malware") + duplicate_input = { + "syslog_message": { + "app_name": "tdp", + "message": json.dumps( + { + "id": "syslog-duplicate", + "net_real_src_ip": "10.10.10.10", + "net_dest_ip": "192.168.10.10", + "threat_name": "Syslog duplicate", + } + ), + } + } + duplicate_output = { + "unique_alerts": { + "_type": "list", + "count": 1, + "preview": [{"_type": "dict", "keys": ["id", "threat_name"]}], + }, + "stats": { + "raw_count": 1, + "normalized_count": 1, + "after_filter_count": 1, + "after_dedup_count": 1, + "dedup_removed_count": 0, + }, + "is_duplicate": True, + } + conn.execute( + "UPDATE workflow_executions SET input_params = ?, output_results = ? WHERE id = ?", + (json.dumps(duplicate_input), json.dumps(duplicate_output), "second"), + ) + conn.commit() + + handlers = _load_dashboard_handlers() + handlers.DEFAULT_SQLITE_DB = soc_db + handlers.WORKFLOW_DB = workflow_db + handlers._schema_ready.clear() + + stats = handlers._get_stats( + {"startTime": str(start_time), "endTime": str(end_time), "force": "1"} + ) + + expected_denoise = { + "totalRaw": 100, + "totalNormalized": 100, + "afterFilter": 100, + "totalUnique": 1, + "filterRemoved": 0, + "dedupRemoved": 99, + "duplicates": 99, + } + assert { + key: stats["denoise"][key] + for key in expected_denoise + } == expected_denoise + assert stats["denoise"]["duplicateRate"] == 0.99 + assert stats["denoise"]["dedupRate"] == 0.99 + assert stats["pipeline"]["raw"] == 100 + assert stats["pipeline"]["unique"] == 1 + assert sum(stats["timeline"]["denoiseRaw"]) == 100 + assert sum(stats["timeline"]["denoiseUnique"]) == 1 + assert stats["sourceStatus"]["workflowStats"]["callCount"] == 100 + assert {source["key"]: source["value"] for source in stats["sources"]} == { + "ndr": 100, + "edr": 0, + "waf": 0, + "ids": 0, + "cloud": 0, + "vuln": 0, + "other": 0, + } + + activity = handlers._get_activity( + { + "bootstrap": "latest", + "startTime": str(start_time), + "endTime": str(end_time), + } + ) + assert activity["workflowStats"] == { + "callCount": 100, + "latestStartedAt": (start_time + 200) * 1000, + } + assert [event["alert"]["threatName"] for event in activity["workflowEvents"]] == [ + "Syslog duplicate", + "降噪批次 · 原始 1 条", + ] + + events = handlers._get_workflow_recent_events( + "stream_alert_denoise", + start_time, + end_time, + ) + assert [event["alert"]["threatName"] for event in events] == [ + "Syslog duplicate", + "降噪批次 · 原始 1 条", + ] + assert events[0]["result"]["rawCount"] == 1 + assert events[0]["result"]["uniqueCount"] == 0 + assert events[0]["result"]["duplicateCount"] == 1 + assert events[0]["result"]["reductionRate"] == 1 + assert events[0]["result"]["isDuplicate"] is True + assert events[0]["alert"]["srcIp"] == "10.10.10.10" + + narrowed = handlers._get_stats( + { + "startTime": str(start_time + 150), + "endTime": str(end_time), + "force": "1", + } + ) + assert narrowed["denoise"]["totalRaw"] == 40 + assert narrowed["denoise"]["totalUnique"] == 0 + assert narrowed["denoise"]["duplicateRate"] == 1 + + no_workflow_calls = handlers._get_stats( + { + "startTime": str(start_time + 50), + "endTime": str(start_time + 90), + "force": "1", + } + ) + assert no_workflow_calls["denoise"]["totalRaw"] == 0 + assert no_workflow_calls["denoise"]["totalUnique"] == 1 + assert no_workflow_calls["denoise"]["duplicateRate"] == 0 + assert next( + source["value"] for source in no_workflow_calls["sources"] if source["key"] == "ndr" + ) == 0 diff --git a/tests/mcp/test_mcp_server.py b/tests/mcp/test_mcp_server.py index 1ea0264ea..b89a6b26d 100644 --- a/tests/mcp/test_mcp_server.py +++ b/tests/mcp/test_mcp_server.py @@ -43,6 +43,19 @@ async def list_resources(self) -> list: return [] +def test_status_snapshot_is_read_only_and_does_not_initialize(): + manager = McpServerManager() + status = SimpleNamespace(status=McpStatus.DISCONNECTED) + manager._status["demo"] = status + + snapshot, initialized = manager.status_snapshot() + + assert initialized is False + assert snapshot == {"demo": status} + snapshot.clear() + assert manager._status == {"demo": status} + + @pytest.mark.asyncio async def test_connect_and_register_accepts_legacy_env_alias(monkeypatch: pytest.MonkeyPatch): captured: dict[str, object] = {} diff --git a/tests/permission/test_permission_next.py b/tests/permission/test_permission_next.py index 9e8c5d5f9..e0fba7b67 100644 --- a/tests/permission/test_permission_next.py +++ b/tests/permission/test_permission_next.py @@ -72,6 +72,26 @@ async def test_reply_persists_session_rule_without_in_memory_future(permission_s assert await Storage.get(f"{PermissionNext._SESSION_PREFIX}{request.session_id}") == {"write": "allow"} +@pytest.mark.asyncio +async def test_reply_persists_session_denial_without_in_memory_future(permission_storage) -> None: + request = PermissionRequestInfo( + id="per_testsessiondeny000000001", + sessionID="ses_testsessiondeny00000001", + permission="bash", + patterns=["*"], + ) + await Storage.set( + f"{PermissionNext._PENDING_PREFIX}{request.id}", + request.model_dump(by_alias=True), + "permission_pending", + ) + + await PermissionNext.reply(request.id, "deny_session", session_id=request.session_id) + + assert PermissionNext._session_permissions[request.session_id]["bash"] == "deny" + assert await Storage.get(f"{PermissionNext._SESSION_PREFIX}{request.session_id}") == {"bash": "deny"} + + @pytest.mark.asyncio @pytest.mark.parametrize("reply", ["allow", "once"]) async def test_reply_unblocks_waiting_request_via_persisted_reply_when_memory_future_missing( @@ -130,6 +150,32 @@ async def test_reply_denies_waiting_request_via_persisted_reply_when_memory_futu assert await Storage.get(f"{PermissionNext._REPLY_PREFIX}{request_id}") is None +@pytest.mark.asyncio +async def test_session_denial_applies_to_active_request_without_global_rule(permission_storage) -> None: + request_id = "per_waiting_session_deny" + session_id = "ses_waiting_session_deny" + ask_task = asyncio.create_task( + PermissionNext.ask( + session_id=session_id, + permission="bash", + patterns=["*"], + ruleset=[], + request_id=request_id, + ) + ) + + while request_id not in PermissionNext._pending: + await asyncio.sleep(0) + + await PermissionNext.reply(request_id, "deny_session", session_id=session_id) + + with pytest.raises(DeniedError): + await asyncio.wait_for(ask_task, timeout=2) + + assert PermissionNext._session_permissions[session_id]["bash"] == "deny" + assert "bash" not in PermissionNext._permanent_rules + + @pytest.mark.asyncio async def test_state_load_retries_after_transient_storage_failure(permission_storage, monkeypatch: pytest.MonkeyPatch) -> None: await Storage.set(f"{PermissionNext._PERMANENT_PREFIX}bash", "allow", "permission_rule") diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 1e75807f4..95758cfd7 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -259,6 +259,96 @@ def test_load_extension_scans_only_requested_extension(self, tmp_path: Path): ] assert tool_items == [] + def test_load_extension_reports_bad_sources_without_dropping_valid_ones(self, tmp_path: Path): + tools_dir = tmp_path / "tools" + _write_plugin(tools_dir, "good.py", 'TOOLS = [{"name": "working"}]\n') + _write_plugin(tools_dir, "broken.py", "TOOLS = [\n") + collected = [] + + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda items, _source: collected.extend(items), + item_type=dict, + dedup_key=lambda item: item["name"], + )) + + errors = PluginLoader.load_extension("TOOLS", project_dir=tmp_path) + + assert [item["name"] for item in collected] == ["working"] + assert len(errors) == 1 + assert "broken.py" in errors[0] + + def test_load_extension_reports_filtered_invalid_entries(self, tmp_path: Path): + tools_dir = tmp_path / "tools" + _write_plugin( + tools_dir, + "mixed.py", + 'TOOLS = [{"name": "working"}, "invalid"]\n', + ) + collected = [] + + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda items, _source: collected.extend(items), + item_type=dict, + dedup_key=lambda item: item["name"], + )) + + errors = PluginLoader.load_extension("TOOLS", project_dir=tmp_path) + + assert [item["name"] for item in collected] == ["working"] + assert len(errors) == 1 + assert "1 invalid TOOLS entries" in errors[0] + + def test_load_extension_propagates_consumer_errors(self, tmp_path: Path): + tools_dir = tmp_path / "tools" + _write_plugin(tools_dir, "invalid.py", 'TOOLS = [{"name": "bad"}]\n') + + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda _items, _source: ["tool definition rejected"], + item_type=dict, + )) + + errors = PluginLoader.load_extension("TOOLS", project_dir=tmp_path) + + assert len(errors) == 1 + assert "invalid.py: tool definition rejected" in errors[0] + + def test_load_extension_reports_entry_point_scan_failure( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ): + PluginLoader._plugin_root = tmp_path + PluginLoader.register_extension_point(ExtensionPoint( + attr_name="TOOLS", + subdir="tools", + consumer=lambda _items, _source: None, + )) + + def _failed_scan(): + raise RuntimeError("metadata unavailable") + + monkeypatch.setattr( + "flocks.plugin.loader.importlib.metadata.entry_points", + _failed_scan, + ) + + errors = PluginLoader.load_extension( + "TOOLS", + project_dir=tmp_path, + load_entry_points=True, + ) + + assert errors == ["entry point scan: RuntimeError: metadata unavailable"] + def test_load_extension_can_load_legacy_entry_points(self, tmp_path: Path, monkeypatch): """Scoped loads opt in to the legacy flocks.plugins entry-point group.""" loaded = [] diff --git a/tests/provider/test_api_service_credentials.py b/tests/provider/test_api_service_credentials.py index d6013be8c..e42620b24 100644 --- a/tests/provider/test_api_service_credentials.py +++ b/tests/provider/test_api_service_credentials.py @@ -2,6 +2,8 @@ import pytest +from flocks.security.secrets import SecretManager + class TestAPIServiceCredentials: @pytest.mark.asyncio @@ -25,7 +27,9 @@ async def test_get_service_credentials_returns_base_url_and_username(self): result = await get_service_credentials("skyeye_api") assert result.secret_id == "skyeye_api_key" - assert result.api_key == "skyeye-login-key" + assert result.api_key is None + assert result.api_key_masked + assert result.api_key_masked != "skyeye-login-key" assert result.base_url == "https://skyeye-domain/skyeye" assert result.username == "skyeye" assert result.has_credential is True @@ -160,8 +164,10 @@ async def test_get_service_credentials_returns_tdp_secret(self): result = await get_service_credentials("tdp_api") assert result.secret_id == "tdp_api_key" - assert result.api_key == "tdp-api-key" - assert result.secret == "tdp-secret" + assert result.api_key is None + assert result.api_key_masked != "tdp-api-key" + assert result.secret is None + assert result.secret_masked != "tdp-secret" assert result.base_url == "https://tdp.example.com" assert result.has_credential is True @@ -231,8 +237,10 @@ async def test_get_service_credentials_splits_legacy_onesec_combined_secret(self result = await get_service_credentials("onesec_api") assert result.secret_id == "onesec_credentials" - assert result.api_key == "onesec-api-key" - assert result.secret == "onesec-secret" + assert result.api_key is None + assert result.api_key_masked != "onesec-api-key" + assert result.secret is None + assert result.secret_masked != "onesec-secret" assert result.base_url == "https://console.onesec.net" assert result.has_credential is True @@ -320,7 +328,7 @@ async def test_get_service_credentials_returns_dynamic_qingteng_fields(self): assert result.fields == { "base_url": "https://qt.example.com:8443/openapi", "username": "alice", - "password": "qt-secret", + "password": SecretManager.mask("qt-secret"), } assert result.secret_ids == {"password": "qingteng_password"} assert result.has_credential is True diff --git a/tests/provider/test_api_service_management.py b/tests/provider/test_api_service_management.py index 1b90da15e..4647fb8df 100644 --- a/tests/provider/test_api_service_management.py +++ b/tests/provider/test_api_service_management.py @@ -33,7 +33,7 @@ async def test_list_api_services_returns_enabled_state_and_bilingual_description "threatbook_api": {"enabled": False}, }.get(service_id, {})), patch( - "flocks.server.routes.provider._load_api_service_metadata_data", + "flocks.server.routes.provider._load_api_service_summary_metadata_data", side_effect=lambda service_id: metadata_by_id[service_id], ), patch( @@ -66,6 +66,35 @@ async def test_list_api_services_returns_enabled_state_and_bilingual_description assert result[1].status == "disabled" assert result[1].tool_count == 2 + def test_api_service_summary_uses_lightweight_metadata_loader(self): + from flocks.server.routes import provider as provider_routes + + with ( + patch( + "flocks.server.routes.provider._load_api_service_summary_metadata_data", + return_value={ + "name": "Example API", + "description": "Summary metadata only", + "version": "1.2.3", + }, + ) as mock_summary_loader, + patch( + "flocks.server.routes.provider._load_api_service_metadata_data", + side_effect=AssertionError("list summaries should not load full API metadata"), + ), + patch("flocks.server.routes.provider._get_api_service_enabled", return_value=True), + patch("flocks.server.routes.provider._get_api_service_tool_infos", return_value=[]), + patch("flocks.server.routes.provider._is_api_service_builtin", return_value=False), + patch("flocks.config.config_writer.ConfigWriter.get_api_service_raw", return_value={}), + ): + summary = provider_routes._build_api_service_summary("example_api", {}) + + mock_summary_loader.assert_called_once_with("example_api") + assert summary.id == "example_api" + assert summary.name == "Example API" + assert summary.version == "1.2.3" + assert summary.tool_count == 0 + @pytest.mark.asyncio async def test_update_api_service_persists_enabled_flag_and_updates_status_cache(self): from flocks.server.routes.provider import ( @@ -394,6 +423,58 @@ def test_load_api_service_metadata_uses_config_writer_raw_data(self): assert metadata["base_url"] == "https://api.example.test" assert metadata["enabled"] is False + def test_load_api_service_summary_metadata_reuses_cached_yaml(self, tmp_path, monkeypatch): + from flocks.server.routes import provider as provider_routes + + provider_routes._clear_api_service_summary_metadata_cache() + monkeypatch.setattr(provider_routes.api_service_schema_helpers, "_LEGACY_METADATA_DIR", tmp_path) + try: + with ( + patch( + "flocks.config.config_writer.ConfigWriter.get_api_service_raw", + return_value={"enabled": True}, + ), + patch( + "flocks.server.routes.provider._provider_yaml_cache_key", + return_value=("/plugins/example/_provider.yaml", 123), + ), + patch( + "flocks.server.routes.provider._load_provider_yaml_summary_metadata", + return_value={ + "name": "Example Service", + "description": "Loaded from provider yaml", + }, + ) as mock_yaml_loader, + ): + first = provider_routes._load_api_service_summary_metadata_data("example_service") + second = provider_routes._load_api_service_summary_metadata_data("example_service") + + mock_yaml_loader.assert_called_once_with("example_service") + assert first == second + assert first["name"] == "Example Service" + assert first["enabled"] is True + finally: + provider_routes._clear_api_service_summary_metadata_cache() + + def test_summary_descriptor_lookup_uses_cached_discovery(self, monkeypatch): + from flocks.server.routes import provider as provider_routes + + calls = [] + + def discover_api_service_descriptors(*, refresh=False): + calls.append(refresh) + if refresh: + raise AssertionError("summary lookup should not refresh descriptor discovery") + return [] + + monkeypatch.setattr( + "flocks.config.api_versioning.discover_api_service_descriptors", + discover_api_service_descriptors, + ) + + assert provider_routes._find_api_service_descriptor("missing_service") is None + assert calls == [False] + def test_load_provider_yaml_metadata_from_project_plugins(self, tmp_path, monkeypatch): from flocks.server.routes.provider import _load_provider_yaml_metadata diff --git a/tests/scripts/test_recover_raw_flocks_db.py b/tests/scripts/test_recover_raw_flocks_db.py new file mode 100644 index 000000000..1fe470887 --- /dev/null +++ b/tests/scripts/test_recover_raw_flocks_db.py @@ -0,0 +1,1224 @@ +"""Safety tests for the offline SQLite recovery helper.""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import sqlite3 +import struct +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def load_module(): + module_path = Path(__file__).resolve().parents[2] / "scripts" / "recover_raw_flocks_db.py" + module_name = "recover_raw_flocks_db_test_module" + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +recover_raw_flocks_db = load_module() + + +def _require_sqlite_recover() -> None: + sqlite_bin = shutil.which("sqlite3") + if sqlite_bin is None: + pytest.skip("sqlite3 CLI is required for the recovery integration test") + supported, reason = recover_raw_flocks_db._sqlite_recover_capability(sqlite_bin) + if not supported: + pytest.skip(f"sqlite3 .recover is unavailable: {reason}") + + +def _wal_checksum(data, *, byte_order, state=(0, 0)): + assert len(data) % 8 == 0 + words = struct.unpack(f"{byte_order}{len(data) // 4}I", data) + s0, s1 = state + for index in range(0, len(words), 2): + s0 = (s0 + words[index] + s1) & 0xFFFFFFFF + s1 = (s1 + words[index + 1] + s0) & 0xFFFFFFFF + return s0, s1 + + +def _build_wal(frames, *, page_size=1024, magic=None): + magic = magic or recover_raw_flocks_db.WAL_MAGIC + byte_order = "<" if magic == 0x377F0682 else ">" + salt1, salt2 = 0x12345678, 0x90ABCDEF + header_prefix = struct.pack( + ">6I", + magic, + recover_raw_flocks_db.WAL_VERSION, + page_size, + 0, + salt1, + salt2, + ) + checksum = _wal_checksum(header_prefix, byte_order=byte_order) + wal = bytearray(header_prefix + struct.pack(">2I", *checksum)) + for page_no, db_page_count, fill_byte in frames: + page = bytes([fill_byte]) * page_size + frame_prefix = struct.pack(">4I", page_no, db_page_count, salt1, salt2) + checksum = _wal_checksum(frame_prefix[:8], byte_order=byte_order, state=checksum) + checksum = _wal_checksum(page, byte_order=byte_order, state=checksum) + wal.extend(frame_prefix + struct.pack(">2I", *checksum) + page) + return bytes(wal) + + +def test_default_artifacts_dir_is_unique_and_not_created(tmp_path, monkeypatch): + workspace_dir = tmp_path / "workspace" + raw_path = tmp_path / "damaged.db" + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(workspace_dir)) + + first = recover_raw_flocks_db._default_artifacts_dir(raw_path) + second = recover_raw_flocks_db._default_artifacts_dir(raw_path) + + assert first != second + assert first.parent == second.parent + assert first.exists() is False + assert second.exists() is False + + +def test_empty_data_dir_env_matches_runtime_current_directory(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("FLOCKS_DATA_DIR", "") + + assert recover_raw_flocks_db._resolve_live_data_dir() == tmp_path.resolve() + + +def test_lowercase_data_dir_env_matches_runtime(tmp_path, monkeypatch): + data_dir = tmp_path / "lowercase-live-data" + monkeypatch.delenv("FLOCKS_DATA_DIR", raising=False) + monkeypatch.setenv("flocks_data_dir", str(data_dir)) + + assert recover_raw_flocks_db._resolve_live_data_dir() == data_dir.resolve() + + +@pytest.mark.skipif(os.name == "nt", reason="Windows environment keys are case-insensitive") +def test_data_dir_case_variant_priority_matches_runtime(tmp_path, monkeypatch): + from flocks.config.config import GlobalConfig + + uppercase_dir = tmp_path / "uppercase" + lowercase_dir = tmp_path / "lowercase" + monkeypatch.delenv("FLOCKS_DATA_DIR", raising=False) + monkeypatch.delenv("flocks_data_dir", raising=False) + monkeypatch.setenv("FLOCKS_DATA_DIR", str(uppercase_dir)) + monkeypatch.setenv("flocks_data_dir", str(lowercase_dir)) + + assert recover_raw_flocks_db._resolve_live_data_dir() == GlobalConfig().data_dir.resolve() + + +def test_cli_loads_dotenv_before_protecting_live_data_dir(tmp_path, monkeypatch): + live_data = tmp_path / "configured-live-data" + live_data.mkdir() + live_db = live_data / "flocks.db" + live_db.write_bytes(b"live") + (tmp_path / ".env").write_text( + f"FLOCKS_DATA_DIR={live_data}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("FLOCKS_DATA_DIR", raising=False) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main([str(live_db)]) + + +def test_missing_input_does_not_touch_live_sidecars(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + wal_path = data_dir / "flocks.db-wal" + shm_path = data_dir / "flocks.db-shm" + wal_path.write_bytes(b"live-wal") + shm_path.write_bytes(b"live-shm") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(FileNotFoundError): + recover_raw_flocks_db.main([str(tmp_path / "missing.db")]) + + assert wal_path.read_bytes() == b"live-wal" + assert shm_path.read_bytes() == b"live-shm" + + +def test_recovery_cli_keeps_live_wal_usable(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + db_path = data_dir / "flocks.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + holder = sqlite3.connect(db_path) + try: + assert holder.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + holder.execute("CREATE TABLE live_rows(value INTEGER)") + holder.execute("INSERT INTO live_rows VALUES (1)") + holder.commit() + assert db_path.with_name("flocks.db-wal").exists() + assert db_path.with_name("flocks.db-shm").exists() + + with pytest.raises(FileNotFoundError): + recover_raw_flocks_db.main([str(tmp_path / "missing.db")]) + + holder.execute("INSERT INTO live_rows VALUES (2)") + holder.commit() + with sqlite3.connect(db_path) as probe: + assert probe.execute("SELECT value FROM live_rows ORDER BY value").fetchall() == [ + (1,), + (2,), + ] + finally: + holder.close() + + +def test_rejects_live_database_as_output_before_recovery(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + recovery_called = False + + def fail_if_called(*_args, **_kwargs): + nonlocal recovery_called + recovery_called = True + raise AssertionError("recovery must not start for an unsafe output path") + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fail_if_called) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main([str(damaged_db), "--output", str(live_db)]) + + assert recovery_called is False + assert live_db.read_bytes() == b"live-db" + + +def test_rejects_live_database_as_input_before_writing_artifacts(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + workspace_dir = tmp_path / "workspace" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(workspace_dir)) + + with pytest.raises(ValueError, match="recover the live Flocks database"): + recover_raw_flocks_db.main([str(live_db)]) + + assert live_db.read_bytes() == b"live-db" + assert workspace_dir.exists() is False + + +def test_rejects_artifact_plan_that_writes_live_database(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + recovery_called = False + + def fail_if_called(*_args, **_kwargs): + nonlocal recovery_called + recovery_called = True + raise AssertionError("recovery must not start for an unsafe artifact path") + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fail_if_called) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(data_dir), + "--prefix", + "flocks", + ] + ) + + assert recovery_called is False + assert live_db.read_bytes() == b"live-db" + + +@pytest.mark.parametrize("prefix, live_name", [("tasks", "tasks.db"), ("workflow", "workflow.db")]) +def test_rejects_artifacts_anywhere_in_live_data_dir( + tmp_path, + monkeypatch, + prefix, + live_name, +): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / live_name + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + monkeypatch.setattr( + recover_raw_flocks_db, + "recover_raw_storage_db", + lambda *_args, **_kwargs: pytest.fail("unsafe recovery plan must be rejected"), + ) + + with pytest.raises(ValueError, match="live Flocks data directory"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(data_dir), + "--prefix", + prefix, + ] + ) + + assert live_db.read_bytes() == b"live-db" + + +def test_rejects_existing_artifact_before_recovery(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + candidate_db = artifacts_dir / "safe.candidate.db" + candidate_db.write_bytes(b"existing-evidence") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(FileExistsError, match="existing recovery file"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert candidate_db.read_bytes() == b"existing-evidence" + + +@pytest.mark.parametrize("suffix", ["-journal", "-wal", "-shm"]) +def test_rejects_existing_sqlite_sidecar_before_recovery(tmp_path, monkeypatch, suffix): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + sidecar_path = artifacts_dir / f"safe.db{suffix}" + sidecar_path.write_bytes(b"existing-sidecar") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(FileExistsError, match="existing recovery file"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert sidecar_path.read_bytes() == b"existing-sidecar" + + +def test_rejects_hardlinked_artifact_to_live_database(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + os.link(live_db, artifacts_dir / "safe.candidate.db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert live_db.read_bytes() == b"live-db" + + +def test_direct_recovery_call_rejects_live_data_directory(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = data_dir / "flocks.db" + live_db.write_bytes(b"live-db") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="live Flocks database"): + recover_raw_flocks_db.recover_raw_storage_db( + damaged_db, + None, + data_dir, + prefix="flocks", + ) + + assert live_db.read_bytes() == b"live-db" + + +def test_rejects_output_collision_with_intermediate_artifact(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="collides with an intermediate artifact"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(artifacts_dir / "safe.summary.txt"), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert artifacts_dir.exists() is False + + +def test_rejects_output_collision_with_sqlite_sidecar(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="collides with an intermediate artifact"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(artifacts_dir / "safe.db-journal"), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert artifacts_dir.exists() is False + + +def test_rejects_case_insensitive_output_collision(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="ambiguous case-insensitive collision"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(artifacts_dir / "SAFE.db"), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert artifacts_dir.exists() is False + + +def test_rejects_wal_path_that_collides_with_artifact(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir() + wal_path = artifacts_dir / "safe.candidate.db" + wal_path.write_bytes(b"wal-evidence") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="source evidence"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--wal", + str(wal_path), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert wal_path.read_bytes() == b"wal-evidence" + + +def test_rejects_live_wal_as_recovery_input(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_wal = data_dir / "flocks.db-wal" + live_wal.write_bytes(b"live-wal") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + with pytest.raises(ValueError, match="live Flocks SQLite sidecar"): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--wal", + str(live_wal), + "--artifacts-dir", + str(tmp_path / "artifacts"), + ] + ) + + assert live_wal.read_bytes() == b"live-wal" + + +def test_detects_storage_quarantine_wal_name(tmp_path): + raw_path = tmp_path / "flocks.db.corrupt.20260710T083229" + wal_path = tmp_path / "flocks.db-wal.corrupt.20260710T083229" + raw_path.write_bytes(b"raw") + wal_path.write_bytes(b"wal") + + assert recover_raw_flocks_db._detect_wal_path(raw_path) == wal_path.resolve() + + +def test_auto_detect_ignores_empty_checkpointed_wal(tmp_path): + raw_path = tmp_path / "flocks.db" + raw_path.write_bytes(b"main") + raw_path.with_name("flocks.db-wal").touch() + + assert recover_raw_flocks_db._detect_wal_path(raw_path) is None + + +@pytest.mark.parametrize("magic", [0x377F0682, 0x377F0683]) +def test_wal_parser_discards_frames_after_last_commit(magic): + wal_bytes = _build_wal( + [ + (1, 1, ord("A")), + (1, 0, ord("B")), + ], + magic=magic, + ) + + page_size, final_pages, latest_pages, frame_count = recover_raw_flocks_db._parse_wal_frames( + wal_bytes + ) + + assert page_size == 1024 + assert final_pages == 1 + assert frame_count == 1 + assert latest_pages[1] == b"A" * 1024 + + +def test_wal_parser_rejects_invalid_header_checksum(): + wal_bytes = bytearray(_build_wal([(1, 1, ord("A"))])) + wal_bytes[24] ^= 0xFF + + with pytest.raises(ValueError, match="header checksum"): + recover_raw_flocks_db._parse_wal_frames(bytes(wal_bytes)) + + +def test_real_wal_snapshot_does_not_replay_uncommitted_spill(tmp_path): + live_db = tmp_path / "live.db" + source = sqlite3.connect(live_db) + try: + assert source.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + source.execute("PRAGMA wal_autocheckpoint=0") + source.execute("PRAGMA cache_size=5") + source.execute("CREATE TABLE records(id INTEGER PRIMARY KEY, value BLOB)") + source.executemany( + "INSERT INTO records(value) VALUES (?)", + [(b"a" * 3500,) for _ in range(300)], + ) + source.commit() + + source.execute("BEGIN IMMEDIATE") + source.executemany( + "UPDATE records SET value=? WHERE id=?", + [(b"b" * 3500, row_id) for row_id in range(1, 301)], + ) + + raw_copy = tmp_path / "offline.db" + wal_copy = tmp_path / "offline.db-wal" + shutil.copy2(live_db, raw_copy) + shutil.copy2(live_db.with_name(f"{live_db.name}-wal"), wal_copy) + finally: + source.rollback() + source.close() + + wal_bytes = wal_copy.read_bytes() + page_size = int.from_bytes(wal_bytes[8:12], "big") + physical_frames = (len(wal_bytes) - 32) // (24 + page_size) + candidate_db = tmp_path / "candidate.db" + stats = recover_raw_flocks_db.reconstruct_sqlite_candidate( + raw_copy, + wal_copy, + candidate_db, + ) + + assert stats["wal_frames"] < physical_frames + with sqlite3.connect(candidate_db) as db: + assert db.execute("SELECT DISTINCT substr(value, 1, 1) FROM records").fetchall() == [ + (b"a",) + ] + + +def test_reconstruct_uses_valid_512_byte_header_page_size(tmp_path): + source_db = tmp_path / "page-512.db" + with sqlite3.connect(source_db) as db: + db.execute("PRAGMA page_size=512") + db.execute("VACUUM") + db.execute("CREATE TABLE probe(value TEXT)") + db.execute("INSERT INTO probe VALUES ('ok')") + db.commit() + assert db.execute("PRAGMA page_size").fetchone()[0] == 512 + + candidate_db = tmp_path / "candidate.db" + stats = recover_raw_flocks_db.reconstruct_sqlite_candidate(source_db, None, candidate_db) + + assert stats["pagesize"] == 512 + with sqlite3.connect(candidate_db) as db: + assert db.execute("SELECT value FROM probe").fetchone() == ("ok",) + + +def test_synthetic_page_one_is_a_valid_empty_sqlite_database(tmp_path): + candidate = tmp_path / "synthetic.db" + candidate.write_bytes(recover_raw_flocks_db._build_synthetic_page1(4096, 1)) + + with sqlite3.connect(candidate) as db: + assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert db.execute("SELECT COUNT(*) FROM sqlite_schema").fetchone() == (0,) + + +def test_reconstruct_rejects_wal_with_different_header_page_size(tmp_path): + source_db = tmp_path / "page-4096.db" + with sqlite3.connect(source_db) as db: + db.execute("PRAGMA page_size=4096") + db.execute("VACUUM") + db.execute("CREATE TABLE probe(value TEXT)") + db.execute("INSERT INTO probe VALUES ('main-db')") + db.commit() + assert source_db.stat().st_size % 1024 == 0 + + unrelated_wal = tmp_path / "unrelated.db-wal" + unrelated_wal.write_bytes(_build_wal([(1, 1, ord("W"))], page_size=1024)) + + with pytest.raises(ValueError, match="does not match"): + recover_raw_flocks_db.reconstruct_sqlite_candidate( + source_db, + unrelated_wal, + tmp_path / "candidate.db", + ) + + +def test_successful_cli_path_preserves_sidecars_and_compatibility_output( + tmp_path, + monkeypatch, + capsys, +): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + wal_path = data_dir / "flocks.db-wal" + shm_path = data_dir / "flocks.db-shm" + wal_path.write_bytes(b"live-wal") + shm_path.write_bytes(b"live-shm") + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + output_db = tmp_path / "recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + def fake_recovery(raw_path, wal_path, recovery_dir, *, prefix): + assert raw_path == damaged_db + assert wal_path is None + recovery_dir.mkdir(parents=True) + artifact_paths = recover_raw_flocks_db._recovery_artifact_paths(recovery_dir, prefix) + candidate_db, recover_sql, extracted_db, recovered_db, summary_path = artifact_paths + recovered_db.write_bytes(b"recovered-db") + summary_path.write_text("recovery=ok\n", encoding="utf-8") + return recover_raw_flocks_db.RecoveryArtifacts( + recovery_dir=recovery_dir, + candidate_db=candidate_db, + recover_sql=recover_sql, + extracted_db=extracted_db, + recovered_db=recovered_db, + summary_path=summary_path, + lost_and_found_table="lost_and_found", + pagesize=4096, + wal_frames=0, + wal_final_db_pages=0, + copied_rows={}, + ) + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fake_recovery) + + assert ( + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(output_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + == 0 + ) + + assert output_db.read_bytes() == b"recovered-db" + assert wal_path.read_bytes() == b"live-wal" + assert shm_path.read_bytes() == b"live-shm" + assert "removed_sidecars=none\n" in capsys.readouterr().out + + +def test_sqlite_recover_rejects_partial_output_on_failure(tmp_path, monkeypatch): + candidate_db = tmp_path / "candidate.db" + candidate_db.write_bytes(b"candidate") + recover_sql = tmp_path / "recover.sql" + + monkeypatch.setattr( + recover_raw_flocks_db, + "_sqlite_recover_capability", + lambda: (True, ""), + ) + monkeypatch.setattr( + recover_raw_flocks_db.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=1, + stdout="BEGIN;\n", + stderr="sql error: no such table: sqlite_dbpage", + ), + ) + + with pytest.raises(RuntimeError, match="no such table: sqlite_dbpage"): + recover_raw_flocks_db._run_sqlite_recover( + candidate_db, + recover_sql, + lost_and_found_table="lost_and_found", + ) + + assert recover_sql.exists() is False + + +def test_real_recovery_preserves_auth_and_custom_tables(tmp_path, monkeypatch): + _require_sqlite_recover() + + data_dir = tmp_path / "live-data" + data_dir.mkdir() + source_db = tmp_path / "source.db" + with sqlite3.connect(source_db) as db: + db.executescript( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO storage VALUES ('probe', '{}', 'json', 'now', 'now'); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'active', + must_reset_password INTEGER NOT NULL DEFAULT 0, + tenant_ids TEXT NOT NULL DEFAULT '[]', + asset_groups TEXT NOT NULL DEFAULT '[]', + temp_password_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT + ); + INSERT INTO users VALUES ( + 'u1', 'admin', 'hash', 'admin', 'active', 0, '[]', '[]', NULL, 'now', 'now', NULL + ); + CREATE TABLE user_sessions ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO user_sessions VALUES ('session-1', 'u1', 'later', 'now', 'now'); + CREATE TABLE custom_records (id INTEGER PRIMARY KEY, value TEXT NOT NULL); + CREATE INDEX idx_custom_records_value ON custom_records(value); + CREATE VIEW custom_record_values AS SELECT value FROM custom_records; + CREATE TABLE custom_audit (record_id INTEGER NOT NULL, value TEXT NOT NULL); + CREATE TRIGGER custom_records_audit + AFTER INSERT ON custom_records + BEGIN + INSERT INTO custom_audit VALUES (NEW.id, NEW.value); + END; + INSERT INTO custom_records(value) VALUES ('preserved'); + CREATE TABLE lost_and_found (id INTEGER PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO lost_and_found(value) VALUES ('business-table'); + PRAGMA user_version=7; + PRAGMA application_id=4242; + """ + ) + db.commit() + + artifacts_dir = tmp_path / "artifacts" + output_db = tmp_path / "recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + assert ( + recover_raw_flocks_db.main( + [ + str(source_db), + "--output", + str(output_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "real", + ] + ) + == 0 + ) + + with sqlite3.connect(output_db) as db: + assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert db.execute("SELECT username FROM users").fetchone() == ("admin",) + assert db.execute("SELECT session_id FROM user_sessions").fetchone() == ("session-1",) + assert db.execute("SELECT value FROM custom_records").fetchone() == ("preserved",) + assert db.execute("SELECT value FROM custom_record_values").fetchone() == ("preserved",) + assert db.execute("SELECT value FROM lost_and_found").fetchone() == ("business-table",) + assert db.execute( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_custom_records_value'" + ).fetchone() == (1,) + db.execute("INSERT INTO custom_records(value) VALUES ('after-recovery')") + db.commit() + assert db.execute( + "SELECT value FROM custom_audit WHERE value='after-recovery'" + ).fetchone() == ("after-recovery",) + assert db.execute("PRAGMA user_version").fetchone() == (7,) + assert db.execute("PRAGMA application_id").fetchone() == (4242,) + + +def test_real_wal_recovery_preserves_committed_row(tmp_path, monkeypatch): + _require_sqlite_recover() + + data_dir = tmp_path / "live-data" + data_dir.mkdir() + live_db = tmp_path / "snapshot-source.db" + source = sqlite3.connect(live_db) + try: + assert source.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + source.execute("PRAGMA wal_autocheckpoint=0") + source.executescript( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE wal_business_records (id INTEGER PRIMARY KEY, value TEXT NOT NULL); + """ + ) + source.commit() + assert source.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()[0] == 0 + source.execute("INSERT INTO storage VALUES ('wal-row', '{}', 'json', 'now', 'now')") + source.execute("INSERT INTO wal_business_records(value) VALUES ('wal-only')") + source.commit() + + raw_copy = tmp_path / "offline-copy.db" + wal_copy = tmp_path / "offline-copy.db-wal" + shutil.copy2(live_db, raw_copy) + shutil.copy2(live_db.with_name(f"{live_db.name}-wal"), wal_copy) + main_only_copy = tmp_path / "main-only.db" + shutil.copy2(live_db, main_only_copy) + with sqlite3.connect(main_only_copy) as main_only: + assert main_only.execute("SELECT COUNT(*) FROM wal_business_records").fetchone() == ( + 0, + ) + + output_db = tmp_path / "wal-recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + assert ( + recover_raw_flocks_db.main( + [ + str(raw_copy), + "--wal", + str(wal_copy), + "--output", + str(output_db), + "--artifacts-dir", + str(tmp_path / "wal-artifacts"), + "--prefix", + "wal", + ] + ) + == 0 + ) + finally: + source.close() + + with sqlite3.connect(output_db) as db: + assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert db.execute("SELECT key FROM storage WHERE key='wal-row'").fetchone() == ( + "wal-row", + ) + assert db.execute("SELECT value FROM wal_business_records").fetchone() == ("wal-only",) + + +def test_real_recovery_preserves_fts_virtual_table(tmp_path, monkeypatch): + _require_sqlite_recover() + + source_db = tmp_path / "fts-source.db" + with sqlite3.connect(source_db) as db: + try: + db.execute("CREATE VIRTUAL TABLE memory_search USING fts5(content)") + except sqlite3.OperationalError: + pytest.skip("SQLite was built without FTS5") + db.execute("INSERT INTO memory_search(content) VALUES ('recoverable memory')") + db.commit() + + data_dir = tmp_path / "live-data" + data_dir.mkdir() + output_db = tmp_path / "fts-recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + assert ( + recover_raw_flocks_db.main( + [ + str(source_db), + "--output", + str(output_db), + "--artifacts-dir", + str(tmp_path / "fts-artifacts"), + "--prefix", + "fts", + ] + ) + == 0 + ) + + with sqlite3.connect(output_db) as db: + assert db.execute( + "SELECT content FROM memory_search WHERE memory_search MATCH 'recoverable'" + ).fetchone() == ("recoverable memory",) + + +def test_failed_normalized_build_does_not_publish_output(tmp_path, monkeypatch): + extracted_db = tmp_path / "extracted.db" + with sqlite3.connect(extracted_db) as db: + db.executescript(recover_raw_flocks_db.STORAGE_DDL) + db.commit() + + def fail_validation(*_args, **_kwargs): + raise RuntimeError("injected normalized build failure") + + monkeypatch.setattr( + recover_raw_flocks_db, + "_validate_recovered_db", + fail_validation, + ) + output_db = tmp_path / "recovered.db" + + with pytest.raises(RuntimeError, match="injected normalized build failure"): + recover_raw_flocks_db.build_normalized_recovery_db(extracted_db, output_db) + + assert output_db.exists() is False + + +def test_unattributed_lost_rows_are_retained_without_guessing_destination(tmp_path): + extracted_db = tmp_path / "extracted.db" + lost_table = "flocks_recovery_lost_test" + with sqlite3.connect(extracted_db) as db: + db.executescript(recover_raw_flocks_db.STORAGE_DDL) + db.execute( + "INSERT INTO storage VALUES (?, ?, ?, ?, ?)", + ("session:existing", '"direct"', "json", "now", "now"), + ) + db.executescript( + """ + CREATE TABLE storage_audit(key TEXT NOT NULL); + CREATE TRIGGER storage_insert_audit + AFTER INSERT ON storage + BEGIN + INSERT INTO storage_audit VALUES (NEW.key); + END; + """ + ) + lost_columns = ", ".join(f"c{index} TEXT" for index in range(24)) + db.execute(f"CREATE TABLE {lost_table} (nfield INTEGER, {lost_columns})") + placeholders = ", ".join("?" for _ in range(25)) + db.executemany( + f"INSERT INTO {lost_table} VALUES ({placeholders})", + [ + (6, "ignored", "x", "x", "x", "x", *(None for _ in range(19))), + ( + 5, + "session:existing", + '"lost"', + "json", + "old", + "old", + *(None for _ in range(19)), + ), + ( + 5, + "session:new", + '"recovered"', + "json", + "old", + "old", + *(None for _ in range(19)), + ), + ], + ) + db.commit() + + output_db = tmp_path / "recovered.db" + counts = recover_raw_flocks_db.build_normalized_recovery_db( + extracted_db, + output_db, + lost_and_found_table=lost_table, + ) + + assert counts["storage"] == 1 + with sqlite3.connect(output_db) as db: + assert db.execute( + "SELECT value FROM storage WHERE key='session:existing'" + ).fetchone() == ('"direct"',) + assert db.execute("SELECT value FROM storage WHERE key='session:new'").fetchone() is None + assert db.execute(f"SELECT COUNT(*) FROM {lost_table}").fetchone() == (3,) + assert db.execute("SELECT COUNT(*) FROM storage_audit").fetchone() == (0,) + db.execute( + "INSERT INTO storage VALUES (?, ?, ?, ?, ?)", + ("session:after", '"normal"', "json", "now", "now"), + ) + db.commit() + assert db.execute("SELECT key FROM storage_audit").fetchone() == ("session:after",) + + +def test_exact_backup_accepts_legacy_schema_and_narrow_lost_table(tmp_path): + """Current schema assumptions must not block an otherwise valid recovered DB.""" + extracted_db = tmp_path / "legacy-extracted.db" + lost_table = "flocks_recovery_lost_narrow" + orphan_table = "flocks_recovery_lost_orphan" + with sqlite3.connect(extracted_db) as db: + db.execute( + "CREATE TABLE usage_records(id TEXT PRIMARY KEY, legacy_value TEXT NOT NULL)" + ) + db.execute("INSERT INTO usage_records VALUES ('legacy-1', 'preserve')") + db.execute( + f"CREATE TABLE {lost_table} " + "(rootpgno INTEGER, pgno INTEGER, nfield INTEGER, c0 TEXT, c1 TEXT, c2 TEXT, c3 TEXT, c4 TEXT)" + ) + db.execute( + f"INSERT INTO {lost_table} VALUES (1, 2, 5, ?, ?, ?, ?, ?)", + ("https://example.test/path", "v", "t", "created", "updated"), + ) + orphan_columns = ", ".join(f"c{index} TEXT" for index in range(24)) + db.execute( + f"CREATE TABLE {orphan_table} " + f"(rootpgno INTEGER, pgno INTEGER, nfield INTEGER, {orphan_columns})" + ) + orphan_values = ( + 5, + 6, + 24, + "txe_orphan", + "missing_scheduler", + *(None for _ in range(22)), + ) + db.execute( + f"INSERT INTO {orphan_table} VALUES ({', '.join('?' for _ in orphan_values)})", + orphan_values, + ) + db.commit() + + output_db = tmp_path / "legacy-recovered.db" + counts = recover_raw_flocks_db.build_normalized_recovery_db( + extracted_db, + output_db, + lost_and_found_table=lost_table, + ) + + assert counts["usage_records"] == 1 + assert counts["storage"] == 0 + with sqlite3.connect(output_db) as db: + assert db.execute("PRAGMA table_info(usage_records)").fetchall() == [ + (0, "id", "TEXT", 0, None, 1), + (1, "legacy_value", "TEXT", 1, None, 0), + ] + assert db.execute("SELECT * FROM usage_records").fetchone() == ( + "legacy-1", + "preserve", + ) + assert db.execute(f"SELECT COUNT(*) FROM {lost_table}").fetchone() == (1,) + assert db.execute(f"SELECT COUNT(*) FROM {orphan_table}").fetchone() == (1,) + assert not db.execute( + "SELECT 1 FROM sqlite_schema WHERE name='task_executions'" + ).fetchone() + assert not db.execute( + "SELECT 1 FROM sqlite_schema WHERE name='idx_usage_provider'" + ).fetchone() + + +@pytest.mark.parametrize( + "mutation", + ["delete_row", "update_row", "rowid_change", "drop_view"], +) +def test_recovered_db_validator_fails_on_unknown_data_or_schema_loss(tmp_path, mutation): + source_db = tmp_path / "source.db" + target_db = tmp_path / "target.db" + with sqlite3.connect(source_db) as source: + source.executescript(recover_raw_flocks_db.STORAGE_DDL) + source.executescript( + """ + CREATE TABLE plugin_records(id INTEGER PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO plugin_records(value) VALUES ('preserve-me'); + CREATE TABLE plugin_heap(value TEXT NOT NULL); + INSERT INTO plugin_heap(value) VALUES ('same-visible-value'); + CREATE VIEW plugin_record_values AS SELECT value FROM plugin_records; + """ + ) + source.commit() + with sqlite3.connect(target_db) as target: + source.backup(target) + + with sqlite3.connect(target_db) as target: + if mutation == "delete_row": + target.execute("DELETE FROM plugin_records") + elif mutation == "update_row": + target.execute("UPDATE plugin_records SET value='changed'") + elif mutation == "rowid_change": + target.execute("DELETE FROM plugin_heap") + target.execute( + "INSERT INTO plugin_heap(rowid, value) VALUES (99, 'same-visible-value')" + ) + else: + target.execute("DROP VIEW plugin_record_values") + target.commit() + + with sqlite3.connect(source_db) as source, sqlite3.connect(target_db) as target: + with pytest.raises(sqlite3.DatabaseError): + recover_raw_flocks_db._validate_recovered_db(source, target) + + +def test_validator_skips_unavailable_virtual_table_module(tmp_path): + source_db = tmp_path / "source.db" + target_db = tmp_path / "target.db" + with sqlite3.connect(source_db) as source: + source.execute("CREATE TABLE plugin_records(id INTEGER PRIMARY KEY, value TEXT)") + source.execute("INSERT INTO plugin_records(value) VALUES ('preserved')") + source.execute("PRAGMA writable_schema=ON") + source.execute( + "INSERT INTO sqlite_schema(type, name, tbl_name, rootpage, sql) " + "VALUES ('table', 'plugin_vtab', 'plugin_vtab', 0, " + "'CREATE VIRTUAL TABLE plugin_vtab USING unavailable_module(value)')" + ) + source.execute("PRAGMA writable_schema=OFF") + source.commit() + with sqlite3.connect(target_db) as target: + source.backup(target) + + with sqlite3.connect(source_db) as source, sqlite3.connect(target_db) as target: + recover_raw_flocks_db._validate_recovered_db(source, target) + + +def test_failed_final_copy_does_not_publish_output(tmp_path, monkeypatch): + source = tmp_path / "source.db" + source.write_bytes(b"recovered-content") + output = tmp_path / "output.db" + + def fail_copy(_source, destination): + destination.write(b"part") + raise OSError("injected copy failure") + + monkeypatch.setattr(recover_raw_flocks_db.shutil, "copyfileobj", fail_copy) + + with pytest.raises(OSError, match="injected copy failure"): + recover_raw_flocks_db._copy_file_exclusive(source, output) + + assert output.exists() is False + + +def test_output_created_after_validation_is_not_overwritten(tmp_path, monkeypatch): + data_dir = tmp_path / "live-data" + data_dir.mkdir() + damaged_db = tmp_path / "damaged.db" + damaged_db.write_bytes(b"damaged-db") + artifacts_dir = tmp_path / "artifacts" + output_db = tmp_path / "recovered.db" + monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + + def fake_recovery(raw_path, wal_path, recovery_dir, *, prefix): + assert raw_path == damaged_db + assert wal_path is None + recovery_dir.mkdir(parents=True) + artifact_paths = recover_raw_flocks_db._recovery_artifact_paths(recovery_dir, prefix) + candidate_db, recover_sql, extracted_db, recovered_db, summary_path = artifact_paths + recovered_db.write_bytes(b"recovered-db") + summary_path.write_text("recovery=ok\n", encoding="utf-8") + output_db.write_bytes(b"late-output") + return recover_raw_flocks_db.RecoveryArtifacts( + recovery_dir=recovery_dir, + candidate_db=candidate_db, + recover_sql=recover_sql, + extracted_db=extracted_db, + recovered_db=recovered_db, + summary_path=summary_path, + lost_and_found_table="lost_and_found", + pagesize=4096, + wal_frames=0, + wal_final_db_pages=0, + copied_rows={}, + ) + + monkeypatch.setattr(recover_raw_flocks_db, "recover_raw_storage_db", fake_recovery) + + with pytest.raises(FileExistsError): + recover_raw_flocks_db.main( + [ + str(damaged_db), + "--output", + str(output_db), + "--artifacts-dir", + str(artifacts_dir), + "--prefix", + "safe", + ] + ) + + assert output_db.read_bytes() == b"late-output" diff --git a/tests/server/routes/test_event_routes.py b/tests/server/routes/test_event_routes.py new file mode 100644 index 000000000..35da794a2 --- /dev/null +++ b/tests/server/routes/test_event_routes.py @@ -0,0 +1,340 @@ +import json +from unittest.mock import AsyncMock + +import pytest +from starlette.requests import Request + +from flocks.auth.context import AuthUser +from flocks.server.routes import event as event_routes +from flocks.server.routes.event import EventBroadcaster +from flocks.session.session import SessionInfo + + +def _snapshot(text: str, delta: str, part_type: str = "text") -> dict: + return { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "messageID": "message-1", + "sessionID": "session-1", + "type": part_type, + "text": text, + }, + "delta": delta, + }, + } + + +@pytest.mark.asyncio +async def test_event_broadcaster_compacts_overflowing_client_queue(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=1) + queue = await broadcaster.subscribe() + + for index in range(5): + await broadcaster.publish({ + "type": "message.part.updated", + "properties": {"index": index}, + }) + + assert queue.qsize() <= 3 + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + + assert any(event["type"] == "server.events_dropped" for event in events) + assert events[-1] == { + "type": "message.part.updated", + "properties": {"index": 4}, + } + + +@pytest.mark.asyncio +async def test_event_broadcaster_shutdown_does_not_block_on_full_queue(): + broadcaster = EventBroadcaster(queue_maxsize=2, queue_drop_to=0) + queue = await broadcaster.subscribe() + + await broadcaster.publish({"type": "event.one", "properties": {}}) + await broadcaster.publish({"type": "event.two", "properties": {}}) + await broadcaster.shutdown() + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + + assert broadcaster.client_count == 0 + assert any(event["type"] == "server.shutting_down" for event in events) + + +@pytest.mark.asyncio +async def test_event_broadcaster_keeps_latest_event_when_drop_target_is_max_minus_one(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=2) + queue = await broadcaster.subscribe() + + for event_type in ("event.one", "event.two", "event.three", "event.latest"): + await broadcaster.publish({"type": event_type, "properties": {}}) + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + + assert events[-1]["type"] == "event.latest" + assert any(event["type"] == "server.events_dropped" for event in events) + + +@pytest.mark.asyncio +async def test_event_broadcaster_preserves_low_backlog_snapshot_cadence(): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=3) + queue = await broadcaster.subscribe() + first = _snapshot("hello", "hello") + latest = _snapshot("hello world", " world") + + broadcaster._publish_to_queue(queue, first) + broadcaster._publish_to_queue(queue, latest) + + assert queue.qsize() == 2 + assert queue.get_nowait() == first + assert queue.get_nowait() == latest + + +@pytest.mark.parametrize("part_type", ["text", "reasoning"]) +@pytest.mark.asyncio +async def test_event_broadcaster_coalesces_adjacent_snapshots_at_high_water(part_type: str): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=3) + queue = await broadcaster.subscribe() + snapshots = [ + _snapshot("h", "h", part_type), + _snapshot("he", "e", part_type), + _snapshot("hel", "l", part_type), + _snapshot("hello", "lo", part_type), + ] + + for snapshot in snapshots: + broadcaster._publish_to_queue(queue, snapshot) + + assert queue.qsize() == 3 + assert queue.get_nowait() == snapshots[0] + assert queue.get_nowait() == snapshots[1] + merged = queue.get_nowait() + assert merged["properties"]["part"]["text"] == "hello" + assert merged["properties"]["delta"] == "llo" + + +@pytest.mark.asyncio +async def test_event_broadcaster_does_not_coalesce_across_control_events(): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=2) + queue = await broadcaster.subscribe() + first = _snapshot("hello", "hello") + control = {"type": "question.asked", "properties": {"requestID": "question-1"}} + latest = _snapshot("hello world", " world") + + broadcaster._publish_to_queue(queue, first) + broadcaster._publish_to_queue(queue, control) + broadcaster._publish_to_queue(queue, latest) + + assert queue.qsize() == 3 + assert queue.get_nowait() == first + assert queue.get_nowait() == control + assert queue.get_nowait() == latest + + +@pytest.mark.asyncio +async def test_message_finish_is_an_ordering_barrier_at_high_water(): + broadcaster = EventBroadcaster(queue_maxsize=10, queue_coalesce_at=2) + queue = await broadcaster.subscribe() + first = _snapshot("hello", "hello") + finish = { + "type": "message.updated", + "properties": { + "info": { + "id": "message-1", + "sessionID": "session-1", + "finish": "stop", + }, + }, + } + later = _snapshot("hello world", " world") + + broadcaster._publish_to_queue(queue, first) + broadcaster._publish_to_queue(queue, finish) + broadcaster._publish_to_queue(queue, later) + + assert queue.qsize() == 3 + assert queue.get_nowait() == first + assert queue.get_nowait() == finish + assert queue.get_nowait() == later + + +@pytest.mark.asyncio +async def test_high_backlog_snapshot_memory_remains_bounded(): + broadcaster = EventBroadcaster(queue_maxsize=1000, queue_coalesce_at=4) + queue = await broadcaster.subscribe() + text = "" + + for _ in range(1000): + delta = "x" * 100 + text += delta + broadcaster._publish_to_queue(queue, _snapshot(text, delta)) + + assert queue.qsize() == 4 + payloads = [queue.get_nowait() for _ in range(queue.qsize())] + assert "".join(payload["properties"]["delta"] for payload in payloads) == text + assert payloads[-1]["properties"]["part"]["text"] == text + assert len(json.dumps(payloads)) < 250_000 + + +@pytest.mark.asyncio +async def test_text_snapshot_does_not_evict_control_events(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=1) + queue = await broadcaster.subscribe() + controls = [ + {"type": "permission.request", "properties": {"requestID": "permission-1"}}, + {"type": "question.asked", "properties": {"id": "question-1"}}, + ] + for event in controls: + broadcaster._publish_to_queue(queue, event) + + def _snapshot(part_id: str) -> dict: + return { + "type": "message.part.updated", + "properties": { + "part": { + "id": part_id, + "messageID": "message-1", + "sessionID": "session-1", + "type": "text", + "text": part_id, + }, + "delta": part_id, + }, + } + + broadcaster._publish_to_queue(queue, _snapshot("part-old")) + broadcaster._publish_to_queue(queue, _snapshot("part-latest")) + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + assert [event["type"] for event in events[:2]] == [ + "permission.request", + "question.asked", + ] + assert events[-1]["type"] == "server.events_dropped" + + +@pytest.mark.asyncio +async def test_snapshot_dropped_behind_full_control_queue_emits_deferred_marker(): + broadcaster = EventBroadcaster(queue_maxsize=3, queue_drop_to=1) + queue = await broadcaster.subscribe() + controls = [ + {"type": f"control.{index}", "properties": {"index": index}} + for index in range(3) + ] + for event in controls: + broadcaster._publish_to_queue(queue, event) + + broadcaster._publish_to_queue(queue, { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-dropped", + "messageID": "message-1", + "sessionID": "session-1", + "type": "text", + "text": "dropped", + }, + "delta": "dropped", + }, + }) + + assert queue.qsize() == 3 + assert await queue.get() == controls[0] + assert await queue.get() == controls[1] + assert await queue.get() == controls[2] + marker = await queue.get() + assert marker["type"] == "server.events_dropped" + assert marker["properties"]["dropped"] == 1 + + +@pytest.mark.asyncio +async def test_session_events_are_filtered_by_subscriber_user(monkeypatch: pytest.MonkeyPatch): + broadcaster = EventBroadcaster() + alice = AuthUser(id="user-alice", username="alice", role="member") + bob = AuthUser(id="user-bob", username="bob", role="member") + session = SessionInfo( + id="session-private", + projectID="project-1", + directory="/tmp/project-1", + ownerUserID=alice.id, + ownerUsername=alice.username, + ) + + async def _get_event_session(session_id: str): + assert session_id == session.id + return session + + monkeypatch.setattr(event_routes, "_get_event_session", _get_event_session) + alice_queue = await broadcaster.subscribe(alice) + bob_queue = await broadcaster.subscribe(bob) + + private_event = { + "type": "message.part.updated", + "properties": { + "part": { + "id": "part-1", + "messageID": "message-1", + "sessionID": session.id, + "type": "text", + "text": "private content", + } + }, + } + await broadcaster.publish(private_event) + + assert alice_queue.get_nowait() == private_event + assert bob_queue.empty() + + global_event = {"type": "workflow.updated", "properties": {"id": "workflow-1"}} + await broadcaster.publish(global_event) + assert alice_queue.get_nowait() == global_event + assert bob_queue.get_nowait() == global_event + + +@pytest.mark.asyncio +async def test_deleted_session_event_can_still_resolve_its_owner(monkeypatch: pytest.MonkeyPatch): + from flocks.session.session import Session + from flocks.storage.storage import Storage + + deleted = SessionInfo( + id="session-deleted", + projectID="project-1", + directory="/tmp/project-1", + status="deleted", + ownerUserID="user-owner", + ownerUsername="owner", + ) + monkeypatch.setattr(Session, "_all_sessions_cache", None) + monkeypatch.setattr(Session, "get_by_id", AsyncMock(return_value=None)) + monkeypatch.setattr(Storage, "list_keys", AsyncMock(return_value=["session:project-1:session-deleted"])) + monkeypatch.setattr(Storage, "get", AsyncMock(return_value=deleted)) + + assert await event_routes._get_event_session(deleted.id) == deleted + + +@pytest.mark.asyncio +async def test_global_event_subscription_keeps_authenticated_user(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import global_ as global_routes + + user = AuthUser(id="user-alice", username="alice", role="member") + request = Request({"type": "http", "method": "GET", "path": "/global/event", "headers": []}) + request.state.auth_user = user + broadcaster = EventBroadcaster() + subscribe = AsyncMock(wraps=broadcaster.subscribe) + monkeypatch.setattr(broadcaster, "subscribe", subscribe) + monkeypatch.setattr(global_routes.EventBroadcaster, "get", classmethod(lambda cls: broadcaster)) + + response = await global_routes.get_global_events(request) + + subscribe.assert_awaited_once_with(user) + await response.body_iterator.aclose() diff --git a/tests/server/routes/test_global_mutation_auth.py b/tests/server/routes/test_global_mutation_auth.py new file mode 100644 index 000000000..2c2bd8d69 --- /dev/null +++ b/tests/server/routes/test_global_mutation_auth.py @@ -0,0 +1,162 @@ +"""Authorization boundaries for global provider and Hub mutations.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient +import pytest + +from flocks.auth.context import AuthUser + + +def _user_client(router, *, prefix: str, role: str) -> TestClient: + app = FastAPI() + + @app.middleware("http") + async def user_auth(request, call_next): + request.state.auth_user = AuthUser( + id=f"{role}-test", + username=f"{role}-test", + role=role, + status="active", + must_reset_password=False, + ) + return await call_next(request) + + app.include_router(router, prefix=prefix) + return TestClient(app, raise_server_exceptions=True) + + +def test_provider_global_configuration_credentials_and_tests_require_admin(): + from flocks.server.routes.provider import router + + client = _user_client(router, prefix="/api/provider", role="member") + responses = [ + client.post("/api/provider/example/oauth/authorize?method=0"), + client.post("/api/provider/example/oauth/callback?method=0"), + client.post("/api/provider/example/configure", json={}), + client.post("/api/provider/example/test"), + client.put("/api/provider/example", json={}), + client.get("/api/provider/example/credentials"), + client.post("/api/provider/example/credentials", json={"api_key": "secret"}), + client.delete("/api/provider/example/credentials"), + client.get("/api/provider/example/service-credentials"), + client.post("/api/provider/example/service-credentials", json={"api_key": "secret"}), + client.post("/api/provider/example/test-credentials", json={}), + client.patch("/api/provider/api-services/example", json={"enabled": False}), + client.delete("/api/provider/api-services/example"), + client.post("/api/provider/api-services/refresh"), + ] + + assert [response.status_code for response in responses] == [403] * len(responses) + + +def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import provider as provider_routes + + raw_llm_key = "sk-raw-llm-secret-1234" + raw_service_key = "raw-service-secret-5678" + secrets = MagicMock() + secrets.get.side_effect = lambda key: { + "llm-example_llm_key": raw_llm_key, + "service-example_api_key": raw_service_key, + }.get(key) + monkeypatch.setattr("flocks.security.get_secret_manager", lambda: secrets) + monkeypatch.setattr( + provider_routes.ConfigWriter, + "get_provider_raw", + lambda _provider_id: None, + ) + monkeypatch.setattr( + provider_routes.ConfigWriter, + "get_api_service_raw", + lambda _provider_id: { + "apiKey": "{secret:service-example_api_key}", + "base_url": "https://service.example.test", + }, + ) + monkeypatch.setattr( + provider_routes, + "_load_api_service_metadata_data", + lambda _provider_id: { + "credential_fields": [ + { + "key": "api_key", + "storage": "secret", + "sensitive": True, + "config_key": "apiKey", + "secret_id": "service-example_api_key", + }, + { + "key": "base_url", + "storage": "config", + "config_key": "base_url", + }, + ], + }, + ) + + client = _user_client(provider_routes.router, prefix="/api/provider", role="admin") + llm_response = client.get("/api/provider/llm-example/credentials") + service_response = client.get("/api/provider/service-example/service-credentials") + + assert llm_response.status_code == 200 + assert service_response.status_code == 200 + llm_payload = llm_response.json() + service_payload = service_response.json() + assert llm_payload["api_key"] is None + assert llm_payload["api_key_masked"] + assert service_payload["api_key"] is None + assert service_payload["api_key_masked"] + assert service_payload["fields"]["api_key"] != raw_service_key + assert service_payload["fields"]["base_url"] == "https://service.example.test" + assert raw_llm_key not in llm_response.text + assert raw_service_key not in service_response.text + + +def test_admin_provider_update_never_writes_raw_api_key_to_storage(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import provider as provider_routes + + runtime_provider = MagicMock() + storage_write = AsyncMock() + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", MagicMock()) + monkeypatch.setattr(provider_routes.Provider, "get", lambda _provider_id: runtime_provider) + monkeypatch.setattr(provider_routes.Storage, "write", storage_write) + monkeypatch.setattr( + provider_routes, + "get_provider", + AsyncMock(return_value=provider_routes.ProviderInfo(id="example", name="Example")), + ) + + client = _user_client(provider_routes.router, prefix="/api/provider", role="admin") + response = client.put( + "/api/provider/example", + json={ + "api_key": "raw-update-secret", + "base_url": "https://provider.example.test", + "custom_settings": {"timeout": 10}, + }, + ) + + assert response.status_code == 200 + storage_write.assert_awaited_once() + stored_payload = storage_write.await_args.args[1] + assert "api_key" not in stored_payload + assert "raw-update-secret" not in repr(stored_payload) + + +def test_hub_global_mutations_require_admin(): + from flocks.server.routes.hub import router + + client = _user_client(router, prefix="/api", role="member") + responses = [ + client.post("/api/hub/plugins/skill/example/install", json={"scope": "global"}), + client.post("/api/hub/plugins/component/example/install/stream", json={"scope": "global"}), + client.post("/api/hub/plugins/skill/example/update", json={"scope": "global"}), + client.delete("/api/hub/plugins/skill/example"), + client.post("/api/hub/refresh"), + ] + + assert [response.status_code for response in responses] == [403, 403, 403, 403, 403] diff --git a/tests/server/routes/test_interaction_access_routes.py b/tests/server/routes/test_interaction_access_routes.py new file mode 100644 index 000000000..00642bcf3 --- /dev/null +++ b/tests/server/routes/test_interaction_access_routes.py @@ -0,0 +1,228 @@ +"""Session ownership checks for question and permission interactions.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user +from flocks.session.session import SessionInfo + + +def _user(user_id: str, username: str, *, role: str = "member") -> AuthUser: + return AuthUser( + id=user_id, + username=username, + role=role, + status="active", + must_reset_password=False, + ) + + +def _client(router, *, prefix: str, user: AuthUser) -> TestClient: + app = FastAPI() + app.dependency_overrides[require_user] = lambda: user + app.include_router(router, prefix=prefix) + return TestClient(app, raise_server_exceptions=True) + + +@pytest.fixture() +def owned_session() -> SessionInfo: + return SessionInfo( + id="ses_owner", + project_id="project", + directory="/tmp", + title="Owned session", + owner_user_id="owner-id", + owner_username="owner", + ) + + +def test_question_routes_reject_non_owner(monkeypatch: pytest.MonkeyPatch, owned_session: SessionInfo): + from flocks.server.routes import question as question_routes + + request_id = "question-owner-only" + question_routes.store_question_request(request_id, { + "id": request_id, + "sessionID": owned_session.id, + "questions": [{"question": "Continue?"}], + }) + monkeypatch.setattr(question_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + client = _client(question_routes.router, prefix="/question", user=_user("other-id", "other")) + + try: + assert client.get(f"/question/session/{owned_session.id}/pending").status_code == 403 + assert client.post( + f"/question/{request_id}/reply", + json={"answers": [["yes"]]}, + ).status_code == 403 + assert client.post(f"/question/{request_id}/reject").status_code == 403 + assert question_routes.get_question_request(request_id) is not None + finally: + question_routes.clear_request_state(request_id) + + +def test_question_owner_can_list_and_reply(monkeypatch: pytest.MonkeyPatch, owned_session: SessionInfo): + from flocks.server.routes import question as question_routes + + request_id = "question-owner-reply" + question_routes.store_question_request(request_id, { + "id": request_id, + "sessionID": owned_session.id, + "questions": [{"question": "Continue?"}], + }) + monkeypatch.setattr(question_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr("flocks.server.routes.event.publish_event", AsyncMock()) + client = _client(question_routes.router, prefix="/question", user=_user("owner-id", "owner")) + + try: + pending = client.get(f"/question/session/{owned_session.id}/pending") + assert pending.status_code == 200 + assert [item["id"] for item in pending.json()] == [request_id] + reply = client.post( + f"/question/{request_id}/reply", + json={"answers": [["yes"]]}, + ) + assert reply.status_code == 200 + assert question_routes.get_request_answer(request_id) == [["yes"]] + finally: + question_routes.clear_request_state(request_id) + + +def test_permission_routes_filter_and_reject_non_owner( + monkeypatch: pytest.MonkeyPatch, + owned_session: SessionInfo, +): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + info = PermissionRequestInfo( + id="perm-owner-only", + sessionID=owned_session.id, + permission="bash", + patterns=["*"], + metadata={"messageID": "msg-1"}, + always=[], + tool={"name": "bash"}, + time={"created": 1}, + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "list_pending_infos", AsyncMock(return_value=[info])) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=_user("other-id", "other")) + + assert client.get("/permission").json() == [] + assert client.get(f"/permission/{info.id}").status_code == 403 + assert client.post( + f"/permission/{info.id}/reply", + json={"allow": True, "always": False}, + ).status_code == 403 + reply_mock.assert_not_awaited() + + +def test_permission_owner_can_reply(monkeypatch: pytest.MonkeyPatch, owned_session: SessionInfo): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + info = PermissionRequestInfo( + id="perm-owner-reply", + sessionID=owned_session.id, + permission="bash", + patterns=["*"], + metadata={"messageID": "msg-1"}, + always=[], + tool={"name": "bash"}, + time={"created": 1}, + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=_user("owner-id", "owner")) + + response = client.post( + f"/permission/{info.id}/reply", + json={"allow": True, "always": False}, + ) + + assert response.status_code == 200 + reply_mock.assert_awaited_once_with(info.id, "allow", session_id=owned_session.id) + + +@pytest.mark.parametrize( + ("allow", "expected_reply"), + [(True, "allow_session"), (False, "deny_session")], +) +def test_permission_member_remember_is_session_scoped( + monkeypatch: pytest.MonkeyPatch, + owned_session: SessionInfo, + allow: bool, + expected_reply: str, +): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + info = PermissionRequestInfo( + id=f"perm-member-remember-{allow}", + sessionID=owned_session.id, + permission="bash", + patterns=["*"], + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=owned_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=_user("owner-id", "owner")) + + response = client.post( + f"/permission/{info.id}/reply", + json={"allow": allow, "always": True}, + ) + + assert response.status_code == 200 + reply_mock.assert_awaited_once_with(info.id, expected_reply, session_id=owned_session.id) + + +@pytest.mark.parametrize( + ("allow", "expected_reply"), + [(True, "always"), (False, "never")], +) +def test_permission_admin_remember_can_be_global( + monkeypatch: pytest.MonkeyPatch, + owned_session: SessionInfo, + allow: bool, + expected_reply: str, +): + from flocks.permission.next import PermissionRequestInfo + from flocks.server.routes import permission as permission_routes + + admin = _user("admin-id", "admin", role="admin") + admin_session = owned_session.model_copy(update={ + "owner_user_id": admin.id, + "owner_username": admin.username, + }) + info = PermissionRequestInfo( + id=f"perm-admin-remember-{allow}", + sessionID=admin_session.id, + permission="bash", + patterns=["*"], + ) + monkeypatch.setattr(permission_routes.Session, "get_by_id", AsyncMock(return_value=admin_session)) + monkeypatch.setattr(permission_routes.PermissionNext, "get_pending_info", AsyncMock(return_value=info)) + reply_mock = AsyncMock() + monkeypatch.setattr(permission_routes.PermissionNext, "reply", reply_mock) + client = _client(permission_routes.router, prefix="/permission", user=admin) + + response = client.post( + f"/permission/{info.id}/reply", + json={"allow": allow, "always": True}, + ) + + assert response.status_code == 200 + reply_mock.assert_awaited_once_with(info.id, expected_reply, session_id=admin_session.id) diff --git a/tests/server/routes/test_monitoring_routes.py b/tests/server/routes/test_monitoring_routes.py new file mode 100644 index 000000000..3886c9cf4 --- /dev/null +++ b/tests/server/routes/test_monitoring_routes.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize("mcp_status", ["disconnected", "timeout", "mystery-state"]) +async def test_status_degrades_for_non_operational_mcp_states( + monkeypatch: pytest.MonkeyPatch, + mcp_status: str, +): + from flocks.server.routes import monitoring as monitoring_routes + + monkeypatch.setattr( + monitoring_routes.MCP, + "status_snapshot", + lambda: ({"demo-mcp": SimpleNamespace(status=mcp_status)}, True), + ) + + response = await monitoring_routes.get_status() + + assert response.status == "degraded" + assert response.mcpServers == {"demo-mcp": mcp_status} + + +async def test_status_degrades_when_mcp_status_check_fails( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import monitoring as monitoring_routes + + def _status_snapshot(): + raise RuntimeError("MCP status failed") + + monkeypatch.setattr(monitoring_routes.MCP, "status_snapshot", _status_snapshot) + + response = await monitoring_routes.get_status() + + assert response.status == "degraded" + assert response.mcpServers == {} + + +async def test_status_snapshot_does_not_trigger_mcp_initialization( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import monitoring as monitoring_routes + + async def _unexpected_status(): + raise AssertionError("monitoring must not initialize MCP while reading status") + + monkeypatch.setattr(monitoring_routes.MCP, "status", _unexpected_status) + monkeypatch.setattr( + monitoring_routes.MCP, + "status_snapshot", + lambda: ({}, False), + ) + + response = await monitoring_routes.get_status() + + assert response.status == "degraded" + assert response.mcpServers == {} + + +@pytest.mark.parametrize("mcp_status", ["connected", "disabled"]) +async def test_status_accepts_operational_or_intentionally_disabled_mcp_states( + monkeypatch: pytest.MonkeyPatch, + mcp_status: str, +): + from flocks.server.routes import monitoring as monitoring_routes + + monkeypatch.setattr( + monitoring_routes.MCP, + "status_snapshot", + lambda: ({"demo-mcp": SimpleNamespace(status=mcp_status)}, True), + ) + + response = await monitoring_routes.get_status() + + assert response.status == "healthy" + + +async def test_metrics_do_not_label_tool_parse_failures_as_system_errors( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import monitoring as monitoring_routes + + monitor = SimpleNamespace( + get_metrics=lambda: { + "global": {"total_calls": 12, "failed_parses": 3}, + } + ) + monkeypatch.setattr(monitoring_routes, "get_monitor", lambda: monitor) + monkeypatch.setattr( + monitoring_routes, + "_sample_tool_rates", + lambda total, failed: (12.0, 3.0), + ) + + response = await monitoring_routes.get_metrics() + payload = response.model_dump() + + assert response.toolCallRate == 12.0 + assert response.toolParseFailureRate == 3.0 + assert response.errorRate is None + assert payload["errorRate"] is None + assert payload["toolParseFailureRate"] == 3.0 diff --git a/tests/server/routes/test_provider_optional_api_key.py b/tests/server/routes/test_provider_optional_api_key.py index ef4309606..5aa342202 100644 --- a/tests/server/routes/test_provider_optional_api_key.py +++ b/tests/server/routes/test_provider_optional_api_key.py @@ -25,6 +25,7 @@ def patched_runtime(monkeypatch: pytest.MonkeyPatch): so credentials calls remain hermetic and never write to disk. """ fake_secrets = MagicMock() + fake_secrets.get.return_value = None runtime_provider = MagicMock() runtime_provider._client = None @@ -125,6 +126,63 @@ async def test_strict_provider_still_rejects_empty_api_key(self, patched_runtime assert excinfo.value.detail == "API key required" patched_runtime["secrets"].set.assert_not_called() + @pytest.mark.asyncio + async def test_strict_provider_blank_key_preserves_existing_stored_secret( + self, patched_runtime + ): + """Editing non-secret settings must not require or rewrite the saved key.""" + patched_runtime["secrets"].get.side_effect = lambda key: { + "openai_llm_key": "sk-existing-secret", + }.get(key) + + result = await provider_routes.set_provider_credentials( + "openai", + provider_routes.ProviderCredentialRequest( + base_url="https://new.example.com/v1", + ), + ) + + assert result["success"] is True + patched_runtime["secrets"].set.assert_not_called() + provider_routes.ConfigWriter.update_provider_field.assert_called_once_with( + "openai", "options.baseURL", "https://new.example.com/v1" + ) + configured = patched_runtime["provider"].configure.call_args.args[0] + assert configured.api_key == "sk-existing-secret" + assert configured.base_url == "https://new.example.com/v1" + + @pytest.mark.asyncio + async def test_strict_provider_blank_key_preserves_existing_inline_secret( + self, patched_runtime, monkeypatch: pytest.MonkeyPatch + ): + """Legacy inline keys are reused without copying them into the secret store.""" + monkeypatch.setattr( + provider_routes.ConfigWriter, + "get_provider_raw", + lambda _provider_id: { + "options": { + "apiKey": "inline-existing-secret", + "baseURL": "https://old.example.com/v1", + } + }, + ) + + result = await provider_routes.set_provider_credentials( + "openai", + provider_routes.ProviderCredentialRequest( + api_key="", + base_url="https://new.example.com/v1", + ), + ) + + assert result["success"] is True + patched_runtime["secrets"].set.assert_not_called() + provider_routes.ConfigWriter.update_provider_field.assert_called_once_with( + "openai", "options.baseURL", "https://new.example.com/v1" + ) + configured = patched_runtime["provider"].configure.call_args.args[0] + assert configured.api_key == "inline-existing-secret" + @pytest.mark.asyncio async def test_sentinel_logged_with_explicit_marker_not_naive_mask( self, patched_runtime, capfd @@ -334,7 +392,7 @@ async def test_placeholder_is_hidden_but_has_credential_true( assert response.base_url == "http://internal/v1" @pytest.mark.asyncio - async def test_real_api_key_still_returned_and_masked( + async def test_real_api_key_is_only_returned_masked( self, monkeypatch: pytest.MonkeyPatch ): fake_secrets = MagicMock() @@ -353,7 +411,7 @@ async def test_real_api_key_still_returned_and_masked( response = await provider_routes.get_provider_credentials("openai") - assert response.api_key == "sk-real-secret-1234567890" + assert response.api_key is None assert response.api_key_masked is not None assert response.api_key_masked != "sk-real-secret-1234567890" assert response.has_credential is True diff --git a/tests/server/routes/test_provider_route_responsiveness.py b/tests/server/routes/test_provider_route_responsiveness.py new file mode 100644 index 000000000..ba6967e37 --- /dev/null +++ b/tests/server/routes/test_provider_route_responsiveness.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import asyncio +import threading +import time + +import pytest + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(autouse=True) +def _reset_provider_route_async_state(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import provider as provider_routes + + monkeypatch.setattr(provider_routes, "_provider_initialization_lock", asyncio.Lock()) + monkeypatch.setattr(provider_routes, "_provider_initialization_task", None) + monkeypatch.setattr(provider_routes, "_dynamic_provider_load_tasks", set()) + + +async def test_initialized_provider_fast_path_does_not_schedule_worker_thread( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + async def _unexpected_to_thread(*args, **kwargs): + raise AssertionError("initialized provider should stay on the fast path") + + monkeypatch.setattr(provider_routes.Provider, "_initialized", True) + monkeypatch.setattr(provider_routes.asyncio, "to_thread", _unexpected_to_thread) + + await provider_routes._ensure_provider_initialized() + + +async def test_concurrent_provider_initialization_uses_one_worker( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + calls = 0 + + def _slow_initialize() -> None: + nonlocal calls + calls += 1 + time.sleep(0.05) + provider_routes.Provider._initialized = True + + monkeypatch.setattr(provider_routes.Provider, "_initialized", False) + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", _slow_initialize) + + await asyncio.gather(*[ + provider_routes._ensure_provider_initialized() + for _ in range(10) + ]) + + assert calls == 1 + + +async def test_dynamic_provider_loading_does_not_block_event_loop( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + monkeypatch.setattr( + provider_routes.Provider, + "_load_dynamic_providers", + lambda: time.sleep(0.05), + ) + + load_task = asyncio.create_task(provider_routes._load_dynamic_providers()) + heartbeat_ticks = 0 + while not load_task.done(): + await asyncio.sleep(0.005) + heartbeat_ticks += 1 + await load_task + + assert heartbeat_ticks >= 3 + + +async def test_concurrent_dynamic_load_triggers_are_serialized_but_not_merged( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + first_started = threading.Event() + release_first = threading.Event() + second_started = threading.Event() + calls = 0 + config_version = "provider-a" + scanned_versions: list[str] = [] + active_workers = 0 + max_active_workers = 0 + + def _blocking_load() -> None: + nonlocal calls, active_workers, max_active_workers + calls += 1 + scanned_versions.append(config_version) + active_workers += 1 + max_active_workers = max(max_active_workers, active_workers) + try: + if calls == 1: + first_started.set() + release_first.wait(timeout=1) + else: + second_started.set() + finally: + active_workers -= 1 + + monkeypatch.setattr( + provider_routes.Provider, + "_load_dynamic_providers", + _blocking_load, + ) + + first = asyncio.create_task(provider_routes._load_dynamic_providers()) + while not first_started.is_set(): + await asyncio.sleep(0.001) + + config_version = "provider-b" + second = asyncio.create_task(provider_routes._load_dynamic_providers()) + try: + await asyncio.sleep(0.01) + assert calls == 1 + finally: + release_first.set() + + await asyncio.wait_for(asyncio.gather(first, second), timeout=0.2) + assert second_started.is_set() + assert calls == 2 + assert scanned_versions == ["provider-a", "provider-b"] + assert max_active_workers == 1 + + +async def test_cancelled_dynamic_load_waiter_keeps_its_scan_and_serial_order( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + first_started = threading.Event() + release_first = threading.Event() + second_started = threading.Event() + calls = 0 + active_workers = 0 + max_active_workers = 0 + + def _blocking_load() -> None: + nonlocal calls, active_workers, max_active_workers + calls += 1 + active_workers += 1 + max_active_workers = max(max_active_workers, active_workers) + try: + if calls == 1: + first_started.set() + release_first.wait(timeout=1) + else: + second_started.set() + finally: + active_workers -= 1 + + monkeypatch.setattr( + provider_routes.Provider, + "_load_dynamic_providers", + _blocking_load, + ) + + cancelled_waiter = asyncio.create_task(provider_routes._load_dynamic_providers()) + while not first_started.is_set(): + await asyncio.sleep(0.001) + + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + + second = asyncio.create_task(provider_routes._load_dynamic_providers()) + try: + await asyncio.sleep(0.01) + assert calls == 1 + finally: + release_first.set() + + await asyncio.wait_for(second, timeout=0.2) + assert second_started.is_set() + assert calls == 2 + assert max_active_workers == 1 + + +async def test_list_providers_initialization_does_not_block_event_loop( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import provider as provider_routes + + def _slow_initialize() -> None: + time.sleep(0.1) + + async def _config(): + raise RuntimeError("no config in focused route test") + + monkeypatch.setattr(provider_routes.Provider, "_initialized", False) + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", _slow_initialize) + monkeypatch.setattr(provider_routes.Config, "get", _config) + monkeypatch.setattr(provider_routes.ConfigWriter, "list_provider_ids", lambda: []) + + route_task = asyncio.create_task(provider_routes.list_providers()) + heartbeat_ticks = 0 + while not route_task.done(): + await asyncio.sleep(0.005) + heartbeat_ticks += 1 + + response = await route_task + + assert heartbeat_ticks >= 3 + assert response.all == [] diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index feb8bf695..ce681b707 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -673,6 +673,7 @@ async def test_reply_missing_allow_field_returns_422(self, client: AsyncClient): async def test_permission_routes_preserve_request_created_time(self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch): """List/detail routes should expose the stored permission request timestamp.""" from flocks.permission.next import PermissionRequestInfo + from flocks.session.session import SessionInfo info = PermissionRequestInfo( id="perm_time_test", @@ -693,6 +694,17 @@ async def test_permission_routes_preserve_request_created_time(self, client: Asy "flocks.server.routes.permission.PermissionNext.get_pending_info", AsyncMock(return_value=info), ) + monkeypatch.setattr( + "flocks.server.routes.permission.Session.get_by_id", + AsyncMock(return_value=SessionInfo( + id="ses_time_test", + project_id="test", + directory="/tmp", + title="Permission route test", + owner_user_id="api-token-service", + owner_username="api-token-service", + )), + ) list_resp = await client.get("/permission") assert list_resp.status_code == status.HTTP_200_OK diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 5aa2e8994..f46b9d153 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -65,6 +65,80 @@ async def test_create_session_with_category(self, client: AsyncClient): assert resp.status_code == status.HTTP_200_OK assert resp.json()["category"] == "workflow" + @pytest.mark.asyncio + async def test_create_session_with_api_token_is_ownerless(self, client: AsyncClient): + """Sessions created by API-token clients remain manageable by WebUI admins.""" + resp = await client.post("/api/session", json={"title": "TUI Session"}) + + assert resp.status_code == status.HTTP_200_OK + session = await Session.get_by_id(resp.json()["id"]) + assert session is not None + assert session.owner_user_id is None + assert session.owner_username is None + + @pytest.mark.asyncio + async def test_create_session_with_local_user_keeps_owner( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + """Browser-authenticated sessions remain private to their local user.""" + from flocks.server.routes import session as session_routes + + user = AuthUser(id="usr_admin", username="admin", role="admin", status="active") + monkeypatch.setattr(session_routes, "require_user", lambda _request: user) + + resp = await client.post("/api/session", json={"title": "WebUI Session"}) + + assert resp.status_code == status.HTTP_200_OK + session = await Session.get_by_id(resp.json()["id"]) + assert session is not None + assert session.owner_user_id == user.id + assert session.owner_username == user.username + + @pytest.mark.asyncio + async def test_webui_admin_can_manage_api_token_session( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + """A WebUI admin can list, continue, rename, and delete a TUI session.""" + from flocks.server.routes import session as session_routes + + create_resp = await client.post("/api/session", json={"title": "TUI Session"}) + assert create_resp.status_code == status.HTTP_200_OK + session_id = create_resp.json()["id"] + + admin = AuthUser(id="usr_admin", username="admin", role="admin", status="active") + monkeypatch.setattr(session_routes, "require_user", lambda _request: admin) + + list_resp = await client.get( + "/api/session", + params={"view": "list", "manager": "true", "roots": "true"}, + ) + assert list_resp.status_code == status.HTTP_200_OK + assert session_id in {session["id"] for session in list_resp.json()} + + message_resp = await client.post( + f"/api/session/{session_id}/message", + json={ + "parts": [{"type": "text", "text": "Continue from WebUI"}], + "noReply": True, + }, + ) + assert message_resp.status_code == status.HTTP_200_OK + + rename_resp = await client.patch( + f"/api/session/{session_id}", + json={"title": "Renamed in WebUI"}, + ) + assert rename_resp.status_code == status.HTTP_200_OK + assert rename_resp.json()["title"] == "Renamed in WebUI" + + delete_resp = await client.delete(f"/api/session/{session_id}") + assert delete_resp.status_code == status.HTTP_200_OK + assert delete_resp.json() is True + @pytest.mark.asyncio async def test_get_session_includes_persisted_goal(self, client: AsyncClient): """GET /api/session/{id} hydrates persisted goal state for the WebUI.""" @@ -182,6 +256,135 @@ async def test_get_session(self, client: AsyncClient, session_id: str): assert resp.status_code == status.HTTP_200_OK assert resp.json()["id"] == session_id + @pytest.mark.asyncio + async def test_context_usage_keeps_inflight_task_after_cancelled_request( + self, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + from flocks.session.context_usage import ContextUsageSnapshot + + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + + calls = 0 + started = asyncio.Event() + release = asyncio.Event() + session = SimpleNamespace(time=SimpleNamespace(updated=123), provider=None, model=None) + + async def fake_build_context_usage_snapshot(session_id: str, *, session=None): + nonlocal calls + calls += 1 + started.set() + await release.wait() + return ContextUsageSnapshot( + sessionID=session_id, + usedTokens=42, + contextWindow=100, + estimatedTokens=42, + ) + + monkeypatch.setattr( + session_routes, + "build_context_usage_snapshot", + fake_build_context_usage_snapshot, + ) + + first = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_cancel", session=session) + ) + second = None + try: + await started.wait() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + second = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_cancel", session=session) + ) + await asyncio.sleep(0) + assert calls == 1 + + release.set() + snapshot = await asyncio.wait_for(second, timeout=1) + assert snapshot.used_tokens == 42 + + cached = await session_routes._cached_context_usage_snapshot("ses_context_cancel", session=session) + assert cached.used_tokens == 42 + assert calls == 1 + finally: + release.set() + if second is not None and not second.done(): + await asyncio.wait_for(second, timeout=1) + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + + @pytest.mark.asyncio + async def test_context_usage_keeps_newer_cache_when_older_task_finishes_late( + self, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + from flocks.session.context_usage import ContextUsageSnapshot + + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + + old_started = asyncio.Event() + new_started = asyncio.Event() + old_release = asyncio.Event() + new_release = asyncio.Event() + old_session = SimpleNamespace(time=SimpleNamespace(updated=100), provider=None, model=None) + new_session = SimpleNamespace(time=SimpleNamespace(updated=200), provider=None, model=None) + + async def fake_build_context_usage_snapshot(session_id: str, *, session=None): + updated = session.time.updated + if updated == 100: + old_started.set() + await old_release.wait() + else: + new_started.set() + await new_release.wait() + return ContextUsageSnapshot( + sessionID=session_id, + usedTokens=updated, + contextWindow=1000, + estimatedTokens=updated, + ) + + monkeypatch.setattr( + session_routes, + "build_context_usage_snapshot", + fake_build_context_usage_snapshot, + ) + + old_task = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_order", session=old_session) + ) + new_task = asyncio.create_task( + session_routes._cached_context_usage_snapshot("ses_context_order", session=new_session) + ) + try: + await old_started.wait() + await new_started.wait() + + new_release.set() + new_snapshot = await asyncio.wait_for(new_task, timeout=1) + assert new_snapshot.used_tokens == 200 + + old_release.set() + old_snapshot = await asyncio.wait_for(old_task, timeout=1) + assert old_snapshot.used_tokens == 100 + + assert ("ses_context_order", 200) in session_routes._context_usage_cache + assert ("ses_context_order", 100) not in session_routes._context_usage_cache + finally: + old_release.set() + new_release.set() + session_routes._context_usage_cache.clear() + session_routes._context_usage_inflight.clear() + @pytest.mark.asyncio async def test_get_session_not_found(self, client: AsyncClient): """GET for an unknown session ID returns 404.""" diff --git a/tests/server/routes/test_tool_routes.py b/tests/server/routes/test_tool_routes.py index 898b03d63..faa087fa8 100644 --- a/tests/server/routes/test_tool_routes.py +++ b/tests/server/routes/test_tool_routes.py @@ -11,7 +11,15 @@ from flocks.auth.context import AuthUser from flocks.session.message import Message, MessageRole from flocks.session.session import Session -from flocks.tool.registry import Tool, ToolCategory, ToolInfo, ToolRegistry, ToolResult +from flocks.tool.registry import ( + ParameterType, + Tool, + ToolCategory, + ToolInfo, + ToolParameter, + ToolRegistry, + ToolResult, +) @contextmanager @@ -19,6 +27,7 @@ def _temporary_tool(tool: Tool) -> Iterator[None]: ToolRegistry.init() existing = ToolRegistry._tools.get(tool.info.name) ToolRegistry.register(tool) + _clear_tool_summary_cache() try: yield finally: @@ -27,6 +36,13 @@ def _temporary_tool(tool: Tool) -> Iterator[None]: ToolRegistry._tools[tool.info.name] = existing else: ToolRegistry._tools.pop(tool.info.name, None) + _clear_tool_summary_cache() + + +def _clear_tool_summary_cache() -> None: + from flocks.server.routes import tool as tool_routes + + tool_routes._invalidate_tool_summary_cache() class _FakeSessionUser: @@ -386,3 +402,118 @@ async def handler(ctx) -> ToolResult: assert kwargs["session_id"] == session_id assert kwargs["metadata"]["messageID"] == message_id assert kwargs["tool"] == {"name": "http_batch_named_tool"} + + +class TestToolListPageRoute: + @pytest.mark.asyncio + async def test_list_page_searches_server_side_and_omits_parameter_payload(self, client: AsyncClient): + async def handler(ctx, text: str) -> ToolResult: + return ToolResult(success=True, output=f"{text}:{ctx.session_id}") + + tool = Tool( + info=ToolInfo( + name="page_unique_alpha_tool", + description="Needle Alpha paginated search tool", + category=ToolCategory.CUSTOM, + source="plugin_py", + parameters=[ + ToolParameter( + name="text", + type=ParameterType.STRING, + description="Text to echo", + ) + ], + ), + handler=handler, + ) + api_tool = Tool( + info=ToolInfo( + name="page_unique_alpha_api_tool", + description="Needle Alpha paginated API tool", + category=ToolCategory.CUSTOM, + source="api", + provider="page-api-provider", + ), + handler=handler, + ) + + with _temporary_tool(tool), _temporary_tool(api_tool): + response = await client.get( + "/api/tools/page", + params={"q": "needle alpha", "source": "plugin_py", "offset": 0, "limit": 20}, + ) + detail = await client.get("/api/tools/page_unique_alpha_tool") + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["total"] == 1 + assert payload["offset"] == 0 + assert payload["limit"] == 20 + assert payload["facets"]["source"]["plugin_py"] == 1 + assert payload["facets"]["source"]["api"] == 1 + assert payload["facets"]["source_groups"]["api"] == 1 + assert payload["items"][0]["name"] == "page_unique_alpha_tool" + assert payload["items"][0]["parameters"] == [] + assert payload["items"][0]["parameters_count"] == 1 + + assert detail.status_code == 200, detail.text + assert len(detail.json()["parameters"]) == 1 + + @pytest.mark.asyncio + async def test_list_page_reuses_lightweight_summary_cache( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import tool as tool_routes + + async def handler(ctx) -> ToolResult: + return ToolResult(success=True, output=ctx.session_id) + + tool = Tool( + info=ToolInfo( + name="page_summary_cache_unique_tool", + description="NeedlePageSummaryCacheUnique", + category=ToolCategory.CUSTOM, + source="plugin_py", + ), + handler=handler, + ) + + original_build_tool_index_item = tool_routes._build_tool_index_item + index_builds = 0 + + def counted_build_tool_index_item(tool_info): + nonlocal index_builds + index_builds += 1 + return original_build_tool_index_item(tool_info) + + monkeypatch.setattr(tool_routes, "_build_tool_index_item", counted_build_tool_index_item) + + with _temporary_tool(tool): + first = await client.get( + "/api/tools/page", + params={"q": "needlepagesummarycacheunique", "limit": 25}, + ) + first_builds = index_builds + second = await client.get( + "/api/tools/page", + params={"q": "needlepagesummarycacheunique", "source": "plugin_py", "limit": 25}, + ) + second_builds = index_builds + + tool_routes._invalidate_tool_summary_cache() + third = await client.get( + "/api/tools/page", + params={"q": "needlepagesummarycacheunique", "limit": 25}, + ) + + assert first.status_code == 200, first.text + assert second.status_code == 200, second.text + assert third.status_code == 200, third.text + assert first.json()["total"] == 1 + assert second.json()["total"] == 1 + assert third.json()["total"] == 1 + assert first_builds > 0 + assert second_builds == first_builds + assert index_builds > second_builds diff --git a/tests/server/routes/test_update_routes.py b/tests/server/routes/test_update_routes.py index 8be953356..060da1462 100644 --- a/tests/server/routes/test_update_routes.py +++ b/tests/server/routes/test_update_routes.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import asyncio from urllib.parse import quote, unquote, urlparse import pytest @@ -19,6 +20,15 @@ def _request() -> Request: return Request({"type": "http", "method": "GET", "path": "/api/update/check", "headers": []}) +@pytest.fixture(autouse=True) +def _clear_update_cache(): + from flocks.server.routes import update as update_routes + + update_routes.clear_update_check_cache() + yield + update_routes.clear_update_check_cache() + + def _manual_real_upgrade_branch() -> str: branch_input = ( os.environ.get(_MANUAL_REAL_UPGRADE_BRANCH_ENV, "").strip() @@ -126,6 +136,167 @@ async def _fake_check_update(**kwargs): assert info.current_version == "v2026.5.9" +async def test_check_version_reuses_cached_flocks_result(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + assert kwargs == {"locale": "zh-CN", "force_console_manifest": False} + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + first = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + second = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + forced = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks", force=True) + + assert calls == 2 + assert first.current_version == "v1" + assert second.current_version == "v1" + assert forced.current_version == "v2" + + +async def test_check_version_deduplicates_inflight_requests(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + await asyncio.sleep(0.01) + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + results = await asyncio.gather(*[ + update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + for _ in range(5) + ]) + + assert calls == 1 + assert [item.current_version for item in results] == ["v1"] * 5 + + +async def test_check_version_deduplicates_concurrent_forced_requests( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + await asyncio.sleep(0.01) + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + results = await asyncio.gather(*[ + update_routes.check_version( + _request(), locale="zh-CN", edition="flocks", force=True + ) + for _ in range(10) + ]) + + assert calls == 1 + assert [item.current_version for item in results] == ["v1"] * 10 + + +async def test_check_version_keeps_inflight_task_after_cancelled_request(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + calls = 0 + started = asyncio.Event() + release = asyncio.Event() + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + started.set() + await release.wait() + return VersionInfo(current_version=f"v{calls}") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + first = asyncio.create_task(update_routes.check_version(_request(), locale="zh-CN", edition="flocks")) + second = None + try: + await started.wait() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + second = asyncio.create_task(update_routes.check_version(_request(), locale="zh-CN", edition="flocks")) + await asyncio.sleep(0) + assert calls == 1 + + release.set() + result = await asyncio.wait_for(second, timeout=1) + assert result.current_version == "v1" + + cached = await update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + assert cached.current_version == "v1" + assert calls == 1 + finally: + release.set() + if second is not None and not second.done(): + await asyncio.wait_for(second, timeout=1) + + +async def test_forced_check_reuses_older_same_key_inflight_request( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import update as update_routes + from flocks.updater.models import VersionInfo + + older_started = asyncio.Event() + release_older = asyncio.Event() + calls = 0 + + async def _fake_check_update(**kwargs): + nonlocal calls + calls += 1 + if calls == 1: + older_started.set() + await release_older.wait() + return VersionInfo(current_version="v1") + return VersionInfo(current_version="v2") + + monkeypatch.setattr(update_routes, "check_update", _fake_check_update) + + older = asyncio.create_task( + update_routes.check_version(_request(), locale="zh-CN", edition="flocks") + ) + await older_started.wait() + + forced = asyncio.create_task( + update_routes.check_version( + _request(), locale="zh-CN", edition="flocks", force=True + ) + ) + await asyncio.sleep(0) + assert calls == 1 + + release_older.set() + assert (await older).current_version == "v1" + assert (await forced).current_version == "v1" + + cached = await update_routes.check_version( + _request(), locale="zh-CN", edition="flocks" + ) + assert cached.current_version == "v1" + assert calls == 1 + + @pytest.mark.skipif( os.environ.get(_MANUAL_REAL_UPGRADE_ENV) != "1", reason=f"manual real upgrade test; set {_MANUAL_REAL_UPGRADE_ENV}=1 to enable", diff --git a/tests/server/routes/test_webui_perf_routes.py b/tests/server/routes/test_webui_perf_routes.py new file mode 100644 index 000000000..51d33f3a2 --- /dev/null +++ b/tests/server/routes/test_webui_perf_routes.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import asyncio +import threading +import time +from collections.abc import AsyncGenerator +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI, status +from httpx import ASGITransport, AsyncClient + +from flocks.auth.context import AuthUser +from flocks.server.auth import require_user + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def webui_perf_client() -> AsyncGenerator[AsyncClient, None]: + from flocks.server.routes.monitoring import router as monitoring_router + from flocks.server.routes.permission import router as permission_router + from flocks.server.routes.stats import router as stats_router + + app = FastAPI() + app.dependency_overrides[require_user] = lambda: AuthUser( + id="webui-perf-user", + username="webui-perf-user", + role="member", + status="active", + must_reset_password=False, + ) + app.include_router(stats_router, prefix="/api/stats") + app.include_router(monitoring_router, prefix="/api/monitoring") + app.include_router(permission_router, prefix="/api/permission") + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: + yield client + + +async def test_stats_summary_returns_lightweight_counts( + webui_perf_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import stats as stats_routes + + async def _task_dashboard(): + return {"completed_week": 2, "failed_week": 1, "scheduled_active": 4} + + async def _count_agents(): + return 5 + + async def _count_workflows(): + return 6 + + async def _count_skills(): + return 7 + + async def _count_tools(): + return 8 + + async def _count_models(): + return 9 + + monkeypatch.setattr(stats_routes, "_task_dashboard", _task_dashboard) + monkeypatch.setattr(stats_routes, "_count_agents", _count_agents) + monkeypatch.setattr(stats_routes, "_count_workflows", _count_workflows) + monkeypatch.setattr(stats_routes, "_count_skills", _count_skills) + monkeypatch.setattr(stats_routes, "_count_tools", _count_tools) + monkeypatch.setattr(stats_routes, "_count_models", _count_models) + + response = await webui_perf_client.get("/api/stats/summary") + + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == { + "tasks": {"week": 3, "scheduledActive": 4}, + "agents": {"total": 5}, + "workflows": {"total": 6}, + "skills": {"total": 7}, + "tools": {"total": 8}, + "models": {"total": 9}, + "system": {"status": "healthy", "message": "所有服务运行正常"}, + } + + +async def test_stats_summary_starts_independent_sources_concurrently( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import stats as stats_routes + + started: set[str] = set() + all_started = asyncio.Event() + release = asyncio.Event() + + async def _count(name: str, value): + started.add(name) + if len(started) == 6: + all_started.set() + await release.wait() + return value + + async def _task_dashboard(): + return await _count("dashboard", {}) + + async def _count_tools(): + return await _count("tools", 8) + + async def _count_skills(): + return await _count("skills", 7) + + async def _count_agents(): + return await _count("agents", 5) + + async def _count_workflows(): + return await _count("workflows", 6) + + async def _count_models(): + return await _count("models", 9) + + monkeypatch.setattr(stats_routes, "_task_dashboard", _task_dashboard) + monkeypatch.setattr(stats_routes, "_count_tools", _count_tools) + monkeypatch.setattr(stats_routes, "_count_skills", _count_skills) + monkeypatch.setattr(stats_routes, "_count_agents", _count_agents) + monkeypatch.setattr(stats_routes, "_count_workflows", _count_workflows) + monkeypatch.setattr(stats_routes, "_count_models", _count_models) + + summary_task = asyncio.create_task(stats_routes.get_system_stats_summary()) + await asyncio.wait_for(all_started.wait(), timeout=1) + release.set() + response = await summary_task + + assert started == {"dashboard", "agents", "workflows", "skills", "tools", "models"} + assert response.tools.total == 8 + assert response.skills.total == 7 + assert response.agents.total == 5 + + +async def test_skill_discovery_does_not_block_the_event_loop( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import stats as stats_routes + from flocks.skill.skill import Skill + + def slow_discover(): + time.sleep(0.06) + return {"demo": SimpleNamespace(name="demo", category="user")} + + monkeypatch.setattr(Skill, "_discover", slow_discover) + Skill.clear_cache() + count_task = asyncio.create_task(stats_routes._count_skills()) + heartbeat_ticks = 0 + while not count_task.done(): + await asyncio.sleep(0.005) + heartbeat_ticks += 1 + + assert await count_task == 1 + assert heartbeat_ticks >= 3 + Skill.clear_cache() + + +async def test_skill_invalidation_during_discovery_cannot_restore_stale_cache( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.skill.skill import Skill + + discovery_started = threading.Event() + release_first_discovery = threading.Event() + discovery_count = 0 + + def discover(): + nonlocal discovery_count + discovery_count += 1 + if discovery_count == 1: + discovery_started.set() + assert release_first_discovery.wait(timeout=1) + name = "stale" + else: + name = "fresh" + return {name: SimpleNamespace(name=name, category="user")} + + monkeypatch.setattr(Skill, "_discover", discover) + Skill.clear_cache() + load_task = asyncio.create_task(Skill.all()) + assert await asyncio.to_thread(discovery_started.wait, 1) + Skill.clear_cache() + release_first_discovery.set() + + skills = await load_task + + assert [skill.name for skill in skills] == ["fresh"] + assert list((Skill._cache or {}).keys()) == ["fresh"] + Skill.clear_cache() + + +async def test_monitoring_routes_back_existing_webui_calls(webui_perf_client: AsyncClient): + status_response = await webui_perf_client.get("/api/monitoring/status") + metrics_response = await webui_perf_client.get("/api/monitoring/metrics") + llm_response = await webui_perf_client.get("/api/monitoring/performance/llm") + tool_response = await webui_perf_client.get("/api/monitoring/performance/tool") + + assert status_response.status_code == status.HTTP_200_OK, status_response.text + assert metrics_response.status_code == status.HTTP_200_OK, metrics_response.text + assert llm_response.status_code == status.HTTP_200_OK, llm_response.text + assert tool_response.status_code == status.HTTP_200_OK, tool_response.text + + status_payload = status_response.json() + assert status_payload["status"] == "healthy" + assert "uptime" in status_payload + assert "mcpServers" in status_payload + + metrics_payload = metrics_response.json() + assert metrics_payload["messageRate"] is None + assert metrics_payload["avgResponseTime"] is None + assert metrics_payload["activeRequests"] is None + assert llm_response.json() == [] + assert tool_response.json() == [] + + +async def test_monitoring_tool_rates_use_counter_deltas(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import monitoring as monitoring_routes + + samples = iter([100.0, 101.0, 160.0, 161.0]) + monkeypatch.setattr(monitoring_routes, "_metrics_clock", lambda: next(samples)) + monkeypatch.setattr(monitoring_routes, "_metrics_rate_state", None) + + assert monitoring_routes._sample_tool_rates(10, 2) == (None, None) + assert monitoring_routes._sample_tool_rates(11, 2) == (None, None) + assert monitoring_routes._sample_tool_rates(15, 3) == (5.0, 1.0) + # Additional clients inside the same window see the same sample instead + # of advancing the global baseline and distorting each other's rates. + assert monitoring_routes._sample_tool_rates(16, 3) == (5.0, 1.0) + + +async def test_api_permission_alias_matches_frontend_route(webui_perf_client: AsyncClient): + response = await webui_perf_client.get("/api/permission") + + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == [] diff --git a/tests/server/test_asgi_middleware.py b/tests/server/test_asgi_middleware.py new file mode 100644 index 000000000..227545326 --- /dev/null +++ b/tests/server/test_asgi_middleware.py @@ -0,0 +1,384 @@ +import asyncio +from collections.abc import Awaitable, Callable + +import pytest +from starlette.applications import Starlette +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import StreamingResponse +from starlette.routing import Route +from starlette.types import ASGIApp, Message, Receive, Scope + +from flocks.server import app as server_app + + +def _http_scope(path: str, *, method: str = "GET") -> Scope: + return { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "root_path": "", + "headers": [], + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 8000), + "state": {}, + } + + +async def _run_auth_middleware( + monkeypatch: pytest.MonkeyPatch, + *, + hook: Callable[[Request, dict], Awaitable[None]], + endpoint: ASGIApp, + receive: Receive, +) -> None: + async def apply_auth(_request: Request): + return None, object(), None + + async def send(_message: Message) -> None: + return None + + monkeypatch.setattr(server_app, "_run_http_middleware_hooks", hook) + monkeypatch.setattr(server_app, "apply_auth_for_request", apply_auth) + monkeypatch.setattr(server_app, "clear_auth_context", lambda _token: None) + + await server_app._AuthGuardMiddleware(endpoint)( + _http_scope("/api/session", method="POST"), + receive, + send, + ) + + +def test_production_http_middleware_is_pure_asgi_and_keeps_order() -> None: + middleware_classes = [entry.cls for entry in server_app.app.user_middleware] + + assert BaseHTTPMiddleware not in middleware_classes + assert middleware_classes == [ + server_app._DeferredCORSMiddleware, + server_app._StaticWebUIMiddleware, + server_app._AuthGuardMiddleware, + server_app._RequestLoggingMiddleware, + server_app._InstanceContextMiddleware, + server_app._SecurityHeadersMiddleware, + ] + + +@pytest.mark.asyncio +async def test_request_log_is_emitted_when_stream_headers_start( + monkeypatch: pytest.MonkeyPatch, +) -> None: + never = asyncio.Event() + response_started = asyncio.Event() + logged: list[tuple[str, dict]] = [] + + async def streaming_app(_scope, _receive, send) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + response_started.set() + await never.wait() + + async def receive(): + await never.wait() + return {"type": "http.disconnect"} + + async def send(_message): + return None + + monkeypatch.setattr(server_app.log, "info", lambda event, payload: logged.append((event, payload))) + middleware = server_app._RequestLoggingMiddleware(streaming_app) + request = asyncio.create_task(middleware(_http_scope("/api/provider"), receive, send)) + try: + await response_started.wait() + + assert not request.done() + assert logged[0][0] == "request.complete" + assert logged[0][1]["status"] == 200 + finally: + request.cancel() + await asyncio.gather(request, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_auth_hook_request_body_is_replayed_to_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, object] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello ", "more_body": True}, + {"type": "http.request", "body": b"world", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_body(request: Request, _context: dict) -> None: + seen["hook"] = await request.body() + + async def endpoint(_scope, downstream_receive, _send) -> None: + body = bytearray() + messages = [] + while True: + message = await downstream_receive() + messages.append(message) + body.extend(message.get("body", b"")) + if not message.get("more_body", False): + break + seen["endpoint"] = bytes(body) + seen["message_count"] = len(messages) + seen["same_body_object"] = messages[0]["body"] is seen["hook"] + + await _run_auth_middleware( + monkeypatch, + hook=inspect_body, + endpoint=endpoint, + receive=receive, + ) + + assert seen == { + "hook": b"hello world", + "endpoint": b"hello world", + "message_count": 1, + "same_body_object": True, + } + + +@pytest.mark.asyncio +async def test_auth_hook_stream_consumption_keeps_legacy_downstream_behavior( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, bytes] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello ", "more_body": True}, + {"type": "http.request", "body": b"world", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_stream(request: Request, _context: dict) -> None: + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if body == b"hello world": + break + seen["hook"] = bytes(body) + + async def endpoint(scope, downstream_receive, _send) -> None: + seen["endpoint"] = await Request(scope, receive=downstream_receive).body() + + await _run_auth_middleware( + monkeypatch, + hook=inspect_stream, + endpoint=endpoint, + receive=receive, + ) + + assert seen == {"hook": b"hello world", "endpoint": b""} + + +@pytest.mark.asyncio +async def test_auth_hook_partial_stream_read_continues_from_upstream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, bytes] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello ", "more_body": True}, + {"type": "http.request", "body": b"world", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_stream(request: Request, _context: dict) -> None: + async for chunk in request.stream(): + seen["hook"] = chunk + break + + async def endpoint(scope, downstream_receive, _send) -> None: + seen["endpoint"] = await Request(scope, receive=downstream_receive).body() + + await _run_auth_middleware( + monkeypatch, + hook=inspect_stream, + endpoint=endpoint, + receive=receive, + ) + + assert seen == {"hook": b"hello ", "endpoint": b"world"} + + +@pytest.mark.asyncio +async def test_auth_hook_terminal_empty_break_does_not_block_downstream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, bytes] = {} + incoming = iter( + [ + {"type": "http.request", "body": b"hello", "more_body": False}, + ] + ) + + async def receive(): + return next(incoming) + + async def inspect_stream(request: Request, _context: dict) -> None: + async for chunk in request.stream(): + if not chunk: + break + + async def endpoint(scope, downstream_receive, _send) -> None: + seen["endpoint"] = await Request(scope, receive=downstream_receive).body() + + await _run_auth_middleware( + monkeypatch, + hook=inspect_stream, + endpoint=endpoint, + receive=receive, + ) + + assert seen == {"endpoint": b""} + + +@pytest.mark.asyncio +async def test_auth_hook_observed_disconnect_is_replayed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[Message] = [] + incoming = iter( + [ + {"type": "http.request", "body": b"hello", "more_body": False}, + {"type": "http.disconnect"}, + ] + ) + never = asyncio.Event() + + async def receive(): + try: + return next(incoming) + except StopIteration: + await never.wait() + return {"type": "http.disconnect"} + + async def inspect_disconnect(request: Request, _context: dict) -> None: + await request.body() + assert await request.is_disconnected() + + async def endpoint(_scope, downstream_receive, _send) -> None: + seen.append(await downstream_receive()) + seen.append(await downstream_receive()) + seen.append(await downstream_receive()) + + await _run_auth_middleware( + monkeypatch, + hook=inspect_disconnect, + endpoint=endpoint, + receive=receive, + ) + + assert seen == [ + {"type": "http.request", "body": b"hello", "more_body": False}, + {"type": "http.disconnect"}, + {"type": "http.disconnect"}, + ] + + +@pytest.mark.asyncio +async def test_auth_without_body_read_passes_original_receive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + endpoint_called = False + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def no_hooks(_request: Request, _context: dict) -> None: + return None + + async def endpoint(_scope, downstream_receive, _send) -> None: + nonlocal endpoint_called + endpoint_called = True + assert downstream_receive is receive + + await _run_auth_middleware( + monkeypatch, + hook=no_hooks, + endpoint=endpoint, + receive=receive, + ) + + assert endpoint_called + + +@pytest.mark.asyncio +async def test_long_lived_streams_do_not_spawn_base_http_middleware_tasks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + never = asyncio.Event() + all_streams_started = asyncio.Event() + cleared_tokens: list[object] = [] + started_streams = 0 + + async def stream(): + nonlocal started_streams + started_streams += 1 + if started_streams == 11: + all_streams_started.set() + yield b"data: connected\n\n" + await never.wait() + + async def endpoint(_request: Request) -> StreamingResponse: + return StreamingResponse(stream(), media_type="text/event-stream") + + async def apply_auth(_request: Request): + return None, object(), None + + async def no_static_response(_request: Request): + return None + + monkeypatch.setattr(server_app, "apply_auth_for_request", apply_auth) + monkeypatch.setattr(server_app, "clear_auth_context", cleared_tokens.append) + monkeypatch.setattr(server_app, "maybe_serve_static_webui", no_static_response) + monkeypatch.setattr(server_app, "_should_log_request", lambda *_args: False) + + app = Starlette(routes=[Route("/global/event", endpoint)]) + app.add_middleware(server_app._SecurityHeadersMiddleware) + app.add_middleware(server_app._InstanceContextMiddleware) + app.add_middleware(server_app._RequestLoggingMiddleware) + app.add_middleware(server_app._AuthGuardMiddleware) + app.add_middleware(server_app._StaticWebUIMiddleware) + + scope = _http_scope("/global/event") + + async def receive(): + await never.wait() + return {"type": "http.disconnect"} + + async def send(_message): + return None + + requests = [asyncio.create_task(app(dict(scope), receive, send)) for _ in range(11)] + try: + await asyncio.wait_for(all_streams_started.wait(), timeout=1) + + base_http_tasks = [task for task in asyncio.all_tasks() if "BaseHTTPMiddleware" in task.get_coro().__qualname__] + + assert started_streams == 11 + assert all(not request.done() for request in requests) + assert base_http_tasks == [] + assert cleared_tokens == [] + finally: + for request in requests: + request.cancel() + await asyncio.gather(*requests, return_exceptions=True) + + assert len(cleared_tokens) == 11 diff --git a/tests/server/test_static_webui.py b/tests/server/test_static_webui.py index 8e478c5ac..4c918815f 100644 --- a/tests/server/test_static_webui.py +++ b/tests/server/test_static_webui.py @@ -1,3 +1,4 @@ +import mimetypes from pathlib import Path import pytest @@ -47,10 +48,12 @@ async def test_static_webui_serves_browser_root(monkeypatch, tmp_path: Path) -> @pytest.mark.asyncio async def test_static_webui_serves_assets_with_immutable_cache(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("FLOCKS_WEBUI_DIST_DIR", str(_write_dist(tmp_path))) + monkeypatch.setattr(mimetypes, "guess_type", lambda *_args, **_kwargs: ("text/plain", None)) async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: response = await client.get("/assets/app.12345678.js") assert response.status_code == 200 + assert response.headers["Content-Type"].startswith("application/javascript") assert "console.log" in response.text assert response.headers["Cache-Control"] == "public, max-age=31536000, immutable" diff --git a/tests/storage/test_storage.py b/tests/storage/test_storage.py index 27c5fbdac..063731c2b 100644 --- a/tests/storage/test_storage.py +++ b/tests/storage/test_storage.py @@ -7,6 +7,7 @@ import os import shutil import sqlite3 +import threading import pytest from pathlib import Path import tempfile @@ -27,6 +28,15 @@ class StorageTestModel(BaseModel): value: int +def _require_sqlite_recover() -> None: + sqlite_bin = shutil.which("sqlite3") + if sqlite_bin is None: + pytest.skip("sqlite3 CLI is not available") + supported, reason = Storage._sqlite_recover_capability_sync(sqlite_bin) + if not supported: + pytest.skip(f"sqlite3 .recover is unavailable: {reason}") + + @pytest.fixture async def storage(): """Create a temporary storage for testing""" @@ -252,17 +262,22 @@ async def test_storage_init_quarantines_invalid_header_and_boots(tmp_path): with patch.object(Storage, "_initialized", False), \ patch.object(Storage, "_db_path", None): await Storage.init(db_path) - - # Fresh DB is now usable - await Storage.set("hello", {"value": 1}) - assert await Storage.get("hello") == {"value": 1} + # Fresh DB is now usable before the patched global state is restored. + await Storage.set("hello", {"value": 1}) + assert await Storage.get("hello") == {"value": 1} siblings = sorted(p.name for p in tmp_path.iterdir()) assert "flocks.db" in siblings corrupt_files = [name for name in siblings if ".corrupt." in name] assert any(name.startswith("flocks.db.corrupt.") for name in corrupt_files), siblings - assert any(name.startswith("flocks.db-wal.corrupt.") for name in corrupt_files), siblings - assert any(name.startswith("flocks.db-shm.corrupt.") for name in corrupt_files), siblings + quarantined_main = next( + tmp_path / name + for name in corrupt_files + if name.startswith("flocks.db.corrupt.") + and not name.endswith(("-wal", "-shm")) + ) + assert quarantined_main.with_name(quarantined_main.name + "-wal").exists() + assert quarantined_main.with_name(quarantined_main.name + "-shm").exists() @pytest.mark.asyncio @@ -291,32 +306,63 @@ async def test_storage_init_recovers_when_pragma_reports_corruption(tmp_path): @pytest.mark.asyncio -async def test_storage_get_recovers_corrupt_db_after_init(tmp_path): - """A request-time storage read should quarantine corruption and retry once.""" +async def test_storage_get_defers_corruption_recovery_until_restart(tmp_path): + """A request-time read must not replace a DB while the server is running.""" db_path = tmp_path / "flocks.db" with patch.object(Storage, "_initialized", False), \ - patch.object(Storage, "_db_path", None): + patch.object(Storage, "_db_path", None), \ + patch.object( + Storage, + "_quarantine_corrupt_db", + side_effect=AssertionError("request-time recovery must not quarantine the live DB"), + ): await Storage.init(db_path) await Storage.set("hello", {"value": "before"}) - db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) - db_path.with_name("flocks.db-wal").write_bytes(b"fake wal payload") - db_path.with_name("flocks.db-shm").write_bytes(b"fake shm payload") + corrupt_payload = Storage._SQLITE_MAGIC + b"\xff" * 2048 + db_path.write_bytes(corrupt_payload) + + with pytest.raises(sqlite3.DatabaseError): + await Storage.get("hello") - assert await Storage.get("hello") is None - await Storage.set("hello", {"value": "after"}) - assert await Storage.get("hello") == {"value": "after"} + assert db_path.read_bytes() == corrupt_payload siblings = sorted(p.name for p in tmp_path.iterdir()) assert "flocks.db" in siblings - assert any(name.startswith("flocks.db.corrupt.") for name in siblings), siblings + assert not any(".corrupt." in name for name in siblings), siblings + + +def test_try_sqlite_recover_rejects_partial_output_on_failure(tmp_path): + quarantined = tmp_path / "flocks.db.corrupt.test" + quarantined.write_bytes(b"quarantined") + target = tmp_path / "flocks.db" + partial_failure = SimpleNamespace( + returncode=1, + stdout="BEGIN;\n", + stderr="sql error: no such table: sqlite_dbpage", + ) + + with patch( + "flocks.storage.storage.shutil.which", + return_value="/usr/bin/sqlite3", + ), patch.object( + Storage, + "_sqlite_recover_capability_sync", + return_value=(True, ""), + ), patch( + "flocks.storage.storage.subprocess.run", + return_value=partial_failure, + ): + assert Storage._try_sqlite_recover_sync(quarantined, target) is None + + assert target.exists() is False + assert target.with_name("flocks.db.recovered").exists() is False def test_try_sqlite_recover_installs_recovered_db(tmp_path): """The lightweight `.recover` path should install a readable recovered DB.""" - if shutil.which("sqlite3") is None: - pytest.skip("sqlite3 CLI is not available") + _require_sqlite_recover() quarantined = tmp_path / "flocks.db.corrupt.test" target = tmp_path / "flocks.db" @@ -337,6 +383,8 @@ def test_try_sqlite_recover_installs_recovered_db(tmp_path): "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", ("hello", '{"value": "recovered"}', "json", "old", "old"), ) + conn.execute("CREATE TABLE lost_and_found (value TEXT NOT NULL)") + conn.execute("INSERT INTO lost_and_found VALUES ('business-data')") conn.commit() finally: conn.close() @@ -349,15 +397,281 @@ def test_try_sqlite_recover_installs_recovered_db(tmp_path): ("hello",), ).fetchone()[0] == '{"value": "recovered"}' assert recovered.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + assert recovered.execute("SELECT value FROM lost_and_found").fetchone() == ( + "business-data", + ) finally: recovered.close() +def test_sqlite_recover_reads_wal_paired_with_quarantined_main(tmp_path): + """A committed WAL must stay paired with the renamed main DB during recovery.""" + _require_sqlite_recover() + + source = tmp_path / "source.db" + source_conn = sqlite3.connect(source) + try: + assert source_conn.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal" + source_conn.execute("PRAGMA wal_autocheckpoint=0") + source_conn.execute( + """ + CREATE TABLE storage ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + source_conn.execute( + "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ("wal-key", '{"value": "committed"}', "json", "old", "old"), + ) + source_conn.commit() + + db_path = tmp_path / "flocks.db" + shutil.copy2(source, db_path) + shutil.copy2(source.with_name("source.db-wal"), db_path.with_name("flocks.db-wal")) + + quarantined = Storage._quarantine_corrupt_db(db_path) + assert quarantined is not None + paired_wal = quarantined.with_name(quarantined.name + "-wal") + assert paired_wal.exists() + assert not db_path.with_name("flocks.db-wal").exists() + quarantined_bytes = quarantined.read_bytes() + wal_bytes = paired_wal.read_bytes() + + assert Storage._try_sqlite_recover_sync(quarantined, db_path) == db_path + assert quarantined.read_bytes() == quarantined_bytes + assert paired_wal.read_bytes() == wal_bytes + recovered = sqlite3.connect(db_path) + try: + assert recovered.execute( + "SELECT value FROM storage WHERE key = ?", + ("wal-key",), + ).fetchone()[0] == '{"value": "committed"}' + finally: + recovered.close() + finally: + source_conn.close() + + +def test_quarantine_rolls_back_main_when_wal_cannot_be_paired(tmp_path, monkeypatch): + """A WAL rename failure must abort instead of recovering an incomplete snapshot.""" + + db_path = tmp_path / "flocks.db" + wal_path = tmp_path / "flocks.db-wal" + main_payload = b"SQLite format 3\x00" + b"main" + wal_payload = b"committed transactions" + db_path.write_bytes(main_payload) + wal_path.write_bytes(wal_payload) + + real_rename = Path.rename + + def fail_wal_rename(path: Path, target: Path) -> Path: + if path == wal_path: + raise OSError("simulated WAL rename failure") + return real_rename(path, target) + + monkeypatch.setattr(Path, "rename", fail_wal_rename) + + assert Storage._quarantine_corrupt_db(db_path) is None + assert db_path.read_bytes() == main_payload + assert wal_path.read_bytes() == wal_payload + assert not list(tmp_path.glob("flocks.db.corrupt.*")) + + +@pytest.mark.asyncio +async def test_recover_corrupt_primary_refuses_online_replacement(tmp_path): + """No partial connection barrier may advertise unsafe online recovery.""" + db_path = tmp_path / "flocks.db" + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None), \ + patch.object(Storage, "_corruption_recovery_generation", 0), \ + patch.object( + Storage, + "_quarantine_corrupt_db", + side_effect=AssertionError("online recovery must fail before quarantine"), + ): + try: + await Storage.init(db_path) + inode_before = db_path.stat().st_ino + async with Storage.connect(db_path) as held: + await held.execute("CREATE TABLE recovery_probe (id TEXT PRIMARY KEY)") + await held.execute("INSERT INTO recovery_probe VALUES ('before')") + await held.commit() + + with pytest.raises(Storage.StorageError, match="while Flocks is running"): + await Storage.recover_corrupt_db( + db_path, + action="test.explicit_recovery", + exc=sqlite3.DatabaseError("database disk image is malformed"), + ) + + await held.execute("INSERT INTO recovery_probe VALUES ('after')") + await held.commit() + + assert db_path.stat().st_ino == inode_before + with sqlite3.connect(db_path) as fresh: + rows = fresh.execute("SELECT id FROM recovery_probe ORDER BY id").fetchall() + assert rows == [("after",), ("before",)] + finally: + await Storage.shutdown() + + +@pytest.mark.asyncio +async def test_live_database_path_disappearance_fails_closed(tmp_path): + """A moved live DB must not cause _ensure_init to create a second inode.""" + from flocks.channel.inbound import session_binding + + await session_binding.close_binding_db() + db_path = tmp_path / "flocks.db" + archived_path = tmp_path / "flocks.db.moved" + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None): + try: + await Storage.init(db_path) + held = await session_binding._get_db() + await held.execute("CREATE TABLE live_probe (value TEXT)") + await held.execute("INSERT INTO live_probe VALUES ('before')") + await held.commit() + + db_path.rename(archived_path) + for suffix in ("-wal", "-shm"): + sidecar = db_path.with_name(db_path.name + suffix) + if sidecar.exists(): + sidecar.rename(archived_path.with_name(archived_path.name + suffix)) + + with pytest.raises(Storage.StorageError, match="active SQLite database disappeared"): + await Storage.set("must-not-create", {"value": 1}) + + assert not db_path.exists() + with pytest.raises(ValueError, match="no active connection"): + await held.execute("SELECT 1") + finally: + await session_binding.close_binding_db() + await Storage.shutdown() + + +@pytest.mark.asyncio +async def test_live_database_inode_replacement_fails_closed(tmp_path): + """An atomic restore over the live path must not mix old and new DB handles.""" + from flocks.channel.inbound import session_binding + + await session_binding.close_binding_db() + db_path = tmp_path / "flocks.db" + archived_path = tmp_path / "flocks.db.before-restore" + + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None), \ + patch.object(Storage, "_db_identity", None): + try: + await Storage.init(db_path) + original_identity = Storage._db_identity + held = await session_binding._get_db() + await held.execute("CREATE TABLE live_probe (value TEXT)") + await held.execute("INSERT INTO live_probe VALUES ('old-inode')") + await held.commit() + + db_path.rename(archived_path) + for suffix in ("-wal", "-shm"): + sidecar = db_path.with_name(db_path.name + suffix) + if sidecar.exists(): + sidecar.rename(archived_path.with_name(archived_path.name + suffix)) + with sqlite3.connect(db_path) as replacement: + replacement.execute("CREATE TABLE restored_probe(value TEXT)") + replacement.execute("INSERT INTO restored_probe VALUES ('new-inode')") + replacement.commit() + + assert Storage._file_identity(db_path) != original_identity + with pytest.raises(Storage.StorageError, match="different file identity"): + async with Storage.connect(db_path): + pass + with pytest.raises(Storage.StorageError, match="different file identity"): + Storage.connect_sync(db_path) + with pytest.raises(Storage.StorageError, match="replaced on disk"): + await Storage.set("must-not-write", {"value": 1}) + with pytest.raises(ValueError, match="no active connection"): + await held.execute("SELECT 1") + + with sqlite3.connect(db_path) as replacement: + assert replacement.execute("SELECT value FROM restored_probe").fetchone() == ( + "new-inode", + ) + assert not replacement.execute( + "SELECT 1 FROM sqlite_schema WHERE name='storage'" + ).fetchone() + finally: + await session_binding.close_binding_db() + await Storage.shutdown() + + +@pytest.mark.asyncio +async def test_cancelled_recovery_waits_for_worker_to_finish(): + """Cancellation must not release callers while a recovery thread still mutates files.""" + started = threading.Event() + release = threading.Event() + + def blocked_worker() -> None: + started.set() + release.wait(timeout=5) + + worker = asyncio.create_task(asyncio.to_thread(blocked_worker)) + waiter = asyncio.create_task(Storage._wait_for_recovery_worker_on_cancel(worker)) + assert await asyncio.to_thread(started.wait, 2) + + waiter.cancel() + await asyncio.sleep(0) + assert not waiter.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await waiter + assert worker.done() + + +@pytest.mark.asyncio +async def test_cancelled_startup_waits_for_recovery_thread(tmp_path, monkeypatch): + """The real Storage.init recovery branch must not leave a detached installer thread.""" + db_path = tmp_path / "flocks.db" + db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 4096) + started = threading.Event() + release = threading.Event() + finished = threading.Event() + + def blocked_recovery(_quarantined: Path, _target: Path) -> None: + started.set() + release.wait(timeout=5) + finished.set() + + monkeypatch.setattr(Storage, "_try_sqlite_recover_sync", staticmethod(blocked_recovery)) + with patch.object(Storage, "_initialized", False), \ + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None), \ + patch.object(Storage, "_db_identity", None): + init_task = asyncio.create_task(Storage.init(db_path)) + assert await asyncio.to_thread(started.wait, 2) + + init_task.cancel() + await asyncio.sleep(0) + assert not init_task.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await init_task + assert finished.is_set() + + @pytest.mark.asyncio async def test_storage_init_recovers_real_malformed_sqlite_file(tmp_path): """Startup should recover a real DB that fails SQLite integrity checks.""" - if shutil.which("sqlite3") is None: - pytest.skip("sqlite3 CLI is not available") + _require_sqlite_recover() db_path = tmp_path / "flocks.db" conn = sqlite3.connect(db_path) @@ -412,42 +726,50 @@ async def test_storage_init_recovers_real_malformed_sqlite_file(tmp_path): @pytest.mark.asyncio -async def test_storage_get_uses_recovered_db_before_empty_rebuild(tmp_path, monkeypatch): - """A recovered DB should be installed before retrying the failed read.""" - db_path = tmp_path / "flocks.db" - - def fake_recover(quarantined_path: Path, target_path: Path): - assert quarantined_path.name.startswith("flocks.db.corrupt.") - conn = sqlite3.connect(target_path) - try: - conn.execute( - """ - CREATE TABLE storage ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - type TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) - conn.execute( - "INSERT INTO storage (key, value, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", - ("hello", '{"value": "recovered"}', "json", "old", "old"), +async def test_request_time_corruption_does_not_call_recover_corrupt_db(tmp_path): + """The generic request wrapper must surface corruption without replacing files.""" + + async def corrupt_operation(): + raise sqlite3.DatabaseError("database disk image is malformed") + + with patch.object(Storage, "recover_corrupt_db", new_callable=AsyncMock) as recover, \ + patch.object(Storage._log, "error") as error_log: + with pytest.raises(sqlite3.DatabaseError, match="malformed"): + await Storage._run_with_corruption_recovery( + corrupt_operation, + db_path=tmp_path / "flocks.db", + action="test.request_read", ) - conn.commit() - finally: - conn.close() - return target_path - monkeypatch.setattr(Storage, "_try_sqlite_recover_sync", staticmethod(fake_recover)) + recover.assert_not_awaited() + assert error_log.call_args.args[0] == "storage.corruption.deferred_to_restart" + + +@pytest.mark.asyncio +async def test_request_time_corruption_is_recovered_only_after_restart(tmp_path): + """Close the fail-closed loop: no online swap, then startup recovery succeeds.""" + db_path = tmp_path / "flocks.db" with patch.object(Storage, "_initialized", False), \ - patch.object(Storage, "_db_path", None): + patch.object(Storage, "_db_path", None), \ + patch.object(Storage, "_init_pid", None): await Storage.init(db_path) - db_path.write_bytes(Storage._SQLITE_MAGIC + b"\xff" * 2048) + await Storage.set("before", {"value": 1}) + + corrupt_payload = Storage._SQLITE_MAGIC + b"\xff" * 4096 + db_path.write_bytes(corrupt_payload) + with pytest.raises(sqlite3.DatabaseError): + await Storage.get("before") + assert db_path.read_bytes() == corrupt_payload + assert not list(tmp_path.glob("flocks.db.corrupt.*")) + + await Storage.shutdown() + await Storage.init(db_path) + await Storage.set("after", {"value": 2}) + assert await Storage.get("after") == {"value": 2} + await Storage.shutdown() - assert await Storage.get("hello") == {"value": "recovered"} + assert list(tmp_path.glob("flocks.db.corrupt.*")) @pytest.mark.asyncio diff --git a/tests/tool/test_channel_message.py b/tests/tool/test_channel_message.py index add4aca2a..ed815ef57 100644 --- a/tests/tool/test_channel_message.py +++ b/tests/tool/test_channel_message.py @@ -26,14 +26,35 @@ def test_channel_message_normalizes_wecom_aliases() -> None: assert _normalize_channel_type("wxwork") == "wecom" -def test_channel_message_schema_includes_weixin() -> None: +def test_channel_message_normalizes_telegram_whatsapp_email_aliases() -> None: + assert _normalize_channel_type("telegram") == "telegram" + assert _normalize_channel_type("tg") == "telegram" + assert _normalize_channel_type("tele") == "telegram" + assert _normalize_channel_type("whatsapp") == "whatsapp" + assert _normalize_channel_type("wa") == "whatsapp" + assert _normalize_channel_type("email") == "email" + assert _normalize_channel_type("mail") == "email" + assert _normalize_channel_type("邮件") == "email" + + +def test_channel_message_schema_includes_builtin_channels() -> None: schema = ToolRegistry.get_schema("channel_message") assert schema is not None - assert "wecom" in schema.properties["channel_type"]["enum"] - assert "企业微信" in schema.properties["channel_type"]["enum"] - assert "weixin" in schema.properties["channel_type"]["enum"] - assert "微信" in schema.properties["channel_type"]["enum"] + channel_enum = schema.properties["channel_type"]["enum"] + for value in ( + "wecom", + "企业微信", + "weixin", + "微信", + "feishu", + "dingtalk", + "telegram", + "whatsapp", + "email", + "邮件", + ): + assert value in channel_enum @pytest.mark.asyncio diff --git a/tests/tool/test_im_send_message.py b/tests/tool/test_im_send_message.py index 72df25a42..42e41a674 100644 --- a/tests/tool/test_im_send_message.py +++ b/tests/tool/test_im_send_message.py @@ -51,6 +51,28 @@ def test_im_send_message_normalizes_wecom_aliases() -> None: assert _normalize_channel_type("wxwork") == "wecom" +def test_im_send_message_normalizes_telegram_whatsapp_email_aliases() -> None: + assert _normalize_channel_type("telegram") == "telegram" + assert _normalize_channel_type("tg") == "telegram" + assert _normalize_channel_type("tele") == "telegram" + assert _normalize_channel_type("whatsapp") == "whatsapp" + assert _normalize_channel_type("wa") == "whatsapp" + assert _normalize_channel_type("email") == "email" + assert _normalize_channel_type("mail") == "email" + assert _normalize_channel_type("邮件") == "email" + + +def test_im_send_message_schema_mentions_extended_builtin_channels() -> None: + schema = ToolRegistry.get_schema("im_send_message") + + assert schema is not None + channel_description = schema.properties["channel_type"]["description"] + tool_description = ToolRegistry.get("im_send_message").info.description + for channel in ("telegram", "whatsapp", "email"): + assert channel in channel_description.lower() + assert channel in tool_description.lower() + + @pytest.mark.asyncio async def test_im_send_message_requires_message_unless_resolve_only() -> None: result = await im_send_message(_ctx(), session_id="ses_target") diff --git a/tests/tool/test_task_center_compat.py b/tests/tool/test_task_center_compat.py index 654cd28f5..afd9dd9ea 100644 --- a/tests/tool/test_task_center_compat.py +++ b/tests/tool/test_task_center_compat.py @@ -60,6 +60,20 @@ def test_task_create_schema_allows_legacy_schedule_type(self): assert "action" in schema.properties assert "type" not in schema.required + def test_task_create_schema_mentions_extended_message_channels(self): + tool = ToolRegistry.get("schedule_task_create") + schema = ToolRegistry.get_schema("schedule_task_create") + + assert tool is not None + assert schema is not None + text = ( + tool.info.description + + " " + + schema.properties["description"]["description"] + ).lower() + for value in ("telegram", "whatsapp", "email", "channel_type=telegram", "channel_type=whatsapp", "channel_type=email"): + assert value in text + def test_task_update_schema_makes_action_optional_and_exposes_trigger_fields(self): schema = ToolRegistry.get_schema("schedule_task_update") diff --git a/tests/tool/test_tdp_api_tools.py b/tests/tool/test_tdp_api_tools.py index ba7073b5f..232ae407e 100644 --- a/tests/tool/test_tdp_api_tools.py +++ b/tests/tool/test_tdp_api_tools.py @@ -283,6 +283,61 @@ async def test_tdp_threat_inbound_attack_uses_severity_distribution_endpoint(): assert request_kwargs["json"]["condition"]["time_from"] < request_kwargs["json"]["condition"]["time_to"] +@pytest.mark.asyncio +async def test_tdp_threat_monitor_list_uses_public_api_endpoint(): + tool = _load_tool("tdp_threat_monitor_list.yaml") + fake_session = _FakeSession( + [_FakeResponse(json_payload={"response_code": 0, "data": {"items": [{"machine": "10.0.0.1"}]}})] + ) + + with ( + patch( + "flocks.config.config_writer.ConfigWriter.get_api_service_raw", + return_value={ + "apiKey": "{secret:tdp_api_key}", + "secret": "{secret:tdp_secret}", + "base_url": "https://tdp.local", + }, + ), + patch( + "flocks.security.get_secret_manager", + return_value=MagicMock( + get=MagicMock(side_effect=lambda key: {"tdp_api_key": "demo-api", "tdp_secret": "demo-secret"}.get(key)) + ), + ), + patch("aiohttp.ClientSession", return_value=fake_session), + ): + result = await tool.handler( + ToolContext(session_id="test", message_id="test"), + time_from=1700000000, + time_to=1700003600, + sql="threat.type = 'c2'", + size=100, + assets_group=[1, 3], + net_data_type=["attack", "risk"], + cur_page=2, + page_size=50, + ) + + assert result.success is True + assert result.metadata["api"] == "threat_monitor_list" + method, request_url, request_kwargs = fake_session.calls[0] + assert method == "POST" + assert request_url == "https://tdp.local/api/v1/monitor/threat/list" + assert request_kwargs["json"] == { + "condition": { + "time_from": 1700000000, + "time_to": 1700003600, + "sql": "threat.type = 'c2'", + "size": 100, + "refresh_rate": -1, + "assets_group": [1, 3], + "net_data_type": ["attack", "risk"], + }, + "page": {"cur_page": 2, "page_size": 50}, + } + + @pytest.mark.asyncio async def test_tdp_login_api_list_can_switch_to_summary_action(): tool = _load_tool("tdp_login_api_list.yaml") @@ -861,3 +916,128 @@ async def test_tdp_policy_settings_ip_reputation_delete_supports_ids_wrapper(): assert result.metadata["api"] == "ip_reputation_delete" assert fake_session.calls[0][1] == "https://tdp.local/api/v1/ipReputation/delete" assert fake_session.calls[0][2]["json"] == [302773, 302774] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("action", "path", "handler_kwargs", "expected_body"), + [ + ( + "custom_rule_list", + "/api/v1/customRule/list", + { + "custom_rule_status": [1], + "custom_rule_severity": [3, 4], + "custom_rule_result": ["success"], + "custom_rule_keyword": "SQL", + }, + { + "condition": { + "status": [1], + "threat_severity": [3, 4], + "threat_result": ["success"], + "fuzzy": {"keyword": "SQL", "fieldlist": ["threat_name", "threat_msg"]}, + }, + "page": { + "cur_page": 1, + "page_size": 20, + "sort": [{"sort_by": "updated_time", "sort_order": "desc"}], + }, + }, + ), + ( + "custom_rule_add", + "/api/v1/customRule/add", + { + "custom_rule": { + "threat_name": "Custom SQL injection", + "threat_msg": "Detected SQL injection", + "threat_severity": 3, + "threat_type": "exploit", + "threat_result": "success", + "directions": ["in"], + "body": [{"field": "http.uri", "method": "GET", "content": "/api"}], + "attacker": "src", + "status": 1, + } + }, + { + "threat_name": "Custom SQL injection", + "threat_msg": "Detected SQL injection", + "threat_severity": 3, + "threat_type": "exploit", + "threat_result": "success", + "directions": ["in"], + "body": [{"field": "http.uri", "method": "GET", "content": "/api"}], + "attacker": "src", + "status": 1, + }, + ), + ( + "custom_rule_update", + "/api/v1/customRule/update", + { + "custom_rule": { + "suuid": "rule-1", + "threat_name": "Updated SQL injection rule", + "threat_msg": "Detected SQL injection", + "threat_severity": 4, + "threat_type": "exploit", + "threat_result": "success", + "directions": ["in"], + "body": [{"field": "http.uri", "method": "GET", "content": "/api"}], + "attacker": "src", + "status": 1, + } + }, + { + "suuid": "rule-1", + "threat_name": "Updated SQL injection rule", + "threat_msg": "Detected SQL injection", + "threat_severity": 4, + "threat_type": "exploit", + "threat_result": "success", + "directions": ["in"], + "body": [{"field": "http.uri", "method": "GET", "content": "/api"}], + "attacker": "src", + "status": 1, + }, + ), + ( + "custom_rule_delete", + "/api/v1/customRule/delete", + {"custom_rule_ids": ["rule-1"], "delete_alert_data": 0}, + {"suuid": ["rule-1"], "delete_alert_data": 0}, + ), + ], +) +async def test_tdp_platform_config_supports_custom_rule_endpoints(action, path, handler_kwargs, expected_body): + tool = _load_tool("tdp_platform_config.yaml") + fake_session = _FakeSession([_FakeResponse(json_payload={"response_code": 0, "verbose_msg": "OK"})]) + + with ( + patch( + "flocks.config.config_writer.ConfigWriter.get_api_service_raw", + return_value={ + "apiKey": "{secret:tdp_api_key}", + "secret": "{secret:tdp_secret}", + "base_url": "https://tdp.local", + }, + ), + patch( + "flocks.security.get_secret_manager", + return_value=MagicMock( + get=MagicMock(side_effect=lambda key: {"tdp_api_key": "demo-api", "tdp_secret": "demo-secret"}.get(key)) + ), + ), + patch("aiohttp.ClientSession", return_value=fake_session), + ): + result = await tool.handler( + ToolContext(session_id="test", message_id="test"), + action=action, + **handler_kwargs, + ) + + assert result.success is True + assert fake_session.calls[0][1] == f"https://tdp.local{path}" + assert fake_session.calls[0][2]["json"] == expected_body diff --git a/tests/tool/test_tdp_skyeye_api_plugins.py b/tests/tool/test_tdp_skyeye_api_plugins.py index 0d6a60521..d3cf573f6 100644 --- a/tests/tool/test_tdp_skyeye_api_plugins.py +++ b/tests/tool/test_tdp_skyeye_api_plugins.py @@ -139,6 +139,89 @@ async def test_tdp_platform_config_exposes_disposal_log_list(): assert mock_run.await_args.kwargs["action"] == "disposal_log_list" +async def test_tdp_threat_monitor_rejects_partial_or_oversized_time_ranges(): + module = _load_module("test_tdp_handler_threat_monitor_time_range", _TDP_HANDLER) + + partial = await module.threat_monitor_list(_ctx(), time_from=1700000000) + oversized = await module.threat_monitor_list( + _ctx(), + time_from=1700000000, + time_to=1700000000 + 24 * 60 * 60 + 1, + ) + + assert partial.success is False + assert "together" in partial.error + assert oversized.success is False + assert "24 hours" in oversized.error + + +async def test_tdp_threat_monitor_maps_semantic_filters(): + module = _load_module("test_tdp_handler_threat_monitor_mapping", _TDP_HANDLER) + mock_result = ToolResult(success=True, output={"status": 200}) + + with patch.object(module, "_run_json_tool", AsyncMock(return_value=mock_result)) as mock_run: + result = await module.threat_monitor_list( + _ctx(), + time_from=1700000000, + time_to=1700003600, + sql="threat.type = 'c2'", + size=50, + assets_group=[1], + net_data_type=["attack"], + refresh_rate=10, + cur_page=3, + page_size=25, + ) + + assert result.success is True + api_name, path, payload = _built_json_payload(mock_run) + assert api_name == "threat_monitor_list" + assert path == "/api/v1/monitor/threat/list" + assert payload["condition"] == { + "time_from": 1700000000, + "time_to": 1700003600, + "sql": "threat.type = 'c2'", + "size": 50, + "assets_group": [1], + "net_data_type": ["attack"], + "refresh_rate": 10, + } + assert payload["page"] == {"cur_page": 3, "page_size": 25} + + +async def test_tdp_platform_custom_rule_write_validation(): + module = _load_module("test_tdp_handler_platform_custom_rule_validation", _TDP_HANDLER) + + missing_add_fields = await module.platform_config( + _ctx(), + action="custom_rule_add", + custom_rule={"threat_name": "Incomplete rule"}, + ) + missing_update_suuid = await module.platform_config( + _ctx(), + action="custom_rule_update", + custom_rule={ + "threat_name": "Rule", + "threat_msg": "Message", + "threat_severity": 3, + "threat_type": "exploit", + "threat_result": "success", + "directions": ["in"], + "body": [{"field": "http.uri", "method": "GET", "content": "/api"}], + "attacker": "src", + "status": 1, + }, + ) + missing_delete_ids = await module.platform_config(_ctx(), action="custom_rule_delete") + + assert missing_add_fields.success is False + assert "threat_msg" in missing_add_fields.error + assert missing_update_suuid.success is False + assert "suuid" in missing_update_suuid.error + assert missing_delete_ids.success is False + assert "suuid list" in missing_delete_ids.error + + async def test_tdp_policy_ip_reputation_delete_requires_non_empty_ids(): module = _load_module("test_tdp_handler_policy", _TDP_HANDLER) @@ -762,6 +845,7 @@ def test_tdp_query_yaml_promotes_semantic_top_level_fields(): "tdp_assets_domain_list.yaml": {"domain_name_or_ip", "has_login_api", "second_level_domain"}, "tdp_privacy_diagram.yaml": {"itag", "methods", "fuzzy_url_host"}, "tdp_threat_inbound_attack.yaml": {"severity", "result_list", "keyword"}, + "tdp_threat_monitor_list.yaml": {"time_from", "time_to", "sql", "assets_group", "cur_page"}, "tdp_machine_asset_list.yaml": {"time_from", "time_to", "service", "service_class", "application", "keyword"}, "tdp_mdr_alert_list.yaml": {"section_list", "threat_severity", "keyword"}, "tdp_cloud_facilities.yaml": {"cloud_vendor", "cloud_instance", "keyword"}, @@ -789,6 +873,9 @@ def test_tdp_platform_yaml_uses_keyword_and_requires_confirmation(): assert "keyword" in raw["inputSchema"]["properties"] assert "device_id" not in raw["inputSchema"]["properties"] assert "disposal_log_list" in raw["inputSchema"]["properties"]["action"]["enum"] + assert "custom_rule_list" in raw["inputSchema"]["properties"]["action"]["enum"] + assert "custom_rule" in raw["inputSchema"]["properties"] + assert "custom_rule_ids" in raw["inputSchema"]["properties"] def test_tdp_policy_yaml_requires_confirmation_and_uses_object_ioc_list(): diff --git a/tests/tool/test_tool_refresh_outcomes.py b/tests/tool/test_tool_refresh_outcomes.py new file mode 100644 index 000000000..12f826f47 --- /dev/null +++ b/tests/tool/test_tool_refresh_outcomes.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +import threading + +import pytest + +from flocks.tool.registry import ( + Tool, + ToolCategory, + ToolInfo, + ToolRefreshError, + ToolRegistry, +) + + +def _tool(name: str, source: str) -> Tool: + return Tool( + info=ToolInfo( + name=name, + description=name, + category=ToolCategory.CUSTOM, + source=source, + ), + handler=lambda _ctx, **_kwargs: None, + ) + + +@contextmanager +def _isolated_registry() -> Iterator[None]: + state = { + "initialized": ToolRegistry._initialized, + "tools": ToolRegistry._tools, + "defaults": ToolRegistry._enabled_defaults, + "plugin_names": ToolRegistry._plugin_tool_names, + "dynamic_modules": ToolRegistry._dynamic_modules, + "dynamic_tools": ToolRegistry._dynamic_tools_by_module, + } + try: + ToolRegistry._initialized = True + ToolRegistry._tools = {} + ToolRegistry._enabled_defaults = {} + ToolRegistry._plugin_tool_names = [] + ToolRegistry._dynamic_modules = {} + ToolRegistry._dynamic_tools_by_module = {} + yield + finally: + ToolRegistry._initialized = state["initialized"] + ToolRegistry._tools = state["tools"] + ToolRegistry._enabled_defaults = state["defaults"] + ToolRegistry._plugin_tool_names = state["plugin_names"] + ToolRegistry._dynamic_modules = state["dynamic_modules"] + ToolRegistry._dynamic_tools_by_module = state["dynamic_tools"] + + +def test_plugin_refresh_restores_last_known_good_on_load_error( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _failed_load(cls, errors): + replacement = _tool("partial_replacement", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._plugin_tool_names = [replacement.info.name] + errors.append("broken plugin") + + monkeypatch.setattr(ToolRegistry, "_load_plugin_tools", classmethod(_failed_load)) + + with pytest.raises(ToolRefreshError, match="broken plugin"): + ToolRegistry.refresh_plugin_tools() + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._plugin_tool_names == [previous.info.name] + + +def test_dynamic_refresh_restores_last_known_good_on_load_error( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_dynamic", "custom") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._dynamic_modules = {"demo": "/old/demo.py"} + ToolRegistry._dynamic_tools_by_module = {"demo": [previous.info.name]} + + def _failed_load(cls, errors): + cls._tools.pop(previous.info.name) + replacement = _tool("partial_dynamic", "custom") + cls._tools[replacement.info.name] = replacement + cls._dynamic_modules = {"demo": "/new/demo.py"} + cls._dynamic_tools_by_module = {"demo": [replacement.info.name]} + errors.append("broken dynamic module") + + monkeypatch.setattr(ToolRegistry, "_register_dynamic_tools", classmethod(_failed_load)) + + with pytest.raises(ToolRefreshError, match="broken dynamic module"): + ToolRegistry.refresh_dynamic_tools() + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._dynamic_modules == {"demo": "/old/demo.py"} + assert ToolRegistry._dynamic_tools_by_module == {"demo": [previous.info.name]} + + +def test_refresh_transactions_are_serialized_across_stages( + monkeypatch: pytest.MonkeyPatch, +): + plugin_load_entered = threading.Event() + allow_plugin_failure = threading.Event() + dynamic_load_entered = threading.Event() + allow_dynamic_finish = threading.Event() + plugin_failures: list[Exception] = [] + dynamic_failures: list[Exception] = [] + + with _isolated_registry(): + base = _tool("base", "builtin") + ToolRegistry._tools[base.info.name] = base + + def _failed_plugin_load(cls, errors): + plugin_load_entered.set() + assert allow_plugin_failure.wait(timeout=2) + errors.append("broken plugin") + + def _successful_dynamic_load(cls, _errors): + dynamic_load_entered.set() + dynamic = _tool("new_dynamic", "custom") + cls._tools[dynamic.info.name] = dynamic + cls._dynamic_modules = {"demo": "/new/demo.py"} + cls._dynamic_tools_by_module = {"demo": [dynamic.info.name]} + assert allow_dynamic_finish.wait(timeout=2) + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_failed_plugin_load), + ) + monkeypatch.setattr( + ToolRegistry, + "_register_dynamic_tools", + classmethod(_successful_dynamic_load), + ) + monkeypatch.setattr(ToolRegistry, "_bump_revision", lambda _reason: None) + + def _refresh_plugin(): + try: + ToolRegistry.refresh_plugin_tools() + except Exception as exc: + plugin_failures.append(exc) + + def _refresh_dynamic(): + try: + ToolRegistry.refresh_dynamic_tools() + except Exception as exc: + dynamic_failures.append(exc) + + plugin_thread = threading.Thread(target=_refresh_plugin) + dynamic_thread = threading.Thread(target=_refresh_dynamic) + plugin_thread.start() + assert plugin_load_entered.wait(timeout=1) + dynamic_thread.start() + + # If transactions are not serialized, make the dynamic refresh commit + # before the plugin rollback to deterministically reproduce lost state. + dynamic_entered_while_plugin_blocked = dynamic_load_entered.wait(timeout=0.2) + if dynamic_entered_while_plugin_blocked: + allow_dynamic_finish.set() + dynamic_thread.join(timeout=1) + allow_plugin_failure.set() + else: + allow_plugin_failure.set() + allow_dynamic_finish.set() + + plugin_thread.join(timeout=1) + dynamic_thread.join(timeout=1) + + assert not plugin_thread.is_alive() + assert not dynamic_thread.is_alive() + assert len(plugin_failures) == 1 + assert isinstance(plugin_failures[0], ToolRefreshError) + assert dynamic_failures == [] + assert set(ToolRegistry._tools) == {"base", "new_dynamic"} + + +def test_registry_readers_do_not_observe_an_in_progress_refresh( + monkeypatch: pytest.MonkeyPatch, +): + load_entered = threading.Event() + allow_failure = threading.Event() + reader_finished = threading.Event() + reader_results: list[Tool | None] = [] + + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _failed_plugin_load(cls, errors): + load_entered.set() + assert allow_failure.wait(timeout=2) + errors.append("broken plugin") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_failed_plugin_load), + ) + + refresh_thread = threading.Thread( + target=lambda: pytest.raises( + ToolRefreshError, + ToolRegistry.refresh_plugin_tools, + ), + ) + + def _read_plugin() -> None: + reader_results.append(ToolRegistry.get(previous.info.name)) + reader_finished.set() + + reader_thread = threading.Thread(target=_read_plugin) + refresh_thread.start() + assert load_entered.wait(timeout=1) + reader_thread.start() + + assert not reader_finished.wait(timeout=0.1) + allow_failure.set() + refresh_thread.join(timeout=1) + reader_thread.join(timeout=1) + + assert not refresh_thread.is_alive() + assert not reader_thread.is_alive() + assert reader_results == [previous] + + +def test_tool_extension_consumer_reports_invalid_specs(): + from flocks.plugin import PluginLoader + + extension_points_before = PluginLoader._extension_points.copy() + try: + PluginLoader.clear_extension_points() + ToolRegistry._register_plugin_extension_point() + extension = PluginLoader._extension_points["TOOLS"] + + errors = extension.consumer( + [ + {"name": "missing_handler"}, + {"name": "not_callable", "handler": 42}, + ], + "invalid_tools.py", + ) + + assert errors == [ + "tool definition requires name and handler", + "tool not_callable: handler must be callable", + ] + finally: + PluginLoader.clear_extension_points() + PluginLoader._extension_points.update(extension_points_before) + + +@pytest.mark.asyncio +async def test_refresh_route_reports_partial_and_total_stage_failures( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import tool as tool_routes + + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_routes.ToolRegistry, "all_tool_ids", lambda: ["working"]) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_dynamic_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("dynamic", ["bad dynamic"])), + ) + monkeypatch.setattr(tool_routes.ToolRegistry, "refresh_plugin_tools", lambda: ["working"]) + + partial = await tool_routes.refresh_tools(_admin=object()) + + assert partial.status == "partial" + assert partial.stages == {"dynamic": "error", "plugin": "success"} + assert partial.errors == ["dynamic: bad dynamic"] + + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("plugin", ["bad plugin"])), + ) + + failed = await tool_routes.refresh_tools(_admin=object()) + + assert failed.status == "error" + assert failed.stages == {"dynamic": "error", "plugin": "error"} + assert failed.errors == ["dynamic: bad dynamic", "plugin: bad plugin"] + + +@pytest.mark.asyncio +async def test_delete_tool_cleans_hub_record_when_registry_refresh_fails( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.hub import local as hub_local + from flocks.server.routes import tool as tool_routes + from flocks.tool import tool_loader + + removed_records: list[tuple[str, str]] = [] + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_loader, "find_yaml_tool", lambda _name: object()) + monkeypatch.setattr(tool_loader, "delete_yaml_tool", lambda _name: True) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("plugin", ["broken"])), + ) + monkeypatch.setattr( + hub_local, + "remove_installed_record", + lambda kind, name: removed_records.append((kind, name)), + ) + + response = await tool_routes.delete_tool("demo", _admin=object()) + + assert response["status"] == "partial" + assert response["errors"] == ["registry refresh: plugin: broken"] + assert removed_records == [("tool", "demo")] + + +@pytest.mark.asyncio +async def test_delete_tool_reports_hub_cleanup_failure_as_partial( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.hub import local as hub_local + from flocks.server.routes import tool as tool_routes + from flocks.tool import tool_loader + + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_loader, "find_yaml_tool", lambda _name: object()) + monkeypatch.setattr(tool_loader, "delete_yaml_tool", lambda _name: True) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: [], + ) + monkeypatch.setattr( + hub_local, + "remove_installed_record", + lambda _kind, _name: (_ for _ in ()).throw(RuntimeError("index locked")), + ) + + response = await tool_routes.delete_tool("demo", _admin=object()) + + assert response["status"] == "partial" + assert response["errors"] == ["Hub record cleanup: index locked"] + + +@pytest.mark.asyncio +async def test_delete_tool_reports_all_post_delete_cleanup_failures( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.hub import local as hub_local + from flocks.server.routes import tool as tool_routes + from flocks.tool import tool_loader + + monkeypatch.setattr(tool_routes.ToolRegistry, "init", lambda: None) + monkeypatch.setattr(tool_loader, "find_yaml_tool", lambda _name: object()) + monkeypatch.setattr(tool_loader, "delete_yaml_tool", lambda _name: True) + monkeypatch.setattr( + tool_routes.ToolRegistry, + "refresh_plugin_tools", + lambda: (_ for _ in ()).throw(ToolRefreshError("plugin", ["broken"])), + ) + monkeypatch.setattr( + hub_local, + "remove_installed_record", + lambda _kind, _name: (_ for _ in ()).throw(RuntimeError("index locked")), + ) + + response = await tool_routes.delete_tool("demo", _admin=object()) + + assert response["status"] == "partial" + assert response["errors"] == [ + "registry refresh: plugin: broken", + "Hub record cleanup: index locked", + ] diff --git a/tests/workflow/test_stream_alert_triage_persistence.py b/tests/workflow/test_stream_alert_triage_persistence.py new file mode 100644 index 000000000..3c8c05720 --- /dev/null +++ b/tests/workflow/test_stream_alert_triage_persistence.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import ast +import datetime as datetime_module +import json +import os +import re +import sqlite3 +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + + +WORKFLOW_PATH = ( + Path(__file__).resolve().parents[2] + / ".flocks" + / "flockshub" + / "plugins" + / "workflows" + / "stream_alert_triage" + / "workflow.json" +) + + +def _concurrent_triage_code() -> str: + workflow = json.loads(WORKFLOW_PATH.read_text(encoding="utf-8")) + return next(node["code"] for node in workflow["nodes"] if node["id"] == "concurrent_triage") + + +def _load_functions(*names: str) -> dict[str, object]: + tree = ast.parse(_concurrent_triage_code()) + body = [ + node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names + ] + namespace: dict[str, object] = { + "_datetime": datetime_module, + "inputs": {"loaded_files": []}, + "json": json, + "os": os, + "re": re, + "time": time, + } + exec(compile(ast.Module(body=body, type_ignores=[]), str(WORKFLOW_PATH), "exec"), namespace) + return namespace + + +def test_soc_db_selects_only_verified_first_seen_unique_alerts() -> None: + functions = _load_functions("_input_bool", "_select_first_seen_soc_alerts") + select_alerts = functions["_select_first_seen_soc_alerts"] + + selected, stats = select_alerts( + [ + {"dedup_key": "first", "is_duplicate": False, "id": "1"}, + {"dedup_key": "first", "is_duplicate": False, "id": "2"}, + {"dedup_key": "duplicate", "is_duplicate": True, "id": "3"}, + {"dedup_key": "string-false", "is_duplicate": "false", "id": "4"}, + {"dedup_key": "string-true", "is_duplicate": "true", "id": "5"}, + {"is_duplicate": False, "id": "missing-key"}, + {"dedup_key": "missing-flag", "id": "6"}, + ] + ) + + assert [alert["dedup_key"] for alert in selected] == ["first", "string-false"] + assert stats == { + "input_rows": 7, + "first_seen_rows": 2, + "skipped_not_first_seen_rows": 3, + "skipped_missing_dedup_key_rows": 1, + "skipped_repeated_dedup_key_rows": 1, + } + + +def test_soc_db_persistence_uses_filtered_first_seen_alerts() -> None: + tree = ast.parse(_concurrent_triage_code()) + write_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_triage_write_soc_db" + ] + + assert len(write_calls) == 1 + assert isinstance(write_calls[0].args[1], ast.Name) + assert write_calls[0].args[1].id == "first_seen_soc_alerts" + + +def test_soc_db_persistence_failure_is_reraised() -> None: + tree = ast.parse(_concurrent_triage_code()) + persistence_try = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.Try) + and any( + isinstance(child, ast.Call) + and isinstance(child.func, ast.Name) + and child.func.id == "_triage_write_soc_db" + for child in ast.walk(node) + ) + ) + + assert any( + isinstance(child, ast.Raise) + for handler in persistence_try.handlers + for child in ast.walk(handler) + ) + + +def test_soc_db_writer_receives_only_selected_first_seen_alerts(tmp_path: Path) -> None: + functions = _load_functions( + "_input_bool", + "_select_first_seen_soc_alerts", + "_ensure_soc_db_schema", + "_event_time_value", + "_asset_date_value", + "_source_type_value", + "_record_id_value", + "_stable_row_id", + "_load_existing_soc_rows", + "_merge_triage_record", + "_triage_write_soc_db", + ) + select_alerts = functions["_select_first_seen_soc_alerts"] + write_soc_db = functions["_triage_write_soc_db"] + alerts = [ + {"dedup_key": "first", "is_duplicate": False, "id": "1", "time": 1784026800}, + {"dedup_key": "first", "is_duplicate": False, "id": "2", "time": 1784026801}, + {"dedup_key": "duplicate", "is_duplicate": True, "id": "3", "time": 1784026802}, + {"dedup_key": "second", "is_duplicate": False, "id": "4", "time": 1784026803}, + {"is_duplicate": False, "id": "missing-key", "time": 1784026804}, + ] + + selected, stats = select_alerts(alerts) + db_path = tmp_path / "soc.db" + result = write_soc_db(str(db_path), selected, "first-seen-test") + + with sqlite3.connect(db_path) as connection: + records = connection.execute( + "SELECT is_duplicate, json_extract(record_json, '$.dedup_key') " + "FROM alert_records ORDER BY event_time" + ).fetchall() + + assert stats["first_seen_rows"] == 2 + assert result["rows"] == 2 + assert records == [(0, "first"), (0, "second")] + + +def test_soc_db_keeps_one_dedup_key_across_runs_and_preserves_first_event(tmp_path: Path) -> None: + functions = _load_functions( + "_input_bool", + "_select_first_seen_soc_alerts", + "_ensure_soc_db_schema", + "_event_time_value", + "_asset_date_value", + "_source_type_value", + "_record_id_value", + "_stable_row_id", + "_load_existing_soc_rows", + "_merge_triage_record", + "_triage_write_soc_db", + ) + select_alerts = functions["_select_first_seen_soc_alerts"] + write_soc_db = functions["_triage_write_soc_db"] + db_path = tmp_path / "soc.db" + first = { + "dedup_key": "same-key", + "is_duplicate": False, + "id": "same-id", + "time": 1784026800, + "source_file": "/source/first.jsonl", + "triage_report": "first report", + "triage_status": "ok", + } + replay = { + **first, + "time": 1784027800, + "source_file": "/source/replay.jsonl", + "triage_report": "updated report", + } + + first_result = write_soc_db(str(db_path), select_alerts([first])[0], "first-run") + replay_result = write_soc_db(str(db_path), select_alerts([replay])[0], "replay-run") + + with sqlite3.connect(db_path) as connection: + row = connection.execute( + "SELECT COUNT(*), event_time, source_file, " + "json_extract(record_json, '$.time'), " + "json_extract(record_json, '$.triage_report'), " + "json_extract(record_json, '$._triage_run_id') " + "FROM alert_records WHERE dedup_key = 'same-key'" + ).fetchone() + indexes = {item[1] for item in connection.execute("PRAGMA index_list(alert_records)")} + + assert row == ( + 1, + 1784026800, + "/source/first.jsonl", + 1784026800, + "updated report", + "replay-run", + ) + assert first_result["inserted_rows"] == 1 + assert first_result["updated_rows"] == 0 + assert replay_result["inserted_rows"] == 0 + assert replay_result["updated_rows"] == 1 + assert "idx_alert_records_first_seen_dedup_key" in indexes + + +def test_soc_db_schema_migrates_legacy_rows_and_removes_duplicate_keys(tmp_path: Path) -> None: + ensure_schema = _load_functions("_ensure_soc_db_schema")["_ensure_soc_db_schema"] + db_path = tmp_path / "legacy-soc.db" + follower_record = json.dumps( + {"dedup_key": "legacy-key", "is_duplicate": True, "marker": "follower"} + ) + first_seen_record = json.dumps( + {"dedup_key": "legacy-key", "is_duplicate": False, "marker": "first-seen"} + ) + orphan_duplicate_record = json.dumps( + {"dedup_key": "orphan-key", "is_duplicate": True, "marker": "orphan-duplicate"} + ) + + with sqlite3.connect(db_path) as connection: + connection.execute( + """ + CREATE TABLE alert_records ( + row_id TEXT PRIMARY KEY, + record_id TEXT, + asset_date TEXT NOT NULL, + source_file TEXT NOT NULL, + line_number INTEGER NOT NULL, + event_time INTEGER, + source_type TEXT, + threat_name TEXT, + is_duplicate INTEGER NOT NULL DEFAULT 0, + record_json TEXT NOT NULL + ) + """ + ) + connection.executemany( + """ + INSERT INTO alert_records ( + row_id, record_id, asset_date, source_file, line_number, + event_time, source_type, threat_name, is_duplicate, record_json + ) VALUES (?, '', '2026-07-14', ?, 1, ?, '', '', ?, ?) + """, + [ + ("follower-row", "/source/follower.jsonl", 100, 1, follower_record), + ("first-seen-row", "/source/first-seen.jsonl", 200, 0, first_seen_record), + ( + "orphan-duplicate-row", + "/source/orphan-duplicate.jsonl", + 300, + 1, + orphan_duplicate_record, + ), + ], + ) + + ensure_schema(connection) + connection.commit() + + rows = connection.execute( + "SELECT row_id, dedup_key, is_duplicate, json_extract(record_json, '$.marker') " + "FROM alert_records" + ).fetchall() + indexes = {item[1] for item in connection.execute("PRAGMA index_list(alert_records)")} + + assert rows == [("first-seen-row", "legacy-key", 0, "first-seen")] + assert "idx_alert_records_first_seen_dedup_key" in indexes + + +def test_soc_db_schema_removes_duplicate_survivors_from_prior_migration(tmp_path: Path) -> None: + ensure_schema = _load_functions("_ensure_soc_db_schema")["_ensure_soc_db_schema"] + db_path = tmp_path / "previously-migrated-soc.db" + + with sqlite3.connect(db_path) as connection: + connection.execute( + """ + CREATE TABLE alert_records ( + row_id TEXT PRIMARY KEY, + record_id TEXT, + asset_date TEXT NOT NULL, + source_file TEXT NOT NULL, + line_number INTEGER NOT NULL, + event_time INTEGER, + source_type TEXT, + threat_name TEXT, + dedup_key TEXT, + is_duplicate INTEGER NOT NULL DEFAULT 0, + record_json TEXT NOT NULL + ) + """ + ) + connection.executemany( + """ + INSERT INTO alert_records ( + row_id, record_id, asset_date, source_file, line_number, + event_time, source_type, threat_name, dedup_key, + is_duplicate, record_json + ) VALUES (?, '', '2026-07-14', ?, 1, ?, '', '', ?, 1, ?) + """, + [ + ( + "follower-survivor", + "/source/follower.jsonl", + 100, + "legacy-key", + json.dumps({"dedup_key": "legacy-key", "is_duplicate": True}), + ), + ( + "orphan-survivor", + "/source/orphan.jsonl", + 200, + "orphan-key", + json.dumps({"dedup_key": "orphan-key", "is_duplicate": True}), + ), + ], + ) + connection.execute( + "CREATE UNIQUE INDEX idx_alert_records_first_seen_dedup_key " + "ON alert_records(dedup_key) " + "WHERE dedup_key IS NOT NULL AND dedup_key <> ''" + ) + + ensure_schema(connection) + connection.commit() + + rows = connection.execute("SELECT row_id FROM alert_records").fetchall() + indexes = {item[1] for item in connection.execute("PRAGMA index_list(alert_records)")} + + assert rows == [] + assert "idx_alert_records_first_seen_dedup_key" in indexes + + +def test_soc_db_serializes_concurrent_writes_for_the_same_dedup_key(tmp_path: Path) -> None: + functions = _load_functions( + "_ensure_soc_db_schema", + "_event_time_value", + "_asset_date_value", + "_source_type_value", + "_record_id_value", + "_stable_row_id", + "_load_existing_soc_rows", + "_merge_triage_record", + "_triage_write_soc_db", + ) + write_soc_db = functions["_triage_write_soc_db"] + db_path = tmp_path / "concurrent-soc.db" + alerts = [ + { + "dedup_key": "concurrent-key", + "is_duplicate": False, + "id": "first", + "time": 1784026800, + "source_file": "/source/first.jsonl", + "triage_report": "first report", + }, + { + "dedup_key": "concurrent-key", + "is_duplicate": False, + "id": "second", + "time": 1784027800, + "source_file": "/source/second.jsonl", + "triage_report": "second report", + }, + ] + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list( + executor.map( + lambda item: write_soc_db(str(db_path), [item[1]], item[0]), + [("first-run", alerts[0]), ("second-run", alerts[1])], + ) + ) + + with sqlite3.connect(db_path) as connection: + row_count = connection.execute( + "SELECT COUNT(*) FROM alert_records WHERE dedup_key = 'concurrent-key'" + ).fetchone()[0] + + assert row_count == 1 + assert sum(result["inserted_rows"] for result in results) == 1 + assert sum(result["updated_rows"] for result in results) == 1 diff --git a/uv.lock b/uv.lock index 95cf9d106..a09013247 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'win32'", @@ -9,25 +9,25 @@ resolution-markers = [ [[package]] name = "aiofiles" version = "25.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, ] [[package]] name = "aiohappyeyeballs" version = "2.6.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] name = "aiohttp" version = "3.13.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, @@ -37,90 +37,90 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, ] [[package]] name = "aiokafka" version = "0.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout" }, { name = "packaging" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1" } +sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374, upload-time = "2026-04-29T10:43:03.574Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a" }, + { url = "https://files.pythonhosted.org/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320", size = 348458, upload-time = "2026-04-29T10:42:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b", size = 351057, upload-time = "2026-04-29T10:42:38.69Z" }, + { url = "https://files.pythonhosted.org/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9", size = 1142238, upload-time = "2026-04-29T10:42:39.96Z" }, + { url = "https://files.pythonhosted.org/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e", size = 1131567, upload-time = "2026-04-29T10:42:41.542Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68", size = 312160, upload-time = "2026-04-29T10:42:42.811Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a", size = 331897, upload-time = "2026-04-29T10:42:43.984Z" }, ] [[package]] name = "aiosignal" version = "1.4.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "aiosqlite" version = "0.22.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] [[package]] name = "annotated-doc" version = "0.0.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "anthropic" version = "0.86.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -131,304 +131,304 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57" }, + { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, ] [[package]] name = "anyio" version = "4.13.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "asttokens" version = "3.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] [[package]] name = "async-timeout" version = "5.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "asyncssh" version = "2.22.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523, upload-time = "2025-12-21T23:38:30.5Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938, upload-time = "2025-12-21T23:38:28.976Z" }, ] [[package]] name = "attrs" version = "26.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "backoff" version = "2.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8" }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] name = "beautifulsoup4" version = "4.14.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb" }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "cattrs" version = "26.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096" }, + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, ] [[package]] name = "cdp-use" version = "1.4.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0", size = 193961, upload-time = "2026-02-22T04:32:50.574Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb" }, + { url = "https://files.pythonhosted.org/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb", size = 350504, upload-time = "2026-02-22T04:32:49.22Z" }, ] [[package]] name = "certifi" version = "2026.2.25" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] name = "claude" version = "0.4.11" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d", size = 820, upload-time = "2025-06-10T18:49:08.992Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478" }, + { url = "https://files.pythonhosted.org/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478", size = 1167, upload-time = "2025-06-10T18:49:08.17Z" }, ] [[package]] name = "click" version = "8.1.8" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coloredlogs" version = "15.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "humanfriendly" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] [[package]] name = "coverage" version = "7.13.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [[package]] name = "croniter" version = "6.2.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960" }, + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, ] [[package]] name = "cryptography" version = "46.0.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] [[package]] name = "datasketch" version = "1.10.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699, upload-time = "2026-04-17T23:06:56.388Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914, upload-time = "2026-04-17T23:06:54.39Z" }, ] [[package]] name = "ddddocr" version = "1.6.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "onnxruntime" }, @@ -436,73 +436,73 @@ dependencies = [ { name = "opencv-python-headless", marker = "sys_platform == 'linux'" }, { name = "pillow" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/07/5f/7c06bbb594b77062e6d0d43f06dc88668aaeb699c8737c84542aaa39da8c/ddddocr-1.6.1.tar.gz", hash = "sha256:1c59d84d63d8703c6c486465a32389c9e41dd92852c794c5e4c0181a5f82d43a" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5f/7c06bbb594b77062e6d0d43f06dc88668aaeb699c8737c84542aaa39da8c/ddddocr-1.6.1.tar.gz", hash = "sha256:1c59d84d63d8703c6c486465a32389c9e41dd92852c794c5e4c0181a5f82d43a", size = 75943309, upload-time = "2026-03-11T10:48:41.681Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl", hash = "sha256:c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935" }, + { url = "https://files.pythonhosted.org/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl", hash = "sha256:c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935", size = 75983593, upload-time = "2026-03-11T10:48:23.702Z" }, ] [[package]] name = "decorator" version = "5.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] [[package]] name = "defusedxml" version = "0.7.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] name = "dingtalk-stream" version = "0.24.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "requests" }, { name = "websockets" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad" }, + { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, ] [[package]] name = "distro" version = "1.9.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "docstring-parser" version = "0.17.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708" }, + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] [[package]] name = "executing" version = "2.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] name = "fastapi" version = "0.135.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -510,50 +510,50 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] name = "fastuuid" version = "0.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, ] [[package]] name = "filelock" version = "3.25.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] name = "flatbuffers" version = "25.12.19" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] name = "flocks" -version = "2026.7.8" +version = "2026.7.15" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -695,72 +695,72 @@ dev = [ [[package]] name = "frozenlist" version = "1.8.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "fsspec" version = "2026.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] name = "gitdb" version = "4.0.12" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smmap" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" }, + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, ] [[package]] name = "gitpython" version = "3.1.46" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f" } +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058" }, + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] [[package]] name = "google-auth" version = "2.49.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [package.optional-dependencies] @@ -771,7 +771,7 @@ requests = [ [[package]] name = "google-genai" version = "1.68.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -784,105 +784,106 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15" }, + { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, ] [[package]] name = "googleapis-common-protos" version = "1.73.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a" } +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8" }, + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] [[package]] name = "greenlet" version = "3.3.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, ] [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "hf-xet" version = "1.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357, upload-time = "2026-03-13T06:58:51.077Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87" }, + { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" }, + { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, ] [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httptools" version = "0.7.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, ] [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [package.optional-dependencies] @@ -893,16 +894,16 @@ socks = [ [[package]] name = "httpx-sse" version = "0.4.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] name = "huggingface-hub" version = "1.7.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, @@ -914,57 +915,57 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b" } +sdist = { url = "https://files.pythonhosted.org/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b", size = 724684, upload-time = "2026-03-20T10:36:08.767Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e" }, + { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, ] [[package]] name = "humanfriendly" version = "10.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477" }, + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] [[package]] name = "idna" version = "3.11" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "importlib-metadata" version = "8.5.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "ipython" version = "9.11.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "decorator" }, @@ -977,103 +978,103 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667" } +sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19" }, + { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, ] [[package]] name = "ipython-pygments-lexers" version = "1.1.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" }, + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] [[package]] name = "jedi" version = "0.19.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0" } +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9" }, + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "jiter" version = "0.13.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152" }, - { url = "https://mirrors.aliyun.com/pypi/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] [[package]] name = "jsonschema" version = "4.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, ] [[package]] name = "jsonschema-specifications" version = "2025.9.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] name = "langfuse" version = "4.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "httpx" }, @@ -1085,15 +1086,15 @@ dependencies = [ { name = "pydantic" }, { name = "wrapt" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749, upload-time = "2026-03-19T14:03:34.508Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524" }, + { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049, upload-time = "2026-03-19T14:03:32.527Z" }, ] [[package]] name = "lark-oapi" version = "1.5.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pycryptodome" }, @@ -1102,34 +1103,34 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36" }, + { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, ] [[package]] name = "librt" version = "0.8.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, ] [[package]] name = "litellm" version = "1.83.7" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, @@ -1144,80 +1145,80 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633" } +sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111" }, + { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" }, ] [[package]] name = "lsprotocol" version = "2025.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7" }, + { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, ] [[package]] name = "magika" version = "0.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "numpy" }, { name = "onnxruntime" }, { name = "python-dotenv" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3" }, + { url = "https://files.pythonhosted.org/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d", size = 2969474, upload-time = "2025-10-30T15:22:25.298Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b", size = 13358841, upload-time = "2025-10-30T15:22:27.413Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64", size = 15367335, upload-time = "2025-10-30T15:22:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3", size = 12692831, upload-time = "2025-10-30T15:22:32.063Z" }, ] [[package]] name = "markdown" version = "3.10.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] name = "markdown-it-py" version = "4.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "markdownify" version = "1.2.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "six" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a" }, + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, ] [[package]] name = "markitdown" version = "0.1.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "charset-normalizer" }, @@ -1226,46 +1227,46 @@ dependencies = [ { name = "markdownify" }, { name = "requests" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a" } +sdist = { url = "https://files.pythonhosted.org/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a", size = 45099, upload-time = "2026-02-20T19:45:23.886Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434" }, + { url = "https://files.pythonhosted.org/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434", size = 63402, upload-time = "2026-02-20T19:45:27.195Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] [[package]] name = "matplotlib-inline" version = "0.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76" }, + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] [[package]] name = "mcp" version = "1.26.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, @@ -1282,118 +1283,118 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mpmath" version = "1.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] [[package]] name = "multidict" version = "6.7.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, - { url = "https://mirrors.aliyun.com/pypi/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] name = "mypy" version = "1.19.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "numpy" version = "2.4.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, ] [[package]] name = "olefile" version = "0.47" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f" }, + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, ] [[package]] name = "onnxruntime" version = "1.20.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coloredlogs" }, { name = "flatbuffers" }, @@ -1403,17 +1404,17 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb" }, + { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580, upload-time = "2024-11-21T00:49:07.029Z" }, + { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833, upload-time = "2024-11-21T00:49:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903, upload-time = "2024-11-21T00:49:12.984Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562, upload-time = "2024-11-21T00:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482, upload-time = "2024-11-21T00:49:19.412Z" }, ] [[package]] name = "openai" version = "2.30.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -1424,68 +1425,68 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d" }, + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, ] [[package]] name = "opencv-python" version = "4.13.0.92" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] [[package]] name = "opencv-python-headless" version = "4.13.0.92" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "sys_platform != 'win32'" }, ] wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, ] [[package]] name = "opentelemetry-api" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa" } +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "opentelemetry-api" }, @@ -1495,323 +1496,323 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, ] [[package]] name = "opentelemetry-proto" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, ] [[package]] name = "opentelemetry-sdk" version = "1.40.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2" } +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" version = "0.61b0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2" }, + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] [[package]] name = "packaging" version = "25.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "parso" version = "0.8.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff" }, + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] [[package]] name = "pathspec" version = "1.0.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] name = "pexpect" version = "4.9.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" } +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523" }, + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] [[package]] name = "pillow" version = "12.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "prompt-toolkit" version = "3.0.52" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "propcache" version = "0.4.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] name = "protobuf" version = "6.33.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] name = "ptyprocess" version = "0.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35" }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] [[package]] name = "pure-eval" version = "0.2.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0" }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] [[package]] name = "pyasn1" version = "0.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] name = "pyasn1-modules" version = "0.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a" }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pycryptodome" version = "3.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] [[package]] name = "pydantic" version = "2.12.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] name = "pydantic-core" version = "2.41.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" }, - { url = "https://mirrors.aliyun.com/pypi/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] [[package]] name = "pydantic-settings" version = "2.13.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] name = "pygls" version = "2.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, { name = "lsprotocol" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac" } +sdist = { url = "https://files.pythonhosted.org/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac", size = 54852, upload-time = "2026-03-19T09:16:56.853Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9" }, + { url = "https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9", size = 68719, upload-time = "2026-03-19T09:16:57.961Z" }, ] [[package]] name = "pygments" version = "2.19.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyjwt" version = "2.12.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -1822,40 +1823,40 @@ crypto = [ [[package]] name = "pymupdf" version = "1.27.2.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380, upload-time = "2026-03-20T09:47:58.386Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b" }, + { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563, upload-time = "2026-03-19T12:35:42.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089, upload-time = "2026-03-20T09:44:16.982Z" }, + { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817, upload-time = "2026-03-20T09:44:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135, upload-time = "2026-03-20T09:44:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585, upload-time = "2026-03-20T09:45:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861, upload-time = "2026-03-20T09:45:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032, upload-time = "2026-03-20T09:45:37.941Z" }, ] [[package]] name = "pypdf" version = "6.9.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844" }, + { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, ] [[package]] name = "pyreadline3" version = "3.5.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] [[package]] name = "pytest" version = "9.0.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -1863,560 +1864,560 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] name = "pytest-cov" version = "7.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" version = "1.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, ] [[package]] name = "python-multipart" version = "0.0.22" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] name = "python-socks" version = "2.8.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035" }, + { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, ] [[package]] name = "pytz" version = "2026.1.post1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a" }, + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] [[package]] name = "pywin32" version = "311" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] [[package]] name = "referencing" version = "0.37.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "regex" version = "2026.2.28" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, ] [[package]] name = "requests" version = "2.32.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "requests-toolbelt" version = "1.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] [[package]] name = "rich" version = "14.3.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "rpds-py" version = "0.30.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, ] [[package]] name = "ruff" version = "0.15.7" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2" }, +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] [[package]] name = "scipy" version = "1.17.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086" }, - { url = "https://mirrors.aliyun.com/pypi/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, ] [[package]] name = "shellingham" version = "1.5.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "six" version = "1.17.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "smmap" version = "5.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f" }, + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] [[package]] name = "sniffio" version = "1.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "socksio" version = "1.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, ] [[package]] name = "soupsieve" version = "2.8.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "sqlalchemy" version = "2.0.48" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] [[package]] name = "sse-starlette" version = "3.3.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049" } +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d" }, + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, ] [[package]] name = "stack-data" version = "0.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, { name = "executing" }, { name = "pure-eval" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9" } +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" }, + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] [[package]] name = "starlette" version = "1.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]] name = "striprtf" version = "0.0.29" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e", size = 7533, upload-time = "2025-03-27T22:55:56.874Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b" }, + { url = "https://files.pythonhosted.org/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b", size = 7879, upload-time = "2025-03-27T22:55:55.977Z" }, ] [[package]] name = "sympy" version = "1.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mpmath" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] [[package]] name = "tenacity" version = "9.1.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] name = "tiktoken" version = "0.12.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, ] [[package]] name = "tokenizers" version = "0.22.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] name = "toml" version = "0.10.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" }, + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, ] [[package]] name = "tqdm" version = "4.67.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] name = "traitlets" version = "5.14.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f" }, + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] [[package]] name = "tree-sitter" version = "0.25.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99" }, - { url = "https://mirrors.aliyun.com/pypi/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5" }, + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, ] [[package]] name = "typer" version = "0.23.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e" }, + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "ulid-py" version = "1.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0", size = 22514, upload-time = "2020-09-15T15:35:09.414Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987" }, + { url = "https://files.pythonhosted.org/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987", size = 25753, upload-time = "2020-09-15T15:35:08.075Z" }, ] [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "uvicorn" version = "0.42.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [package.optional-dependencies] @@ -2433,159 +2434,159 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, ] [[package]] name = "watchdog" version = "6.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] name = "watchfiles" version = "1.1.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, ] [[package]] name = "wcwidth" version = "0.6.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]] name = "websockets" version = "16.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" }, - { url = "https://mirrors.aliyun.com/pypi/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "wecom-aibot-sdk" version = "1.0.5" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "websockets" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a", size = 58176, upload-time = "2026-03-24T04:05:34.801Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05" }, + { url = "https://files.pythonhosted.org/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05", size = 26229, upload-time = "2026-03-24T04:05:33.298Z" }, ] [[package]] name = "wrapt" version = "1.17.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] name = "yarl" version = "1.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]] name = "zipp" version = "3.23.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] diff --git a/webui/public/channel-email.png b/webui/public/channel-email.png new file mode 100644 index 000000000..794e7d95c Binary files /dev/null and b/webui/public/channel-email.png differ diff --git a/webui/public/channel-whatsapp.png b/webui/public/channel-whatsapp.png new file mode 100644 index 000000000..338198d43 Binary files /dev/null and b/webui/public/channel-whatsapp.png differ diff --git a/webui/src/api/hub.ts b/webui/src/api/hub.ts index 32c50a576..b65dce47e 100644 --- a/webui/src/api/hub.ts +++ b/webui/src/api/hub.ts @@ -112,12 +112,40 @@ export interface HubCatalogParams { q?: string; } +export interface HubCatalogPageParams extends HubCatalogParams { + offset?: number; + limit?: number; +} + +export interface HubCatalogFacets { + type: Record; + category: Record; + tags: Record; + useCases: Record; + state: Record; + trust: Record; + riskLevel: Record; +} + +export interface HubCatalogPageResponse { + items: HubCatalogEntry[]; + total: number; + offset: number; + limit: number; + facets: HubCatalogFacets; +} + export const hubAPI = { catalog: (params?: HubCatalogParams) => client.get('/api/hub/catalog', { params }), - categories: () => - client.get('/api/hub/categories'), + catalogPage: (params?: HubCatalogPageParams) => + client.get('/api/hub/catalog', { params }), + + categories: (params?: { includeCounts?: boolean }) => + client.get('/api/hub/categories', { + params: params ? { include_counts: params.includeCounts } : undefined, + }), get: (type: HubPluginType, id: string) => client.get(`/api/hub/plugins/${type}/${id}`), diff --git a/webui/src/api/monitoring.ts b/webui/src/api/monitoring.ts index e74a2c22d..564169cf4 100644 --- a/webui/src/api/monitoring.ts +++ b/webui/src/api/monitoring.ts @@ -3,19 +3,20 @@ import client from './client'; export interface SystemStatus { status: 'healthy' | 'degraded' | 'down'; uptime: number; - activeSessions: number; - activeAgents: number; + activeSessions: number | null; + activeAgents: number | null; mcpServers: Record; timestamp: number; } export interface MetricsSnapshot { timestamp: number; - messageRate: number; - toolCallRate: number; - errorRate: number; - avgResponseTime: number; - activeRequests: number; + messageRate: number | null; + toolCallRate: number | null; + errorRate: number | null; + toolParseFailureRate: number | null; + avgResponseTime: number | null; + activeRequests: number | null; } export interface PerformanceData { diff --git a/webui/src/api/provider.test.ts b/webui/src/api/provider.test.ts new file mode 100644 index 000000000..1e2cda5ce --- /dev/null +++ b/webui/src/api/provider.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); +const mockPut = vi.fn(); + +vi.mock('./client', () => ({ + default: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + patch: (...args: unknown[]) => mockPatch(...args), + delete: (...args: unknown[]) => mockDelete(...args), + put: (...args: unknown[]) => mockPut(...args), + }, +})); + +function serviceSummary(id: string, name: string) { + return { + id, + name, + enabled: true, + status: 'unknown', + tool_count: 0, + verify_ssl: false, + }; +} + +describe('providerAPI.listApiServices', () => { + afterEach(() => { + vi.resetModules(); + vi.useRealTimers(); + mockGet.mockReset(); + mockPost.mockReset(); + mockPatch.mockReset(); + mockDelete.mockReset(); + mockPut.mockReset(); + }); + + it('shares concurrent API service list requests', async () => { + let resolveRequest: (value: { data: ReturnType[] }) => void = () => undefined; + mockGet.mockImplementation( + () => new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + + const { providerAPI } = await import('./provider'); + const first = providerAPI.listApiServices(); + const second = providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledWith('/api/provider/api-services'); + + resolveRequest({ data: [serviceSummary('svc-a', 'Service A')] }); + const [firstResponse, secondResponse] = await Promise.all([first, second]); + + firstResponse.data[0].name = 'Mutated'; + expect(secondResponse.data[0].name).toBe('Service A'); + }); + + it('serves a fresh cached response without another request', async () => { + mockGet.mockResolvedValue({ data: [serviceSummary('svc-a', 'Service A')] }); + + const { providerAPI } = await import('./provider'); + const firstResponse = await providerAPI.listApiServices(); + firstResponse.data[0].name = 'Mutated'; + const secondResponse = await providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(1); + expect(secondResponse.data[0].name).toBe('Service A'); + }); + + it('invalidates the cached list after an API service update', async () => { + mockGet + .mockResolvedValueOnce({ data: [serviceSummary('svc-a', 'Service A')] }) + .mockResolvedValueOnce({ data: [serviceSummary('svc-a', 'Service A Updated')] }); + mockPatch.mockResolvedValue({ data: serviceSummary('svc-a', 'Service A Updated') }); + + const { providerAPI } = await import('./provider'); + await providerAPI.listApiServices(); + await providerAPI.updateApiService('svc-a', { enabled: false }); + const response = await providerAPI.listApiServices(); + + expect(mockPatch).toHaveBeenCalledWith('/api/provider/api-services/svc-a', { enabled: false }); + expect(mockGet).toHaveBeenCalledTimes(2); + expect(response.data[0].name).toBe('Service A Updated'); + }); + + it('does not let an older list request repopulate the cache after a mutation', async () => { + let resolveOldList: (value: { data: ReturnType[] }) => void = () => undefined; + mockGet + .mockImplementationOnce(() => new Promise((resolve) => { + resolveOldList = resolve; + })) + .mockResolvedValueOnce({ data: [serviceSummary('svc-a', 'Fresh Service')] }); + mockPatch.mockResolvedValue({ data: serviceSummary('svc-a', 'Fresh Service') }); + + const { providerAPI } = await import('./provider'); + const oldRequest = providerAPI.listApiServices(); + await providerAPI.updateApiService('svc-a', { enabled: false }); + const freshRequest = providerAPI.listApiServices(); + resolveOldList({ data: [serviceSummary('svc-a', 'Stale Service')] }); + await Promise.all([oldRequest, freshRequest]); + + const response = await providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(response.data[0].name).toBe('Fresh Service'); + }); + + it('keeps a forced request authoritative when an older request finishes later', async () => { + let resolveOldList: (value: { data: ReturnType[] }) => void = () => undefined; + let resolveForcedList: (value: { data: ReturnType[] }) => void = () => undefined; + mockGet + .mockImplementationOnce(() => new Promise((resolve) => { + resolveOldList = resolve; + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveForcedList = resolve; + })); + + const { providerAPI } = await import('./provider'); + const oldRequest = providerAPI.listApiServices(); + const forcedRequest = providerAPI.listApiServices({ force: true }); + + resolveForcedList({ data: [serviceSummary('svc-a', 'Forced Service')] }); + await forcedRequest; + resolveOldList({ data: [serviceSummary('svc-a', 'Stale Service')] }); + await oldRequest; + + const response = await providerAPI.listApiServices(); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(response.data[0].name).toBe('Forced Service'); + }); +}); diff --git a/webui/src/api/provider.ts b/webui/src/api/provider.ts index 5eb77ecb6..a35235f42 100644 --- a/webui/src/api/provider.ts +++ b/webui/src/api/provider.ts @@ -16,6 +16,71 @@ import type { APIServiceMetadata, } from '@/types'; +const API_SERVICES_LIST_CACHE_TTL_MS = 5000; + +function requestApiServicesList() { + return client.get('/api/provider/api-services'); +} + +type APIServiceListResponse = Awaited>; + +let apiServicesListInFlight: Promise | null = null; +let apiServicesListInFlightGeneration: number | null = null; +let apiServicesListCache: { response: APIServiceListResponse; updatedAt: number } | null = null; +let apiServicesListGeneration = 0; + +function cloneApiServicesResponse(response: APIServiceListResponse): APIServiceListResponse { + const services = Array.isArray(response.data) ? response.data : []; + return { + ...response, + data: services.map((service) => ({ ...service })), + }; +} + +function invalidateApiServicesListCache(): void { + apiServicesListGeneration += 1; + apiServicesListCache = null; +} + +function listApiServicesCached(options: { force?: boolean } = {}) { + const useCache = !options.force; + if (useCache && apiServicesListCache && Date.now() - apiServicesListCache.updatedAt < API_SERVICES_LIST_CACHE_TTL_MS) { + return Promise.resolve(cloneApiServicesResponse(apiServicesListCache.response)); + } + if ( + useCache + && apiServicesListInFlight + && apiServicesListInFlightGeneration === apiServicesListGeneration + ) { + return apiServicesListInFlight.then(cloneApiServicesResponse); + } + + if (options.force) { + invalidateApiServicesListCache(); + } + const requestGeneration = apiServicesListGeneration; + const request = requestApiServicesList() + .then((response) => { + if (requestGeneration === apiServicesListGeneration) { + apiServicesListCache = { + response: cloneApiServicesResponse(response), + updatedAt: Date.now(), + }; + } + return response; + }) + .finally(() => { + if (apiServicesListInFlight === request) { + apiServicesListInFlight = null; + apiServicesListInFlightGeneration = null; + } + }); + apiServicesListInFlight = request; + apiServicesListInFlightGeneration = requestGeneration; + + return request.then(cloneApiServicesResponse); +} + // ==================== Provider API (Legacy + Enhanced) ==================== export const providerAPI = { @@ -54,7 +119,11 @@ export const providerAPI = { client.get(`/api/provider/${id}/service-credentials`), setServiceCredentials: (id: string, credentials: ProviderCredentialInput) => - client.post<{ success: boolean; message: string }>(`/api/provider/${id}/service-credentials`, credentials), + client.post<{ success: boolean; message: string }>(`/api/provider/${id}/service-credentials`, credentials) + .then((response) => { + invalidateApiServicesListCache(); + return response; + }), deleteCredentials: (id: string) => client.delete<{ success: boolean }>(`/api/provider/${id}/credentials`), @@ -70,17 +139,25 @@ export const providerAPI = { ), // API service status (connectivity) - listApiServices: () => - client.get('/api/provider/api-services'), + listApiServices: (options?: { force?: boolean }) => + listApiServicesCached(options), getServiceMetadata: (id: string) => client.get(`/api/provider/${id}/metadata`), updateApiService: (id: string, data: { enabled: boolean; verify_ssl?: boolean }) => - client.patch(`/api/provider/api-services/${id}`, data), + client.patch(`/api/provider/api-services/${id}`, data) + .then((response) => { + invalidateApiServicesListCache(); + return response; + }), deleteApiService: (id: string) => - client.delete<{ success: boolean }>(`/api/provider/api-services/${id}`), + client.delete<{ success: boolean }>(`/api/provider/api-services/${id}`) + .then((response) => { + invalidateApiServicesListCache(); + return response; + }), getApiServiceStatuses: () => client.get>( @@ -90,7 +167,10 @@ export const providerAPI = { refreshApiServiceStatuses: () => client.post<{ statuses: Record; refreshed_at: number }>( '/api/provider/api-services/refresh' - ), + ).then((response) => { + invalidateApiServicesListCache(); + return response; + }), getApiServiceStatus: (id: string) => client.get<{ status: string; message?: string; latency_ms?: number; tool_tested?: string; error?: string; checked_at?: number }>( diff --git a/webui/src/api/stats.test.ts b/webui/src/api/stats.test.ts index 9f9dc98db..7407e93ba 100644 --- a/webui/src/api/stats.test.ts +++ b/webui/src/api/stats.test.ts @@ -9,6 +9,7 @@ vi.mock('./client', () => ({ // Helper: build a default mock for every endpoint except /api/skills and /api/agent. function defaultMock(skillsData: unknown[], agentsData: unknown[] = []) { mockGet.mockImplementation((url: string) => { + if (url === '/api/stats/summary') return Promise.reject(new Error('legacy backend')); if (url === '/api/skills') return Promise.resolve({ data: skillsData }); if (url === '/api/task-system/dashboard') return Promise.resolve({ data: {} }); if (url === '/api/agent') return Promise.resolve({ data: agentsData }); @@ -23,6 +24,26 @@ function defaultMock(skillsData: unknown[], agentsData: unknown[] = []) { describe('statsApi.getSystemStats', () => { beforeEach(() => vi.clearAllMocks()); + it('uses the lightweight summary endpoint when available', async () => { + const summary = { + tasks: { week: 3, scheduledActive: 1 }, + agents: { total: 2 }, + workflows: { total: 4 }, + skills: { total: 5 }, + tools: { total: 6 }, + models: { total: 7 }, + system: { status: 'healthy', message: 'ok' }, + }; + mockGet.mockResolvedValue({ data: summary }); + + const { statsApi } = await import('./stats'); + const result = await statsApi.getSystemStats(); + + expect(result).toEqual(summary); + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledWith('/api/stats/summary'); + }); + it('counts only non-system skills', async () => { defaultMock([ { category: 'custom' }, @@ -62,6 +83,7 @@ describe('statsApi.getSystemStats', () => { it('handles skills API failure gracefully (returns 0)', async () => { mockGet.mockImplementation((url: string) => { + if (url === '/api/stats/summary') return Promise.reject(new Error('legacy backend')); if (url === '/api/skills') return Promise.reject(new Error('network')); if (url === '/api/task-system/dashboard') return Promise.resolve({ data: {} }); if (url === '/api/agent') return Promise.resolve({ data: [] }); diff --git a/webui/src/api/stats.ts b/webui/src/api/stats.ts index 363fb8095..89ad2130f 100644 --- a/webui/src/api/stats.ts +++ b/webui/src/api/stats.ts @@ -32,61 +32,87 @@ function shouldCountForAgentPage(agent: any): boolean { return !Array.isArray(agent.tags) || !agent.tags.includes('system'); } +function isSystemStats(value: any): value is SystemStats { + return Boolean( + value && + typeof value === 'object' && + value.tasks && + value.agents && + value.workflows && + value.skills && + value.tools && + value.models && + value.system + ); +} + +async function getSystemStatsLegacy(): Promise { + const [taskDash, agents, workflows, skills, tools, providers, health] = await Promise.all([ + apiClient.get('/api/task-system/dashboard').catch(() => ({ data: {} })), + apiClient.get('/api/agent').catch(() => ({ data: [] })), + apiClient.get('/api/workflow').catch(() => ({ data: [] })), + apiClient.get('/api/skills').catch(() => ({ data: [] })), + apiClient.get('/api/tools').catch(() => ({ data: [] })), + apiClient.get('/api/provider').catch(() => ({ data: { all: [] } })), + apiClient.get('/api/health').catch(() => ({ data: { status: 'error' } })), + ]); + + const dash = taskDash.data || {}; + const agentList = (Array.isArray(agents.data) ? agents.data : []).filter(shouldCountForAgentPage); + const workflowList = Array.isArray(workflows.data) ? workflows.data : []; + // Exclude `system` category skills so the count matches the Skills page, + // which hides system skills (e.g. onboarding) from the user. + const skillList = (Array.isArray(skills.data) ? skills.data : []).filter( + (s: any) => s?.category !== 'system' + ); + const toolList = Array.isArray(tools.data) ? tools.data : []; + const providerData = providers.data ?? {}; + const providerAll: any[] = providerData.all ?? (Array.isArray(providers.data) ? providers.data : []); + const connectedSet = new Set(providerData.connected ?? []); + const totalModels = providerAll + .filter((p: any) => connectedSet.has(p.id)) + .reduce((sum: number, p: any) => sum + Object.keys(p.models ?? {}).length, 0); + + return { + tasks: { + week: (dash.completed_week ?? 0) + (dash.failed_week ?? 0), + scheduledActive: dash.scheduled_active ?? 0, + }, + agents: { total: agentList.length }, + workflows: { total: workflowList.length }, + skills: { total: skillList.length }, + tools: { total: toolList.length }, + models: { total: totalModels }, + system: { + status: health.data.status === 'healthy' ? 'healthy' : 'error', + message: health.data.status === 'healthy' ? '所有服务运行正常' : '部分服务异常', + }, + }; +} + export const statsApi = { getSystemStats: async (): Promise => { try { - const [taskDash, agents, workflows, skills, tools, providers, health] = await Promise.all([ - apiClient.get('/api/task-system/dashboard').catch(() => ({ data: {} })), - apiClient.get('/api/agent').catch(() => ({ data: [] })), - apiClient.get('/api/workflow').catch(() => ({ data: [] })), - apiClient.get('/api/skills').catch(() => ({ data: [] })), - apiClient.get('/api/tools').catch(() => ({ data: [] })), - apiClient.get('/api/provider').catch(() => ({ data: { all: [] } })), - apiClient.get('/api/health').catch(() => ({ data: { status: 'error' } })), - ]); - - const dash = taskDash.data || {}; - const agentList = (Array.isArray(agents.data) ? agents.data : []).filter(shouldCountForAgentPage); - const workflowList = Array.isArray(workflows.data) ? workflows.data : []; - // Exclude `system` category skills so the count matches the Skills page, - // which hides system skills (e.g. onboarding) from the user. - const skillList = (Array.isArray(skills.data) ? skills.data : []).filter( - (s: any) => s?.category !== 'system' - ); - const toolList = Array.isArray(tools.data) ? tools.data : []; - const providerData = providers.data ?? {}; - const providerAll: any[] = providerData.all ?? (Array.isArray(providers.data) ? providers.data : []); - const connectedSet = new Set(providerData.connected ?? []); - const totalModels = providerAll - .filter((p: any) => connectedSet.has(p.id)) - .reduce((sum: number, p: any) => sum + Object.keys(p.models ?? {}).length, 0); - - return { - tasks: { - week: (dash.completed_week ?? 0) + (dash.failed_week ?? 0), - scheduledActive: dash.scheduled_active ?? 0, - }, - agents: { total: agentList.length }, - workflows: { total: workflowList.length }, - skills: { total: skillList.length }, - tools: { total: toolList.length }, - models: { total: totalModels }, - system: { - status: health.data.status === 'healthy' ? 'healthy' : 'error', - message: health.data.status === 'healthy' ? '所有服务运行正常' : '部分服务异常', - }, - }; + const response = await apiClient.get('/api/stats/summary'); + if (isSystemStats(response.data)) { + return response.data; + } + return await getSystemStatsLegacy(); } catch (error) { - console.error('Failed to fetch system stats:', error); - return { - tasks: { week: 0, scheduledActive: 0 }, - agents: { total: 0 }, - workflows: { total: 0 }, - skills: { total: 0 }, - tools: { total: 0 }, - models: { total: 0 }, - system: { status: 'error', message: '无法连接到后端服务' }, - }; + try { + return await getSystemStatsLegacy(); + } catch (fallbackError) { + console.error('Failed to fetch system stats:', fallbackError || error); + return { + tasks: { week: 0, scheduledActive: 0 }, + agents: { total: 0 }, + workflows: { total: 0 }, + skills: { total: 0 }, + tools: { total: 0 }, + models: { total: 0 }, + system: { status: 'error', message: '无法连接到后端服务' }, + }; + } } }, }; diff --git a/webui/src/api/tool.test.ts b/webui/src/api/tool.test.ts new file mode 100644 index 000000000..5d6d3f056 --- /dev/null +++ b/webui/src/api/tool.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getMock = vi.fn(); + +vi.mock('./client', () => ({ + default: { + get: (...args: unknown[]) => getMock(...args), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('listAllToolPages', () => { + beforeEach(() => { + getMock.mockReset(); + }); + + it('loads every page for one service without changing the list page size', async () => { + getMock + .mockResolvedValueOnce({ + data: { + items: [{ name: 'first' }], + total: 2, + offset: 0, + limit: 200, + facets: {}, + }, + }) + .mockResolvedValueOnce({ + data: { + items: [{ name: 'second' }], + total: 2, + offset: 1, + limit: 200, + facets: {}, + }, + }); + + const { listAllToolPages } = await import('./tool'); + const result = await listAllToolPages({ + source: 'api', + sourceName: 'service-a', + enabled: 'true', + q: 'indicator', + }); + + expect(result.map((tool) => tool.name)).toEqual(['first', 'second']); + expect(getMock).toHaveBeenNthCalledWith(1, '/api/tools/page', { + params: expect.objectContaining({ + source: 'api', + source_name: 'service-a', + enabled: 'true', + q: 'indicator', + offset: 0, + limit: 200, + }), + }); + expect(getMock).toHaveBeenNthCalledWith(2, '/api/tools/page', { + params: expect.objectContaining({ source: 'api', source_name: 'service-a', offset: 1, limit: 200 }), + }); + }); + + it('stops on an empty page even when the reported total is stale', async () => { + getMock.mockResolvedValue({ + data: { + items: [], + total: 500, + offset: 0, + limit: 200, + facets: {}, + }, + }); + + const { listAllToolPages } = await import('./tool'); + const result = await listAllToolPages({ source: 'mcp', sourceName: 'server-a' }); + + expect(result).toEqual([]); + expect(getMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/src/api/tool.ts b/webui/src/api/tool.ts index b0299f6b8..36729ddb2 100644 --- a/webui/src/api/tool.ts +++ b/webui/src/api/tool.ts @@ -22,15 +22,75 @@ export interface ToolFixture { has_assertion: boolean; } +export type ToolListSortField = 'category' | 'source' | 'source_name' | 'enabled' | 'name'; +export type ToolListSortDir = 'asc' | 'desc'; + +export interface ToolListPageParams { + source?: string; + category?: string; + sourceName?: string; + enabled?: string; + q?: string; + sortBy?: ToolListSortField; + sortDir?: ToolListSortDir; + offset?: number; + limit?: number; +} + +export interface ToolListFacets { + category: Record; + source: Record; + source_groups: Record; + source_name: Record; + enabled: Record; +} + +export interface ToolListPageResponse { + items: Tool[]; + total: number; + offset: number; + limit: number; + facets: ToolListFacets; +} + +export interface ToolRefreshResponse { + status: 'success' | 'partial' | 'error'; + tool_count: number; + message: string; + stages: Record; + errors: string[]; +} + +export interface ToolDeleteResponse { + status: 'success' | 'partial'; + message: string; + errors?: string[]; +} + export const toolAPI = { list: (params?: { source?: ToolSource; category?: string }) => client.get('/api/tools', { params }), + listPage: (params?: ToolListPageParams) => + client.get('/api/tools/page', { + params: { + source: params?.source, + category: params?.category, + source_name: params?.sourceName, + enabled: params?.enabled, + q: params?.q, + sort_by: params?.sortBy, + sort_dir: params?.sortDir, + offset: params?.offset, + limit: params?.limit, + }, + }), + get: (name: string) => client.get(`/api/tools/${name}`), refresh: () => - client.post('/api/tools/refresh'), + client.post('/api/tools/refresh'), test: (name: string, params: Record) => client.post(`/api/tools/${name}/test`, { params }), @@ -56,8 +116,28 @@ export const toolAPI = { client.post(`/api/tools/${name}/reset`), delete: (name: string) => - client.delete<{ status: string; message: string }>(`/api/tools/${name}`), + client.delete(`/api/tools/${name}`), }; +export async function listAllToolPages(params: ToolListPageParams): Promise { + const pageSize = 200; + const items: Tool[] = []; + let offset = 0; + + while (true) { + const response = await toolAPI.listPage({ + ...params, + offset, + limit: pageSize, + }); + const pageItems = Array.isArray(response.data.items) ? response.data.items : []; + items.push(...pageItems); + offset += pageItems.length; + if (pageItems.length === 0 || offset >= response.data.total) break; + } + + return items; +} + export const canDirectlyTestTool = (tool: Pick) => tool.source !== 'builtin'; diff --git a/webui/src/api/update.ts b/webui/src/api/update.ts index 10de702f6..bd10d894f 100644 --- a/webui/src/api/update.ts +++ b/webui/src/api/update.ts @@ -52,11 +52,16 @@ export interface UpdateProgress { // API // ====================================================================== -export const checkUpdate = async (locale?: string, edition?: UpdateEdition): Promise => { +export const checkUpdate = async ( + locale?: string, + edition?: UpdateEdition, + force?: boolean, +): Promise => { const response = await client.get('/api/update/check', { params: { ...(locale ? { locale } : {}), ...(edition ? { edition } : {}), + ...(force ? { force: true } : {}), }, }); return response.data; diff --git a/webui/src/components/common/ChatPromptSelectors.test.tsx b/webui/src/components/common/ChatPromptSelectors.test.tsx index 147ad560c..60ea93cbe 100644 --- a/webui/src/components/common/ChatPromptSelectors.test.tsx +++ b/webui/src/components/common/ChatPromptSelectors.test.tsx @@ -1,8 +1,32 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ChatModelPicker, type ChatModelProviderGroup } from './ChatPromptSelectors'; +import { + __resetChatModelOptionsResourcesForTesting, + ChatModelPicker, + useChatModelOptions, + type ChatModelProviderGroup, +} from './ChatPromptSelectors'; + +const { listDefinitionsMock, getResolvedMock, useProvidersMock } = vi.hoisted(() => ({ + listDefinitionsMock: vi.fn(), + getResolvedMock: vi.fn(), + useProvidersMock: vi.fn(), +})); + +vi.mock('@/api/provider', () => ({ + modelV2API: { + listDefinitions: listDefinitionsMock, + }, + defaultModelAPI: { + getResolved: getResolvedMock, + }, +})); + +vi.mock('@/hooks/useProviders', () => ({ + useProviders: useProvidersMock, +})); vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -40,6 +64,45 @@ const groupedOptions: ChatModelProviderGroup[] = [ }, ]; +function makeProvider(id: string) { + return { + id, + name: id, + source: 'builtin', + env: [], + key: null, + options: {}, + models: {}, + configured: true, + modelCount: 1, + category: 'connected', + }; +} + +function makeModelDefinition(providerId = 'provider-1', modelId = 'model-1') { + return { + id: modelId, + name: modelId, + provider_id: providerId, + model_type: 'chat', + status: 'active', + capabilities: { supports_vision: false }, + limits: { context_window: 128000 }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + __resetChatModelOptionsResourcesForTesting(); + useProvidersMock.mockReturnValue({ + providers: [makeProvider('provider-1')], + loading: false, + error: null, + connectedIds: ['provider-1'], + refetch: vi.fn(), + }); +}); + describe('ChatModelPicker', () => { it('opens the model menu toward the left edge of the trigger', async () => { const user = userEvent.setup(); @@ -62,3 +125,39 @@ describe('ChatModelPicker', () => { expect(menu).not.toHaveClass('left-0'); }); }); + +describe('useChatModelOptions', () => { + it('shares enabled model and default model requests across concurrent hook instances', async () => { + let resolveDefinitions: (value: { data: { models: any[] } }) => void = () => {}; + listDefinitionsMock.mockReturnValue(new Promise((resolve) => { + resolveDefinitions = resolve; + })); + getResolvedMock.mockResolvedValue({ + data: { provider_id: 'provider-1', model_id: 'model-1' }, + }); + + const first = renderHook(() => useChatModelOptions()); + const second = renderHook(() => useChatModelOptions()); + + expect(listDefinitionsMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveDefinitions({ + data: { models: [makeModelDefinition()] }, + }); + }); + + await waitFor(() => { + expect(first.result.current.loading).toBe(false); + expect(second.result.current.loading).toBe(false); + expect(first.result.current.options).toHaveLength(1); + expect(second.result.current.options).toHaveLength(1); + }); + + await waitFor(() => { + expect(first.result.current.selectedModelKey).toBe('provider-1::model-1'); + expect(second.result.current.selectedModelKey).toBe('provider-1::model-1'); + }); + expect(getResolvedMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/src/components/common/ChatPromptSelectors.tsx b/webui/src/components/common/ChatPromptSelectors.tsx index bcf576204..0df85d411 100644 --- a/webui/src/components/common/ChatPromptSelectors.tsx +++ b/webui/src/components/common/ChatPromptSelectors.tsx @@ -3,7 +3,11 @@ import { Bot, ChevronDown, Cpu, Info } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { Agent } from '@/api/agent'; -import { defaultModelAPI, modelV2API } from '@/api/provider'; +import { + __resetChatModelResourcesForTesting, + useEnabledChatModelDefinitions, + useResolvedDefaultModel, +} from '@/hooks/useChatModelResources'; import { useAgents } from '@/hooks/useAgents'; import { useProviders } from '@/hooks/useProviders'; import { getAgentDisplayDescription, getAgentDisplayName, isAgentUsableInChat } from '@/utils/agentDisplay'; @@ -36,6 +40,10 @@ type SelectorTooltip = { y: number; }; +export function __resetChatModelOptionsResourcesForTesting(): void { + __resetChatModelResourcesForTesting(); +} + function formatAgentName(name: string): string { return name ? name.charAt(0).toUpperCase() + name.slice(1) : name; } @@ -68,28 +76,12 @@ export function useChatAgentOptions(options: { allowedAgentNames?: string[] } = export function useChatModelOptions() { const { t } = useTranslation('session'); const { providers, loading: loadingProviders } = useProviders(); - const [enabledModelDefinitions, setEnabledModelDefinitions] = useState([]); - const [loadingEnabledModels, setLoadingEnabledModels] = useState(true); + const { + data: enabledModelDefinitions, + loading: loadingEnabledModels, + } = useEnabledChatModelDefinitions(); const [selectedModelKey, setSelectedModelKey] = useState(null); - useEffect(() => { - let cancelled = false; - setLoadingEnabledModels(true); - Promise.resolve(modelV2API.listDefinitions({ enabled_only: true })) - .then((response) => { - if (!cancelled) setEnabledModelDefinitions(response?.data?.models ?? []); - }) - .catch(() => { - if (!cancelled) setEnabledModelDefinitions([]); - }) - .finally(() => { - if (!cancelled) setLoadingEnabledModels(false); - }); - return () => { - cancelled = true; - }; - }, []); - const options = useMemo(() => { const providerById = new Map( providers @@ -162,24 +154,19 @@ export function useChatModelOptions() { [options, selectedModelKey], ); + const { + data: resolvedDefaultModel, + initialized: resolvedDefaultModelInitialized, + } = useResolvedDefaultModel(options.length > 0); + useEffect(() => { - if (selectedModelKey || options.length === 0) return; - let cancelled = false; - Promise.resolve(defaultModelAPI.getResolved()) - .then((response) => { - if (cancelled) return; - const { provider_id: providerID, model_id: modelID } = response?.data ?? {}; - const defaultKey = `${providerID}::${modelID}`; - const fallbackKey = options[0]?.key ?? null; - setSelectedModelKey(options.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); - }) - .catch(() => { - if (!cancelled) setSelectedModelKey(options[0]?.key ?? null); - }); - return () => { - cancelled = true; - }; - }, [options, selectedModelKey]); + if (selectedModelKey || options.length === 0 || !resolvedDefaultModelInitialized) return; + const defaultKey = resolvedDefaultModel + ? `${resolvedDefaultModel.providerID}::${resolvedDefaultModel.modelID}` + : null; + const fallbackKey = options[0]?.key ?? null; + setSelectedModelKey(defaultKey && options.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); + }, [options, resolvedDefaultModel, resolvedDefaultModelInitialized, selectedModelKey]); useEffect(() => { if (loadingEnabledModels || options.length === 0 || !selectedModelKey) return; diff --git a/webui/src/components/common/EntitySheet.test.tsx b/webui/src/components/common/EntitySheet.test.tsx index 99a8ac78f..ffde9c541 100644 --- a/webui/src/components/common/EntitySheet.test.tsx +++ b/webui/src/components/common/EntitySheet.test.tsx @@ -178,14 +178,14 @@ describe('EntitySheet', () => { expect(screen.getByText('Form content')).toBeInTheDocument(); }); - it('defaults Rex workbench process details collapsed like workflow workbenches', () => { + it('defaults Rex workbench process details collapsed like workflow workbenches', async () => { render(
Form content
, ); - expect(screen.getByTestId('session-chat')).toHaveAttribute( + expect(await screen.findByTestId('session-chat')).toHaveAttribute( 'data-display', JSON.stringify({ collapseIntermediateSteps: true, processGroupsDefaultOpen: false }), ); diff --git a/webui/src/components/common/EntitySheet.tsx b/webui/src/components/common/EntitySheet.tsx index 802631221..37e39b752 100644 --- a/webui/src/components/common/EntitySheet.tsx +++ b/webui/src/components/common/EntitySheet.tsx @@ -12,7 +12,7 @@ * 3. 传入 onRunTest 启用「测试」Tab */ -import { useState, useEffect, useRef, useCallback, createContext, useContext } from 'react'; +import { lazy, Suspense, useState, useEffect, useRef, useCallback, createContext, useContext } from 'react'; import { X, FileText, @@ -27,7 +27,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import client from '@/api/client'; -import SessionChat, { buildInstructionDisplayText, type SessionChatDisplay } from './SessionChat'; +import { buildInstructionDisplayText, type SessionChatDisplay } from '@/features/session-chat'; import { useSessionChat } from '@/hooks/useSessionChat'; import { useDefaultModelVision } from '@/hooks/useDefaultModelVision'; import ChatGuideDock, { type ChatGuideAction } from './ChatGuideDock'; @@ -40,6 +40,16 @@ import { } from './sidePanelSizing'; // ─── Context ────────────────────────────────────────────────────────────────── +const LazySessionChat = lazy(() => import('./SessionChat')); + +function ChatPanelFallback() { + return ( +
+ +
+ ); +} + interface EntitySheetCtx { /** Switch to the Rex tab, optionally sending an initial message */ openRex: (prefillMessage?: string) => void; @@ -582,13 +592,15 @@ export default function EntitySheet({ {activeTab === 'test' && (
{testSessionId ? ( - + }> + + ) : (
{testError ? ( @@ -674,69 +686,71 @@ export default function EntitySheet({
)} {!sessionError && rexSessionHydrated && ( - createAndSendRex({ - text, - imageParts, - agent: agentOverride || rexAgentName, - model: modelOverride === undefined ? rexModel : modelOverride, - displayText: options?.displayText, - }) : undefined} - welcomeContent={( - hasRexGuideActions ? ( - } - title={rexGuidePanelTitle ?? t('entity.rexAssist')} - description={rexGuidePanelDesc ?? t('entity.rexReady')} - groups={rexWelcomeGuideGroups} - onStartPrompt={startRexGuidePrompt} - /> - ) : ( -
- -

{t('entity.rexAssist')}

-

{t('entity.rexReady')}

-
- ) - )} - conversationBottomSlot={({ sendPrompt, sending, streaming, hasMessages }) => ( - hasRexGuideActions && hasMessages ? ( - { - if (prompt === EXTRACT_FROM_REX_GUIDE_PROMPT) { - void handleExtract(); - return; - } - - sendPrompt(prompt, { - displayText: buildInstructionDisplayText(label), - }); - }} - /> - ) : null - )} - /> + }> + createAndSendRex({ + text, + imageParts, + agent: agentOverride || rexAgentName, + model: modelOverride === undefined ? rexModel : modelOverride, + displayText: options?.displayText, + }) : undefined} + welcomeContent={( + hasRexGuideActions ? ( + } + title={rexGuidePanelTitle ?? t('entity.rexAssist')} + description={rexGuidePanelDesc ?? t('entity.rexReady')} + groups={rexWelcomeGuideGroups} + onStartPrompt={startRexGuidePrompt} + /> + ) : ( +
+ +

{t('entity.rexAssist')}

+

{t('entity.rexReady')}

+
+ ) + )} + conversationBottomSlot={({ sendPrompt, sending, streaming, hasMessages }) => ( + hasRexGuideActions && hasMessages ? ( + { + if (prompt === EXTRACT_FROM_REX_GUIDE_PROMPT) { + void handleExtract(); + return; + } + + sendPrompt(prompt, { + displayText: buildInstructionDisplayText(label), + }); + }} + /> + ) : null + )} + /> +
)}
)} diff --git a/webui/src/components/common/LazyLoadErrorBoundary.test.tsx b/webui/src/components/common/LazyLoadErrorBoundary.test.tsx new file mode 100644 index 000000000..ab147219d --- /dev/null +++ b/webui/src/components/common/LazyLoadErrorBoundary.test.tsx @@ -0,0 +1,67 @@ +import { Component, type ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import LazyLoadErrorBoundary from './LazyLoadErrorBoundary'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +function BrokenContent({ error }: { error: Error }): never { + throw error; +} + +class CapturingBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + if (this.state.error) return
{this.state.error.message}
; + return this.props.children; + } +} + +describe('LazyLoadErrorBoundary', () => { + it('shows a recoverable fallback when lazy content fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const onRetry = vi.fn(); + const user = userEvent.setup(); + const error = new Error('Loading chunk route failed'); + error.name = 'ChunkLoadError'; + + render( + + + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('error.chunkLoadFailed'); + await user.click(screen.getByRole('button', { name: 'button.retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('propagates ordinary render errors to the surrounding error boundary', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + render( + + + + + , + ); + + expect(screen.getByTestId('outer-error')).toHaveTextContent('ordinary render failure'); + expect(screen.queryByText('error.chunkLoadFailed')).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/components/common/LazyLoadErrorBoundary.tsx b/webui/src/components/common/LazyLoadErrorBoundary.tsx new file mode 100644 index 000000000..960581ccf --- /dev/null +++ b/webui/src/components/common/LazyLoadErrorBoundary.tsx @@ -0,0 +1,70 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { isChunkLoadError, retryChunkLoad } from '@/utils/chunkLoadRecovery'; + +interface LazyLoadErrorBoundaryProps { + children: ReactNode; + mode?: 'page' | 'overlay'; + onRetry?: () => void; +} + +interface LazyLoadErrorBoundaryState { + failed: boolean; +} + +function LazyLoadErrorFallback({ + mode, + onRetry, +}: Required>) { + const { t } = useTranslation('common'); + const containerClassName = mode === 'overlay' + ? 'fixed inset-0 z-[120] flex items-center justify-center bg-black/30 p-6' + : 'flex min-h-64 items-center justify-center p-6'; + + return ( +
+
+

+ {t('error.chunkLoadFailed')} +

+

+ {t('error.chunkLoadHint')} +

+ +
+
+ ); +} + +export default class LazyLoadErrorBoundary extends Component< + LazyLoadErrorBoundaryProps, + LazyLoadErrorBoundaryState +> { + state: LazyLoadErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(error: Error): LazyLoadErrorBoundaryState { + if (!isChunkLoadError(error)) throw error; + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[LazyLoadErrorBoundary] Failed to load lazy UI:', error, info.componentStack); + } + + render() { + if (!this.state.failed) return this.props.children; + return ( + + ); + } +} diff --git a/webui/src/components/common/LoadingIndicators.test.tsx b/webui/src/components/common/LoadingIndicators.test.tsx new file mode 100644 index 000000000..cea074360 --- /dev/null +++ b/webui/src/components/common/LoadingIndicators.test.tsx @@ -0,0 +1,41 @@ +import { act, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import LoadingSpinner from './LoadingSpinner'; +import RoutePageSkeleton from './RoutePageSkeleton'; + +describe('delayed loading indicators', () => { + it('hides short spinner flashes until the delay elapses', async () => { + vi.useFakeTimers(); + + render(); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(179); + }); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByRole('status')).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it('hides short route skeleton flashes until the delay elapses', async () => { + vi.useFakeTimers(); + + render(); + + expect(screen.queryByTestId('route-page-skeleton')).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(180); + }); + expect(screen.getByTestId('route-page-skeleton')).toBeInTheDocument(); + + vi.useRealTimers(); + }); +}); diff --git a/webui/src/components/common/LoadingSpinner.tsx b/webui/src/components/common/LoadingSpinner.tsx index 90dd7fd0c..3b25f59ac 100644 --- a/webui/src/components/common/LoadingSpinner.tsx +++ b/webui/src/components/common/LoadingSpinner.tsx @@ -1,19 +1,24 @@ import { Loader2 } from 'lucide-react'; +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; interface LoadingSpinnerProps { size?: 'sm' | 'md' | 'lg'; className?: string; + delayMs?: number; } -export default function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) { +export default function LoadingSpinner({ size = 'md', className = '', delayMs = 0 }: LoadingSpinnerProps) { + const visible = useDelayedVisible(delayMs); const sizeClasses = { sm: 'w-4 h-4', md: 'w-8 h-8', lg: 'w-12 h-12', }; + if (!visible) return null; + return ( -
+
); diff --git a/webui/src/components/common/RoutePageSkeleton.tsx b/webui/src/components/common/RoutePageSkeleton.tsx index 82581c05f..223b7f780 100644 --- a/webui/src/components/common/RoutePageSkeleton.tsx +++ b/webui/src/components/common/RoutePageSkeleton.tsx @@ -1,6 +1,15 @@ -export default function RoutePageSkeleton() { +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; + +interface RoutePageSkeletonProps { + delayMs?: number; +} + +export default function RoutePageSkeleton({ delayMs = 0 }: RoutePageSkeletonProps) { + const visible = useDelayedVisible(delayMs); + if (!visible) return null; + return ( -
+
diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index 934b5df8e..0551d8ab4 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -7,7 +7,9 @@ import type { Message } from '@/types'; import { areChatMessagePartsRenderEqual, + areChatTimelineItemsRenderEqual, buildInstructionDisplayText, + buildChatTimelineItems, buildContextUsageBreakdown, buildTodoSummary, ChatMessageBubble, @@ -29,6 +31,7 @@ import { isActiveSessionStatus, listUploadedDocumentPaths, shouldRenderMessage, + shouldForwardSSEEventToParent, shouldRefetchFinishedMessage, truncateToolDisplayText, } from './SessionChat'; @@ -163,9 +166,13 @@ vi.mock('@/hooks/useReasoningToggle', () => ({ }), })); -vi.mock('@/hooks/usePendingQuestions', () => ({ - usePendingQuestions: () => pendingQuestionsHookMock, -})); +vi.mock('@/features/session-chat', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePendingQuestions: () => pendingQuestionsHookMock, + }; +}); vi.mock('./Toast', () => ({ useToast: () => toastMock, @@ -996,6 +1003,155 @@ describe('getRenderableThinkingText', () => { }); }); +describe('ChatMessageBubble reasoning streaming', () => { + it.each(['reasoning', 'thinking'] as const)( + 'paces an active %s part after a tool and flushes the completed text', + (partType) => { + type RafCallback = (time: number) => void; + const callbacks = new Map(); + let nextRafId = 0; + vi.stubGlobal('requestAnimationFrame', (callback: RafCallback) => { + const id = ++nextRafId; + callbacks.set(id, callback); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + callbacks.delete(id); + }); + + const makeReasoningMessage = (text: string, finish?: Message['finish']) => makeMessage({ + id: 'assistant-reasoning-stream', + role: 'assistant', + finish, + parts: [ + { + id: 'tool-before-reasoning', + messageID: 'assistant-reasoning-stream', + sessionID: 'sess-1', + type: 'tool', + tool: 'read', + state: { status: 'completed', output: 'done' }, + } as any, + { + id: 'reasoning-stream', + messageID: 'assistant-reasoning-stream', + sessionID: 'sess-1', + type: partType, + text, + } as any, + ], + }); + + let unmount = () => {}; + try { + const rendered = render(React.createElement(ChatMessageBubble, { + message: makeReasoningMessage('思'), + isActive: true, + })); + unmount = rendered.unmount; + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeReasoningMessage('思考过程'), + isActive: true, + })); + + expect(screen.getByText('思考中...')).toBeInTheDocument(); + expect(screen.getByText('思')).toBeInTheDocument(); + expect(screen.queryByText('思考过程')).not.toBeInTheDocument(); + + act(() => { + const pending = [...callbacks.values()]; + callbacks.clear(); + pending.forEach(callback => callback(1000 / 60)); + }); + expect(screen.getByText('思考')).toBeInTheDocument(); + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeReasoningMessage('思考过程', 'stop'), + isActive: false, + })); + expect(screen.getByText('思考过程')).toBeInTheDocument(); + } finally { + unmount(); + vi.unstubAllGlobals(); + } + }, + ); + + it('does not animate reasoning while its process group is closed', () => { + let nextRafId = 0; + const requestAnimationFrameSpy = vi.fn(() => ++nextRafId); + vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const messageId = 'assistant-hidden-reasoning'; + const processGroupKey = `${messageId}:process:0`; + const makeHiddenReasoningMessage = (text: string) => makeMessage({ + id: messageId, + role: 'assistant', + parts: [ + { + id: 'tool-before-hidden-reasoning', + messageID: messageId, + sessionID: 'sess-1', + type: 'tool', + tool: 'read', + state: { status: 'completed', output: 'done' }, + } as any, + { + id: 'hidden-reasoning', + messageID: messageId, + sessionID: 'sess-1', + type: 'reasoning', + text, + } as any, + ], + }); + + let unmount = () => {}; + try { + const rendered = render(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: false }, + })); + unmount = rendered.unmount; + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏更新'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: false }, + })); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏更新'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: true }, + })); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + + rendered.rerender(React.createElement(ChatMessageBubble, { + message: makeHiddenReasoningMessage('隐藏更新继续'), + isActive: true, + collapseIntermediateSteps: true, + processGroupsOpenWhileActive: true, + processGroupOpenState: { [processGroupKey]: true }, + })); + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + } finally { + unmount(); + vi.unstubAllGlobals(); + } + }); +}); + describe('getMessageErrorText', () => { it('prefers user-facing display messages over raw provider errors', () => { expect(getMessageErrorText(makeMessage({ @@ -1028,6 +1184,146 @@ describe('getMessageErrorText', () => { }); }); +describe('shouldForwardSSEEventToParent', () => { + it('forwards global workflow, task, and session update events', () => { + expect(shouldForwardSSEEventToParent({ + type: 'workflow.updated', + properties: { id: 'workflow-1' }, + }, 'sess-1')).toBe(true); + expect(shouldForwardSSEEventToParent({ + type: 'task.updated', + properties: { executionID: 'task-1' }, + }, 'sess-1')).toBe(true); + expect(shouldForwardSSEEventToParent({ + type: 'session.updated', + properties: { id: 'other-session' }, + }, 'sess-1')).toBe(true); + }); + + it('forwards chat events only for the current session', () => { + expect(shouldForwardSSEEventToParent({ + type: 'message.part.updated', + properties: { part: { sessionID: 'sess-1' } }, + }, 'sess-1')).toBe(true); + expect(shouldForwardSSEEventToParent({ + type: 'message.part.updated', + properties: { part: { sessionID: 'other-session' } }, + }, 'sess-1')).toBe(false); + expect(shouldForwardSSEEventToParent({ + type: 'context.usage.updated', + properties: { sessionID: 'other-session' }, + }, 'sess-1')).toBe(false); + }); + + it('skips heartbeat-style events without payloads', () => { + expect(shouldForwardSSEEventToParent({ + type: 'server.heartbeat', + }, 'sess-1')).toBe(false); + }); +}); + +describe('buildChatTimelineItems', () => { + it('filters skipped and non-renderable messages while marking the active assistant', () => { + const messages = [ + makeMessage({ + id: 'user-1', + role: 'user', + parts: [{ id: 'user-part', type: 'text', text: 'hello' }] as Message['parts'], + }), + makeMessage({ + id: 'synthetic-1', + role: 'assistant', + parts: [{ id: 'synthetic-part', type: 'text', text: '', synthetic: true }] as Message['parts'], + }), + makeMessage({ + id: 'assistant-empty', + role: 'assistant', + parts: [], + finish: null, + }), + makeMessage({ + id: 'assistant-active', + role: 'assistant', + parts: [], + finish: null, + }), + ]; + + const items = buildChatTimelineItems({ + messages, + skipIndices: new Set([1]), + isStreaming: true, + }); + + expect(items.map((item) => item.message.id)).toEqual(['user-1', 'assistant-active']); + expect(items.map((item) => item.isActive)).toEqual([false, true]); + }); + + it('keeps the same visible set when not streaming', () => { + const messages = [ + makeMessage({ + id: 'assistant-empty', + role: 'assistant', + parts: [], + finish: null, + }), + makeMessage({ + id: 'assistant-text', + role: 'assistant', + parts: [{ id: 'text-part', type: 'text', text: 'done' }] as Message['parts'], + finish: 'stop', + }), + ]; + + const items = buildChatTimelineItems({ + messages, + skipIndices: new Set(), + isStreaming: false, + }); + + expect(items.map((item) => item.message.id)).toEqual(['assistant-text']); + expect(items[0].isActive).toBe(false); + }); +}); + +describe('areChatTimelineItemsRenderEqual', () => { + it('treats cloned assistant messages with identical visible parts as equal', () => { + const prevMessage = makeMessage({ + id: 'assistant-1', + role: 'assistant', + agent: 'rex', + parts: [{ id: 'text-1', type: 'text', text: 'hello' }] as Message['parts'], + finish: 'stop', + }); + const nextMessage = { + ...prevMessage, + parts: [{ id: 'text-1', type: 'text', text: 'hello' }] as Message['parts'], + }; + + expect(areChatTimelineItemsRenderEqual( + [{ message: prevMessage as any, isActive: false }], + [{ message: nextMessage as any, isActive: false }], + )).toBe(true); + }); + + it('detects visible text changes in otherwise stable timeline items', () => { + const prevMessage = makeMessage({ + id: 'assistant-1', + role: 'assistant', + parts: [{ id: 'text-1', type: 'text', text: 'hello' }] as Message['parts'], + }); + const nextMessage = { + ...prevMessage, + parts: [{ id: 'text-1', type: 'text', text: 'hello world' }] as Message['parts'], + }; + + expect(areChatTimelineItemsRenderEqual( + [{ message: prevMessage as any, isActive: false }], + [{ message: nextMessage as any, isActive: false }], + )).toBe(false); + }); +}); + describe('SessionChat error rendering', () => { it('renders empty assistant error messages instead of the thinking indicator', () => { useSessionMessagesMock.mockReturnValue({ diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index 8e2d4a98a..c52d08b8a 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -18,7 +18,7 @@ import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'; import { Send, Loader2, ChevronDown, Square, Copy, User, FileText, AlertCircle, X, RefreshCw, Pencil, Save, ImageIcon, Paperclip, ArrowUp, Clock, CheckCircle2, XCircle, Brain, Trash2, Bot, Check, ListTree } from 'lucide-react'; -import { StreamingMarkdown } from './StreamingMarkdown'; +import { StreamingMarkdown, useStreamingContent } from './StreamingMarkdown'; import { useTranslation } from 'react-i18next'; import LoadingSpinner from './LoadingSpinner'; import { QuestionTool, type QuestionItem } from './QuestionTool'; @@ -28,10 +28,9 @@ import ImageLightbox from './ImageLightbox'; import { useSessionMessages } from '@/hooks/useSessions'; import { useSSE, type SSEConnectionStatus } from '@/hooks/useSSE'; import { useReasoningToggle } from '@/hooks/useReasoningToggle'; -import { usePendingQuestions, type PendingQuestion } from '@/hooks/usePendingQuestions'; import { sessionApi, type ContextUsageSnapshot, type QueuedPrompt } from '@/api/session'; import client, { getApiBase } from '@/api/client'; -import { commandAPI, type Command } from '@/api/skill'; +import type { Command } from '@/api/skill'; import type { Agent } from '@/api/agent'; import { useToast } from './Toast'; import { buildRunWorkflowHeaderSummary } from './toolStageSummary'; @@ -50,9 +49,33 @@ import { type ImagePartData, } from '@/utils/imageUpload'; import type { Message, MessagePart, SessionGoalState, ToolState } from '@/types'; +import { + buildInstructionDisplayText, + fetchSessionChatCommands, + getQueuedPromptText, + parseInstructionDisplayText, + resolveSessionChatSSEAction, + shouldForwardSSEEventToParent, + type CompactionStage, + usePendingQuestions, + useSessionContextUsage, + useSessionPromptQueue, + type PendingQuestion, + type PromptDisplayOptions, + type SSEChatEvent, + type SessionChatDisplay, +} from '@/features/session-chat'; export { formatSmartTime }; export type { SSEConnectionStatus }; +export { + buildInstructionDisplayText, + parseInstructionDisplayText, + shouldForwardSSEEventToParent, + type PromptDisplayOptions, + type SSEChatEvent, + type SessionChatDisplay, +} from '@/features/session-chat'; // ============================================================================ // Types @@ -60,11 +83,6 @@ export type { SSEConnectionStatus }; export type MergedMessage = Message & { _merged?: boolean }; -export interface SSEChatEvent { - type: string; - properties?: Record; -} - /** Node reference shown above the chat input as a dismissible chip */ export interface NodeRef { id: string; @@ -90,22 +108,6 @@ export interface ConversationBottomSlotActions { hasMessages: boolean; } -export interface PromptDisplayOptions { - displayText?: string; -} - -const INSTRUCTION_DISPLAY_PREFIX = '@@flocks-instruction:'; - -export function buildInstructionDisplayText(label: string): string { - return `${INSTRUCTION_DISPLAY_PREFIX}${label}`; -} - -export function parseInstructionDisplayText(text: string): string | null { - return text.startsWith(INSTRUCTION_DISPLAY_PREFIX) - ? text.slice(INSTRUCTION_DISPLAY_PREFIX.length).trim() || null - : null; -} - function getMessagePartDisplayText(part: MessagePart): string { const metadataDisplayText = part.metadata?.displayText ?? part.metadata?.display_text; return typeof metadataDisplayText === 'string' && metadataDisplayText @@ -113,24 +115,6 @@ function getMessagePartDisplayText(part: MessagePart): string { : part.text || ''; } -/** Display-related options grouped to reduce prop surface. */ -export interface SessionChatDisplay { - /** Compact mode for panels/dialogs (default: true). Set false for full-page. */ - compact?: boolean; - /** Let embedded chats use the full available message width. */ - fullWidth?: boolean; - /** Show copy action on assistant messages */ - showActions?: boolean; - /** Show timestamp below each message */ - showTimestamp?: boolean; - /** Default-collapse intermediate reasoning and tool-process details in embedded panels. */ - collapseIntermediateSteps?: boolean; - /** Initial open state for grouped reasoning/tool-process details. */ - processGroupsDefaultOpen?: boolean; - /** Keep grouped reasoning/tool-process details open while the assistant message is actively streaming. */ - processGroupsOpenWhileActive?: boolean; -} - export interface SessionChatProps { /** When null/undefined, only welcomeContent + input are rendered (lazy session). */ sessionId?: string | null; @@ -254,6 +238,17 @@ export function getRenderableThinkingText(part: Pick{displayContent}; +}); + function stringifyToolPayload(value: unknown): string { if (value == null) return ''; if (typeof value === 'string') return value; @@ -708,18 +703,6 @@ export function listUploadedDocumentPaths(items: UploadedDocumentAttachmentLike[ // don't share a draft, and namespaced to avoid colliding with other features. import { readChatDraft, writeChatDraft } from '@/utils/chatDraft'; -// Backend stages emitted by ``SessionCompaction.process`` / -// ``summarize_chunked`` via the ``session.compaction_progress`` SSE event. -// Keep in sync with ``flocks/session/lifecycle/compaction/{compaction,summary}.py``. -type CompactionStage = - | 'load' - | 'strategy' - | 'chunk_done' - | 'merge_started' - | 'merge_done' - | 'summarize_done' - | 'complete'; - interface CompactionStageEntry { stage: CompactionStage; data: Record; @@ -985,6 +968,91 @@ export function shouldRenderMessage( return true; } +export interface ChatTimelineItem { + message: MergedMessage; + isActive: boolean; +} + +export function buildChatTimelineItems({ + messages, + skipIndices, + isStreaming, +}: { + messages: MergedMessage[]; + skipIndices: Set; + isStreaming: boolean; +}): ChatTimelineItem[] { + const items: ChatTimelineItem[] = []; + for (let index = 0; index < messages.length; index++) { + if (skipIndices.has(index)) continue; + const message = messages[index]; + const isActive = + isStreaming && + index === messages.length - 1 && + message.role === 'assistant' && + !message.finish; + if (!shouldRenderMessage(message, { isActive })) continue; + items.push({ message, isActive }); + } + return items; +} + +export function areChatTimelineItemsRenderEqual( + prevItems: ChatTimelineItem[], + nextItems: ChatTimelineItem[], +): boolean { + if (prevItems.length !== nextItems.length) return false; + + for (let index = 0; index < prevItems.length; index++) { + const prev = prevItems[index]; + const next = nextItems[index]; + if (prev.isActive !== next.isActive) return false; + + const prevMessage = prev.message; + const nextMessage = next.message; + if (prevMessage === nextMessage) continue; + if (prevMessage.id !== nextMessage.id) return false; + if (prevMessage.role !== nextMessage.role) return false; + if (prevMessage.finish !== nextMessage.finish) return false; + if (prevMessage.error !== nextMessage.error) return false; + if (prevMessage.agent !== nextMessage.agent) return false; + if (prevMessage.timestamp !== nextMessage.timestamp) return false; + if (prevMessage.compacted !== nextMessage.compacted) return false; + if (!areChatMessagePartsRenderEqual(prevMessage.parts, nextMessage.parts)) return false; + } + + return true; +} + +function useStableChatTimelineSegments(items: ChatTimelineItem[]): { + historyItems: ChatTimelineItem[]; + tailItems: ChatTimelineItem[]; +} { + const previousRef = useRef<{ + historyItems: ChatTimelineItem[]; + tailItems: ChatTimelineItem[]; + } | null>(null); + + return useMemo(() => { + const tailStart = items.length > 0 && items[items.length - 1].isActive + ? items.length - 1 + : items.length; + const nextHistoryItems = tailStart === items.length ? items : items.slice(0, tailStart); + const nextTailItems = tailStart === items.length ? [] : items.slice(tailStart); + const previous = previousRef.current; + + const historyItems = previous && areChatTimelineItemsRenderEqual(previous.historyItems, nextHistoryItems) + ? previous.historyItems + : nextHistoryItems; + const tailItems = previous && areChatTimelineItemsRenderEqual(previous.tailItems, nextTailItems) + ? previous.tailItems + : nextTailItems; + const next = { historyItems, tailItems }; + previousRef.current = next; + return next; + }, [items]); +} + export function getMessageErrorText(message: Pick): string { const error = message.error as any; if (!error) return ''; @@ -1107,13 +1175,6 @@ function isAllowedUploadFile(file: File): boolean { return ALLOWED_UPLOAD_EXTENSIONS.has(getFileExtension(file.name)); } -function getQueuedPromptText(item: QueuedPrompt): string { - if (typeof item.displayText === 'string' && item.displayText) return item.displayText; - if (typeof item.display_text === 'string' && item.display_text) return item.display_text; - const textPart = item.parts.find((part) => part.type === 'text' && typeof part.text === 'string'); - return typeof textPart?.text === 'string' ? textPart.text : ''; -} - function getGoalBannerKey(goal: GoalBannerState | null): string { return goal ? `${goal.status}:${goal.objective}` : ''; } @@ -1502,11 +1563,23 @@ export default function SessionChat({ const [compactingMessage, setCompactingMessage] = useState(''); const [goalBanner, setGoalBanner] = useState(null); const [dismissedGoalKey, setDismissedGoalKey] = useState(() => readDismissedGoalKey(sessionId)); - const [queuedPrompts, setQueuedPrompts] = useState([]); - const [queueExpanded, setQueueExpanded] = useState(true); - const [editingQueueId, setEditingQueueId] = useState(null); - const [editingQueueText, setEditingQueueText] = useState(''); - const [queueActionId, setQueueActionId] = useState(null); + const { + items: queuedPrompts, + expanded: queueExpanded, + setExpanded: setQueueExpanded, + editingId: editingQueueId, + editingText: editingQueueText, + setEditingText: setEditingQueueText, + actionId: queueActionId, + refresh: fetchPromptQueue, + applyItems: applyPromptQueueItems, + enqueue: enqueuePrompt, + startEdit: startQueuedEdit, + cancelEdit: cancelQueuedEdit, + saveEdit: saveQueuedEdit, + remove: removeQueuedPrompt, + runNow: runQueuedPromptNow, + } = useSessionPromptQueue(sessionId); const [processGroupOpenState, setProcessGroupOpenState] = useState(() => ( readProcessGroupOpenState(sessionId) )); @@ -1569,12 +1642,14 @@ export default function SessionChat({ const [editingRole, setEditingRole] = useState(null); const [editingText, setEditingText] = useState(''); const [actionMessageId, setActionMessageId] = useState(null); - const [contextUsageSnapshot, setContextUsageSnapshot] = useState(null); - const [contextUsageRefreshing, setContextUsageRefreshing] = useState(false); - const [contextUsageWindowTokens, setContextUsageWindowTokens] = useState(0); - const contextUsageRequestRef = useRef<{ sessionId: string; promise: Promise } | null>(null); - const contextUsageRequestSeqRef = useRef(0); - const lastContextUsagePushAtRef = useRef(0); + const { + snapshot: contextUsageSnapshot, + refreshing: contextUsageRefreshing, + contextWindowTokens: contextUsageWindowTokens, + refresh: refreshContextUsage, + applyPushSnapshot: applyContextUsagePushSnapshot, + stopRefreshing: stopContextUsageRefreshing, + } = useSessionContextUsage(sessionId); const isCompactingRef = useRef(false); const prevStreamingRef = useRef(false); // Tracks "sessionId::message" key to prevent double-send in React StrictMode @@ -1599,6 +1674,7 @@ export default function SessionChat({ const messagesContentRef = useRef(null); const scrollContainerRef = useRef(null); const isAtBottomRef = useRef(true); + const scrollToBottomRafRef = useRef(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const isComposingRef = useRef(false); @@ -1608,7 +1684,8 @@ export default function SessionChat({ const [showCommandDropdown, setShowCommandDropdown] = useState(false); const [commandQuery, setCommandQuery] = useState(''); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); - const commandsLoadedRef = useRef(false); + const commandsLoadedAtRef = useRef(0); + const commandsLoadingRef = useRef(false); const [mentionQuery, setMentionQuery] = useState(''); const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null); const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); @@ -1638,11 +1715,20 @@ export default function SessionChat({ const scrollToBottom = useCallback(() => { if (!isAtBottomRef.current) return; - requestAnimationFrame(() => { + if (scrollToBottomRafRef.current !== null) return; + scrollToBottomRafRef.current = requestAnimationFrame(() => { + scrollToBottomRafRef.current = null; messagesEndRef.current?.scrollIntoView({ behavior: 'instant' }); }); }, []); + useEffect(() => () => { + if (scrollToBottomRafRef.current !== null) { + cancelAnimationFrame(scrollToBottomRafRef.current); + scrollToBottomRafRef.current = null; + } + }, []); + const loadOlderMessagesRef = useRef<(() => Promise) | null>(null); const hasMoreMessagesRef = useRef(false); const loadingOlderMessagesRef = useRef(false); @@ -1719,92 +1805,6 @@ export default function SessionChat({ const sseEnabled = Boolean(sessionId) && (live || isStreaming || !hideInput); - const fetchPromptQueue = useCallback(async () => { - if (!sessionId) { - setQueuedPrompts([]); - return; - } - try { - const response = await sessionApi.listPromptQueue(sessionId); - setQueuedPrompts(response.items ?? []); - } catch (err) { - console.warn('[SessionChat] Failed to fetch prompt queue:', err); - } - }, [sessionId]); - - const refreshContextUsage = useCallback((options?: { clear?: boolean; skipIfFreshMs?: number }) => { - if (!sessionId) { - setContextUsageSnapshot(null); - setContextUsageRefreshing(false); - setContextUsageWindowTokens(0); - contextUsageRequestSeqRef.current += 1; - contextUsageRequestRef.current = null; - lastContextUsagePushAtRef.current = 0; - return; - } - if (options?.clear) { - setContextUsageSnapshot(null); - setContextUsageRefreshing(true); - contextUsageRequestSeqRef.current += 1; - contextUsageRequestRef.current = null; - lastContextUsagePushAtRef.current = 0; - } else if ( - options?.skipIfFreshMs && - Date.now() - lastContextUsagePushAtRef.current < options.skipIfFreshMs - ) { - return; - } - - const existingRequest = contextUsageRequestRef.current; - if (existingRequest?.sessionId === sessionId) { - return existingRequest.promise; - } - - const requestSessionId = sessionId; - const requestSeq = contextUsageRequestSeqRef.current; - const request = sessionApi.getContextUsage(requestSessionId).then((snapshot) => { - if (requestSeq === contextUsageRequestSeqRef.current && snapshot.sessionID === sessionId) { - setContextUsageSnapshot(snapshot); - if (snapshot.contextWindow && snapshot.contextWindow > 0) { - setContextUsageWindowTokens(snapshot.contextWindow); - } - setContextUsageRefreshing(false); - } - }).catch((err) => { - setContextUsageRefreshing(false); - console.warn('[SessionChat] Failed to fetch context usage:', err); - }).finally(() => { - if (contextUsageRequestRef.current?.promise === request) { - contextUsageRequestRef.current = null; - } - }); - contextUsageRequestRef.current = { sessionId: requestSessionId, promise: request }; - return request; - }, [sessionId]); - - useEffect(() => { - if (!sessionId) { - void refreshContextUsage({ clear: true }); - return; - } - const requestIdle = (window as any).requestIdleCallback as - | ((cb: () => void, options?: { timeout?: number }) => number) - | undefined; - const cancelIdle = (window as any).cancelIdleCallback as - | ((id: number) => void) - | undefined; - if (requestIdle) { - const idleId = requestIdle(() => { - void refreshContextUsage({ clear: true }); - }, { timeout: 1500 }); - return () => cancelIdle?.(idleId); - } - const timer = window.setTimeout(() => { - void refreshContextUsage({ clear: true }); - }, 250); - return () => window.clearTimeout(timer); - }, [refreshContextUsage]); - useEffect(() => { goalHydrationVersionRef.current += 1; const hydrationVersion = goalHydrationVersionRef.current; @@ -1832,184 +1832,174 @@ export default function SessionChat({ const handleSSEEvent = useCallback( (event: SSEChatEvent) => { - const { type, properties } = event; - - // Forward events with payload to parent (e.g. session.updated, workflow.updated). - // Skip empty events like heartbeats to avoid noisy callbacks. - if (properties) onSSEEvent?.(event); - - if (!properties || !sessionId) return; - - if (type === 'session.cleared' && properties.sessionID === sessionId) { - abortingRef.current = false; - sessionBusyRef.current = false; - activeToolPartIdsRef.current.clear(); - abortedMessageIdRef.current = null; - suppressStreamingUntilIdleRef.current = false; - setContextUsageSnapshot(null); - setContextUsageRefreshing(true); - setContextUsageWindowTokens(0); - setIsStreaming(false); - setGoalBanner(null); - setDismissedGoalKey(''); - refetch(); - void refreshContextUsage({ clear: true }); - } else if ( - (type === 'session.status' && properties.sessionID === sessionId) - || (type === 'session.updated' && properties.id === sessionId && properties.status === 'idle') - ) { - const statusType = type === 'session.status' ? properties.status?.type : properties.status; - if (statusType === 'busy') { - sessionBusyRef.current = true; - if ( - !abortingRef.current && - !suppressStreamingUntilIdleRef.current - ) setIsStreaming(true); - setIsCompacting(false); - isCompactingRef.current = false; - } else if (statusType === 'compacting') { - sessionBusyRef.current = true; - if ( - !abortingRef.current && - !suppressStreamingUntilIdleRef.current - ) setIsStreaming(true); - setIsCompacting(true); - isCompactingRef.current = true; - setCompactingMessage(properties.status?.message || t('chat.compacting')); - // Reset progress state on each new compaction cycle so a stale - // run's stages do not leak into a fresh "Compacting..." panel. - setCompactionStages([]); - } else if (statusType === 'idle') { + // Forward only global events or events relevant to this chat. The global + // stream can be very noisy when multiple sessions run in parallel. + if (shouldForwardSSEEventToParent(event, sessionId)) onSSEEvent?.(event); + + const action = resolveSessionChatSSEAction(event, sessionId); + + switch (action.kind) { + case 'ignore': + return; + case 'session-cleared': + abortingRef.current = false; sessionBusyRef.current = false; - suppressStreamingUntilIdleRef.current = false; activeToolPartIdsRef.current.clear(); + abortedMessageIdRef.current = null; + suppressStreamingUntilIdleRef.current = false; setIsStreaming(false); - setIsCompacting(false); - isCompactingRef.current = false; - setCompactingMessage(''); - setCompactionStages([]); + setGoalBanner(null); + setDismissedGoalKey(''); refetch(); - void refreshContextUsage({ skipIfFreshMs: 500 }); - } - } else if (type === 'message.updated' && properties.info?.sessionID === sessionId) { - updateMessage(properties.info); - if ( - properties.info.role === 'assistant' && - (abortingRef.current || suppressStreamingUntilIdleRef.current) - ) { - abortedMessageIdRef.current = properties.info.id; - markMessageStopped(properties.info.id); - setIsStreaming(false); - setSending(false); - if (properties.info.finish || properties.info.time?.completed) { - void refreshContextUsage(); - } - } else if (properties.info.finish || properties.info.time?.completed) { - const shouldRefetch = shouldRefetchFinishedMessage({ - finishedMessageId: properties.info.id, - abortedMessageId: abortedMessageIdRef.current, - }); - // Preserve locally streamed partial text when the user aborts. The - // backend never persists in-flight text chunks, so refetching here - // would replace the visible partial response with an empty message. - if (shouldRefetch) { + void refreshContextUsage({ clear: true }); + return; + case 'session-status': + if (action.statusType === 'busy') { + sessionBusyRef.current = true; + if ( + !abortingRef.current && + !suppressStreamingUntilIdleRef.current + ) setIsStreaming(true); + setIsCompacting(false); + isCompactingRef.current = false; + } else if (action.statusType === 'compacting') { + sessionBusyRef.current = true; + if ( + !abortingRef.current && + !suppressStreamingUntilIdleRef.current + ) setIsStreaming(true); + setIsCompacting(true); + isCompactingRef.current = true; + setCompactingMessage(action.message || t('chat.compacting')); + // Reset progress state on each new compaction cycle so a stale + // run's stages do not leak into a fresh "Compacting..." panel. + setCompactionStages([]); + } else if (action.statusType === 'idle') { + sessionBusyRef.current = false; + suppressStreamingUntilIdleRef.current = false; + activeToolPartIdsRef.current.clear(); + setIsStreaming(false); + setIsCompacting(false); + isCompactingRef.current = false; + setCompactingMessage(''); + setCompactionStages([]); refetch(); - if (!sessionBusyRef.current && activeToolPartIdsRef.current.size === 0) { - setIsStreaming(false); + void refreshContextUsage({ skipIfFreshMs: 500 }); + } + return; + case 'message-updated': { + const { info } = action; + updateMessage(info); + if ( + info.role === 'assistant' && + (abortingRef.current || suppressStreamingUntilIdleRef.current) + ) { + if (info.id) { + abortedMessageIdRef.current = info.id; + markMessageStopped(info.id); + } + setIsStreaming(false); + setSending(false); + if (info.finish || info.time?.completed) { + void refreshContextUsage(); + } + } else if (info.finish || info.time?.completed) { + const shouldRefetch = shouldRefetchFinishedMessage({ + finishedMessageId: info.id, + abortedMessageId: abortedMessageIdRef.current, + }); + // Preserve locally streamed partial text when the user aborts. The + // backend never persists in-flight text chunks, so refetching here + // would replace the visible partial response with an empty message. + if (shouldRefetch) { + refetch(); + if (!sessionBusyRef.current && activeToolPartIdsRef.current.size === 0) { + setIsStreaming(false); + } } + void refreshContextUsage(); + abortingRef.current = false; + abortedMessageIdRef.current = null; + } else if ( + info.role === 'assistant' && + !info.finish && + !abortingRef.current + ) { + setIsStreaming(true); } - void refreshContextUsage(); - abortingRef.current = false; - abortedMessageIdRef.current = null; - } else if ( - properties.info.role === 'assistant' && - !properties.info.finish && - !abortingRef.current - ) { - setIsStreaming(true); + return; } - } else if (type === 'message.part.updated' && properties.part?.sessionID === sessionId) { - const part = properties.part as Pick; - if (part.id) { - if (isActiveToolPart(part)) { - activeToolPartIdsRef.current.add(part.id); - if (!abortingRef.current && !suppressStreamingUntilIdleRef.current) setIsStreaming(true); - } else { - activeToolPartIdsRef.current.delete(part.id); + case 'message-part-updated': { + const part = action.part as Pick; + if (part.id) { + if (isActiveToolPart(part)) { + activeToolPartIdsRef.current.add(part.id); + if (!abortingRef.current && !suppressStreamingUntilIdleRef.current) setIsStreaming(true); + } else { + activeToolPartIdsRef.current.delete(part.id); + } } - } - updateMessagePart(properties.part, properties.delta); - scrollToBottom(); - } else if (type === 'question.asked' && properties.sessionID === sessionId) { - const callID: string | undefined = properties.tool?.callID; - const requestId: string | undefined = properties.id; - if (callID && requestId) { - handleQuestionAsked(callID, requestId, properties.questions || []); + updateMessagePart(action.part, action.delta); scrollToBottom(); + return; } - } else if ( - (type === 'question.replied' || type === 'question.rejected') && - properties.sessionID === sessionId - ) { - const requestId: string | undefined = properties.requestID; - if (requestId) { - removeByRequestId(requestId); - } - } else if (type === 'session.compaction_progress' && properties.sessionID === sessionId) { - const stage = properties.stage as CompactionStage | undefined; - const data = (properties.data ?? {}) as Record; - if (!stage) return; - if (stage === 'complete' && data.result === 'continue') { - void refreshContextUsage({ skipIfFreshMs: 500 }); - } - // Single source of truth: append into ``compactionStages`` and let - // the progress bar derive ``done/total`` from it via useMemo. - // ``chunk_done`` arrives in non-deterministic order under - // ``asyncio.gather``; deduplicate by chunk index here so SSE - // reconnects / accidental re-deliveries are idempotent. - setCompactionStages((prev) => { - if (stage === 'chunk_done') { - const chunkIdx = typeof data.chunk === 'number' ? data.chunk : undefined; - if (chunkIdx !== undefined && prev.some( - (e) => e.stage === 'chunk_done' && (e.data as { chunk?: number }).chunk === chunkIdx, - )) { - return prev; + case 'question-asked': + handleQuestionAsked(action.callID, action.requestId, action.questions as QuestionItem[]); + scrollToBottom(); + return; + case 'question-resolved': + removeByRequestId(action.requestId); + return; + case 'compaction-progress': + if (action.stage === 'complete' && action.data.result === 'continue') { + void refreshContextUsage({ skipIfFreshMs: 500 }); + } + // Single source of truth: append into ``compactionStages`` and let + // the progress bar derive ``done/total`` from it via useMemo. + // ``chunk_done`` arrives in non-deterministic order under + // ``asyncio.gather``; deduplicate by chunk index here so SSE + // reconnects / accidental re-deliveries are idempotent. + setCompactionStages((prev) => { + if (action.stage === 'chunk_done') { + const chunkIdx = typeof action.data.chunk === 'number' ? action.data.chunk : undefined; + if (chunkIdx !== undefined && prev.some( + (e) => e.stage === 'chunk_done' && (e.data as { chunk?: number }).chunk === chunkIdx, + )) { + return prev; + } } + return [...prev, { stage: action.stage, data: action.data, ts: Date.now() }]; + }); + return; + case 'prompt-queue-updated': + applyPromptQueueItems(action.items); + return; + case 'goal-updated': { + const nextGoal = toGoalBannerState(action.goal); + if (nextGoal) { + goalHydrationVersionRef.current += 1; + setGoalBanner(nextGoal); + setDismissedGoalKey(readDismissedGoalKey(sessionId)); } - return [...prev, { stage, data, ts: Date.now() }]; - }); - } else if (type === 'session.prompt_queue.updated' && properties.sessionID === sessionId) { - const items = Array.isArray(properties.items) ? properties.items : []; - setQueuedPrompts(items as QueuedPrompt[]); - if (items.length > 0) setQueueExpanded(true); - } else if (type === 'session.goal.updated' && properties.sessionID === sessionId) { - const nextGoal = toGoalBannerState(properties as SessionGoalState); - if (nextGoal) { - goalHydrationVersionRef.current += 1; - setGoalBanner(nextGoal); - setDismissedGoalKey(readDismissedGoalKey(sessionId)); - } - } else if (type === 'context.compacted' && properties.sessionID === sessionId) { - void refreshContextUsage({ skipIfFreshMs: 500 }); - } else if (type === 'context.usage.updated' && properties.sessionID === sessionId) { - setContextUsageSnapshot(properties as ContextUsageSnapshot); - if (typeof properties.contextWindow === 'number' && properties.contextWindow > 0) { - setContextUsageWindowTokens(properties.contextWindow); + return; } - contextUsageRequestSeqRef.current += 1; - contextUsageRequestRef.current = null; - lastContextUsagePushAtRef.current = Date.now(); - setContextUsageRefreshing(false); - } else if (type === 'session.error' && properties.sessionID === sessionId) { - setIsStreaming(false); - setIsCompacting(false); - setCompactionStages([]); - setContextUsageRefreshing(false); - void refreshContextUsage({ skipIfFreshMs: 500 }); - abortingRef.current = false; - sessionBusyRef.current = false; - activeToolPartIdsRef.current.clear(); - onError?.(properties.error?.message || t('chat.placeholder')); + case 'context-compacted': + void refreshContextUsage({ skipIfFreshMs: 500 }); + return; + case 'context-usage-updated': + applyContextUsagePushSnapshot(action.snapshot); + return; + case 'session-error': + setIsStreaming(false); + setIsCompacting(false); + setCompactionStages([]); + stopContextUsageRefreshing(); + void refreshContextUsage({ skipIfFreshMs: 500 }); + abortingRef.current = false; + sessionBusyRef.current = false; + activeToolPartIdsRef.current.clear(); + onError?.(action.message || t('chat.placeholder')); + return; } }, [ @@ -2018,11 +2008,15 @@ export default function SessionChat({ updateMessagePart, refetch, refreshContextUsage, + applyContextUsagePushSnapshot, + stopContextUsageRefreshing, handleQuestionAsked, removeByRequestId, + applyPromptQueueItems, onSSEEvent, onError, scrollToBottom, + t, ], ); @@ -2125,11 +2119,6 @@ export default function SessionChat({ setCompactionStages([]); setGoalBanner(null); setDismissedGoalKey(''); - setQueuedPrompts([]); - setEditingQueueId(null); - setEditingQueueText(''); - setQueueActionId(null); - setContextUsageWindowTokens(0); setMentionRange(null); setMentionQuery(''); setSelectedMentionIndex(0); @@ -2237,32 +2226,17 @@ export default function SessionChat({ return () => { if (timer) clearTimeout(timer); }; }, [isCompacting, sessionId, refetch]); - /** Lazily load slash commands on first use (for autocomplete dropdown). */ + /** Lazily load slash commands and periodically revalidate while autocomplete is used. */ const loadCommandsIfNeeded = useCallback(async (): Promise => { - if (commandsLoadedRef.current) return; - commandsLoadedRef.current = true; // Optimistic: prevent concurrent fetches + if (commandsLoadingRef.current || Date.now() - commandsLoadedAtRef.current < 5_000) return; + commandsLoadingRef.current = true; try { - const res = await commandAPI.list(); - const serverCommands = res.data ?? []; - // Merge client-side /new command into the autocomplete list - setCommands([ - { - name: 'new', - canonical_name: 'new', - description: 'Create a new session', - template: '', - hidden: false, - aliases: [], - visible_surfaces: [], - execution_kind: 'session_control', - allow_attachments: false, - requires_existing_session: false, - channel_safe: false, - } satisfies Command, - ...serverCommands, - ]); + setCommands(await fetchSessionChatCommands()); + commandsLoadedAtRef.current = Date.now(); } catch { - commandsLoadedRef.current = false; // Allow retry on failure + commandsLoadedAtRef.current = 0; + } finally { + commandsLoadingRef.current = false; } }, []); @@ -2610,14 +2584,12 @@ export default function SessionChat({ if (!sessionId) return; const effectiveAgent = agentOverride || agentName; try { - await sessionApi.enqueuePrompt(sessionId, { + await enqueuePrompt({ parts: buildPromptParts(text, imageParts), ...(effectiveAgent ? { agent: effectiveAgent } : {}), ...(model ? { model } : {}), ...(options?.displayText ? { displayText: options.displayText } : {}), }); - await fetchPromptQueue(); - setQueueExpanded(true); } catch (err: any) { const statusCode = err?.response?.status; const detail = err?.response?.data?.detail; @@ -2904,62 +2876,41 @@ export default function SessionChat({ }, [isStreaming, markMessageStopped, sending, sessionId]); const handleQueuedEditStart = useCallback((item: QueuedPrompt) => { - setEditingQueueId(item.id); - setEditingQueueText(getQueuedPromptText(item)); - }, []); + startQueuedEdit(item); + }, [startQueuedEdit]); const handleQueuedEditCancel = useCallback(() => { - setEditingQueueId(null); - setEditingQueueText(''); - }, []); + cancelQueuedEdit(); + }, [cancelQueuedEdit]); const handleQueuedEditSave = useCallback(async (item: QueuedPrompt) => { - if (!sessionId) return; - const text = editingQueueText.trim(); - if (!text) return; - setQueueActionId(item.id); try { - await sessionApi.updateQueuedPrompt(sessionId, item.id, text); - handleQueuedEditCancel(); - await fetchPromptQueue(); + await saveQueuedEdit(item); } catch (err: any) { toast.error(err?.response?.data?.detail || err?.message || t('chat.queue.updateFailed')); - } finally { - setQueueActionId(null); } - }, [editingQueueText, fetchPromptQueue, handleQueuedEditCancel, sessionId, t, toast]); + }, [saveQueuedEdit, t, toast]); const handleQueuedRemove = useCallback(async (item: QueuedPrompt) => { - if (!sessionId) return; - setQueueActionId(item.id); try { - await sessionApi.removeQueuedPrompt(sessionId, item.id); - if (editingQueueId === item.id) handleQueuedEditCancel(); - await fetchPromptQueue(); + await removeQueuedPrompt(item); } catch (err: any) { toast.error(err?.response?.data?.detail || err?.message || t('chat.queue.removeFailed')); - } finally { - setQueueActionId(null); } - }, [editingQueueId, fetchPromptQueue, handleQueuedEditCancel, sessionId, t, toast]); + }, [removeQueuedPrompt, t, toast]); const handleQueuedRunNow = useCallback(async (item: QueuedPrompt) => { - if (!sessionId) return; - setQueueActionId(item.id); try { - await sessionApi.runQueuedPromptNow(sessionId, item.id); - if (editingQueueId === item.id) handleQueuedEditCancel(); - await fetchPromptQueue(); + const didRun = await runQueuedPromptNow(item); + if (!didRun) return; abortingRef.current = false; abortedMessageIdRef.current = null; suppressStreamingUntilIdleRef.current = false; setIsStreaming(true); } catch (err: any) { toast.error(err?.response?.data?.detail || err?.message || t('chat.queue.runNowFailed')); - } finally { - setQueueActionId(null); } - }, [editingQueueId, fetchPromptQueue, handleQueuedEditCancel, sessionId, t, toast]); + }, [runQueuedPromptNow, t, toast]); // Fire onStreamingDone when isStreaming transitions true → false useEffect(() => { @@ -3150,6 +3101,10 @@ export default function SessionChat({ return { merged, skipIndices }; }, [messages]); + const timelineItems = useMemo(() => ( + buildChatTimelineItems({ messages: merged, skipIndices, isStreaming }) + ), [isStreaming, merged, skipIndices]); + const { historyItems, tailItems } = useStableChatTimelineSegments(timelineItems); // ── Styling based on compact mode ── const msgAreaClass = compact @@ -3210,44 +3165,56 @@ export default function SessionChat({
)} - {merged.map((msg, i) => { - if (skipIndices.has(i)) return null; - const isActiveMessage = - isStreaming && - i === merged.length - 1 && - msg.role === 'assistant' && - !msg.finish; - if (!shouldRenderMessage(msg, { isActive: isActiveMessage })) return null; - return ( - - ); - })} + + {/* Compacting indicator with live progress stages */} {isCompacting && ( @@ -3795,6 +3762,72 @@ export interface ChatMessageBubbleProps { onRegenerate?: (messageId: string) => Promise; } +interface ChatMessageTimelineProps extends Omit { + items: ChatTimelineItem[]; +} + +function ChatMessageTimelineInner({ + items, + pendingQuestions, + onQuestionAnswer, + onQuestionReject, + showActions, + showTimestamp, + collapseIntermediateSteps, + processGroupsDefaultOpen, + processGroupsOpenWhileActive, + processGroupOpenState, + onProcessGroupOpenChange, + compact, + onCopy, + editingMessageId, + editingText, + actionsDisabled, + actionMessageId, + onEditStart, + onEditChange, + onEditCancel, + onEditSave, + onEditSend, + onRegenerate, +}: ChatMessageTimelineProps) { + return ( + <> + {items.map(({ message, isActive }) => ( + + ))} + + ); +} + +export const ChatMessageTimeline = memo(ChatMessageTimelineInner); + function ProcessGroupDetails({ defaultOpen, open, @@ -3874,7 +3907,7 @@ function ChatMessageBubbleInner({ const { t } = useTranslation('session'); const isUser = message.role === 'user'; const parts: MessagePart[] = Array.isArray(message.parts) ? message.parts : []; - const { getPartExpanded, togglePart, isReasoningDone } = useReasoningToggle(parts, message.finish); + const { getPartExpanded, togglePart } = useReasoningToggle(parts, message.finish); // Lightbox state for inline image previews. Browsers block top-level // navigation to ``data:`` URLs (the format we send for chat images), so a // ``window.open`` would land on a blank page. We open an in-app overlay @@ -4004,7 +4037,10 @@ function ChatMessageBubbleInner({ if (part.type === 'file') return !!part.url; return false; }; - const renderPart = (part: MessagePart, i: number) => ( + const activeTailPart = isActive + ? [...displayParts].reverse().find(isRenderableDisplayPart) + : undefined; + const renderPart = (part: MessagePart, i: number, isVisible = true) => ( // Spacing between consecutive parts is owned by this wrapper, // not by individual part components. Each part used to set its // own `mt-2 first:mt-0`, but since every part lives in its own @@ -4065,8 +4101,8 @@ function ChatMessageBubbleInner({ const thinkingText = getRenderableThinkingText(part); if (!thinkingText) return null; const partKey = part.id || `reasoning-${i}`; - const isExpanded = getPartExpanded(partKey); - const isThinking = !isReasoningDone; + const isThinking = part === activeTailPart; + const isExpanded = isThinking || getPartExpanded(partKey); return ( // Vertical spacing is provided by the parent part wrapper // (see `otherParts.map` above); keep this container neutral @@ -4099,9 +4135,12 @@ function ChatMessageBubbleInner({ )}
- {isExpanded && ( + {isExpanded && isVisible && (
- {thinkingText} +
)}
@@ -4146,14 +4185,14 @@ function ChatMessageBubbleInner({ )} >
- {group.map(({ part, index }) => renderPart(part, index))} + {group.map(({ part, index }) => renderPart(part, index, effectiveProcessGroupOpen))}
); }; const renderDisplayParts = () => { if (!collapseIntermediateSteps || isUser) { - return displayParts.map(renderPart); + return displayParts.map((part, index) => renderPart(part, index)); } const nodes: React.ReactNode[] = []; let processGroup: Array<{ part: MessagePart; index: number }> = []; diff --git a/webui/src/components/common/StreamingMarkdown.test.tsx b/webui/src/components/common/StreamingMarkdown.test.tsx index 6b85a6931..27af58ed8 100644 --- a/webui/src/components/common/StreamingMarkdown.test.tsx +++ b/webui/src/components/common/StreamingMarkdown.test.tsx @@ -1,39 +1,50 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, render } from '@testing-library/react'; -import { StreamingMarkdown, useStreamingContent } from './StreamingMarkdown'; +import { + StreamingMarkdown, + fallbackSplitStreamingGraphemes, + splitStreamingGraphemes, + useStreamingContent, +} from './StreamingMarkdown'; // ─── rAF fake setup ────────────────────────────────────────────────────────── type RafCallback = (time: number) => void; -let rafQueue: RafCallback[] = []; +let rafQueue = new Map(); let rafIdCounter = 0; +let rafTime = 0; function setupFakeRaf() { vi.stubGlobal('requestAnimationFrame', (cb: RafCallback) => { - rafIdCounter++; - rafQueue.push(cb); - return rafIdCounter; + const id = ++rafIdCounter; + rafQueue.set(id, cb); + return id; }); vi.stubGlobal('cancelAnimationFrame', (id: number) => { - // Mark cancelled by removing; simplified — good enough for these tests - rafQueue = rafQueue.filter((_, i) => i !== id - 1); + rafQueue.delete(id); }); } -function flushRaf() { - const pending = [...rafQueue]; - rafQueue = []; - pending.forEach(cb => cb(performance.now())); +function flushRafAt(time: number) { + rafTime = time; + const pending = [...rafQueue.values()]; + rafQueue.clear(); + pending.forEach(cb => cb(time)); +} + +function flushRaf(stepMs = 1000 / 60) { + flushRafAt(rafTime + stepMs); } // ─── Tests ─────────────────────────────────────────────────────────────────── describe('useStreamingContent', () => { beforeEach(() => { - rafQueue = []; + rafQueue = new Map(); rafIdCounter = 0; + rafTime = 0; setupFakeRaf(); }); @@ -86,8 +97,9 @@ describe('useStreamingContent', () => { it('streaming: multiple content updates in same frame only trigger one rAF', () => { const rafSpy = vi.fn().mockImplementation((cb: RafCallback) => { - rafQueue.push(cb); - return ++rafIdCounter; + const id = ++rafIdCounter; + rafQueue.set(id, cb); + return id; }); vi.stubGlobal('requestAnimationFrame', rafSpy); @@ -105,7 +117,7 @@ describe('useStreamingContent', () => { }); it('streaming→done: cancels pending rAF and applies final content immediately', () => { - const cancelSpy = vi.fn(); + const cancelSpy = vi.fn((id: number) => rafQueue.delete(id)); vi.stubGlobal('cancelAnimationFrame', cancelSpy); const { result, rerender } = renderHook( @@ -121,15 +133,19 @@ describe('useStreamingContent', () => { expect(cancelSpy).toHaveBeenCalled(); expect(result.current).toBe('chunk1 chunk2 final'); + + act(() => { flushRaf(); }); + expect(result.current).toBe('chunk1 chunk2 final'); }); - it('streaming: drains queued deltas progressively instead of jumping to latest content', () => { + it('streaming: types a small English backlog one character per frame', () => { const { result, rerender } = renderHook( ({ content, isStreaming }) => useStreamingContent(content, isStreaming), { initialProps: { content: 'a', isStreaming: true } }, ); - // Multiple updates before the frame fires + // Multiple updates before the frame fires still retain a typewriter-sized + // first step rather than jumping to the latest accumulated snapshot. act(() => { rerender({ content: 'ab', isStreaming: true }); }); act(() => { rerender({ content: 'abc', isStreaming: true }); }); act(() => { rerender({ content: 'abcd', isStreaming: true }); }); @@ -145,7 +161,27 @@ describe('useStreamingContent', () => { expect(result.current).toBe('abcd'); }); - it('streaming: catches up large backlogs within a bounded number of frames', () => { + it('streaming: types a small Chinese backlog one character per frame', () => { + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: '你', isStreaming: true } }, + ); + + act(() => { + rerender({ content: '你好世界', isStreaming: true }); + }); + + act(() => { flushRaf(); }); + expect(result.current).toBe('你好'); + + act(() => { flushRaf(); }); + expect(result.current).toBe('你好世'); + + act(() => { flushRaf(); }); + expect(result.current).toBe('你好世界'); + }); + + it('streaming: starts a large backlog with one character, then accelerates in bounded steps', () => { const fullContent = `a${'b'.repeat(120)}`; const { result, rerender } = renderHook( ({ content, isStreaming }) => useStreamingContent(content, isStreaming), @@ -159,10 +195,16 @@ describe('useStreamingContent', () => { act(() => { flushRaf(); }); - expect(result.current.length).toBeGreaterThan('a'.length); - expect(result.current.length).toBeLessThan(fullContent.length); + expect(result.current).toBe('ab'); + + act(() => { + flushRaf(); + }); + const acceleratedLength = result.current.length; + expect(acceleratedLength).toBeGreaterThan(2); + expect(acceleratedLength - 2).toBeLessThanOrEqual(8); - for (let i = 0; i < 12; i += 1) { + for (let i = 0; i < 90; i += 1) { act(() => { flushRaf(); }); @@ -170,6 +212,102 @@ describe('useStreamingContent', () => { expect(result.current).toBe(fullContent); }); + + it('streaming: advances at nearly the same rate on 60 Hz and 120 Hz displays', () => { + const progressAfter = (frameMs: number) => { + rafQueue.clear(); + rafTime = 0; + const fullContent = `a${'b'.repeat(300)}`; + const hook = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'a', isStreaming: true } }, + ); + + act(() => { hook.rerender({ content: fullContent, isStreaming: true }); }); + const frameCount = Math.round(500 / frameMs); + for (let frame = 1; frame <= frameCount; frame += 1) { + act(() => { flushRafAt(frame * frameMs); }); + } + const length = hook.result.current.length; + hook.unmount(); + return length; + }; + + const progress60Hz = progressAfter(1000 / 60); + const progress120Hz = progressAfter(1000 / 120); + expect(Math.abs(progress60Hz - progress120Hz)).toBeLessThanOrEqual(4); + }); + + it('streaming: clamps a long stalled frame instead of dumping the backlog', () => { + const fullContent = `a${'b'.repeat(300)}`; + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'a', isStreaming: true } }, + ); + + act(() => { rerender({ content: fullContent, isStreaming: true }); }); + act(() => { flushRafAt(1000 / 60); }); + expect(result.current).toBe('ab'); + + act(() => { flushRafAt(5000); }); + expect(result.current.length - 2).toBeGreaterThan(0); + expect(result.current.length - 2).toBeLessThanOrEqual(8); + + const afterStallLength = result.current.length; + act(() => { flushRaf(); }); + expect(result.current.length - afterStallLength).toBeLessThanOrEqual(7); + }); + + it('streaming: never splits a grapheme cluster across frames', () => { + const graphemes = ['A', '👍🏽', '👨‍👩‍👧‍👦', '🇨🇳', 'é']; + const fullContent = graphemes.join(''); + expect(splitStreamingGraphemes(fullContent)).toEqual(graphemes); + + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: graphemes[0], isStreaming: true } }, + ); + act(() => { rerender({ content: fullContent, isStreaming: true }); }); + + for (let index = 2; index <= graphemes.length; index += 1) { + act(() => { flushRaf(); }); + expect(result.current).toBe(graphemes.slice(0, index).join('')); + } + }); + + it('streaming: re-segments an unpainted grapheme split across SSE updates', () => { + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'A', isStreaming: true } }, + ); + + act(() => { rerender({ content: 'A👍', isStreaming: true }); }); + act(() => { rerender({ content: 'A👍🏽', isStreaming: true }); }); + act(() => { flushRaf(); }); + + expect(result.current).toBe('A👍🏽'); + }); + + it('fallback: keeps common compound graphemes intact without Intl.Segmenter', () => { + const graphemes = ['A', '👍🏽', '👨‍👩‍👧‍👦', '🇨🇳', 'é', '1️⃣']; + expect(fallbackSplitStreamingGraphemes(graphemes.join(''))).toEqual(graphemes); + }); + + it('streaming: the final delta and finish settle synchronously with no stale frame', () => { + const { result, rerender } = renderHook( + ({ content, isStreaming }) => useStreamingContent(content, isStreaming), + { initialProps: { content: 'almost', isStreaming: true } }, + ); + + act(() => { + rerender({ content: 'almost done', isStreaming: true }); + rerender({ content: 'almost done', isStreaming: false }); + }); + expect(result.current).toBe('almost done'); + + act(() => { flushRafAt(5000); }); + expect(result.current).toBe('almost done'); + }); }); describe('StreamingMarkdown', () => { diff --git a/webui/src/components/common/StreamingMarkdown.tsx b/webui/src/components/common/StreamingMarkdown.tsx index 5e1a1330a..8f6e4d83d 100644 --- a/webui/src/components/common/StreamingMarkdown.tsx +++ b/webui/src/components/common/StreamingMarkdown.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { memo, useState, useEffect, useRef, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import rehypeRaw from 'rehype-raw'; @@ -12,6 +12,117 @@ const sanitizeSchema = { strip: [...(defaultSchema.strip || []), 'style'], }; +const BASE_STREAMING_GRAPHEMES_PER_SECOND = 60; +const BACKLOG_RATE_BOOST = 4; +const MAX_STREAMING_GRAPHEMES_PER_SECOND = 360; +const MAX_STREAMING_GRAPHEMES_PER_FRAME = 8; +const MAX_DRAIN_ELAPSED_MS = 50; + +interface SegmentData { + segment: string; +} + +interface GraphemeSegmenter { + segment(input: string): Iterable; +} + +type GraphemeSegmenterConstructor = new ( + locales?: string | string[], + options?: { granularity: 'grapheme' }, +) => GraphemeSegmenter; + +const Segmenter = (Intl as typeof Intl & { Segmenter?: GraphemeSegmenterConstructor }).Segmenter; +const graphemeSegmenter = Segmenter + ? new Segmenter(undefined, { granularity: 'grapheme' }) + : null; + +const MARK_PATTERN = /\p{Mark}/u; + +/** Narrow fallback for engines that predate Intl.Segmenter. */ +export function fallbackSplitStreamingGraphemes(value: string): string[] { + const clusters: string[] = []; + let cluster = ''; + let regionalIndicatorCount = 0; + + for (const codePoint of Array.from(value)) { + const code = codePoint.codePointAt(0) ?? 0; + const isRegionalIndicator = code >= 0x1f1e6 && code <= 0x1f1ff; + const isEmojiModifier = code >= 0x1f3fb && code <= 0x1f3ff; + const isVariationSelector = ( + (code >= 0xfe00 && code <= 0xfe0f) + || (code >= 0xe0100 && code <= 0xe01ef) + ); + const isEmojiTag = code >= 0xe0020 && code <= 0xe007f; + const isJoiner = code === 0x200d; + const joinsPrevious = ( + MARK_PATTERN.test(codePoint) + || isEmojiModifier + || isVariationSelector + || isEmojiTag + || isJoiner + || cluster.endsWith('\u200d') + || (isRegionalIndicator && regionalIndicatorCount === 1) + ); + + if (!cluster || joinsPrevious) { + cluster += codePoint; + } else { + clusters.push(cluster); + cluster = codePoint; + } + + if (isRegionalIndicator) { + regionalIndicatorCount = joinsPrevious ? regionalIndicatorCount + 1 : 1; + } else if (!isVariationSelector && !isEmojiModifier && !MARK_PATTERN.test(codePoint)) { + regionalIndicatorCount = 0; + } + } + + if (cluster) clusters.push(cluster); + return clusters; +} + +export function splitStreamingGraphemes(value: string): string[] { + if (!graphemeSegmenter) return fallbackSplitStreamingGraphemes(value); + return Array.from(graphemeSegmenter.segment(value), ({ segment }) => segment); +} + +interface DrainBudget { + count: number; + credit: number; +} + +/** + * Convert elapsed time into a bounded render budget. A small backlog keeps a + * natural typewriter cadence; a growing backlog raises the target rate so the + * UI catches up without dumping an entire model chunk in one paint. + */ +export function getStreamingDrainBudget( + backlogLength: number, + elapsedMs: number, + credit: number, + firstFrame: boolean, +): DrainBudget { + if (backlogLength <= 0) return { count: 0, credit: 0 }; + if (firstFrame) return { count: 1, credit: 0 }; + + const boundedElapsedMs = Math.max(0, Math.min(MAX_DRAIN_ELAPSED_MS, elapsedMs)); + const targetRate = Math.min( + MAX_STREAMING_GRAPHEMES_PER_SECOND, + BASE_STREAMING_GRAPHEMES_PER_SECOND + + Math.max(0, backlogLength - 1) * BACKLOG_RATE_BOOST, + ); + const availableCredit = credit + targetRate * boundedElapsedMs / 1000; + const count = Math.min( + backlogLength, + MAX_STREAMING_GRAPHEMES_PER_FRAME, + Math.floor(availableCredit), + ); + // Preserve only fractional credit. Integer work rejected by the per-frame + // cap must not become a multi-frame burst after a long main-thread stall. + return { count, credit: availableCredit - Math.floor(availableCredit) }; +} + /** * Smooths streamed content by queueing appended text and draining it across * animation frames. The previous implementation collapsed all updates that @@ -23,31 +134,46 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri const pendingRafRef = useRef(null); const incomingContentRef = useRef(content); const displayedContentRef = useRef(content); - const queuedCharsRef = useRef([]); + const queuedGraphemesRef = useRef([]); const isStreamingRef = useRef(isStreaming); + const lastDrainTimeRef = useRef(null); + const drainCreditRef = useRef(0); const scheduleDrain = useCallback((drainQueue: (time: number) => void) => { if (pendingRafRef.current !== null) return; pendingRafRef.current = requestAnimationFrame(drainQueue); }, []); - const drainQueue = useCallback(() => { + const drainQueue = useCallback((time: number) => { pendingRafRef.current = null; - if (queuedCharsRef.current.length === 0) { + if (queuedGraphemesRef.current.length === 0) { + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; return; } - // Drain progressively so a stalled frame does not dump the whole backlog - // in a single repaint. Larger backlogs should still catch up within a - // handful of frames instead of lagging behind for visibly too long. - const charsToRenderCount = Math.max(1, Math.ceil(queuedCharsRef.current.length / 3)); - const nextChunk = queuedCharsRef.current.splice(0, charsToRenderCount).join(''); - displayedContentRef.current += nextChunk; - setDisplayContent(displayedContentRef.current); + const previousDrainTime = lastDrainTimeRef.current; + const budget = getStreamingDrainBudget( + queuedGraphemesRef.current.length, + previousDrainTime === null ? 0 : time - previousDrainTime, + drainCreditRef.current, + previousDrainTime === null, + ); + lastDrainTimeRef.current = time; + drainCreditRef.current = budget.credit; - if (queuedCharsRef.current.length > 0 && isStreamingRef.current) { + if (budget.count > 0) { + const nextChunk = queuedGraphemesRef.current.splice(0, budget.count).join(''); + displayedContentRef.current += nextChunk; + setDisplayContent(displayedContentRef.current); + } + + if (queuedGraphemesRef.current.length > 0 && isStreamingRef.current) { scheduleDrain(drainQueue); + } else { + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; } }, [scheduleDrain]); @@ -60,7 +186,9 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri cancelAnimationFrame(pendingRafRef.current); pendingRafRef.current = null; } - queuedCharsRef.current = []; + queuedGraphemesRef.current = []; + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; incomingContentRef.current = content; displayedContentRef.current = content; setDisplayContent(content); @@ -72,7 +200,9 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri if (!content.startsWith(previousIncoming)) { // Content replaced or rewound: reset immediately to preserve correctness. - queuedCharsRef.current = []; + queuedGraphemesRef.current = []; + lastDrainTimeRef.current = null; + drainCreditRef.current = 0; displayedContentRef.current = content; setDisplayContent(content); return; @@ -81,7 +211,11 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri const delta = content.slice(previousIncoming.length); if (!delta) return; - queuedCharsRef.current.push(...Array.from(delta)); + // The last queued grapheme may be completed by the next SSE delta (for + // example 👍 + 🏽 or an emoji ZWJ sequence). Re-segment the unpainted tail + // together with the new text before it reaches the screen. + const queuedTail = queuedGraphemesRef.current.pop() ?? ''; + queuedGraphemesRef.current.push(...splitStreamingGraphemes(queuedTail + delta)); scheduleDrain(drainQueue); }, [content, isStreaming, drainQueue, scheduleDrain]); @@ -95,7 +229,9 @@ export function useStreamingContent(content: string, isStreaming: boolean): stri [], ); - return displayContent; + // Completion must win in the same paint as the finish event. The effect + // above still clears queued work, but rendering does not wait for it. + return isStreaming ? displayContent : content; } export interface StreamingMarkdownProps { @@ -110,9 +246,7 @@ export interface StreamingMarkdownProps { * Content updates are throttled via requestAnimationFrame while streaming, * limiting ReactMarkdown re-parses to ~60fps instead of every SSE chunk. */ -export function StreamingMarkdown({ content, isStreaming }: StreamingMarkdownProps) { - const displayContent = useStreamingContent(content, isStreaming); - +const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) { return (
- {displayContent} + {content}
); +}); + +export function StreamingMarkdown({ content, isStreaming }: StreamingMarkdownProps) { + const displayContent = useStreamingContent(content, isStreaming); + return ; } diff --git a/webui/src/components/common/UpdateModal.test.tsx b/webui/src/components/common/UpdateModal.test.tsx new file mode 100644 index 000000000..67e4cda8e --- /dev/null +++ b/webui/src/components/common/UpdateModal.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import UpdateModal from './UpdateModal'; + +const { applyUpdate, checkUpdate } = vi.hoisted(() => ({ + applyUpdate: vi.fn(), + checkUpdate: vi.fn(), +})); + +vi.mock('@/api/update', () => ({ + applyUpdate, + checkUpdate, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'zh-CN' }, + }), +})); + +const currentVersion = { + current_version: '2026.07.10', + latest_version: '2026.07.10', + has_update: false, + release_notes: null, + release_url: null, + error: null, +}; + +describe('UpdateModal update checks', () => { + beforeEach(() => { + vi.clearAllMocks(); + checkUpdate.mockResolvedValue(currentVersion); + }); + + it('forces the initial request when Layout opens a manual check', async () => { + render( + , + ); + + await waitFor(() => { + expect(checkUpdate).toHaveBeenCalledWith('zh-CN', 'flocks', true); + }); + }); + + it('forces explicit refreshes from the modal', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'checkUpdate' })); + + await waitFor(() => { + expect(checkUpdate).toHaveBeenCalledWith('zh-CN', 'flocks', true); + }); + }); +}); diff --git a/webui/src/components/common/UpdateModal.tsx b/webui/src/components/common/UpdateModal.tsx index ab312d31f..bef95e371 100644 --- a/webui/src/components/common/UpdateModal.tsx +++ b/webui/src/components/common/UpdateModal.tsx @@ -53,13 +53,21 @@ function formatBytes(value?: number | null): string { interface UpdateModalProps { initialInfo?: VersionInfo | null; + forceInitialCheck?: boolean; edition?: UpdateEdition; canUpgrade?: boolean; onClose: () => void; onDismiss?: () => void; } -export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrade = true, onClose, onDismiss }: UpdateModalProps) { +export default function UpdateModal({ + initialInfo, + forceInitialCheck = false, + edition = 'flocks', + canUpgrade = true, + onClose, + onDismiss, +}: UpdateModalProps) { const { t, i18n } = useTranslation('update'); const [info, setInfo] = useState(initialInfo ?? null); const [checking, setChecking] = useState(false); @@ -83,11 +91,11 @@ export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrad setRestarting(val); }; - const fetchVersion = useCallback(async () => { + const fetchVersion = useCallback(async (force = false) => { setChecking(true); setError(null); try { - const data = await checkUpdate(i18n.language, edition); + const data = await checkUpdate(i18n.language, edition, force); setInfo(data); if (data.error) setError(data.error); } catch (e: any) { @@ -99,9 +107,9 @@ export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrad useEffect(() => { if (!initialInfo) { - fetchVersion(); + void fetchVersion(forceInitialCheck); } - }, [fetchVersion, initialInfo]); + }, [fetchVersion, forceInitialCheck, initialInfo]); const handleUpgrade = useCallback(async () => { if (!info?.has_update) return; @@ -407,7 +415,7 @@ export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrad
+
+ + {open && !disabled && ( +
+ {visibleOptions.map((option) => ( + + ))} +
+ )} +
+ ); +} + function SecretInput({ value, onChange, @@ -455,7 +744,9 @@ const CHANNEL_ICON_SRC: Record = { wecom: '/channel-wecom.png', dingtalk: '/channel-dingtalk.png', telegram: '/channel-telegram.png', + email: '/channel-email.png', weixin: '/channel-weixin.png', + whatsapp: '/channel-whatsapp.png', }; const FEISHU_GUIDE_PDF_URL = '/feishu-bot-guide.pdf'; @@ -644,6 +935,7 @@ function ConnectionStatusPanel({ status, config, channelId }: ConnectionStatusPa {channelId === 'dingtalk' && 'Stream'} {channelId === 'weixin' && 'Long-Poll'} {channelId === 'telegram' && ((config as TelegramChannelConfig).mode === 'webhook' ? 'Webhook' : 'Polling')} + {channelId === 'email' && 'IMAP Polling'}
@@ -1396,6 +1688,549 @@ function TelegramPanel({ config, onChange, onRefresh }: TelegramPanelProps) { ); } +// ============================================================================ +// Email Config Panel +// ============================================================================ + +interface EmailPanelProps { + config: EmailChannelConfig; + onChange: (c: EmailChannelConfig) => void; +} + +function EmailPanel({ config, onChange }: EmailPanelProps) { + const { t } = useTranslation('channel'); + const set = useCallback( + (key: K, value: EmailChannelConfig[K]) => + onChange({ ...config, [key]: value }), + [config, onChange] + ); + const emailHostPreset = getEmailHostPreset(config.address); + const showImapHostWarning = isEmailHostMismatch(config.address, config.imapHost, 'imap'); + const showSmtpHostWarning = isEmailHostMismatch(config.address, config.smtpHost, 'smtp'); + + return ( + <> +
+ + set('address', v || undefined)} + placeholder="agent@example.com" + /> + + + set('password', v || undefined)} + placeholder="app password" + /> + +
+ +
+ + set('imapHost', v || undefined)} + placeholder="imap.gmail.com" + options={EMAIL_IMAP_HOST_OPTIONS} + /> + {showImapHostWarning && emailHostPreset && ( +

+ {t('email.imapHostMismatchWarning', { expectedHost: emailHostPreset.imapHost })} +

+ )} +
+ + set('smtpSecurity', v as EmailChannelConfig['smtpSecurity'])} + options={[ + { value: 'ssl', label: t('email.securitySsl') }, + { value: 'starttls', label: t('email.securityStarttls') }, + { value: 'insecure', label: t('email.securityInsecure') }, + ]} + /> + + + set('smtpPort', v)} + min={1} + /> + +
+ +
+ + set('allowAll', v)} + label={t('email.allowAllLabel')} + /> + + {!(config.allowAll ?? false) && ( + + set('allowFrom', v)} + placeholder={t('email.allowFromPlaceholder')} + /> + + )} + + set('requireAuthenticatedSender', v)} + label={t('email.requireAuthenticatedSenderLabel')} + /> + + {config.requireAuthenticatedSender && ( + + set('authservId', v || undefined)} + placeholder={t('email.optional')} + /> + + )} + + set('allowInsecureConnections', v)} + label={t('email.allowInsecureConnectionsLabel')} + /> + +
+ +
+ + set('defaultAgent', v || undefined)} + placeholder={t('email.optional')} + /> + + + set('defaultSubject', v || undefined)} + placeholder="Flocks Agent" + /> + +
+ +
+ + set('pollIntervalSeconds', v)} + min={5} + /> + + + set('skipExistingOnStart', v)} + label={t('email.skipExistingOnStartLabel')} + /> + + + set('skipAttachments', v)} + label={t('email.skipAttachmentsLabel')} + /> + +
+ + ); +} + +// ============================================================================ + +// WhatsApp Config Panel +// ============================================================================ + +interface WhatsAppPanelProps { + config: WhatsAppChannelConfig; + onChange: (c: WhatsAppChannelConfig) => void; + onPairSuccess?: (data: { sessionPath: string }) => Promise | void; +} + +type WhatsAppQrPhase = + | 'idle' + | 'loading' + | 'scanning' + | 'connected' + | 'complete' + | 'error'; + +function WhatsAppPanel({ config, onChange, onPairSuccess }: WhatsAppPanelProps) { + const { t } = useTranslation('channel'); + const toast = useToast(); + const set = useCallback( + (key: K, value: WhatsAppChannelConfig[K]) => + onChange({ ...config, [key]: value }), + [config, onChange] + ); + + const [qrPhase, setQrPhase] = useState('idle'); + const [qrValue, setQrValue] = useState(''); + const [qrError, setQrError] = useState(''); + const [pairingId, setPairingId] = useState(''); + const pollRef = useRef | null>(null); + const completedRef = useRef(false); + + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + useEffect(() => () => stopPolling(), []); + + const closeQrModal = async () => { + stopPolling(); + if (pairingId && !['complete', 'connected'].includes(qrPhase)) { + client.post(`/api/channel/whatsapp/pair/${pairingId}/cancel`, {}, { timeout: 5000 }).catch(() => {}); + } + setQrPhase('idle'); + setQrValue(''); + setQrError(''); + setPairingId(''); + }; + + const finishPairing = async (sessionPath: string) => { + if (completedRef.current) return; + completedRef.current = true; + stopPolling(); + setQrPhase('complete'); + const newConfig = { ...config, sessionPath, enabled: true, _paired: true }; + onChange(newConfig); + if (onPairSuccess) { + await onPairSuccess({ sessionPath }); + } + toast.success(t('whatsapp.qrSuccess')); + }; + + const startPairing = async (replaceExisting = false) => { + stopPolling(); + completedRef.current = false; + setQrError(''); + setQrValue(''); + setQrPhase('loading'); + try { + const pairingSessionPath = + replaceExisting && config.sessionPath + ? `${config.sessionPath}.relink.${Date.now()}` + : (config.sessionPath || null); + const res = await client.post('/api/channel/whatsapp/pair/start', { + sessionPath: pairingSessionPath, + resetSession: false, + }); + const id = String(res.data?.pairing_id ?? ''); + setPairingId(id); + pollRef.current = setInterval(async () => { + try { + const statusRes = await client.get(`/api/channel/whatsapp/pair/${id}/status`); + const status = String(statusRes.data?.status ?? ''); + const qr = String(statusRes.data?.qr ?? ''); + if (qr) { + setQrValue(qr); + setQrPhase('scanning'); + } + if (status === 'connected') { + setQrPhase('connected'); + } + if (status === 'complete') { + await finishPairing(String(statusRes.data?.session_path ?? config.sessionPath ?? '')); + } + if (status === 'error') { + stopPolling(); + setQrError(String(statusRes.data?.error ?? t('whatsapp.qrError'))); + setQrPhase('error'); + } + } catch { + // Pairing may still be starting; keep polling until the bridge reports a terminal state. + } + }, 1500); + } catch (err: any) { + const detail = err?.response?.data?.detail ?? err?.message ?? ''; + setQrError(detail); + setQrPhase('error'); + } + }; + + const showModal = qrPhase !== 'idle'; + const isPaired = Boolean(config._paired); + const allowFromEnabled = (config.dmPolicy ?? 'allowlist') === 'allowlist'; + + return ( + <> +
+
+ + {isPaired && ( +

+ + {t('whatsapp.sessionConfigured')} +

+ )} +
+ + {showModal && ( +
+
+ +

{t('whatsapp.qrModalTitle')}

+ {qrPhase === 'loading' && ( +
+ +
+ )} + {qrPhase === 'scanning' && qrValue && ( + + )} + {(qrPhase === 'connected' || qrPhase === 'complete') && ( +
+ +

+ {qrPhase === 'complete' ? t('whatsapp.qrComplete') : t('whatsapp.qrConnected')} +

+
+ )} + {qrPhase === 'error' && ( +
+ +

{qrError || t('whatsapp.qrError')}

+ +
+ )} +

+ {qrPhase === 'loading' && t('whatsapp.qrHintLoading')} + {qrPhase === 'scanning' && t('whatsapp.qrHintScanning')} + {qrPhase === 'connected' && t('whatsapp.qrHintConnected')} + {qrPhase === 'complete' && t('whatsapp.qrHintComplete')} +

+ {qrPhase === 'complete' && ( + + )} +
+
+ )} + +
+ + + set('sessionPath', v || undefined)} + placeholder={t('whatsapp.optional')} + /> + + + set('dmPolicy', v)} + options={[ + { value: 'allowlist', label: t('whatsapp.dmPolicyAllowlist') }, + { value: 'open', label: t('whatsapp.dmPolicyOpen') }, + { value: 'disabled', label: t('whatsapp.dmPolicyDisabled') }, + ]} + /> + + + onChange({ + ...config, + dmPolicy: enabled ? 'allowlist' : 'open', + allowFrom: enabled ? (config.allowFrom ?? []) : undefined, + })} + /> + + {allowFromEnabled && ( + + set('allowFrom', v)} + placeholder={t('whatsapp.allowFromPlaceholder')} + /> + + )} + + set('groupTrigger', v)} + options={[ + { value: 'mention', label: t('whatsapp.triggerMention') }, + { value: 'all', label: t('whatsapp.triggerAll') }, + ]} + /> + + )} +
+ +
+ + set('bridgePort', v)} + min={1} + /> + + + set('replyPrefix', v || undefined)} + placeholder={t('whatsapp.optional')} + /> + + + set('textBatchDelaySeconds', v)} + min={0} + /> + + + set('sendChunkDelayMs', v)} + min={0} + /> + + + set('sendTimeoutMs', v)} + min={1000} + /> + + + set('mediaCacheDir', v || undefined)} + placeholder={t('whatsapp.optional')} + /> + +
+ + ); +} + // ============================================================================ // Weixin Config Panel // ============================================================================ @@ -1926,9 +2761,9 @@ export default function ChannelPage() { const originalConfigsRef = useRef>({}); const toggleInFlightRef = useRef(false); - const fetchAll = useCallback(async () => { + const fetchAll = useCallback(async (showLoading = false) => { try { - setLoading(true); + if (showLoading) setLoading(true); const [listRes, configRes] = await Promise.all([ client.get('/api/channel/list'), client.get('/api/config'), @@ -1956,6 +2791,26 @@ export default function ChannelPage() { configs[ch.id] = { ...defaultDingTalkConfig(), ...saved }; } else if (ch.id === 'telegram') { configs[ch.id] = { ...defaultTelegramConfig(), ...saved }; + } else if (ch.id === 'email') { + configs[ch.id] = { ...defaultEmailConfig(), ...saved }; + } else if (ch.id === 'email') { + configs[ch.id] = { ...defaultEmailConfig(), ...saved }; + } else if (ch.id === 'whatsapp') { + const whatsappCfg = { ...defaultWhatsAppConfig(), ...saved }; + if (whatsappCfg.sessionPath) { + try { + const sessionRes = await client.get('/api/channel/whatsapp/session-status', { + params: { sessionPath: whatsappCfg.sessionPath }, + }); + whatsappCfg._paired = Boolean(sessionRes.data?.paired); + } catch { + whatsappCfg._paired = false; + } + } else { + whatsappCfg._paired = false; + } + configs[ch.id] = whatsappCfg; + } else if (ch.id === 'weixin') { configs[ch.id] = { ...defaultWeixinConfig(), ...saved }; } else { @@ -1993,7 +2848,7 @@ export default function ChannelPage() { }, []); useEffect(() => { - fetchAll(); + fetchAll(true); fetchStatuses(true); const interval = setInterval(() => fetchStatuses(true), 15000); return () => clearInterval(interval); @@ -2090,6 +2945,37 @@ export default function ChannelPage() { setTimeout(() => { fetchAll(); fetchStatuses(true); }, 8000); }; + const handleWhatsAppPairSuccess = async ( + data: { sessionPath: string } + ) => { + const channelId = 'whatsapp'; + const savedChannelCfg = (fullConfig.channels?.[channelId] ?? {}) as Record; + const updatedChannelCfg: Record = { + ...savedChannelCfg, + ...stripEmpty(channelConfigs[channelId] ?? {}), + enabled: true, + }; + if (data.sessionPath) updatedChannelCfg.sessionPath = data.sessionPath; + const nextUiConfig = { ...updatedChannelCfg, _paired: true }; + + const updatedChannels = { ...(fullConfig.channels ?? {}), [channelId]: updatedChannelCfg }; + const updated = { ...fullConfig, channels: updatedChannels }; + + await client.patch('/api/config/', updated); + setFullConfig(updated); + setChannelConfigs((prev) => ({ + ...prev, + [channelId]: { ...prev[channelId], ...nextUiConfig } as ChannelConfig, + })); + originalConfigsRef.current = { + ...originalConfigsRef.current, + [channelId]: { ...originalConfigsRef.current[channelId], ...nextUiConfig }, + }; + client.post(`/api/channel/${channelId}/restart`, {}, { timeout: 5000 }).catch(() => {}); + setTimeout(() => { fetchAll(); fetchStatuses(true); }, 3000); + setTimeout(() => { fetchAll(); fetchStatuses(true); }, 8000); + }; + // Manual restart — useful when connection drops and user wants to reconnect const handleRestart = async (channelId?: string) => { const id = channelId ?? selectedId; @@ -2176,7 +3062,7 @@ export default function ChannelPage() { if (loading) { return (
- +
); } @@ -2281,6 +3167,19 @@ export default function ChannelPage() { onRefresh={fetchAll} /> )} + {selectedId === 'email' && ( + handleChannelConfigChange('email', cfg)} + /> + )} + {selectedId === 'whatsapp' && ( + handleChannelConfigChange('whatsapp', cfg)} + onPairSuccess={handleWhatsAppPairSuccess} + /> + )} {selectedId === 'weixin' && ( ): Record { const result: Record = {}; for (const [k, v] of Object.entries(obj)) { + if (k.startsWith('_')) continue; if (v === '' || v === undefined) continue; // Empty arrays ARE preserved: e.g. allowFrom:[] means "require pairing for everyone" // (distinct from absent key which means "open access"). diff --git a/webui/src/pages/DeviceIntegration/index.test.tsx b/webui/src/pages/DeviceIntegration/index.test.tsx index 4d4a93474..68d70730c 100644 --- a/webui/src/pages/DeviceIntegration/index.test.tsx +++ b/webui/src/pages/DeviceIntegration/index.test.tsx @@ -415,7 +415,7 @@ describe('DeviceIntegrationPage', () => { await openManualAddWizard(user); - expect(screen.getByText('API 接入')).toBeInTheDocument(); + expect(await screen.findByText('API 接入')).toBeInTheDocument(); expect(screen.getByText('浏览器接入')).toBeInTheDocument(); expect(screen.getByText('TDP 接入')).toBeInTheDocument(); expect(screen.getByText('OneSEC 接入')).toBeInTheDocument(); diff --git a/webui/src/pages/DeviceIntegration/index.tsx b/webui/src/pages/DeviceIntegration/index.tsx index 1538bdc89..121c388ec 100644 --- a/webui/src/pages/DeviceIntegration/index.tsx +++ b/webui/src/pages/DeviceIntegration/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, @@ -9,7 +9,6 @@ import { import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import { useToast } from '@/components/common/Toast'; -import SessionChat from '@/components/common/SessionChat'; import GuideInfoIcon from '@/components/common/GuideInfoIcon'; import { useRexComposerControls } from '@/components/common/useRexComposerControls'; import { useSessionChat, type CreateAndSendOptions } from '@/hooks/useSessionChat'; @@ -29,6 +28,15 @@ import { buildCustomDeviceModeRoutingPrompt } from './customDevice'; const DEFAULT_GROUP_ID = 'default-room'; const DEVICE_DRAWER_WIDTH = 560; const DEVICE_DRAWER_WIDTH_CSS = `${DEVICE_DRAWER_WIDTH}px`; +const LazySessionChat = lazy(() => import('@/components/common/SessionChat')); + +function SessionChatFallback() { + return ( +
+ +
+ ); +} /** Pull the backend's human-readable error detail (e.g. "机房名称已存在") * out of an axios error, falling back to a generic message. */ @@ -697,171 +705,172 @@ function DeviceAddRexPanel({ return (
- void detectLatestDraft(true)} - welcomeContent={ -
-
- {!showBuiltInTemplates ? ( - <> -
-
- + }> + void detectLatestDraft(true)} + welcomeContent={ +
+
+ {!showBuiltInTemplates ? ( + <> +
+
+ +
+

{t('wizard.guide.title')}

+

+ {t('wizard.guide.subtitle')} +

-

{t('wizard.guide.title')}

-

- {t('wizard.guide.subtitle')} -

-
-
- - startGuidedPrompt(t('wizard.guide.prompts.api'))} - /> - startGuidedPrompt(t('wizard.guide.prompts.browser'))} - /> - - - - handleCaseTemplate(['tdp'], t('wizard.guide.prompts.tdp'))} - /> - handleCaseTemplate(['onesec', 'one sec'], t('wizard.guide.prompts.onesec'))} - /> - setShowBuiltInTemplates(true)} - /> - -
- - ) : ( - <> -
- -

{t('wizard.supportedList.title')}

-

{t('wizard.supportedList.subtitle')}

-
+
+ + startGuidedPrompt(t('wizard.guide.prompts.api'))} + /> + startGuidedPrompt(t('wizard.guide.prompts.browser'))} + /> + -
- {vendorGroups.map(({ vendor, templates: vendorTemplates }) => { - const expanded = expandedVendors.has(vendor.id); - const vendorName = i18n.language.startsWith('zh') ? vendor.nameCn : vendor.nameEn; - const integratedCount = vendorTemplates.reduce( - (sum, template) => sum + (instanceCounts[template.storage_key] ?? 0), - 0, - ); - return ( -
- - {expanded && ( -
- {vendorTemplates.map((tpl) => { - const count = instanceCounts[tpl.storage_key] ?? 0; - const action = templateAction(tpl); - const installing = installingTemplateKey === tpl.storage_key; - const templateMeta = tpl.version ? formatTemplateVersion(tpl.version) : tpl.storage_key; - const stateBadge = tpl.installed - ? t('wizard.installState.installed') - : tpl.state === 'updateAvailable' - ? t('wizard.installState.updateAvailable') - : tpl.state === 'broken' - ? t('wizard.installState.brokenShort') - : t('wizard.installState.available'); - return ( -
+ + ) : ( + <> +
+ +

{t('wizard.supportedList.title')}

+

{t('wizard.supportedList.subtitle')}

+
+ +
+ {vendorGroups.map(({ vendor, templates: vendorTemplates }) => { + const expanded = expandedVendors.has(vendor.id); + const vendorName = i18n.language.startsWith('zh') ? vendor.nameCn : vendor.nameEn; + const integratedCount = vendorTemplates.reduce( + (sum, template) => sum + (instanceCounts[template.storage_key] ?? 0), + 0, + ); + return ( +
+ + {expanded && ( +
+ {vendorTemplates.map((tpl) => { + const count = instanceCounts[tpl.storage_key] ?? 0; + const action = templateAction(tpl); + const installing = installingTemplateKey === tpl.storage_key; + const templateMeta = tpl.version ? formatTemplateVersion(tpl.version) : tpl.storage_key; + const stateBadge = tpl.installed + ? t('wizard.installState.installed') + : tpl.state === 'updateAvailable' + ? t('wizard.installState.updateAvailable') + : tpl.state === 'broken' + ? t('wizard.installState.brokenShort') + : t('wizard.installState.available'); + return ( + - ); - })} -
- )} -
- ); - })} -
- - )} + + {count > 0 && {t('wizard.instanceCount', { count })}} + {installing ? t(action === 'update' ? 'wizard.installState.updating' : 'wizard.installState.installing') : stateBadge} + + {installing + ? + : ( + + )} + + ); + })} +
+ )} + + ); + })} +
+ + )}
} @@ -872,6 +881,7 @@ function DeviceAddRexPanel({ model: modelOverride === undefined ? rexComposerControls.rexModel : modelOverride, }) : undefined} /> + {detectedAction && (
diff --git a/webui/src/pages/Home/index.tsx b/webui/src/pages/Home/index.tsx index b4d664cb0..27d4198ae 100644 --- a/webui/src/pages/Home/index.tsx +++ b/webui/src/pages/Home/index.tsx @@ -196,7 +196,7 @@ export default function Home() { {/* Stats */} {loading ? (
- +
) : ( <> diff --git a/webui/src/pages/Hub/index.test.tsx b/webui/src/pages/Hub/index.test.tsx new file mode 100644 index 000000000..152054b3a --- /dev/null +++ b/webui/src/pages/Hub/index.test.tsx @@ -0,0 +1,448 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import HubPage from './index'; + +const { hubAPI, toastError } = vi.hoisted(() => ({ + hubAPI: { + catalog: vi.fn(), + catalogPage: vi.fn(), + categories: vi.fn(), + refresh: vi.fn(), + install: vi.fn(), + installStream: vi.fn(), + update: vi.fn(), + uninstall: vi.fn(), + get: vi.fn(), + files: vi.fn(), + fileContent: vi.fn(), + }, + toastError: vi.fn(), +})); + +vi.mock('@/api/hub', () => ({ hubAPI })); +vi.mock('@/hooks/useDebouncedValue', () => ({ + useDebouncedValue: (value: T) => value, +})); +vi.mock('@/contexts/ProductNameContext', () => ({ + useProductName: () => ({ productName: 'Flocks' }), +})); +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ user: { id: 'admin-1', role: 'admin' } }), +})); +vi.mock('@/components/common/Toast', () => ({ + useToast: () => ({ error: toastError }), +})); +vi.mock('@/components/common/LoadingSpinner', () => ({ + default: () =>
loading
, +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + i18n: { language: 'en-US' }, + }), +})); + +const emptyFacets = { + type: {}, + category: {}, + tags: {}, + useCases: {}, + state: {}, + trust: {}, + riskLevel: {}, +}; + +function catalogEntry(id: string, name: string, manifestPath = `${id}/manifest.json`) { + return { + id, + type: 'tool' as const, + name, + description: `${name} description`, + version: '1.0.0', + category: 'security', + tags: ['security'], + useCases: ['investigation'], + domains: [], + capabilities: [], + trust: 'verified', + riskLevel: 'low', + state: 'available' as const, + source: 'bundled', + manifestPath, + native: false, + }; +} + +function catalogPage(items: ReturnType[], total = items.length) { + return { + data: { + items, + total, + offset: 0, + limit: 25, + facets: emptyFacets, + }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function renderHub() { + return render( + + + , + ); +} + +describe('HubPage catalog loading', () => { + beforeEach(() => { + vi.clearAllMocks(); + hubAPI.categories.mockResolvedValue({ + data: { categories: [], tags: [], useCases: [] }, + }); + hubAPI.catalog.mockResolvedValue({ data: [] }); + hubAPI.files.mockResolvedValue({ + data: { name: 'root', path: '', type: 'directory', size: 0, previewable: false, children: [] }, + }); + }); + + it('ignores an older search response that finishes after the latest query', async () => { + const oldSearch = deferred>(); + const latestSearch = deferred>(); + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([catalogEntry('initial', 'Initial result')])) + .mockImplementationOnce(() => oldSearch.promise) + .mockImplementationOnce(() => latestSearch.promise); + + renderHub(); + expect(await screen.findByText('Initial result')).toBeInTheDocument(); + + const search = screen.getByPlaceholderText('Search plugin name, description, tag, use case'); + fireEvent.change(search, { target: { value: 'old' } }); + await waitFor(() => expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2)); + fireEvent.change(search, { target: { value: 'latest' } }); + await waitFor(() => expect(hubAPI.catalogPage).toHaveBeenCalledTimes(3)); + + await act(async () => { + latestSearch.resolve(catalogPage([catalogEntry('latest', 'Latest result')])); + }); + expect(await screen.findByText('Latest result')).toBeInTheDocument(); + + await act(async () => { + oldSearch.resolve(catalogPage([catalogEntry('old', 'Old result')])); + }); + + expect(screen.getByText('Latest result')).toBeInTheDocument(); + expect(screen.queryByText('Old result')).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ q: 'latest' }), + ); + }); + + it('loads the complete unpaged catalog for directory view', async () => { + const user = userEvent.setup(); + const pagedEntry = catalogEntry('paged', 'Paged Entry', 'paged-tree/manifest.json'); + const beyondPage = catalogEntry('complete', 'Complete Entry', 'complete-tree/manifest.json'); + hubAPI.catalogPage.mockResolvedValue(catalogPage([pagedEntry], 854)); + hubAPI.catalog.mockResolvedValue({ data: [pagedEntry, beyondPage] }); + + renderHub(); + expect(await screen.findByText('Paged Entry')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Directory View' })); + + expect(await screen.findByText('complete-tree')).toBeInTheDocument(); + expect(screen.getByText('paged-tree')).toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(1); + expect(hubAPI.catalogPage).toHaveBeenCalledWith( + expect.objectContaining({ offset: 0, limit: 25 }), + ); + expect(hubAPI.catalog).toHaveBeenCalledWith({ + q: undefined, + type: undefined, + useCases: undefined, + tags: undefined, + state: undefined, + }); + }); + + it('refreshes the selected entity after an action even when it leaves the filtered page', async () => { + const user = userEvent.setup(); + const available = catalogEntry('action-entry', 'Action Entry'); + const installed = { ...available, state: 'installed' as const, installedVersion: '1.0.0' }; + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([available])) + .mockResolvedValueOnce(catalogPage([])); + hubAPI.catalog.mockResolvedValue({ data: [installed] }); + // The manifest endpoint intentionally has no dynamic catalog state. + hubAPI.get.mockResolvedValueOnce({ data: available }); + hubAPI.install.mockResolvedValue({ data: installed }); + + renderHub(); + await user.click(await screen.findByText('Action Entry')); + await waitFor(() => expect(hubAPI.get).toHaveBeenCalledTimes(1)); + + const installButtons = screen.getAllByRole('button', { name: 'Install' }); + await user.click(installButtons[installButtons.length - 1]); + + await waitFor(() => expect(hubAPI.install).toHaveBeenCalledWith('tool', 'action-entry')); + expect(hubAPI.catalog).toHaveBeenCalledWith({ q: 'action-entry', type: 'tool' }); + expect(hubAPI.get).toHaveBeenCalledTimes(1); + expect(await screen.findByRole('button', { name: 'Uninstall' })).toBeInTheDocument(); + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + + it('does not let a table action started under an old query overwrite the latest query', async () => { + const user = userEvent.setup(); + const actionEntry = catalogEntry('action-entry', 'Action Entry'); + const latestEntry = catalogEntry('latest-entry', 'Latest Entry'); + const installedEntry = { ...actionEntry, state: 'installed' as const, installedVersion: '1.0.0' }; + const install = deferred(); + hubAPI.catalogPage.mockImplementation(({ q }: { q?: string }) => ( + Promise.resolve(q === 'latest' ? catalogPage([latestEntry]) : catalogPage([actionEntry])) + )); + hubAPI.install.mockReturnValue(install.promise); + hubAPI.catalog.mockResolvedValue({ data: [installedEntry] }); + + renderHub(); + await user.click(await screen.findByRole('button', { name: 'Install' })); + await waitFor(() => expect(hubAPI.install).toHaveBeenCalledWith('tool', 'action-entry')); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByText('Latest Entry')).toBeInTheDocument(); + + await act(async () => { + install.resolve(); + await install.promise; + }); + + await waitFor(() => { + expect(hubAPI.catalog).toHaveBeenCalledWith({ q: 'action-entry', type: 'tool' }); + }); + expect(screen.getByText('Latest Entry')).toBeInTheDocument(); + expect(screen.queryByText('Action Entry')).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(hubAPI.catalogPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'latest' })); + }); + + it('does not let a refresh started under an old query overwrite the latest query', async () => { + const user = userEvent.setup(); + const initialEntry = catalogEntry('initial-entry', 'Initial Entry'); + const latestEntry = catalogEntry('latest-entry', 'Latest Entry'); + const refresh = deferred(); + hubAPI.catalogPage.mockImplementation(({ q }: { q?: string }) => ( + Promise.resolve(q === 'latest' ? catalogPage([latestEntry]) : catalogPage([initialEntry])) + )); + hubAPI.refresh.mockReturnValue(refresh.promise); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByText('Latest Entry')).toBeInTheDocument(); + + await act(async () => { + refresh.resolve(); + await refresh.promise; + }); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()); + expect(screen.getByText('Latest Entry')).toBeInTheDocument(); + expect(screen.queryByText('Initial Entry')).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(hubAPI.catalogPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'latest' })); + }); + + it('shows a user-visible error when a manual refresh fails', async () => { + const user = userEvent.setup(); + hubAPI.catalogPage.mockResolvedValue(catalogPage([catalogEntry('initial-entry', 'Initial Entry')])); + hubAPI.refresh.mockRejectedValue(new Error('refresh unavailable')); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith('Hub refresh failed', 'refresh unavailable'); + }); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(1); + }); + + it('distinguishes a completed manual refresh from a catalog reload failure', async () => { + const user = userEvent.setup(); + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([catalogEntry('initial-entry', 'Initial Entry')])) + .mockRejectedValueOnce({ + response: { data: { detail: 'catalog temporarily unavailable' } }, + message: 'Request failed with status code 503', + }); + hubAPI.refresh.mockResolvedValue({ data: { status: 'success' } }); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + 'Hub refresh completed, but catalog reload failed', + 'catalog temporarily unavailable', + ); + }); + expect(toastError).not.toHaveBeenCalledWith('Hub refresh failed', expect.anything()); + expect(hubAPI.refresh).toHaveBeenCalledTimes(1); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled(); + }); + + it('does not report a stale manual reload failure after the query changes', async () => { + const user = userEvent.setup(); + const staleReload = deferred>(); + hubAPI.catalogPage + .mockResolvedValueOnce(catalogPage([catalogEntry('initial-entry', 'Initial Entry')])) + .mockImplementationOnce(() => staleReload.promise) + .mockResolvedValueOnce(catalogPage([catalogEntry('latest-entry', 'Latest Entry')])); + hubAPI.refresh.mockResolvedValue({ data: { status: 'success' } }); + + renderHub(); + expect(await screen.findByText('Initial Entry')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2)); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByText('Latest Entry')).toBeInTheDocument(); + + await act(async () => { + staleReload.reject(new Error('obsolete catalog failure')); + try { + await staleReload.promise; + } catch { + // The component owns this rejected request. + } + }); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()); + expect(toastError).not.toHaveBeenCalledWith( + 'Hub refresh completed, but catalog reload failed', + expect.anything(), + ); + expect(screen.getByText('Latest Entry')).toBeInTheDocument(); + }); + + it.each([ + ['install', 'available', 'Install', 'Install failed'], + ['update', 'updateAvailable', 'Update', 'Update failed'], + ['uninstall', 'installed', 'Uninstall', 'Uninstall failed'], + ] as const)('shows a user-visible error when %s fails', async (action, state, buttonLabel, errorTitle) => { + const user = userEvent.setup(); + const entry = { ...catalogEntry('action-entry', 'Action Entry'), state }; + hubAPI.catalogPage.mockResolvedValue(catalogPage([entry])); + hubAPI[action].mockRejectedValue({ + response: { data: { detail: `${action} permission denied` } }, + message: `Request failed with status code 422`, + }); + + renderHub(); + await user.click(await screen.findByRole('button', { name: buttonLabel })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + `${errorTitle}: Action Entry`, + `${action} permission denied`, + ); + }); + expect(screen.getByRole('button', { name: buttonLabel })).toBeEnabled(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(1); + }); + + it('does not report a completed action as failed when the follow-up reload fails', async () => { + const user = userEvent.setup(); + const entry = catalogEntry('action-entry', 'Action Entry'); + hubAPI.catalogPage.mockResolvedValue(catalogPage([entry])); + hubAPI.install.mockResolvedValue({ data: { ...entry, state: 'installed' } }); + hubAPI.catalog.mockRejectedValue({ + response: { data: { detail: 'catalog temporarily unavailable' } }, + message: 'Request failed with status code 503', + }); + + renderHub(); + await user.click(await screen.findByRole('button', { name: 'Install' })); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + 'Action completed, but Hub reload failed: Action Entry', + 'catalog temporarily unavailable', + ); + }); + expect(toastError).not.toHaveBeenCalledWith( + 'Install failed: Action Entry', + expect.anything(), + ); + expect(hubAPI.install).toHaveBeenCalledTimes(1); + }); + + it('does not let a tree action started under old filters overwrite the latest tree', async () => { + const user = userEvent.setup(); + const actionEntry = catalogEntry('tree-action', 'Tree Action'); + const latestEntry = catalogEntry('tree-latest', 'Tree Latest'); + const installedEntry = { ...actionEntry, state: 'installed' as const, installedVersion: '1.0.0' }; + const install = deferred(); + hubAPI.catalogPage.mockImplementation(({ q }: { q?: string }) => ( + Promise.resolve(q === 'latest' ? catalogPage([latestEntry]) : catalogPage([actionEntry])) + )); + hubAPI.catalog.mockImplementation(({ q, type }: { q?: string; type?: string }) => { + if (q === 'tree-action' && type === 'tool') return Promise.resolve({ data: [installedEntry] }); + return Promise.resolve({ data: q === 'latest' ? [latestEntry] : [actionEntry] }); + }); + hubAPI.get.mockResolvedValue({ data: actionEntry }); + hubAPI.install.mockReturnValue(install.promise); + + renderHub(); + expect(await screen.findByText('Tree Action')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Directory View' })); + await user.click(await screen.findByRole('button', { name: 'tree-action' })); + await user.click(await screen.findByRole('button', { name: 'Install' })); + await waitFor(() => expect(hubAPI.install).toHaveBeenCalledWith('tool', 'tree-action')); + + fireEvent.change( + screen.getByPlaceholderText('Search plugin name, description, tag, use case'), + { target: { value: 'latest' } }, + ); + expect(await screen.findByRole('button', { name: 'tree-latest' })).toBeInTheDocument(); + + await act(async () => { + install.resolve(); + await install.promise; + }); + + await waitFor(() => { + expect(hubAPI.catalog).toHaveBeenCalledWith({ q: 'tree-action', type: 'tool' }); + }); + expect(screen.getByRole('button', { name: 'tree-latest' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'tree-action' })).not.toBeInTheDocument(); + expect(hubAPI.catalogPage).toHaveBeenCalledTimes(2); + expect(hubAPI.catalog.mock.calls.filter(([params]) => params?.q === undefined)).toHaveLength(1); + }); +}); diff --git a/webui/src/pages/Hub/index.tsx b/webui/src/pages/Hub/index.tsx index 0804f104e..4b0ed8f07 100644 --- a/webui/src/pages/Hub/index.tsx +++ b/webui/src/pages/Hub/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Archive, @@ -19,6 +19,8 @@ import { } from 'lucide-react'; import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; +import { useToast } from '@/components/common/Toast'; +import { extractErrorMessage } from '@/utils/error'; import SuiteInstallProgressPanel, { applySuiteInstallProgressEvent, createSuiteInstallProgressState, @@ -28,20 +30,25 @@ import SuiteInstallProgressPanel, { import { hubAPI, HubCatalogEntry, + type HubCatalogFacets, HubFileContent, HubFileNode, HubInstallProgressEvent, HubManifest, HubPluginType, } from '@/api/hub'; -import FlowCanvas from '@/pages/WorkflowDetail/FlowCanvas'; import type { WorkflowJSON } from '@/api/workflow'; import { useTranslation } from 'react-i18next'; import { getCatalogDescription } from '@/utils/mcpCatalog'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useProductName } from '@/contexts/ProductNameContext'; +import { useAuth } from '@/contexts/AuthContext'; type ViewMode = 'table' | 'tree'; +const FlowCanvas = lazy(() => import('@/pages/WorkflowDetail/FlowCanvas')); +const HUB_LOADING_DELAY_MS = 180; + interface HubTaxonomyCategory { id: string; name: string; @@ -138,6 +145,11 @@ const HUB_TEXT = { suiteInstallFailed: '安装失败', suiteInstallProgress: '安装进度', suiteInstallDismiss: '关闭', + refreshFailed: '刷新 Hub 失败', + refreshReloadFailed: 'Hub 刷新已完成,但列表重载失败', + actionRefreshFailed: '操作已完成,但刷新 Hub 列表失败', + actionFailed: { install: '安装失败', update: '更新失败', uninstall: '卸载失败' }, + unknownError: '未知错误', suiteItemStatuses: { pending: '等待中', installing: '安装中', @@ -194,6 +206,11 @@ const HUB_TEXT = { suiteInstallFailed: 'Install failed', suiteInstallProgress: 'Progress', suiteInstallDismiss: 'Close', + refreshFailed: 'Hub refresh failed', + refreshReloadFailed: 'Hub refresh completed, but catalog reload failed', + actionRefreshFailed: 'Action completed, but Hub reload failed', + actionFailed: { install: 'Install failed', update: 'Update failed', uninstall: 'Uninstall failed' }, + unknownError: 'Unknown error', suiteItemStatuses: { pending: 'Pending', installing: 'Installing', @@ -243,70 +260,21 @@ function getHubName(entry: Pick, lang return language.toLowerCase().startsWith('zh') ? (nameCn || name || entry.id) : (name || nameCn || entry.id); } -interface HubFilterSnapshot { - query?: string; - type?: HubPluginType | ''; - useCase?: string; - tag?: string; - state?: string; -} - -interface HubFacetCounts { - type: Record; - useCases: Record; - tags: Record; - state: Record; -} - -function matchesCatalogEntry(entry: HubCatalogEntry, filters: HubFilterSnapshot) { - if (filters.type && entry.type !== filters.type) return false; - if (filters.useCase && !entry.useCases.includes(filters.useCase)) return false; - if (filters.tag && !entry.tags.includes(filters.tag)) return false; - if (filters.state && entry.state !== filters.state) return false; - const query = filters.query?.trim().toLowerCase(); - if (query) { - const haystack = [ - entry.id, - entry.name, - entry.description, - entry.descriptionCn ?? '', - entry.category, - ...entry.tags, - ...entry.useCases, - ].join(' ').toLowerCase(); - if (!haystack.includes(query)) return false; - } - return true; -} - -function addFacetCount(counts: Record, key: string) { - counts[key] = (counts[key] ?? 0) + 1; -} - -function buildFacetCounts(items: HubCatalogEntry[], filters: HubFilterSnapshot): HubFacetCounts { - const counts: HubFacetCounts = { type: {}, useCases: {}, tags: {}, state: {} }; - - items.forEach(item => { - if (matchesCatalogEntry(item, { query: filters.query })) { - addFacetCount(counts.type, item.type); - } - if (matchesCatalogEntry(item, { query: filters.query, type: filters.type })) { - item.useCases.forEach(useCase => addFacetCount(counts.useCases, useCase)); - } - if (matchesCatalogEntry(item, { query: filters.query, type: filters.type, useCase: filters.useCase })) { - item.tags.forEach(tag => addFacetCount(counts.tags, tag)); - } - if (matchesCatalogEntry(item, { query: filters.query, type: filters.type, useCase: filters.useCase, tag: filters.tag })) { - addFacetCount(counts.state, item.state); - } - }); - - return counts; -} +const EMPTY_HUB_FACETS: HubCatalogFacets = { + type: {}, + category: {}, + tags: {}, + useCases: {}, + state: {}, + trust: {}, + riskLevel: {}, +}; export default function HubPage() { const { i18n } = useTranslation(); + const { user } = useAuth(); const { productName } = useProductName(); + const toast = useToast(); const [searchParams] = useSearchParams(); const text = i18n.language.toLowerCase().startsWith('zh') ? HUB_TEXT.zh : HUB_TEXT.en; const hubTitle = `${productName} Hub`; @@ -316,7 +284,12 @@ export default function HubPage() { const urlPluginId = searchParams.get('plugin') || searchParams.get('id') || ''; const urlType = normalizePluginType(searchParams.get('type')); const [catalogItems, setCatalogItems] = useState([]); + const [treeItems, setTreeItems] = useState([]); const [loading, setLoading] = useState(true); + const [treeLoading, setTreeLoading] = useState(false); + const [catalogError, setCatalogError] = useState(null); + const [treeError, setTreeError] = useState(null); + const [hasLoadedCatalog, setHasLoadedCatalog] = useState(false); const [refreshing, setRefreshing] = useState(false); const [query, setQuery] = useState(searchParams.get('q') || urlPluginId); const [typeFilter, setTypeFilter] = useState(urlType); @@ -331,26 +304,122 @@ export default function HubPage() { const [actionId, setActionId] = useState(null); const [suiteInstallProgress, setSuiteInstallProgress] = useState(null); const [taxonomy, setTaxonomy] = useState(null); - - const fetchCatalog = async (silent = false) => { + const [totalItems, setTotalItems] = useState(0); + const [facetCounts, setFacetCounts] = useState(EMPTY_HUB_FACETS); + const catalogRequestIdRef = useRef(0); + const treeRequestIdRef = useRef(0); + const debouncedQuery = useDebouncedValue(query, 250); + const catalogRequestKey = JSON.stringify([ + query, + debouncedQuery, + typeFilter, + useCaseFilter, + tagFilter, + stateFilter, + page, + pageSize, + ]); + const treeRequestKey = JSON.stringify([ + query, + debouncedQuery, + typeFilter, + useCaseFilter, + tagFilter, + stateFilter, + ]); + const currentCatalogRequestKeyRef = useRef(catalogRequestKey); + const currentTreeRequestKeyRef = useRef(treeRequestKey); + currentCatalogRequestKeyRef.current = catalogRequestKey; + currentTreeRequestKeyRef.current = treeRequestKey; + const canManageHub = user?.role === 'admin'; + + const fetchCatalog = useCallback(async (silent = false, propagateError = false) => { + const requestKey = catalogRequestKey; + if (requestKey !== currentCatalogRequestKeyRef.current) return null; + const requestId = ++catalogRequestIdRef.current; + const isCurrentRequest = () => ( + requestId === catalogRequestIdRef.current + && requestKey === currentCatalogRequestKeyRef.current + ); try { if (!silent) setLoading(true); - const res = await hubAPI.catalog(); - const nextItems = Array.isArray(res.data) ? res.data : []; - setCatalogItems(nextItems); - setSelected(current => { - if (!current) return current; - return nextItems.find(item => item.type === current.type && item.id === current.id) ?? current; + setCatalogError(null); + const res = await hubAPI.catalogPage({ + q: debouncedQuery.trim() || undefined, + type: typeFilter || undefined, + useCases: useCaseFilter || undefined, + tags: tagFilter || undefined, + state: stateFilter || undefined, + offset: (page - 1) * pageSize, + limit: pageSize, }); + if (!isCurrentRequest()) return null; + const nextItems = Array.isArray(res.data.items) ? res.data.items : []; + setCatalogItems(nextItems); + setTotalItems(res.data.total ?? nextItems.length); + setFacetCounts(res.data.facets ?? EMPTY_HUB_FACETS); return nextItems; + } catch (error) { + const currentRequest = isCurrentRequest(); + if (currentRequest) { + setCatalogError(error instanceof Error ? error.message : 'Failed to load Hub catalog'); + } + if (propagateError && currentRequest) throw error; + return null; } finally { - if (!silent) setLoading(false); + if (isCurrentRequest()) { + setHasLoadedCatalog(true); + if (!silent) setLoading(false); + } } - }; + }, [catalogRequestKey, debouncedQuery, page, pageSize, stateFilter, tagFilter, typeFilter, useCaseFilter]); + + const fetchTreeCatalog = useCallback(async (silent = false, propagateError = false) => { + const requestKey = treeRequestKey; + if (requestKey !== currentTreeRequestKeyRef.current) return; + const requestId = ++treeRequestIdRef.current; + const isCurrentRequest = () => ( + requestId === treeRequestIdRef.current + && requestKey === currentTreeRequestKeyRef.current + ); + try { + if (!silent) setTreeLoading(true); + setTreeError(null); + const res = await hubAPI.catalog({ + q: debouncedQuery.trim() || undefined, + type: typeFilter || undefined, + useCases: useCaseFilter || undefined, + tags: tagFilter || undefined, + state: stateFilter || undefined, + }); + if (!isCurrentRequest()) return; + setTreeItems(Array.isArray(res.data) ? res.data : []); + } catch (error) { + const currentRequest = isCurrentRequest(); + if (currentRequest) { + setTreeError(error instanceof Error ? error.message : 'Failed to load Hub tree'); + } + if (propagateError && currentRequest) throw error; + } finally { + if (isCurrentRequest() && !silent) { + setTreeLoading(false); + } + } + }, [debouncedQuery, stateFilter, tagFilter, treeRequestKey, typeFilter, useCaseFilter]); + + useEffect(() => { + if (query !== debouncedQuery) return; + void fetchCatalog(); + }, [debouncedQuery, fetchCatalog, query]); + + useEffect(() => { + if (viewMode === 'tree' && query === debouncedQuery) { + void fetchTreeCatalog(); + } + }, [debouncedQuery, fetchTreeCatalog, query, viewMode]); useEffect(() => { - fetchCatalog(); - hubAPI.categories().then(res => setTaxonomy(res.data as HubTaxonomyResponse)).catch(() => setTaxonomy(null)); + hubAPI.categories({ includeCounts: false }).then(res => setTaxonomy(res.data as HubTaxonomyResponse)).catch(() => setTaxonomy(null)); }, []); useEffect(() => { @@ -363,31 +432,7 @@ export default function HubPage() { } }, [catalogItems, urlPluginId, urlType]); - useEffect(() => { - setPage(1); - }, [query, typeFilter, stateFilter, tagFilter, useCaseFilter, viewMode, pageSize]); - - const items = useMemo( - () => catalogItems.filter(item => matchesCatalogEntry(item, { - query, - type: typeFilter, - useCase: useCaseFilter, - tag: tagFilter, - state: stateFilter, - })), - [catalogItems, query, typeFilter, useCaseFilter, tagFilter, stateFilter], - ); - - const facetCounts = useMemo( - () => buildFacetCounts(catalogItems, { - query, - type: typeFilter, - useCase: useCaseFilter, - tag: tagFilter, - state: stateFilter, - }), - [catalogItems, query, typeFilter, useCaseFilter, tagFilter, stateFilter], - ); + const items = catalogItems; const useCases = useMemo( () => taxonomy?.useCases ?? Array.from(new Set(catalogItems.flatMap(item => item.useCases))).sort(), @@ -398,17 +443,38 @@ export default function HubPage() { [catalogItems, taxonomy], ); const activeFilterCount = [typeFilter, useCaseFilter, tagFilter, stateFilter].filter(Boolean).length; - const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const currentPage = Math.min(page, totalPages); - const pagedItems = useMemo( - () => items.slice((currentPage - 1) * pageSize, currentPage * pageSize), - [items, currentPage, pageSize], - ); + const pagedItems = items; + const isInitialLoading = loading && !hasLoadedCatalog; + const visibleCatalogError = viewMode === 'tree' ? (treeError || catalogError) : catalogError; + + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); const handleRefresh = async () => { setRefreshing(true); try { await hubAPI.refresh(); - await fetchCatalog(true); + } catch (error) { + toast.error( + text.refreshFailed, + extractErrorMessage(error, text.unknownError), + ); + setRefreshing(false); + return; + } + + try { + await Promise.all([ + fetchCatalog(true, true), + viewMode === 'tree' ? fetchTreeCatalog(true, true) : Promise.resolve(), + ]); + } catch (error) { + toast.error( + text.refreshReloadFailed, + extractErrorMessage(error, text.unknownError), + ); } finally { setRefreshing(false); } @@ -419,6 +485,7 @@ export default function HubPage() { }; const runAction = async (entry: HubCatalogEntry, action: 'install' | 'update' | 'uninstall') => { + if (!canManageHub) return; const key = `${entry.type}:${entry.id}:${action}`; setActionId(key); try { @@ -430,18 +497,38 @@ export default function HubPage() { } if (action === 'update') await hubAPI.update(entry.type, entry.id); if (action === 'uninstall') await hubAPI.uninstall(entry.type, entry.id); - const nextItems = await fetchCatalog(true); - const updated = nextItems?.find(item => item.type === entry.type && item.id === entry.id); - if (updated) { - setSelected(current => (current?.type === entry.type && current?.id === entry.id ? updated : current)); - } } catch (error) { if (action === 'install' && entry.type === 'component') { - const message = error instanceof Error ? error.message : text.suiteInstallFailed; + const message = extractErrorMessage(error, text.suiteInstallFailed); setSuiteInstallProgress(current => failSuiteInstallProgress(current, entry, message)); } else { - console.error(error); + toast.error( + `${text.actionFailed[action]}: ${getHubName(entry, i18n.language)}`, + extractErrorMessage(error, text.unknownError), + ); } + setActionId(null); + return; + } + + try { + const [, , refreshedEntry] = await Promise.all([ + fetchCatalog(true, true), + viewMode === 'tree' ? fetchTreeCatalog(true, true) : Promise.resolve(), + hubAPI.catalog({ q: entry.id, type: entry.type }).then(res => ( + (Array.isArray(res.data) ? res.data : []).find(item => ( + item.type === entry.type && item.id === entry.id + )) ?? null + )), + ]); + setSelected(current => ( + current?.type === entry.type && current?.id === entry.id ? refreshedEntry : current + )); + } catch (error) { + toast.error( + `${text.actionRefreshFailed}: ${getHubName(entry, i18n.language)}`, + extractErrorMessage(error, text.unknownError), + ); } finally { setActionId(null); } @@ -452,14 +539,32 @@ export default function HubPage() { setUseCaseFilter(''); setTagFilter(''); setStateFilter(''); + setPage(1); }; - if (loading) { - return
; + if (isInitialLoading) { + return
; + } + + if (catalogError && catalogItems.length === 0) { + return ( +
+
+

{catalogError}

+ +
+
+ ); } return ( -
+
setQuery(e.target.value)} + onChange={e => { + setQuery(e.target.value); + setPage(1); + }} placeholder={text.searchPlaceholder} className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg text-sm outline-none bg-white/90 focus:ring-2 focus:ring-slate-200 focus:border-slate-400" /> @@ -482,25 +590,30 @@ export default function HubPage() { {viewMode === 'table' ? : } {viewMode === 'table' ? text.treeView : text.tableView} - + {canManageHub && ( + + )}
} /> -
+
setTypeFilter(value as HubPluginType | '')} + onChange={value => { + setTypeFilter(value as HubPluginType | ''); + setPage(1); + }} options={[ { value: '', label: text.all }, ...HUB_PLUGIN_TYPES.map(type => ({ @@ -517,7 +630,10 @@ export default function HubPage() { { + setUseCaseFilter(value); + setPage(1); + }} options={[ { value: '', label: text.all }, ...useCases.map(useCase => ({ @@ -531,7 +647,10 @@ export default function HubPage() { { + setTagFilter(value); + setPage(1); + }} options={[ { value: '', label: text.all }, ...tags.map(tag => ({ @@ -545,7 +664,10 @@ export default function HubPage() { { + setStateFilter(value); + setPage(1); + }} options={[ { value: '', label: text.all }, ...(['available', 'installed', 'updateAvailable', 'incompatible'] as const).map(state => ({ @@ -580,23 +702,35 @@ export default function HubPage() {
-
+
+ {visibleCatalogError && ( +
+ {visibleCatalogError} +
+ )} {viewMode === 'table' ? ( + ) : treeLoading && treeItems.length === 0 ? ( +
+ +
) : ( - + )}
{viewMode === 'table' && ( { + setPageSize(nextPageSize); + setPage(1); + }} /> )} @@ -642,15 +776,15 @@ function FilterRow({ label, value, options, onChange }: { key={option.value || 'all'} title={option.title} onClick={() => onChange(option.value)} - className={`px-2 py-1 rounded-md transition-colors ${ + className={`inline-flex h-8 items-center whitespace-nowrap rounded-md px-2 py-1 font-medium tabular-nums transition-colors ${ active - ? 'bg-slate-800 text-white font-medium' + ? 'bg-slate-800 text-white' : 'text-gray-600 hover:bg-white hover:text-gray-900' }`} > {option.label} {option.count !== undefined && option.value && ( - {option.count} + {option.count} )} ); @@ -658,8 +792,8 @@ function FilterRow({ label, value, options, onChange }: { const [allOption, ...facetOptions] = options; return ( -
-
+
+
{label} @@ -686,7 +820,7 @@ function PaginationBar({ total, page, pageSize, totalPages, text, onPageChange, const start = total === 0 ? 0 : (page - 1) * pageSize + 1; const end = Math.min(total, page * pageSize); return ( -
+
{text.showing} {start}-{end} / {text.of} {total} {text.plugins}
@@ -729,7 +863,7 @@ function HubTable({ items, actionId, tagLabels, language, text, onSelect, onActi onAction: (entry: HubCatalogEntry, action: 'install' | 'update' | 'uninstall') => void; }) { return ( -
+
@@ -822,7 +956,7 @@ function HubTree({ items, actionId, text, onSelect, onAction }: { }, [items]); return ( -
+
); @@ -1019,7 +1153,9 @@ function PluginDetail({ entry, language, onClose, onAction, actionId, text }: { {text.workflowDiagram}
- +
}> + + ) : workflowError ? ( @@ -1093,11 +1229,13 @@ function ActionButtons({ item, actionId, text, onAction, compact = false }: { compact?: boolean; onAction: (entry: HubCatalogEntry, action: 'install' | 'update' | 'uninstall') => void; }) { + const { user } = useAuth(); const busy = actionId?.startsWith(`${item.type}:${item.id}:`); const buttonClass = compact ? 'p-1.5 rounded-md border border-gray-200 hover:bg-gray-50 disabled:opacity-50' : 'inline-flex items-center gap-1 whitespace-nowrap px-2 py-1 rounded-md border border-gray-200 text-xs hover:bg-gray-50 disabled:opacity-50'; if (busy) return ; + if (user?.role !== 'admin') return null; if (item.native && item.state === 'installed') return null; if (item.state === 'available') return ; if (item.state === 'updateAvailable') return ; diff --git a/webui/src/pages/MCP/index.tsx b/webui/src/pages/MCP/index.tsx index bb7e27891..e5ca185da 100644 --- a/webui/src/pages/MCP/index.tsx +++ b/webui/src/pages/MCP/index.tsx @@ -119,7 +119,7 @@ function ServersTab() { setSelectedServer(server); }; - if (loading) return
; + if (loading) return
; if (error) return (
@@ -290,7 +290,7 @@ function CatalogTab() { } }; - if (loading) return
; + if (loading) return
; return (
diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index 06e092dc7..1515819c6 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -20,6 +20,9 @@ const mocks = vi.hoisted(() => ({ listDefinitions: vi.fn(), catalogList: vi.fn(), createProvider: vi.fn(), + getCredentials: vi.fn(), + setCredentials: vi.fn(), + testCredentials: vi.fn(), })); vi.mock('react-i18next', () => ({ @@ -42,6 +45,7 @@ vi.mock('react-i18next', () => ({ 'form.baseUrlRequired': 'Please enter Base URL', 'form.apiKeyOptional': '(optional, leave empty for no-auth gateways)', 'form.apiKeyOptionalHint': 'Leave empty for no-auth gateway', + 'form.apiKeyKeepExisting': 'Leave blank to keep the existing API key', 'form.searchProvider': 'Search providers...', 'form.noResults': 'No results', 'form.alreadyAdded': 'Already added', @@ -108,9 +112,9 @@ vi.mock('@/components/common/EntitySheet', () => ({ vi.mock('@/api/provider', () => ({ providerAPI: { - getCredentials: vi.fn(), - setCredentials: vi.fn(), - testCredentials: vi.fn(), + getCredentials: mocks.getCredentials, + setCredentials: mocks.setCredentials, + testCredentials: mocks.testCredentials, }, modelV2API: { listDefinitions: mocks.listDefinitions, @@ -218,3 +222,120 @@ describe('ModelPage add provider dialog', () => { }); }); }); + +describe('ModelPage configure provider dialog', () => { + const provider = { + id: 'openai', + name: 'OpenAI', + source: 'config', + env: [], + key: null, + options: {}, + models: {}, + configured: true, + modelCount: 1, + category: 'connected', + }; + const model = { + id: 'gpt-4o', + name: 'GPT-4o', + provider_id: 'openai', + model_type: 'chat', + status: 'active', + capabilities: { + features: [], + supports_streaming: true, + supports_tools: true, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + sessionStorage.clear(); + mocks.useProviders.mockReturnValue({ + providers: [provider], + connectedIds: ['openai'], + loading: false, + error: null, + refetch: mocks.refetch, + }); + mocks.getSummary.mockResolvedValue({ data: null }); + mocks.getResolved.mockResolvedValue({ data: null }); + mocks.listDefinitions.mockResolvedValue({ data: { models: [model], total: 1 } }); + mocks.catalogList.mockResolvedValue({ + data: { + providers: [{ id: 'openai', models: [] }], + }, + }); + mocks.getCredentials.mockResolvedValue({ + data: { + secret_id: 'openai_llm_key', + api_key: null, + api_key_masked: 'sk-***1234', + base_url: 'https://old.example.com/v1', + has_credential: true, + }, + }); + mocks.setCredentials.mockResolvedValue({ data: { success: true } }); + mocks.testCredentials.mockResolvedValue({ + data: { + success: true, + message: 'ok', + model_id: 'gpt-4o', + question: 'ping', + answer: 'pong', + latency_ms: 10, + }, + }); + }); + + async function openConfigureDialog(user: ReturnType) { + renderWithRouter(); + await user.click(await screen.findByTitle('Configure')); + expect(await screen.findByTestId('entity-sheet')).toBeInTheDocument(); + } + + it('preserves an existing secret when saving a new base URL with a blank key', async () => { + const user = userEvent.setup(); + await openConfigureDialog(user); + + const apiKeyInput = screen.getByPlaceholderText('Leave blank to keep the existing API key'); + expect(apiKeyInput).toHaveValue(''); + expect(apiKeyInput).not.toHaveValue('sk-***1234'); + + const baseUrlInput = screen.getByPlaceholderText('https://api.example.com/v1'); + await user.clear(baseUrlInput); + await user.type(baseUrlInput, 'https://new.example.com/v1'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mocks.setCredentials).toHaveBeenCalled()); + const payload = mocks.setCredentials.mock.calls.at(-1)?.[1]; + expect(payload).toEqual(expect.objectContaining({ + base_url: 'https://new.example.com/v1', + })); + expect(payload).not.toHaveProperty('api_key'); + }); + + it('persists a base URL change without a key before testing the connection', async () => { + const user = userEvent.setup(); + await openConfigureDialog(user); + mocks.setCredentials.mockClear(); + mocks.testCredentials.mockClear(); + + const baseUrlInput = screen.getByPlaceholderText('https://api.example.com/v1'); + await user.clear(baseUrlInput); + await user.type(baseUrlInput, 'https://test.example.com/v1'); + await user.click(screen.getByRole('button', { name: 'form.testConnection2' })); + + await waitFor(() => expect(mocks.setCredentials).toHaveBeenCalled()); + const payload = mocks.setCredentials.mock.calls[0]?.[1]; + expect(payload).toEqual(expect.objectContaining({ + base_url: 'https://test.example.com/v1', + })); + expect(payload).not.toHaveProperty('api_key'); + await waitFor(() => { + expect(mocks.testCredentials).toHaveBeenCalledWith('openai', 'gpt-4o'); + }); + }); +}); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 8175777a6..3a8e1b4c8 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -31,7 +31,7 @@ import { import type { ProviderCredentials, ModelDefinitionV2, UsageStats, CatalogProvider, CatalogModel, CatalogCredentialField, ModelSettingV2, - CustomModelCreate, + CustomModelCreate, ProviderCredentialInput, } from '@/types'; // ==================== Provider Auth Helpers ==================== @@ -418,7 +418,7 @@ export default function ModelPage() { if (loading) { return (
- +
); } @@ -2192,7 +2192,10 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos const toast = useToast(); const { t } = useTranslation('model'); const hasExisting = existingCredentials?.has_credential ?? false; - const existingKey = existingCredentials?.api_key ?? ''; + // Credential reads intentionally never return the raw key. Keep the input + // empty and use hasExisting to express "leave blank to preserve"; in + // particular, never seed this field with api_key_masked. + const existingKey = ''; const existingBaseUrl = existingCredentials?.base_url ?? ''; const [baseUrl, setBaseUrl] = useState(existingCredentials?.base_url ?? ''); @@ -2269,19 +2272,21 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos }; const handleSubmit = async () => { - if (!apiKey.trim() && !providerAllowsEmptyApiKey(provider.id)) { + const nextApiKey = apiKey.trim(); + if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { toast.warning('Please enter API Key'); return; } try { setLoading(true); - await providerAPI.setCredentials(provider.id, { - api_key: apiKey.trim() || 'not-needed', + const payload: ProviderCredentialInput = { base_url: baseUrl.trim() || undefined, provider_name: (provider.id === 'openai-compatible' || provider.id.startsWith('custom-')) ? (providerName.trim() || undefined) : undefined, - }); + }; + if (nextApiKey) payload.api_key = nextApiKey; + await providerAPI.setCredentials(provider.id, payload); // Sync catalog model list: add newly selected, delete deselected if (catalogModels.length > 0) { @@ -2320,24 +2325,28 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos { apiKey, baseUrl }, ); + const nextApiKey = apiKey.trim(); + if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { + toast.warning('Please enter API Key first'); + return; + } + // Persist pending credential changes before testing so the backend uses // the latest base URL even when the API key itself did not change. - if (apiKey.trim() && hasPendingChanges) { + if (hasPendingChanges) { try { - await providerAPI.setCredentials(provider.id, { - api_key: apiKey.trim(), + const payload: ProviderCredentialInput = { base_url: baseUrl.trim() || undefined, provider_name: (provider.id === 'openai-compatible' || provider.id.startsWith('custom-')) ? (providerName.trim() || undefined) : undefined, - }); + }; + if (nextApiKey) payload.api_key = nextApiKey; + await providerAPI.setCredentials(provider.id, payload); } catch (err: any) { toast.error(t('deleteFailed'), err.message); return; } - } else if (!apiKey.trim() && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { - toast.warning('Please enter API Key first'); - return; } try { setTesting(true); @@ -2451,7 +2460,7 @@ ${hasExisting ? '你已有凭证配置,可以更新或测试连接。' : '请
@@ -127,7 +179,7 @@ export default function MonitoringPage() {
- {status?.activeAgents || 0} + {status?.activeAgents ?? t('unavailable')}
{t('activeAgents')}
@@ -137,7 +189,7 @@ export default function MonitoringPage() {
- {metrics?.messageRate.toFixed(1) || '0'} + {metrics?.messageRate == null ? t('unavailable') : metrics.messageRate.toFixed(1)}
{t('messagesPerMin')}
@@ -147,35 +199,41 @@ export default function MonitoringPage() { {metrics && (

{t('realTimeMetrics')}

-
+
{t('metrics.messageRate')}
- {metrics.messageRate.toFixed(1)}/min + {formatRate(metrics.messageRate)}
{t('metrics.toolCallRate')}
- {metrics.toolCallRate.toFixed(1)}/min + {formatRate(metrics.toolCallRate)}
{t('metrics.errorRate')}
- {metrics.errorRate.toFixed(1)}/min + {formatRate(metrics.errorRate)} +
+
+
+
{t('metrics.toolParseFailureRate')}
+
+ {formatRate(metrics.toolParseFailureRate)}
{t('metrics.avgResponse')}
- {metrics.avgResponseTime.toFixed(0)}ms + {metrics.avgResponseTime == null ? t('unavailable') : `${metrics.avgResponseTime.toFixed(0)}ms`}
{t('metrics.activeRequests')}
- {metrics.activeRequests} + {metrics.activeRequests ?? t('unavailable')}
diff --git a/webui/src/pages/Permission/index.tsx b/webui/src/pages/Permission/index.tsx index ff6f1c77d..b6b56bc1d 100644 --- a/webui/src/pages/Permission/index.tsx +++ b/webui/src/pages/Permission/index.tsx @@ -51,7 +51,7 @@ export default function PermissionPage() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 5940ab6ce..0f44eb568 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useNavigate } from 'react-router-dom'; -import SessionPage from './index'; +import { __resetChatModelResourcesForTesting } from '@/hooks/useChatModelResources'; +import SessionPage, { getVisibleSessionGroupItems, groupSessionsByDate } from './index'; const { client, @@ -107,6 +108,7 @@ vi.mock('@/components/common/SessionChat', () => ({ initialMessage, initialDisplayText, onCreateAndSend, + onSSEEvent, agentName, model, display, @@ -137,6 +139,7 @@ vi.mock('@/components/common/SessionChat', () => ({ modelOverride?: unknown, options?: { displayText?: string }, ) => Promise | unknown; + onSSEEvent?: (event: { type: string; properties?: Record }) => void; }) { const [input, setInput] = React.useState(''); return ( @@ -162,6 +165,15 @@ vi.mock('@/components/common/SessionChat', () => ({ +
); }, @@ -248,9 +260,75 @@ function renderSessionPage( ); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('session sidebar grouping helpers', () => { + it('groups sessions by updated date and applies search filtering', () => { + const now = new Date(2026, 6, 9, 12, 0, 0); + const makeSession = (id: string, title: string, updated: number) => ({ + ...session, + id, + title, + time: { ...session.time, updated }, + }); + + const groups = groupSessionsByDate([ + makeSession('today', 'Today Investigation', new Date(2026, 6, 9, 9).getTime()), + makeSession('yesterday', 'Yesterday Work', new Date(2026, 6, 8, 9).getTime()), + makeSession('earlier', 'Old Investigation', new Date(2026, 5, 1, 9).getTime()), + ], 'investigation', now); + + expect(groups.map((group) => [group.key, group.items.map((item) => item.id)])).toEqual([ + ['today', ['today']], + ['earlier', ['earlier']], + ]); + }); + + it('limits collapsed older groups and reports hidden count', () => { + const group = { + key: 'thisWeek' as const, + labelKey: 'groupThisWeek', + items: Array.from({ length: 7 }, (_, index) => ({ + ...session, + id: `session-${index}`, + title: `Session ${index}`, + })), + }; + + expect(getVisibleSessionGroupItems({ + group, + expanded: false, + searching: false, + })).toMatchObject({ + visibleItems: group.items.slice(0, 5), + hiddenCount: 2, + limit: 5, + }); + + expect(getVisibleSessionGroupItems({ + group, + expanded: false, + searching: true, + })).toMatchObject({ + visibleItems: group.items, + hiddenCount: 0, + limit: Infinity, + }); + }); +}); + describe('SessionPage session actions menu', () => { beforeEach(() => { vi.clearAllMocks(); + __resetChatModelResourcesForTesting(); localStorage.clear(); sessionStorage.clear(); @@ -336,6 +414,34 @@ describe('SessionPage session actions menu', () => { expect(sessionApi.update).toHaveBeenCalledTimes(1); }); + it('coalesces bursty session.updated events into one sidebar refetch', () => { + vi.useFakeTimers(); + try { + renderSessionPage(); + + const emitSessionUpdated = screen.getByRole('button', { name: 'mock-session-updated' }); + act(() => { + emitSessionUpdated.click(); + emitSessionUpdated.click(); + }); + + expect(updateSessionTitle).toHaveBeenCalledTimes(2); + expect(refetchSessions).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(499); + }); + expect(refetchSessions).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(refetchSessions).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('downloads session data as CLI-compatible JSON', async () => { const user = userEvent.setup(); const OriginalBlob = Blob; @@ -690,6 +796,7 @@ describe('SessionPage session actions menu', () => { }); it('keeps a selected session that is valid but missing from the current list', async () => { + const request = deferred(); useSessions.mockReturnValue({ sessions: [], loading: false, @@ -700,17 +807,28 @@ describe('SessionPage session actions menu', () => { removeSessions, addSession, }); - sessionApi.get.mockResolvedValue({ + sessionApi.get.mockReturnValue(request.promise); + const fetchedSession = { ...session, id: 'session-missing-from-list', title: 'Fetched Session', canWrite: false, - }); + }; renderSessionPage('/sessions?session=session-missing-from-list'); await waitFor(() => { expect(sessionApi.get).toHaveBeenCalledWith('session-missing-from-list'); + }); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + expect(screen.getByText('loading-spinner')).toBeInTheDocument(); + + await act(async () => { + request.resolve(fetchedSession); + await request.promise; + }); + + await waitFor(() => { expect(screen.getByTestId('session-chat')).toHaveTextContent('session-missing-from-list'); expect(screen.getByTestId('session-chat')).toHaveAttribute('data-hide-input', 'true'); }); @@ -737,6 +855,27 @@ describe('SessionPage session actions menu', () => { }); }); + it('drops a URL initial message when the target session no longer exists', async () => { + const user = userEvent.setup(); + const message = 'Do not send this to another session'; + sessionApi.get.mockRejectedValue({ response: { status: 404 } }); + + renderSessionPage(`/sessions?session=session-deleted&message=${encodeURIComponent(message)}`); + + await waitFor(() => { + expect(sessionApi.get).toHaveBeenCalledWith('session-deleted'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('no-session'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + }); + + await user.click(screen.getByText('Original Session')); + + await waitFor(() => { + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + }); + }); + it('lists the same visible agents as the Agent page selector logic', async () => { const user = userEvent.setup(); useAgents.mockReturnValue({ diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index ee5eb702d..bf3a4a124 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { memo, useState, useEffect, useMemo, useCallback, useRef, type RefObject } from 'react'; import { MessageSquare, Plus, Trash2, ChevronDown, Sparkles, Shield, Search, AlertTriangle, @@ -24,8 +24,8 @@ import type { Agent } from '@/api/agent'; import { useSessions } from '@/hooks/useSessions'; import { useAgents } from '@/hooks/useAgents'; import { useProviders } from '@/hooks/useProviders'; +import { useEnabledChatModelDefinitions, useResolvedDefaultModel } from '@/hooks/useChatModelResources'; import client from '@/api/client'; -import { defaultModelAPI, modelV2API } from '@/api/provider'; import { useDefaultModelVision } from '@/hooks/useDefaultModelVision'; import { buildPromptParts, type ImagePartData } from '@/utils/imageUpload'; import { getAgentDisplayDescription, getAgentDisplayName, isAgentUsableInChat } from '@/utils/agentDisplay'; @@ -45,6 +45,7 @@ const LAST_SELECTED_SESSION_STORAGE_KEY = 'flocks:last-selected-session'; const SESSION_PAGE_VISITED_STORAGE_KEY = 'flocks:sessions:visited'; const SOC_WORKSPACE_COMPONENT_ID = 'soc-workspace'; const INSTALLED_HUB_STATES = new Set(['installed', 'localOnly', 'updateAvailable']); +const SESSION_UPDATE_REFETCH_DEBOUNCE_MS = 500; type AgentSourceFilter = 'all' | 'builtin' | 'custom'; type ChatModelOption = { key: string; @@ -122,6 +123,219 @@ function makeModelKey(providerID: string, modelID: string): string { return `${providerID}::${modelID}`; } +export type SessionGroupKey = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'earlier'; + +export interface SessionGroup { + key: SessionGroupKey; + labelKey: string; + items: Session[]; +} + +const SESSION_GROUP_DEFAULT_LIMIT: Record = { + today: Infinity, + yesterday: Infinity, + thisWeek: 5, + lastWeek: 5, + earlier: 5, +}; + +const SESSION_GROUP_DEFS: Array> = [ + { key: 'today', labelKey: 'groupToday' }, + { key: 'yesterday', labelKey: 'groupYesterday' }, + { key: 'thisWeek', labelKey: 'groupThisWeek' }, + { key: 'lastWeek', labelKey: 'groupLastWeek' }, + { key: 'earlier', labelKey: 'groupEarlier' }, +]; + +export function groupSessionsByDate( + sessions: Session[], + searchQuery: string, + now = new Date(), +): SessionGroup[] { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const yesterdayStart = todayStart - 86400000; + const dayOfWeek = now.getDay() === 0 ? 7 : now.getDay(); + const thisWeekStart = todayStart - (dayOfWeek - 1) * 86400000; + const lastWeekStart = thisWeekStart - 7 * 86400000; + + const q = searchQuery.toLowerCase().trim(); + const filtered = q ? sessions.filter(s => s.title.toLowerCase().includes(q)) : sessions; + const buckets: SessionGroup[] = SESSION_GROUP_DEFS.map((group) => ({ + ...group, + items: [], + })); + + for (const session of filtered) { + const ts = session.time?.updated ?? 0; + if (ts >= todayStart) buckets[0].items.push(session); + else if (ts >= yesterdayStart) buckets[1].items.push(session); + else if (ts >= thisWeekStart) buckets[2].items.push(session); + else if (ts >= lastWeekStart) buckets[3].items.push(session); + else buckets[4].items.push(session); + } + + return buckets.filter(group => group.items.length > 0); +} + +export function getVisibleSessionGroupItems({ + group, + expanded, + searching, +}: { + group: SessionGroup; + expanded: boolean; + searching: boolean; +}): { visibleItems: Session[]; hiddenCount: number; limit: number } { + const limit = searching ? Infinity : SESSION_GROUP_DEFAULT_LIMIT[group.key]; + const visibleItems = (searching || expanded || group.items.length <= limit) + ? group.items + : group.items.slice(0, limit); + return { + visibleItems, + hiddenCount: group.items.length - visibleItems.length, + limit, + }; +} + +interface SessionSidebarItemProps { + session: Session; + selected: boolean; + selectMode: boolean; + checked: boolean; + menuOpen: boolean; + renaming: boolean; + renameValue: string; + renameSubmitting: boolean; + t: (key: string, options?: Record) => string; + renameInputRef: RefObject; + onSelect: (sessionId: string) => void; + onToggleCheck: (sessionId: string) => void; + onRenameValueChange: (value: string) => void; + onSubmitRename: (sessionId: string) => void | Promise; + onCancelRename: () => void; + onToggleMenu: (sessionId: string, trigger: HTMLElement) => void; +} + +function SessionSidebarItemInner({ + session, + selected, + selectMode, + checked, + menuOpen, + renaming, + renameValue, + renameSubmitting, + t, + renameInputRef, + onSelect, + onToggleCheck, + onRenameValueChange, + onSubmitRename, + onCancelRename, + onToggleMenu, +}: SessionSidebarItemProps) { + return ( +
onSelect(session.id)} + className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ + !selectMode && selected + ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' + : selectMode && checked + ? 'bg-blue-50 border-blue-200 dark:border-blue-500/40 dark:bg-blue-950/30' + : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' + }`} + > +
+ {selectMode && ( + onToggleCheck(session.id)} + onClick={(e) => e.stopPropagation()} + className="flex-shrink-0 w-3.5 h-3.5 accent-blue-500 cursor-pointer rounded" + /> + )} + {session.category === 'workflow' && ( + + + + )} + {session.category === 'entity-config' && ( + + + + )} + {renaming ? ( + onRenameValueChange(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => void onSubmitRename(session.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); void onSubmitRename(session.id); } + if (e.key === 'Escape') { e.preventDefault(); onCancelRename(); } + }} + placeholder={t('renamePlaceholder')} + disabled={renameSubmitting} + className="w-full min-w-0 rounded border border-blue-300 bg-white px-1.5 py-0.5 text-sm text-gray-900 outline-none focus:border-blue-400 dark:border-blue-500/50 dark:bg-zinc-950 dark:text-zinc-100" + aria-label={t('rename')} + data-session-rename-input + /> + ) : ( +

+ {session.title} + {session.isShared && ( + + {t('sharedTag')} + + )} +

+ )} +
+ {session.time?.updated && !renaming && ( +

+ {formatSessionDate(session.time.updated)} +

+ )} + {!selectMode && ( +
+ +
+ )} +
+ ); +} + +const SessionSidebarItem = memo(SessionSidebarItemInner, (prev, next) => ( + prev.session.id === next.session.id && + prev.session.title === next.session.title && + prev.session.category === next.session.category && + prev.session.isShared === next.session.isShared && + prev.session.time?.updated === next.session.time?.updated && + prev.selected === next.selected && + prev.selectMode === next.selectMode && + prev.checked === next.checked && + prev.menuOpen === next.menuOpen && + prev.renaming === next.renaming && + prev.renameValue === next.renameValue && + prev.renameSubmitting === next.renameSubmitting && + prev.t === next.t +)); + export default function SessionPage() { const { t, i18n } = useTranslation('session'); const location = useLocation(); @@ -133,8 +347,6 @@ export default function SessionPage() { const [showAgentOptions, setShowAgentOptions] = useState(false); const [selectedModelKey, setSelectedModelKey] = useState(null); const [showModelOptions, setShowModelOptions] = useState(false); - const [enabledModelDefinitions, setEnabledModelDefinitions] = useState([]); - const [loadingEnabledModels, setLoadingEnabledModels] = useState(true); const [sseStatus, setSseStatus] = useState('disconnected'); const [creating, setCreating] = useState(false); const [installingSocWorkspace, setInstallingSocWorkspace] = useState(false); @@ -157,6 +369,7 @@ export default function SessionPage() { const [selectorTooltip, setSelectorTooltip] = useState(null); const renameInputRef = useRef(null); const renameSubmitInFlightRef = useRef(false); + const sessionUpdateRefetchTimerRef = useRef(null); const toast = useToast(); const { @@ -173,6 +386,10 @@ export default function SessionPage() { } = useSessions(searchQuery); const { agents, loading: loadingAgents } = useAgents(); const { providers, loading: loadingProviders } = useProviders(); + const { + data: enabledModelDefinitions, + loading: loadingEnabledModels, + } = useEnabledChatModelDefinitions(); const primaryAgents = useMemo(() => agents.filter((a) => a.mode === 'primary' && isAgentUsableInChat(a)), [agents]); const subAgents = useMemo( () => agents.filter((a) => a.mode !== 'primary' && isAgentUsableInChat(a)), @@ -256,6 +473,18 @@ export default function SessionPage() { .filter((group) => group.models.length > 0) .sort((a, b) => a.providerName.localeCompare(b.providerName)); }, [chatModelOptions, providers]); + const listedSelectedSession = useMemo( + () => sessions.find(s => s.id === selectedSessionId) ?? null, + [sessions, selectedSessionId], + ); + const selectedSession = listedSelectedSession + ?? (selectedSessionFallback?.id === selectedSessionId ? selectedSessionFallback : null); + const activeChatSessionId = selectedSession ? selectedSessionId : null; + const resolvingSelectedSession = Boolean(selectedSessionId && !selectedSession); + const pinnedModelKey = selectedSession?.model_pinned && selectedSession.provider && selectedSession.model + ? makeModelKey(selectedSession.provider, selectedSession.model) + : null; + const hasPinnedModelOption = !!pinnedModelKey && chatModelOptions.some((option) => option.key === pinnedModelKey); const selectedModelOption = useMemo( () => chatModelOptions.find((option) => option.key === selectedModelKey) ?? (selectedModelKey ? null : chatModelOptions[0] ?? null), [chatModelOptions, selectedModelKey], @@ -263,22 +492,11 @@ export default function SessionPage() { const selectedPromptModel = selectedModelOption ? { providerID: selectedModelOption.providerID, modelID: selectedModelOption.modelID } : null; + const { + data: resolvedDefaultModel, + initialized: resolvedDefaultModelInitialized, + } = useResolvedDefaultModel(chatModelOptions.length > 0 && !hasPinnedModelOption); const effectiveSupportsVision = selectedModelOption?.supportsVision ?? supportsVision; - const listedSelectedSession = useMemo( - () => sessions.find(s => s.id === selectedSessionId) ?? null, - [sessions, selectedSessionId], - ); - const selectedSession = listedSelectedSession - ?? (selectedSessionFallback?.id === selectedSessionId ? selectedSessionFallback : null); - - // 今天/昨天不限制;本周/上周/更早默认只显示 5 条 - const GROUP_DEFAULT_LIMIT: Record = { - today: Infinity, - yesterday: Infinity, - thisWeek: 5, - lastWeek: 5, - earlier: 5, - }; const [expandedGroups, setExpandedGroups] = useState>(new Set()); const toggleGroupExpand = useCallback((key: string) => { @@ -290,55 +508,54 @@ export default function SessionPage() { }, []); const groupedSessions = useMemo(() => { - const now = new Date(); - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const yesterdayStart = todayStart - 86400000; - // Week starts on Monday - const dayOfWeek = now.getDay() === 0 ? 7 : now.getDay(); - const thisWeekStart = todayStart - (dayOfWeek - 1) * 86400000; - const lastWeekStart = thisWeekStart - 7 * 86400000; - - const q = searchQuery.toLowerCase().trim(); - const filtered = q ? sessions.filter(s => s.title.toLowerCase().includes(q)) : sessions; - - const buckets: { key: string; labelKey: string; items: typeof sessions }[] = [ - { key: 'today', labelKey: 'groupToday', items: [] }, - { key: 'yesterday', labelKey: 'groupYesterday', items: [] }, - { key: 'thisWeek', labelKey: 'groupThisWeek', items: [] }, - { key: 'lastWeek', labelKey: 'groupLastWeek', items: [] }, - { key: 'earlier', labelKey: 'groupEarlier', items: [] }, - ]; - - for (const s of filtered) { - const ts = s.time?.updated ?? 0; - if (ts >= todayStart) buckets[0].items.push(s); - else if (ts >= yesterdayStart) buckets[1].items.push(s); - else if (ts >= thisWeekStart) buckets[2].items.push(s); - else if (ts >= lastWeekStart) buckets[3].items.push(s); - else buckets[4].items.push(s); - } - - return buckets.filter(b => b.items.length > 0); + return groupSessionsByDate(sessions, searchQuery); }, [sessions, searchQuery]); + const visibleSessionGroups = useMemo(() => { + const searching = searchQuery.trim().length > 0; + return groupedSessions.map((group) => ({ + ...group, + ...getVisibleSessionGroupItems({ + group, + expanded: expandedGroups.has(group.key), + searching, + }), + expanded: expandedGroups.has(group.key), + searching, + })); + }, [expandedGroups, groupedSessions, searchQuery]); // Handle SSE events for session-level updates (title changes, etc.) const handleChatError = useCallback((msg: string) => { toast.error(t('chat.error', 'Error'), msg); }, [toast, t]); + const scheduleSessionListRefetch = useCallback(() => { + if (sessionUpdateRefetchTimerRef.current !== null) return; + sessionUpdateRefetchTimerRef.current = window.setTimeout(() => { + sessionUpdateRefetchTimerRef.current = null; + void refetchSessions(); + }, SESSION_UPDATE_REFETCH_DEBOUNCE_MS); + }, [refetchSessions]); + + useEffect(() => () => { + if (sessionUpdateRefetchTimerRef.current !== null) { + window.clearTimeout(sessionUpdateRefetchTimerRef.current); + sessionUpdateRefetchTimerRef.current = null; + } + }, []); + const handleSSEEvent = useCallback((event: SSEChatEvent) => { if (event.type === 'session.updated' && event.properties?.id) { if (event.properties?.title) { // Instant local title update so the sidebar reflects the change immediately. updateSessionTitle(event.properties.id, event.properties.title); } - // Always do a silent background sync: session.updated also changes - // time.updated (affects ordering) and potentially other metadata. - // refetchSessions() is safe here — it never shows a loading spinner - // after the initial load (see initializedRef in useSessions). - refetchSessions(); + // Session/title updates can arrive in bursts when several sessions or + // background tasks finish together. Coalesce the full sidebar refresh so + // those bursts don't turn into a request/re-render storm. + scheduleSessionListRefetch(); } - }, [updateSessionTitle, refetchSessions]); + }, [scheduleSessionListRefetch, updateSessionTitle]); // Keep the selected session in sync with URL query params (e.g. onboarding // or other in-app navigation to `/sessions?session=...`). Clear the params @@ -357,6 +574,7 @@ export default function SessionPage() { setPendingInitialMessage(messageParam); setPendingInitialDisplayText(displayParam ? buildInstructionDisplayText(displayParam) : null); } else { + setPendingInitialMessage(null); setPendingInitialDisplayText(null); } setSearchParams({}, { replace: true }); @@ -381,9 +599,9 @@ export default function SessionPage() { }, [loadingSessions, location.state, searchParams, selectedSessionId]); useEffect(() => { - if (!selectedSessionId) return; + if (!selectedSessionId || selectedSession?.id !== selectedSessionId) return; writeLastSelectedSessionId(selectedSessionId); - }, [selectedSessionId]); + }, [selectedSession?.id, selectedSessionId]); useEffect(() => { if (!selectedSessionId) { @@ -394,6 +612,7 @@ export default function SessionPage() { setSelectedSessionFallback(null); return; } + if (selectedSessionFallback?.id === selectedSessionId) return; if (loadingSessions) return; let cancelled = false; @@ -408,31 +627,15 @@ export default function SessionPage() { if (statusCode === 403 || statusCode === 404) { setSelectedSessionId((current) => (current === selectedSessionId ? null : current)); setSelectedSessionFallback(null); + setPendingInitialMessage(null); + setPendingInitialDisplayText(null); writeLastSelectedSessionId(null); } }); return () => { cancelled = true; }; - }, [listedSelectedSession, loadingSessions, selectedSessionId]); - - useEffect(() => { - let cancelled = false; - setLoadingEnabledModels(true); - modelV2API.listDefinitions({ enabled_only: true }) - .then((response) => { - if (!cancelled) setEnabledModelDefinitions(response.data.models ?? []); - }) - .catch(() => { - if (!cancelled) setEnabledModelDefinitions([]); - }) - .finally(() => { - if (!cancelled) setLoadingEnabledModels(false); - }); - return () => { - cancelled = true; - }; - }, []); + }, [listedSelectedSession, loadingSessions, selectedSessionFallback?.id, selectedSessionId]); // Close agent dropdown on outside click useEffect(() => { @@ -461,35 +664,24 @@ export default function SessionPage() { return; } - const pinnedKey = selectedSession?.model_pinned && selectedSession.provider && selectedSession.model - ? makeModelKey(selectedSession.provider, selectedSession.model) - : null; - if (pinnedKey && chatModelOptions.some((option) => option.key === pinnedKey)) { - setSelectedModelKey(pinnedKey); + if (hasPinnedModelOption && pinnedModelKey) { + setSelectedModelKey(pinnedModelKey); return; } - let cancelled = false; setSelectedModelKey(null); - defaultModelAPI.getResolved() - .then((response) => { - if (cancelled) return; - const { provider_id: providerID, model_id: modelID } = response.data; - const defaultKey = makeModelKey(providerID, modelID); - const fallbackKey = chatModelOptions[0]?.key ?? null; - setSelectedModelKey(chatModelOptions.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); - }) - .catch(() => { - if (!cancelled) setSelectedModelKey(chatModelOptions[0]?.key ?? null); - }); - return () => { - cancelled = true; - }; + if (!resolvedDefaultModelInitialized) return; + const defaultKey = resolvedDefaultModel + ? makeModelKey(resolvedDefaultModel.providerID, resolvedDefaultModel.modelID) + : null; + const fallbackKey = chatModelOptions[0]?.key ?? null; + setSelectedModelKey(defaultKey && chatModelOptions.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); }, [ chatModelOptions, - selectedSession?.model, - selectedSession?.model_pinned, - selectedSession?.provider, + hasPinnedModelOption, + pinnedModelKey, + resolvedDefaultModel, + resolvedDefaultModelInitialized, selectedSessionId, ]); @@ -536,6 +728,7 @@ export default function SessionPage() { try { const response = await client.post('/api/session', { title: 'New Session' }); addSession(response.data); + setSelectedSessionFallback(response.data); setSelectedAgent('rex'); setSelectedModelKey(null); setSelectedSessionId(response.data.id); @@ -575,6 +768,7 @@ export default function SessionPage() { const newSessionId = response.data.id; addSession(response.data); + setSelectedSessionFallback(response.data); setSelectedModelKey(null); setSelectedSessionId(newSessionId); @@ -773,6 +967,24 @@ export default function SessionPage() { return next; }); }, []); + const handleSelectSessionRow = useCallback((sessionId: string) => { + if (selectMode) { + handleToggleCheck(sessionId); + } else { + setSelectedSessionId(sessionId); + } + }, [handleToggleCheck, selectMode]); + const handleToggleSessionMenu = useCallback((sessionId: string, trigger: HTMLElement) => { + setOpenMenuSessionId((current) => { + if (current === sessionId) { + setMenuAnchor(null); + return null; + } + const rect = trigger.getBoundingClientRect(); + setMenuAnchor({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + return sessionId; + }); + }, []); const handleSelectAll = useCallback(() => { if (checkedIds.size === sessions.length) { @@ -816,7 +1028,7 @@ export default function SessionPage() { if (loadingSessions) { return (
- +
); } @@ -866,118 +1078,34 @@ export default function SessionPage() {

{t('noResults', 'No conversations found')}

) : ( - groupedSessions.map(({ key, labelKey, items }) => { - const isSearching = searchQuery.trim().length > 0; - const limit = isSearching ? Infinity : (GROUP_DEFAULT_LIMIT[key] ?? 5); - const isExpanded = expandedGroups.has(key); - const visibleItems = (isSearching || isExpanded || items.length <= limit) - ? items - : items.slice(0, limit); - const hiddenCount = items.length - visibleItems.length; - - return ( + visibleSessionGroups.map(({ key, labelKey, items, visibleItems, hiddenCount, limit, expanded, searching }) => (
{t(labelKey, labelKey)}
{visibleItems.map((session) => ( -
selectMode ? handleToggleCheck(session.id) : setSelectedSessionId(session.id)} - className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ - !selectMode && selectedSessionId === session.id - ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' - : selectMode && checkedIds.has(session.id) - ? 'bg-blue-50 border-blue-200 dark:border-blue-500/40 dark:bg-blue-950/30' - : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' - }`} - > - {/* Title row */} -
- {selectMode && ( - handleToggleCheck(session.id)} - onClick={(e) => e.stopPropagation()} - className="flex-shrink-0 w-3.5 h-3.5 accent-blue-500 cursor-pointer rounded" - /> - )} - {session.category === 'workflow' && ( - - - - )} - {session.category === 'entity-config' && ( - - - - )} - {renamingSessionId === session.id ? ( - setRenameValue(e.target.value)} - onClick={(e) => e.stopPropagation()} - onBlur={() => void handleSubmitRename(session.id)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); void handleSubmitRename(session.id); } - if (e.key === 'Escape') { e.preventDefault(); handleCancelRename(); } - }} - placeholder={t('renamePlaceholder')} - disabled={renameSubmitting} - className="w-full min-w-0 rounded border border-blue-300 bg-white px-1.5 py-0.5 text-sm text-gray-900 outline-none focus:border-blue-400 dark:border-blue-500/50 dark:bg-zinc-950 dark:text-zinc-100" - aria-label={t('rename')} - data-session-rename-input - /> - ) : ( -

- {session.title} - {session.isShared && ( - - {t('sharedTag')} - - )} -

- )} -
- {/* Timestamp row */} - {session.time?.updated && renamingSessionId !== session.id && ( -

- {formatSessionDate(session.time.updated)} -

- )} - - {/* Three-dot menu trigger */} - {!selectMode && ( -
- -
- )} -
+ session={session} + selected={selectedSessionId === session.id} + selectMode={selectMode} + checked={checkedIds.has(session.id)} + menuOpen={openMenuSessionId === session.id} + renaming={renamingSessionId === session.id} + renameValue={renameValue} + renameSubmitting={renameSubmitting} + t={t} + renameInputRef={renameInputRef} + onSelect={handleSelectSessionRow} + onToggleCheck={handleToggleCheck} + onRenameValueChange={setRenameValue} + onSubmitRename={handleSubmitRename} + onCancelRename={handleCancelRename} + onToggleMenu={handleToggleSessionMenu} + /> ))} {/* 展开/收起按钮 */} - {!isSearching && hiddenCount > 0 && ( + {!searching && hiddenCount > 0 && ( )} - {!isSearching && isExpanded && items.length > (GROUP_DEFAULT_LIMIT[key] ?? 5) && ( + {!searching && expanded && items.length > limit && ( )}
- ); - }) + )) )} {hasMoreSessions && (
@@ -1075,10 +1202,15 @@ export default function SessionPage() {
{/* Chat — powered by unified SessionChat */} - + + + ) : ( + } - /> + /> + )} {selectorTooltip && ( diff --git a/webui/src/pages/Settings/index.tsx b/webui/src/pages/Settings/index.tsx index a8eca7d1a..337cd9e92 100644 --- a/webui/src/pages/Settings/index.tsx +++ b/webui/src/pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import type { ChangeEvent, ReactNode } from 'react'; +import type { ChangeEvent, ComponentType, ReactNode } from 'react'; import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -26,11 +26,24 @@ import { useAuth } from '@/contexts/AuthContext'; import { useProductName } from '@/contexts/ProductNameContext'; import { useToast } from '@/components/common/Toast'; import { flocksproUsersApi } from '@/api/flocksproUsers'; +import { preloadI18nNamespaces } from '@/i18nResources'; + +type LazySettingsModule = { default: ComponentType }; + +function lazySettingsPage( + loader: () => Promise, + namespaces: readonly string[] = [], +) { + return lazy(() => Promise.all([ + loader(), + preloadI18nNamespaces(namespaces), + ]).then(([module]) => module)); +} -const ConfigPage = lazy(() => import('@/pages/Config')); -const SystemLogPage = lazy(() => import('@/pages/SystemLog')); -const FlocksproUpgradePage = lazy(() => import('@/pages/FlocksproUpgrade')); -const AuditLogsPage = lazy(() => import('@/pages/AuditLogs')); +const ConfigPage = lazySettingsPage(() => import('@/pages/Config')); +const SystemLogPage = lazySettingsPage(() => import('@/pages/SystemLog')); +const FlocksproUpgradePage = lazySettingsPage(() => import('@/pages/FlocksproUpgrade'), ['flockspro']); +const AuditLogsPage = lazySettingsPage(() => import('@/pages/AuditLogs'), ['flockspro']); type SettingsSectionId = 'preferences' | 'account' | 'system-logs' | 'audit-logs' | 'flockspro'; @@ -367,7 +380,7 @@ function SettingsContent({ sectionId }: { sectionId: SettingsSectionId }) { if (sectionId === 'preferences') return ; return ( - }> + }> {sectionId === 'account' && } {sectionId === 'system-logs' && } {sectionId === 'audit-logs' && } diff --git a/webui/src/pages/Skill/SkillSheet.tsx b/webui/src/pages/Skill/SkillSheet.tsx index b11665c36..c1df862c5 100644 --- a/webui/src/pages/Skill/SkillSheet.tsx +++ b/webui/src/pages/Skill/SkillSheet.tsx @@ -7,16 +7,25 @@ * - Rex chat mode (natural language → extract config into form) */ -import { useMemo, useState } from 'react'; +import { lazy, Suspense, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { BookOpen, Lock, Pencil, Eye, Save, Loader2, Trash2 } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; import { skillAPI, Skill } from '@/api/skill'; import { useToast } from '@/components/common/Toast'; import EntitySheet from '@/components/common/EntitySheet'; import { buildGuidedCreateGroups } from '@/components/common/GuidedCreatePanel'; import { useRexComposerControls } from '@/components/common/useRexComposerControls'; +const ReactMarkdown = lazy(() => import('react-markdown')); + +function MarkdownFallback() { + return ( +
+ +
+ ); +} + interface SkillFormData { name: string; description: string; @@ -344,7 +353,9 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill className="px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-sm prose prose-sm max-w-none overflow-y-auto" style={{ minHeight: '320px', maxHeight: '65vh' }} > - {contentForRender || t('sheet.noContent')} + }> + {contentForRender || t('sheet.noContent')} + )} diff --git a/webui/src/pages/Skill/index.tsx b/webui/src/pages/Skill/index.tsx index f90f38c48..50e2bab9a 100644 --- a/webui/src/pages/Skill/index.tsx +++ b/webui/src/pages/Skill/index.tsx @@ -275,7 +275,7 @@ export default function SkillPage() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/Task/QueuedSection.tsx b/webui/src/pages/Task/QueuedSection.tsx index 68a33fc31..2ebc15f5c 100644 --- a/webui/src/pages/Task/QueuedSection.tsx +++ b/webui/src/pages/Task/QueuedSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { ListTodo, Play, RotateCcw, XCircle, Trash2, @@ -6,7 +6,6 @@ import { } from 'lucide-react'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import EmptyState from '@/components/common/EmptyState'; -import SessionChat from '@/components/common/SessionChat'; import { useToast } from '@/components/common/Toast'; import { useConfirm } from '@/components/common/ConfirmDialog'; import { useTaskExecutions } from '@/hooks/useTasks'; @@ -16,6 +15,15 @@ import { StatusBadge, PriorityBadge, SourceBadge, ModeBadge, ActionButton } from import { formatTime, formatDuration, PAGE_SIZE } from './helpers'; const DETAIL_POLL_INTERVAL_MS = 30000; +const LazySessionChat = lazy(() => import('@/components/common/SessionChat')); + +function SessionChatFallback() { + return ( +
+ +
+ ); +} export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () => void }) { const { t } = useTranslation('task'); @@ -233,7 +241,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () setSelectedTasks(new Set()); }; - if (loading && effectiveTasks.length === 0) return
; + if (loading && effectiveTasks.length === 0) return
; if (error) return
{error}
; return ( @@ -454,18 +462,20 @@ function QueuedDetailPanel({ task, onClose, onAction, onRefresh }: { {isWorkflowExecution ? ( ) : ( - { - if (event.type === 'task.updated' && event.properties?.executionID === task.id) { - onRefresh?.(); - } - }} - /> + }> + { + if (event.type === 'task.updated' && event.properties?.executionID === task.id) { + onRefresh?.(); + } + }} + /> + )} diff --git a/webui/src/pages/Task/ScheduledSection.tsx b/webui/src/pages/Task/ScheduledSection.tsx index e1ebc1b38..ce9c9645a 100644 --- a/webui/src/pages/Task/ScheduledSection.tsx +++ b/webui/src/pages/Task/ScheduledSection.tsx @@ -48,7 +48,7 @@ export default function ScheduledSection({ onRefreshGlobal }: { onRefreshGlobal: } }; - if (loading && tasks.length === 0) return
; + if (loading && tasks.length === 0) return
; if (error) return
{error}
; return ( diff --git a/webui/src/pages/Tool/components/APITabContent.test.tsx b/webui/src/pages/Tool/components/APITabContent.test.tsx new file mode 100644 index 000000000..b6329a742 --- /dev/null +++ b/webui/src/pages/Tool/components/APITabContent.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import APITabContent from './APITabContent'; + +const { apiDetailProps, listAllToolPages, mcpAPI, providerAPI } = vi.hoisted(() => ({ + apiDetailProps: vi.fn(), + listAllToolPages: vi.fn(), + mcpAPI: { + catalogInstall: vi.fn(), + connect: vi.fn(), + }, + providerAPI: { + listApiServices: vi.fn(), + updateApiService: vi.fn(), + deleteApiService: vi.fn(), + }, +})); + +vi.mock('@/api/provider', () => ({ providerAPI })); +vi.mock('@/api/mcp', () => ({ mcpAPI })); +vi.mock('@/api/tool', () => ({ listAllToolPages })); + +vi.mock('@/components/common/LoadingSpinner', () => ({ + default: () =>
loading
, +})); + +vi.mock('@/components/common/EmptyState', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock('./ServiceDetailPanel', () => ({ + APIServiceDetailPanel: (props: { serviceTools: Array<{ name: string }> }) => { + apiDetailProps(props); + return ( +
+ detail-panel + {props.serviceTools.map((tool) => {tool.name})} +
+ ); + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'zh-CN' }, + }), +})); + +describe('APITabContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + providerAPI.listApiServices.mockResolvedValue({ data: [] }); + listAllToolPages.mockResolvedValue([]); + }); + + it('loads the complete tool list when a service detail drawer opens', async () => { + const user = userEvent.setup(); + providerAPI.listApiServices.mockResolvedValue({ + data: [ + { + id: 'service-a', + name: 'Service A', + description: 'Service A API', + enabled: true, + status: 'connected', + tool_count: 2, + verify_ssl: false, + }, + ], + }); + listAllToolPages.mockResolvedValue([ + { + name: 'complete-api-tool', + description: 'Loaded beyond the current page', + category: 'custom', + source: 'api', + source_name: 'service-a', + enabled: true, + }, + ]); + + render( + , + ); + + await user.click((await screen.findByText('Service A')).closest('button')!); + + await waitFor(() => { + expect(listAllToolPages).toHaveBeenCalledWith({ + source: 'api', + sourceName: 'service-a', + sortBy: 'name', + sortDir: 'asc', + }); + }); + expect(await screen.findByText('complete-api-tool')).toBeInTheDocument(); + expect(apiDetailProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + serviceTools: [expect.objectContaining({ name: 'complete-api-tool' })], + }), + ); + }); +}); diff --git a/webui/src/pages/Tool/components/APITabContent.tsx b/webui/src/pages/Tool/components/APITabContent.tsx index 3144ca050..13f54e3c7 100644 --- a/webui/src/pages/Tool/components/APITabContent.tsx +++ b/webui/src/pages/Tool/components/APITabContent.tsx @@ -5,9 +5,10 @@ import { import { useTranslation } from 'react-i18next'; import { mcpAPI } from '@/api/mcp'; import { providerAPI } from '@/api/provider'; -import type { Tool } from '@/api/tool'; +import { listAllToolPages, type Tool } from '@/api/tool'; import type { APIServiceSummary, MCPCatalogCategory, MCPCatalogEntry } from '@/types'; import EmptyState from '@/components/common/EmptyState'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; import { getCatalogDescription } from '@/utils/mcpCatalog'; import { APIServiceDetailPanel } from './ServiceDetailPanel'; import { SERVICE_TAB_GRID_COLS } from './gridLayout'; @@ -64,6 +65,7 @@ export default function APITabContent({ const [installing, setInstalling] = useState(null); const [credModalEntry, setCredModalEntry] = useState(null); const [credValues, setCredValues] = useState>({}); + const [serviceToolCache, setServiceToolCache] = useState>({}); const fetchServices = useCallback(async () => { try { @@ -86,10 +88,36 @@ export default function APITabContent({ () => (selectedServiceId ? services.find((service) => service.id === selectedServiceId) ?? null : null), [selectedServiceId, services], ); - const selectedModuleTools = selectedServiceId ? (toolsByModule[selectedServiceId] || []) : []; + const selectedModuleTools = selectedServiceId + ? (serviceToolCache[selectedServiceId] ?? toolsByModule[selectedServiceId] ?? []) + : []; const isConfigured = useCallback((entryId: string) => configuredIds.has(entryId), [configuredIds]); const PRIORITY_IDS = useMemo(() => new Set(['virustotal_mcp', 'urlhaus']), []); + useEffect(() => { + setServiceToolCache({}); + }, [tools]); + + useEffect(() => { + if (!selectedServiceId || serviceToolCache[selectedServiceId]) return; + let cancelled = false; + void listAllToolPages({ + source: 'api', + sourceName: selectedServiceId, + sortBy: 'name', + sortDir: 'asc', + }).then((serviceTools) => { + if (!cancelled) { + setServiceToolCache((current) => ({ ...current, [selectedServiceId]: serviceTools })); + } + }).catch(() => { + // Keep the current page slice visible; reopening retries the complete service list. + }); + return () => { + cancelled = true; + }; + }, [selectedServiceId, serviceToolCache]); + const inactiveCatalogEntries = useMemo(() => { const activeModuleIds = new Set(services.map((service) => service.id)); return catalogEntries.filter((entry) => !activeModuleIds.has(entry.id)); @@ -301,12 +329,15 @@ export default function APITabContent({ )} - {services.length === 0 && filteredCatalog.length === 0 && !catalogLoading && !servicesLoading ? ( + {servicesLoading && services.length === 0 && filteredCatalog.length === 0 ? ( +
+ +
+ ) : services.length === 0 && filteredCatalog.length === 0 && !catalogLoading ? ( } title={t('api.noTools')} description={t('api.noToolsDesc')} /> ) : (
{services.map((service) => { - const serviceTools = toolsByModule[service.id] || []; const isSelected = selectedServiceId === service.id; const rowDescription = getServiceDescription(service) || `${service.name} API service`; @@ -356,7 +387,7 @@ export default function APITabContent({ {/* Stats column */}
- {serviceTools.length} + {service.tool_count} {service.latency_ms != null && {service.latency_ms}ms}
diff --git a/webui/src/pages/Tool/components/LocalTabContent.tsx b/webui/src/pages/Tool/components/LocalTabContent.tsx index ed0e64c3e..aa3e7b528 100644 --- a/webui/src/pages/Tool/components/LocalTabContent.tsx +++ b/webui/src/pages/Tool/components/LocalTabContent.tsx @@ -154,7 +154,7 @@ export default function LocalTabContent({ {/* Stats column */}
- {t('local.paramsCount', { count: tool.parameters?.length || 0 })} + {t('local.paramsCount', { count: tool.parameters_count ?? tool.parameters?.length ?? 0 })}
{/* Actions column */} diff --git a/webui/src/pages/Tool/components/MCPTabContent.test.tsx b/webui/src/pages/Tool/components/MCPTabContent.test.tsx index b6895b306..f5948fdf0 100644 --- a/webui/src/pages/Tool/components/MCPTabContent.test.tsx +++ b/webui/src/pages/Tool/components/MCPTabContent.test.tsx @@ -4,12 +4,14 @@ import userEvent from '@testing-library/user-event'; import MCPTabContent from './MCPTabContent'; -const { mcpAPI } = vi.hoisted(() => ({ +const { listAllToolPages, mcpAPI, mcpDetailProps } = vi.hoisted(() => ({ + listAllToolPages: vi.fn(), mcpAPI: { list: vi.fn(), catalogInstall: vi.fn(), connect: vi.fn(), }, + mcpDetailProps: vi.fn(), })); vi.mock('@/api/mcp', () => ({ @@ -17,6 +19,7 @@ vi.mock('@/api/mcp', () => ({ })); vi.mock('@/api/tool', () => ({ + listAllToolPages, toolAPI: { test: vi.fn(), }, @@ -31,7 +34,15 @@ vi.mock('@/components/common/EmptyState', () => ({ })); vi.mock('./ServiceDetailPanel', () => ({ - MCPServerDetailPanel: () =>
detail-panel
, + MCPServerDetailPanel: (props: { serverTools: Array<{ name: string }> }) => { + mcpDetailProps(props); + return ( +
+ detail-panel + {props.serverTools.map((tool) => {tool.name})} +
+ ); + }, })); vi.mock('react-i18next', () => ({ @@ -69,6 +80,7 @@ describe('MCPTabContent', () => { }, }); mcpAPI.connect.mockResolvedValue({ data: true }); + listAllToolPages.mockResolvedValue([]); }); it('collects credentials and env vars before installing protected catalog entries', async () => { @@ -145,4 +157,61 @@ describe('MCPTabContent', () => { expect(onConfiguredChange).toHaveBeenCalledWith('panther'); expect(onRefreshTools).toHaveBeenCalled(); }); + + it('loads the complete tool list when a server detail drawer opens', async () => { + const user = userEvent.setup(); + mcpAPI.list.mockResolvedValue({ + data: [ + { + name: 'server-a', + status: 'connected', + tools: [], + resources: [], + tools_count: 2, + resources_count: 0, + }, + ], + }); + listAllToolPages.mockResolvedValue([ + { + name: 'complete-tool', + description: 'Loaded beyond the current page', + category: 'custom', + source: 'mcp', + source_name: 'server-a', + enabled: true, + }, + ]); + + render( + , + ); + + await user.click(await screen.findByText('server-a')); + + await waitFor(() => { + expect(listAllToolPages).toHaveBeenCalledWith({ + source: 'mcp', + sourceName: 'server-a', + sortBy: 'name', + sortDir: 'asc', + }); + }); + expect(await screen.findByText('complete-tool')).toBeInTheDocument(); + expect(mcpDetailProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + serverTools: [expect.objectContaining({ name: 'complete-tool' })], + }), + ); + }); }); diff --git a/webui/src/pages/Tool/components/MCPTabContent.tsx b/webui/src/pages/Tool/components/MCPTabContent.tsx index b7ede1274..48fe0592c 100644 --- a/webui/src/pages/Tool/components/MCPTabContent.tsx +++ b/webui/src/pages/Tool/components/MCPTabContent.tsx @@ -5,7 +5,7 @@ import { Download, ExternalLink, Star, TestTube, Play, Info, CheckCircle, XCircle, AlertTriangle, Power, } from 'lucide-react'; import { mcpAPI } from '@/api/mcp'; -import { toolAPI } from '@/api/tool'; +import { listAllToolPages, toolAPI } from '@/api/tool'; import type { Tool } from '@/api/tool'; import type { MCPCatalogCategory, MCPCatalogEntry, MCPServer } from '@/types'; import LoadingSpinner from '@/components/common/LoadingSpinner'; @@ -73,6 +73,7 @@ export default function MCPTabContent({ const [setupEntry, setSetupEntry] = useState(null); const [setupCredentials, setSetupCredentials] = useState>({}); const [setupEnvOverrides, setSetupEnvOverrides] = useState>({}); + const [serverToolCache, setServerToolCache] = useState>({}); const fetchServers = useCallback(async () => { try { @@ -165,11 +166,48 @@ export default function MCPTabContent({ return map; }, [tools]); + useEffect(() => { + setServerToolCache({}); + }, [tools]); + + useEffect(() => { + if (!selectedServer || serverToolCache[selectedServer]) return; + let cancelled = false; + void listAllToolPages({ + source: 'mcp', + sourceName: selectedServer, + sortBy: 'name', + sortDir: 'asc', + }).then((serverTools) => { + if (!cancelled) { + setServerToolCache((current) => ({ ...current, [selectedServer]: serverTools })); + } + }).catch(() => { + // Keep the current page slice visible; reopening retries the complete server list. + }); + return () => { + cancelled = true; + }; + }, [selectedServer, serverToolCache]); + const setSelectedCard = useCallback((name: string | null) => { setSelectedServer(name); setSelectedServerData(name ? (servers.find((server) => server.name === name) ?? null) : null); }, [servers]); + const selectMcpTool = useCallback((tool: Tool) => { + setSelectedToolFromMCP(tool); + toolAPI.get(tool.name) + .then((response) => { + setSelectedToolFromMCP((current) => ( + current?.name === tool.name ? response.data : current + )); + }) + .catch(() => { + // Keep the summary row visible; reopening retries the detail request. + }); + }, []); + const selectedServerObj = useMemo(() => { if (!selectedServer) return undefined; const fromServers = servers.find((server) => server.name === selectedServer); @@ -424,7 +462,9 @@ export default function MCPTabContent({
{serversLoading && catalogLoading ? ( -
+
+ +
) : unifiedCards.length === 0 ? ( } title={t('mcp.noServers')} description={t('mcp.noServersDesc')} /> ) : ( @@ -497,7 +537,7 @@ export default function MCPTabContent({ {/* Stats column */}
- {serverTools.length} + {server.tools_count ?? serverTools.length} {server.connected_at ? ( {formatDuration(server.connected_at, t)} ) : entry ? ( @@ -696,13 +736,13 @@ export default function MCPTabContent({ )} handleConnect(selectedServer)} onDisconnect={() => handleDisconnect(selectedServer)} onRefresh={async () => { await mcpAPI.refresh(selectedServer); await fetchServers(); await onRefreshTools(); }} onStatusChange={async () => { await fetchServers(); await onRefreshTools(); }} onRemove={() => handleRemove(selectedServer)} - onSelectTool={setSelectedToolFromMCP} + onSelectTool={selectMcpTool} />
diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index e0f6eae94..cd0c44400 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -43,8 +43,10 @@ import { } from 'lucide-react'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import EmptyState from '@/components/common/EmptyState'; -import { useTools } from '@/hooks/useTools'; -import { canDirectlyTestTool, toolAPI, Tool, ToolFixture, ToolSource } from '@/api/tool'; +import { useToast } from '@/components/common/Toast'; +import { useToolPage } from '@/hooks/useTools'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { canDirectlyTestTool, toolAPI, Tool, ToolFixture } from '@/api/tool'; import { mcpAPI, MCPServer } from '@/api/mcp'; import { providerAPI } from '@/api/provider'; import client from '@/api/client'; @@ -54,22 +56,27 @@ import type { MCPFormData, ConnStatus as MCPConnStatus } from './ToolSheets'; import MCPTabContent from './components/MCPTabContent'; import APITabContent from './components/APITabContent'; import LocalTabContent from './components/LocalTabContent'; -import { getToolTabCounts } from './tabCounts'; import { getSourceLabel } from './constants'; import { getCatalogDescription, getMetadataDescription } from '@/utils/mcpCatalog'; +import { extractErrorMessage } from '@/utils/error'; import { getLocalizedToolDescription, getLocalizedFixtureLabel } from './toolDisplay'; +import { + DEFAULT_TOOL_TAB, + TOOL_PAGE_SIZE, + getTabSourceFilter, + shouldLoadMcpCatalog, + type TabKey, +} from './tabLoading'; +import { getToolTabCounts } from './tabCounts'; // ============================================================================ // Constants & Config // ============================================================================ -type TabKey = 'all' | 'mcp' | 'api' | 'local'; - interface TabConfig { key: TabKey; label: string; icon: React.ReactNode; - sourceFilter?: ToolSource | ToolSource[]; } /** 类别 badge (source) - preserve existing Tool page colors while fixing device label rendering */ @@ -105,7 +112,7 @@ const SOURCE_SORT_ORDER: Record = { device: 6, }; -const PAGE_SIZE = 20; +const PAGE_SIZE = TOOL_PAGE_SIZE; /** MCP/API 服务器详情抽屉宽度(px) */ const DETAIL_DRAWER_WIDTH = 560; @@ -138,21 +145,31 @@ const EMPTY_FILTERS: ColumnFilters = { enabled: new Set(), }; +function joinFilterValues(values: Set): string | undefined { + if (values.size === 0) return undefined; + return Array.from(values).sort().join(','); +} + +function mergeFacetKeys(facets: Record, activeValues: Set = new Set()): string[] { + return Array.from(new Set([...Object.keys(facets), ...Array.from(activeValues)])).sort(); +} + // ============================================================================ // Main Page // ============================================================================ export default function ToolPage() { const { t, i18n } = useTranslation('tool'); + const toast = useToast(); const TABS: TabConfig[] = [ { key: 'all', label: t('tabs.all'), icon: }, - { key: 'mcp', label: t('tabs.mcp'), icon: , sourceFilter: 'mcp' }, - { key: 'api', label: t('tabs.api'), icon: , sourceFilter: 'api' }, - { key: 'local', label: t('tabs.local'), icon: , sourceFilter: 'plugin_py' }, + { key: 'mcp', label: t('tabs.mcp'), icon: }, + { key: 'api', label: t('tabs.api'), icon: }, + { key: 'local', label: t('tabs.local'), icon: }, ]; - const [activeTab, setActiveTab] = useState('all'); + const [activeTab, setActiveTab] = useState(DEFAULT_TOOL_TAB); const [searchQuery, setSearchQuery] = useState(''); const [selectedTool, setSelectedTool] = useState(null); const [testParams, setTestParams] = useState('{}'); @@ -171,38 +188,103 @@ export default function ToolPage() { const [sort, setSort] = useState({ field: 'source', dir: 'asc' }); const [filters, setFilters] = useState(EMPTY_FILTERS); - const { tools, loading, error, refetch } = useTools(); const [apiEnabledServicesCount, setApiEnabledServicesCount] = useState(0); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 250); + const tabSourceFilter = getTabSourceFilter(activeTab); + const sourceFilterParam = useMemo(() => { + const values = new Set(filters.source); + if (tabSourceFilter) { + const tabSources = Array.isArray(tabSourceFilter) ? tabSourceFilter : [tabSourceFilter]; + tabSources.forEach((source) => values.add(source)); + } + return joinFilterValues(values); + }, [filters.source, tabSourceFilter]); + const toolPageParams = useMemo(() => ({ + source: sourceFilterParam, + category: joinFilterValues(filters.category), + sourceName: joinFilterValues(filters.source_name), + enabled: joinFilterValues(filters.enabled), + q: debouncedSearchQuery.trim() || undefined, + sortBy: sort.field, + sortDir: sort.dir, + offset: (currentPage - 1) * PAGE_SIZE, + limit: PAGE_SIZE, + }), [ + sourceFilterParam, + filters.category, + filters.source_name, + filters.enabled, + debouncedSearchQuery, + sort.field, + sort.dir, + currentPage, + ]); + const { + tools, + total: totalTools, + facets: toolFacets, + loading, + error, + initialized: toolPageInitialized, + refetch, + } = useToolPage(toolPageParams); + const [hasLoadedToolPage, setHasLoadedToolPage] = useState(false); // Catalog data (fetched once at top level, shared with MCP & API tabs) const [catalogEntries, setCatalogEntries] = useState([]); const [catalogCategories, setCatalogCategories] = useState>({}); - const [catalogLoading, setCatalogLoading] = useState(true); + const [catalogLoading, setCatalogLoading] = useState(false); + const [catalogError, setCatalogError] = useState(null); + const [catalogRetryKey, setCatalogRetryKey] = useState(0); const [configuredIds, setConfiguredIds] = useState>(new Set()); + const catalogLoadedRef = useRef(false); useEffect(() => { + if (!shouldLoadMcpCatalog(activeTab) || catalogLoadedRef.current) return; + let cancelled = false; + const loadCatalog = async () => { + let loaded = false; try { setCatalogLoading(true); + setCatalogError(null); const [entriesRes, catsRes] = await Promise.all([ mcpAPI.catalogList(), mcpAPI.catalogCategories(), ]); + if (cancelled) return; setCatalogEntries(entriesRes.data); setCatalogCategories(catsRes.data); + loaded = true; // Get currently configured IDs (no auto-setup to avoid re-adding removed entries) try { const confRes = await mcpAPI.catalogConfigured(); - setConfiguredIds(new Set(confRes.data)); + if (!cancelled) setConfiguredIds(new Set(confRes.data)); } catch { /* ignore */ } } catch (err) { console.error('Failed to load catalog:', err); + if (!cancelled) { + setCatalogError(err instanceof Error ? err.message : 'Failed to load MCP catalog'); + } } finally { - setCatalogLoading(false); + if (!cancelled) { + catalogLoadedRef.current = loaded; + setCatalogLoading(false); + } } }; - loadCatalog(); - }, []); + void loadCatalog(); + + return () => { + cancelled = true; + }; + }, [activeTab, catalogRetryKey]); + + useEffect(() => { + if (!shouldLoadMcpCatalog(activeTab)) { + setCatalogLoading(false); + } + }, [activeTab]); const onConfiguredChange = useCallback((id: string) => { setConfiguredIds(prev => new Set(prev).add(id)); @@ -227,112 +309,72 @@ export default function ToolPage() { }, [fetchApiServicesCount]); const refreshToolData = useCallback(async () => { - await Promise.all([ + const [refreshResult] = await Promise.all([ refetch(), fetchApiServicesCount(), ]); + return refreshResult; }, [refetch, fetchApiServicesCount]); + const showRefreshOutcome = useCallback((status: 'success' | 'partial' | 'error', message: string) => { + if (status === 'partial') { + toast.warning(t('alert.refreshPartialTitle'), message || t('alert.refreshPartialDefault')); + } else if (status === 'error') { + toast.error(t('alert.refreshFailedTitle'), message || t('alert.unknownError')); + } + }, [t, toast]); + + const refreshToolDataAfterMutation = useCallback(async () => { + try { + const result = await refreshToolData(); + showRefreshOutcome(result.status, result.message); + } catch (err: unknown) { + const message = extractErrorMessage(err, t('alert.unknownError')); + toast.error(t('alert.refreshFailedTitle'), message); + } + }, [refreshToolData, showRefreshOutcome, t, toast]); + // The backend still marks some valid MCP catalog entries as "api" based on category. // Until API catalog has its own rendering path, show all catalog entries in the MCP tab. const mcpCatalogEntries = useMemo(() => catalogEntries, [catalogEntries]); // API catalog not yet implemented — keep this empty so entries are not duplicated across tabs. const apiCatalogEntries = useMemo(() => [] as MCPCatalogEntry[], []); - // Compute tab counts: all = active tools; mcp/api = unique active servers/modules const tabCounts = useMemo( - () => getToolTabCounts(tools, apiEnabledServicesCount), - [tools, apiEnabledServicesCount], + () => getToolTabCounts(totalTools, toolFacets, apiEnabledServicesCount), + [totalTools, toolFacets, apiEnabledServicesCount], ); + const enabledSummary = useMemo(() => ({ + active: toolFacets.enabled['true'] ?? tools.filter((tool) => tool.enabled).length, + inactive: toolFacets.enabled['false'] ?? 0, + }), [toolFacets.enabled, tools]); - // Get unique values for filter options (active tools only) + // Filter dropdown options come from server-side facets, plus active values + // so a selected filter remains visible even when it narrows the result set. const filterOptions = useMemo(() => { - const cats = new Set(); - const sources = new Set(); - const sourceNames = new Set(); - tools.forEach((tool) => { - cats.add(tool.category); - sources.add(tool.source); - sourceNames.add(tool.source_name || 'Flocks'); - }); return { - category: Array.from(cats).sort(), - source: Array.from(sources).sort((a, b) => (SOURCE_SORT_ORDER[a] ?? 99) - (SOURCE_SORT_ORDER[b] ?? 99)), - source_name: Array.from(sourceNames).sort(), + category: mergeFacetKeys(toolFacets.category, filters.category), + source: mergeFacetKeys(toolFacets.source, filters.source) + .sort((a, b) => (SOURCE_SORT_ORDER[a] ?? 99) - (SOURCE_SORT_ORDER[b] ?? 99)), + source_name: mergeFacetKeys(toolFacets.source_name, filters.source_name), enabled: ['true', 'false'], }; - }, [tools]); - - // Apply tab filter + search + column filters + sort (All tab shows active tools only) - const processedTools = useMemo(() => { - let result = [...tools]; + }, [toolFacets, filters.category, filters.source, filters.source_name]); - // Tab filter - const tabConfig = TABS.find((tab) => tab.key === activeTab); - if (tabConfig?.sourceFilter) { - const sf = tabConfig.sourceFilter; - const allowed = Array.isArray(sf) ? sf : [sf]; - result = result.filter((tool) => allowed.includes(tool.source)); - } + // Pagination + const processedTools = tools; + const totalPages = Math.max(1, Math.ceil(totalTools / PAGE_SIZE)); + const paginatedTools = tools; - // Search - if (searchQuery) { - const q = searchQuery.toLowerCase(); - result = result.filter( - (tool) => - tool.name.toLowerCase().includes(q) || - tool.description.toLowerCase().includes(q) || - (tool.description_cn || '').toLowerCase().includes(q) || - (tool.source_name || '').toLowerCase().includes(q) - ); - } + useEffect(() => { + if (currentPage > totalPages) setCurrentPage(totalPages); + }, [currentPage, totalPages]); - // Column filters - if (filters.category.size > 0) { - result = result.filter((tool) => filters.category.has(tool.category)); - } - if (filters.source.size > 0) { - result = result.filter((tool) => filters.source.has(tool.source)); - } - if (filters.source_name.size > 0) { - result = result.filter((tool) => filters.source_name.has(tool.source_name || 'Flocks')); - } - if (filters.enabled.size > 0) { - result = result.filter((tool) => filters.enabled.has(String(tool.enabled))); + useEffect(() => { + if (toolPageInitialized && !error) { + setHasLoadedToolPage(true); } - - // Sort - result.sort((a, b) => { - let cmp = 0; - switch (sort.field) { - case 'category': { - const la = a.category; - const lb = b.category; - cmp = la.localeCompare(lb); - break; - } - case 'source': - cmp = (SOURCE_SORT_ORDER[a.source] ?? 99) - (SOURCE_SORT_ORDER[b.source] ?? 99); - break; - case 'source_name': - cmp = (a.source_name || 'Flocks').localeCompare(b.source_name || 'Flocks', 'zh'); - break; - case 'enabled': - cmp = (a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1); - break; - } - return sort.dir === 'desc' ? -cmp : cmp; - }); - - return result; - }, [tools, activeTab, searchQuery, filters, sort]); - - // Pagination - const totalPages = Math.ceil(processedTools.length / PAGE_SIZE); - const paginatedTools = useMemo(() => { - const start = (currentPage - 1) * PAGE_SIZE; - return processedTools.slice(start, start + PAGE_SIZE); - }, [processedTools, currentPage]); + }, [toolPageInitialized, error]); const handleTabChange = (tab: TabKey) => { setActiveTab(tab); @@ -351,14 +393,20 @@ export default function ToolPage() { if (refreshing) return; try { setRefreshing(true); - await Promise.all([ + setRefreshDone(false); + const [result] = await Promise.all([ refreshToolData(), new Promise((r) => setTimeout(r, 600)), ]); - setRefreshDone(true); - setTimeout(() => setRefreshDone(false), 2000); - } catch (err: any) { - alert(t('alert.refreshFailed', { error: err.message })); + if (result.status === 'success') { + setRefreshDone(true); + setTimeout(() => setRefreshDone(false), 2000); + } else { + showRefreshOutcome(result.status, result.message); + } + } catch (err: unknown) { + const message = extractErrorMessage(err, t('alert.unknownError')); + toast.error(t('alert.refreshFailedTitle'), message); } finally { setRefreshing(false); } @@ -384,6 +432,15 @@ export default function ToolPage() { setSelectedTool(tool); setTestParams('{}'); setTestResult(null); + toolAPI.get(tool.name) + .then((response) => { + setSelectedTool((current) => ( + current?.name === tool.name ? response.data : current + )); + }) + .catch(() => { + // Keep the lightweight row data visible; retry happens when reopened. + }); }; const toggleSort = (field: SortField) => { @@ -410,20 +467,23 @@ export default function ToolPage() { setCurrentPage(1); }; - if (loading) { + const isInitialLoading = loading && !hasLoadedToolPage; + const isTabPageLoading = loading && hasLoadedToolPage && !toolPageInitialized; + + if (isInitialLoading) { return (
- +
); } - if (error) { + if (error && !hasLoadedToolPage) { return (

{error}

-
@@ -432,7 +492,7 @@ export default function ToolPage() { } return ( -
+
{/* Page Header */}
@@ -442,9 +502,9 @@ export default function ToolPage() {

{t('pageTitle')}

- {tools.length} {t('statusBadge.active')} + {enabledSummary.active} {t('statusBadge.active')} · - {catalogEntries.length} {t('statusBadge.inactive')} + {enabledSummary.inactive} {t('statusBadge.inactive')}

@@ -487,24 +547,24 @@ export default function ToolPage() { {/* Tabs row with search */}
-
+
+ {error && ( +
+ {error} +
+ )} + + {activeTab === 'mcp' && catalogError && ( +
+ {catalogError} + +
+ )} + {/* Tab Content */} - {activeTab === 'mcp' ? ( - - ) : activeTab === 'api' ? ( - - ) : activeTab === 'local' ? ( - - ) : ( - /* All tab: active tools only */ -
- {/* Inactive services callout */} - {(mcpCatalogEntries.length > 0 || apiCatalogEntries.length > 0) && !searchQuery && ( -
- - {catalogEntries.length} {t('summary.inactiveServicesAvailable')} - -
- {mcpCatalogEntries.length > 0 && ( - - )} - {apiCatalogEntries.length > 0 && ( - - )} -
-
- )} - {processedTools.length === 0 ? ( - } - title={t('empty.noTools')} - description={searchQuery ? t('empty.tryOtherKeywords') : t('empty.noToolsInCategory')} +
+ {isTabPageLoading ? ( +
+ +
+ ) : activeTab === 'mcp' ? ( + + ) : activeTab === 'api' ? ( + + ) : activeTab === 'local' ? ( +
+ - ) : ( - - )} -
- )} +
+ ) : ( + /* All tab: active tools only */ +
+ {/* Inactive services callout */} + {(mcpCatalogEntries.length > 0 || apiCatalogEntries.length > 0) && !searchQuery && ( +
+ + {catalogEntries.length} {t('summary.inactiveServicesAvailable')} + +
+ {mcpCatalogEntries.length > 0 && ( + + )} + {apiCatalogEntries.length > 0 && ( + + )} +
+
+ )} + {totalTools === 0 ? ( + } + title={t('empty.noTools')} + description={searchQuery ? t('empty.tryOtherKeywords') : t('empty.noToolsInCategory')} + /> + ) : ( + + )} +
+ )} +
{/* Tool Detail Drawer */} {selectedTool && ( @@ -630,17 +724,25 @@ export default function ToolPage() { onTest={handleTest} onDelete={async (name) => { try { - await toolAPI.delete(name); + const { data: deleteResult } = await toolAPI.delete(name); setSelectedTool(null); setTestResult(null); + if (deleteResult.status === 'partial') { + toast.warning( + t('alert.deletePartialTitle'), + deleteResult.errors?.join('; ') || deleteResult.message, + ); + } await handleRefresh(); - } catch (err: any) { - alert(t('alert.deleteFailed', { error: err.response?.data?.detail || err.message })); + } catch (err: unknown) { + alert(t('alert.deleteFailed', { + error: extractErrorMessage(err, t('alert.unknownError')), + })); } }} onEnabledChange={(name, newEnabled) => { setSelectedTool((prev) => prev ? { ...prev, enabled: newEnabled } : prev); - void refreshToolData(); + void refreshToolDataAfterMutation(); }} /> )} @@ -3814,4 +3916,3 @@ const LANG_COLORS: Record = { // CatalogBrowser removed — catalog UI is now inline in MCPTabContent / APITabContent // (CatalogBrowser component removed — catalog UI is inline in MCPTabContent / APITabContent) - diff --git a/webui/src/pages/Tool/tabCounts.test.ts b/webui/src/pages/Tool/tabCounts.test.ts index a5b660397..2ca2cec9d 100644 --- a/webui/src/pages/Tool/tabCounts.test.ts +++ b/webui/src/pages/Tool/tabCounts.test.ts @@ -1,20 +1,18 @@ import { describe, expect, it } from 'vitest'; -import type { Tool } from '@/api/tool'; +import type { ToolListFacets } from '@/api/tool'; import { getToolTabCounts } from './tabCounts'; describe('getToolTabCounts', () => { it('uses enabled API service count for the API tab', () => { - const tools = [ - { source: 'api', source_name: 'f1' }, - { source: 'api', source_name: 'f2' }, - { source: 'api', source_name: 'f3' }, - { source: 'mcp', source_name: 'mcp-a' }, - { source: 'mcp', source_name: 'mcp-a' }, - { source: 'plugin_py', source_name: 'local' }, - { source: 'plugin_py', source_name: 'local' }, - ] as Tool[]; + const facets: ToolListFacets = { + category: {}, + source: { api: 3, mcp: 2, plugin_py: 2 }, + source_groups: { api: 3, mcp: 1, plugin_py: 1 }, + source_name: {}, + enabled: {}, + }; - expect(getToolTabCounts(tools, 3)).toEqual({ + expect(getToolTabCounts(2, facets, 3)).toEqual({ all: 7, mcp: 1, api: 3, diff --git a/webui/src/pages/Tool/tabCounts.ts b/webui/src/pages/Tool/tabCounts.ts index 76b12e62a..a81edc7b4 100644 --- a/webui/src/pages/Tool/tabCounts.ts +++ b/webui/src/pages/Tool/tabCounts.ts @@ -1,4 +1,4 @@ -import type { Tool } from '@/api/tool'; +import type { ToolListFacets } from '@/api/tool'; export interface ToolTabCounts { all: number; @@ -7,11 +7,16 @@ export interface ToolTabCounts { local: number; } -export function getToolTabCounts(tools: Tool[], apiEnabledServicesCount: number): ToolTabCounts { +export function getToolTabCounts( + totalTools: number, + facets: ToolListFacets, + apiEnabledServicesCount: number, +): ToolTabCounts { + const allTools = Object.values(facets.source).reduce((sum, count) => sum + count, 0); return { - all: tools.length, - mcp: new Set(tools.filter((tool) => tool.source === 'mcp').map((tool) => tool.source_name)).size, + all: allTools || totalTools, + mcp: facets.source_groups.mcp ?? 0, api: apiEnabledServicesCount, - local: tools.filter((tool) => tool.source === 'plugin_py').length, + local: facets.source.plugin_py ?? 0, }; } diff --git a/webui/src/pages/Tool/tabLoading.test.ts b/webui/src/pages/Tool/tabLoading.test.ts new file mode 100644 index 000000000..8d30c0407 --- /dev/null +++ b/webui/src/pages/Tool/tabLoading.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_TOOL_TAB, + TOOL_PAGE_SIZE, + getTabSourceFilter, + shouldLoadMcpCatalog, +} from './tabLoading'; + +describe('tool tab loading strategy', () => { + it('starts from all tools with 25 visible rows', () => { + expect(DEFAULT_TOOL_TAB).toBe('all'); + expect(TOOL_PAGE_SIZE).toBe(25); + expect(getTabSourceFilter(DEFAULT_TOOL_TAB)).toBeUndefined(); + }); + + it('loads each source group only for its tab', () => { + expect(getTabSourceFilter('all')).toBeUndefined(); + expect(getTabSourceFilter('mcp')).toBe('mcp'); + expect(getTabSourceFilter('api')).toBe('api'); + expect(getTabSourceFilter('local')).toBe('plugin_py'); + }); + + it('defers MCP catalog requests until the MCP tab is opened', () => { + expect(shouldLoadMcpCatalog('all')).toBe(false); + expect(shouldLoadMcpCatalog('api')).toBe(false); + expect(shouldLoadMcpCatalog('local')).toBe(false); + expect(shouldLoadMcpCatalog('mcp')).toBe(true); + }); +}); diff --git a/webui/src/pages/Tool/tabLoading.ts b/webui/src/pages/Tool/tabLoading.ts new file mode 100644 index 000000000..ef3501115 --- /dev/null +++ b/webui/src/pages/Tool/tabLoading.ts @@ -0,0 +1,17 @@ +import type { ToolSource } from '@/api/tool'; + +export type TabKey = 'all' | 'mcp' | 'api' | 'local'; + +export const DEFAULT_TOOL_TAB: TabKey = 'all'; +export const TOOL_PAGE_SIZE = 25; + +export function getTabSourceFilter(tab: TabKey): ToolSource | ToolSource[] | undefined { + if (tab === 'mcp') return 'mcp'; + if (tab === 'api') return 'api'; + if (tab === 'local') return 'plugin_py'; + return undefined; +} + +export function shouldLoadMcpCatalog(tab: TabKey): boolean { + return tab === 'mcp'; +} diff --git a/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx b/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx index e1cccee74..d1e87aa67 100644 --- a/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx +++ b/webui/src/pages/WebUIContractPageHost/PageRuntimeHost.tsx @@ -13,6 +13,7 @@ import { AlertCircle, Loader2 } from 'lucide-react'; import { getApiBase } from '@/api/client'; import { webuiContractPagesAPI } from '@/api/webuiContractPages'; import { useSSE } from '@/hooks/useSSE'; +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; import { installWebUIContractPageRuntime, loadWebUIContractPageBundle } from './runtime'; interface WebUIContractPageErrorBoundaryProps { @@ -73,6 +74,7 @@ export default function PageRuntimeHost({ pageId }: PageRuntimeHostProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [buildHash, setBuildHash] = useState(''); + const showLoading = useDelayedVisible(loading ? 180 : 0); const loadBundle = useCallback(async (hash: string) => { if (!pageId || !hash) return; @@ -140,6 +142,7 @@ export default function PageRuntimeHost({ pageId }: PageRuntimeHostProps) { } if (loading) { + if (!showLoading) return null; return (
diff --git a/webui/src/pages/WebUIContractWorkspaceHost/index.tsx b/webui/src/pages/WebUIContractWorkspaceHost/index.tsx index df890edf8..cee5d4c5d 100644 --- a/webui/src/pages/WebUIContractWorkspaceHost/index.tsx +++ b/webui/src/pages/WebUIContractWorkspaceHost/index.tsx @@ -7,6 +7,7 @@ import { type WebUIContractWorkspaceListItem, } from '@/api/webuiContractPages'; import { useSSE } from '@/hooks/useSSE'; +import { useDelayedVisible } from '@/hooks/useDelayedVisible'; import { ThemeContext } from '@/contexts/ThemeContext'; import PageRuntimeHost from '@/pages/WebUIContractPageHost/PageRuntimeHost'; import { buildWebUIContractWorkspaceSections } from '@/utils/webuiContractWorkspaceSections'; @@ -17,6 +18,7 @@ export default function WebUIContractWorkspaceHost() { const [workspaces, setWorkspaces] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const showLoading = useDelayedVisible(loading ? 180 : 0); const { theme, setTemporaryThemeOverride } = useContext(ThemeContext); const fetchWorkspaces = useCallback(async (silent = false) => { @@ -82,6 +84,7 @@ export default function WebUIContractWorkspaceHost() { } if (loading) { + if (!showLoading) return null; return (
diff --git a/webui/src/pages/Workflow/index.tsx b/webui/src/pages/Workflow/index.tsx index 330c183a1..ec2acd37b 100644 --- a/webui/src/pages/Workflow/index.tsx +++ b/webui/src/pages/Workflow/index.tsx @@ -103,7 +103,7 @@ export default function WorkflowPage() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/WorkflowDetail/index.tsx b/webui/src/pages/WorkflowDetail/index.tsx index a3074d55f..7e1bbd8a4 100644 --- a/webui/src/pages/WorkflowDetail/index.tsx +++ b/webui/src/pages/WorkflowDetail/index.tsx @@ -596,7 +596,7 @@ export default function WorkflowDetail() { if (loading) { return (
- +
); } diff --git a/webui/src/pages/WorkflowEditor/index.tsx b/webui/src/pages/WorkflowEditor/index.tsx index 536e15a35..d65350068 100644 --- a/webui/src/pages/WorkflowEditor/index.tsx +++ b/webui/src/pages/WorkflowEditor/index.tsx @@ -32,6 +32,7 @@ import { Trash2, } from 'lucide-react'; import { workflowAPI, Workflow, WorkflowExecution, WorkflowJSON, WorkflowNode as APINode } from '@/api/workflow'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; import { ThemeContext } from '@/contexts/ThemeContext'; import { extractErrorMessage } from '@/utils/error'; import { @@ -779,7 +780,7 @@ export default function WorkflowEditor() { if (loading) { return (
-
+
); } diff --git a/webui/src/pages/Workspace/index.tsx b/webui/src/pages/Workspace/index.tsx index 2e8632ead..95d115dc7 100644 --- a/webui/src/pages/Workspace/index.tsx +++ b/webui/src/pages/Workspace/index.tsx @@ -1,18 +1,15 @@ -import { useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react'; +import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react'; import { FolderOpen, Upload, Download, Trash2, Edit3, Save, X, ChevronRight, ChevronLeft, ChevronDown, ChevronUp, RefreshCw, FolderPlus, Brain, AlertTriangle, Search, ArrowLeft, Maximize2, Code2, Eye, ZoomIn, ZoomOut, } from 'lucide-react'; -import * as pdfjsLib from 'pdfjs-dist'; -import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import { useToast } from '@/components/common/Toast'; import { useConfirm } from '@/components/common/ConfirmDialog'; -import { StreamingMarkdown } from '@/components/common/StreamingMarkdown'; import { workspaceAPI, WorkspaceNode, formatBytes, formatDate, fileIcon, } from '@/api/workspace'; @@ -52,7 +49,8 @@ const IMAGE_MIN_SCALE = 0.5; const IMAGE_MAX_SCALE = 3; const IMAGE_SCALE_STEP = 0.25; -pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl; +const LazyStreamingMarkdown = lazy(() => import('@/components/common/StreamingMarkdown') + .then((module) => ({ default: module.StreamingMarkdown }))); function getViewportWidth(): number { return typeof window === 'undefined' ? PREVIEW_PANEL_MIN_WIDTH * 2 : window.innerWidth; @@ -361,6 +359,7 @@ function PdfPreview({ useEffect(() => { let cancelled = false; + let loadingTask: any = null; setPdfDoc(null); setPageNumber(1); setPageCount(0); @@ -370,26 +369,40 @@ function PdfPreview({ setLoading(true); setError(null); - const loadingTask = pdfjsLib.getDocument({ url: previewUrl, withCredentials: true }); - loadingTask.promise - .then((doc) => { + async function loadPdf() { + try { + const [pdfjsLib, pdfWorkerModule] = await Promise.all([ + import('pdfjs-dist'), + import('pdfjs-dist/build/pdf.worker.min.mjs?url'), + ]); + if (cancelled) { + return; + } + + pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerModule.default; + loadingTask = pdfjsLib.getDocument({ url: previewUrl, withCredentials: true }); + const doc = await loadingTask.promise; if (cancelled) { + doc?.destroy?.(); return; } + setPdfDoc(doc); setPageCount(doc.numPages); setPagesToRender(new Set(Array.from({ length: Math.min(doc.numPages, PDF_RENDER_WINDOW + 1) }, (_, index) => index + 1))); setLoading(false); - }) - .catch((e: any) => { + } catch (e: any) { if (cancelled) return; setError(e?.message ?? 'PDF preview failed'); setLoading(false); - }); + } + } + + loadPdf(); return () => { cancelled = true; - loadingTask.destroy(); + loadingTask?.destroy?.(); }; }, [previewUrl]); @@ -764,7 +777,9 @@ function RenderedPreview({ if (kind === 'markdown') { return (
- +
}> + +
); } diff --git a/webui/src/routes/index.tsx b/webui/src/routes/index.tsx index d26866ada..72e3113b1 100644 --- a/webui/src/routes/index.tsx +++ b/webui/src/routes/index.tsx @@ -1,46 +1,69 @@ import { Suspense, lazy } from 'react'; +import type { ComponentType, ReactNode } from 'react'; import { Routes as RouterRoutes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Layout from '@/components/layout/Layout'; +import LazyLoadErrorBoundary from '@/components/common/LazyLoadErrorBoundary'; import RoutePageSkeleton from '@/components/common/RoutePageSkeleton'; import AuthLayout from '@/components/layout/AuthLayout'; import Home from '@/pages/Home'; import { useAuth } from '@/contexts/AuthContext'; +import { preloadI18nNamespaces } from '@/i18nResources'; +import { installVitePreloadErrorRecovery, recoverLazyLoad } from '@/utils/chunkLoadRecovery'; + +installVitePreloadErrorRecovery(); // All non-Home pages are code-split. Home stays eager because it's the very // first frame after auth and we don't want a Suspense flash on initial paint. // In particular, Session/Agent and the auth screens are kept lazy so heavy // transitive deps (SessionChat ~2.7k LOC + react-markdown + rehype/remark + // highlight.js) are not pulled into the main entry chunk. -const SessionPage = lazy(() => import('@/pages/Session')); -const AgentPage = lazy(() => import('@/pages/Agent')); -const LoginPage = lazy(() => import('@/pages/Login')); -const SetupAdminPage = lazy(() => import('@/pages/SetupAdmin')); -const ForceChangePasswordPage = lazy(() => import('@/pages/ForceChangePassword')); -const WorkflowListPage = lazy(() => import('@/pages/Workflow')); -const WorkflowCreate = lazy(() => import('@/pages/WorkflowCreate')); -const WorkflowEditor = lazy(() => import('@/pages/WorkflowEditor')); -const WorkflowDetail = lazy(() => import('@/pages/WorkflowDetail')); -const TaskPage = lazy(() => import('@/pages/Task')); -const ToolPage = lazy(() => import('@/pages/Tool')); -const HubPage = lazy(() => import('@/pages/Hub')); -const SkillPage = lazy(() => import('@/pages/Skill')); -const ModelPage = lazy(() => import('@/pages/Model')); -const ChannelPage = lazy(() => import('@/pages/Channel')); -const PermissionPage = lazy(() => import('@/pages/Permission')); -const MonitoringPage = lazy(() => import('@/pages/Monitoring')); -const WorkspacePage = lazy(() => import('@/pages/Workspace')); -const DeviceIntegrationPage = lazy(() => import('@/pages/DeviceIntegration')); -const FlocksproUpgradeCallbackPage = lazy(() => import('@/pages/FlocksproUpgrade/Callback')); -const SettingsPage = lazy(() => import('@/pages/Settings')); -const WebUIContractPageHost = lazy(() => import('@/pages/WebUIContractPageHost')); -const WebUIContractWorkspaceHost = lazy(() => import('@/pages/WebUIContractWorkspaceHost')); - -function LazyRoute({ children }: { children: React.ReactNode }) { +type LazyPageModule = { default: ComponentType }; + +function lazyPage( + loader: () => Promise, + namespaces: readonly string[] = [], +) { + return lazy(() => recoverLazyLoad( + Promise.all([ + loader(), + preloadI18nNamespaces(namespaces), + ]).then(([module]) => module), + )); +} + +const SessionPage = lazyPage(() => import('@/pages/Session'), ['session']); +const AgentPage = lazyPage(() => import('@/pages/Agent'), ['agent']); +const LoginPage = lazyPage(() => import('@/pages/Login')); +const SetupAdminPage = lazyPage(() => import('@/pages/SetupAdmin')); +const ForceChangePasswordPage = lazyPage(() => import('@/pages/ForceChangePassword')); +const WorkflowListPage = lazyPage(() => import('@/pages/Workflow'), ['workflow']); +const WorkflowCreate = lazyPage(() => import('@/pages/WorkflowCreate'), ['workflow']); +const WorkflowEditor = lazyPage(() => import('@/pages/WorkflowEditor'), ['workflow']); +const WorkflowDetail = lazyPage(() => import('@/pages/WorkflowDetail'), ['workflow']); +const TaskPage = lazyPage(() => import('@/pages/Task'), ['task']); +const ToolPage = lazyPage(() => import('@/pages/Tool'), ['tool']); +const HubPage = lazyPage(() => import('@/pages/Hub')); +const SkillPage = lazyPage(() => import('@/pages/Skill'), ['skill']); +const ModelPage = lazyPage(() => import('@/pages/Model'), ['model']); +const ChannelPage = lazyPage(() => import('@/pages/Channel'), ['channel']); +const PermissionPage = lazyPage(() => import('@/pages/Permission'), ['permission']); +const MonitoringPage = lazyPage(() => import('@/pages/Monitoring'), ['monitoring']); +const WorkspacePage = lazyPage(() => import('@/pages/Workspace'), ['workspace']); +const DeviceIntegrationPage = lazyPage(() => import('@/pages/DeviceIntegration'), ['device']); +const FlocksproUpgradeCallbackPage = lazyPage(() => import('@/pages/FlocksproUpgrade/Callback'), ['flockspro']); +const SettingsPage = lazyPage(() => import('@/pages/Settings')); +const WebUIContractPageHost = lazyPage(() => import('@/pages/WebUIContractPageHost')); +const WebUIContractWorkspaceHost = lazyPage(() => import('@/pages/WebUIContractWorkspaceHost')); +const ROUTE_FALLBACK_DELAY_MS = 180; + +function LazyRoute({ children }: { children: ReactNode }) { return ( - }> - {children} - + + }> + {children} + + ); } @@ -96,31 +119,37 @@ export function Routes() { if (!bootstrapped) { return ( - }> - - } /> - } /> - - + + }> + + } /> + } /> + + + ); } if (!user) { return ( - }> - - } /> - } /> - - + + }> + + } /> + } /> + + + ); } if (user.must_reset_password) { return ( - }> - - + + }> + + + ); } diff --git a/webui/src/types/index.ts b/webui/src/types/index.ts index 4b8995e03..e78dbfd28 100644 --- a/webui/src/types/index.ts +++ b/webui/src/types/index.ts @@ -203,6 +203,7 @@ export interface Tool { source: ToolSource; source_name?: string; parameters: ToolParameter[]; + parameters_count?: number; enabled: boolean; /** Factory default from the YAML/registration source (no overlay applied). */ enabled_default?: boolean; @@ -396,12 +397,10 @@ export interface MCPCatalogStats { export interface ProviderCredentials { secret_id?: string | null; /** - * The actual API key value, when one exists. - * Note: For openai-compatible / custom-* providers configured WITHOUT an - * API key (internal no-auth gateways) the backend stores a sentinel value - * but returns ``api_key: null`` while still setting ``has_credential: true`` - * and a real ``secret_id``. UI code should rely on ``has_credential`` (not - * ``api_key``) to decide whether a credential record exists. + * Write-only compatibility field. Credential reads never return the raw key; + * they return ``api_key: null`` plus ``api_key_masked`` and + * ``has_credential``. UI code must never submit the masked value as a new + * secret. Leave ``api_key`` out of an update to preserve the stored key. */ api_key?: string | null; api_key_masked?: string | null; @@ -409,6 +408,7 @@ export interface ProviderCredentials { secret_masked?: string | null; base_url?: string | null; username?: string | null; + /** Sensitive entries are masked on reads and must not be resubmitted unchanged. */ fields?: Record; secret_ids?: Record; has_credential: boolean; diff --git a/webui/src/utils/chunkLoadRecovery.test.ts b/webui/src/utils/chunkLoadRecovery.test.ts new file mode 100644 index 000000000..be99df7f2 --- /dev/null +++ b/webui/src/utils/chunkLoadRecovery.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recoverLazyLoad, reloadOnceForChunkLoadError } from './chunkLoadRecovery'; + +describe('chunkLoadRecovery', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('reloads only once for the same failed chunk and allows a new chunk failure', () => { + const reload = vi.fn(); + const staleChunk = new TypeError('Failed to fetch /assets/Session-old.js'); + + expect(reloadOnceForChunkLoadError(staleChunk, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(staleChunk, reload)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + + expect(reloadOnceForChunkLoadError( + new TypeError('Failed to fetch /assets/Session-new.js'), + reload, + )).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('does not reload when session storage cannot guard against a loop', () => { + const reload = vi.fn(); + const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + + expect(reloadOnceForChunkLoadError(new Error('chunk failed'), reload)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + getItem.mockRestore(); + }); + + it('reloads for an explicit dynamic-import failure', async () => { + const reload = vi.fn(); + const error = new TypeError('Failed to fetch dynamically imported module: /assets/Session-old.js'); + + await expect(recoverLazyLoad(Promise.reject(error), reload)).rejects.toBe(error); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('does not reload for a non-chunk lazy dependency failure', async () => { + const reload = vi.fn(); + const error = new Error('i18n instance is unavailable'); + + await expect(recoverLazyLoad(Promise.reject(error), reload)).rejects.toBe(error); + + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/utils/chunkLoadRecovery.ts b/webui/src/utils/chunkLoadRecovery.ts new file mode 100644 index 000000000..240b3541e --- /dev/null +++ b/webui/src/utils/chunkLoadRecovery.ts @@ -0,0 +1,78 @@ +const CHUNK_RELOAD_STORAGE_KEY = 'flocks:chunk-load-reload'; + +type ReloadPage = () => void; + +const CHUNK_LOAD_ERROR_PATTERNS = [ + /failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /importing a module script failed/i, + /loading (?:css )?chunk .+ failed/i, + /unable to preload css/i, +]; + +export function isChunkLoadError(error: unknown): boolean { + const candidate = error && typeof error === 'object' + ? error as { name?: unknown; message?: unknown } + : null; + const name = candidate && typeof candidate.name === 'string' ? candidate.name : ''; + const message = candidate && typeof candidate.message === 'string' + ? candidate.message + : typeof error === 'string' ? error : ''; + return name === 'ChunkLoadError' || CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + +function errorFingerprint(error: unknown): string { + if (error instanceof Error) { + return `${error.name}:${error.message}`.slice(0, 1000); + } + if (typeof error === 'string') { + return error.slice(0, 1000); + } + return 'unknown-chunk-load-error'; +} + +export function reloadOnceForChunkLoadError( + error: unknown, + reload: ReloadPage = () => window.location.reload(), +): boolean { + const fingerprint = errorFingerprint(error); + try { + if (window.sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY) === fingerprint) { + return false; + } + window.sessionStorage.setItem(CHUNK_RELOAD_STORAGE_KEY, fingerprint); + } catch { + // Without session storage we cannot safely prevent a reload loop. + return false; + } + + reload(); + return true; +} + +export function recoverLazyLoad(promise: Promise, reload?: ReloadPage): Promise { + return promise.catch((error: unknown) => { + if (isChunkLoadError(error)) { + reloadOnceForChunkLoadError(error, reload); + } + throw error; + }); +} + +export function retryChunkLoad(): void { + window.location.reload(); +} + +let preloadRecoveryInstalled = false; + +export function installVitePreloadErrorRecovery(): void { + if (preloadRecoveryInstalled || typeof window === 'undefined') return; + preloadRecoveryInstalled = true; + + window.addEventListener('vite:preloadError', (event) => { + const preloadEvent = event as Event & { payload?: unknown }; + if (reloadOnceForChunkLoadError(preloadEvent.payload)) { + event.preventDefault(); + } + }); +}