bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
164 lines
5.4 KiB
Python
164 lines
5.4 KiB
Python
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
|
|
import asyncio
|
|
import functools
|
|
import json
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request, Response
|
|
from pydantic import BaseModel
|
|
|
|
from mirage.server.io_serde import io_result_to_dict
|
|
from mirage.server.jobs import JobEntry, JobStatus
|
|
|
|
router = APIRouter(prefix="/v1/workspaces/{workspace_id}/execute")
|
|
|
|
|
|
class ExecuteRequest(BaseModel):
|
|
command: str
|
|
session_id: str | None = None
|
|
provision: bool = False
|
|
agent_id: str | None = None
|
|
|
|
|
|
class BackgroundResponse(BaseModel):
|
|
job_id: str
|
|
workspace_id: str
|
|
submitted_at: float
|
|
|
|
|
|
def _require_entry(request: Request, workspace_id: str):
|
|
registry = request.app.state.registry
|
|
if workspace_id not in registry:
|
|
raise HTTPException(status_code=404, detail="workspace not found")
|
|
return registry.get(workspace_id)
|
|
|
|
|
|
def _build_execute_kwargs(req: ExecuteRequest, stdin: bytes | None) -> dict:
|
|
kwargs: dict[str, Any] = {
|
|
"command": req.command,
|
|
"provision": req.provision,
|
|
}
|
|
if req.session_id is not None:
|
|
kwargs["session_id"] = req.session_id
|
|
if req.agent_id is not None:
|
|
kwargs["agent_id"] = req.agent_id
|
|
if stdin is not None:
|
|
kwargs["stdin"] = stdin
|
|
return kwargs
|
|
|
|
|
|
def _make_coro_factory(runner, kwargs: dict):
|
|
return functools.partial(_invoke_execute, runner, kwargs)
|
|
|
|
|
|
async def _invoke_execute(runner, kwargs: dict):
|
|
return await runner.ws.execute(**kwargs)
|
|
|
|
|
|
def _schedule_on_runner(runner, coro):
|
|
return asyncio.run_coroutine_threadsafe(coro, runner.loop)
|
|
|
|
|
|
def _job_to_dict(entry: JobEntry,
|
|
result_dict: dict | None = None) -> dict[str, Any]:
|
|
return {
|
|
"job_id": entry.id,
|
|
"workspace_id": entry.workspace_id,
|
|
"command": entry.command,
|
|
"status": entry.status.value,
|
|
"submitted_at": entry.submitted_at,
|
|
"started_at": entry.started_at,
|
|
"finished_at": entry.finished_at,
|
|
"result": result_dict,
|
|
"error": entry.error,
|
|
}
|
|
|
|
|
|
@router.post("")
|
|
async def execute(
|
|
workspace_id: str,
|
|
request: Request,
|
|
background: bool = Query(False),
|
|
) -> Response:
|
|
entry = _require_entry(request, workspace_id)
|
|
job_table = request.app.state.jobs
|
|
content_type = request.headers.get("content-type", "")
|
|
req_obj, stdin_bytes = await _parse_execute_body(request, content_type)
|
|
schedule = functools.partial(_schedule_on_runner, entry.runner)
|
|
job = job_table.submit(
|
|
workspace_id=workspace_id,
|
|
command=req_obj.command,
|
|
schedule=schedule,
|
|
coro_factory=_make_coro_factory(
|
|
entry.runner,
|
|
_build_execute_kwargs(req_obj, stdin_bytes),
|
|
),
|
|
)
|
|
if background:
|
|
return Response(
|
|
content=BackgroundResponse(
|
|
job_id=job.id,
|
|
workspace_id=workspace_id,
|
|
submitted_at=job.submitted_at,
|
|
).model_dump_json(),
|
|
media_type="application/json",
|
|
status_code=202,
|
|
headers={"X-Mirage-Job-Id": job.id},
|
|
)
|
|
await job_table.wait(job.id)
|
|
if job.status == JobStatus.CANCELED:
|
|
raise HTTPException(status_code=499, detail="job canceled")
|
|
if job.status == JobStatus.FAILED:
|
|
raise HTTPException(status_code=500,
|
|
detail=job.error or "execute failed")
|
|
result_dict = await io_result_to_dict(job.result)
|
|
return Response(
|
|
content=json.dumps(result_dict),
|
|
media_type="application/json",
|
|
status_code=200,
|
|
headers={"X-Mirage-Job-Id": job.id},
|
|
)
|
|
|
|
|
|
async def _parse_execute_body(
|
|
request: Request,
|
|
content_type: str) -> tuple[ExecuteRequest, bytes | None]:
|
|
if content_type.startswith("multipart/"):
|
|
form = await request.form()
|
|
request_part = form.get("request")
|
|
if request_part is None:
|
|
raise HTTPException(status_code=400,
|
|
detail="multipart body missing 'request' part")
|
|
if hasattr(request_part, "read"):
|
|
req_text = (await request_part.read()).decode("utf-8")
|
|
else:
|
|
req_text = str(request_part)
|
|
try:
|
|
req_obj = ExecuteRequest.model_validate(json.loads(req_text))
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
raise HTTPException(status_code=400,
|
|
detail=f"bad request part: {e}")
|
|
stdin_part = form.get("stdin")
|
|
stdin_bytes: bytes | None = None
|
|
if stdin_part is not None:
|
|
if hasattr(stdin_part, "read"):
|
|
stdin_bytes = await stdin_part.read()
|
|
else:
|
|
stdin_bytes = str(stdin_part).encode("utf-8")
|
|
return req_obj, stdin_bytes
|
|
body = await request.json()
|
|
return ExecuteRequest.model_validate(body), None
|