diff --git a/coderd/database/migrations/000387_migrate_task_workspaces.down.sql b/coderd/database/migrations/000387_migrate_task_workspaces.down.sql new file mode 100644 index 0000000000..b266837171 --- /dev/null +++ b/coderd/database/migrations/000387_migrate_task_workspaces.down.sql @@ -0,0 +1,3 @@ +-- No-op: This migration is not reversible as it transforms existing data into +-- a new schema. Rolling back would require deleting tasks and potentially +-- losing data. diff --git a/coderd/database/migrations/000387_migrate_task_workspaces.up.sql b/coderd/database/migrations/000387_migrate_task_workspaces.up.sql new file mode 100644 index 0000000000..8c09cfe44d --- /dev/null +++ b/coderd/database/migrations/000387_migrate_task_workspaces.up.sql @@ -0,0 +1,113 @@ +-- Migrate existing task workspaces to the new tasks data model. This migration +-- identifies workspaces that were created as tasks (has_ai_task = true) and +-- populates the tasks and task_workspace_apps tables with their data. + +-- Step 1: Create tasks from workspaces with has_ai_task TRUE in their latest build. +INSERT INTO tasks ( + id, + organization_id, + owner_id, + name, + workspace_id, + template_version_id, + template_parameters, + prompt, + created_at, + deleted_at +) +SELECT + gen_random_uuid() AS id, + w.organization_id, + w.owner_id, + w.name, + w.id AS workspace_id, + latest_task_build.template_version_id, + COALESCE(params.template_parameters, '{}'::jsonb) AS template_parameters, + COALESCE(ai_prompt.value, '') AS prompt, + w.created_at, + CASE WHEN w.deleted = true THEN w.deleting_at ELSE NULL END AS deleted_at +FROM workspaces w +INNER JOIN LATERAL ( + -- Find the latest build for this workspace that has has_ai_task = true. + SELECT + wb.template_version_id + FROM workspace_builds wb + WHERE wb.workspace_id = w.id + AND wb.has_ai_task = true + ORDER BY wb.build_number DESC + LIMIT 1 +) latest_task_build ON true +LEFT JOIN LATERAL ( + -- Find the latest build that has a non-empty AI Prompt parameter. + SELECT + wb.id + FROM workspace_builds wb + WHERE wb.workspace_id = w.id + AND EXISTS ( + SELECT 1 + FROM workspace_build_parameters wbp + WHERE wbp.workspace_build_id = wb.id + AND wbp.name = 'AI Prompt' + AND wbp.value != '' + ) + ORDER BY wb.build_number DESC + LIMIT 1 +) latest_prompt_build ON true +LEFT JOIN LATERAL ( + -- Extract the AI Prompt parameter value from the prompt build. + SELECT wbp.value + FROM workspace_build_parameters wbp + WHERE wbp.workspace_build_id = latest_prompt_build.id + AND wbp.name = 'AI Prompt' + LIMIT 1 +) ai_prompt ON true +LEFT JOIN LATERAL ( + -- Aggregate all other parameters (excluding AI Prompt) from the prompt build. + SELECT jsonb_object_agg(wbp.name, wbp.value) AS template_parameters + FROM workspace_build_parameters wbp + WHERE wbp.workspace_build_id = latest_prompt_build.id + AND wbp.name != 'AI Prompt' +) params ON true +WHERE + -- Skip deleted workspaces because of duplicate name. + w.deleted = false + -- Safe-guard, do not create tasks for workspaces that are already tasks. + AND NOT EXISTS ( + SELECT 1 + FROM tasks t + WHERE t.workspace_id = w.id + ); + +-- Step 2: Populate task_workspace_apps table with build/agent/app information. +INSERT INTO task_workspace_apps ( + task_id, + workspace_build_number, + workspace_agent_id, + workspace_app_id +) +SELECT + t.id AS task_id, + latest_build.build_number AS workspace_build_number, + sidebar_app.agent_id AS workspace_agent_id, + sidebar_app.id AS workspace_app_id +FROM tasks t +INNER JOIN LATERAL ( + -- Find the latest build for this tasks workspace. + SELECT + wb.build_number, + wb.ai_task_sidebar_app_id + FROM workspace_builds wb + WHERE wb.workspace_id = t.workspace_id + ORDER BY wb.build_number DESC + LIMIT 1 +) latest_build ON true +-- Get the sidebar app (optional, can be NULL). +LEFT JOIN workspace_apps sidebar_app + ON sidebar_app.id = latest_build.ai_task_sidebar_app_id +WHERE + -- Safe-guard, do not create for existing tasks. + NOT EXISTS ( + SELECT 1 + FROM task_workspace_apps twa + WHERE twa.task_id = t.id + ); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index f31a3adb0e..7bab30c0d4 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -469,3 +469,416 @@ func TestMigration000362AggregateUsageEvents(t *testing.T) { require.JSONEq(t, string(expectedDailyRows[i].usageData), string(row.UsageData)) } } + +func TestMigration000387MigrateTaskWorkspaces(t *testing.T) { + t.Parallel() + + // This test verifies the migration of task workspaces to the new tasks data model. + // Test cases: + // + // Task 1 (ws1) - Basic case: + // - Single build with has_ai_task=true, prompt, and parameters + // - Verifies: all task fields are populated correctly + // + // Task 2 (ws2) - No AI Prompt parameter: + // - Single build with has_ai_task=true but NO AI Prompt parameter + // - Verifies: prompt defaults to empty string (tests LEFT JOIN for optional prompt) + // + // Task 3 (ws3) - Latest build is stop: + // - Build 1: start with agents/apps and prompt + // - Build 2: stop build (references same app via ai_task_sidebar_app_id) + // - Verifies: twa uses latest build number with agents/apps from that build's ai_task_sidebar_app_id + // + // Antagonists - Should NOT be migrated: + // - Regular workspace without has_ai_task flag + // - Deleted workspace (w.deleted = true) + + const migrationVersion = 387 + + ctx := testutil.Context(t, testutil.WaitLong) + sqlDB := testSQLDB(t) + + // Migrate up to the migration before the task workspace migration. + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", migrationVersion) + } + if version == migrationVersion-1 { + break + } + } + + now := time.Now().UTC().Truncate(time.Microsecond) + deletingAt := now.Add(24 * time.Hour).Truncate(time.Microsecond) + + // Define all IDs upfront. + orgID := uuid.New() + userID := uuid.New() + templateID := uuid.New() + templateVersionID := uuid.New() + templateJobID := uuid.New() + + // Task workspace 1: basic case with prompt and parameters. + ws1ID := uuid.New() + ws1Build1JobID := uuid.New() + ws1Build1ID := uuid.New() + ws1Resource1ID := uuid.New() + ws1Agent1ID := uuid.New() + ws1App1ID := uuid.New() + + // Task workspace 2: no AI Prompt parameter. + ws2ID := uuid.New() + ws2Build1JobID := uuid.New() + ws2Build1ID := uuid.New() + ws2Resource1ID := uuid.New() + ws2Agent1ID := uuid.New() + ws2App1ID := uuid.New() + + // Task workspace 3: has both start and stop builds. + ws3ID := uuid.New() + ws3Build1JobID := uuid.New() + ws3Build1ID := uuid.New() + ws3Resource1ID := uuid.New() + ws3Agent1ID := uuid.New() + ws3App1ID := uuid.New() + ws3Build2JobID := uuid.New() + ws3Build2ID := uuid.New() + ws3Resource2ID := uuid.New() + + // Antagonist 1: deleted workspace. + wsAntDeletedID := uuid.New() + wsAntDeletedBuild1JobID := uuid.New() + wsAntDeletedBuild1ID := uuid.New() + wsAntDeletedResource1ID := uuid.New() + wsAntDeletedAgent1ID := uuid.New() + wsAntDeletedApp1ID := uuid.New() + + // Antagonist 2: regular workspace without has_ai_task. + wsAntID := uuid.New() + wsAntBuild1JobID := uuid.New() + wsAntBuild1ID := uuid.New() + + // Create all fixtures in a single transaction. + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() + + // Execute fixture setup as individual statements. + fixtures := []struct { + query string + args []any + }{ + // Setup organization, user, and template. + { + `INSERT INTO organizations (id, name, display_name, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`, + []any{orgID, "test-org", "Test Org", "Test Org", now, now}, + }, + { + `INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{userID, "testuser", "test@example.com", []byte{}, now, now, "active", []byte("{}"), "password"}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{templateJobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "template_version_import", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO template_versions (id, organization_id, name, readme, created_at, updated_at, job_id, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + []any{templateVersionID, orgID, "v1.0", "Test template", now, now, templateJobID, userID}, + }, + { + `INSERT INTO templates (id, organization_id, name, created_at, updated_at, provisioner, active_version_id, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + []any{templateID, orgID, "test-template", now, now, "terraform", templateVersionID, userID}, + }, + { + `UPDATE template_versions SET template_id = $1 WHERE id = $2`, + []any{templateID, templateVersionID}, + }, + + // Task workspace 1 is a normal start build. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{ws1ID, now, now, userID, orgID, templateID, false, "task-ws-1", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws1Build1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws1Resource1ID, now, ws1Build1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{ws1Agent1ID, now, now, "agent1", ws1Resource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws1App1ID, now, ws1Agent1ID, "code-server", "Code Server", "", "", "http://localhost:8080", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws1Build1ID, now, now, ws1ID, templateVersionID, 1, "start", userID, []byte{}, ws1Build1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws1App1ID}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws1Build1ID, "AI Prompt", "Build a web server"}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws1Build1ID, "region", "us-east-1"}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws1Build1ID, "instance_type", "t2.micro"}, + }, + + // Task workspace 2: no AI Prompt parameter (tests LEFT JOIN). + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{ws2ID, now, now, userID, orgID, templateID, false, "task-ws-2-no-prompt", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws2Build1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws2Resource1ID, now, ws2Build1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{ws2Agent1ID, now, now, "agent2", ws2Resource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws2App1ID, now, ws2Agent1ID, "terminal", "Terminal", "", "", "http://localhost:3000", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws2Build1ID, now, now, ws2ID, templateVersionID, 1, "start", userID, []byte{}, ws2Build1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws2App1ID}, + }, + // Note: No AI Prompt parameter for ws2 - this tests the LEFT JOIN for optional prompt. + + // Task workspace 3: has both start and stop builds. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{ws3ID, now, now, userID, orgID, templateID, false, "task-ws-3-stop", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws3Build1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws3Resource1ID, now, ws3Build1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{ws3Agent1ID, now, now, "agent3", ws3Resource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws3App1ID, now, ws3Agent1ID, "app3", "App3", "", "", "http://localhost:5000", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws3Build1ID, now, now, ws3ID, templateVersionID, 1, "start", userID, []byte{}, ws3Build1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws3App1ID}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws3Build1ID, "AI Prompt", "Task with stop build"}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws3Build2JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws3Resource2ID, now, ws3Build2JobID, "stop", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws3Build2ID, now, now, ws3ID, templateVersionID, 2, "stop", userID, []byte{}, ws3Build2JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws3App1ID}, + }, + + // Antagonist 1: deleted workspace. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at, deleting_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{wsAntDeletedID, now, now, userID, orgID, templateID, true, "deleted-task-workspace", now, deletingAt}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{wsAntDeletedBuild1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{wsAntDeletedResource1ID, now, wsAntDeletedBuild1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{wsAntDeletedAgent1ID, now, now, "agent-deleted", wsAntDeletedResource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{wsAntDeletedApp1ID, now, wsAntDeletedAgent1ID, "app-deleted", "AppDeleted", "", "", "http://localhost:6000", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{wsAntDeletedBuild1ID, now, now, wsAntDeletedID, templateVersionID, 1, "start", userID, []byte{}, wsAntDeletedBuild1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, wsAntDeletedApp1ID}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{wsAntDeletedBuild1ID, "AI Prompt", "Should not migrate deleted"}, + }, + + // Antagonist 2: regular workspace without has_ai_task. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{wsAntID, now, now, userID, orgID, templateID, false, "regular-workspace", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{wsAntBuild1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{wsAntBuild1ID, now, now, wsAntID, templateVersionID, 1, "start", userID, []byte{}, wsAntBuild1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour)}, + }, + } + + for _, fixture := range fixtures { + _, err = tx.ExecContext(ctx, fixture.query, fixture.args...) + require.NoError(t, err) + } + + err = tx.Commit() + require.NoError(t, err) + + // Run the migration. + version, _, err := next() + require.NoError(t, err) + require.EqualValues(t, migrationVersion, version) + + // Should have exactly 3 tasks (not antagonists). + var taskCount int + err = sqlDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM tasks").Scan(&taskCount) + require.NoError(t, err) + require.Equal(t, 3, taskCount, "should have created 3 tasks from workspaces") + + // Verify task 1, normal start build. + var task1 struct { + id uuid.UUID + name string + workspaceID uuid.UUID + templateVersionID uuid.UUID + prompt string + templateParameters []byte + createdAt time.Time + deletedAt *time.Time + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT id, name, workspace_id, template_version_id, prompt, template_parameters, created_at, deleted_at + FROM tasks WHERE workspace_id = $1 + `, ws1ID).Scan(&task1.id, &task1.name, &task1.workspaceID, &task1.templateVersionID, &task1.prompt, &task1.templateParameters, &task1.createdAt, &task1.deletedAt) + require.NoError(t, err) + require.Equal(t, "task-ws-1", task1.name) + require.Equal(t, "Build a web server", task1.prompt) + require.JSONEq(t, `{"region":"us-east-1","instance_type":"t2.micro"}`, string(task1.templateParameters)) + require.Nil(t, task1.deletedAt) + + // Verify task_workspace_apps for task 1. + var twa1 struct { + buildNumber int32 + agentID uuid.UUID + appID uuid.UUID + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps WHERE task_id = $1 + `, task1.id).Scan(&twa1.buildNumber, &twa1.agentID, &twa1.appID) + require.NoError(t, err) + require.Equal(t, int32(1), twa1.buildNumber) + require.Equal(t, ws1Agent1ID, twa1.agentID) + require.Equal(t, ws1App1ID, twa1.appID) + + // Verify task 2, no AI Prompt parameter. + var task2 struct { + id uuid.UUID + name string + prompt string + templateParameters []byte + deletedAt *time.Time + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT id, name, prompt, template_parameters, deleted_at + FROM tasks WHERE workspace_id = $1 + `, ws2ID).Scan(&task2.id, &task2.name, &task2.prompt, &task2.templateParameters, &task2.deletedAt) + require.NoError(t, err) + require.Equal(t, "task-ws-2-no-prompt", task2.name) + require.Equal(t, "", task2.prompt, "prompt should be empty string when no AI Prompt parameter") + require.JSONEq(t, `{}`, string(task2.templateParameters), "no parameters") + require.Nil(t, task2.deletedAt) + + // Verify task_workspace_apps for task 2. + var twa2 struct { + buildNumber int32 + agentID uuid.UUID + appID uuid.UUID + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps WHERE task_id = $1 + `, task2.id).Scan(&twa2.buildNumber, &twa2.agentID, &twa2.appID) + require.NoError(t, err) + require.Equal(t, int32(1), twa2.buildNumber) + require.Equal(t, ws2Agent1ID, twa2.agentID) + require.Equal(t, ws2App1ID, twa2.appID) + + // Verify task 3, has both start and stop builds. + var task3 struct { + id uuid.UUID + name string + prompt string + templateParameters []byte + templateVersionID uuid.UUID + deletedAt *time.Time + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT id, name, prompt, template_parameters, template_version_id, deleted_at + FROM tasks WHERE workspace_id = $1 + `, ws3ID).Scan(&task3.id, &task3.name, &task3.prompt, &task3.templateParameters, &task3.templateVersionID, &task3.deletedAt) + require.NoError(t, err) + require.Equal(t, "task-ws-3-stop", task3.name) + require.Equal(t, "Task with stop build", task3.prompt) + require.JSONEq(t, `{}`, string(task3.templateParameters), "no other parameters") + require.Equal(t, templateVersionID, task3.templateVersionID) + require.Nil(t, task3.deletedAt) + + // Verify task_workspace_apps for task 3 uses latest build and its ai_task_sidebar_app_id. + var twa3 struct { + buildNumber int32 + agentID uuid.UUID + appID uuid.UUID + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps WHERE task_id = $1 + `, task3.id).Scan(&twa3.buildNumber, &twa3.agentID, &twa3.appID) + require.NoError(t, err) + require.Equal(t, int32(2), twa3.buildNumber, "should use latest build number") + require.Equal(t, ws3Agent1ID, twa3.agentID, "should use agent from latest build's ai_task_sidebar_app_id") + require.Equal(t, ws3App1ID, twa3.appID, "should use app from latest build's ai_task_sidebar_app_id") + + // Verify antagonists should NOT be migrated. + var antCount int + err = sqlDB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM tasks + WHERE workspace_id IN ($1, $2) + `, wsAntDeletedID, wsAntID).Scan(&antCount) + require.NoError(t, err) + require.Equal(t, 0, antCount, "antagonist workspaces (deleted and regular) should not be migrated") +}