Files
kandev/apps/backend/internal/task/service/handoff_materialize_test.go
T
2026-05-16 20:07:57 +01:00

327 lines
13 KiB
Go

package service
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
orchmodels "github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/task/models"
)
// fakeSessionReader implements SessionWorktreeReader for the
// materializer tests. Sessions and worktrees are keyed by the task /
// session ID so tests can build small graphs without a real DB.
type fakeSessionReader struct {
mu sync.Mutex
sessions map[string][]*models.TaskSession // taskID → sessions
worktrees map[string][]*models.TaskSessionWorktree // sessionID → worktrees
tasks map[string]*models.Task
// sessionsWithRunningExecutor records sessions that should be
// reported as still bound to an active executor by
// HasExecutorRunningRow. Tests use this to exercise the
// cleanup-safety gate (post-review #5).
sessionsWithRunningExecutor map[string]bool
}
func newFakeSessionReader() *fakeSessionReader {
return &fakeSessionReader{
sessions: map[string][]*models.TaskSession{},
worktrees: map[string][]*models.TaskSessionWorktree{},
tasks: map[string]*models.Task{},
sessionsWithRunningExecutor: map[string]bool{},
}
}
func (f *fakeSessionReader) ListTaskSessions(_ context.Context, taskID string) ([]*models.TaskSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.sessions[taskID], nil
}
func (f *fakeSessionReader) ListTaskSessionWorktrees(_ context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.worktrees[sessionID], nil
}
func (f *fakeSessionReader) GetTask(_ context.Context, id string) (*models.Task, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.tasks[id], nil
}
func (f *fakeSessionReader) HasExecutorRunningRow(_ context.Context, sessionID string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.sessionsWithRunningExecutor[sessionID], nil
}
func newMaterializerService(t *testing.T, tasks *fakeTaskRepo, ws *fakeWSGroupRepoCascade, sr *fakeSessionReader) *HandoffService {
t.Helper()
tr := newCascadeRepo(tasks)
svc := NewHandoffService(tr, nil, nil, nil, ws, nil)
svc.SetSessionReader(sr)
return svc
}
func TestMarkOwnerSessionMaterialized_FlipsOwnership(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("owner", "", "ws-1")
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "owner",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
CleanupStatus: orchmodels.WorkspaceCleanupStatusActive,
CleanupPolicy: orchmodels.WorkspaceCleanupPolicyNeverDelete,
}
groups.members["g1"] = map[string]string{"owner": orchmodels.WorkspaceMemberRoleOwner}
sr := newFakeSessionReader()
sr.sessions["owner"] = []*models.TaskSession{{ID: "s1", IsPrimary: true, TaskID: "owner"}}
sr.worktrees["s1"] = []*models.TaskSessionWorktree{{
WorktreeID: "wt-1", RepositoryID: "repo-1",
WorktreePath: "/tmp/wt", WorktreeBranch: "feature/x",
}}
svc := newMaterializerService(t, tasks, groups, sr)
svc.MarkOwnerSessionMaterialized(context.Background(), "owner")
g := groups.groups["g1"]
if !g.OwnedByKandev {
t.Error("group should now be owned_by_kandev=true")
}
if g.CleanupPolicy != orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel {
t.Errorf("cleanup_policy = %q, want delete_when_last_member_*", g.CleanupPolicy)
}
if g.MaterializedPath != "/tmp/wt" {
t.Errorf("materialized_path = %q", g.MaterializedPath)
}
if g.MaterializedKind != orchmodels.WorkspaceGroupKindSingleRepo {
t.Errorf("kind = %q", g.MaterializedKind)
}
}
// TestMarkOwnerSessionMaterialized_InheritParentMemberWithoutWorktrees
// covers the inherit_parent semantics: a child task whose session
// inherits the parent's TaskEnvironment does NOT have worktrees of its
// own. The materializer reports ok=false from materializationFromSession
// and the group stays unmaterialized until the parent's launch produces
// its own worktrees.
func TestMarkOwnerSessionMaterialized_InheritParentMemberWithoutWorktrees(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("owner", "", "ws-1")
tasks.addTask("member", "owner", "ws-1")
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "owner",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
}
groups.members["g1"] = map[string]string{
"owner": orchmodels.WorkspaceMemberRoleOwner,
"member": orchmodels.WorkspaceMemberRoleMember,
}
sr := newFakeSessionReader()
// member session inherits parent env — no worktrees of its own.
sr.sessions["member"] = []*models.TaskSession{{ID: "s2", IsPrimary: true, TaskID: "member"}}
// no entry in sr.worktrees["s2"] — empty list
svc := newMaterializerService(t, tasks, groups, sr)
svc.MarkOwnerSessionMaterialized(context.Background(), "member")
if groups.groups["g1"].OwnedByKandev {
t.Error("inherit_parent member without worktrees must not flip ownership")
}
}
// TestMarkOwnerSessionMaterialized_SharedGroupFirstMemberFlips covers
// the shared_group semantics: when a non-owner member of a shared_group
// is the first to launch (with real worktrees), it materializes the
// group so later members can inherit the env. The MaterializedPath
// idempotency guard prevents double-materialization on subsequent
// launches.
func TestMarkOwnerSessionMaterialized_SharedGroupFirstMemberFlips(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("creator", "", "ws-1")
tasks.addTask("first-launcher", "", "ws-1")
groups := newCascadeWSGroupRepo()
// The group's owner is the task that created it ("creator"), but
// "first-launcher" is going to materialize first.
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "creator",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
}
groups.members["g1"] = map[string]string{
"creator": orchmodels.WorkspaceMemberRoleOwner,
"first-launcher": orchmodels.WorkspaceMemberRoleMember,
}
sr := newFakeSessionReader()
sr.sessions["first-launcher"] = []*models.TaskSession{{ID: "sFL", IsPrimary: true, TaskID: "first-launcher"}}
sr.worktrees["sFL"] = []*models.TaskSessionWorktree{{
WorktreeID: "wt-fl", RepositoryID: "repo-1",
WorktreePath: "/tmp/wt-fl", WorktreeBranch: "feature/x",
}}
svc := newMaterializerService(t, tasks, groups, sr)
svc.MarkOwnerSessionMaterialized(context.Background(), "first-launcher")
if !groups.groups["g1"].OwnedByKandev {
t.Error("first shared_group member with real worktrees must materialize the group")
}
if groups.groups["g1"].MaterializedPath != "/tmp/wt-fl" {
t.Errorf("materialized_path = %q, want /tmp/wt-fl",
groups.groups["g1"].MaterializedPath)
}
}
func TestMarkOwnerSessionMaterialized_Idempotent(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("owner", "", "ws-1")
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "owner",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
MaterializedPath: "/already/set",
OwnedByKandev: true,
}
groups.members["g1"] = map[string]string{"owner": orchmodels.WorkspaceMemberRoleOwner}
sr := newFakeSessionReader()
sr.sessions["owner"] = []*models.TaskSession{{ID: "s1", IsPrimary: true, TaskID: "owner"}}
sr.worktrees["s1"] = []*models.TaskSessionWorktree{{
WorktreePath: "/tmp/wt-changed",
}}
svc := newMaterializerService(t, tasks, groups, sr)
svc.MarkOwnerSessionMaterialized(context.Background(), "owner")
if groups.groups["g1"].MaterializedPath != "/already/set" {
t.Error("idempotent re-invocation must not overwrite the materialized path")
}
}
func TestMarkOwnerSessionMaterialized_NoSessionYetSilent(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("owner", "", "ws-1")
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "owner",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
}
groups.members["g1"] = map[string]string{"owner": orchmodels.WorkspaceMemberRoleOwner}
svc := newMaterializerService(t, tasks, groups, newFakeSessionReader())
// Should be a clean no-op when there's no session yet.
svc.MarkOwnerSessionMaterialized(context.Background(), "owner")
if groups.groups["g1"].OwnedByKandev {
t.Error("no-session case must not flip owned_by_kandev")
}
}
func TestGetSharedGroupEnvironment_ReturnsMaterializedEnv(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("member", "", "ws-1")
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "creator",
MaterializedKind: orchmodels.WorkspaceGroupKindRemoteEnvironment,
MaterializedEnvironmentID: "env-abc",
}
groups.members["g1"] = map[string]string{
"member": orchmodels.WorkspaceMemberRoleMember,
}
svc := newMaterializerService(t, tasks, groups, newFakeSessionReader())
if got := svc.GetSharedGroupEnvironment(context.Background(), "member"); got != "env-abc" {
t.Errorf("env = %q, want env-abc", got)
}
}
func TestGetSharedGroupEnvironment_EmptyWhenNoGroup(t *testing.T) {
svc := newMaterializerService(t, newFakeTaskRepo(), newCascadeWSGroupRepo(), newFakeSessionReader())
if got := svc.GetSharedGroupEnvironment(context.Background(), "no-task"); got != "" {
t.Errorf("no-group env should be empty, got %q", got)
}
}
func TestGetSharedGroupEnvironment_EmptyWhenGroupUnmaterialized(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("member", "", "ws-1")
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "creator",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
// MaterializedEnvironmentID intentionally empty
}
groups.members["g1"] = map[string]string{"member": orchmodels.WorkspaceMemberRoleMember}
svc := newMaterializerService(t, tasks, groups, newFakeSessionReader())
if got := svc.GetSharedGroupEnvironment(context.Background(), "member"); got != "" {
t.Errorf("unmaterialized group env should be empty, got %q", got)
}
}
func TestRestoreCleanedGroups_PlainFolderMkdirs(t *testing.T) {
tasks := newFakeTaskRepo()
groups := newCascadeWSGroupRepo()
tmp := t.TempDir()
target := filepath.Join(tmp, "restored")
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "owner",
MaterializedKind: orchmodels.WorkspaceGroupKindPlainFolder,
MaterializedPath: target,
CleanupStatus: orchmodels.WorkspaceCleanupStatusCleaned,
OwnedByKandev: true,
RestoreConfigJSON: `{"kind":"plain_folder","path":"` + target + `"}`,
}
svc := newMaterializerService(t, tasks, groups, newFakeSessionReader())
svc.restoreCleanedGroups(context.Background(), []string{"g1"})
if _, err := os.Stat(target); err != nil {
t.Errorf("plain folder restore should mkdir the path; stat err: %v", err)
}
}
func TestRestoreCleanedGroups_RepoKindMarksRestorable(t *testing.T) {
tasks := newFakeTaskRepo()
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "owner",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
CleanupStatus: orchmodels.WorkspaceCleanupStatusCleaned,
OwnedByKandev: true,
RestoreConfigJSON: `{"kind":"single_repo","worktree_ids":{"r":"wt"}}`,
}
svc := newMaterializerService(t, tasks, groups, newFakeSessionReader())
svc.restoreCleanedGroups(context.Background(), []string{"g1"})
if got := groups.cleanupStatuses["g1"]; got != orchmodels.WorkspaceCleanupStatusActive {
t.Errorf("repo restore should flip cleanup_status to active; got %q", got)
}
}
// REGRESSION: an empty restore_config_json (impossible if the
// materializer ran, but possible if someone hand-edited the row) must
// not panic. The restore should mark the group restore_failed.
func TestRestoreCleanedGroups_EmptyConfigMarksFailed(t *testing.T) {
tasks := newFakeTaskRepo()
groups := newCascadeWSGroupRepo()
groups.groups["g1"] = &orchmodels.WorkspaceGroup{
ID: "g1", WorkspaceID: "ws-1", OwnerTaskID: "owner",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
CleanupStatus: orchmodels.WorkspaceCleanupStatusCleaned,
OwnedByKandev: true,
// RestoreConfigJSON intentionally empty.
}
svc := newMaterializerService(t, tasks, groups, newFakeSessionReader())
svc.restoreCleanedGroups(context.Background(), []string{"g1"})
// We don't have UpdateWorkspaceGroupRestoreStatus tracking on the
// fake; the contract here is "must not panic and must not advance
// cleanup_status to active".
if got := groups.cleanupStatuses["g1"]; got == orchmodels.WorkspaceCleanupStatusActive {
t.Error("empty restore config must not transition to active")
}
}