feat(t207): audit local procurement results
This commit is contained in:
@@ -180,6 +180,15 @@ func buildRouter(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results, err := usecase.NewExecutionResultService(
|
||||
store,
|
||||
files,
|
||||
clock,
|
||||
ids,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passwords, err := password.NewBcrypt(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -188,6 +197,7 @@ func buildRouter(
|
||||
httpapi.DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -245,8 +255,9 @@ func buildRouter(
|
||||
}
|
||||
registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar(
|
||||
httpapi.AdminServices{
|
||||
Assets: assets,
|
||||
Tasks: tasks,
|
||||
Assets: assets,
|
||||
Tasks: tasks,
|
||||
Results: results,
|
||||
},
|
||||
webHandler,
|
||||
)
|
||||
|
||||
@@ -84,11 +84,75 @@ type TaskExecution struct {
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
type ExecutionEvent struct {
|
||||
ID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
Step string
|
||||
Type string
|
||||
Message string
|
||||
OccurredAt time.Time
|
||||
ReceivedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionEvidenceAsset struct {
|
||||
ID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
MediaType string
|
||||
SizeBytes int64
|
||||
SHA256 string
|
||||
StorageKey string
|
||||
CreatedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionCandidateBatch struct {
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
TaskContentSHA256 string
|
||||
ExecutionMode string
|
||||
SearchQuery string
|
||||
ProvenanceJSON *string
|
||||
CandidatesJSON string
|
||||
RecommendationJSON *string
|
||||
ReceivedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionOutcome struct {
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ResultType string
|
||||
ExecutionMode *string
|
||||
TaskContentSHA256 *string
|
||||
Outcome *string
|
||||
OperatorReason *string
|
||||
SelectedCandidateJSON *string
|
||||
EvidenceAssetIDsJSON *string
|
||||
ErrorCode *string
|
||||
ErrorMessage *string
|
||||
ErrorStep *string
|
||||
Retryable *bool
|
||||
OrderSubmitted bool
|
||||
ReceivedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionReport struct {
|
||||
Events []ExecutionEvent
|
||||
EvidenceAssets []ExecutionEvidenceAsset
|
||||
CandidateBatch *ExecutionCandidateBatch
|
||||
Outcome *ExecutionOutcome
|
||||
}
|
||||
|
||||
type TaskDetail struct {
|
||||
Task PurchaseTask
|
||||
Asset Asset
|
||||
Execution *TaskExecution
|
||||
Events []TaskEvent
|
||||
Report *ExecutionReport
|
||||
}
|
||||
|
||||
type TaskValidationError struct {
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("initial Up() error = %v", err)
|
||||
} else if applied != 4 {
|
||||
t.Fatalf("initial Up() applied = %d, want 4", applied)
|
||||
} else if applied != 5 {
|
||||
t.Fatalf("initial Up() applied = %d, want 5", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v4) error = %v", err)
|
||||
@@ -50,6 +50,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v5) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v4) with compatible history error = %v", err)
|
||||
}
|
||||
@@ -57,8 +62,8 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4) error = %v", err)
|
||||
} else if applied != 1 {
|
||||
t.Fatalf("final Up(v4) applied = %d, want 1", applied)
|
||||
} else if applied != 2 {
|
||||
t.Fatalf("final Up(v4-v5) applied = %d, want 2", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -294,6 +299,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v5) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err == nil {
|
||||
t.Fatal("Down(v4) succeeded with non-representable audit event")
|
||||
}
|
||||
|
||||
@@ -27,14 +27,15 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 4 {
|
||||
t.Fatalf("Up() applied = %d, want 4", applied)
|
||||
if applied != 5 {
|
||||
t.Fatalf("Up() applied = %d, want 5", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -52,7 +53,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: false,
|
||||
4: true,
|
||||
5: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -67,6 +69,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v5) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v4) error = %v", err)
|
||||
}
|
||||
@@ -403,8 +406,8 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("Up(v3-v4) error = %v", err)
|
||||
} else if applied != 2 {
|
||||
t.Fatalf("Up(v3-v4) applied = %d, want 2", applied)
|
||||
} else if applied != 3 {
|
||||
t.Fatalf("Up(v3-v5) applied = %d, want 3", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,891 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
type executionResultRequestRecord struct {
|
||||
RequestHash string
|
||||
ClaimTokenHash string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ResourceID *string
|
||||
}
|
||||
|
||||
func (s *Store) GetExecutionEvidence(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
evidenceID string,
|
||||
) (domain.ExecutionEvidenceAsset, error) {
|
||||
return getExecutionEvidenceForTask(ctx, s.db, taskID, evidenceID)
|
||||
}
|
||||
|
||||
func (s *Store) AppendExecutionEvents(
|
||||
ctx context.Context,
|
||||
write usecase.ExecutionResultWrite,
|
||||
events []domain.ExecutionEvent,
|
||||
) (bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if replayed, err := replayExecutionResultRequest(
|
||||
ctx, tx, write,
|
||||
); err != nil || replayed {
|
||||
return replayed, err
|
||||
}
|
||||
_, _, expired, err := authorizeExecutionResult(ctx, tx, write)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, event := range events {
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO execution_events (
|
||||
id, task_id, execution_id, step, event_type, message,
|
||||
occurred_at, received_at, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
event.ID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
event.Step,
|
||||
event.Type,
|
||||
event.Message,
|
||||
formatTimestamp(event.OccurredAt),
|
||||
formatTimestamp(write.Now),
|
||||
expired,
|
||||
)
|
||||
if err != nil {
|
||||
return false, repositoryFailure(err)
|
||||
}
|
||||
}
|
||||
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, repositoryFailure(err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateExecutionEvidence(
|
||||
ctx context.Context,
|
||||
write usecase.ExecutionResultWrite,
|
||||
candidate domain.ExecutionEvidenceAsset,
|
||||
) (domain.ExecutionEvidenceAsset, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
|
||||
if err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, err
|
||||
}
|
||||
if found {
|
||||
if err := validateExecutionResultReplay(record, write); err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, err
|
||||
}
|
||||
if record.ResourceID == nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, usecase.ErrRepositoryInvariant
|
||||
}
|
||||
evidence, err := getExecutionEvidence(ctx, tx, *record.ResourceID)
|
||||
if err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
|
||||
}
|
||||
return evidence, true, nil
|
||||
}
|
||||
_, _, expired, err := authorizeExecutionResult(ctx, tx, write)
|
||||
if err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, err
|
||||
}
|
||||
candidate.ReceivedAfterExecutionExpiry = expired
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO execution_evidence_assets (
|
||||
id, task_id, execution_id, media_type, size_bytes, sha256,
|
||||
storage_key, created_at, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
candidate.ID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
candidate.MediaType,
|
||||
candidate.SizeBytes,
|
||||
candidate.SHA256,
|
||||
candidate.StorageKey,
|
||||
formatTimestamp(candidate.CreatedAt),
|
||||
expired,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
|
||||
}
|
||||
if err := insertExecutionResultRequest(ctx, tx, write, &candidate.ID); err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
|
||||
}
|
||||
return candidate, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) StoreExecutionCandidates(
|
||||
ctx context.Context,
|
||||
write usecase.ExecutionResultWrite,
|
||||
candidate domain.ExecutionCandidateBatch,
|
||||
) (bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if replayed, err := replayExecutionResultRequest(ctx, tx, write); err != nil || replayed {
|
||||
return replayed, err
|
||||
}
|
||||
task, _, expired, err := authorizeExecutionResult(ctx, tx, write)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if usecase.TaskContentSHA256(task) != candidate.TaskContentSHA256 {
|
||||
return false, usecase.ErrTaskVersionConflict
|
||||
}
|
||||
if err := validateCandidateEvidence(ctx, tx, write, candidate.CandidatesJSON); err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO execution_candidate_batches (
|
||||
execution_id, task_id, task_content_sha256, execution_mode,
|
||||
search_query, provenance_json, candidates_json, recommendation_json,
|
||||
received_at, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
write.ExecutionID,
|
||||
write.TaskID,
|
||||
candidate.TaskContentSHA256,
|
||||
candidate.ExecutionMode,
|
||||
candidate.SearchQuery,
|
||||
nullableString(candidate.ProvenanceJSON),
|
||||
candidate.CandidatesJSON,
|
||||
nullableString(candidate.RecommendationJSON),
|
||||
formatTimestamp(write.Now),
|
||||
expired,
|
||||
)
|
||||
if err != nil {
|
||||
return false, repositoryFailure(err)
|
||||
}
|
||||
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, repositoryFailure(err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Store) CompleteExecution(
|
||||
ctx context.Context,
|
||||
write usecase.ExecutionResultWrite,
|
||||
outcome domain.ExecutionOutcome,
|
||||
) (domain.PurchaseTask, bool, error) {
|
||||
return s.finishExecution(ctx, write, outcome, domain.TaskStatusSucceeded)
|
||||
}
|
||||
|
||||
func (s *Store) FailExecution(
|
||||
ctx context.Context,
|
||||
write usecase.ExecutionResultWrite,
|
||||
outcome domain.ExecutionOutcome,
|
||||
) (domain.PurchaseTask, bool, error) {
|
||||
return s.finishExecution(ctx, write, outcome, domain.TaskStatusFailed)
|
||||
}
|
||||
|
||||
func (s *Store) finishExecution(
|
||||
ctx context.Context,
|
||||
write usecase.ExecutionResultWrite,
|
||||
outcome domain.ExecutionOutcome,
|
||||
terminalStatus domain.TaskStatus,
|
||||
) (domain.PurchaseTask, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
if found {
|
||||
if err := validateExecutionResultReplay(record, write); err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
task, err := getLifecycleTask(ctx, tx, write.TaskID)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
if task.Status != terminalStatus {
|
||||
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
return task, true, nil
|
||||
}
|
||||
task, execution, expired, err := authorizeExecutionResult(ctx, tx, write)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
if task.CancelRequestedAt != nil {
|
||||
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if outcome.ResultType == "COMPLETE" {
|
||||
if outcome.TaskContentSHA256 == nil ||
|
||||
*outcome.TaskContentSHA256 != usecase.TaskContentSHA256(task) {
|
||||
return domain.PurchaseTask{}, false, usecase.ErrTaskVersionConflict
|
||||
}
|
||||
if err := validateCompleteCandidate(ctx, tx, write, outcome); err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
if err := validateSelectedEvidence(ctx, tx, write, outcome.SelectedCandidateJSON); err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
} else if err := validateEvidenceIDsFromOutcome(
|
||||
ctx, tx, write, outcome.EvidenceAssetIDsJSON,
|
||||
); err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
_ = execution
|
||||
outcome.ReceivedAfterExecutionExpiry = expired
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO execution_outcomes (
|
||||
execution_id, task_id, result_type, execution_mode, task_content_sha256,
|
||||
outcome, operator_reason, selected_candidate_json, evidence_asset_ids_json,
|
||||
error_code, error_message, error_step, retryable, order_submitted, received_at,
|
||||
received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||
write.ExecutionID,
|
||||
write.TaskID,
|
||||
outcome.ResultType,
|
||||
nullableString(outcome.ExecutionMode),
|
||||
nullableString(outcome.TaskContentSHA256),
|
||||
nullableString(outcome.Outcome),
|
||||
nullableString(outcome.OperatorReason),
|
||||
nullableString(outcome.SelectedCandidateJSON),
|
||||
nullableString(outcome.EvidenceAssetIDsJSON),
|
||||
nullableString(outcome.ErrorCode),
|
||||
nullableString(outcome.ErrorMessage),
|
||||
nullableString(outcome.ErrorStep),
|
||||
nullableBool(outcome.Retryable),
|
||||
formatTimestamp(write.Now),
|
||||
expired,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
step := "COMPLETED"
|
||||
if terminalStatus == domain.TaskStatusFailed {
|
||||
step = "FAILED"
|
||||
}
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE task_executions
|
||||
SET current_step = ?,
|
||||
last_heartbeat_at = ?,
|
||||
finished_at = ?
|
||||
WHERE id = ? AND finished_at IS NULL`,
|
||||
step,
|
||||
formatTimestamp(write.Now),
|
||||
formatTimestamp(write.Now),
|
||||
write.ExecutionID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
return domain.PurchaseTask{}, false, usecase.ErrExecutionMismatch
|
||||
}
|
||||
result, err = tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE purchase_tasks
|
||||
SET status = ?,
|
||||
version = version + 1,
|
||||
claimed_by_user_id = NULL,
|
||||
claimed_by_device_id = NULL,
|
||||
claim_token_hash = NULL,
|
||||
claim_issued_at = NULL,
|
||||
claim_expires_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
|
||||
AND claim_generation = ?`,
|
||||
terminalStatus,
|
||||
formatTimestamp(write.Now),
|
||||
write.TaskID,
|
||||
write.ClaimGeneration,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
task, err = getLifecycleTask(ctx, tx, write.TaskID)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||
}
|
||||
return task, false, nil
|
||||
}
|
||||
|
||||
func authorizeExecutionResult(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
) (domain.PurchaseTask, domain.TaskExecution, bool, error) {
|
||||
task, err := getClaimProtectedTask(ctx, tx, write.TaskID)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
|
||||
}
|
||||
if err := validateClaimOwner(
|
||||
task,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
); err != nil {
|
||||
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
|
||||
}
|
||||
if !domain.CanHeartbeat(task.Status) {
|
||||
return domain.PurchaseTask{}, domain.TaskExecution{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
execution, err := getExecutionByID(ctx, tx, write.ExecutionID)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
|
||||
}
|
||||
if execution.TaskID != write.TaskID || execution.UserID != write.UserID ||
|
||||
execution.DeviceID != write.DeviceID ||
|
||||
execution.ClaimGeneration != write.ClaimGeneration || execution.FinishedAt != nil {
|
||||
return domain.PurchaseTask{}, domain.TaskExecution{}, false, usecase.ErrExecutionMismatch
|
||||
}
|
||||
expired := task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(write.Now)
|
||||
return task, execution, expired, nil
|
||||
}
|
||||
|
||||
func replayExecutionResultRequest(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
) (bool, error) {
|
||||
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
|
||||
if err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
if err := validateExecutionResultReplay(record, write); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, repositoryFailure(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func lookupExecutionResultRequest(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
) (executionResultRequestRecord, bool, error) {
|
||||
var record executionResultRequestRecord
|
||||
var resourceID sql.NullString
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT request_sha256, claim_token_sha256, task_id, execution_id, resource_id
|
||||
FROM execution_result_requests
|
||||
WHERE user_id = ? AND device_id = ? AND operation = ? AND idempotency_key = ?`,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.Operation,
|
||||
write.IdempotencyKey,
|
||||
).Scan(
|
||||
&record.RequestHash,
|
||||
&record.ClaimTokenHash,
|
||||
&record.TaskID,
|
||||
&record.ExecutionID,
|
||||
&resourceID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return executionResultRequestRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return executionResultRequestRecord{}, false, repositoryFailure(err)
|
||||
}
|
||||
if resourceID.Valid {
|
||||
record.ResourceID = &resourceID.String
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func validateExecutionResultReplay(
|
||||
record executionResultRequestRecord,
|
||||
write usecase.ExecutionResultWrite,
|
||||
) error {
|
||||
if record.RequestHash != write.RequestHash ||
|
||||
record.ClaimTokenHash != write.ClaimTokenHash ||
|
||||
record.TaskID != write.TaskID || record.ExecutionID != write.ExecutionID {
|
||||
return usecase.ErrIdempotencyConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertExecutionResultRequest(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
resourceID *string,
|
||||
) error {
|
||||
_, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO execution_result_requests (
|
||||
user_id, device_id, operation, idempotency_key, request_sha256,
|
||||
claim_token_sha256, task_id, execution_id, resource_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.Operation,
|
||||
write.IdempotencyKey,
|
||||
write.RequestHash,
|
||||
write.ClaimTokenHash,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
nullableString(resourceID),
|
||||
formatTimestamp(write.Now),
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getExecutionEvidence(
|
||||
ctx context.Context,
|
||||
queryer queryRower,
|
||||
evidenceID string,
|
||||
) (domain.ExecutionEvidenceAsset, error) {
|
||||
var evidence domain.ExecutionEvidenceAsset
|
||||
var createdAt string
|
||||
var receivedAfter bool
|
||||
err := queryer.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, task_id, execution_id, media_type, size_bytes, sha256,
|
||||
storage_key, created_at, received_after_execution_expiry
|
||||
FROM execution_evidence_assets WHERE id = ?`, evidenceID,
|
||||
).Scan(
|
||||
&evidence.ID,
|
||||
&evidence.TaskID,
|
||||
&evidence.ExecutionID,
|
||||
&evidence.MediaType,
|
||||
&evidence.SizeBytes,
|
||||
&evidence.SHA256,
|
||||
&evidence.StorageKey,
|
||||
&createdAt,
|
||||
&receivedAfter,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryInvariant
|
||||
}
|
||||
if err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, repositoryFailure(err)
|
||||
}
|
||||
evidence.CreatedAt, err = parseTimestamp(createdAt)
|
||||
if err != nil {
|
||||
return domain.ExecutionEvidenceAsset{}, repositoryFailure(err)
|
||||
}
|
||||
evidence.ReceivedAfterExecutionExpiry = receivedAfter
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func getExecutionEvidenceForTask(
|
||||
ctx context.Context,
|
||||
queryer queryRower,
|
||||
taskID string,
|
||||
evidenceID string,
|
||||
) (domain.ExecutionEvidenceAsset, error) {
|
||||
evidence, err := getExecutionEvidence(ctx, queryer, evidenceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, usecase.ErrRepositoryInvariant) {
|
||||
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
return domain.ExecutionEvidenceAsset{}, err
|
||||
}
|
||||
if evidence.TaskID != taskID {
|
||||
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func validateCandidateEvidence(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
candidatesJSON string,
|
||||
) error {
|
||||
var candidates []struct {
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if err := verifyExecutionEvidenceIDs(
|
||||
ctx, tx, write.TaskID, write.ExecutionID, candidate.EvidenceAssetIDs,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSelectedEvidence(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
selectedJSON *string,
|
||||
) error {
|
||||
if selectedJSON == nil {
|
||||
return nil
|
||||
}
|
||||
var selected struct {
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*selectedJSON), &selected); err != nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
return verifyExecutionEvidenceIDs(
|
||||
ctx, tx, write.TaskID, write.ExecutionID, selected.EvidenceAssetIDs,
|
||||
)
|
||||
}
|
||||
|
||||
func validateCompleteCandidate(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
outcome domain.ExecutionOutcome,
|
||||
) error {
|
||||
if outcome.Outcome == nil || *outcome.Outcome != "CANDIDATE_ACCEPTED" {
|
||||
return nil
|
||||
}
|
||||
if outcome.ExecutionMode == nil || outcome.SelectedCandidateJSON == nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
var mode string
|
||||
var candidatesJSON string
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT execution_mode, candidates_json
|
||||
FROM execution_candidate_batches
|
||||
WHERE execution_id = ? AND task_id = ?`,
|
||||
write.ExecutionID,
|
||||
write.TaskID,
|
||||
).Scan(&mode, &candidatesJSON)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if mode != *outcome.ExecutionMode {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
var selected usecase.ExecutionCandidate
|
||||
var candidates []usecase.ExecutionCandidate
|
||||
if err := json.Unmarshal([]byte(*outcome.SelectedCandidateJSON), &selected); err != nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Ordinal != selected.Ordinal {
|
||||
continue
|
||||
}
|
||||
stored, storedErr := json.Marshal(candidate)
|
||||
selectedJSON, selectedErr := json.Marshal(selected)
|
||||
if storedErr != nil || selectedErr != nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
if string(stored) == string(selectedJSON) {
|
||||
return nil
|
||||
}
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
|
||||
func validateEvidenceIDsFromOutcome(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ExecutionResultWrite,
|
||||
evidenceJSON *string,
|
||||
) error {
|
||||
if evidenceJSON == nil {
|
||||
return nil
|
||||
}
|
||||
var evidenceIDs []string
|
||||
if err := json.Unmarshal([]byte(*evidenceJSON), &evidenceIDs); err != nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
return verifyExecutionEvidenceIDs(
|
||||
ctx, tx, write.TaskID, write.ExecutionID, evidenceIDs,
|
||||
)
|
||||
}
|
||||
|
||||
func verifyExecutionEvidenceIDs(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
taskID string,
|
||||
executionID string,
|
||||
ids []string,
|
||||
) error {
|
||||
seen := map[string]struct{}{}
|
||||
for _, evidenceID := range ids {
|
||||
evidenceID = strings.TrimSpace(evidenceID)
|
||||
if _, duplicate := seen[evidenceID]; duplicate {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
seen[evidenceID] = struct{}{}
|
||||
var exists int
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM execution_evidence_assets
|
||||
WHERE id = ? AND task_id = ? AND execution_id = ?
|
||||
)`,
|
||||
evidenceID,
|
||||
taskID,
|
||||
executionID,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if exists != 1 {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getExecutionReport(
|
||||
ctx context.Context,
|
||||
queryer queryer,
|
||||
taskID string,
|
||||
executionID string,
|
||||
) (*domain.ExecutionReport, error) {
|
||||
report := &domain.ExecutionReport{
|
||||
Events: make([]domain.ExecutionEvent, 0),
|
||||
EvidenceAssets: make([]domain.ExecutionEvidenceAsset, 0),
|
||||
}
|
||||
events, err := queryer.QueryContext(
|
||||
ctx,
|
||||
`SELECT id, task_id, execution_id, step, event_type, message,
|
||||
occurred_at, received_at, received_after_execution_expiry
|
||||
FROM execution_events
|
||||
WHERE task_id = ? AND execution_id = ?
|
||||
ORDER BY occurred_at ASC, id ASC`,
|
||||
taskID,
|
||||
executionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
defer events.Close()
|
||||
for events.Next() {
|
||||
var event domain.ExecutionEvent
|
||||
var occurredAt, receivedAt string
|
||||
if err := events.Scan(
|
||||
&event.ID,
|
||||
&event.TaskID,
|
||||
&event.ExecutionID,
|
||||
&event.Step,
|
||||
&event.Type,
|
||||
&event.Message,
|
||||
&occurredAt,
|
||||
&receivedAt,
|
||||
&event.ReceivedAfterExecutionExpiry,
|
||||
); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
var parseErr error
|
||||
event.OccurredAt, parseErr = parseTimestamp(occurredAt)
|
||||
if parseErr == nil {
|
||||
event.ReceivedAt, parseErr = parseTimestamp(receivedAt)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, repositoryFailure(parseErr)
|
||||
}
|
||||
report.Events = append(report.Events, event)
|
||||
}
|
||||
if err := events.Err(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
evidenceRows, err := queryer.QueryContext(
|
||||
ctx,
|
||||
`SELECT id, task_id, execution_id, media_type, size_bytes, sha256,
|
||||
storage_key, created_at, received_after_execution_expiry
|
||||
FROM execution_evidence_assets
|
||||
WHERE task_id = ? AND execution_id = ?
|
||||
ORDER BY created_at ASC, id ASC`,
|
||||
taskID,
|
||||
executionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
defer evidenceRows.Close()
|
||||
for evidenceRows.Next() {
|
||||
var evidence domain.ExecutionEvidenceAsset
|
||||
var createdAt string
|
||||
if err := evidenceRows.Scan(
|
||||
&evidence.ID,
|
||||
&evidence.TaskID,
|
||||
&evidence.ExecutionID,
|
||||
&evidence.MediaType,
|
||||
&evidence.SizeBytes,
|
||||
&evidence.SHA256,
|
||||
&evidence.StorageKey,
|
||||
&createdAt,
|
||||
&evidence.ReceivedAfterExecutionExpiry,
|
||||
); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
parsed, err := parseTimestamp(createdAt)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
evidence.CreatedAt = parsed
|
||||
report.EvidenceAssets = append(report.EvidenceAssets, evidence)
|
||||
}
|
||||
if err := evidenceRows.Err(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
var batch domain.ExecutionCandidateBatch
|
||||
var provenance, recommendation sql.NullString
|
||||
var receivedAt string
|
||||
err = queryer.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT task_id, execution_id, task_content_sha256, execution_mode,
|
||||
search_query, provenance_json, candidates_json, recommendation_json,
|
||||
received_at, received_after_execution_expiry
|
||||
FROM execution_candidate_batches
|
||||
WHERE task_id = ? AND execution_id = ?`,
|
||||
taskID,
|
||||
executionID,
|
||||
).Scan(
|
||||
&batch.TaskID,
|
||||
&batch.ExecutionID,
|
||||
&batch.TaskContentSHA256,
|
||||
&batch.ExecutionMode,
|
||||
&batch.SearchQuery,
|
||||
&provenance,
|
||||
&batch.CandidatesJSON,
|
||||
&recommendation,
|
||||
&receivedAt,
|
||||
&batch.ReceivedAfterExecutionExpiry,
|
||||
)
|
||||
if err == nil {
|
||||
if provenance.Valid {
|
||||
batch.ProvenanceJSON = &provenance.String
|
||||
}
|
||||
if recommendation.Valid {
|
||||
batch.RecommendationJSON = &recommendation.String
|
||||
}
|
||||
batch.ReceivedAt, err = parseTimestamp(receivedAt)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
report.CandidateBatch = &batch
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
var outcome domain.ExecutionOutcome
|
||||
var mode, hash, result, reason, selected, evidenceIDs, code, message, step sql.NullString
|
||||
var retryable sql.NullBool
|
||||
var outcomeReceivedAt string
|
||||
err = queryer.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT task_id, execution_id, result_type, execution_mode,
|
||||
task_content_sha256, outcome, operator_reason, selected_candidate_json,
|
||||
evidence_asset_ids_json, error_code, error_message, error_step, retryable, order_submitted,
|
||||
received_at, received_after_execution_expiry
|
||||
FROM execution_outcomes
|
||||
WHERE task_id = ? AND execution_id = ?`,
|
||||
taskID,
|
||||
executionID,
|
||||
).Scan(
|
||||
&outcome.TaskID,
|
||||
&outcome.ExecutionID,
|
||||
&outcome.ResultType,
|
||||
&mode,
|
||||
&hash,
|
||||
&result,
|
||||
&reason,
|
||||
&selected,
|
||||
&evidenceIDs,
|
||||
&code,
|
||||
&message,
|
||||
&step,
|
||||
&retryable,
|
||||
&outcome.OrderSubmitted,
|
||||
&outcomeReceivedAt,
|
||||
&outcome.ReceivedAfterExecutionExpiry,
|
||||
)
|
||||
if err == nil {
|
||||
outcome.ExecutionMode = nullableStringFromSQL(mode)
|
||||
outcome.TaskContentSHA256 = nullableStringFromSQL(hash)
|
||||
outcome.Outcome = nullableStringFromSQL(result)
|
||||
outcome.OperatorReason = nullableStringFromSQL(reason)
|
||||
outcome.SelectedCandidateJSON = nullableStringFromSQL(selected)
|
||||
outcome.EvidenceAssetIDsJSON = nullableStringFromSQL(evidenceIDs)
|
||||
outcome.ErrorCode = nullableStringFromSQL(code)
|
||||
outcome.ErrorMessage = nullableStringFromSQL(message)
|
||||
outcome.ErrorStep = nullableStringFromSQL(step)
|
||||
if retryable.Valid {
|
||||
outcome.Retryable = &retryable.Bool
|
||||
}
|
||||
outcome.ReceivedAt, err = parseTimestamp(outcomeReceivedAt)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
report.Outcome = &outcome
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func nullableStringFromSQL(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.String
|
||||
}
|
||||
|
||||
var _ usecase.ExecutionResultRepository = (*Store)(nil)
|
||||
@@ -20,6 +20,11 @@ type queryRower interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
type queryer interface {
|
||||
queryRower
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
@@ -332,6 +337,13 @@ func nullableInt64(value *int64) any {
|
||||
return *value
|
||||
}
|
||||
|
||||
func nullableBool(value *bool) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func repositoryFailure(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -313,11 +313,19 @@ func (s *Store) GetTaskDetail(
|
||||
} else {
|
||||
executionPointer = &execution
|
||||
}
|
||||
var report *domain.ExecutionReport
|
||||
if executionPointer != nil {
|
||||
report, err = getExecutionReport(ctx, tx, taskID, executionPointer.ID)
|
||||
if err != nil {
|
||||
return domain.TaskDetail{}, err
|
||||
}
|
||||
}
|
||||
detail := domain.TaskDetail{
|
||||
Task: task,
|
||||
Asset: asset,
|
||||
Execution: executionPointer,
|
||||
Events: events,
|
||||
Report: report,
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.TaskDetail{}, repositoryFailure(err)
|
||||
|
||||
@@ -24,12 +24,13 @@ const (
|
||||
)
|
||||
|
||||
type AdminServices struct {
|
||||
Assets *usecase.AssetService
|
||||
Tasks *usecase.TaskService
|
||||
Assets *usecase.AssetService
|
||||
Tasks *usecase.TaskService
|
||||
Results *usecase.ExecutionResultService
|
||||
}
|
||||
|
||||
func (s AdminServices) validate() error {
|
||||
if s.Assets == nil || s.Tasks == nil {
|
||||
if s.Assets == nil || s.Tasks == nil || s.Results == nil {
|
||||
return errors.New("admin services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -49,10 +50,38 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
routes.POST("/api/v1/tasks", handler.createTask)
|
||||
routes.GET("/api/v1/tasks", handler.listTasks)
|
||||
routes.GET("/api/v1/tasks/:id", handler.taskDetail)
|
||||
routes.GET(
|
||||
"/api/v1/tasks/:id/evidence/:evidence_id/content",
|
||||
handler.evidenceContent,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *adminHandlers) evidenceContent(ctx *gin.Context) {
|
||||
result, err := h.services.Results.OpenEvidence(
|
||||
ctx.Request.Context(),
|
||||
ctx.Param("id"),
|
||||
ctx.Param("evidence_id"),
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
defer result.Content.Close()
|
||||
ctx.Header("Cache-Control", "private, no-store")
|
||||
ctx.Header("Content-Type", result.Evidence.MediaType)
|
||||
ctx.Header("Content-Length", strconv.FormatInt(result.Evidence.SizeBytes, 10))
|
||||
ctx.Header("ETag", `"`+result.Evidence.SHA256+`"`)
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Header(
|
||||
"Content-Disposition",
|
||||
`inline; filename="`+result.Evidence.ID+`.jpg"`,
|
||||
)
|
||||
ctx.Status(http.StatusOK)
|
||||
_, _ = io.Copy(ctx.Writer, result.Content)
|
||||
}
|
||||
|
||||
func (h *adminHandlers) uploadAsset(ctx *gin.Context) {
|
||||
if !hasMediaType(ctx, "multipart/form-data") {
|
||||
writePublicError(
|
||||
@@ -317,6 +346,10 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
if detail.Execution != nil {
|
||||
execution = executionResponse(*detail.Execution)
|
||||
}
|
||||
var executionReport any
|
||||
if detail.Report != nil {
|
||||
executionReport = executionReportResponse(detail.Report)
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"id": detail.Task.ID,
|
||||
@@ -337,6 +370,7 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
"derived_requirement": nil,
|
||||
"claim": claim,
|
||||
"execution": execution,
|
||||
"execution_report": executionReport,
|
||||
"events": events,
|
||||
"assets": []gin.H{
|
||||
assetResponse(detail.Asset),
|
||||
@@ -344,6 +378,78 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func executionReportResponse(report *domain.ExecutionReport) gin.H {
|
||||
events := make([]gin.H, 0, len(report.Events))
|
||||
for _, event := range report.Events {
|
||||
events = append(events, gin.H{
|
||||
"id": event.ID,
|
||||
"step": event.Step,
|
||||
"type": event.Type,
|
||||
"message": event.Message,
|
||||
"occurred_at": formatTime(event.OccurredAt),
|
||||
"received_at": formatTime(event.ReceivedAt),
|
||||
"received_after_execution_expiry": event.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
evidence := make([]gin.H, 0, len(report.EvidenceAssets))
|
||||
for _, asset := range report.EvidenceAssets {
|
||||
evidence = append(evidence, gin.H{
|
||||
"id": asset.ID,
|
||||
"media_type": asset.MediaType,
|
||||
"size_bytes": asset.SizeBytes,
|
||||
"sha256": asset.SHA256,
|
||||
"created_at": formatTime(asset.CreatedAt),
|
||||
"received_after_execution_expiry": asset.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
response := gin.H{
|
||||
"events": events,
|
||||
"evidence": evidence,
|
||||
}
|
||||
if batch := report.CandidateBatch; batch != nil {
|
||||
response["candidate_batch"] = gin.H{
|
||||
"task_content_sha256": batch.TaskContentSHA256,
|
||||
"execution_mode": batch.ExecutionMode,
|
||||
"search_query": batch.SearchQuery,
|
||||
"provenance": decodedAuditJSON(batch.ProvenanceJSON),
|
||||
"candidates": decodedAuditJSON(&batch.CandidatesJSON),
|
||||
"recommendation": decodedAuditJSON(batch.RecommendationJSON),
|
||||
"received_at": formatTime(batch.ReceivedAt),
|
||||
"received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
if outcome := report.Outcome; outcome != nil {
|
||||
response["outcome"] = gin.H{
|
||||
"result_type": outcome.ResultType,
|
||||
"execution_mode": outcome.ExecutionMode,
|
||||
"task_content_sha256": outcome.TaskContentSHA256,
|
||||
"outcome": outcome.Outcome,
|
||||
"operator_reason": outcome.OperatorReason,
|
||||
"selected_candidate": decodedAuditJSON(outcome.SelectedCandidateJSON),
|
||||
"evidence_asset_ids": decodedAuditJSON(outcome.EvidenceAssetIDsJSON),
|
||||
"error_code": outcome.ErrorCode,
|
||||
"error_message": outcome.ErrorMessage,
|
||||
"error_step": outcome.ErrorStep,
|
||||
"retryable": outcome.Retryable,
|
||||
"order_submitted": outcome.OrderSubmitted,
|
||||
"received_at": formatTime(outcome.ReceivedAt),
|
||||
"received_after_execution_expiry": outcome.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func decodedAuditJSON(value *string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func (h *adminHandlers) cancelTask(ctx *gin.Context) {
|
||||
if !hasMediaType(ctx, "application/json") {
|
||||
writePublicError(
|
||||
|
||||
@@ -349,8 +349,12 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewTaskService() error = %v", err)
|
||||
}
|
||||
results, err := usecase.NewExecutionResultService(repositories, files, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
registrar, err := NewAdminRouteRegistrar(
|
||||
AdminServices{Assets: assets, Tasks: tasks},
|
||||
AdminServices{Assets: assets, Tasks: tasks, Results: results},
|
||||
emptyAdminWeb{},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -478,6 +479,141 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
|
||||
assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken)
|
||||
}
|
||||
|
||||
func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
taskID := fixture.createPendingTask(t)
|
||||
claimedResponse := fixture.claimNext(t, "claim-for-results", testOpaqueToken)
|
||||
requireDeviceStatus(t, claimedResponse, http.StatusOK)
|
||||
var claimed deviceLifecycleResponse
|
||||
decodeResponse(t, claimedResponse, &claimed)
|
||||
|
||||
startResponse := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(fmt.Sprintf(
|
||||
`{"claim_generation":%d,"expected_version":%d}`,
|
||||
claimed.Task.ClaimGeneration,
|
||||
claimed.Task.Version,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "start-for-results",
|
||||
})
|
||||
requireDeviceStatus(t, startResponse, http.StatusOK)
|
||||
var started deviceLifecycleResponse
|
||||
decodeResponse(t, startResponse, &started)
|
||||
|
||||
occurredAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
events := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/events",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"events":[{"event_id":"00000000-0000-4000-8000-000000000701","step":"SEARCH","type":"SEARCH_STARTED","message":"开始采集候选","occurred_at":%q}]}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
occurredAt,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-events-1",
|
||||
})
|
||||
requireDeviceStatus(t, events, http.StatusOK)
|
||||
|
||||
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/evidence",
|
||||
contentType: "image/jpeg",
|
||||
body: deviceReferenceImage(t, 701),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-evidence-1",
|
||||
executionID: started.Execution.ID,
|
||||
claimGeneration: started.Task.ClaimGeneration,
|
||||
})
|
||||
requireDeviceStatus(t, evidence, http.StatusCreated)
|
||||
var evidenceResponse struct {
|
||||
Evidence struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"evidence"`
|
||||
}
|
||||
decodeResponse(t, evidence, &evidenceResponse)
|
||||
if evidenceResponse.Evidence.ID == "" {
|
||||
t.Fatalf("evidence response = %s", evidence.Body.String())
|
||||
}
|
||||
|
||||
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task for content hash: %v", err)
|
||||
}
|
||||
taskHash := usecase.TaskContentSHA256(detail.Task)
|
||||
candidatePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null}]}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
evidenceResponse.Evidence.ID,
|
||||
)
|
||||
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/candidates",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(candidatePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-candidates-1",
|
||||
})
|
||||
requireDeviceStatus(t, candidates, http.StatusOK)
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null},"order_submitted":false}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
evidenceResponse.Evidence.ID,
|
||||
)
|
||||
complete := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/complete",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(completePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-complete-1",
|
||||
})
|
||||
requireDeviceStatus(t, complete, http.StatusOK)
|
||||
assertNoClaimSecret(t, complete, testOpaqueToken)
|
||||
replayed := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/complete",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(completePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-complete-1",
|
||||
})
|
||||
requireDeviceStatus(t, replayed, http.StatusOK)
|
||||
if !strings.Contains(replayed.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("terminal replay response = %s", replayed.Body.String())
|
||||
}
|
||||
|
||||
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task result detail: %v", err)
|
||||
}
|
||||
if detail.Task.Status != domain.TaskStatusSucceeded ||
|
||||
detail.Report == nil ||
|
||||
detail.Report.Outcome == nil ||
|
||||
detail.Report.Outcome.OrderSubmitted ||
|
||||
len(detail.Report.Events) != 1 ||
|
||||
len(detail.Report.EvidenceAssets) != 1 ||
|
||||
detail.Report.CandidateBatch == nil {
|
||||
t.Fatalf("execution report = %+v", detail.Report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
@@ -710,10 +846,15 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewLifecycleService() error = %v", err)
|
||||
}
|
||||
results, err := usecase.NewExecutionResultService(store, files, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -880,13 +1021,15 @@ func deviceReferenceImage(t *testing.T, index int) io.Reader {
|
||||
}
|
||||
|
||||
type deviceRequest struct {
|
||||
method string
|
||||
target string
|
||||
contentType string
|
||||
body io.Reader
|
||||
bearerToken string
|
||||
claimToken string
|
||||
idempotencyKey string
|
||||
method string
|
||||
target string
|
||||
contentType string
|
||||
body io.Reader
|
||||
bearerToken string
|
||||
claimToken string
|
||||
idempotencyKey string
|
||||
executionID string
|
||||
claimGeneration int64
|
||||
}
|
||||
|
||||
func performDeviceRequest(
|
||||
@@ -911,6 +1054,13 @@ func performDeviceRequest(
|
||||
if spec.idempotencyKey != "" {
|
||||
request.Header.Set("Idempotency-Key", spec.idempotencyKey)
|
||||
}
|
||||
if spec.executionID != "" {
|
||||
request.Header.Set("X-Execution-ID", spec.executionID)
|
||||
request.Header.Set(
|
||||
"X-Claim-Generation",
|
||||
strconv.FormatInt(spec.claimGeneration, 10),
|
||||
)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
|
||||
@@ -756,6 +756,7 @@ type taskDetailView struct {
|
||||
UpdatedAt time.Time
|
||||
CanCancel bool
|
||||
CancelRequiresAck bool
|
||||
ExecutionReport *ExecutionReport
|
||||
}
|
||||
|
||||
type taskDetailPage struct {
|
||||
@@ -789,6 +790,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
|
||||
CanCancel: canCancelTaskStatus(task.Status),
|
||||
CancelRequiresAck: task.Status == "RUNNING" ||
|
||||
task.Status == "WAITING_CONFIRMATION",
|
||||
ExecutionReport: task.ExecutionReport,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,62 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.execution-audit {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.audit-summary {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.audit-list {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.audit-list li {
|
||||
margin: 6px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.audit-evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.audit-evidence {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.audit-evidence img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 240px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.audit-evidence figcaption {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.audit-json {
|
||||
max-height: 360px;
|
||||
margin: 8px 0 16px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-soft);
|
||||
font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
|
||||
@@ -73,6 +73,39 @@
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{{with .Task.ExecutionReport}}
|
||||
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
|
||||
<h2 id="execution-audit-heading">执行审计</h2>
|
||||
{{if .Outcome}}
|
||||
<dl class="definition-list audit-summary">
|
||||
<dt>结果类型</dt><dd>{{.Outcome.ResultType}}</dd>
|
||||
<dt>人工理由</dt><dd>{{if .Outcome.OperatorReason}}{{.Outcome.OperatorReason}}{{else}}未提供{{end}}</dd>
|
||||
<dt>订单提交</dt><dd>{{if .Outcome.OrderSubmitted}}是{{else}}否{{end}}</dd>
|
||||
{{if .Outcome.ErrorCode}}<dt>失败信息</dt><dd>{{.Outcome.ErrorCode}}:{{.Outcome.ErrorMessage}}</dd>{{end}}
|
||||
</dl>
|
||||
{{end}}
|
||||
{{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}}
|
||||
{{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}}
|
||||
{{if .Candidates}}<h3>候选与评估</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
|
||||
{{if .Recommendation}}<h3>本地推荐</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
|
||||
{{if .Evidence}}
|
||||
<h3>证据截图</h3>
|
||||
<div class="audit-evidence-grid">
|
||||
{{range .Evidence}}<figure class="audit-evidence">
|
||||
<img src="{{.ContentURL}}" alt="执行证据截图">
|
||||
<figcaption>SHA-256 {{.SHA256}}({{.SizeBytes}} bytes){{if .ReceivedAfterExecutionExpiry}},授权到期后补报{{end}}</figcaption>
|
||||
</figure>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Events}}
|
||||
<h3>执行事件</h3>
|
||||
<ul class="audit-list">
|
||||
{{range .Events}}<li><time datetime="{{machineTime .OccurredAt}}">{{displayTime .OccurredAt}}</time> · {{.Step}} · {{.Message}}{{if .ReceivedAfterExecutionExpiry}}(授权到期后补报){{end}}</li>{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Task.CanCancel}}
|
||||
<noscript>
|
||||
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
|
||||
|
||||
@@ -58,6 +58,47 @@ type Task struct {
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ExecutionReport *ExecutionReport
|
||||
}
|
||||
|
||||
type ExecutionReport struct {
|
||||
Events []ExecutionReportEvent
|
||||
Evidence []ExecutionReportEvidence
|
||||
Mode string
|
||||
SearchQuery string
|
||||
Provenance string
|
||||
Candidates string
|
||||
Recommendation string
|
||||
Outcome *ExecutionReportOutcome
|
||||
}
|
||||
|
||||
type ExecutionReportEvent struct {
|
||||
Step string
|
||||
Type string
|
||||
Message string
|
||||
OccurredAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionReportEvidence struct {
|
||||
ID string
|
||||
ContentURL string
|
||||
SHA256 string
|
||||
SizeBytes int64
|
||||
CreatedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionReportOutcome struct {
|
||||
ResultType string
|
||||
Outcome string
|
||||
OperatorReason string
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ErrorStep string
|
||||
OrderSubmitted bool
|
||||
ReceivedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type UploadReferenceInput struct {
|
||||
|
||||
@@ -2,7 +2,9 @@ package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
@@ -176,9 +178,82 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
|
||||
func taskFromDetail(detail domain.TaskDetail) Task {
|
||||
task := taskFromPurchase(detail.Task)
|
||||
task.ReferenceAssetID = detail.Asset.ID
|
||||
task.ExecutionReport = executionReportFrom(detail.Report)
|
||||
return task
|
||||
}
|
||||
|
||||
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
result := &ExecutionReport{
|
||||
Events: make([]ExecutionReportEvent, 0, len(report.Events)),
|
||||
Evidence: make([]ExecutionReportEvidence, 0, len(report.EvidenceAssets)),
|
||||
}
|
||||
for _, event := range report.Events {
|
||||
result.Events = append(result.Events, ExecutionReportEvent{
|
||||
Step: event.Step,
|
||||
Type: event.Type,
|
||||
Message: event.Message,
|
||||
OccurredAt: event.OccurredAt,
|
||||
ReceivedAfterExecutionExpiry: event.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
for _, evidence := range report.EvidenceAssets {
|
||||
result.Evidence = append(result.Evidence, ExecutionReportEvidence{
|
||||
ID: evidence.ID,
|
||||
ContentURL: "/api/v1/tasks/" + evidence.TaskID + "/evidence/" + evidence.ID + "/content",
|
||||
SHA256: evidence.SHA256,
|
||||
SizeBytes: evidence.SizeBytes,
|
||||
CreatedAt: evidence.CreatedAt,
|
||||
ReceivedAfterExecutionExpiry: evidence.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
if batch := report.CandidateBatch; batch != nil {
|
||||
result.Mode = batch.ExecutionMode
|
||||
result.SearchQuery = batch.SearchQuery
|
||||
result.Provenance = prettyAuditJSON(batch.ProvenanceJSON)
|
||||
result.Candidates = prettyAuditJSON(&batch.CandidatesJSON)
|
||||
result.Recommendation = prettyAuditJSON(batch.RecommendationJSON)
|
||||
}
|
||||
if outcome := report.Outcome; outcome != nil {
|
||||
result.Outcome = &ExecutionReportOutcome{
|
||||
ResultType: outcome.ResultType,
|
||||
Outcome: stringValue(outcome.Outcome),
|
||||
OperatorReason: stringValue(outcome.OperatorReason),
|
||||
ErrorCode: stringValue(outcome.ErrorCode),
|
||||
ErrorMessage: stringValue(outcome.ErrorMessage),
|
||||
ErrorStep: stringValue(outcome.ErrorStep),
|
||||
OrderSubmitted: outcome.OrderSubmitted,
|
||||
ReceivedAt: outcome.ReceivedAt,
|
||||
ReceivedAfterExecutionExpiry: outcome.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func prettyAuditJSON(value *string) string {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
return ""
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
formatted, err := json.MarshalIndent(decoded, "", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(formatted)
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func mapUsecaseError(err error) error {
|
||||
var typed *usecase.Error
|
||||
if !errors.As(err, &typed) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type ExecutionResultAuthorization struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimTokenHash string
|
||||
}
|
||||
|
||||
type ExecutionResultWrite struct {
|
||||
ExecutionResultAuthorization
|
||||
Operation string
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type ExecutionResultRepository interface {
|
||||
AppendExecutionEvents(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
[]domain.ExecutionEvent,
|
||||
) (bool, error)
|
||||
CreateExecutionEvidence(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionEvidenceAsset,
|
||||
) (domain.ExecutionEvidenceAsset, bool, error)
|
||||
StoreExecutionCandidates(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionCandidateBatch,
|
||||
) (bool, error)
|
||||
CompleteExecution(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionOutcome,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
FailExecution(
|
||||
context.Context,
|
||||
ExecutionResultWrite,
|
||||
domain.ExecutionOutcome,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
GetExecutionEvidence(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.ExecutionEvidenceAsset, error)
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
executionResultEventsOperation = "EVENTS"
|
||||
executionResultEvidenceOperation = "EVIDENCE"
|
||||
executionResultCandidatesOperation = "CANDIDATES"
|
||||
executionResultCompleteOperation = "COMPLETE"
|
||||
executionResultFailOperation = "FAIL"
|
||||
|
||||
manualFirstMode = "MANUAL_FIRST"
|
||||
aiAssistedMode = "AI_ASSISTED"
|
||||
)
|
||||
|
||||
type ExecutionResultService struct {
|
||||
repository ExecutionResultRepository
|
||||
store ReferenceImageStore
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
type ExecutionResultIdentity struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ClientExecutionEvent struct {
|
||||
ID string `json:"event_id"`
|
||||
Step string `json:"step"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type AppendExecutionEventsCommand struct {
|
||||
Identity ExecutionResultIdentity
|
||||
Events []ClientExecutionEvent
|
||||
}
|
||||
|
||||
type UploadExecutionEvidenceCommand struct {
|
||||
Identity ExecutionResultIdentity
|
||||
DeclaredMediaType string
|
||||
Content io.Reader
|
||||
}
|
||||
|
||||
type ExecutionProvenance struct {
|
||||
ProviderID string `json:"provider_id"`
|
||||
Model string `json:"model"`
|
||||
PromptVersion string `json:"prompt_version"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
}
|
||||
|
||||
type CandidateEvaluation struct {
|
||||
Decision string `json:"decision"`
|
||||
Score float64 `json:"score"`
|
||||
Matched []string `json:"matched"`
|
||||
MissingOrUncertain []string `json:"missing_or_uncertain"`
|
||||
RejectionReasons []string `json:"rejection_reasons"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type ExecutionCandidate struct {
|
||||
Ordinal int `json:"ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
Price string `json:"price"`
|
||||
ProductURL string `json:"product_url"`
|
||||
ImageURL string `json:"image_url"`
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
Evaluation *CandidateEvaluation `json:"evaluation"`
|
||||
}
|
||||
|
||||
type CandidateRecommendation struct {
|
||||
CandidateOrdinal int `json:"candidate_ordinal"`
|
||||
PolicyVersion string `json:"policy_version"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
type StoreExecutionCandidatesCommand struct {
|
||||
Identity ExecutionResultIdentity
|
||||
TaskContentSHA256 string
|
||||
ExecutionMode string
|
||||
SearchQuery string
|
||||
Provenance *ExecutionProvenance
|
||||
Candidates []ExecutionCandidate
|
||||
Recommendation *CandidateRecommendation
|
||||
}
|
||||
|
||||
type CompleteExecutionCommand struct {
|
||||
Identity ExecutionResultIdentity
|
||||
TaskContentSHA256 string
|
||||
ExecutionMode string
|
||||
Outcome string
|
||||
OperatorReason string
|
||||
Candidate *ExecutionCandidate
|
||||
OrderSubmitted bool
|
||||
}
|
||||
|
||||
type FailExecutionCommand struct {
|
||||
Identity ExecutionResultIdentity
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ErrorStep string
|
||||
Retryable bool
|
||||
EvidenceAssetIDs []string
|
||||
}
|
||||
|
||||
type UploadExecutionEvidenceResult struct {
|
||||
Evidence domain.ExecutionEvidenceAsset
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type ExecutionEvidenceContent struct {
|
||||
Evidence domain.ExecutionEvidenceAsset
|
||||
Content io.ReadCloser
|
||||
}
|
||||
|
||||
func NewExecutionResultService(
|
||||
repository ExecutionResultRepository,
|
||||
store ReferenceImageStore,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*ExecutionResultService, error) {
|
||||
if repository == nil || store == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("execution result service dependencies are required")
|
||||
}
|
||||
return &ExecutionResultService{
|
||||
repository: repository,
|
||||
store: store,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) AppendEvents(
|
||||
ctx context.Context,
|
||||
command AppendExecutionEventsCommand,
|
||||
) (bool, error) {
|
||||
identity, err := normalizeExecutionIdentity(command.Identity)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(command.Events) < 1 || len(command.Events) > 32 {
|
||||
return false, executionResultInvalid("events", "must contain 1 to 32 events")
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
events := make([]domain.ExecutionEvent, 0, len(command.Events))
|
||||
seen := make(map[string]struct{}, len(command.Events))
|
||||
for _, event := range command.Events {
|
||||
parsed, eventErr := normalizeClientEvent(event, identity.TaskID, identity.ExecutionID, now)
|
||||
if eventErr != nil {
|
||||
return false, eventErr
|
||||
}
|
||||
if _, found := seen[parsed.ID]; found {
|
||||
return false, executionResultInvalid("events", "event_id must be unique")
|
||||
}
|
||||
seen[parsed.ID] = struct{}{}
|
||||
events = append(events, parsed)
|
||||
}
|
||||
requestHash, err := executionResultHash(command.Events)
|
||||
if err != nil {
|
||||
return false, internalExecutionResultFailure(err)
|
||||
}
|
||||
replayed, err := service.repository.AppendExecutionEvents(
|
||||
ctx,
|
||||
service.write(identity, executionResultEventsOperation, requestHash, now),
|
||||
events,
|
||||
)
|
||||
if err != nil {
|
||||
return false, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return replayed, nil
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) UploadEvidence(
|
||||
ctx context.Context,
|
||||
command UploadExecutionEvidenceCommand,
|
||||
) (UploadExecutionEvidenceResult, error) {
|
||||
identity, err := normalizeExecutionIdentity(command.Identity)
|
||||
if err != nil {
|
||||
return UploadExecutionEvidenceResult{}, err
|
||||
}
|
||||
if command.Content == nil {
|
||||
return UploadExecutionEvidenceResult{}, executionResultInvalid("file", "required")
|
||||
}
|
||||
evidenceID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
|
||||
}
|
||||
normalized, err := service.store.Put(
|
||||
ctx,
|
||||
evidenceID,
|
||||
command.DeclaredMediaType,
|
||||
command.Content,
|
||||
)
|
||||
if err != nil {
|
||||
return UploadExecutionEvidenceResult{}, mapImageStoreError(err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = service.store.Delete(context.Background(), normalized.StorageKey)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := executionResultHash(struct {
|
||||
InputSHA256 string `json:"input_sha256"`
|
||||
}{InputSHA256: normalized.InputSHA256})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
|
||||
}
|
||||
evidence, replayed, err := service.repository.CreateExecutionEvidence(
|
||||
ctx,
|
||||
service.write(identity, executionResultEvidenceOperation, requestHash, now),
|
||||
domain.ExecutionEvidenceAsset{
|
||||
ID: evidenceID,
|
||||
TaskID: identity.TaskID,
|
||||
ExecutionID: identity.ExecutionID,
|
||||
MediaType: normalized.MediaType,
|
||||
SizeBytes: normalized.SizeBytes,
|
||||
SHA256: normalized.SHA256,
|
||||
StorageKey: normalized.StorageKey,
|
||||
CreatedAt: now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return UploadExecutionEvidenceResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
if replayed {
|
||||
cleanup()
|
||||
}
|
||||
return UploadExecutionEvidenceResult{Evidence: evidence, Replayed: replayed}, nil
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) StoreCandidates(
|
||||
ctx context.Context,
|
||||
command StoreExecutionCandidatesCommand,
|
||||
) (bool, error) {
|
||||
identity, err := normalizeExecutionIdentity(command.Identity)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := validateCandidateCommand(command); err != nil {
|
||||
return false, err
|
||||
}
|
||||
provenanceJSON, candidatesJSON, recommendationJSON, err := candidateJSON(command)
|
||||
if err != nil {
|
||||
return false, internalExecutionResultFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := executionResultHash(command)
|
||||
if err != nil {
|
||||
return false, internalExecutionResultFailure(err)
|
||||
}
|
||||
replayed, err := service.repository.StoreExecutionCandidates(
|
||||
ctx,
|
||||
service.write(identity, executionResultCandidatesOperation, requestHash, now),
|
||||
domain.ExecutionCandidateBatch{
|
||||
TaskID: identity.TaskID,
|
||||
ExecutionID: identity.ExecutionID,
|
||||
TaskContentSHA256: command.TaskContentSHA256,
|
||||
ExecutionMode: command.ExecutionMode,
|
||||
SearchQuery: strings.TrimSpace(command.SearchQuery),
|
||||
ProvenanceJSON: provenanceJSON,
|
||||
CandidatesJSON: candidatesJSON,
|
||||
RecommendationJSON: recommendationJSON,
|
||||
ReceivedAt: now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return false, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return replayed, nil
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) Complete(
|
||||
ctx context.Context,
|
||||
command CompleteExecutionCommand,
|
||||
) (domain.PurchaseTask, bool, error) {
|
||||
identity, err := normalizeExecutionIdentity(command.Identity)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
if err := validateCompleteCommand(command); err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
selectedJSON, err := optionalJSON(command.Candidate)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := executionResultHash(command)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
||||
}
|
||||
mode := command.ExecutionMode
|
||||
taskHash := command.TaskContentSHA256
|
||||
outcome := command.Outcome
|
||||
reason := strings.TrimSpace(command.OperatorReason)
|
||||
task, replayed, err := service.repository.CompleteExecution(
|
||||
ctx,
|
||||
service.write(identity, executionResultCompleteOperation, requestHash, now),
|
||||
domain.ExecutionOutcome{
|
||||
TaskID: identity.TaskID,
|
||||
ExecutionID: identity.ExecutionID,
|
||||
ResultType: "COMPLETE",
|
||||
ExecutionMode: &mode,
|
||||
TaskContentSHA256: &taskHash,
|
||||
Outcome: &outcome,
|
||||
OperatorReason: &reason,
|
||||
SelectedCandidateJSON: selectedJSON,
|
||||
OrderSubmitted: false,
|
||||
ReceivedAt: now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return task, replayed, nil
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) Fail(
|
||||
ctx context.Context,
|
||||
command FailExecutionCommand,
|
||||
) (domain.PurchaseTask, bool, error) {
|
||||
identity, err := normalizeExecutionIdentity(command.Identity)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
if err := validateFailCommand(command); err != nil {
|
||||
return domain.PurchaseTask{}, false, err
|
||||
}
|
||||
evidenceJSON, err := optionalJSON(command.EvidenceAssetIDs)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := executionResultHash(command)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
|
||||
}
|
||||
code := strings.TrimSpace(command.ErrorCode)
|
||||
message := strings.TrimSpace(command.ErrorMessage)
|
||||
step := strings.TrimSpace(command.ErrorStep)
|
||||
retryable := command.Retryable
|
||||
task, replayed, err := service.repository.FailExecution(
|
||||
ctx,
|
||||
service.write(identity, executionResultFailOperation, requestHash, now),
|
||||
domain.ExecutionOutcome{
|
||||
TaskID: identity.TaskID,
|
||||
ExecutionID: identity.ExecutionID,
|
||||
ResultType: "FAIL",
|
||||
EvidenceAssetIDsJSON: evidenceJSON,
|
||||
ErrorCode: &code,
|
||||
ErrorMessage: &message,
|
||||
ErrorStep: &step,
|
||||
Retryable: &retryable,
|
||||
OrderSubmitted: false,
|
||||
ReceivedAt: now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return task, replayed, nil
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) OpenEvidence(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
evidenceID string,
|
||||
) (ExecutionEvidenceContent, error) {
|
||||
evidence, err := service.repository.GetExecutionEvidence(ctx, taskID, evidenceID)
|
||||
if err != nil {
|
||||
return ExecutionEvidenceContent{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
content, err := service.store.Open(ctx, evidence.StorageKey)
|
||||
if err != nil {
|
||||
return ExecutionEvidenceContent{}, mapImageStoreError(err)
|
||||
}
|
||||
return ExecutionEvidenceContent{Evidence: evidence, Content: content}, nil
|
||||
}
|
||||
|
||||
func (service *ExecutionResultService) write(
|
||||
identity ExecutionResultIdentity,
|
||||
operation string,
|
||||
requestHash string,
|
||||
now time.Time,
|
||||
) ExecutionResultWrite {
|
||||
return ExecutionResultWrite{
|
||||
ExecutionResultAuthorization: ExecutionResultAuthorization{
|
||||
UserID: identity.UserID,
|
||||
DeviceID: identity.DeviceID,
|
||||
TaskID: identity.TaskID,
|
||||
ExecutionID: identity.ExecutionID,
|
||||
ClaimGeneration: identity.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(identity.ClaimToken),
|
||||
},
|
||||
Operation: operation,
|
||||
IdempotencyKey: identity.IdempotencyKey,
|
||||
RequestHash: requestHash,
|
||||
Now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExecutionIdentity(
|
||||
identity ExecutionResultIdentity,
|
||||
) (ExecutionResultIdentity, error) {
|
||||
identity.UserID = strings.TrimSpace(identity.UserID)
|
||||
identity.DeviceID = strings.TrimSpace(identity.DeviceID)
|
||||
identity.TaskID = strings.TrimSpace(identity.TaskID)
|
||||
identity.ExecutionID = strings.TrimSpace(identity.ExecutionID)
|
||||
identity.IdempotencyKey = strings.TrimSpace(identity.IdempotencyKey)
|
||||
fields := map[string]string{}
|
||||
if !isUUID(identity.UserID) {
|
||||
fields["user_id"] = "must be a UUID"
|
||||
}
|
||||
if !isUUID(identity.DeviceID) {
|
||||
fields["device_id"] = "must be a UUID"
|
||||
}
|
||||
if !isUUID(identity.TaskID) {
|
||||
fields["task_id"] = "must be a UUID"
|
||||
}
|
||||
if !isUUID(identity.ExecutionID) {
|
||||
fields["execution_id"] = "must be a UUID"
|
||||
}
|
||||
if identity.ClaimGeneration < 1 {
|
||||
fields["claim_generation"] = "must be a positive integer"
|
||||
}
|
||||
if strings.TrimSpace(identity.ClaimToken) == "" {
|
||||
fields["claim_token"] = "required"
|
||||
}
|
||||
if len([]byte(identity.IdempotencyKey)) == 0 ||
|
||||
len([]byte(identity.IdempotencyKey)) > maxIdempotencyKeyBytes ||
|
||||
!isPrintableASCII(identity.IdempotencyKey) {
|
||||
fields["idempotency_key"] = "must be printable ASCII up to 128 bytes"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return ExecutionResultIdentity{}, invalidError(
|
||||
"EXECUTION_RESULT_INVALID",
|
||||
"execution result request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func normalizeClientEvent(
|
||||
event ClientExecutionEvent,
|
||||
taskID string,
|
||||
executionID string,
|
||||
now time.Time,
|
||||
) (domain.ExecutionEvent, error) {
|
||||
if !isUUID(strings.TrimSpace(event.ID)) {
|
||||
return domain.ExecutionEvent{}, executionResultInvalid("event_id", "must be a UUID")
|
||||
}
|
||||
step := strings.TrimSpace(event.Step)
|
||||
typeValue := strings.TrimSpace(event.Type)
|
||||
message := strings.TrimSpace(event.Message)
|
||||
if !validLifecycleStep(step) {
|
||||
return domain.ExecutionEvent{}, executionResultInvalid("step", "must be uppercase ASCII")
|
||||
}
|
||||
if !validLifecycleStep(typeValue) {
|
||||
return domain.ExecutionEvent{}, executionResultInvalid("type", "must be uppercase ASCII")
|
||||
}
|
||||
if !validAuditText(message, 1000) {
|
||||
return domain.ExecutionEvent{}, executionResultInvalid("message", "is invalid or contains sensitive material")
|
||||
}
|
||||
occurredAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(event.OccurredAt))
|
||||
if err != nil || occurredAt.After(now.Add(5*time.Minute)) ||
|
||||
occurredAt.Before(now.Add(-7*24*time.Hour)) {
|
||||
return domain.ExecutionEvent{}, executionResultInvalid("occurred_at", "must be a recent RFC3339 timestamp")
|
||||
}
|
||||
return domain.ExecutionEvent{
|
||||
ID: strings.TrimSpace(event.ID),
|
||||
TaskID: taskID,
|
||||
ExecutionID: executionID,
|
||||
Step: step,
|
||||
Type: typeValue,
|
||||
Message: message,
|
||||
OccurredAt: occurredAt.UTC(),
|
||||
ReceivedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
|
||||
if !sha256Pattern.MatchString(command.TaskContentSHA256) {
|
||||
return executionResultInvalid("task_content_sha256", "must be lowercase SHA-256")
|
||||
}
|
||||
if !validExecutionMode(command.ExecutionMode) {
|
||||
return executionResultInvalid("execution_mode", "must be MANUAL_FIRST or AI_ASSISTED")
|
||||
}
|
||||
if !validAuditText(command.SearchQuery, 512) {
|
||||
return executionResultInvalid("search_query", "is invalid")
|
||||
}
|
||||
if len(command.Candidates) > 5 {
|
||||
return executionResultInvalid("candidates", "must contain at most 5 candidates")
|
||||
}
|
||||
if command.ExecutionMode == aiAssistedMode {
|
||||
if !validProvenance(command.Provenance) {
|
||||
return executionResultInvalid("provenance", "is required for AI_ASSISTED")
|
||||
}
|
||||
} else if command.Provenance != nil {
|
||||
return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST")
|
||||
}
|
||||
for index, candidate := range command.Candidates {
|
||||
if candidate.Ordinal != index+1 || !validCandidate(candidate, command.ExecutionMode) {
|
||||
return executionResultInvalid("candidates", "must be continuous, bounded observations")
|
||||
}
|
||||
}
|
||||
if command.Recommendation != nil {
|
||||
recommendation := command.Recommendation
|
||||
if recommendation.CandidateOrdinal < 1 ||
|
||||
recommendation.CandidateOrdinal > len(command.Candidates) ||
|
||||
!validAuditText(recommendation.PolicyVersion, 128) ||
|
||||
!validStringList(recommendation.Reasons, 8, 160) {
|
||||
return executionResultInvalid("recommendation", "is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCompleteCommand(command CompleteExecutionCommand) error {
|
||||
if !sha256Pattern.MatchString(command.TaskContentSHA256) ||
|
||||
!validExecutionMode(command.ExecutionMode) ||
|
||||
!validOutcome(command.Outcome) ||
|
||||
!validAuditText(command.OperatorReason, 1000) ||
|
||||
command.OrderSubmitted {
|
||||
return executionResultInvalid("complete", "contains an invalid outcome or order state")
|
||||
}
|
||||
if command.Outcome == "CANDIDATE_ACCEPTED" {
|
||||
if command.Candidate == nil || !validCandidate(*command.Candidate, command.ExecutionMode) {
|
||||
return executionResultInvalid("candidate", "is required for CANDIDATE_ACCEPTED")
|
||||
}
|
||||
} else if command.Candidate != nil {
|
||||
return executionResultInvalid("candidate", "must be omitted for this outcome")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFailCommand(command FailExecutionCommand) error {
|
||||
if !validLifecycleStep(strings.TrimSpace(command.ErrorCode)) ||
|
||||
!validLifecycleStep(strings.TrimSpace(command.ErrorStep)) ||
|
||||
!validAuditText(command.ErrorMessage, 1000) {
|
||||
return executionResultInvalid("error", "is invalid")
|
||||
}
|
||||
if len(command.EvidenceAssetIDs) > 5 {
|
||||
return executionResultInvalid("evidence_asset_ids", "must contain at most 5 items")
|
||||
}
|
||||
for _, id := range command.EvidenceAssetIDs {
|
||||
if !isUUID(strings.TrimSpace(id)) {
|
||||
return executionResultInvalid("evidence_asset_ids", "must contain UUIDs")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCandidate(candidate ExecutionCandidate, mode string) bool {
|
||||
if candidate.Ordinal < 1 ||
|
||||
!validAuditText(candidate.Title, 512) ||
|
||||
!validOptionalAuditText(candidate.SKUText, 512) ||
|
||||
!validOptionalAuditText(candidate.Price, 64) ||
|
||||
!validObservationURL(candidate.ProductURL) ||
|
||||
!validObservationURL(candidate.ImageURL) ||
|
||||
len(candidate.EvidenceAssetIDs) > 5 {
|
||||
return false
|
||||
}
|
||||
for _, id := range candidate.EvidenceAssetIDs {
|
||||
if !isUUID(strings.TrimSpace(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if mode == manualFirstMode {
|
||||
return candidate.Evaluation == nil
|
||||
}
|
||||
return validEvaluation(candidate.Evaluation)
|
||||
}
|
||||
|
||||
func validEvaluation(value *CandidateEvaluation) bool {
|
||||
if value == nil || value.Score < 0 || value.Score > 1 ||
|
||||
value.Confidence < 0 || value.Confidence > 1 {
|
||||
return false
|
||||
}
|
||||
if value.Decision != "REVIEW" && value.Decision != "REJECT" &&
|
||||
value.Decision != "MANUAL_REQUIRED" {
|
||||
return false
|
||||
}
|
||||
return validStringList(value.Matched, 12, 160) &&
|
||||
validStringList(value.MissingOrUncertain, 12, 160) &&
|
||||
validStringList(value.RejectionReasons, 12, 160)
|
||||
}
|
||||
|
||||
func validProvenance(value *ExecutionProvenance) bool {
|
||||
return value != nil &&
|
||||
validAuditText(value.ProviderID, 64) &&
|
||||
validAuditText(value.Model, 256) &&
|
||||
validAuditText(value.PromptVersion, 128) &&
|
||||
value.SchemaVersion >= 1 && value.SchemaVersion <= 32
|
||||
}
|
||||
|
||||
func validExecutionMode(value string) bool {
|
||||
return value == manualFirstMode || value == aiAssistedMode
|
||||
}
|
||||
|
||||
func validOutcome(value string) bool {
|
||||
switch value {
|
||||
case "CANDIDATE_ACCEPTED", "CANDIDATE_REJECTED", "NO_MATCH", "MANUAL_REQUIRED":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validObservationURL(value string) bool {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
return err == nil &&
|
||||
(parsed.Scheme == "https" || parsed.Scheme == "http") &&
|
||||
parsed.Host != "" &&
|
||||
parsed.User == nil &&
|
||||
len(value) <= 2048
|
||||
}
|
||||
|
||||
func validAuditText(value string, maximum int) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || !utf8.ValidString(value) || len([]byte(value)) > maximum {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
return !strings.Contains(lower, "authorization:") &&
|
||||
!strings.Contains(lower, "api_key") &&
|
||||
!strings.Contains(lower, "bearer ")
|
||||
}
|
||||
|
||||
func validOptionalAuditText(value string, maximum int) bool {
|
||||
return strings.TrimSpace(value) == "" || validAuditText(value, maximum)
|
||||
}
|
||||
|
||||
func validStringList(values []string, maximumItems int, maximumText int) bool {
|
||||
if len(values) > maximumItems {
|
||||
return false
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
if !validAuditText(value, maximumText) {
|
||||
return false
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func candidateJSON(command StoreExecutionCandidatesCommand) (*string, string, *string, error) {
|
||||
var provenance *string
|
||||
if command.Provenance != nil {
|
||||
encoded, err := json.Marshal(command.Provenance)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
value := string(encoded)
|
||||
provenance = &value
|
||||
}
|
||||
candidates, err := json.Marshal(command.Candidates)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
var recommendation *string
|
||||
if command.Recommendation != nil {
|
||||
encoded, err := json.Marshal(command.Recommendation)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
value := string(encoded)
|
||||
recommendation = &value
|
||||
}
|
||||
return provenance, string(candidates), recommendation, nil
|
||||
}
|
||||
|
||||
func optionalJSON(value any) (*string, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := string(encoded)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func executionResultHash(value any) (string, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func TaskContentSHA256(task domain.PurchaseTask) string {
|
||||
budget := ""
|
||||
if task.MaxBudgetCents != nil {
|
||||
budget = strconv.FormatInt(*task.MaxBudgetCents, 10)
|
||||
}
|
||||
payload := strings.Join([]string{
|
||||
task.Title,
|
||||
task.Description,
|
||||
task.SKU,
|
||||
task.ImageAssetID,
|
||||
strconv.Itoa(task.Quantity),
|
||||
budget,
|
||||
task.Currency,
|
||||
}, "\x00")
|
||||
sum := sha256.Sum256([]byte(payload))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func executionResultInvalid(field string, message string) error {
|
||||
return invalidError(
|
||||
"EXECUTION_RESULT_INVALID",
|
||||
"execution result request is invalid",
|
||||
map[string]string{field: message},
|
||||
)
|
||||
}
|
||||
|
||||
func internalExecutionResultFailure(err error) error {
|
||||
return newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var sha256Pattern = regexp.MustCompile("^[0-9a-f]{64}$")
|
||||
@@ -0,0 +1,204 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE execution_events (
|
||||
id TEXT PRIMARY KEY NOT NULL
|
||||
CHECK (length(id) = 36),
|
||||
task_id TEXT NOT NULL
|
||||
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
execution_id TEXT NOT NULL
|
||||
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
step TEXT NOT NULL
|
||||
CHECK (
|
||||
length(trim(step)) > 0
|
||||
AND length(CAST(step AS BLOB)) <= 64
|
||||
),
|
||||
event_type TEXT NOT NULL
|
||||
CHECK (
|
||||
length(trim(event_type)) > 0
|
||||
AND length(CAST(event_type AS BLOB)) <= 64
|
||||
),
|
||||
message TEXT NOT NULL
|
||||
CHECK (length(CAST(message AS BLOB)) <= 1000),
|
||||
occurred_at TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (received_after_execution_expiry IN (0, 1))
|
||||
);
|
||||
|
||||
CREATE INDEX execution_events_execution_occurred_idx
|
||||
ON execution_events (execution_id, occurred_at ASC, id ASC);
|
||||
|
||||
CREATE TABLE execution_evidence_assets (
|
||||
id TEXT PRIMARY KEY NOT NULL
|
||||
CHECK (length(id) = 36),
|
||||
task_id TEXT NOT NULL
|
||||
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
execution_id TEXT NOT NULL
|
||||
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
media_type TEXT NOT NULL
|
||||
CHECK (media_type = 'image/jpeg'),
|
||||
size_bytes INTEGER NOT NULL
|
||||
CHECK (size_bytes > 0),
|
||||
sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(sha256) = 64
|
||||
AND sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
storage_key TEXT NOT NULL UNIQUE
|
||||
CHECK (
|
||||
length(storage_key) > 0
|
||||
AND substr(storage_key, 1, 1) <> '/'
|
||||
AND instr(storage_key, '\') = 0
|
||||
AND instr(storage_key, '..') = 0
|
||||
),
|
||||
created_at TEXT NOT NULL,
|
||||
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (received_after_execution_expiry IN (0, 1))
|
||||
);
|
||||
|
||||
CREATE INDEX execution_evidence_execution_created_idx
|
||||
ON execution_evidence_assets (execution_id, created_at ASC, id ASC);
|
||||
|
||||
CREATE TABLE execution_candidate_batches (
|
||||
execution_id TEXT PRIMARY KEY NOT NULL
|
||||
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
task_id TEXT NOT NULL
|
||||
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
task_content_sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(task_content_sha256) = 64
|
||||
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
execution_mode TEXT NOT NULL
|
||||
CHECK (execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
|
||||
search_query TEXT NOT NULL
|
||||
CHECK (
|
||||
length(trim(search_query)) > 0
|
||||
AND length(CAST(search_query AS BLOB)) <= 512
|
||||
),
|
||||
provenance_json TEXT,
|
||||
candidates_json TEXT NOT NULL
|
||||
CHECK (length(CAST(candidates_json AS BLOB)) <= 65536),
|
||||
recommendation_json TEXT,
|
||||
received_at TEXT NOT NULL,
|
||||
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (received_after_execution_expiry IN (0, 1))
|
||||
);
|
||||
|
||||
CREATE TABLE execution_outcomes (
|
||||
execution_id TEXT PRIMARY KEY NOT NULL
|
||||
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
task_id TEXT NOT NULL
|
||||
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
result_type TEXT NOT NULL
|
||||
CHECK (result_type IN ('COMPLETE', 'FAIL')),
|
||||
execution_mode TEXT
|
||||
CHECK (execution_mode IS NULL OR execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
|
||||
task_content_sha256 TEXT
|
||||
CHECK (
|
||||
task_content_sha256 IS NULL
|
||||
OR (
|
||||
length(task_content_sha256) = 64
|
||||
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
)
|
||||
),
|
||||
outcome TEXT
|
||||
CHECK (
|
||||
outcome IS NULL
|
||||
OR outcome IN (
|
||||
'CANDIDATE_ACCEPTED',
|
||||
'CANDIDATE_REJECTED',
|
||||
'NO_MATCH',
|
||||
'MANUAL_REQUIRED'
|
||||
)
|
||||
),
|
||||
operator_reason TEXT
|
||||
CHECK (
|
||||
operator_reason IS NULL
|
||||
OR length(CAST(operator_reason AS BLOB)) <= 1000
|
||||
),
|
||||
selected_candidate_json TEXT,
|
||||
evidence_asset_ids_json TEXT,
|
||||
error_code TEXT
|
||||
CHECK (error_code IS NULL OR length(CAST(error_code AS BLOB)) <= 64),
|
||||
error_message TEXT
|
||||
CHECK (error_message IS NULL OR length(CAST(error_message AS BLOB)) <= 1000),
|
||||
error_step TEXT
|
||||
CHECK (error_step IS NULL OR length(CAST(error_step AS BLOB)) <= 64),
|
||||
retryable INTEGER
|
||||
CHECK (retryable IS NULL OR retryable IN (0, 1)),
|
||||
order_submitted INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (order_submitted = 0),
|
||||
received_at TEXT NOT NULL,
|
||||
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (received_after_execution_expiry IN (0, 1)),
|
||||
CHECK (
|
||||
(result_type = 'COMPLETE'
|
||||
AND execution_mode IS NOT NULL
|
||||
AND task_content_sha256 IS NOT NULL
|
||||
AND outcome IS NOT NULL
|
||||
AND operator_reason IS NOT NULL
|
||||
AND error_code IS NULL)
|
||||
OR
|
||||
(result_type = 'FAIL'
|
||||
AND error_code IS NOT NULL
|
||||
AND error_message IS NOT NULL
|
||||
AND error_step IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE execution_result_requests (
|
||||
user_id TEXT NOT NULL
|
||||
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
device_id TEXT NOT NULL
|
||||
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN ('EVENTS', 'EVIDENCE', 'CANDIDATES', 'COMPLETE', 'FAIL')),
|
||||
idempotency_key TEXT NOT NULL
|
||||
CHECK (
|
||||
length(trim(idempotency_key)) > 0
|
||||
AND length(CAST(idempotency_key AS BLOB)) <= 128
|
||||
),
|
||||
request_sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(request_sha256) = 64
|
||||
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
claim_token_sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(claim_token_sha256) = 64
|
||||
AND claim_token_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
task_id TEXT NOT NULL
|
||||
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
execution_id TEXT NOT NULL
|
||||
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
resource_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_id, operation, idempotency_key)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
CREATE TEMP TABLE execution_results_v5_down_guard (
|
||||
allowed INTEGER NOT NULL
|
||||
CHECK (allowed = 1)
|
||||
);
|
||||
|
||||
INSERT INTO execution_results_v5_down_guard (allowed)
|
||||
SELECT CASE
|
||||
WHEN EXISTS (SELECT 1 FROM execution_events)
|
||||
OR EXISTS (SELECT 1 FROM execution_evidence_assets)
|
||||
OR EXISTS (SELECT 1 FROM execution_candidate_batches)
|
||||
OR EXISTS (SELECT 1 FROM execution_outcomes)
|
||||
OR EXISTS (SELECT 1 FROM execution_result_requests)
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END;
|
||||
|
||||
DROP TABLE execution_results_v5_down_guard;
|
||||
DROP TABLE execution_result_requests;
|
||||
DROP TABLE execution_outcomes;
|
||||
DROP TABLE execution_candidate_batches;
|
||||
DROP INDEX execution_evidence_execution_created_idx;
|
||||
DROP TABLE execution_evidence_assets;
|
||||
DROP INDEX execution_events_execution_occurred_idx;
|
||||
DROP TABLE execution_events;
|
||||
Reference in New Issue
Block a user