Compare commits

...

3 Commits

Author SHA1 Message Date
581fb0c0f0 ... 2025-08-25 07:06:50 +02:00
e8d09164ff ... 2025-08-22 13:18:50 +02:00
bc0d90d41a ... 2025-08-22 13:11:04 +02:00
25 changed files with 17534 additions and 13 deletions

1762
aiprompts/libtmux_python.md Normal file

File diff suppressed because it is too large Load Diff

14342
aiprompts/psutil_python.md Normal file

File diff suppressed because it is too large Load Diff

47
atest.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import requests
import sys
import os
# ---- Config ----
LMSTUDIO_URL = "http://172.22.22.210:1234/v1"
AUDIO_FILE = "/Users/despiegk/Downloads/harvard.wav" # change to your input file
# ---- Step 1: List available models ----
models_resp = requests.get(f"{LMSTUDIO_URL}/models")
models_resp.raise_for_status()
models = [m["id"] for m in models_resp.json().get("data", [])]
print("Available models:", models)
# ---- Step 2: Find Whisper ----
whisper_model = None
for m in models:
if "whisper" in m.lower():
whisper_model = m
break
if not whisper_model:
print("❌ No Whisper model found in LM Studio. Please download/start one.")
sys.exit(1)
print(f"✅ Found Whisper model: {whisper_model}")
# ---- Step 3: Transcribe ----
if not os.path.exists(AUDIO_FILE):
print(f"❌ Audio file '{AUDIO_FILE}' not found.")
sys.exit(1)
with open(AUDIO_FILE, "rb") as f:
files = {"file": f}
data = {"model": whisper_model}
headers = {"Authorization": "Bearer no-key"} # LM Studio ignores key
resp = requests.post(f"{LMSTUDIO_URL}/audio/transcriptions",
headers=headers,
files=files,
data=data)
resp.raise_for_status()
result = resp.json()
print("📝 Transcription result:")
print(result.get("text", result))

17
examples/tmuxrunner_start.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import sys
from herolib.infra.tmuxrunner.task_runner_enhanced import TaskOrchestrator
def main():
tasks_dir = sys.argv[1]
api_port = int(sys.argv[2]) if len(sys.argv) > 2 else 8000
orchestrator = TaskOrchestrator(tasks_dir, api_port)
orchestrator.run()
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python enhanced_runner.py <tasks_directory_path> [api_port]")
sys.exit(1)
main()

View File

@@ -0,0 +1,32 @@
# Common error handling setup
# set -euo pipefail
SCRIPT="${BASH_SOURCE[-1]}" # last sourced = the actual script file
ERROR_FILE="$SCRIPT.error"
DONE_FILE="$SCRIPT.done"
# Reset markers
rm -f "$ERROR_FILE" "$DONE_FILE"
error_handler() {
local exit_code=$?
local line_no=$1
local cmd="$2"
{
echo "EXIT_CODE=$exit_code"
echo "LINE=$line_no"
echo "COMMAND=$cmd"
} > "$ERROR_FILE"
# If we are inside a sourced script, don't kill the shell
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
return $exit_code
else
exit $exit_code
fi
}
trap 'error_handler ${LINENO} "$BASH_COMMAND"' ERR
mark_done() {
touch "$DONE_FILE"
}

View File

@@ -0,0 +1,12 @@
hpy() {
if [ ! -f ".venv/bin/activate" ]; then
echo "Error: .venv not found in current directory" >&2
return 1
fi
# Activate venv in a subshell so it doesnt pollute caller
(
source .venv/bin/activate
python "$@"
)
}

View File

@@ -0,0 +1,19 @@
get_session() {
local sessions
sessions=$(tmux ls 2>/dev/null | cut -d: -f1)
local count
count=$(echo "$sessions" | wc -l)
if [ "$count" -eq 0 ]; then
echo "Error: no tmux sessions found." >&2
return 1
elif [ "$count" -gt 1 ]; then
echo "Error: more than one tmux session found:" >&2
echo "$sessions" >&2
return 1
fi
export SESSIONNAME="$sessions"
echo "$SESSIONNAME"
}

View File

@@ -0,0 +1,3 @@
export SSH_SERVER=108.5.176.71
export SSH_PORT=10200

View File

