feat(t207): audit local procurement results
This commit is contained in:
@@ -19,10 +19,11 @@ const claimTokenHeader = "X-Claim-Token"
|
||||
type DeviceServices struct {
|
||||
Lifecycle *usecase.LifecycleService
|
||||
Assets *usecase.AssetService
|
||||
Results *usecase.ExecutionResultService
|
||||
}
|
||||
|
||||
func (services DeviceServices) validate() error {
|
||||
if services.Lifecycle == nil || services.Assets == nil {
|
||||
if services.Lifecycle == nil || services.Assets == nil || services.Results == nil {
|
||||
return errors.New("device services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -68,6 +69,11 @@ func NewDeviceRouteRegistrar(
|
||||
"/api/v1/tasks/:id/cancel-ack",
|
||||
handler.acknowledgeCancellation,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/events", handler.appendEvents)
|
||||
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
|
||||
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
|
||||
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
||||
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
@@ -367,6 +373,257 @@ func (handler *deviceHandlers) acknowledgeCancellation(
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) appendEvents(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
Events []usecase.ClientExecutionEvent `json:"events"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
replayed, err := handler.services.Results.AppendEvents(
|
||||
ctx.Request.Context(),
|
||||
usecase.AppendExecutionEventsCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
Events: request.Events,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) uploadEvidence(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !isEvidenceMediaType(ctx.GetHeader("Content-Type")) {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnsupportedMediaType,
|
||||
"ASSET_MEDIA_TYPE_UNSUPPORTED",
|
||||
"JPEG, PNG, or WebP evidence is required",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
generation, err := strconv.ParseInt(
|
||||
strings.TrimSpace(ctx.GetHeader("X-Claim-Generation")),
|
||||
10,
|
||||
64,
|
||||
)
|
||||
if err != nil {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"EXECUTION_RESULT_INVALID",
|
||||
"execution result request is invalid",
|
||||
false,
|
||||
fieldDetails("claim_generation", "must be a positive integer"),
|
||||
)
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxMultipartBytes)
|
||||
result, err := handler.services.Results.UploadEvidence(
|
||||
ctx.Request.Context(),
|
||||
usecase.UploadExecutionEvidenceCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx,
|
||||
principal,
|
||||
ctx.GetHeader("X-Execution-ID"),
|
||||
generation,
|
||||
),
|
||||
DeclaredMediaType: ctx.GetHeader("Content-Type"),
|
||||
Content: ctx.Request.Body,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusCreated, gin.H{
|
||||
"evidence": deviceEvidenceResponse(result.Evidence),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
TaskContentSHA256 string `json:"task_content_sha256"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
SearchQuery string `json:"search_query"`
|
||||
Provenance *usecase.ExecutionProvenance `json:"provenance"`
|
||||
Candidates []usecase.ExecutionCandidate `json:"candidates"`
|
||||
Recommendation *usecase.CandidateRecommendation `json:"recommendation"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
replayed, err := handler.services.Results.StoreCandidates(
|
||||
ctx.Request.Context(),
|
||||
usecase.StoreExecutionCandidatesCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
TaskContentSHA256: request.TaskContentSHA256,
|
||||
ExecutionMode: request.ExecutionMode,
|
||||
SearchQuery: request.SearchQuery,
|
||||
Provenance: request.Provenance,
|
||||
Candidates: request.Candidates,
|
||||
Recommendation: request.Recommendation,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) completeTask(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
TaskContentSHA256 string `json:"task_content_sha256"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Outcome string `json:"outcome"`
|
||||
OperatorReason string `json:"operator_reason"`
|
||||
Candidate *usecase.ExecutionCandidate `json:"candidate"`
|
||||
OrderSubmitted bool `json:"order_submitted"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
result, replayed, err := handler.services.Results.Complete(
|
||||
ctx.Request.Context(),
|
||||
usecase.CompleteExecutionCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
TaskContentSHA256: request.TaskContentSHA256,
|
||||
ExecutionMode: request.ExecutionMode,
|
||||
Outcome: request.Outcome,
|
||||
OperatorReason: request.OperatorReason,
|
||||
Candidate: request.Candidate,
|
||||
OrderSubmitted: request.OrderSubmitted,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"task": deviceTaskResponse(result),
|
||||
"replayed": replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) failTask(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Step string `json:"step"`
|
||||
Retryable bool `json:"retryable"`
|
||||
} `json:"error"`
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) {
|
||||
return
|
||||
}
|
||||
result, replayed, err := handler.services.Results.Fail(
|
||||
ctx.Request.Context(),
|
||||
usecase.FailExecutionCommand{
|
||||
Identity: handler.executionResultIdentity(
|
||||
ctx, principal, request.ExecutionID, request.ClaimGeneration,
|
||||
),
|
||||
ErrorCode: request.Error.Code,
|
||||
ErrorMessage: request.Error.Message,
|
||||
ErrorStep: request.Error.Step,
|
||||
Retryable: request.Error.Retryable,
|
||||
EvidenceAssetIDs: request.EvidenceAssetIDs,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"task": deviceTaskResponse(result),
|
||||
"replayed": replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) executionResultIdentity(
|
||||
ctx *gin.Context,
|
||||
principal domain.AuthPrincipal,
|
||||
executionID string,
|
||||
claimGeneration int64,
|
||||
) usecase.ExecutionResultIdentity {
|
||||
return usecase.ExecutionResultIdentity{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: executionID,
|
||||
ClaimGeneration: claimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
}
|
||||
}
|
||||
|
||||
func isEvidenceMediaType(value string) bool {
|
||||
return hasEvidenceMediaType(value, "image/jpeg") ||
|
||||
hasEvidenceMediaType(value, "image/png") ||
|
||||
hasEvidenceMediaType(value, "image/webp")
|
||||
}
|
||||
|
||||
func hasEvidenceMediaType(value string, expected string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(strings.Split(value, ";")[0]), expected)
|
||||
}
|
||||
|
||||
func deviceEvidenceResponse(evidence domain.ExecutionEvidenceAsset) gin.H {
|
||||
return gin.H{
|
||||
"id": evidence.ID,
|
||||
"media_type": evidence.MediaType,
|
||||
"size_bytes": evidence.SizeBytes,
|
||||
"sha256": evidence.SHA256,
|
||||
"created_at": formatTime(evidence.CreatedAt),
|
||||
"received_after_execution_expiry": evidence.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
|
||||
type lifecycleTransitionRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
|
||||
Reference in New Issue
Block a user