feat(14.1-02): force re-analyze flag + single-item retry-job creation

- Add force: bool = False to AnalysisEnqueueRequest in cloud/schemas.py (D-11)
- Extend enqueue_analysis_job to accept force param; already_current check
  becomes (already_current and not force) — bypass for supported items (D-11, ANALYZE-06)
- Add retry_or_create_single_item_job service helper in cloud_analysis.py that
  creates a single-item force enqueue job when no active job exists (D-12)
- Add POST /analysis/connections/{id}/items/{cloud_item_id}/retry route
  in analysis.py returning AnalysisEnqueueOut with queued_count >= 1
- Wire force= through enqueue_job route handler (body.force)
- All 8 test_cloud_reanalyze_force tests pass; 26 test_cloud_analysis_contract
  tests pass (no idempotency regression); 872/873 backend tests pass
This commit is contained in:
curo1305
2026-06-26 21:59:50 +02:00
parent a0d5c1d6c4
commit 52acd5634b
2 changed files with 171 additions and 3 deletions
+89
View File
@@ -59,6 +59,7 @@ from services.cloud_analysis import (
list_analysis_jobs,
list_job_items,
retry_job_item,
retry_or_create_single_item_job,
skip_job_item,
InvalidJobState,
)
@@ -171,6 +172,7 @@ async def enqueue_job(
provider_item_ids=body.provider_item_ids,
recursive=body.recursive,
failure_behavior=body.failure_behavior,
force=body.force,
)
except ConnectionNotFound as exc:
raise HTTPException(
@@ -520,3 +522,90 @@ async def retry_analysis_item(
job_id=str(job_id),
item_id=str(item_id),
)
# ── Single-item retry — no active job required (D-12) ─────────────────────────
@router.post(
"/analysis/connections/{connection_id}/items/{cloud_item_id}/retry",
response_model=AnalysisEnqueueOut,
status_code=202,
)
@account_limiter.limit("30/minute")
async def retry_cloud_item(
request: Request,
connection_id: uuid.UUID,
cloud_item_id: uuid.UUID,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> AnalysisEnqueueOut:
"""Retry a failed cloud item, creating a single-item job if no active job exists.
D-12: If no surviving active job covers the failed item, this route creates a
one-item analysis job through the authorized enqueue path (force=True) so the
item is re-queued for fresh extraction and classification.
cloud_item_id must be the DocuVault stable UUID (not the provider_item_id).
The connection_id is used only for ownership verification of the URL namespace;
the service resolves the correct connection from the cloud item row.
Returns AnalysisEnqueueOut with job_id and queued_count >= 1 on success.
T-14.1-04: Owner-scoped via get_regular_user + item ownership check.
T-14.1-05: Creates a queued analysis job — no provider mutations are made.
"""
request.state.current_user = current_user
# Verify the caller owns the connection in the URL namespace (IDOR gate).
# The service also checks item ownership independently.
from services.cloud_items import resolve_owned_connection as _resolve_conn
try:
await _resolve_conn(
session,
connection_id=connection_id,
user_id=current_user.id,
)
except ConnectionNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Connection not found",
) from exc
try:
result = await retry_or_create_single_item_job(
session,
cloud_item_id=cloud_item_id,
user_id=current_user.id,
)
except AnalysisItemNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except InvalidJobState as exc:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"kind": "invalid_state", "reason": str(exc)},
)
except ConnectionNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
await session.commit()
return AnalysisEnqueueOut(
job_id=str(result.job_id),
status="queued",
total_count=result.total_count,
queued_count=result.queued_count,
already_current_count=result.already_current_count,
unsupported_count=result.unsupported_count,
)