@@ -0,0 +1,10 @@
#!/bin/bash
set -euo pipefail
source ../../functions/base.sh
apt update
apt upgrade -y
apt install -y tmux btop nvtop psutils htop
mark_done

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
source source ../../functions/base.sh
# --- create ~/.tmux.conf ---
TMUX_CONF="$HOME/.tmux.conf"
cat > "$TMUX_CONF" <<'EOF'
# ~/.tmux.conf
# Enable mouse support (scroll, resize, select panes/windows)
set -g mouse on
# Use the mouse wheel to scroll in copy mode automatically
bind -T root WheelUpPane if-shell -F -t = "#{mouse_any_flag}" \
"send-keys -M" "if -Ft= '#{pane_in_mode}' 'send-keys -M' 'copy-mode -e'"
# Allow resizing panes by dragging borders
setw -g aggressive-resize on
# Easier navigation in copy mode
setw -g mode-keys vi
# Status bar improvements
set -g status-bg black
set -g status-fg green
set -g status-left-length 40
set -g status-left '#S '
set -g status-right '#(whoami)@#H %Y-%m-%d %H:%M'
# Pane borders more visible
set -g pane-border-style fg=cyan
set -g pane-active-border-style fg=yellow
# Reload config quickly
bind r source-file ~/.tmux.conf \; display-message "Reloaded tmux.conf"
# Use system clipboard on macOS
if-shell "command -v pbcopy >/dev/null 2>&1" \
"bind -T copy-mode-vi y send -X copy-pipe-and-cancel 'pbcopy'" \
"bind -T copy-mode-vi y send -X copy-pipe-and-cancel 'xclip -selection clipboard -in'"
EOF
echo "✅ Wrote $TMUX_CONF"
# --- apply config if tmux is running ---
if pgrep -x tmux >/dev/null 2>&1; then
echo "🔄 Reloading tmux config..."
tmux source-file "$TMUX_CONF"
else
echo " tmux is not running yet. Config will apply on next start."
fi
mark_done

View File

@@ -0,0 +1,42 @@
#!/bin/bash
set -euo pipefail
source source ../../functions/base.sh
mark_done
exit 0
URL="https://github.com/ollama/ollama/releases/download/v0.11.6/ollama-linux-amd64.tgz"
TGZ="/tmp/ollama.tgz"
INSTALL_PATH="/usr/bin/ollama"
echo "[*] Checking for running ollama serve..."
if pgrep -x "ollama" > /dev/null; then
echo "[*] Stopping running ollama process..."
pkill -9 ollama
sleep 2
fi
echo "[*] Downloading ollama..."
curl -L "$URL" -o "$TGZ"
echo "[*] Extracting..."
tar -xzf "$TGZ" -C /tmp
echo "[*] Installing to $INSTALL_PATH..."
sudo mv /tmp/ollama "$INSTALL_PATH"
sudo chmod +x "$INSTALL_PATH"
pkill -9 ollama
SESSION=$(tmux display-message -p '#S')
echo "[*] Using tmux session: $SESSION"
echo "[*] Started ollama pulls in tmux windows."
ollama pull adhishtanaka/llama_3.2_1b-SQL
mark_done

View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -euo pipefail
source source ../../functions/base.sh
# vllm serve openai/gpt-oss-20b
# vllm serve openai/gpt-oss-20b --tensor-parallel-size 2
# For 120B
# vllm serve openai/gpt-oss-120b
mark_done

View File

@@ -0,0 +1,42 @@
#!/bin/bash
set -euo pipefail
source source ../../functions/base.sh
mark_done
exit 0
# uv pip install --pre torch --index-url https://download.pytorch.org/whl/nightly/cu121
# uv pip install --pre torch --index-url https://download.pytorch.org/whl/nightly/cu128
touch "$0.done"
exit 0
uv pip install --pre torch==2.9.0.dev20250804+cu128 \
--index-url https://download.pytorch.org/whl/nightly/cu128
uv pip install tiktoken ipython numpy psutil
# 4. Confirm it's correct
python -c "import torch; print(torch.__version__, torch.version.cuda)"
# 2.9.0.dev20250804+cu128 12.8
cd /root
source .venv/bin/activate
uv pip install --upgrade pip setuptools wheel ninja
export MAX_JOBS=8
export TORCH_CUDA_ARCH_LIST="12.0"
export NCCL_P2P_DISABLE=0
export NCCL_DEBUG=INFO
export CUDA_DEVICE_MAX_CONNECTIONS=1
pip install flash-attn --no-build-isolation
uv pip install --pre vllm==0.10.1+gptoss \
--extra-index-url https://wheels.vllm.ai/gpt-oss/ \
--extra-index-url https://download.pytorch.org/whl/nightly/cu128 \
--index-strategy unsafe-best-match
mark_done

View File

@@ -6,6 +6,13 @@ Author-email: Kilo Code <kilo.code@example.com>
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: peewee
Requires-Dist: psutil>=5.9.0
Requires-Dist: fastapi>=0.100.0
Requires-Dist: uvicorn>=0.23.0
Requires-Dist: toml>=0.10.2
Requires-Dist: libtmux>=0.25.0
Requires-Dist: lmstudio
Requires-Dist: requests
# herolib_python

View File

@@ -64,6 +64,10 @@ herolib/downloader/scrape_scapegraph/scrape_md.py
herolib/downloader/scrape_scapegraph/scrape_search.py
herolib/downloader/scrape_scapegraph/scrape_with_local_llm.py
herolib/downloader/scrape_scapegraph/scrape_with_local_llm_search.py
herolib/infra/tmuxrunner/model.py
herolib/infra/tmuxrunner/process_monitor.py
herolib/infra/tmuxrunner/task_runner.py
herolib/infra/tmuxrunner/task_runner_api.py
herolib/tools/__init__.py
herolib/tools/extensions.py
herolib/tools/gitscanner.py

View File

