657b92eb69
* 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>
358 lines
10 KiB
Go
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(¤tName)
|
|
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()
|
|
}
|