fix: clean office workspace data on delete (#1486)

This commit is contained in:
Carlos Florêncio
2026-06-25 10:28:52 +01:00
committed by GitHub
parent a28c4226de
commit 81ef4fb5e5
16 changed files with 567 additions and 20 deletions
@@ -508,6 +508,9 @@ func registerRoutes(p routeParams) {
// the kanban board doesn't react to subtree archive/delete until a
// full reload.
handoffSvc.SetTaskEventPublisher(p.taskSvc)
if p.services.Office != nil {
p.services.Office.SetWorkspaceGroupCleaner(handoffSvc)
}
// Cascade archive/delete must tear down runtime resources
// (container, sandbox, worktree, executor_running rows) for every
// task in the tree. Without this wiring the agent gets stopped via
@@ -51,15 +51,37 @@ func (r *Repository) deleteWorkspaceDataTx(ctx context.Context, tx *sqlx.Tx, wor
// We delete them via the merged table; shallow kanban profiles
// (workspace_id = '') are unaffected.
statements := []string{
`DELETE FROM run_events WHERE run_id IN (
SELECT id FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)
)`,
`DELETE FROM office_run_route_attempts WHERE run_id IN (
SELECT id FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)
)`,
`DELETE FROM office_run_skills WHERE run_id IN (
SELECT id FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)
)`,
`DELETE FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM agent_wakeup_requests WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM agent_continuation_summaries WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM office_agent_memory WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM office_agent_instructions WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM office_agent_runtime WHERE agent_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM office_cost_events WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM office_cost_events WHERE project_id IN (SELECT id FROM office_projects WHERE workspace_id = ?)`,
`DELETE FROM office_provider_health WHERE workspace_id = ?`,
`DELETE FROM office_workspace_routing WHERE workspace_id = ?`,
`DELETE FROM office_workspace_settings WHERE workspace_id = ?`,
`DELETE FROM office_budget_policies WHERE workspace_id = ?`,
`DELETE FROM office_routine_runs WHERE routine_id IN (SELECT id FROM office_routines WHERE workspace_id = ?)`,
`DELETE FROM office_routine_triggers WHERE routine_id IN (SELECT id FROM office_routines WHERE workspace_id = ?)`,
`DELETE FROM task_workspace_group_members WHERE workspace_group_id IN (
SELECT id FROM task_workspace_groups WHERE workspace_id = ?
)`,
`DELETE FROM task_workspace_groups WHERE workspace_id = ?`,
`DELETE FROM office_task_tree_hold_members WHERE hold_id IN (
SELECT id FROM office_task_tree_holds WHERE workspace_id = ?
)`,
`DELETE FROM office_task_tree_holds WHERE workspace_id = ?`,
`DELETE FROM office_task_labels WHERE label_id IN (SELECT id FROM office_labels WHERE workspace_id = ?)`,
`DELETE FROM office_labels WHERE workspace_id = ?`,
`DELETE FROM office_channels WHERE workspace_id = ?`,
@@ -32,9 +32,21 @@ func TestDeleteWorkspaceDataDeletesOwnedOfficeRows(t *testing.T) {
"office_agent_instructions",
"office_agent_runtime",
"runs",
"run_events",
"office_run_route_attempts",
"office_run_skills",
"agent_wakeup_requests",
"agent_continuation_summaries",
"office_routine_triggers",
"office_routine_runs",
"office_task_labels",
"office_provider_health",
"office_workspace_routing",
"office_workspace_settings",
"task_workspace_groups",
"task_workspace_group_members",
"office_task_tree_holds",
"office_task_tree_hold_members",
} {
assertTableCount(t, db, table, "ws-delete", 0)
assertTableCount(t, db, table, "ws-keep", 1)
@@ -85,6 +97,10 @@ func seedWorkspaceDeletionRows(t *testing.T, repo *sqlite.Repository, workspaceI
projectID := workspaceID + "-project"
routineID := workspaceID + "-routine"
labelID := workspaceID + "-label"
runID := workspaceID + "-run"
groupID := workspaceID + "-group"
treeHoldID := workspaceID + "-hold"
taskID := workspaceID + "-task"
// Office agents now live in agent_profiles (workspace_id != ''). The
// seed inserts the minimum columns the merged schema requires.
@@ -98,19 +114,31 @@ func seedWorkspaceDeletionRows(t *testing.T, repo *sqlite.Repository, workspaceI
execRaw(t, repo, `INSERT INTO office_agent_runtime (agent_id, status, updated_at) VALUES (?, 'running', ?)`, agentID, now)
execRaw(t, repo, `INSERT INTO office_agent_memory (id, agent_profile_id, layer, key, created_at, updated_at) VALUES (?, ?, 'project', 'note', ?, ?)`, workspaceID+"-memory", agentID, now, now)
execRaw(t, repo, `INSERT INTO office_agent_instructions (id, agent_profile_id, filename, content, created_at, updated_at) VALUES (?, ?, 'guide.md', 'content', ?, ?)`, workspaceID+"-instruction", agentID, now, now)
execRaw(t, repo, `INSERT INTO runs (id, agent_profile_id, reason, requested_at) VALUES (?, ?, 'test', ?)`, workspaceID+"-run", agentID, now)
execRaw(t, repo, `INSERT INTO runs (id, agent_profile_id, reason, requested_at) VALUES (?, ?, 'test', ?)`, runID, agentID, now)
execRaw(t, repo, `INSERT INTO run_events (run_id, seq, event_type, payload, created_at) VALUES (?, 1, 'queued', '{}', ?)`, runID, now)
execRaw(t, repo, `INSERT INTO office_run_route_attempts (run_id, seq, provider_id, model, tier, outcome, started_at) VALUES (?, 1, 'codex', 'gpt', 'balanced', 'failed', ?)`, runID, now)
execRaw(t, repo, `INSERT INTO office_run_skills (run_id, skill_id, version, content_hash, materialized_path) VALUES (?, ?, 'v1', 'hash', '/tmp/skill')`, runID, workspaceID+"-skill")
execRaw(t, repo, `INSERT INTO agent_wakeup_requests (id, agent_profile_id, source, status, requested_at) VALUES (?, ?, 'test', 'queued', ?)`, workspaceID+"-wakeup", agentID, now)
execRaw(t, repo, `INSERT INTO agent_continuation_summaries (agent_profile_id, scope, content) VALUES (?, 'workspace', 'summary')`, agentID)
execRaw(t, repo, `INSERT INTO office_cost_events (id, agent_profile_id, project_id, occurred_at, created_at) VALUES (?, ?, ?, ?, ?)`, workspaceID+"-cost", agentID, projectID, now, now)
execRaw(t, repo, `INSERT INTO office_budget_policies (id, workspace_id, scope_type, scope_id, limit_subcents, period, created_at, updated_at) VALUES (?, ?, 'workspace', ?, 100, 'month', ?, ?)`, workspaceID+"-budget", workspaceID, workspaceID, now, now)
execRaw(t, repo, `INSERT INTO office_routines (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, routineID, workspaceID, "Routine "+workspaceID, now, now)
execRaw(t, repo, `INSERT INTO office_routine_triggers (id, routine_id, kind, created_at, updated_at) VALUES (?, ?, 'manual', ?, ?)`, workspaceID+"-trigger", routineID, now, now)
execRaw(t, repo, `INSERT INTO office_routine_runs (id, routine_id, source, created_at) VALUES (?, ?, 'manual', ?)`, workspaceID+"-run", routineID, now)
execRaw(t, repo, `INSERT INTO office_routine_runs (id, routine_id, source, created_at) VALUES (?, ?, 'manual', ?)`, workspaceID+"-routine-run", routineID, now)
execRaw(t, repo, `INSERT INTO office_labels (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, labelID, workspaceID, "Label "+workspaceID, now, now)
execRaw(t, repo, `INSERT INTO office_task_labels (task_id, label_id, created_at) VALUES (?, ?, ?)`, workspaceID+"-task", labelID, now)
execRaw(t, repo, `INSERT INTO office_task_labels (task_id, label_id, created_at) VALUES (?, ?, ?)`, taskID, labelID, now)
execRaw(t, repo, `INSERT INTO office_channels (id, workspace_id, agent_profile_id, platform, created_at, updated_at) VALUES (?, ?, ?, 'slack', ?, ?)`, workspaceID+"-channel", workspaceID, agentID, now, now)
execRaw(t, repo, `INSERT INTO office_approvals (id, workspace_id, type, created_at, updated_at) VALUES (?, ?, 'test', ?, ?)`, workspaceID+"-approval", workspaceID, now, now)
execRaw(t, repo, `INSERT INTO office_activity_log (id, workspace_id, actor_type, actor_id, action, created_at) VALUES (?, ?, 'agent', ?, 'created', ?)`, workspaceID+"-activity", workspaceID, agentID, now)
execRaw(t, repo, `INSERT INTO office_onboarding (workspace_id, completed, ceo_agent_id, created_at) VALUES (?, 1, ?, ?)`, workspaceID, agentID, now)
execRaw(t, repo, `INSERT INTO office_workspace_governance (workspace_id, key, value) VALUES (?, 'require_approval_for_new_agents', 1)`, workspaceID)
execRaw(t, repo, `INSERT INTO office_workspace_routing (workspace_id, enabled, default_tier, provider_order, provider_profiles, updated_at) VALUES (?, 1, 'balanced', '["codex"]', '{}', ?)`, workspaceID, now)
execRaw(t, repo, `INSERT INTO office_provider_health (workspace_id, provider_id, scope, scope_value, state, updated_at) VALUES (?, 'codex', 'provider', '', 'degraded', ?)`, workspaceID, now)
execRaw(t, repo, `INSERT INTO office_workspace_settings (workspace_id, agent_failure_threshold, updated_at) VALUES (?, 3, ?)`, workspaceID, now)
execRaw(t, repo, `INSERT INTO task_workspace_groups (id, workspace_id, owner_task_id, materialized_kind, created_at, updated_at) VALUES (?, ?, ?, 'local', ?, ?)`, groupID, workspaceID, taskID, now, now)
execRaw(t, repo, `INSERT INTO task_workspace_group_members (workspace_group_id, task_id, created_at) VALUES (?, ?, ?)`, groupID, taskID, now)
execRaw(t, repo, `INSERT INTO office_task_tree_holds (id, workspace_id, root_task_id, mode, created_at) VALUES (?, ?, ?, 'manual', ?)`, treeHoldID, workspaceID, taskID, now)
execRaw(t, repo, `INSERT INTO office_task_tree_hold_members (hold_id, task_id, depth) VALUES (?, ?, 0)`, treeHoldID, taskID)
}
func execRaw(t *testing.T, repo *sqlite.Repository, query string, args ...interface{}) {
@@ -167,14 +195,25 @@ func workspaceDeletionTableSpec(table string) workspaceDeletionCountSpec {
"office_budget_policies", "office_routines", "office_labels", "office_channels",
"office_approvals", "office_activity_log":
return workspaceDeletionCountSpec{column: "id", operator: "LIKE", value: likeID}
case "office_agent_memory", "office_agent_instructions", "runs":
case "office_agent_memory", "office_agent_instructions", "runs", "agent_wakeup_requests",
"agent_continuation_summaries":
return workspaceDeletionCountSpec{column: "agent_profile_id", operator: "LIKE", value: likeID}
case "office_agent_runtime":
return workspaceDeletionCountSpec{column: "agent_id", operator: "LIKE", value: likeID}
case "run_events", "office_run_route_attempts", "office_run_skills":
return workspaceDeletionCountSpec{column: "run_id", operator: "LIKE", value: likeID}
case "office_routine_triggers", "office_routine_runs":
return workspaceDeletionCountSpec{column: "routine_id", operator: "LIKE", value: likeID}
case "office_task_labels":
return workspaceDeletionCountSpec{column: "label_id", operator: "LIKE", value: likeID}
return workspaceDeletionCountSpec{column: "task_id", operator: "LIKE", value: likeID}
case "task_workspace_group_members":
return workspaceDeletionCountSpec{column: "workspace_group_id", operator: "LIKE", value: likeID}
case "office_task_tree_hold_members":
return workspaceDeletionCountSpec{column: "hold_id", operator: "LIKE", value: likeID}
case "task_workspace_groups":
return workspaceDeletionCountSpec{column: "id", operator: "LIKE", value: likeID}
case "office_task_tree_holds":
return workspaceDeletionCountSpec{column: "id", operator: "LIKE", value: likeID}
default:
return workspaceDeletionCountSpec{column: "workspace_id", operator: "=", value: eqWorkspace}
}
@@ -127,6 +127,28 @@ func (r *Repository) GetWorkspaceGroup(ctx context.Context, id string) (*models.
return &g, nil
}
// ListWorkspaceGroupsByWorkspace returns all workspace-group rows owned by a workspace.
func (r *Repository) ListWorkspaceGroupsByWorkspace(ctx context.Context, workspaceID string) ([]*models.WorkspaceGroup, error) {
var groups []*models.WorkspaceGroup
err := r.ro.SelectContext(ctx, &groups, r.ro.Rebind(`
SELECT id, workspace_id, owner_task_id, materialized_path,
materialized_environment_id, materialized_kind, owned_by_kandev,
cleanup_policy, cleanup_status, cleaned_at, cleanup_error,
restore_status, restore_error, restore_config_json,
created_at, updated_at
FROM task_workspace_groups
WHERE workspace_id = ?
ORDER BY created_at, id
`), workspaceID)
if err != nil {
return nil, err
}
if groups == nil {
groups = []*models.WorkspaceGroup{}
}
return groups, nil
}
// MarkWorkspaceMaterialized records the materialized path/environment AND
// flips the ownership fields atomically. It is the ONLY supported path
// for setting owned_by_kandev=true: callers must pass m.OwnedByKandev=true
@@ -3,6 +3,7 @@ package sqlite_test
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/repository/sqlite"
@@ -107,6 +108,44 @@ func TestWorkspaceGroup_RawInsertDefaultsAreSafe(t *testing.T) {
}
}
func TestWorkspaceGroup_ListByWorkspace(t *testing.T) {
repo := newWorkspaceGroupTestRepo(t)
ctx := context.Background()
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
groups := []*models.WorkspaceGroup{
{ID: "target-later", WorkspaceID: "ws-1", OwnerTaskID: "owner-2", MaterializedKind: models.WorkspaceGroupKindPlainFolder, CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)},
{ID: "other-workspace", WorkspaceID: "ws-2", OwnerTaskID: "owner-3", MaterializedKind: models.WorkspaceGroupKindPlainFolder, CreatedAt: base.Add(-time.Minute), UpdatedAt: base.Add(-time.Minute)},
{ID: "target-earlier", WorkspaceID: "ws-1", OwnerTaskID: "owner-1", MaterializedKind: models.WorkspaceGroupKindPlainFolder, CreatedAt: base, UpdatedAt: base},
}
for _, g := range groups {
if err := repo.CreateWorkspaceGroup(ctx, g); err != nil {
t.Fatalf("create %s: %v", g.ID, err)
}
}
got, err := repo.ListWorkspaceGroupsByWorkspace(ctx, "ws-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 {
t.Fatalf("list returned %d groups, want 2", len(got))
}
if got[0].ID != "target-earlier" || got[1].ID != "target-later" {
t.Fatalf("list order = [%s %s], want [target-earlier target-later]", got[0].ID, got[1].ID)
}
empty, err := repo.ListWorkspaceGroupsByWorkspace(ctx, "ws-empty")
if err != nil {
t.Fatalf("list empty: %v", err)
}
if empty == nil {
t.Fatal("empty list should be a non-nil slice")
}
if len(empty) != 0 {
t.Fatalf("empty list returned %d groups, want 0", len(empty))
}
}
// TestMarkWorkspaceMaterialized_FlipsOwnershipAtomically: setting
// OwnedByKandev=true via the materializer is the only way the cleanup
// policy switches to delete_when_last_member. Setting OwnedByKandev=false
@@ -153,6 +153,9 @@ func applyServiceOverrides(opts *service.ServiceOptions, o service.ServiceOption
if o.TaskWorkspace != nil {
opts.TaskWorkspace = o.TaskWorkspace
}
if o.WorkspaceGroupCleaner != nil {
opts.WorkspaceGroupCleaner = o.WorkspaceGroupCleaner
}
if o.TaskCreator != nil {
opts.TaskCreator = o.TaskCreator
}
@@ -108,6 +108,12 @@ type TaskWorkspaceService interface {
GetLastAgentMessage(ctx context.Context, sessionID string) (string, error)
}
// WorkspaceGroupCleaner removes Kandev-owned materialized task workspaces
// before the office repository deletes the rows holding their cleanup handles.
type WorkspaceGroupCleaner interface {
CleanupWorkspaceGroups(ctx context.Context, workspaceID string) error
}
// TaskStarterFunc adapts a function to the TaskStarter interface.
// Useful for wrapping callers whose StartTask returns additional values.
type TaskStarterFunc func(ctx context.Context, taskID, agentProfileID, executorID,
@@ -200,6 +206,7 @@ type ServiceOptions struct {
TaskStarter TaskStarter
TaskCanceller TaskCanceller
TaskWorkspace TaskWorkspaceService
WorkspaceGroupCleaner WorkspaceGroupCleaner
TaskCreator TaskCreator
WorkspaceCreator WorkspaceCreator
AgentTypeResolver AgentTypeResolver
@@ -224,6 +231,7 @@ type Service struct {
routingDispatcher RoutingDispatcher
taskCanceller TaskCanceller
taskWorkspace TaskWorkspaceService
workspaceGroupCleaner WorkspaceGroupCleaner
taskCreator TaskCreator
workspaceCreator WorkspaceCreator
taskPRs TaskPRLister
@@ -338,6 +346,7 @@ func NewService(opts ServiceOptions) *Service {
taskStarter: opts.TaskStarter,
taskCanceller: opts.TaskCanceller,
taskWorkspace: opts.TaskWorkspace,
workspaceGroupCleaner: opts.WorkspaceGroupCleaner,
taskCreator: opts.TaskCreator,
workspaceCreator: opts.WorkspaceCreator,
taskPRs: opts.TaskPRs,
@@ -350,6 +359,12 @@ func NewService(opts ServiceOptions) *Service {
return svc
}
// SetWorkspaceGroupCleaner wires the handoff cleanup service after startup
// constructs the shared HandoffService instance.
func (s *Service) SetWorkspaceGroupCleaner(cleaner WorkspaceGroupCleaner) {
s.workspaceGroupCleaner = cleaner
}
// SetAgentctlBinaryPath overrides the host path to the agentctl binary.
// Prefer passing AgentctlBinaryPath via ServiceOptions. This setter exists
// for cases where the path must be updated after construction (e.g. tests).
@@ -58,6 +58,11 @@ func (s *Service) ExecSQL(t *testing.T, query string, args ...interface{}) {
}
}
// GetWorkspaceGroupForTest exposes workspace-group rows for deletion-order tests.
func (s *Service) GetWorkspaceGroupForTest(ctx context.Context, id string) (*models.WorkspaceGroup, error) {
return s.repo.GetWorkspaceGroup(ctx, id)
}
// GetTaskExecutionFieldsForTest exposes task execution fields for service package tests.
func (s *Service) GetTaskExecutionFieldsForTest(
ctx context.Context,
@@ -10,7 +10,10 @@ import (
"go.uber.org/zap"
)
const workspaceDeletionPageSize = 500
const (
workspaceDeletionPageSize = 500
workspaceDeletionPhaseTimeout = 60 * time.Second
)
// WorkspaceDeletionSummary describes the permanent-delete impact for a workspace.
type WorkspaceDeletionSummary struct {
@@ -61,22 +64,33 @@ func (s *Service) DeleteWorkspace(ctx context.Context, workspaceID string) error
return err
}
cleanupCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
s.cancelWorkspaceTasks(cleanupCtx, tasks)
cleanupBaseCtx := context.WithoutCancel(ctx)
taskCancelCtx, cancelTaskCancel := workspaceDeletionPhaseContext(cleanupBaseCtx)
s.cancelWorkspaceTasks(taskCancelCtx, tasks)
cancelTaskCancel()
if err := s.repo.DeleteWorkspaceData(ctx, workspaceID); err != nil {
if s.workspaceGroupCleaner != nil {
groupCleanupCtx, cancelGroupCleanup := workspaceDeletionPhaseContext(cleanupBaseCtx)
if err := s.workspaceGroupCleaner.CleanupWorkspaceGroups(groupCleanupCtx, workspaceID); err != nil {
cancelGroupCleanup()
return fmt.Errorf("clean workspace groups: %w", err)
}
cancelGroupCleanup()
}
dataDeleteCtx, cancelDataDelete := workspaceDeletionPhaseContext(cleanupBaseCtx)
defer cancelDataDelete()
if err := s.repo.DeleteWorkspaceData(dataDeleteCtx, workspaceID); err != nil {
return fmt.Errorf("delete office workspace data: %w", err)
}
for _, task := range tasks {
if task == nil || task.ID == "" {
continue
}
if err := s.taskWorkspace.DeleteTask(cleanupCtx, task.ID); err != nil {
if err := s.taskWorkspace.DeleteTask(dataDeleteCtx, task.ID); err != nil {
return fmt.Errorf("delete task %s: %w", task.ID, err)
}
}
if err := s.taskWorkspace.DeleteWorkspace(cleanupCtx, workspaceID); err != nil {
if err := s.taskWorkspace.DeleteWorkspace(dataDeleteCtx, workspaceID); err != nil {
return fmt.Errorf("delete workspace row: %w", err)
}
if s.cfgWriter != nil {
@@ -91,6 +105,10 @@ func (s *Service) DeleteWorkspace(ctx context.Context, workspaceID string) error
return nil
}
func workspaceDeletionPhaseContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, workspaceDeletionPhaseTimeout)
}
func (s *Service) listAllWorkspaceTasks(ctx context.Context, workspaceID string) ([]*taskmodels.Task, error) {
var all []*taskmodels.Task
for page := 1; ; page++ {
@@ -4,6 +4,7 @@ import (
"context"
"os"
"testing"
"time"
officemodels "github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/service"
@@ -19,10 +20,13 @@ func TestDeleteWorkspaceStopsTasksDeletesDataAndConfig(t *testing.T) {
{ID: "task-2", WorkspaceID: "ws-delete"},
},
}
groupCleaner := &fakeWorkspaceGroupCleaner{groupID: "group-1"}
svc := newTestService(t, service.ServiceOptions{
TaskWorkspace: taskSvc,
TaskCanceller: &fakeTaskCanceller{},
TaskWorkspace: taskSvc,
TaskCanceller: &fakeTaskCanceller{},
WorkspaceGroupCleaner: groupCleaner,
})
groupCleaner.svc = svc
createTestAgent(t, svc, "ws-delete", "agent-delete")
if err := svc.CreateSkill(ctx, &officemodels.Skill{
@@ -33,6 +37,13 @@ func TestDeleteWorkspaceStopsTasksDeletesDataAndConfig(t *testing.T) {
}); err != nil {
t.Fatalf("create skill: %v", err)
}
now := time.Now().UTC()
svc.ExecSQL(t, `INSERT INTO task_workspace_groups (
id, workspace_id, owner_task_id, materialized_path, materialized_kind,
owned_by_kandev, cleanup_policy, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"group-1", "ws-delete", "task-1", "/tmp/kandev-group", officemodels.WorkspaceGroupKindPlainFolder,
true, officemodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel, now, now)
if err := svc.DeleteWorkspace(ctx, "ws-delete"); err != nil {
t.Fatalf("DeleteWorkspace: %v", err)
@@ -44,16 +55,56 @@ func TestDeleteWorkspaceStopsTasksDeletesDataAndConfig(t *testing.T) {
if got := taskSvc.deletedTasks; len(got) != 2 || got[0] != "task-1" || got[1] != "task-2" {
t.Fatalf("deleted tasks = %#v, want task-1/task-2", got)
}
if !groupCleaner.called {
t.Fatal("workspace group cleaner was not called")
}
if !groupCleaner.groupExistedDuringCleanup {
t.Fatal("workspace group row should exist while cleanup runs")
}
if group, err := svc.GetWorkspaceGroupForTest(ctx, "group-1"); err != nil {
t.Fatalf("get group after deletion: %v", err)
} else if group != nil {
t.Fatal("workspace group row should be removed after workspace deletion")
}
if _, err := os.Stat(svc.ConfigWriter().WorkspacePath("default")); !os.IsNotExist(err) {
t.Fatalf("workspace config should be removed, stat err: %v", err)
}
}
func TestDeleteWorkspaceUsesFreshDataDeletionTimeoutAfterGroupCleanup(t *testing.T) {
ctx := context.Background()
taskSvc := &fakeWorkspaceTaskService{
workspace: &taskmodels.Workspace{ID: "ws-delete", Name: "default"},
tasks: []*taskmodels.Task{{ID: "task-1", WorkspaceID: "ws-delete"}},
}
groupCleaner := &deadlineWorkspaceGroupCleaner{}
svc := newTestService(t, service.ServiceOptions{
TaskWorkspace: taskSvc,
TaskCanceller: &fakeTaskCanceller{},
WorkspaceGroupCleaner: groupCleaner,
})
if err := svc.DeleteWorkspace(ctx, "ws-delete"); err != nil {
t.Fatalf("DeleteWorkspace: %v", err)
}
if groupCleaner.deadline.IsZero() {
t.Fatal("workspace group cleanup did not receive a timeout")
}
if len(taskSvc.deletedTaskDeadlines) != 1 {
t.Fatalf("deleted task deadlines = %#v, want one deadline", taskSvc.deletedTaskDeadlines)
}
if !taskSvc.deletedTaskDeadlines[0].After(groupCleaner.deadline) {
t.Fatalf("task deletion deadline = %v, want after group cleanup deadline %v",
taskSvc.deletedTaskDeadlines[0], groupCleaner.deadline)
}
}
type fakeWorkspaceTaskService struct {
workspace *taskmodels.Workspace
tasks []*taskmodels.Task
deletedTasks []string
deletedWorkspace string
workspace *taskmodels.Workspace
tasks []*taskmodels.Task
deletedTasks []string
deletedTaskDeadlines []time.Time
deletedWorkspace string
}
func (f *fakeWorkspaceTaskService) GetWorkspace(context.Context, string) (*taskmodels.Workspace, error) {
@@ -85,8 +136,11 @@ func (f *fakeWorkspaceTaskService) ListTasksByWorkspace(
return f.tasks, len(f.tasks), nil
}
func (f *fakeWorkspaceTaskService) DeleteTask(_ context.Context, id string) error {
func (f *fakeWorkspaceTaskService) DeleteTask(ctx context.Context, id string) error {
f.deletedTasks = append(f.deletedTasks, id)
if deadline, ok := ctx.Deadline(); ok {
f.deletedTaskDeadlines = append(f.deletedTaskDeadlines, deadline)
}
return nil
}
@@ -94,6 +148,34 @@ func (f *fakeWorkspaceTaskService) GetLastAgentMessage(context.Context, string)
return "", nil
}
type fakeWorkspaceGroupCleaner struct {
svc *service.Service
groupID string
called bool
groupExistedDuringCleanup bool
}
func (f *fakeWorkspaceGroupCleaner) CleanupWorkspaceGroups(ctx context.Context, workspaceID string) error {
f.called = true
group, err := f.svc.GetWorkspaceGroupForTest(ctx, f.groupID)
if err != nil {
return err
}
f.groupExistedDuringCleanup = group != nil && group.WorkspaceID == workspaceID
return nil
}
type deadlineWorkspaceGroupCleaner struct {
deadline time.Time
}
func (f *deadlineWorkspaceGroupCleaner) CleanupWorkspaceGroups(ctx context.Context, _ string) error {
if deadline, ok := ctx.Deadline(); ok {
f.deadline = deadline
}
return nil
}
type fakeTaskCanceller struct {
taskIDs []string
}
@@ -605,6 +605,105 @@ func TestCascade_NilEventPublisher_NoCrash(t *testing.T) {
}
}
func TestCleanupWorkspaceGroupsUsesStoredMaterializedHandles(t *testing.T) {
groups := newCascadeWSGroupRepo()
ctx := context.Background()
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
ID: "group-owned",
WorkspaceID: "ws-delete",
OwnerTaskID: "task-1",
MaterializedPath: "/tmp/kandev-owned-group",
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
OwnedByKandev: true,
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
}); err != nil {
t.Fatalf("create owned group: %v", err)
}
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
ID: "group-user",
WorkspaceID: "ws-delete",
OwnerTaskID: "task-2",
MaterializedPath: "/tmp/user-owned-group",
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
OwnedByKandev: false,
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyNeverDelete,
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
}); err != nil {
t.Fatalf("create user group: %v", err)
}
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
ID: "group-cleaned",
WorkspaceID: "ws-delete",
OwnerTaskID: "task-3",
MaterializedPath: "/tmp/already-cleaned-group",
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
OwnedByKandev: true,
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
CleanupStatus: orchmodels.WorkspaceCleanupStatusCleaned,
}); err != nil {
t.Fatalf("create cleaned group: %v", err)
}
cleaner := &fakeWorkspaceCleaner{}
svc := NewHandoffService(nil, nil, nil, nil, groups, nil)
svc.SetWorkspaceCleaner(cleaner)
if err := svc.CleanupWorkspaceGroups(ctx, "ws-delete"); err != nil {
t.Fatalf("cleanup workspace groups: %v", err)
}
if len(cleaner.plainFolders) != 1 || cleaner.plainFolders[0] != "/tmp/kandev-owned-group" {
t.Fatalf("plain folder cleanups = %#v, want owned group path", cleaner.plainFolders)
}
if got := groups.cleanupStatuses["group-owned"]; got != orchmodels.WorkspaceCleanupStatusCleaned {
t.Fatalf("owned group cleanup status = %q, want cleaned", got)
}
if _, ok := groups.cleanupStatuses["group-user"]; ok {
t.Fatal("user-owned group should not be cleaned")
}
}
func TestCleanupWorkspaceGroupsWaitsForActiveExecutions(t *testing.T) {
groups := newCascadeWSGroupRepo()
ctx := context.Background()
if err := groups.CreateWorkspaceGroup(ctx, &orchmodels.WorkspaceGroup{
ID: "group-owned",
WorkspaceID: "ws-delete",
OwnerTaskID: "task-1",
MaterializedPath: "/tmp/kandev-owned-group",
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
OwnedByKandev: true,
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel,
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
}); err != nil {
t.Fatalf("create owned group: %v", err)
}
if err := groups.AddWorkspaceGroupMember(ctx, "group-owned", "task-1", "owner"); err != nil {
t.Fatalf("add group member: %v", err)
}
sessions := &flippingActiveSessionReader{taskID: "task-1", sessionID: "session-1"}
cleaner := &fakeWorkspaceCleaner{}
svc := NewHandoffService(nil, nil, nil, nil, groups, nil)
svc.SetSessionReader(sessions)
svc.SetWorkspaceCleaner(cleaner)
cleanupCtx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
if err := svc.CleanupWorkspaceGroups(cleanupCtx, "ws-delete"); err != nil {
t.Fatalf("cleanup workspace groups: %v", err)
}
if len(cleaner.plainFolders) != 1 || cleaner.plainFolders[0] != "/tmp/kandev-owned-group" {
t.Fatalf("plain folder cleanups = %#v, want owned group path", cleaner.plainFolders)
}
sessions.mu.Lock()
if sessions.checks < 2 {
t.Errorf("HasExecutorRunningRow called %d times, want >= 2", sessions.checks)
}
sessions.mu.Unlock()
if got := groups.cleanupStatuses["group-owned"]; got != orchmodels.WorkspaceCleanupStatusCleaned {
t.Fatalf("owned group cleanup status = %q, want cleaned", got)
}
}
// fakeResourceCleaner captures the (taskID, deleteEnvRow) pair delivered
// to CleanupTaskResources by each cascade invocation. Mirrors
// fakeEventPublisher above — the same regression class (cascade silently
@@ -626,6 +725,59 @@ func (f *fakeResourceCleaner) CleanupTaskResources(_ context.Context, taskID str
f.calls = append(f.calls, resourceCleanerCall{taskID: taskID, deleteEnvRow: deleteEnvRow})
}
type fakeWorkspaceCleaner struct {
plainFolders []string
}
func (f *fakeWorkspaceCleaner) CleanupPlainFolder(_ context.Context, path string) error {
f.plainFolders = append(f.plainFolders, path)
return nil
}
func (f *fakeWorkspaceCleaner) CleanupSingleRepoWorktree(context.Context, string) error {
return nil
}
func (f *fakeWorkspaceCleaner) CleanupMultiRepoRoot(context.Context, string, []string) error {
return nil
}
func (f *fakeWorkspaceCleaner) CleanupRemoteEnvironment(context.Context, string, string) error {
return nil
}
type flippingActiveSessionReader struct {
mu sync.Mutex
taskID string
sessionID string
checks int
}
func (f *flippingActiveSessionReader) ListTaskSessions(_ context.Context, taskID string) ([]*models.TaskSession, error) {
if taskID != f.taskID {
return nil, nil
}
return []*models.TaskSession{{ID: f.sessionID, TaskID: f.taskID}}, nil
}
func (f *flippingActiveSessionReader) ListTaskSessionWorktrees(context.Context, string) ([]*models.TaskSessionWorktree, error) {
return nil, nil
}
func (f *flippingActiveSessionReader) GetTask(context.Context, string) (*models.Task, error) {
return nil, nil
}
func (f *flippingActiveSessionReader) HasExecutorRunningRow(_ context.Context, sessionID string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if sessionID != f.sessionID {
return false, nil
}
f.checks++
return f.checks == 1, nil
}
// TestArchiveTaskTree_InvokesResourceCleanerPerTask pins the regression
// where cascade-archive stopped active runs (which stops the agent /
// container) but never invoked the env-teardown branch — so containers
@@ -4,8 +4,10 @@ import (
"context"
"errors"
"fmt"
"time"
orchmodels "github.com/kandev/kandev/internal/office/models"
"go.uber.org/zap"
)
// WorkspaceCleaner is the disk-cleanup surface evaluateWorkspaceGroupCleanup
@@ -35,6 +37,87 @@ type WorkspaceCleaner interface {
CleanupRemoteEnvironment(ctx context.Context, provider, environmentID string) error
}
const workspaceGroupCleanupPollInterval = 100 * time.Millisecond
// CleanupWorkspaceGroups removes materialized Kandev-owned workspace groups
// before workspace deletion removes the rows containing their cleanup handles.
func (s *HandoffService) CleanupWorkspaceGroups(ctx context.Context, workspaceID string) error {
if s.cleaner == nil || s.wsGroups == nil {
return nil
}
groups, err := s.wsGroups.ListWorkspaceGroupsByWorkspace(ctx, workspaceID)
if err != nil {
return err
}
statusCtx := context.WithoutCancel(ctx)
for _, g := range groups {
if !shouldCleanupWorkspaceGroup(g) {
continue
}
hasActive, err := s.hasActiveExecutionsForGroup(ctx, g.ID)
if err != nil {
return fmt.Errorf("check active workspace group %s: %w", g.ID, err)
}
if hasActive {
s.logf().Warn("workspace group cleanup: active executions remain",
zap.String("workspace_id", workspaceID),
zap.String("group_id", g.ID))
if err := s.wsGroups.UpdateWorkspaceGroupCleanupStatus(statusCtx, g.ID,
orchmodels.WorkspaceCleanupStatusPending,
"active executor still bound during workspace deletion", nil); err != nil {
return err
}
if err := s.waitForWorkspaceGroupIdle(ctx, workspaceID, g.ID); err != nil {
return err
}
}
if err := s.wsGroups.UpdateWorkspaceGroupCleanupStatus(statusCtx, g.ID,
orchmodels.WorkspaceCleanupStatusPending, "", nil); err != nil {
return err
}
if err := s.runWorkspaceGroupCleanup(ctx, g); err != nil {
_ = s.wsGroups.UpdateWorkspaceGroupCleanupStatus(statusCtx, g.ID,
orchmodels.WorkspaceCleanupStatusFailed, err.Error(), nil)
return fmt.Errorf("clean workspace group %s: %w", g.ID, err)
}
now := time.Now().UTC()
if err := s.wsGroups.UpdateWorkspaceGroupCleanupStatus(statusCtx, g.ID,
orchmodels.WorkspaceCleanupStatusCleaned, "", &now); err != nil {
return err
}
}
return nil
}
func shouldCleanupWorkspaceGroup(g *orchmodels.WorkspaceGroup) bool {
return g != nil &&
g.OwnedByKandev &&
g.CleanupPolicy == orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel &&
g.CleanupStatus != orchmodels.WorkspaceCleanupStatusCleaned
}
func (s *HandoffService) waitForWorkspaceGroupIdle(ctx context.Context, workspaceID, groupID string) error {
ticker := time.NewTicker(workspaceGroupCleanupPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("workspace group %s still has active executions: %w", groupID, ctx.Err())
case <-ticker.C:
hasActive, err := s.hasActiveExecutionsForGroup(ctx, groupID)
if err != nil {
return fmt.Errorf("check active workspace group %s: %w", groupID, err)
}
if !hasActive {
s.logf().Info("workspace group cleanup: active executions stopped",
zap.String("workspace_id", workspaceID),
zap.String("group_id", groupID))
return nil
}
}
}
}
// runWorkspaceGroupCleanup is the dispatcher invoked by
// evaluateWorkspaceGroupCleanup once it confirms the group is owned by
// Kandev, has no live members, and is configured to delete on last
@@ -30,6 +30,7 @@ const (
type WorkspaceGroupRepo interface {
CreateWorkspaceGroup(ctx context.Context, g *orchmodels.WorkspaceGroup) error
GetWorkspaceGroup(ctx context.Context, id string) (*orchmodels.WorkspaceGroup, error)
ListWorkspaceGroupsByWorkspace(ctx context.Context, workspaceID string) ([]*orchmodels.WorkspaceGroup, error)
GetWorkspaceGroupForTask(ctx context.Context, taskID string) (*orchmodels.WorkspaceGroup, error)
AddWorkspaceGroupMember(ctx context.Context, groupID, taskID, role string) error
// Phase 6 surface — cascade release / restore + cleanup status updates.
@@ -48,6 +48,18 @@ func (f *fakeWSGroupRepo) GetWorkspaceGroup(_ context.Context, id string) (*orch
return f.groups[id], nil
}
func (f *fakeWSGroupRepo) ListWorkspaceGroupsByWorkspace(_ context.Context, workspaceID string) ([]*orchmodels.WorkspaceGroup, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []*orchmodels.WorkspaceGroup{}
for _, g := range f.groups {
if g.WorkspaceID == workspaceID {
out = append(out, g)
}
}
return out, nil
}
func (f *fakeWSGroupRepo) GetWorkspaceGroupForTask(_ context.Context, taskID string) (*orchmodels.WorkspaceGroup, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { OfficeApiClient } from "../../helpers/office-api-client";
test.describe("Workspace settings deletion", () => {
test("deletes a workspace from the settings edit page", async ({ testPage, apiClient }) => {
@@ -44,4 +45,54 @@ test.describe("Workspace settings deletion", () => {
const { workspaces } = await apiClient.listWorkspaces();
expect(workspaces.some((item) => item.id === workspace.id)).toBe(false);
});
test("deletes an office workspace from the settings edit page", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
const officeApi = new OfficeApiClient(backend.baseUrl);
const suffix = Date.now().toString(36);
const workspaceName = `Settings Office Delete ${suffix}`;
const onboarded = await officeApi.completeOnboarding({
workspaceName,
taskPrefix: `SOD${suffix.toUpperCase().slice(0, 3)}`,
agentName: "Settings Delete CEO",
agentProfileId: seedData.agentProfileId,
executorPreference: "local_pc",
});
await officeApi.createSkill(onboarded.workspaceId, {
name: `Settings Cleanup Skill ${suffix}`,
slug: `settings-cleanup-skill-${suffix}`,
content: "# Settings cleanup skill\n",
});
await testPage.goto(`/settings/workspace/${onboarded.workspaceId}`);
await expect(testPage.getByRole("heading", { name: workspaceName })).toBeVisible({
timeout: 15_000,
});
await testPage.getByTestId("workspace-settings-delete-button").click();
const confirmInput = testPage.getByTestId("workspace-settings-delete-confirm-input");
const confirmButton = testPage.getByTestId("workspace-settings-delete-confirm-button");
await expect(confirmInput).toBeVisible();
await confirmInput.fill(workspaceName);
await expect(confirmButton).toBeEnabled();
await confirmButton.click();
await expect(testPage).toHaveURL(/\/settings\/workspace$/, { timeout: 10_000 });
const { workspaces } = await apiClient.listWorkspaces();
expect(workspaces.some((item) => item.id === onboarded.workspaceId)).toBe(false);
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: seedData.workflowId,
keyboard_shortcuts: {},
enable_preview_on_click: false,
sidebar_views: [],
});
});
});
+1 -1
View File
@@ -15,7 +15,7 @@ Users who create a workspace by mistake, finish an experiment, or consolidate wo
- The workspace settings page shows a "Delete workspace" button in a danger zone section.
- Clicking it opens a confirmation dialog that shows a summary of what will be deleted: number of tasks, agents, and skills. It also displays the full filesystem path that will be removed (e.g. `~/.kandev/workspaces/my-workspace/`). The user must type the workspace name to proceed.
- Deletion stops all running agents in the workspace before removing data.
- All workspace-owned data is removed: agents (+ memory, instructions, runtime), skills, projects, routines (+ triggers, runs), approvals, channels, labels, cost events, budget policies, wakeup queue entries, activity logs, governance settings, and the onboarding record.
- All workspace-owned data is removed: agents (+ memory, instructions, runtime, runs), skills, projects, routines (+ triggers, runs), run events, run route attempts, run skill materializations, wakeup requests, continuation summaries, approvals, channels, labels, cost events, budget policies, routing settings, provider health, activity logs, governance settings, workspace groups, tree holds, workspace settings, and the onboarding record.
- All tasks in the workspace are deleted, including their sessions, worktrees, blockers, and comments.
- The filesystem config directory (`~/.kandev/workspaces/<slug>/`) and any quick-chat workspace directories created for the workspace's sessions are removed.
- After deletion the user is redirected to `/office/setup` if no other workspaces remain, or to `/office` with the next available workspace selected.