@@ -1 +1,8 @@
peewee
psutil>=5.9.0
fastapi>=0.100.0
uvicorn>=0.23.0
toml>=0.10.2
libtmux>=0.25.0
lmstudio
requests

View File

View File

@@ -0,0 +1,64 @@
from typing import Dict, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class ProcessMetrics:
"""Metrics for a running process and its children."""
cpu_percent: float = 0.0
memory_rss: int = 0 # Resident Set Size in bytes
memory_vms: int = 0 # Virtual Memory Size in bytes
memory_percent: float = 0.0
num_threads: int = 0
num_children: int = 0
children_cpu_percent: float = 0.0
children_memory_rss: int = 0
last_updated: str = ""
@dataclass
class TaskStatus:
"""Status of an individual task (script)."""
script_path: str
script_name: str
state: str = "PENDING" # PENDING, WAITING, RUNNING, DONE, ERROR, CRASHED, TIMED_OUT
start_time: Optional[str] = None
end_time: Optional[str] = None
duration_seconds: float = 0.0
exit_code: Optional[int] = None
error_message: Optional[str] = None
pane_id: Optional[str] = None
process_metrics: ProcessMetrics = field(default_factory=ProcessMetrics)
@dataclass
class DirectoryStatus:
"""Status of a directory containing tasks."""
directory_num: int
directory_path: str
state: str = "PENDING" # PENDING, RUNNING, DONE, ERROR, TIMED_OUT
timeout: int = 600
start_time: Optional[str] = None
end_time: Optional[str] = None
duration_seconds: float = 0.0
tasks: List[TaskStatus] = field(default_factory=list)
window_name: Optional[str] = None
@dataclass
class DAGStructure:
"""Complete DAG structure for the task run."""
run_name: str
run_id: str
state: str = "INITIALIZING" # INITIALIZING, RUNNING, COMPLETED, FAILED
start_time: str = ""
end_time: Optional[str] = None
duration_seconds: float = 0.0
total_directories: int = 0
completed_directories: int = 0
failed_directories: int = 0
directories: List[DirectoryStatus] = field(default_factory=list)
last_updated: str = ""
class MetaData:
"""Class to hold metadata for a task directory."""
def __init__(self, timeout: int = 600): # Default timeout to 10 minutes (600 seconds)
self.timeout = timeout
# Add more attributes here in the future

View File

@@ -0,0 +1,89 @@
import psutil
from typing import List, Optional, Tuple
from datetime import datetime
from libtmux.pane import Pane
from .model import ProcessMetrics
class ProcessMonitor:
"""Monitor processes running in tmux panes using psutil."""
@staticmethod
def get_pane_process_tree(pane: Pane) -> Tuple[Optional[psutil.Process], List[psutil.Process]]:
"""Get the main process and all child processes for a tmux pane."""
try:
pane_pid = pane.pane_pid
if pane_pid is None:
return None, []
# Get the main process
try:
main_process = psutil.Process(int(pane_pid))
except (psutil.NoSuchProcess, ValueError):
return None, []
# Get all children recursively
children = []
try:
children = main_process.children(recursive=True)
except psutil.NoSuchProcess:
pass
return main_process, children
except Exception as e:
print(f"Error getting process tree: {e}")
return None, []
@staticmethod
def get_process_metrics(pane: Pane) -> ProcessMetrics:
"""Get CPU and memory metrics for all processes in a pane."""
metrics = ProcessMetrics()
metrics.last_updated = datetime.now().isoformat()
main_proc, children = ProcessMonitor.get_pane_process_tree(pane)
if main_proc is None:
return metrics
try:
# Get main process metrics
if main_proc.is_running():
metrics.cpu_percent = main_proc.cpu_percent(interval=0.1)
mem_info = main_proc.memory_info()
metrics.memory_rss = mem_info.rss
metrics.memory_vms = mem_info.vms
metrics.memory_percent = main_proc.memory_percent()
metrics.num_threads = main_proc.num_threads()
# Get children metrics
metrics.num_children = len(children)
for child in children:
try:
if child.is_running():
metrics.children_cpu_percent += child.cpu_percent(interval=0.1)
child_mem = child.memory_info()
metrics.children_memory_rss += child_mem.rss
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
print(f"Error getting process metrics: {e}")
return metrics
@staticmethod
def is_process_running_command(pane: Pane, command_pattern: str) -> bool:
"""Check if a specific command is running in the pane."""
main_proc, children = ProcessMonitor.get_pane_process_tree(pane)
all_processes = [main_proc] + children if main_proc else children
for proc in all_processes:
try:
if proc and proc.is_running():
cmdline = " ".join(proc.cmdline())
if command_pattern in cmdline:
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return False

View File

