feat(*): 模型生成链路 - 完善 Agnes 结果兼容、价格展示与请求日志追踪
This commit is contained in:
@@ -752,7 +752,10 @@ func parseAgnesPoll(c PollContext, payload map[string]any) (PollResult, error) {
|
||||
metadata := object(payload["metadata"])
|
||||
videoURL := firstString(metadata, "url")
|
||||
if videoURL == "" {
|
||||
return PollResult{}, fmt.Errorf("Agnes 完成响应没有 metadata.url")
|
||||
videoURL = firstString(payload, "url")
|
||||
}
|
||||
if videoURL == "" {
|
||||
return PollResult{}, fmt.Errorf("Agnes 完成响应没有返回视频 URL")
|
||||
}
|
||||
return PollResult{TaskID: id, Status: status, Result: &Result{Videos: []MediaReference{{URL: videoURL, Kind: string(CapabilityVideo)}}}}, nil
|
||||
}
|
||||
|
||||
@@ -242,6 +242,10 @@ func TestAgnesMapsOfficialCreateAndPollContracts(t *testing.T) {
|
||||
if err != nil || completed.Status != StatusSucceeded || completed.Result == nil || completed.Result.Videos[0].URL != "https://cdn.example/result.mp4" {
|
||||
t.Fatalf("completed = %#v, err = %v", completed, err)
|
||||
}
|
||||
completedV20, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "video-2"}, []byte(`{"id":"video-2","status":"completed","url":"https://cdn.example/v20-result.mp4"}`))
|
||||
if err != nil || completedV20.Status != StatusSucceeded || completedV20.Result == nil || completedV20.Result.Videos[0].URL != "https://cdn.example/v20-result.mp4" {
|
||||
t.Fatalf("completed v2.0 = %#v, err = %v", completedV20, err)
|
||||
}
|
||||
failed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "video-1"}, []byte(`{"video_id":"video-1","status":"failed","error":{"message":"quota exceeded"}}`))
|
||||
if err != nil || failed.Status != StatusFailed || failed.Message != "quota exceeded" {
|
||||
t.Fatalf("failed = %#v, err = %v", failed, err)
|
||||
|
||||
@@ -32,7 +32,7 @@ Agnes AI 官方视频接口使用 Bearer API Key 和异步任务模型。影策
|
||||
|
||||
## 响应、状态与错误
|
||||
|
||||
创建成功响应中的 `video_id` 是推荐轮询标识。状态映射为:`queued` -> 等待,`in_progress` -> 处理中,`completed` -> 成功,`failed` -> 失败。成功结果只读取官方 `metadata.url`;如果响应宣称完成却没有该字段,影策会返回真实协议错误,不生成假 URL。失败信息优先读取 `error.message`,其次读取顶层 `message` 或 `detail`。
|
||||
创建成功响应中的 `video_id` 是推荐轮询标识。状态映射为:`queued` -> 等待,`in_progress` -> 处理中,`completed` -> 成功,`failed` -> 失败。成功结果优先读取 2.5 官方结构中的 `metadata.url`,并兼容 V2.0 返回的顶层 `url`;如果响应宣称完成却没有视频 URL,影策会返回真实协议错误,不生成假 URL。失败信息优先读取 `error.message`,其次读取顶层 `message` 或 `detail`。
|
||||
|
||||
参考媒体必须是 Agnes 服务可公开访问且在任务完成前持续有效的 URL。适配器声明了公共媒体 URL 要求,影策运行时会按现有资源流程准备上游可访问地址;本机、私网、Cookie 保护或很快过期的链接不能作为 Agnes 输入。
|
||||
|
||||
|
||||
@@ -144,8 +144,8 @@ func (r *Repository) filteredAPICallLogQuery(filter APICallLogFilter) *gorm.DB {
|
||||
Joins("LEFT JOIN users ON users.id = api_call_logs.user_id").
|
||||
Joins("LEFT JOIN model_channels ON model_channels.id = api_call_logs.channel_id").
|
||||
Where(
|
||||
"lower(api_call_logs.user_id) LIKE ? OR lower(users.username) LIKE ? OR lower(users.display_name) LIKE ? OR lower(api_call_logs.channel_id) LIKE ? OR lower(model_channels.name) LIKE ? OR lower(api_call_logs.model) LIKE ? OR lower(api_call_logs.path) LIKE ? OR lower(api_call_logs.provider_request_id) LIKE ?",
|
||||
pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern,
|
||||
"lower(api_call_logs.user_id) LIKE ? OR lower(users.username) LIKE ? OR lower(users.display_name) LIKE ? OR lower(api_call_logs.channel_id) LIKE ? OR lower(model_channels.name) LIKE ? OR lower(api_call_logs.model) LIKE ? OR lower(api_call_logs.path) LIKE ? OR lower(api_call_logs.provider_request_id) LIKE ? OR lower(api_call_logs.error_code) LIKE ? OR lower(api_call_logs.error) LIKE ?",
|
||||
pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern,
|
||||
)
|
||||
}
|
||||
if filter.Status != "" {
|
||||
@@ -172,6 +172,15 @@ func (r *Repository) LatestProviderRequestIDForTask(taskID string) (string, erro
|
||||
return strings.TrimSpace(log.ProviderRequestID), err
|
||||
}
|
||||
|
||||
func (r *Repository) HasAPICallLogForTask(taskID string) (bool, error) {
|
||||
if strings.TrimSpace(taskID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.ApiCallLog{}).Where("task_id = ?", taskID).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func visibleAPICallLogQuery(query *gorm.DB) *gorm.DB {
|
||||
// 视频轮询属于一次生成调用的内部阶段,管理端只展示聚合后的创建主记录。
|
||||
return query.Where("NOT (api_call_logs.capability = ? AND api_call_logs.request_kind IN ?)", "video", []string{"poll", "download"})
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -71,3 +74,41 @@ func TestRecordUserActivityQualifiesPostgresConflictColumns(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryAPICallLogsSearchesFailureFields(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:api-log-error-search?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.ModelChannel{}, &model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
item := model.ApiCallLog{
|
||||
ID: "api-log-1",
|
||||
UserID: "user-1",
|
||||
RequestKind: "create",
|
||||
Status: model.ApiCallStatusFailed,
|
||||
ErrorCode: "request_not_sent",
|
||||
Error: "模型服务拒绝了请求,请检查模型和参数;上游:invalid parameter: size",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := New(db)
|
||||
for _, keyword := range []string{"模型服务拒绝", "invalid parameter", "request_not_sent"} {
|
||||
logs, total, err := repo.QueryAPICallLogs(APICallLogFilter{
|
||||
AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)},
|
||||
Keyword: keyword,
|
||||
Page: 1,
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryAPICallLogs(%q) error = %v", keyword, err)
|
||||
}
|
||||
if total != 1 || len(logs) != 1 || logs[0].ID != item.ID {
|
||||
t.Fatalf("QueryAPICallLogs(%q) = total:%d logs:%#v", keyword, total, logs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,12 +942,29 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) {
|
||||
log.ProviderRequestID = providerRequestIDFromPath(log.Path)
|
||||
}
|
||||
payloads := providerResponsePayloads(responseBody)
|
||||
if len(payloads) == 0 {
|
||||
return
|
||||
}
|
||||
for _, payload := range payloads {
|
||||
s.enrichAPICallLogPayload(log, payload)
|
||||
}
|
||||
s.enrichAPICallLogFailureSummary(log, responseBody)
|
||||
}
|
||||
|
||||
func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, responseBody []byte) {
|
||||
if log.Status != model.ApiCallStatusFailed || log.StatusCode < 400 {
|
||||
return
|
||||
}
|
||||
userMessage := providerUserFacingErrorMessage(providerHTTPError{
|
||||
StatusCode: log.StatusCode,
|
||||
Body: string(responseBody),
|
||||
})
|
||||
detail := strings.TrimSpace(log.Error)
|
||||
if detail == "" || detail == userMessage {
|
||||
log.Error = userMessage
|
||||
return
|
||||
}
|
||||
if strings.Contains(detail, userMessage) {
|
||||
return
|
||||
}
|
||||
log.Error = truncateRunes(userMessage+";上游:"+detail, 2_000)
|
||||
}
|
||||
|
||||
func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
@@ -85,6 +86,24 @@ func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAPICallLogKeepsUserFacingAndUpstreamFailureDetails(t *testing.T) {
|
||||
log := &model.ApiCallLog{
|
||||
Status: model.ApiCallStatusFailed,
|
||||
StatusCode: 400,
|
||||
Error: "上游 HTTP 400",
|
||||
}
|
||||
(&Service{}).EnrichAPICallLog(log, []byte(`{"error":{"code":"invalid_request","message":"invalid parameter: size"}}`))
|
||||
if !strings.Contains(log.Error, "模型服务拒绝了请求,请检查模型和参数") {
|
||||
t.Fatalf("EnrichAPICallLog() missing user-facing category: %q", log.Error)
|
||||
}
|
||||
if !strings.Contains(log.Error, "invalid parameter: size") {
|
||||
t.Fatalf("EnrichAPICallLog() missing upstream detail: %q", log.Error)
|
||||
}
|
||||
if log.ErrorCode != "invalid_request" {
|
||||
t.Fatalf("EnrichAPICallLog() error code = %q", log.ErrorCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAPICallLogReadsChatCompletionStreamUsage(t *testing.T) {
|
||||
log := &model.ApiCallLog{Capability: "text", Path: "/v1/chat/completions"}
|
||||
stream := []byte("data: {\"id\":\"chatcmpl-test\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n" +
|
||||
|
||||
93
backend/internal/service/task_api_call_log.go
Normal file
93
backend/internal/service/task_api_call_log.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
// ensureFailedProviderAttemptLogged fills the task-level audit gap only when no
|
||||
// provider log exists. This normally represents a preflight failure; the coarse
|
||||
// request/state variants also preserve visibility if primary log persistence failed.
|
||||
func (s *Service) ensureFailedProviderAttemptLogged(task model.Task, taskErr error) {
|
||||
if s == nil || s.repo == nil || strings.TrimSpace(task.ID) == "" || taskErr == nil {
|
||||
return
|
||||
}
|
||||
hasLog, err := s.repo.HasAPICallLogForTask(task.ID)
|
||||
if err != nil {
|
||||
log.Printf("provider attempt log lookup failed: task_id=%s error=%v", task.ID, err)
|
||||
return
|
||||
}
|
||||
if hasLog {
|
||||
return
|
||||
}
|
||||
|
||||
metadata := providerAnalyticsContext{
|
||||
UserID: task.UserID,
|
||||
TaskID: task.ID,
|
||||
TraceID: task.TraceID,
|
||||
RequestID: task.RequestID,
|
||||
BillingOrderID: task.BillingOrderID,
|
||||
Capability: capabilityFromTaskType(task.Type),
|
||||
Operation: task.Operation,
|
||||
Model: task.Model,
|
||||
ProviderRequestID: task.ProviderRequestID,
|
||||
}
|
||||
decodedTask := task
|
||||
if decrypted, decryptErr := s.decryptTaskInputJSON(task.InputJSON); decryptErr == nil {
|
||||
decodedTask.InputJSON = decrypted
|
||||
if value, ok := withProviderAnalytics(context.Background(), s, decodedTask).Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok {
|
||||
metadata = value
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startedAt := now
|
||||
if task.StartedAt != nil && !task.StartedAt.IsZero() {
|
||||
startedAt = *task.StartedAt
|
||||
}
|
||||
path := "/task/provider-preflight"
|
||||
errorCode := "request_not_sent"
|
||||
statusCode := 0
|
||||
var httpErr providerHTTPError
|
||||
if errors.As(taskErr, &httpErr) {
|
||||
path = "/task/provider-request"
|
||||
errorCode = "provider_request_unlogged"
|
||||
statusCode = httpErr.StatusCode
|
||||
} else if strings.TrimSpace(metadata.ProviderRequestID) != "" {
|
||||
path = "/task/provider-state"
|
||||
errorCode = "provider_state_unlogged"
|
||||
}
|
||||
callLog := model.ApiCallLog{
|
||||
UserID: metadata.UserID,
|
||||
TraceID: metadata.TraceID,
|
||||
RequestID: metadata.RequestID,
|
||||
ChannelID: metadata.ChannelID,
|
||||
TaskID: metadata.TaskID,
|
||||
BillingOrderID: metadata.BillingOrderID,
|
||||
Source: "backend-task",
|
||||
Capability: metadata.Capability,
|
||||
Operation: metadata.Operation,
|
||||
RequestKind: "create",
|
||||
Billable: false,
|
||||
APIFormat: "internal",
|
||||
Method: "INTERNAL",
|
||||
Path: path,
|
||||
Model: metadata.Model,
|
||||
Status: model.ApiCallStatusFailed,
|
||||
StatusCode: statusCode,
|
||||
DurationMs: now.Sub(startedAt).Milliseconds(),
|
||||
ProviderRequestID: metadata.ProviderRequestID,
|
||||
ErrorCode: errorCode,
|
||||
Error: truncateRunes(s.UserFacingErrorMessage(taskErr), 2_000),
|
||||
StartedAt: startedAt,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := s.LogAPICall(callLog); err != nil {
|
||||
log.Printf("provider attempt log write failed: task_id=%s error=%v", task.ID, err)
|
||||
}
|
||||
}
|
||||
98
backend/internal/service/task_api_call_log_test.go
Normal file
98
backend/internal/service/task_api_call_log_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureFailedProviderAttemptLoggedFillsPreflightGapOnce(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:provider-preflight-log?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.SystemSetting{},
|
||||
&model.Asset{},
|
||||
&model.CanvasProject{},
|
||||
&model.Session{},
|
||||
&model.Message{},
|
||||
&model.Task{},
|
||||
&model.TaskLog{},
|
||||
&model.Result{},
|
||||
&model.ApiCallLog{},
|
||||
&model.TaskTextDelta{},
|
||||
&model.ModelPricing{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := model.Task{
|
||||
ID: "task-1",
|
||||
UserID: "user-1",
|
||||
Type: "canvas_video",
|
||||
Status: model.TaskStatusRunning,
|
||||
Operation: "video.generate",
|
||||
Model: "logical-video",
|
||||
InputJSON: `{"mode":"video","config":{"channelId":"channel-1","channelModelKey":"agnes-video-2.5","apiKey":"secret-key"}}`,
|
||||
}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{repo: repository.New(db)}
|
||||
failure := errors.New("Agnes Video 2.5 size 必须是 720P、960P 或 2K")
|
||||
|
||||
service.ensureFailedProviderAttemptLogged(task, failure)
|
||||
service.ensureFailedProviderAttemptLogged(task, failure)
|
||||
|
||||
var logs []model.ApiCallLog
|
||||
if err := db.Find(&logs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logs) != 1 {
|
||||
t.Fatalf("api call log count = %d, want 1", len(logs))
|
||||
}
|
||||
logged := logs[0]
|
||||
if logged.ErrorCode != "request_not_sent" || logged.Status != model.ApiCallStatusFailed || logged.StatusCode != 0 {
|
||||
t.Fatalf("unexpected preflight log state: %#v", logged)
|
||||
}
|
||||
if logged.ChannelID != "channel-1" || logged.Model != "agnes-video-2.5" || logged.Capability != "video" {
|
||||
t.Fatalf("unexpected provider metadata: %#v", logged)
|
||||
}
|
||||
if logged.Error != failure.Error() {
|
||||
t.Fatalf("preflight error = %q, want %q", logged.Error, failure.Error())
|
||||
}
|
||||
serialized := logged.RequestBody + logged.ResponseBody + logged.UpstreamURL + logged.Error
|
||||
if strings.Contains(serialized, "secret-key") || logged.RequestBody != "" || logged.ResponseBody != "" {
|
||||
t.Fatalf("preflight log leaked task input: %#v", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureFailedProviderAttemptLoggedDoesNotDuplicateHTTPLog(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:provider-http-log-dedup?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := model.ApiCallLog{ID: "api-log-1", TaskID: "task-1", Status: model.ApiCallStatusFailed}
|
||||
if err := db.Create(&existing).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{repo: repository.New(db)}
|
||||
service.ensureFailedProviderAttemptLogged(model.Task{ID: "task-1"}, errors.New("provider rejected request"))
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.ApiCallLog{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("api call log count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type taskTerminalCoordinator struct {
|
||||
logger taskLifecycleLogger
|
||||
outputs taskOutputLifecycle
|
||||
userFacingMessage func(error) string
|
||||
logFailedAttempt func(model.Task, error)
|
||||
}
|
||||
|
||||
type taskTerminalRepository interface {
|
||||
@@ -61,6 +62,7 @@ func newTaskTerminalCoordinator(s *Service) *taskTerminalCoordinator {
|
||||
logger: s,
|
||||
outputs: s,
|
||||
userFacingMessage: s.UserFacingErrorMessage,
|
||||
logFailedAttempt: s.ensureFailedProviderAttemptLogged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +75,7 @@ func (s *Service) terminalCoordinator() *taskTerminalCoordinator {
|
||||
}
|
||||
|
||||
func (c *taskTerminalCoordinator) markPreparationFailure(task *model.Task, stage string, err error, billingUncertain bool, refundReason string) error {
|
||||
c.ensureFailedAttemptLogged(task, err)
|
||||
task.Status = model.TaskStatusFailed
|
||||
task.Stage = stage
|
||||
task.Error = c.userFacingMessage(err)
|
||||
@@ -126,6 +129,7 @@ func (c *taskTerminalCoordinator) handleExecutionFailure(task *model.Task, err e
|
||||
}
|
||||
|
||||
task.Status = model.TaskStatusFailed
|
||||
c.ensureFailedAttemptLogged(task, err)
|
||||
task.Stage = "任务失败"
|
||||
task.Error = c.userFacingMessage(err)
|
||||
if terminalErr := c.markTerminalState(task); terminalErr != nil {
|
||||
@@ -208,6 +212,13 @@ func (c *taskTerminalCoordinator) handleResultPersistenceFailure(task *model.Tas
|
||||
return false, resultErr
|
||||
}
|
||||
|
||||
func (c *taskTerminalCoordinator) ensureFailedAttemptLogged(task *model.Task, err error) {
|
||||
if c.logFailedAttempt == nil || task == nil || err == nil {
|
||||
return
|
||||
}
|
||||
c.logFailedAttempt(*task, err)
|
||||
}
|
||||
|
||||
func (c *taskTerminalCoordinator) handleSuccess(task *model.Task) error {
|
||||
c.finalizeReplay(task, model.TaskStatusSucceeded, "文本回放窗口更新失败")
|
||||
var completionErr error
|
||||
|
||||
@@ -170,6 +170,32 @@ func TestTaskTerminalCoordinatorRefundsFailureBeforeProviderRequest(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTerminalCoordinatorLogsUnrecordedProviderFailure(t *testing.T) {
|
||||
task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"}
|
||||
coordinator := newTaskTerminalCoordinatorForTest(
|
||||
&taskTerminalRepositoryStub{task: task},
|
||||
&taskTerminalBillingStub{},
|
||||
&taskTerminalReplayStub{},
|
||||
&taskTerminalSessionStub{},
|
||||
&taskTerminalLoggerStub{},
|
||||
&taskTerminalOutputStub{},
|
||||
)
|
||||
var loggedTask model.Task
|
||||
var loggedErr error
|
||||
coordinator.logFailedAttempt = func(value model.Task, err error) {
|
||||
loggedTask = value
|
||||
loggedErr = err
|
||||
}
|
||||
failure := errors.New("provider preflight failed")
|
||||
|
||||
if err := coordinator.handleExecutionFailure(task, failure, false, true); !errors.Is(err, failure) {
|
||||
t.Fatalf("handleExecutionFailure() error = %v, want %v", err, failure)
|
||||
}
|
||||
if loggedTask.ID != task.ID || !errors.Is(loggedErr, failure) {
|
||||
t.Fatalf("logged failure = task:%#v error:%v", loggedTask, loggedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTerminalCoordinatorReturnsTerminalStateWriteError(t *testing.T) {
|
||||
task := &model.Task{ID: "task-1", UserID: "user-1", BillingOrderID: "order-1"}
|
||||
terminalError := errors.New("database unavailable")
|
||||
|
||||
Reference in New Issue
Block a user