Добавил остановку активной runtime команды

Этот коммит содержится в:
Виктор
2026-05-12 01:15:52 +09:00
родитель be5fb0abb2
Коммит a04f096ed1
+60 -40
Просмотреть файл
@@ -1,6 +1,9 @@
import json import json
import os import os
import signal
import subprocess import subprocess
import time
import uuid
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -12,6 +15,7 @@ from backend.runtime_logs import get_logs, add_log
from backend.shell_runner import run_command from backend.shell_runner import run_command
router = APIRouter(prefix='/api/runtime', tags=['runtime']) router = APIRouter(prefix='/api/runtime', tags=['runtime'])
ACTIVE_PROCESSES: dict[str, subprocess.Popen] = {}
def _runtime_env_prefix() -> str: def _runtime_env_prefix() -> str:
@@ -19,14 +23,7 @@ def _runtime_env_prefix() -> str:
flutter_home = os.getenv('FLUTTER_HOME') or f'{runtime_home}/flutter' flutter_home = os.getenv('FLUTTER_HOME') or f'{runtime_home}/flutter'
android_home = os.getenv('ANDROID_HOME') or os.getenv('ANDROID_SDK_ROOT') or f'{runtime_home}/Android' android_home = os.getenv('ANDROID_HOME') or os.getenv('ANDROID_SDK_ROOT') or f'{runtime_home}/Android'
pub_cache = os.getenv('PUB_CACHE') or f'{runtime_home}/.pub-cache' pub_cache = os.getenv('PUB_CACHE') or f'{runtime_home}/.pub-cache'
return f'export HOME="{runtime_home}" PUB_CACHE="{pub_cache}" ANDROID_HOME="{android_home}" ANDROID_SDK_ROOT="{android_home}" PATH="{flutter_home}/bin:{android_home}/cmdline-tools/latest/bin:{android_home}/platform-tools:$PATH" && '
return (
f'export HOME="{runtime_home}" '
f'PUB_CACHE="{pub_cache}" '
f'ANDROID_HOME="{android_home}" '
f'ANDROID_SDK_ROOT="{android_home}" '
f'PATH="{flutter_home}/bin:{android_home}/cmdline-tools/latest/bin:{android_home}/platform-tools:$PATH" && '
)
def _flutter(command: str) -> str: def _flutter(command: str) -> str:
@@ -40,6 +37,11 @@ class RuntimeCommandRequest(BaseModel):
package_name: str | None = None package_name: str | None = None
class RuntimeStopRequest(BaseModel):
task_id: str | None = None
send_confirm: bool = True
def _runtime_commands() -> dict[str, dict]: def _runtime_commands() -> dict[str, dict]:
return { return {
'git_pull': {'label': 'Git Pull', 'command': 'git pull', 'timeout': 600}, 'git_pull': {'label': 'Git Pull', 'command': 'git pull', 'timeout': 600},
@@ -74,6 +76,7 @@ def _event(payload: dict) -> str:
def _stream_command(command: str, cwd: str, timeout: int): def _stream_command(command: str, cwd: str, timeout: int):
task_id = uuid.uuid4().hex
process = subprocess.Popen( process = subprocess.Popen(
command, command,
cwd=cwd, cwd=cwd,
@@ -81,25 +84,34 @@ def _stream_command(command: str, cwd: str, timeout: int):
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
bufsize=1, bufsize=1,
preexec_fn=os.setsid,
) )
ACTIVE_PROCESSES[task_id] = process
yield _event({'type': 'start', 'message': command}) yield _event({'type': 'start', 'task_id': task_id, 'message': command})
started_at = time.time()
try: try:
assert process.stdout is not None assert process.stdout is not None
for line in iter(process.stdout.readline, ''): while True:
if not line: if time.time() - started_at > timeout:
yield _event({'type': 'error', 'task_id': task_id, 'message': 'Command timeout'})
break break
yield _event({'type': 'line', 'message': line.rstrip()}) line = process.stdout.readline()
if line:
returncode = process.wait(timeout=timeout) yield _event({'type': 'line', 'task_id': task_id, 'message': line.rstrip()})
except subprocess.TimeoutExpired: continue
process.kill() if process.poll() is not None:
break
time.sleep(0.1)
returncode = process.poll()
if returncode is None:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
returncode = -1 returncode = -1
yield _event({'type': 'error', 'message': 'Command timeout'}) finally:
ACTIVE_PROCESSES.pop(task_id, None)
yield _event({'type': 'done', 'returncode': returncode}) yield _event({'type': 'done', 'task_id': task_id, 'returncode': returncode})
@router.get('/logs') @router.get('/logs')
@@ -109,13 +121,7 @@ async def runtime_logs():
@router.get('/commands') @router.get('/commands')
async def runtime_commands(): async def runtime_commands():
return { return {'success': True, 'commands': [{'id': command_id, 'label': config['label'], 'needs_device': bool(config.get('device'))} for command_id, config in _runtime_commands().items()]}
'success': True,
'commands': [
{'id': command_id, 'label': config['label'], 'needs_device': bool(config.get('device'))}
for command_id, config in _runtime_commands().items()
],
}
@router.post('/event') @router.post('/event')
@@ -131,14 +137,11 @@ async def runtime_command(payload: RuntimeCommandRequest):
config = commands.get(payload.command) config = commands.get(payload.command)
if not config: if not config:
raise HTTPException(status_code=400, detail='Runtime command is not allowed') raise HTTPException(status_code=400, detail='Runtime command is not allowed')
cwd = _workspace_cwd(payload.workspace) cwd = _workspace_cwd(payload.workspace)
command = _with_device(config['command'], payload.device, bool(config.get('device'))) command = _with_device(config['command'], payload.device, bool(config.get('device')))
add_log(f"Runtime command started: {config['label']}") add_log(f"Runtime command started: {config['label']}")
result = run_command(command, cwd, timeout=int(config.get('timeout', 900))) result = run_command(command, cwd, timeout=int(config.get('timeout', 900)))
add_log(f"Runtime command finished: {config['label']} (exit {result['returncode']})") add_log(f"Runtime command finished: {config['label']} (exit {result['returncode']})")
return {'success': result['returncode'] == 0, 'command': payload.command, 'label': config['label'], 'result': result} return {'success': result['returncode'] == 0, 'command': payload.command, 'label': config['label'], 'result': result}
@@ -148,16 +151,37 @@ async def runtime_command_stream(payload: RuntimeCommandRequest):
config = commands.get(payload.command) config = commands.get(payload.command)
if not config: if not config:
raise HTTPException(status_code=400, detail='Runtime command is not allowed') raise HTTPException(status_code=400, detail='Runtime command is not allowed')
cwd = _workspace_cwd(payload.workspace) cwd = _workspace_cwd(payload.workspace)
command = _with_device(config['command'], payload.device, bool(config.get('device'))) command = _with_device(config['command'], payload.device, bool(config.get('device')))
add_log(f"Runtime stream started: {config['label']}") add_log(f"Runtime stream started: {config['label']}")
return StreamingResponse(_stream_command(command, cwd, int(config.get('timeout', 900))), media_type='application/x-ndjson')
return StreamingResponse(
_stream_command(command, cwd, int(config.get('timeout', 900))), @router.post('/stop')
media_type='application/x-ndjson', async def runtime_stop(payload: RuntimeStopRequest):
) targets = []
if payload.task_id:
process = ACTIVE_PROCESSES.get(payload.task_id)
if process:
targets.append((payload.task_id, process))
else:
targets = list(ACTIVE_PROCESSES.items())
stopped = []
for task_id, process in targets:
try:
if payload.send_confirm and process.stdin:
process.stdin.write('Y\n')
process.stdin.flush()
time.sleep(0.4)
if process.poll() is None:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
stopped.append(task_id)
except Exception as exc:
add_log(f'Runtime stop error: {exc}')
add_log(f'Runtime stop requested: {stopped}')
return {'success': True, 'stopped': stopped}
@router.post('/install-latest-apk') @router.post('/install-latest-apk')
@@ -166,11 +190,9 @@ async def runtime_install_latest_apk(payload: RuntimeCommandRequest):
apks = find_apks(cwd) apks = find_apks(cwd)
if not apks: if not apks:
raise HTTPException(status_code=404, detail='APK not found. Build project first.') raise HTTPException(status_code=404, detail='APK not found. Build project first.')
adb = f'{_runtime_env_prefix()}adb' adb = f'{_runtime_env_prefix()}adb'
if payload.device: if payload.device:
adb = f'{_runtime_env_prefix()}adb -s {payload.device}' adb = f'{_runtime_env_prefix()}adb -s {payload.device}'
command = f'{adb} install -r "{apks[0]}"' command = f'{adb} install -r "{apks[0]}"'
add_log(f'Runtime APK install started: {apks[0]}') add_log(f'Runtime APK install started: {apks[0]}')
result = run_command(command, cwd, timeout=900) result = run_command(command, cwd, timeout=900)
@@ -182,12 +204,10 @@ async def runtime_install_latest_apk(payload: RuntimeCommandRequest):
async def runtime_restart_app(payload: RuntimeCommandRequest): async def runtime_restart_app(payload: RuntimeCommandRequest):
if not payload.package_name: if not payload.package_name:
raise HTTPException(status_code=400, detail='package_name is required') raise HTTPException(status_code=400, detail='package_name is required')
cwd = _workspace_cwd(payload.workspace) cwd = _workspace_cwd(payload.workspace)
adb = f'{_runtime_env_prefix()}adb' adb = f'{_runtime_env_prefix()}adb'
if payload.device: if payload.device:
adb = f'{_runtime_env_prefix()}adb -s {payload.device}' adb = f'{_runtime_env_prefix()}adb -s {payload.device}'
command = f'{adb} shell monkey -p {payload.package_name} 1' command = f'{adb} shell monkey -p {payload.package_name} 1'
add_log(f'Runtime app restart requested: {payload.package_name}') add_log(f'Runtime app restart requested: {payload.package_name}')
result = run_command(command, cwd, timeout=120) result = run_command(command, cwd, timeout=120)