@@ -0,0 +1,559 @@
import os
import time
import re
import toml
import libtmux
from libtmux.pane import Pane
from libtmux.window import Window
from libtmux.session import Session
from typing import Dict, List, Optional, Tuple
from dataclasses import asdict
from datetime import datetime
import uuid
from pathlib import Path
import threading
from .model import DAGStructure, DirectoryStatus, TaskStatus, MetaData
from .process_monitor import ProcessMonitor
# Configuration
WAITING_MESSAGE = "WAITING FOR JOBS"
HPY_SH_PATH = "/root/heromonkey/functions/hpy.sh" # Path to hpy.sh
class TaskRunner:
def __init__(self, tasks_root_dir: str):
self.tasks_root_dir = tasks_root_dir
self.run_name = os.path.basename(os.path.abspath(tasks_root_dir)) # Derive run_name
self.session = self._get_current_tmux_session()
self.all_tasks_with_meta = self._get_sorted_tasks_with_meta(tasks_root_dir)
self.window_panes = {} # {window_idx: [pane1, pane2, ...]}
self.run_id = str(uuid.uuid4())
self.dag = self._initialize_dag()
self.dag_file_path = Path(tasks_root_dir) / ".dag.toml"
self.process_monitor = ProcessMonitor()
self._save_dag()
def _initialize_dag(self) -> DAGStructure:
"""Initialize the DAG structure."""
dag = DAGStructure(
run_name=self.run_name,
run_id=self.run_id,
state="INITIALIZING",
start_time=datetime.now().isoformat(),
total_directories=len(self.all_tasks_with_meta)
)
# Create directory entries
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
dir_status = DirectoryStatus(
directory_num=dir_num,
directory_path=os.path.dirname(scripts[0]) if scripts else "",
timeout=metadata.timeout,
window_name=f"{self.run_name}_{dir_num}"
)
# Create task entries
for script_path in scripts:
task = TaskStatus(
script_path=script_path,
script_name=os.path.basename(script_path)
)
dir_status.tasks.append(task)
dag.directories.append(dir_status)
return dag
def _save_dag(self):
"""Save the DAG structure to a TOML file."""
try:
dag_dict = asdict(self.dag)
with open(self.dag_file_path, 'w') as f:
toml.dump(dag_dict, f)
except Exception as e:
print(f"Error saving DAG: {e}")
def _update_task_state(self, dir_idx: int, task_idx: int,
state: str, error_message: Optional[str] = None):
"""Update task state and save DAG."""
task = self.dag.directories[dir_idx].tasks[task_idx]
old_state = task.state
task.state = state
if state == "RUNNING" and old_state != "RUNNING":
task.start_time = datetime.now().isoformat()
elif state in ["DONE", "ERROR", "CRASHED", "TIMED_OUT"]:
task.end_time = datetime.now().isoformat()
if task.start_time:
start = datetime.fromisoformat(task.start_time)
end = datetime.fromisoformat(task.end_time)
task.duration_seconds = (end - start).total_seconds()
if error_message:
task.error_message = error_message
self.dag.last_updated = datetime.now().isoformat()
self._save_dag()
def _update_directory_state(self, dir_idx: int, state: str):
"""Update directory state and save DAG."""
directory = self.dag.directories[dir_idx]
old_state = directory.state
directory.state = state
if state == "RUNNING" and old_state != "RUNNING":
directory.start_time = datetime.now().isoformat()
elif state in ["DONE", "ERROR", "TIMED_OUT"]:
directory.end_time = datetime.now().isoformat()
if directory.start_time:
start = datetime.fromisoformat(directory.start_time)
end = datetime.fromisoformat(directory.end_time)
directory.duration_seconds = (end - start).total_seconds()
if state == "DONE":
self.dag.completed_directories += 1
else:
self.dag.failed_directories += 1
self._save_dag()
def _check_task_status(self, script_path: str, pane: Pane) -> Tuple[str, Optional[str]]:
"""
Comprehensive task status checking.
Returns: (state, error_message)
"""
script_basename = os.path.basename(script_path)
done_file = f"{script_path}.done"
error_file = f"{script_path}.error"
ok_file = f"{script_path}.ok"
# Check file markers
if os.path.exists(done_file) or os.path.exists(ok_file):
# Create .ok file if it doesn't exist
if not os.path.exists(ok_file):
Path(ok_file).touch()
return "DONE", None
if os.path.exists(error_file):
error_msg = None
try:
with open(error_file, 'r') as f:
error_msg = f.read().strip()
except:
error_msg = "Unknown error"
return "ERROR", error_msg
# Check if hpy command is running
if self.process_monitor.is_process_running_command(pane, f"hpy {script_basename}"):
return "RUNNING", None
# Check if pane has any running process
if self._is_pane_running(pane):
# Might be setting up or running something else
return "RUNNING", None
# If we get here, the process finished without markers
# This is likely a crash
error_msg = f"Process terminated without completion marker"
# Create error file
with open(error_file, 'w') as f:
f.write(error_msg)
return "CRASHED", error_msg
def _monitor_directory_tasks(self, dir_idx: int, timeout: int) -> bool:
"""
Monitor tasks in a directory with comprehensive status checking.
Returns: True if all tasks completed successfully, False otherwise.
"""
directory = self.dag.directories[dir_idx]
scripts, metadata = self.all_tasks_with_meta[directory.directory_num]
panes = self.window_panes[dir_idx]
self._update_directory_state(dir_idx, "RUNNING")
start_time = time.time()
all_success = True
while True:
all_finished = True
has_errors = False
for task_idx, (script_path, pane) in enumerate(zip(scripts, panes)):
task = directory.tasks[task_idx]
# Get process metrics if running
if task.state == "RUNNING":
metrics = self.process_monitor.get_process_metrics(pane)
task.process_metrics = metrics
# Check task status
new_state, error_msg = self._check_task_status(script_path, pane)
if new_state != task.state:
self._update_task_state(dir_idx, task_idx, new_state, error_msg)
print(f" Task {task.script_name}: {task.state}")
if new_state == "RUNNING":
all_finished = False
elif new_state in ["ERROR", "CRASHED", "TIMED_OUT"]:
has_errors = True
all_success = False
# Save DAG periodically
self._save_dag()
if all_finished:
if has_errors:
self._update_directory_state(dir_idx, "ERROR")
else:
self._update_directory_state(dir_idx, "DONE")
break
# Check timeout
elapsed = time.time() - start_time
if elapsed > timeout:
print(f" Directory {directory.directory_num} timed out!")
for task_idx, task in enumerate(directory.tasks):
if task.state == "RUNNING":
self._update_task_state(dir_idx, task_idx, "TIMED_OUT")
panes[task_idx].send_keys("C-c", literal=True)
self._update_directory_state(dir_idx, "TIMED_OUT")
all_success = False
break
time.sleep(2) # Check every 2 seconds
return all_success
# def run(self):
# """Enhanced run method with DAG tracking."""
# print(f"Starting enhanced task orchestration for '{self.run_name}'")
# print(f"Run ID: {self.run_id}")
# print(f"DAG file: {self.dag_file_path}")
# self.dag.state = "RUNNING"
# self._save_dag()
# # Initialize windows and panes (similar to original)
# self._setup_windows_and_panes()
# # Process directories sequentially
# overall_success = True
# for dir_idx in range(len(self.dag.directories)):
# directory = self.dag.directories[dir_idx]
# print(f"\n--- Processing Directory {directory.directory_num} ---")
# # Start tasks if not the first directory
# if dir_idx > 0:
# self._start_directory_tasks(dir_idx)
# # Monitor tasks
# success = self._monitor_directory_tasks(
# dir_idx,
# directory.timeout
# )
# if not success:
# overall_success = False
# # Update final DAG state
# self.dag.state = "COMPLETED" if overall_success else "FAILED"
# self.dag.end_time = datetime.now().isoformat()
# if self.dag.start_time:
# start = datetime.fromisoformat(self.dag.start_time)
# end = datetime.fromisoformat(self.dag.end_time)
# self.dag.duration_seconds = (end - start).total_seconds()
# self._save_dag()
# print(f"\nTask orchestration completed: {self.dag.state}")
# print(f"Total duration: {self.dag.duration_seconds:.2f} seconds")
def reset(self):
"""Kills all processes and panes inside task windows, removes windows, and deletes .done/.error/.ok files."""
print(f"\n--- Resetting run '{self.run_name}' ---")
self.cleanup() # First, kill all associated tmux windows
# Then, remove all .done, .error, and .ok files
print(" Removing .done, .error, and .ok files...")
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
for script_path in scripts:
done_file = f"{script_path}.done"
error_file = f"{script_path}.error"
ok_file = f"{script_path}.ok"
if os.path.exists(done_file):
os.remove(done_file)
print(f" Removed: {done_file}")
if os.path.exists(error_file):
os.remove(error_file)
print(f" Removed: {error_file}")
if os.path.exists(ok_file):
os.remove(ok_file)
print(f" Removed: {ok_file}")
# Also remove the .dag.toml file if it exists
if hasattr(self, 'dag_file_path') and self.dag_file_path.exists():
os.remove(self.dag_file_path)
print(f" Removed: {self.dag_file_path}")
print("Reset complete.")
def _get_sorted_tasks_with_meta(self, tasks_root):
"""
Reads all scripts and .meta.toml from the tasks_root, sorts them by directory,
and then by script name within each directory.
Returns a dictionary where keys are directory numbers (e.g., 1, 2)
and values are tuples of (list_of_full_script_paths, MetaData_object).
"""
tasks_with_meta = {}
for dirpath, dirnames, filenames in os.walk(tasks_root):
if dirpath == tasks_root:
dirnames[:] = sorted([d for d in dirnames if d.isdigit()], key=int)
relative_path = os.path.relpath(dirpath, tasks_root)
if relative_path != "." and relative_path.isdigit():
dir_num = int(relative_path)
scripts = sorted([os.path.join(dirpath, f) for f in filenames if f.endswith(".sh")])
metadata_file = os.path.join(dirpath, ".meta.toml")
metadata = MetaData() # Default metadata
if os.path.exists(metadata_file):
try:
with open(metadata_file, 'r') as f:
meta_data_dict = toml.load(f)
if 'timeout' in meta_data_dict:
metadata.timeout = int(meta_data_dict['timeout'])
except Exception as e:
print(f"Warning: Could not read or parse .meta.toml for directory {dir_num}: {e}")
if scripts:
tasks_with_meta[dir_num] = (scripts, metadata)
sorted_tasks_with_meta = dict(sorted(tasks_with_meta.items()))
return sorted_tasks_with_meta
def _get_current_tmux_session(self) -> Session:
"""Gets the current tmux session based on TMUX environment variable."""
server = libtmux.Server()
tmux_env = os.environ.get('TMUX')
if not tmux_env:
raise Exception("Not running inside a tmux session. The 'TMUX' environment variable is not set.")
try:
# TMUX variable format: /tmp/tmux-1000/default,12345,0
# The last part '0' is the session index.
match = re.search(r',(\d+)$', tmux_env)
if not match:
raise Exception(f"Could not parse session index from TMUX environment variable: {tmux_env}")
session_index_from_env = match.group(1)
found_session = None
for s in server.sessions:
if s.session_id == f"${session_index_from_env}":
found_session = s
break
if not found_session:
raise Exception(f"Could not find tmux session with ID: ${session_index_from_env}")
print(f"Attached to current tmux session: '{found_session.name}' via TMUX env var.")
return found_session
except Exception as e:
raise Exception(f"Error getting current tmux session: {e}")
def _create_tmux_window(self, window_name: str) -> Window:
"""Creates a new tmux window."""
window = self.session.new_window(attach=False, window_name=window_name)
print(f" Tmux window '{window_name}' created.")
return window
def _create_tmux_pane(self, window: Window, pane_index: int, command: str) -> Pane:
"""Creates a tmux pane and sends a command."""
if pane_index == 0:
pane = window.active_pane
pane.send_keys("clear", enter=True)
else:
pane = window.split(attach=False)
pane.send_keys(command, enter=True)
print(f" Pane {pane_index}: Command sent: '{command}'")
return pane
def _is_pane_running(self, pane: Pane) -> bool:
"""Checks if a tmux pane is still running a process."""
try:
pane_pid = pane.pane_pid
if pane_pid is not None:
try:
pid_int = int(pane_pid)
if pid_int > 0:
os.kill(pid_int, 0)
return True
except (ValueError, OSError):
return False
return False
except Exception as e:
print(f"Error checking pane status for {pane.window_name}:{pane.pane_index}: {e}")
return False
def _setup_windows_and_panes(self):
"""Initial setup of tmux windows and panes for all tasks."""
all_dir_nums = sorted(self.all_tasks_with_meta.keys())
print("\n--- Initial Tmux Setup: Creating windows and panes ---")
for window_idx, dir_num in enumerate(all_dir_nums):
scripts, metadata = self.all_tasks_with_meta[dir_num]
window_name = f"{self.run_name}_{dir_num}"
window = self._create_tmux_window(window_name)
self.window_panes[window_idx] = []
for pane_idx, script_path in enumerate(scripts):
script_dir = os.path.dirname(script_path)
script_basename = os.path.basename(script_path)
if window_idx == 0:
# Send cd command first, then the hpy command
pane = self._create_tmux_pane(window, pane_idx, f"cd {script_dir}")
pane.send_keys(f"source {HPY_SH_PATH} && hpy {script_basename}; echo \"Script {script_basename} finished.\"", enter=True)
print(f" Pane {pane_idx}: Command sent: 'cd {script_dir}' and 'source {HPY_SH_PATH} && hpy {script_basename}'")
else:
command = f"echo '{WAITING_MESSAGE} for {script_basename}'"
pane = self._create_tmux_pane(window, pane_idx, command)
self.window_panes[window_idx].append(pane)
if window_idx == 0:
print(f" Window '{window_name}' (Directory {dir_num}) tasks started.")
else:
print(f" Window '{window_name}' (Directory {dir_num}) panes set to '{WAITING_MESSAGE}'.")
def _start_directory_tasks(self, dir_idx: int):
"""Starts tasks in a specific directory (window)."""
directory = self.dag.directories[dir_idx]
scripts, metadata = self.all_tasks_with_meta[directory.directory_num]
panes_in_current_window = self.window_panes[dir_idx]
print(f"\n--- Activating tasks in window '{directory.window_name}' (Directory {directory.directory_num}) ---")
for pane_idx, script_path in enumerate(scripts):
script_dir = os.path.dirname(script_path)
script_basename = os.path.basename(script_path)
pane = panes_in_current_window[pane_idx]
pane.send_keys("C-c", literal=True) # Clear any previous command/output
# Send cd command first, then the hpy command
pane.send_keys(f"cd {script_dir}", enter=True)
pane.send_keys(f"source {HPY_SH_PATH} && hpy {script_basename}; echo \"Script {script_basename} finished.\"", enter=True)
print(f" Pane {pane_idx}: Command sent: 'cd {script_dir}' and 'source {HPY_SH_PATH} && hpy {script_basename}'")
def run(self):
"""Enhanced run method with DAG tracking."""
print(f"Starting enhanced task orchestration for '{self.run_name}'")
print(f"Run ID: {self.run_id}")
print(f"DAG file: {self.dag_file_path}")
self.dag.state = "RUNNING"
self._save_dag()
if not self.all_tasks_with_meta:
print("No tasks found to execute.")
return
print("Detected tasks:")
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
print(f" Directory {dir_num} (Timeout: {metadata.timeout}s):")
for script in scripts:
print(f" - {script}")
# Initialize windows and panes
self._setup_windows_and_panes()
# Process directories sequentially
overall_success = True
for dir_idx in range(len(self.dag.directories)):
directory = self.dag.directories[dir_idx]
print(f"\n--- Processing Directory {directory.directory_num} ---")
# Start tasks if not the first directory
if dir_idx > 0:
self._start_directory_tasks(dir_idx)
# Monitor tasks
success = self._monitor_directory_tasks(
dir_idx,
directory.timeout
)
if not success:
overall_success = False
# Update final DAG state
self.dag.state = "COMPLETED" if overall_success else "FAILED"
self.dag.end_time = datetime.now().isoformat()
if self.dag.start_time:
start = datetime.fromisoformat(self.dag.start_time)
end = datetime.fromisoformat(self.dag.end_time)
self.dag.duration_seconds = (end - start).total_seconds()
self._save_dag()
print(f"\nTask orchestration completed: {self.dag.state}")
print(f"Total duration: {self.dag.duration_seconds:.2f} seconds")
print(f"You can attach to the tmux session to review: tmux attach -t {self.session.name}")
def cleanup(self):
"""Removes all tmux windows created by this run."""
print(f"\n--- Cleaning up tmux windows for run '{self.run_name}' ---")
print(f" Current session name: '{self.session.name}'")
all_session_windows = [w.name for w in self.session.windows if w.name]
print(f" All windows in current session: {all_session_windows}")
windows_to_kill = []
expected_prefix = f"{self.run_name}_"
print(f" Looking for windows starting with prefix: '{expected_prefix}'")
for window in self.session.windows:
if window.name and window.name.startswith(expected_prefix):
windows_to_kill.append(window)
if not windows_to_kill:
print(f" No windows found to kill with prefix '{expected_prefix}'.")
print("Cleanup complete.")
return
print(f" Identified {len(windows_to_kill)} windows to kill: {[w.name for w in windows_to_kill]}")
for window in windows_to_kill:
try:
window.kill()
print(f" Killed window: '{window.name}'")
except Exception as e:
print(f" Error killing window '{window.name}': {e}")
print("Cleanup complete.")
def reset(self):
"""Kills all processes and panes inside task windows, removes windows, and deletes .done/.error/.ok files."""
print(f"\n--- Resetting run '{self.run_name}' ---")
self.cleanup() # First, kill all associated tmux windows
# Then, remove all .done, .error, and .ok files
print(" Removing .done, .error, and .ok files...")
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
for script_path in scripts:
done_file = f"{script_path}.done"
error_file = f"{script_path}.error"
ok_file = f"{script_path}.ok"
if os.path.exists(done_file):
os.remove(done_file)
print(f" Removed: {done_file}")
if os.path.exists(error_file):
os.remove(error_file)
print(f" Removed: {error_file}")
if os.path.exists(ok_file):
os.remove(ok_file)
print(f" Removed: {ok_file}")
# Also remove the .dag.toml file if it exists
if hasattr(self, 'dag_file_path') and self.dag_file_path.exists():
os.remove(self.dag_file_path)
print(f" Removed: {self.dag_file_path}")
print("Reset complete.")

