chore: commit workspace updates
This commit is contained in:
79
backend/internal/database/cloud_agent_recovery_test.go
Normal file
79
backend/internal/database/cloud_agent_recovery_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestMigrateSchemaV14BackfillsRecoveryAcrossBatches(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:agent-recovery-migration?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CloudAgentExecution{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 205; i++ {
|
||||
status := []string{"running", "waiting_approval", "failed", "cancelled", "completed"}[i%5]
|
||||
run := model.CloudAgentExecution{ID: fmt.Sprintf("run-%03d", i), UserID: "user", Status: status, Revision: 7, StateJSON: `{"request":{"canvasId":"canvas"},"activeTaskId":"child","mediaTaskId":"media"}`}
|
||||
if err := db.Create(&run).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Transaction(migrateSchemaV14); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runs []model.CloudAgentExecution
|
||||
if err := db.Find(&runs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, run := range runs {
|
||||
if run.Status == "completed" {
|
||||
continue
|
||||
}
|
||||
if run.CanvasID != "canvas" || run.ActiveTaskID != "child" || run.MediaTaskID != "media" || run.Revision != 7 {
|
||||
t.Fatalf("missing recovery fields: %+v", run)
|
||||
}
|
||||
if run.CleanupPending != (run.Status == "failed" || run.Status == "cancelled") {
|
||||
t.Fatalf("incorrect cleanup flag: %+v", run)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaV14KeepsCorruptExecutionRecoverable(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:agent-corrupt-migration?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.CloudAgentExecution{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, run := range []model.CloudAgentExecution{
|
||||
{ID: "a-valid", Status: "cancelled", StateJSON: `{"activeTaskId":"child"}`},
|
||||
{ID: "b-corrupt", Status: "failed", StateJSON: `{broken`},
|
||||
} {
|
||||
if err := db.Create(&run).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err = db.Transaction(migrateSchemaV14)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var valid model.CloudAgentExecution
|
||||
if err := db.First(&valid, "id = ?", "a-valid").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !valid.CleanupPending || valid.ActiveTaskID != "child" {
|
||||
t.Fatalf("valid cancelled execution is not recoverable: %+v", valid)
|
||||
}
|
||||
var corrupt model.CloudAgentExecution
|
||||
if err := db.First(&corrupt, "id = ?", "b-corrupt").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !corrupt.CleanupPending || corrupt.ActiveTaskID != "b-corrupt" || corrupt.FailureMessage == "" {
|
||||
t.Fatalf("corrupt execution was orphaned: %+v", corrupt)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 10
|
||||
const CurrentSchemaVersion int64 = 14
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
@@ -57,6 +58,59 @@ var schemaMigrations = []migration{
|
||||
{version: 8, name: "logical_model_active_code", checksum: logicalModelActiveCodeChecksum, apply: migrateSchemaV8},
|
||||
{version: 9, name: "channel_presentation", checksum: "sha256:channel-presentation-v9-20260908", apply: migrateChannelPresentation},
|
||||
{version: 10, name: "creation_runtime", checksum: creationRuntimeChecksum, apply: migrateSchemaV10},
|
||||
{version: 11, name: "cloud_agent_runtime", checksum: "sha256:cloud-agent-runtime-v11-20260912", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.CloudAgentExecution{}) }},
|
||||
{version: 12, name: "agent_token_charge_limit", checksum: "sha256:agent-token-charge-limit-v12-20260913", apply: migrateSchemaV12},
|
||||
{version: 13, name: "cloud_agent_canvas_mutation", checksum: "sha256:cloud-agent-canvas-mutation-v13-20260913", apply: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.CloudAgentCanvasMutation{})
|
||||
}},
|
||||
{version: 14, name: "cloud_agent_recovery_control", checksum: "sha256:cloud-agent-recovery-control-v14", apply: migrateSchemaV14},
|
||||
}
|
||||
|
||||
func migrateSchemaV14(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.CloudAgentExecution{}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep cancellation recoverable for executions admitted before this schema.
|
||||
// Bound memory while retaining the migration transaction's all-or-nothing semantics.
|
||||
after := ""
|
||||
for {
|
||||
var runs []model.CloudAgentExecution
|
||||
if err := tx.Where("id > ? AND status <> ?", after, "completed").Order("id ASC").Limit(100).Find(&runs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, run := range runs {
|
||||
var state struct {
|
||||
Request struct {
|
||||
CanvasID string `json:"canvasId"`
|
||||
} `json:"request"`
|
||||
ActiveTaskID string `json:"activeTaskId"`
|
||||
MediaTaskID string `json:"mediaTaskId"`
|
||||
}
|
||||
// The root task ID is always a safe cancellation anchor. If an old
|
||||
// transcript is damaged, retain a durable warning and cancel that
|
||||
// root task during recovery instead of blocking the whole deployment.
|
||||
updates := map[string]any{"active_task_id": run.ID}
|
||||
if err := json.Unmarshal([]byte(run.StateJSON), &state); err != nil {
|
||||
updates["failure_message"] = "旧 Agent 运行记录损坏,已保留根任务并进入安全收尾;请核对任务中心"
|
||||
} else {
|
||||
updates["canvas_id"] = state.Request.CanvasID
|
||||
if state.ActiveTaskID != "" {
|
||||
updates["active_task_id"] = state.ActiveTaskID
|
||||
}
|
||||
updates["media_task_id"] = state.MediaTaskID
|
||||
}
|
||||
if run.Status == "cancelled" || run.Status == "failed" {
|
||||
updates["cleanup_pending"] = true
|
||||
}
|
||||
if err := tx.Model(&model.CloudAgentExecution{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
after = run.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func migrateChannelPresentation(tx *gorm.DB) error {
|
||||
@@ -223,6 +277,20 @@ func migrateSchemaV10(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateSchemaV12 为 Agent 的 Token 计费增加最终扣费上限;旧账单保持 0,继续沿用既有按 usage 结算语义。
|
||||
func migrateSchemaV12(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasTable(&model.BillingOrder{}) {
|
||||
return nil
|
||||
}
|
||||
if tx.Migrator().HasColumn(&model.BillingOrder{}, "ChargeLimitMicrocredits") {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Migrator().AddColumn(&model.BillingOrder{}, "ChargeLimitMicrocredits"); err != nil {
|
||||
return fmt.Errorf("增加 Agent Token 扣费上限列:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MigrateSchema(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
|
||||
@@ -62,6 +62,22 @@ func TestMigrateSchemaV8AllowsReusingArchivedLogicalModelCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaV12AddsAgentTokenChargeLimit(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-agent-token-charge-limit?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE billing_orders (id text PRIMARY KEY)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateSchemaV12(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model.BillingOrder{}, "ChargeLimitMicrocredits") {
|
||||
t.Fatal("migration v12 did not add Agent token charge limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRejectsChecksumMismatch(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-checksum?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
@@ -257,3 +273,21 @@ func TestRequireSchemaVersionRejectsUninitializedDatabase(t *testing.T) {
|
||||
t.Fatalf("expected missing migration error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaV13AddsCloudAgentCanvasMutation(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-cloud-agent-canvas-mutation?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.CloudAgentCanvasMutation{}) {
|
||||
t.Fatal("migration v13 did not create cloud agent canvas mutation table")
|
||||
}
|
||||
for _, field := range []string{"RunID", "BeforeSnapshotHash", "AfterSnapshotHash", "BeforeJSON", "HasSubmittedTask", "Status"} {
|
||||
if !db.Migrator().HasColumn(&model.CloudAgentCanvasMutation{}, field) {
|
||||
t.Fatalf("migration v13 did not add %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
// Models 是应用持久化表的唯一清单,服务启动和跨数据库迁移必须共用它。
|
||||
func Models() []any {
|
||||
return []any{
|
||||
&model.CloudAgentExecution{},
|
||||
&model.CloudAgentCanvasMutation{},
|
||||
&model.User{},
|
||||
&model.AuthSession{},
|
||||
&model.UserIdentity{},
|
||||
@@ -90,13 +92,8 @@ func Models() []any {
|
||||
&model.CreationRun{},
|
||||
&model.CreationSubmission{},
|
||||
&model.TaskTextDelta{},
|
||||
&model.Session{},
|
||||
&model.Message{},
|
||||
&model.TaskLog{},
|
||||
&model.SessionFile{},
|
||||
&model.Result{},
|
||||
&model.ComfyBridge{},
|
||||
&model.ComfyBridgeRequest{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,3 +60,12 @@ func TestPostgresAssetIDMigrationsCoverEveryAssetIDColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsIncludeCloudAgentCanvasMutation(t *testing.T) {
|
||||
for _, item := range Models() {
|
||||
if _, ok := item.(*model.CloudAgentCanvasMutation); ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("Models must include CloudAgentCanvasMutation")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user