feat: add miner management endpoints and standardize all API paths to /api/v1 prefix

- Add POST /api/v1/miners/{miner_id}/jobs endpoint for listing miner-assigned jobs with filtering
- Add POST /api/v1/miners/{miner_id}/earnings endpoint for miner earnings tracking (mock implementation)
- Add PUT /api/v1/miners/{miner_id}/capabilities endpoint for updating miner capabilities
- Add DELETE /api/v1/miners/{miner_id} endpoint for miner deregistration (sets OFFLINE status)
- Add JobService.fail_job()
This commit is contained in:
oib
2026-03-05 11:12:57 +01:00
parent c2d4f39a36
commit 80b9ea4b25
11 changed files with 1664 additions and 85 deletions

View File

@@ -121,34 +121,142 @@ async def submit_failure(
session: SessionDep,
miner_id: str = Depends(require_miner_key()),
) -> dict[str, str]: # type: ignore[arg-type]
job_service = JobService(session)
miner_service = MinerService(session)
try:
job = job_service.get_job(job_id)
service = JobService(session)
service.fail_job(job_id, miner_id, req.error_message)
return {"status": "ok"}
except KeyError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="job not found")
job.state = JobState.failed
job.error = f"{req.error_code}: {req.error_message}"
job.assigned_miner_id = miner_id
session.add(job)
session.commit()
# Auto-refund payment if job has payment
if job.payment_id and job.payment_status in ["pending", "escrowed"]:
from ..services.payments import PaymentService
payment_service = PaymentService(session)
success = await payment_service.refund_payment(
job.id,
job.payment_id,
reason=f"Job failed: {req.error_code}: {req.error_message}"
@router.post("/miners/{miner_id}/jobs", summary="List jobs for a miner")
async def list_miner_jobs(
miner_id: str,
limit: int = 20,
offset: int = 0,
job_type: str | None = None,
min_reward: float | None = None,
job_status: str | None = None,
session: SessionDep = SessionDep,
api_key: str = Depends(require_miner_key()),
) -> dict[str, Any]: # type: ignore[arg-type]
"""List jobs assigned to a specific miner"""
try:
service = JobService(session)
# Build filters
filters = {}
if job_type:
filters["job_type"] = job_type
if job_status:
try:
filters["state"] = JobState(job_status.upper())
except ValueError:
pass # Invalid status, ignore
# Get jobs for this miner
jobs = service.list_jobs(
client_id=miner_id, # Using client_id as miner_id for now
limit=limit,
offset=offset,
**filters
)
if success:
job.payment_status = "refunded"
session.commit()
logger.info(f"Auto-refunded payment {job.payment_id} for failed job {job.id}")
else:
logger.error(f"Failed to auto-refund payment {job.payment_id} for job {job.id}")
miner_service.release(miner_id, success=False)
return {"status": "ok"}
return {
"jobs": [service.to_view(job) for job in jobs],
"total": len(jobs),
"limit": limit,
"offset": offset,
"miner_id": miner_id
}
except Exception as e:
logger.error(f"Error listing miner jobs: {e}")
return {
"jobs": [],
"total": 0,
"limit": limit,
"offset": offset,
"miner_id": miner_id,
"error": str(e)
}
@router.post("/miners/{miner_id}/earnings", summary="Get miner earnings")
async def get_miner_earnings(
miner_id: str,
from_time: str | None = None,
to_time: str | None = None,
session: SessionDep = SessionDep,
api_key: str = Depends(require_miner_key()),
) -> dict[str, Any]: # type: ignore[arg-type]
"""Get earnings for a specific miner"""
try:
# For now, return mock earnings data
# In a full implementation, this would query payment records
earnings_data = {
"miner_id": miner_id,
"total_earnings": 0.0,
"pending_earnings": 0.0,
"completed_jobs": 0,
"currency": "AITBC",
"from_time": from_time,
"to_time": to_time,
"earnings_history": []
}
return earnings_data
except Exception as e:
logger.error(f"Error getting miner earnings: {e}")
return {
"miner_id": miner_id,
"total_earnings": 0.0,
"pending_earnings": 0.0,
"completed_jobs": 0,
"currency": "AITBC",
"error": str(e)
}
@router.put("/miners/{miner_id}/capabilities", summary="Update miner capabilities")
async def update_miner_capabilities(
miner_id: str,
req: MinerRegister,
session: SessionDep = SessionDep,
api_key: str = Depends(require_miner_key()),
) -> dict[str, Any]: # type: ignore[arg-type]
"""Update capabilities for a registered miner"""
try:
service = MinerService(session)
record = service.register(miner_id, req) # Re-use register to update
return {
"miner_id": miner_id,
"status": "updated",
"capabilities": req.capabilities,
"session_token": record.session_token
}
except KeyError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="miner not found")
except Exception as e:
logger.error(f"Error updating miner capabilities: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.delete("/miners/{miner_id}", summary="Deregister miner")
async def deregister_miner(
miner_id: str,
session: SessionDep = SessionDep,
api_key: str = Depends(require_miner_key()),
) -> dict[str, str]: # type: ignore[arg-type]
"""Deregister a miner from the coordinator"""
try:
service = MinerService(session)
service.deregister(miner_id)
return {
"miner_id": miner_id,
"status": "deregistered"
}
except KeyError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="miner not found")
except Exception as e:
logger.error(f"Error deregistering miner: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))

View File

@@ -72,6 +72,17 @@ class JobService:
return self.session.execute(query).scalars().all()
def fail_job(self, job_id: str, miner_id: str, error_message: str) -> Job:
"""Mark a job as failed"""
job = self.get_job(job_id)
job.state = JobState.FAILED
job.error = error_message
job.assigned_miner_id = miner_id
self.session.add(job)
self.session.commit()
self.session.refresh(job)
return job
def cancel_job(self, job: Job) -> Job:
if job.state not in {JobState.queued, JobState.running}:
return job

View File

@@ -116,3 +116,15 @@ class MinerService:
def online_count(self) -> int:
result = self.session.execute(select(Miner).where(Miner.status == "ONLINE"))
return len(result.all())
def deregister(self, miner_id: str) -> None:
"""Deregister a miner from the system"""
miner = self.session.get(Miner, miner_id)
if miner is None:
raise KeyError("miner not registered")
# Set status to OFFLINE instead of deleting to maintain history
miner.status = "OFFLINE"
miner.session_token = None
self.session.add(miner)
self.session.commit()