View File

@@ -0,0 +1,167 @@
import threading
import time
from typing import Dict, List, Optional
from dataclasses import asdict
from datetime import datetime
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from .task_runner import TaskRunner
class TaskRunnerAPI:
"""FastAPI interface for the task runner."""
def __init__(self, runner: TaskRunner):
self.runner = runner
self.app = FastAPI(title="Task Runner API", version="1.0.0")
# Add CORS middleware
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
self._setup_routes()
def _setup_routes(self):
"""Setup API routes."""
@self.app.get("/")
async def root():
"""Get API information."""
return {
"name": "Task Runner API",
"version": "1.0.0",
"run_id": self.runner.run_id,
"run_name": self.runner.run_name
}
@self.app.get("/status")
async def get_status():
"""Get current run status."""
return {
"run_id": self.runner.run_id,
"run_name": self.runner.run_name,
"state": self.runner.dag.state,
"start_time": self.runner.dag.start_time,
"end_time": self.runner.dag.end_time,
"duration_seconds": self.runner.dag.duration_seconds,
"total_directories": self.runner.dag.total_directories,
"completed_directories": self.runner.dag.completed_directories,
"failed_directories": self.runner.dag.failed_directories
}
@self.app.get("/directories")
async def get_directories():
"""Get all directory statuses."""
return [
{
"directory_num": d.directory_num,
"directory_path": d.directory_path,
"state": d.state,
"timeout": d.timeout,
"start_time": d.start_time,
"end_time": d.end_time,
"duration_seconds": d.duration_seconds,
"task_count": len(d.tasks),
"tasks_done": sum(1 for t in d.tasks if t.state == "DONE"),
"tasks_error": sum(1 for t in d.tasks if t.state in ["ERROR", "CRASHED", "TIMED_OUT"])
}
for d in self.runner.dag.directories
]
@self.app.get("/directories/{dir_num}/tasks")
async def get_directory_tasks(dir_num: int):
"""Get tasks for a specific directory."""
for d in self.runner.dag.directories:
if d.directory_num == dir_num:
return d.tasks
raise HTTPException(status_code=404, detail="Directory not found")
@self.app.get("/tasks/{dir_num}/{task_name}")
async def get_task_details(dir_num: int, task_name: str):
"""Get detailed information about a specific task."""
for d in self.runner.dag.directories:
if d.directory_num == dir_num:
for t in d.tasks:
if t.script_name == task_name:
return t
raise HTTPException(status_code=404, detail="Task not found")
@self.app.get("/metrics")
async def get_metrics():
"""Get current process metrics for all running tasks."""
metrics = []
for d in self.runner.dag.directories:
for t in d.tasks:
if t.state == "RUNNING":
metrics.append({
"directory": d.directory_num,
"task": t.script_name,
"cpu_percent": t.process_metrics.cpu_percent,
"memory_rss_mb": t.process_metrics.memory_rss / (1024 * 1024),
"memory_percent": t.process_metrics.memory_percent,
"num_threads": t.process_metrics.num_threads,
"num_children": t.process_metrics.num_children
})
return metrics
@self.app.get("/dag")
async def get_full_dag():
"""Get the complete DAG structure."""
return asdict(self.runner.dag)
def start(self, host: str = "0.0.0.0", port: int = 8000):
"""Start the FastAPI server."""
uvicorn.run(self.app, host=host, port=port)
class TaskOrchestrator:
"""Main orchestrator that runs tasks and API server."""
def __init__(self, tasks_dir: str, api_port: int = 8000):
self.runner = TaskRunner(tasks_dir)
self.api = TaskRunnerAPI(self.runner)
self.api_thread = None
self.api_port = api_port
def start_api_server(self, port: int = 8000):
"""Start API server in a separate thread."""
self.api_thread = threading.Thread(
target=self.api.start,
args=("0.0.0.0", port),
daemon=True
)
self.api_thread.start()
print(f"API server started on http://0.0.0.0:{port}")
def run(self):
"""Run the task orchestration."""
# Start API server
self.start_api_server(self.api_port)
# Reset and run tasks
self.runner.reset()
try:
self.runner.run()
except Exception as e:
print(f"Error during execution: {e}")
self.runner.dag.state = "FAILED"
self.runner.dag.end_time = datetime.now().isoformat()
self.runner._save_dag()
print("\nExecution completed. API server still running.")
print(f"Access API at: http://localhost:{self.api_port}")
print("Press Ctrl+C to stop the API server.")
try:
# Keep the main thread alive for API access
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")

