chore: snapshot current state before cleanup
This commit is contained in:
parent
db4ce8b14f
commit
94e62c9456
14 changed files with 489 additions and 3929 deletions
199
app.py
199
app.py
|
|
@ -5,6 +5,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -35,6 +36,8 @@ NANOBOT_SCRIPT_WORKSPACE = Path(
|
|||
CARDS_ROOT = NANOBOT_WORKSPACE / "cards"
|
||||
CARD_INSTANCES_DIR = CARDS_ROOT / "instances"
|
||||
CARD_TEMPLATES_DIR = CARDS_ROOT / "templates"
|
||||
CARD_SOURCES_DIR = CARDS_ROOT / "sources"
|
||||
CARD_SOURCE_STATE_DIR = CARDS_ROOT / "source-state"
|
||||
TEMPLATES_CONTEXT_PATH = NANOBOT_WORKSPACE / "CARD_TEMPLATES.md"
|
||||
MAX_TEMPLATES_IN_PROMPT = 12
|
||||
MAX_TEMPLATE_HTML_CHARS = 4000
|
||||
|
|
@ -46,6 +49,8 @@ _MAX_SCRIPT_PROXY_ARGS = 16
|
|||
_MAX_SCRIPT_PROXY_STDERR_CHARS = 2000
|
||||
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CARD_SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CARD_SOURCE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="Nanobot SuperTonic Wisper Web")
|
||||
|
||||
|
|
@ -525,6 +530,155 @@ def _normalize_home_assistant_proxy_path(target_path: str) -> str:
|
|||
return f"/api{normalized}"
|
||||
|
||||
|
||||
|
||||
def _run_workspace_script(script_file: Path, args: list[str], *, timeout_seconds: float) -> tuple[int, str, str]:
|
||||
process = subprocess.run(
|
||||
[sys.executable, str(script_file), *args],
|
||||
cwd=str(script_file.parent),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
return process.returncode, process.stdout.strip(), process.stderr.strip()
|
||||
|
||||
|
||||
def _card_source_state_path(source_id: str) -> Path:
|
||||
return CARD_SOURCE_STATE_DIR / f"{source_id}.json"
|
||||
|
||||
|
||||
def _load_card_source_configs() -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for path in sorted(CARD_SOURCES_DIR.glob('*.json')):
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
source_id = _normalize_card_id(str(raw.get('id') or path.stem))
|
||||
if not source_id or raw.get('enabled', True) is False:
|
||||
continue
|
||||
script = str(raw.get('script', '')).strip()
|
||||
if not script:
|
||||
continue
|
||||
raw_args = raw.get('args', [])
|
||||
if not isinstance(raw_args, list):
|
||||
raw_args = []
|
||||
try:
|
||||
min_interval_ms = max(0, int(raw.get('min_interval_ms', 10000)))
|
||||
except (TypeError, ValueError):
|
||||
min_interval_ms = 10000
|
||||
try:
|
||||
timeout_seconds = max(1, min(300, int(raw.get('timeout_seconds', 60))))
|
||||
except (TypeError, ValueError):
|
||||
timeout_seconds = 60
|
||||
rows.append({
|
||||
'id': source_id,
|
||||
'script': script,
|
||||
'args': [str(arg) for arg in raw_args][: _MAX_SCRIPT_PROXY_ARGS],
|
||||
'min_interval_ms': min_interval_ms,
|
||||
'timeout_seconds': timeout_seconds,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _load_card_source_state(source_id: str) -> dict[str, Any]:
|
||||
path = _card_source_state_path(source_id)
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_card_source_state(source_id: str, payload: dict[str, Any]) -> None:
|
||||
_card_source_state_path(source_id).write_text(
|
||||
json.dumps(payload, indent=2, ensure_ascii=False) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
def _sync_card_sources(*, force: bool = False, source_id: str | None = None) -> list[dict[str, Any]]:
|
||||
now = datetime.now(timezone.utc)
|
||||
results: list[dict[str, Any]] = []
|
||||
for config in _load_card_source_configs():
|
||||
current_id = str(config.get('id', ''))
|
||||
if source_id and current_id != source_id:
|
||||
continue
|
||||
state = _load_card_source_state(current_id)
|
||||
last_completed_raw = str(state.get('last_completed_at', '') or '')
|
||||
should_run = force
|
||||
if not should_run:
|
||||
if not last_completed_raw:
|
||||
should_run = True
|
||||
else:
|
||||
try:
|
||||
last_completed = datetime.fromisoformat(last_completed_raw)
|
||||
elapsed_ms = (now - last_completed).total_seconds() * 1000
|
||||
should_run = elapsed_ms >= int(config.get('min_interval_ms', 10000))
|
||||
except ValueError:
|
||||
should_run = True
|
||||
if not should_run:
|
||||
results.append({'id': current_id, 'status': 'skipped'})
|
||||
continue
|
||||
|
||||
try:
|
||||
script_file = _resolve_workspace_script(str(config.get('script', '')))
|
||||
returncode, stdout_text, stderr_text = _run_workspace_script(
|
||||
script_file,
|
||||
list(config.get('args', [])),
|
||||
timeout_seconds=float(config.get('timeout_seconds', 60)),
|
||||
)
|
||||
runtime = {
|
||||
'id': current_id,
|
||||
'last_started_at': now.isoformat(),
|
||||
'last_completed_at': datetime.now(timezone.utc).isoformat(),
|
||||
'last_return_code': returncode,
|
||||
'script': str(config.get('script', '')),
|
||||
'args': list(config.get('args', [])),
|
||||
}
|
||||
if returncode != 0:
|
||||
runtime['last_error'] = stderr_text[:_MAX_SCRIPT_PROXY_STDERR_CHARS]
|
||||
_save_card_source_state(current_id, runtime)
|
||||
results.append({'id': current_id, 'status': 'error', 'error': runtime['last_error']})
|
||||
continue
|
||||
parsed_stdout: Any = None
|
||||
if stdout_text:
|
||||
try:
|
||||
parsed_stdout = json.loads(stdout_text)
|
||||
except json.JSONDecodeError:
|
||||
parsed_stdout = stdout_text
|
||||
runtime['last_result'] = parsed_stdout
|
||||
runtime.pop('last_error', None)
|
||||
_save_card_source_state(current_id, runtime)
|
||||
results.append({'id': current_id, 'status': 'synced', 'result': parsed_stdout})
|
||||
except subprocess.TimeoutExpired:
|
||||
runtime = {
|
||||
'id': current_id,
|
||||
'last_started_at': now.isoformat(),
|
||||
'last_completed_at': datetime.now(timezone.utc).isoformat(),
|
||||
'last_return_code': -1,
|
||||
'last_error': 'card source timed out',
|
||||
'script': str(config.get('script', '')),
|
||||
'args': list(config.get('args', [])),
|
||||
}
|
||||
_save_card_source_state(current_id, runtime)
|
||||
results.append({'id': current_id, 'status': 'error', 'error': 'card source timed out'})
|
||||
except Exception as exc:
|
||||
runtime = {
|
||||
'id': current_id,
|
||||
'last_started_at': now.isoformat(),
|
||||
'last_completed_at': datetime.now(timezone.utc).isoformat(),
|
||||
'last_return_code': -1,
|
||||
'last_error': str(exc),
|
||||
'script': str(config.get('script', '')),
|
||||
'args': list(config.get('args', [])),
|
||||
}
|
||||
_save_card_source_state(current_id, runtime)
|
||||
results.append({'id': current_id, 'status': 'error', 'error': str(exc)})
|
||||
return results
|
||||
|
||||
|
||||
def _resolve_workspace_script(script_path: str) -> Path:
|
||||
normalized = script_path.strip().lstrip("/")
|
||||
if not normalized:
|
||||
|
|
@ -631,33 +785,26 @@ async def workspace_script_proxy(script_path: str, request: Request) -> JSONResp
|
|||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
str(script_file),
|
||||
*args,
|
||||
cwd=str(script_file.parent),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
returncode, stdout_text, stderr_text = await asyncio.to_thread(
|
||||
_run_workspace_script,
|
||||
script_file,
|
||||
args,
|
||||
timeout_seconds=60.0,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
except subprocess.TimeoutExpired:
|
||||
return JSONResponse({"error": "script execution timed out"}, status_code=504)
|
||||
except OSError as exc:
|
||||
return JSONResponse({"error": f"failed to start script: {exc}"}, status_code=502)
|
||||
|
||||
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
||||
if process.returncode != 0:
|
||||
if returncode != 0:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": f"script exited with code {process.returncode}",
|
||||
"error": f"script exited with code {returncode}",
|
||||
"stderr": stderr_text[:_MAX_SCRIPT_PROXY_STDERR_CHARS],
|
||||
},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
||||
try:
|
||||
payload = json.loads(stdout_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
|
|
@ -674,9 +821,26 @@ async def workspace_script_proxy(script_path: str, request: Request) -> JSONResp
|
|||
|
||||
@app.get("/cards")
|
||||
async def get_cards() -> JSONResponse:
|
||||
_sync_card_sources()
|
||||
return JSONResponse(_load_cards())
|
||||
|
||||
|
||||
@app.post("/cards/sync")
|
||||
async def sync_cards_endpoint(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
raw_source_id = str(payload.get('source_id', '')).strip()
|
||||
source_id = _normalize_card_id(raw_source_id) if raw_source_id else ''
|
||||
if raw_source_id and not source_id:
|
||||
return JSONResponse({'error': 'invalid source id'}, status_code=400)
|
||||
results = _sync_card_sources(force=True, source_id=source_id or None)
|
||||
return JSONResponse({'status': 'ok', 'results': results})
|
||||
|
||||
|
||||
@app.delete("/cards/{card_id}")
|
||||
async def delete_card(card_id: str) -> JSONResponse:
|
||||
if not _normalize_card_id(card_id):
|
||||
|
|
@ -761,7 +925,10 @@ async def post_message(request: Request) -> JSONResponse:
|
|||
return JSONResponse({"error": "empty message"}, status_code=400)
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
await gateway.send_user_message(text, metadata=metadata)
|
||||
try:
|
||||
await gateway.send_user_message(text, metadata=metadata)
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue