Files
kandev/apps/backend/internal/backendapp/workspace_source_materializer_test.go
T

681 lines
31 KiB
Go

package backendapp
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/worktree"
)
func TestRepositoryHostClonerRequiresExactSessionScope(t *testing.T) {
t.Parallel()
contract := reflect.TypeOf((*orchestrator.RepositoryHostCloner)(nil)).Elem()
if _, found := contract.MethodByName("EnsureRepositoryClonedForSession"); !found {
t.Fatal("RepositoryHostCloner does not require exact task/session clone scope")
}
}
type remoteWorkspaceMaterializerStub struct {
calls [][]lifecycle.WorkspaceRepositoryMaterialization
err error
ids []string
}
type hostRepositoryClonerStub struct {
path string
calls []*models.Repository
taskID string
sessionID string
err error
}
func (s *hostRepositoryClonerStub) EnsureRepositoryClonedForSession(
_ context.Context, taskID, sessionID string, repository *models.Repository,
) (string, error) {
s.calls = append(s.calls, repository)
s.taskID = taskID
s.sessionID = sessionID
return s.path, s.err
}
func (s *remoteWorkspaceMaterializerStub) MaterializeRepositoriesForEnvironment(_ context.Context, _ string, repositories []lifecycle.WorkspaceRepositoryMaterialization) ([]string, error) {
s.calls = append(s.calls, append([]lifecycle.WorkspaceRepositoryMaterialization(nil), repositories...))
return append([]string(nil), s.ids...), s.err
}
type failingSessionMaterializerRepo struct {
workspaceSourceMaterializerRepo
err error
}
func (r failingSessionMaterializerRepo) ListTaskSessions(context.Context, string) ([]*models.TaskSession, error) {
return nil, r.err
}
func TestBuildRemoteWorkspaceRepositories_SkipsPrimaryAndUsesDurableRuntimeNames(t *testing.T) {
repositories, err := buildRemoteWorkspaceRepositories([]*models.TaskRepository{
{RepositoryID: "primary", BaseBranch: "main"},
{RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/add-source"},
}, map[string]*models.Repository{
"primary": {ID: "primary", Name: "primary", RemoteURL: "https://github.com/acme/primary.git"},
"repo-1": {ID: "repo-1", Name: "API", RemoteURL: "https://github.com/acme/api.git"},
})
if err != nil {
t.Fatalf("buildRemoteWorkspaceRepositories: %v", err)
}
want := lifecycle.WorkspaceRepositoryMaterialization{RepositoryURL: "https://github.com/acme/api.git", Destination: "API-feature-add-source", BaseBranch: "main", CheckoutBranch: "feature/add-source"}
if len(repositories) != 1 || repositories[0] != want {
t.Fatalf("repositories=%+v, want [%+v]", repositories, want)
}
_, err = buildRemoteWorkspaceRepositories([]*models.TaskRepository{{RepositoryID: "primary", BaseBranch: "main"}, {RepositoryID: "local", BaseBranch: "main"}}, map[string]*models.Repository{
"primary": {ID: "primary", Name: "primary", RemoteURL: "https://github.com/acme/primary.git"},
"local": {ID: "local", Name: "local", LocalPath: "/host/only/repository"},
})
if err == nil {
t.Fatal("local-only repository was accepted for remote materialization")
}
}
func TestLocalWorkspaceEntries_RejectsFolderRepositoryNameCollision(t *testing.T) {
_, err := localWorkspaceEntries(
[]*models.TaskRepository{{RepositoryID: "repo-1", BaseBranch: "main"}},
[]*models.TaskWorkspaceFolder{{DisplayName: "docs", LocalPath: "/folders/docs"}},
map[string]*models.Repository{"repo-1": {ID: "repo-1", Name: "docs", LocalPath: "/repositories/docs"}},
nil,
)
if err == nil {
t.Fatal("local workspace entries accepted colliding folder and repository names")
}
}
func TestWorkspaceSourceMaterializer_RemoteLoadsSessionsBeforeFilesystemMutation(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
seedWorkspaceSourceTask(t, repo, t.TempDir())
seedRemoteMaterializerRepositories(t, repo)
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = string(models.ExecutorTypeLocalDocker)
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
remote := &remoteWorkspaceMaterializerStub{}
materializer := &workspaceSourceMaterializer{
repo: failingSessionMaterializerRepo{workspaceSourceMaterializerRepo: repo, err: errors.New("sessions unavailable")},
worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")),
remoteMaterializer: remote,
logger: newTestLogger(),
}
_, err = materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1"})
if err == nil {
t.Fatal("MaterializeWorkspaceSources succeeded despite session lookup failure")
}
if len(remote.calls) != 0 {
t.Fatalf("remote materialization ran before session lookup: %+v", remote.calls)
}
}
func TestWorkspaceSourceMaterializer_PrelaunchReturnsExplicitDeferredResult(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-prelaunch", Name: "Prelaunch"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTask(ctx, &models.Task{ID: "task-prelaunch", WorkspaceID: "ws-prelaunch", WorkflowID: "wf", WorkflowStepID: "step", Title: "Task"}); err != nil {
t.Fatal(err)
}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-prelaunch")), logger: newTestLogger()}
result, err := materializer.MaterializeWorkspaceSources(ctx, "task-prelaunch", &models.WorkspaceSourceBatch{TaskID: "task-prelaunch"})
if err != nil {
t.Fatal(err)
}
if result == nil || result.WorkspacePath != "" || len(result.SessionIDs) != 0 {
t.Fatalf("prelaunch materialization result = %#v, want explicit empty deferred result", result)
}
}
func TestWorkspaceSourceMaterializer_RemoteMaterializesAdditionalRepositories(t *testing.T) {
for _, executorType := range []models.ExecutorType{models.ExecutorTypeLocalDocker, models.ExecutorTypeSSH, models.ExecutorTypeSprites} {
t.Run(string(executorType), func(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
seedWorkspaceSourceTask(t, repo, t.TempDir())
seedRemoteMaterializerRepositories(t, repo)
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = string(executorType)
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
remote := &remoteWorkspaceMaterializerStub{ids: []string{"session-1"}}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()}
result, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: &models.TaskRepository{RepositoryID: "repo-added", BaseBranch: "main"}}}})
if err != nil {
t.Fatal(err)
}
if result.WorkspacePath != env.WorkspacePath || len(result.SessionIDs) != 1 || result.SessionIDs[0] != "session-1" {
t.Fatalf("materialization result = %#v", result)
}
if len(remote.calls) != 1 || len(remote.calls[0]) != 1 || remote.calls[0][0].Destination != "added-main" {
t.Fatalf("remote projection=%+v; want only additional repository", remote.calls)
}
})
}
}
func TestWorkspaceSourceMaterializer_RemoteReturnsOnlyLifecycleAdoptedSessions(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
seedWorkspaceSourceTask(t, repo, t.TempDir())
seedRemoteMaterializerRepositories(t, repo)
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = string(models.ExecutorTypeSSH)
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
remote := &remoteWorkspaceMaterializerStub{ids: []string{"session-live"}}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()}
result, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: &models.TaskRepository{RepositoryID: "repo-added", BaseBranch: "main"}}}})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.SessionIDs, []string{"session-live"}) {
t.Fatalf("session ids = %v, want only lifecycle-adopted session", result.SessionIDs)
}
}
// A live attachment batch must not revalidate durable siblings. An agent may
// have committed in the first sibling between attachments, making its HEAD
// intentionally differ from origin; only the newly submitted destination is
// eligible for materialization on this live execution.
func TestWorkspaceSourceMaterializer_RemoteMaterializesOnlySubmittedRepositoryBatch(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
seedWorkspaceSourceTask(t, repo, t.TempDir())
seedRemoteMaterializerRepositories(t, repo)
if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-later", WorkspaceID: "ws-1", Name: "later", RemoteURL: "https://github.com/acme/later.git"}); err != nil {
t.Fatal(err)
}
later := &models.TaskRepository{ID: "task-repo-later", TaskID: "task-1", RepositoryID: "repo-later", BaseBranch: "main", Position: 2, Metadata: map[string]interface{}{}}
if err := repo.CreateTaskRepository(ctx, later); err != nil {
t.Fatal(err)
}
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = string(models.ExecutorTypeSSH)
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
remote := &remoteWorkspaceMaterializerStub{}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()}
_, err = materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: later}}})
if err != nil {
t.Fatal(err)
}
if len(remote.calls) != 1 || len(remote.calls[0]) != 1 || remote.calls[0][0].Destination != "later-main" {
t.Fatalf("live remote projection=%+v; want only later-main", remote.calls)
}
}
func TestWorkspaceSourceMaterializer_RemoteFailure(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
seedWorkspaceSourceTask(t, repo, t.TempDir())
seedRemoteMaterializerRepositories(t, repo)
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = string(models.ExecutorTypeSprites)
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: &remoteWorkspaceMaterializerStub{err: errors.New("agentctl unavailable")}, logger: newTestLogger()}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1"}); err == nil {
t.Fatal("MaterializeWorkspaceSources succeeded despite remote materialization failure")
}
}
func TestWorkspaceSourceMaterializer_RemoteRejectsLocalOnlyAdditionalRepositoryBeforeCall(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
seedWorkspaceSourceTask(t, repo, t.TempDir())
seedRemoteMaterializerRepositories(t, repo)
if err := repo.UpdateRepository(ctx, &models.Repository{ID: "repo-added", WorkspaceID: "ws-1", Name: "added", LocalPath: "/host/only/repository"}); err != nil {
t.Fatal(err)
}
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = string(models.ExecutorTypeSSH)
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
remote := &remoteWorkspaceMaterializerStub{}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: &models.TaskRepository{RepositoryID: "repo-added", BaseBranch: "main"}}}}); !errors.Is(err, ErrRemoteRepositoryLocatorUnavailable) {
t.Fatalf("error=%v; want local-only remote locator error", err)
}
if len(remote.calls) != 0 {
t.Fatalf("remote materialization called for local-only repository: %+v", remote.calls)
}
}
func TestWorkspaceSourceMaterializer_LocalFolderCreatesLiveTaskEntryAndRebindsSessions(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
tasksBase := filepath.Join(canonicalTempDir(t), "tasks")
mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1"))
source := filepath.Join(t.TempDir(), "notes")
if err := os.MkdirAll(source, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "note.txt"), []byte("before"), 0o644); err != nil {
t.Fatal(err)
}
seedWorkspaceSourceTask(t, repo, source)
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = "local_pc"
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
rescan := &workspaceSourceRescanStub{}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescan, logger: newTestLogger()}
result, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source}}}})
if err != nil {
t.Fatalf("MaterializeWorkspaceSources: %v", err)
}
root := filepath.Join(tasksBase, "task-1")
if got, err := os.ReadFile(filepath.Join(root, "notes", "note.txt")); err != nil || string(got) != "before" {
t.Fatalf("live entry = %q, %v", got, err)
}
if err := os.WriteFile(filepath.Join(source, "note.txt"), []byte("after"), 0o644); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(root, "notes", "note.txt")); string(got) != "after" {
t.Fatalf("entry is not live: %q", got)
}
env, err = repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil || env.WorkspacePath != root {
t.Fatalf("workspace path = %q, %v; want %q", env.WorkspacePath, err, root)
}
if len(rescan.calls) != 1 || rescan.calls[0].workDir != root {
t.Fatalf("rescan calls = %+v", rescan.calls)
}
if result.WorkspacePath != root || len(result.SessionIDs) != 1 || result.SessionIDs[0] != "session-1" {
t.Fatalf("materialization result = %#v", result)
}
}
func TestWorkspaceSourceMaterializer_LocalFolderUsesPersistedBatchSourceOnlyOnce(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
tasksBase := filepath.Join(canonicalTempDir(t), "tasks")
mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1"))
source := filepath.Join(t.TempDir(), "notes")
if err := os.MkdirAll(source, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "note.txt"), []byte("persisted"), 0o644); err != nil {
t.Fatal(err)
}
seedWorkspaceSourceTask(t, repo, source)
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatal(err)
}
env.ExecutorType = "local_pc"
if err := repo.UpdateTaskEnvironment(ctx, env); err != nil {
t.Fatal(err)
}
batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{
Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source},
}}}
if err := repo.CreateWorkspaceSourceBatch(ctx, batch); err != nil {
t.Fatal(err)
}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, logger: newTestLogger()}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err != nil {
t.Fatalf("MaterializeWorkspaceSources after persistence: %v", err)
}
if got, err := os.ReadFile(filepath.Join(tasksBase, "task-1", "notes", "note.txt")); err != nil || string(got) != "persisted" {
t.Fatalf("persisted folder entry = %q, %v", got, err)
}
}
func TestWorkspaceSourceMaterializer_LocalPromotionPreservesPrimaryRepository(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
tasksBase := filepath.Join(canonicalTempDir(t), "tasks")
mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1"))
primary := filepath.Join(t.TempDir(), "primary")
folder := filepath.Join(t.TempDir(), "notes")
for _, path := range []string{primary, folder} {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(primary, "repo.txt"), []byte("repo"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(folder, "note.txt"), []byte("note"), 0o644); err != nil {
t.Fatal(err)
}
seedWorkspaceSourceTask(t, repo, primary)
if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "primary", LocalPath: primary, DefaultBranch: "main"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-primary", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main", Metadata: map[string]interface{}{}}); err != nil {
t.Fatal(err)
}
rescan := &workspaceSourceRescanStub{}
m := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescan, logger: newTestLogger()}
batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: folder}}}}
if _, err := m.MaterializeWorkspaceSources(ctx, "task-1", batch); err != nil {
t.Fatal(err)
}
root := filepath.Join(tasksBase, "task-1")
for _, file := range []string{"primary/repo.txt", "notes/note.txt"} {
if _, err := os.ReadFile(filepath.Join(root, file)); err != nil {
t.Fatalf("missing promoted source %s: %v", file, err)
}
}
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil || env.WorkspacePath != root {
t.Fatalf("workspace path = %q, %v", env.WorkspacePath, err)
}
if len(rescan.calls) != 1 || rescan.calls[0].workDir != root {
t.Fatalf("rescan calls = %+v", rescan.calls)
}
}
func TestWorkspaceSourceMaterializer_LocalClonesProviderRepositoryBeforeLinking(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
tasksBase := filepath.Join(canonicalTempDir(t), "tasks")
mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1"))
seedWorkspaceSourceTask(t, repo, t.TempDir())
clonePath := filepath.Join(canonicalTempDir(t), "cloned")
if err := os.MkdirAll(clonePath, 0o755); err != nil {
t.Fatal(err)
}
if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-remote", WorkspaceID: "ws-1", Name: "remote", Provider: "github", ProviderOwner: "acme", ProviderName: "remote"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "task-repo-remote", TaskID: "task-1", RepositoryID: "repo-remote", BaseBranch: "main", Position: 0, Metadata: map[string]interface{}{}}); err != nil {
t.Fatal(err)
}
cloner := &hostRepositoryClonerStub{path: clonePath}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, hostCloner: cloner, logger: newTestLogger()}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1"}); err != nil {
t.Fatalf("MaterializeWorkspaceSources: %v", err)
}
if len(cloner.calls) != 1 || cloner.calls[0].ID != "repo-remote" {
t.Fatalf("clone calls = %+v", cloner.calls)
}
if cloner.taskID != "task-1" || cloner.sessionID != "session-1" {
t.Fatalf("clone scope = task %q session %q", cloner.taskID, cloner.sessionID)
}
if got, err := os.Readlink(filepath.Join(tasksBase, "task-1", "remote")); err != nil || got != clonePath {
t.Fatalf("repository link = %q, %v; want %q", got, err, clonePath)
}
}
func TestWorkspaceSourceMaterializer_WorktreeAddsLiveFolderAtTaskRoot(t *testing.T) {
ctx := context.Background()
repoPath, taskRoot, primary := setupMaterializerScenario(t)
repo := newMaterializerRepo(t)
seedMaterializerTask(t, ctx, repo, repoPath, taskRoot, primary)
folder := filepath.Join(t.TempDir(), "notes")
if err := os.MkdirAll(folder, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(folder, "note.txt"), []byte("live"), 0o644); err != nil {
t.Fatal(err)
}
rescan := &workspaceSourceRescanStub{}
m := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, taskRoot), rescanner: rescan, logger: newTestLogger()}
batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: folder}}}}
if _, err := m.MaterializeWorkspaceSources(ctx, "task-1", batch); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(filepath.Join(taskRoot, "notes", "note.txt")); err != nil || string(got) != "live" {
t.Fatalf("worktree folder link = %q, %v", got, err)
}
if len(rescan.calls) != 1 || rescan.calls[0].workDir != taskRoot {
t.Fatalf("rescan calls = %+v", rescan.calls)
}
}
func TestWorkspaceSourceMaterializer_RollsBackLinkAndPathWhenAdoptionFails(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
tasksBase := filepath.Join(t.TempDir(), "tasks")
mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1"))
source := filepath.Join(t.TempDir(), "notes")
if err := os.MkdirAll(source, 0o755); err != nil {
t.Fatal(err)
}
seedWorkspaceSourceTask(t, repo, source)
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: &workspaceSourceRescanStub{err: os.ErrPermission}, logger: newTestLogger()}
batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source}}}}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err == nil {
t.Fatal("MaterializeWorkspaceSources succeeded despite failed adoption")
}
if _, err := os.Lstat(filepath.Join(tasksBase, "task-1", "notes")); !os.IsNotExist(err) {
t.Fatalf("created link remains after rollback: %v", err)
}
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil || env.WorkspacePath != source {
t.Fatalf("workspace path = %q, %v; want original %q", env.WorkspacePath, err, source)
}
}
func TestRollbackOwnedDirectoryLinkPreservesReplacementFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "notes")
const contents = "user replacement"
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
err := rollbackOwnedDirectoryLink(ownedDirectoryLinkUndo{Path: path})
if err == nil {
t.Fatal("rollbackOwnedDirectoryLink removed a replacement file")
}
if got, readErr := os.ReadFile(path); readErr != nil || string(got) != contents {
t.Fatalf("replacement file = %q, %v; want it preserved", got, readErr)
}
}
func TestWorkspaceSourceMaterializer_RestoresRepointedLinkWhenAdoptionFails(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
tasksBase := filepath.Join(canonicalTempDir(t), "tasks")
root := filepath.Join(tasksBase, "task-1")
mgr := newMaterializerWorktreeMgr(t, root)
original := filepath.Join(canonicalTempDir(t), "original-notes")
replacement := filepath.Join(canonicalTempDir(t), "replacement-notes")
for path, content := range map[string]string{original: "before", replacement: "after"} {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "note.txt"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
seedWorkspaceSourceTask(t, repo, original)
if _, err := worktree.CreateOwnedDirectoryLink(root, "notes", original); err != nil {
t.Fatalf("seed owned directory link: %v", err)
}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: &workspaceSourceRescanStub{err: os.ErrPermission}, logger: newTestLogger()}
batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: replacement}}}}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err == nil {
t.Fatal("MaterializeWorkspaceSources succeeded despite failed adoption")
}
if got, err := os.ReadFile(filepath.Join(root, "notes", "note.txt")); err != nil || string(got) != "before" {
t.Fatalf("repointed link after rollback = %q, %v; want original target restored", got, err)
}
}
func TestWorkspaceSourceMaterializer_RebindFailureRestoresEarlierSessionsInReverseOrder(t *testing.T) {
ctx := context.Background()
repo := newMaterializerRepo(t)
tasksBase := filepath.Join(canonicalTempDir(t), "tasks")
mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1"))
source := filepath.Join(canonicalTempDir(t), "notes")
if err := os.MkdirAll(source, 0o755); err != nil {
t.Fatal(err)
}
seedWorkspaceSourceTask(t, repo, source)
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-2", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
rescan := &orderedWorkspaceRebindStub{failOnCall: 2}
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescan, logger: newTestLogger()}
batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source}}}}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err == nil {
t.Fatal("MaterializeWorkspaceSources succeeded despite second rebind failure")
}
root := filepath.Join(tasksBase, "task-1")
if len(rescan.calls) != 3 {
t.Fatalf("rebind calls = %+v, want two adoptions and one restore", rescan.calls)
}
if rescan.calls[0].workDir != root || rescan.calls[1].workDir != root || rescan.calls[0].sessionID == rescan.calls[1].sessionID {
t.Fatalf("adoption calls = %+v, want distinct sessions at %q", rescan.calls[:2], root)
}
if !reflect.DeepEqual(rescan.calls[2], stubRescanCall{sessionID: rescan.calls[0].sessionID, workDir: source}) {
t.Fatalf("restore call = %+v, want reverse restoration to %q", rescan.calls[2], source)
}
if len(rescan.roots) != 3 || !reflect.DeepEqual(rescan.roots[0], []string{source}) || !reflect.DeepEqual(rescan.roots[1], []string{source}) || len(rescan.roots[2]) != 0 {
t.Fatalf("authoritative roots = %+v, want post-state roots followed by prior roots", rescan.roots)
}
}
func TestWorkspaceSourceMaterializer_WorktreeLateFolderFailureEmitsNoMaterializedEvent(t *testing.T) {
ctx := context.Background()
repoPath, taskRoot, primaryPath := setupMaterializerScenario(t)
repo := newMaterializerRepo(t)
seedMaterializerTask(t, ctx, repo, repoPath, taskRoot, primaryPath)
branch := &models.TaskRepository{ID: "tr-branch-2", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "branch-2", Position: 1, Metadata: map[string]interface{}{}}
if err := repo.CreateTaskRepository(ctx, branch); err != nil {
t.Fatal(err)
}
rescanner := &stubRescanner{}
materializer := &workspaceSourceMaterializer{
repo: repo,
worktreeMgr: newMaterializerWorktreeMgr(t, taskRoot),
branches: &branchMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, taskRoot), rescanner: rescanner, logger: newTestLogger()},
logger: newTestLogger(),
}
batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{
{Repository: branch},
{Folder: &models.TaskWorkspaceFolder{DisplayName: "duplicate", LocalPath: t.TempDir()}},
{Folder: &models.TaskWorkspaceFolder{DisplayName: "duplicate", LocalPath: t.TempDir()}},
}}
if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err == nil {
t.Fatal("MaterializeWorkspaceSources succeeded despite late folder failure")
}
if len(rescanner.notifyCalls) != 0 {
t.Fatalf("materialized events = %+v, want none", rescanner.notifyCalls)
}
}
type workspaceSourceRescanStub struct {
calls []stubRescanCall
err error
}
type orderedWorkspaceRebindStub struct {
calls []stubRescanCall
roots [][]string
failOnCall int
}
func (s *orderedWorkspaceRebindStub) RebindWorkspaceForSession(_ context.Context, id, dir string, sourceRoots ...[]string) error {
s.calls = append(s.calls, stubRescanCall{sessionID: id, workDir: dir})
if len(sourceRoots) == 1 {
s.roots = append(s.roots, append([]string(nil), sourceRoots[0]...))
}
if len(s.calls) == s.failOnCall {
return errors.New("rebind failed")
}
return nil
}
func (s *workspaceSourceRescanStub) RebindWorkspaceForSession(_ context.Context, id, dir string, _ ...[]string) error {
s.calls = append(s.calls, stubRescanCall{sessionID: id, workDir: dir})
return s.err
}
func seedWorkspaceSourceTask(t *testing.T, repo *sqliterepo.Repository, source string) {
t.Helper()
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Sources", Priority: "medium"}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-1", TaskID: "task-1", ExecutorType: string(models.ExecutorTypeLocal), Status: models.TaskEnvironmentStatusReady, TaskDirName: "task-1", WorkspacePath: source, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
func seedRemoteMaterializerRepositories(t *testing.T, repo *sqliterepo.Repository) {
t.Helper()
ctx := context.Background()
for _, repository := range []*models.Repository{
{ID: "repo-primary", WorkspaceID: "ws-1", Name: "primary", RemoteURL: "https://github.com/acme/primary.git"},
{ID: "repo-added", WorkspaceID: "ws-1", Name: "added", RemoteURL: "https://github.com/acme/added.git"},
} {
if err := repo.CreateRepository(ctx, repository); err != nil {
t.Fatal(err)
}
}
for _, taskRepository := range []*models.TaskRepository{
{ID: "task-repo-primary", TaskID: "task-1", RepositoryID: "repo-primary", BaseBranch: "main", Position: 0, Metadata: map[string]interface{}{}},
{ID: "task-repo-added", TaskID: "task-1", RepositoryID: "repo-added", BaseBranch: "main", Position: 1, Metadata: map[string]interface{}{}},
} {
if err := repo.CreateTaskRepository(ctx, taskRepository); err != nil {
t.Fatal(err)
}
}
}