View File

@@ -48,12 +48,3 @@ else
uv pip install herolib@git+https://git.ourworld.tf/herocode/herolib_python.git --force-reinstall --no-cache-dir
fi
echo -e "${GREEN}✅ Dependencies installed${NC}"
# Create necessary directories
mkdir -p static/css static/js static/images
mkdir -p templates
mkdir -p md
echo -e "${GREEN}✅ Directory structure verified${NC}"
echo -e "${GREEN}🎉 Installation complete! You can now run start_server.sh${NC}"

View File

@@ -7,7 +7,7 @@ authors = [
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["peewee"]
dependencies = ["peewee", "psutil>=5.9.0", "fastapi>=0.100.0", "uvicorn>=0.23.0", "toml>=0.10.2", "libtmux>=0.25.0","lmstudio","requests"]
[build-system]
@@ -18,4 +18,4 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["herolib*"]
include = ["herolib*"]

238
uv.lock generated
View File

@@ -1,20 +1,254 @@
version = 1
revision = 3
requires-python = "==3.12"
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
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://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 = "anyio"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
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://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 = "fastapi"
version = "0.116.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
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://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 = "herolib"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "libtmux" },
{ name = "peewee" },
{ name = "psutil" },
{ name = "toml" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [{ name = "peewee" }]
requires-dist = [
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "libtmux", specifier = ">=0.25.0" },
{ name = "peewee" },
{ name = "psutil", specifier = ">=5.9.0" },
{ name = "toml", specifier = ">=0.10.2" },
{ name = "uvicorn", specifier = ">=0.23.0" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "libtmux"
version = "0.46.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/aa/7e1dcaa097156d6f3a7d8669be4389dced997feeb81744e3ff4681d65ee8/libtmux-0.46.2.tar.gz", hash = "sha256:9a398fec5d714129c8344555d466e1a903dfc0f741ba07aabe75a8ceb25c5dda", size = 346887, upload-time = "2025-05-26T19:40:04.096Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/2f/9d207039fcfa00d3b30e4d765f062fbcc42c873c7518a8cfebb3eafd00e0/libtmux-0.46.2-py3-none-any.whl", hash = "sha256:6c32dbf22bde8e5e33b2714a4295f6e838dc640f337cd4c085a044f6828c7793", size = 60873, upload-time = "2025-05-26T19:40:02.284Z" },
]
[[package]]
name = "peewee"
version = "3.18.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" }
[[package]]
name = "psutil"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" },
{ url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" },
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
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://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 = "starlette"
version = "0.47.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
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://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 = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "uvicorn"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]