Files
kandev/apps/backend/internal/task/repository/sqlite/workspace.go
T
Carlos Florêncio 657b92eb69 fix: restore workspace deletion from settings (#1547)
* fix: restore workspace deletion from settings

* fix: validate workspace delete in service

* fix: clarify workspace delete errors

* fix: address workspace delete review feedback

* fix: guard confirmed workspace delete before cascade

* fix: simplify workspace delete cascade guard

* fix: make confirmed workspace delete atomic

* fix: preserve confirmed workspace delete side effects

* fix: snapshot confirmed workspace cascade rows

* fix: clarify confirmed workspace delete cleanup

* fix: preserve office workspace deletion cleanup

* fix: preserve workspace delete error mapping

* fix: streamline workspace delete cascade

* fix: harden workspace delete cleanup

* fix: keep workspace delete failure logs

* fix: fail workspace delete on missing session inventory

---------

Co-authored-by: root <root@kandev.ts.net>
2026-06-30 08:24:43 +01:00

358 lines
10 KiB
Go

package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
)
// CreateWorkspace creates a new workspace
func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error {
if workspace.ID == "" {
workspace.ID = uuid.New().String()
}
now := time.Now().UTC()
workspace.CreatedAt = now
workspace.UpdatedAt = now
if workspace.TaskPrefix == "" {
workspace.TaskPrefix = "KAN"
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workspaces (
id,
name,
description,
owner_id,
default_executor_id,
default_environment_id,
default_agent_profile_id,
default_config_agent_profile_id,
task_prefix,
task_sequence,
office_workflow_id,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.TaskPrefix, workspace.TaskSequence, workspace.OfficeWorkflowID, workspace.CreatedAt, workspace.UpdatedAt)
return err
}
// GetWorkspace retrieves a workspace by ID
func (r *Repository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
workspace := &models.Workspace{}
var defaultExecutorID sql.NullString
var defaultEnvironmentID sql.NullString
var defaultAgentProfileID sql.NullString
var defaultConfigAgentProfileID sql.NullString
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at
FROM workspaces WHERE id = ?
`), id).Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.OwnerID,
&defaultExecutorID,
&defaultEnvironmentID,
&defaultAgentProfileID,
&defaultConfigAgentProfileID,
&workspace.TaskPrefix,
&workspace.TaskSequence,
&workspace.OfficeWorkflowID,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if defaultExecutorID.Valid && defaultExecutorID.String != "" {
workspace.DefaultExecutorID = &defaultExecutorID.String
}
if defaultEnvironmentID.Valid && defaultEnvironmentID.String != "" {
workspace.DefaultEnvironmentID = &defaultEnvironmentID.String
}
if defaultAgentProfileID.Valid && defaultAgentProfileID.String != "" {
workspace.DefaultAgentProfileID = &defaultAgentProfileID.String
}
if defaultConfigAgentProfileID.Valid && defaultConfigAgentProfileID.String != "" {
workspace.DefaultConfigAgentProfileID = &defaultConfigAgentProfileID.String
}
if err == sql.ErrNoRows {
return nil, workspaceNotFoundError(id)
}
return workspace, err
}
// UpdateWorkspace updates an existing workspace
func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error {
workspace.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workspaces
SET name = ?,
description = ?,
default_executor_id = ?,
default_environment_id = ?,
default_agent_profile_id = ?,
default_config_agent_profile_id = ?,
updated_at = ?
WHERE id = ?
`), workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.UpdatedAt, workspace.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return workspaceNotFoundError(workspace.ID)
}
return nil
}
// DeleteWorkspace deletes a workspace by ID
func (r *Repository) DeleteWorkspace(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM workspaces WHERE id = ?`), id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return workspaceNotFoundError(id)
}
return nil
}
// DeleteWorkspaceCascade deletes a workspace and its task/workflow rows in one transaction.
func (r *Repository) DeleteWorkspaceCascade(
ctx context.Context,
id string,
) ([]*models.Task, []*models.Workflow, error) {
return r.deleteWorkspaceCascade(ctx, id, nil)
}
// DeleteWorkspaceCascadeWithName deletes a workspace and its task/workflow rows
// in one transaction, only if the current workspace name matches name.
func (r *Repository) DeleteWorkspaceCascadeWithName(
ctx context.Context,
id, name string,
) ([]*models.Task, []*models.Workflow, error) {
return r.deleteWorkspaceCascade(ctx, id, &name)
}
func (r *Repository) deleteWorkspaceCascade(
ctx context.Context,
id string,
expectedName *string,
) ([]*models.Task, []*models.Workflow, error) {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, nil, err
}
defer func() { _ = tx.Rollback() }()
if expectedName != nil {
if err := r.confirmWorkspaceNameForCascadeDelete(ctx, tx, id, *expectedName); err != nil {
return nil, nil, err
}
}
tasks, err := r.listWorkspaceCascadeDeleteTasks(ctx, tx, id)
if err != nil {
return nil, nil, err
}
workflows, err := r.listWorkspaceCascadeDeleteWorkflows(ctx, tx, id)
if err != nil {
return nil, nil, err
}
rows, err := r.deleteWorkspaceCascadeRow(ctx, tx, id, expectedName)
if err != nil {
return nil, nil, err
}
if rows == 0 {
if expectedName != nil {
return nil, nil, r.confirmedWorkspaceDeleteMismatch(ctx, tx, id)
}
return nil, nil, workspaceNotFoundError(id)
}
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
DELETE FROM tasks
WHERE workspace_id = ?
`), id); err != nil {
return nil, nil, err
}
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
DELETE FROM workflows
WHERE workspace_id = ?
`), id); err != nil {
return nil, nil, err
}
if err := tx.Commit(); err != nil {
return nil, nil, err
}
return tasks, workflows, nil
}
func (r *Repository) deleteWorkspaceCascadeRow(
ctx context.Context,
tx *sqlx.Tx,
id string,
expectedName *string,
) (int64, error) {
var result sql.Result
var err error
if expectedName == nil {
result, err = tx.ExecContext(ctx, r.db.Rebind(`
DELETE FROM workspaces
WHERE id = ?
`), id)
} else {
// Keep the name predicate on the delete itself so a rename racing after
// the confirmation cannot delete the newly renamed workspace.
result, err = tx.ExecContext(ctx, r.db.Rebind(`
DELETE FROM workspaces
WHERE id = ? AND name = ?
`), id, *expectedName)
}
if err != nil {
return 0, err
}
rows, _ := result.RowsAffected()
return rows, nil
}
func (r *Repository) confirmWorkspaceNameForCascadeDelete(ctx context.Context, tx *sqlx.Tx, id, name string) error {
currentName, err := r.workspaceNameForCascadeDelete(ctx, tx, id)
if err != nil {
return err
}
if currentName != name {
return repoerrors.ErrWorkspaceNameMismatch
}
return nil
}
func (r *Repository) workspaceNameForCascadeDelete(ctx context.Context, tx *sqlx.Tx, id string) (string, error) {
var currentName string
err := tx.QueryRowContext(ctx, r.db.Rebind(`
SELECT name
FROM workspaces
WHERE id = ?
`), id).Scan(&currentName)
if errors.Is(err, sql.ErrNoRows) {
return "", workspaceNotFoundError(id)
}
return currentName, err
}
func workspaceNotFoundError(id string) error {
return fmt.Errorf("%w: %s", repoerrors.ErrWorkspaceNotFound, id)
}
func (r *Repository) confirmedWorkspaceDeleteMismatch(ctx context.Context, tx *sqlx.Tx, id string) error {
if _, err := r.workspaceNameForCascadeDelete(ctx, tx, id); err != nil {
return err
}
return repoerrors.ErrWorkspaceNameMismatch
}
func (r *Repository) listWorkspaceCascadeDeleteTasks(
ctx context.Context,
tx *sqlx.Tx,
workspaceID string,
) ([]*models.Task, error) {
rows, err := tx.QueryContext(ctx, r.db.Rebind(fmt.Sprintf(`
SELECT %s
FROM tasks
WHERE workspace_id = ?
ORDER BY created_at ASC, id ASC
`, taskSelectColumns("tasks"))), workspaceID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}
func (r *Repository) listWorkspaceCascadeDeleteWorkflows(
ctx context.Context,
tx *sqlx.Tx,
workspaceID string,
) ([]*models.Workflow, error) {
rows, err := tx.QueryContext(ctx, r.db.Rebind(`
SELECT `+workflowSelectColumns+`
FROM workflows
WHERE workspace_id = ?
ORDER BY sort_order ASC, created_at ASC
`), workspaceID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return scanWorkflowRows(rows)
}
// ListWorkspaces returns all workspaces
func (r *Repository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
rows, err := r.ro.QueryContext(ctx, `
SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at
FROM workspaces ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.Workspace
for rows.Next() {
workspace := &models.Workspace{}
var defaultExecutorID sql.NullString
var defaultEnvironmentID sql.NullString
var defaultAgentProfileID sql.NullString
var defaultConfigAgentProfileID sql.NullString
if err := rows.Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.OwnerID,
&defaultExecutorID,
&defaultEnvironmentID,
&defaultAgentProfileID,
&defaultConfigAgentProfileID,
&workspace.TaskPrefix,
&workspace.TaskSequence,
&workspace.OfficeWorkflowID,
&workspace.CreatedAt,
&workspace.UpdatedAt,
); err != nil {
return nil, err
}
if defaultExecutorID.Valid && defaultExecutorID.String != "" {
workspace.DefaultExecutorID = &defaultExecutorID.String
}
if defaultEnvironmentID.Valid && defaultEnvironmentID.String != "" {
workspace.DefaultEnvironmentID = &defaultEnvironmentID.String
}
if defaultAgentProfileID.Valid && defaultAgentProfileID.String != "" {
workspace.DefaultAgentProfileID = &defaultAgentProfileID.String
}
if defaultConfigAgentProfileID.Valid && defaultConfigAgentProfileID.String != "" {
workspace.DefaultConfigAgentProfileID = &defaultConfigAgentProfileID.String
}
result = append(result, workspace)
}
return result, rows.Err()
}