From 25f3a19dfe3dede7fc4db6e7be31203fd9f49490 Mon Sep 17 00:00:00 2001 From: black-fe Date: Wed, 15 Apr 2026 17:51:11 +0800 Subject: [PATCH] fix(daemon): refresh workspace repos on checkout miss --- packages/views/onboarding/step-complete.tsx | 2 +- .../settings/components/repositories-tab.tsx | 4 +- server/cmd/server/router.go | 1 + server/internal/daemon/client.go | 19 +- server/internal/daemon/daemon.go | 155 ++++++++++++++-- server/internal/daemon/daemon_test.go | 175 ++++++++++++++++++ server/internal/daemon/health.go | 15 ++ server/internal/handler/daemon.go | 92 ++++++++- server/internal/handler/daemon_test.go | 124 +++++++++++++ 9 files changed, 561 insertions(+), 26 deletions(-) diff --git a/packages/views/onboarding/step-complete.tsx b/packages/views/onboarding/step-complete.tsx index fc8936c00..cc27c7e90 100644 --- a/packages/views/onboarding/step-complete.tsx +++ b/packages/views/onboarding/step-complete.tsx @@ -46,7 +46,7 @@ function getOnboardingIssues(): OnboardingIssueDef[] { "", "**Steps:**", "1. Go to **Settings** in the sidebar", - "2. Under **Repositories**, add a GitHub repo URL", + "2. Under **Repositories**, add a Git repository URL", "3. The agent daemon will sync the repo locally", "", "Once connected, your agents can clone, branch, and push code as part of any task.", diff --git a/packages/views/settings/components/repositories-tab.tsx b/packages/views/settings/components/repositories-tab.tsx index e414be6be..4d406b8b2 100644 --- a/packages/views/settings/components/repositories-tab.tsx +++ b/packages/views/settings/components/repositories-tab.tsx @@ -67,7 +67,7 @@ export function RepositoriesTab() {

- GitHub repositories associated with this workspace. Agents use these to clone and work on code. + Git repositories associated with this workspace. Agents use these to clone and work on code.

{repos.map((repo, index) => ( @@ -78,7 +78,7 @@ export function RepositoriesTab() { value={repo.url} onChange={(e) => handleRepoChange(index, "url", e.target.value)} disabled={!canManageWorkspace} - placeholder="https://github.com/org/repo" + placeholder="https://git.example.com/org/repo.git" className="text-sm" /> 0 { - go func(wsID string, repos []RepoData) { - if err := d.repoCache.Sync(wsID, repoDataToInfo(repos)); err != nil { - d.logger.Warn("repo cache sync failed", "workspace_id", wsID, "error", err) - } - }(ws.ID, resp.Repos) + go d.syncWorkspaceRepos(ws.ID, resp.Repos) } d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(resp.Runtimes), "repos", len(resp.Repos)) @@ -276,6 +282,131 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s return resp, nil } +func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData) *workspaceState { + return &workspaceState{ + workspaceID: workspaceID, + runtimeIDs: runtimeIDs, + reposVersion: reposVersion, + allowedRepoURLs: repoAllowlist(repos), + } +} + +func repoAllowlist(repos []RepoData) map[string]struct{} { + allowed := make(map[string]struct{}, len(repos)) + for _, repo := range repos { + if repo.URL == "" { + continue + } + allowed[repo.URL] = struct{}{} + } + return allowed +} + +func (d *Daemon) setWorkspaceRepoSyncError(workspaceID, syncErr string) { + d.mu.Lock() + defer d.mu.Unlock() + if ws, ok := d.workspaces[workspaceID]; ok { + ws.lastRepoSyncErr = syncErr + } +} + +func (d *Daemon) workspaceRepoAllowed(workspaceID, repoURL string) bool { + d.mu.Lock() + defer d.mu.Unlock() + ws, ok := d.workspaces[workspaceID] + if !ok { + return false + } + _, allowed := ws.allowedRepoURLs[repoURL] + return allowed +} + +func (d *Daemon) workspaceLastRepoSyncErr(workspaceID string) string { + d.mu.Lock() + defer d.mu.Unlock() + ws, ok := d.workspaces[workspaceID] + if !ok { + return "" + } + return ws.lastRepoSyncErr +} + +func (d *Daemon) syncWorkspaceRepos(workspaceID string, repos []RepoData) { + if d.repoCache == nil { + return + } + if err := d.repoCache.Sync(workspaceID, repoDataToInfo(repos)); err != nil { + d.setWorkspaceRepoSyncError(workspaceID, err.Error()) + d.logger.Warn("repo cache sync failed", "workspace_id", workspaceID, "error", err) + return + } + d.setWorkspaceRepoSyncError(workspaceID, "") +} + +func (d *Daemon) refreshWorkspaceRepos(ctx context.Context, workspaceID string) (*WorkspaceReposResponse, error) { + refreshCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := d.client.GetWorkspaceRepos(refreshCtx, workspaceID) + if err != nil { + return nil, err + } + + d.mu.Lock() + if ws, ok := d.workspaces[workspaceID]; ok { + ws.reposVersion = resp.ReposVersion + ws.allowedRepoURLs = repoAllowlist(resp.Repos) + } + d.mu.Unlock() + + return resp, nil +} + +func (d *Daemon) ensureRepoReady(ctx context.Context, workspaceID, repoURL string) error { + if d.repoCache == nil { + return fmt.Errorf("repo cache not initialized") + } + + d.mu.Lock() + ws, ok := d.workspaces[workspaceID] + d.mu.Unlock() + if !ok { + return fmt.Errorf("workspace is not watched by this daemon: %s", workspaceID) + } + + if d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.Lookup(workspaceID, repoURL) != "" { + return nil + } + + ws.repoRefreshMu.Lock() + defer ws.repoRefreshMu.Unlock() + + if d.workspaceRepoAllowed(workspaceID, repoURL) && d.repoCache.Lookup(workspaceID, repoURL) != "" { + return nil + } + + resp, err := d.refreshWorkspaceRepos(ctx, workspaceID) + if err != nil { + return fmt.Errorf("refresh workspace repos: %w", err) + } + + if !d.workspaceRepoAllowed(workspaceID, repoURL) { + return ErrRepoNotConfigured + } + + d.syncWorkspaceRepos(workspaceID, resp.Repos) + + if d.repoCache.Lookup(workspaceID, repoURL) != "" { + return nil + } + + if syncErr := d.workspaceLastRepoSyncErr(workspaceID); syncErr != "" { + return fmt.Errorf("repo is configured but not synced: %s", syncErr) + } + + return fmt.Errorf("repo is configured but not synced") +} + // configWatchLoop periodically checks for config file changes and reloads workspaces. func (d *Daemon) configWatchLoop(ctx context.Context) { configPath, err := cli.CLIConfigPathForProfile(d.cfg.Profile) @@ -405,7 +536,7 @@ func (d *Daemon) reloadWorkspaces(ctx context.Context) { runtimeIDs[i] = rt.ID } d.mu.Lock() - d.workspaces[id] = &workspaceState{workspaceID: id, runtimeIDs: runtimeIDs} + d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos) for _, rt := range resp.Runtimes { d.runtimeIndex[rt.ID] = rt } @@ -413,11 +544,7 @@ func (d *Daemon) reloadWorkspaces(ctx context.Context) { // Sync workspace repos to local cache in the background. if d.repoCache != nil && len(resp.Repos) > 0 { - go func(wsID string, repos []RepoData) { - if err := d.repoCache.Sync(wsID, repoDataToInfo(repos)); err != nil { - d.logger.Warn("repo cache sync failed", "workspace_id", wsID, "error", err) - } - }(id, resp.Repos) + go d.syncWorkspaceRepos(id, resp.Repos) } d.logger.Info("now watching workspace", "workspace_id", id, "name", name) diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 6a416692b..014f18285 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -2,16 +2,42 @@ package daemon import ( "context" + "encoding/json" + "errors" "log/slog" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" "strings" + "sync" "sync/atomic" "testing" + "github.com/multica-ai/multica/server/internal/daemon/repocache" "github.com/multica-ai/multica/server/pkg/agent" ) +func createDaemonTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, args := range [][]string{ + {"init", dir}, + {"-C", dir, "commit", "--allow-empty", "-m", "initial"}, + } { + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git setup failed: %s: %v", out, err) + } + } + return dir +} + func TestNormalizeServerBaseURL(t *testing.T) { t.Parallel() @@ -198,6 +224,19 @@ func newTestDaemon(t *testing.T) *Daemon { } } +func newRepoReadyTestDaemon(t *testing.T, handler http.HandlerFunc) *Daemon { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return &Daemon{ + client: NewClient(srv.URL), + repoCache: repocache.New(t.TempDir(), slog.Default()), + logger: slog.Default(), + workspaces: make(map[string]*workspaceState), + runtimeIndex: make(map[string]Runtime), + } +} + func TestExecuteAndDrain_ResumeFailureFallback(t *testing.T) { t.Parallel() @@ -280,3 +319,139 @@ func TestExecuteAndDrain_NoRetryWhenSessionEstablished(t *testing.T) { t.Fatalf("expected 1 call, got %d", fb.idx.Load()) } } + +func TestEnsureRepoReadyFastPathDoesNotRefresh(t *testing.T) { + t.Parallel() + + sourceRepo := createDaemonTestRepo(t) + var refreshCalls atomic.Int32 + d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) { + refreshCalls.Add(1) + http.Error(w, "unexpected refresh", http.StatusInternalServerError) + }) + if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil { + t.Fatalf("seed repo cache: %v", err) + } + d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}) + + if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil { + t.Fatalf("ensureRepoReady: %v", err) + } + if got := refreshCalls.Load(); got != 0 { + t.Fatalf("expected no refresh calls, got %d", got) + } +} + +func TestEnsureRepoReadyRefreshesOnMiss(t *testing.T) { + t.Parallel() + + sourceRepo := createDaemonTestRepo(t) + var refreshCalls atomic.Int32 + d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/daemon/workspaces/ws-1/repos" { + http.NotFound(w, r) + return + } + refreshCalls.Add(1) + json.NewEncoder(w).Encode(WorkspaceReposResponse{ + WorkspaceID: "ws-1", + Repos: []RepoData{{URL: sourceRepo, Description: "repo"}}, + ReposVersion: "v2", + }) + }) + d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil) + + if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil { + t.Fatalf("ensureRepoReady: %v", err) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("expected 1 refresh call, got %d", got) + } + if d.repoCache.Lookup("ws-1", sourceRepo) == "" { + t.Fatal("expected repo to be cached after refresh") + } +} + +func TestEnsureRepoReadyReturnsNotConfigured(t *testing.T) { + t.Parallel() + + d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(WorkspaceReposResponse{ + WorkspaceID: "ws-1", + Repos: []RepoData{}, + ReposVersion: "v1", + }) + }) + d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil) + + err := d.ensureRepoReady(context.Background(), "ws-1", "git@example.com:team/api.git") + if !errors.Is(err, ErrRepoNotConfigured) { + t.Fatalf("expected ErrRepoNotConfigured, got %v", err) + } +} + +func TestEnsureRepoReadyReportsSyncFailure(t *testing.T) { + t.Parallel() + + missingRepo := filepath.Join(t.TempDir(), "missing-repo") + d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(WorkspaceReposResponse{ + WorkspaceID: "ws-1", + Repos: []RepoData{{URL: missingRepo, Description: "missing"}}, + ReposVersion: "v1", + }) + }) + d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil) + + err := d.ensureRepoReady(context.Background(), "ws-1", missingRepo) + if err == nil || !strings.Contains(err.Error(), "repo is configured but not synced:") { + t.Fatalf("expected sync failure error, got %v", err) + } + if got := d.workspaceLastRepoSyncErr("ws-1"); got == "" { + t.Fatal("expected lastRepoSyncErr to be recorded") + } +} + +func TestEnsureRepoReadyConcurrentMissRefreshesOnce(t *testing.T) { + t.Parallel() + + sourceRepo := createDaemonTestRepo(t) + var refreshCalls atomic.Int32 + d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/daemon/workspaces/ws-1/repos" { + http.NotFound(w, r) + return + } + refreshCalls.Add(1) + json.NewEncoder(w).Encode(WorkspaceReposResponse{ + WorkspaceID: "ws-1", + Repos: []RepoData{{URL: sourceRepo, Description: "repo"}}, + ReposVersion: "v2", + }) + }) + d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil) + + const concurrency = 8 + var wg sync.WaitGroup + errCh := make(chan error, concurrency) + for range concurrency { + wg.Add(1) + go func() { + defer wg.Done() + errCh <- d.ensureRepoReady(context.Background(), "ws-1", sourceRepo) + }() + } + wg.Wait() + close(errCh) + + for err := range errCh { + if err != nil { + t.Fatalf("ensureRepoReady returned error: %v", err) + } + } + // All 8 goroutines race on a cold miss; the per-workspace mutex + // must serialize them so the server is only called once. + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("expected exactly 1 refresh call, got %d", got) + } +} diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go index 4f06d6e78..d2ac1d735 100644 --- a/server/internal/daemon/health.go +++ b/server/internal/daemon/health.go @@ -3,6 +3,7 @@ package daemon import ( "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -99,6 +100,10 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim http.Error(w, "url is required", http.StatusBadRequest) return } + if req.WorkspaceID == "" { + http.Error(w, "workspace_id is required", http.StatusBadRequest) + return + } if req.WorkDir == "" { http.Error(w, "workdir is required", http.StatusBadRequest) return @@ -109,6 +114,16 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim return } + if err := d.ensureRepoReady(r.Context(), req.WorkspaceID, req.URL); err != nil { + statusCode := http.StatusInternalServerError + if errors.Is(err, ErrRepoNotConfigured) { + statusCode = http.StatusBadRequest + } + d.logger.Error("repo checkout readiness failed", "workspace_id", req.WorkspaceID, "url", req.URL, "error", err) + http.Error(w, err.Error(), statusCode) + return + } + result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{ WorkspaceID: req.WorkspaceID, RepoURL: req.URL, diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 0bf2ea8d7..e8d0d8968 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -1,10 +1,13 @@ package handler import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log/slog" "net/http" + "sort" "strconv" "strings" @@ -125,6 +128,70 @@ type DaemonRegisterRequest struct { } `json:"runtimes"` } +type daemonWorkspaceReposResponse struct { + WorkspaceID string `json:"workspace_id"` + Repos []RepoData `json:"repos"` + ReposVersion string `json:"repos_version"` +} + +func normalizeWorkspaceRepos(repos []RepoData) []RepoData { + if len(repos) == 0 { + return []RepoData{} + } + + normalized := make([]RepoData, 0, len(repos)) + seen := make(map[string]struct{}, len(repos)) + for _, repo := range repos { + url := strings.TrimSpace(repo.URL) + if url == "" { + continue + } + if _, exists := seen[url]; exists { + continue + } + seen[url] = struct{}{} + normalized = append(normalized, RepoData{ + URL: url, + Description: strings.TrimSpace(repo.Description), + }) + } + return normalized +} + +func workspaceReposVersion(repos []RepoData) string { + urls := make([]string, 0, len(repos)) + for _, repo := range repos { + if repo.URL == "" { + continue + } + urls = append(urls, repo.URL) + } + sort.Strings(urls) + sum := sha256.Sum256([]byte(strings.Join(urls, "\n"))) + return hex.EncodeToString(sum[:]) +} + +func parseWorkspaceRepos(raw []byte) []RepoData { + if len(raw) == 0 { + return []RepoData{} + } + + var repos []RepoData + if err := json.Unmarshal(raw, &repos); err != nil { + return []RepoData{} + } + return normalizeWorkspaceRepos(repos) +} + +func workspaceReposResponse(workspaceID string, raw []byte) daemonWorkspaceReposResponse { + repos := parseWorkspaceRepos(raw) + return daemonWorkspaceReposResponse{ + WorkspaceID: workspaceID, + Repos: repos, + ReposVersion: workspaceReposVersion(repos), + } +} + func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { var req DaemonRegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -248,16 +315,27 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { "runtimes": resp, }) - // Include workspace repos so the daemon can cache them locally. - var repos []RepoData - if ws.Repos != nil { - json.Unmarshal(ws.Repos, &repos) + repoResp := workspaceReposResponse(req.WorkspaceID, ws.Repos) + writeJSON(w, http.StatusOK, map[string]any{ + "runtimes": resp, + "repos": repoResp.Repos, + "repos_version": repoResp.ReposVersion, + }) +} + +func (h *Handler) GetDaemonWorkspaceRepos(w http.ResponseWriter, r *http.Request) { + workspaceID := strings.TrimSpace(chi.URLParam(r, "workspaceId")) + if !h.requireDaemonWorkspaceAccess(w, r, workspaceID) { + return } - if repos == nil { - repos = []RepoData{} + + ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID)) + if err != nil { + writeError(w, http.StatusNotFound, "workspace not found") + return } - writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp, "repos": repos}) + writeJSON(w, http.StatusOK, workspaceReposResponse(workspaceID, ws.Repos)) } // DaemonDeregister marks runtimes as offline when the daemon shuts down. diff --git a/server/internal/handler/daemon_test.go b/server/internal/handler/daemon_test.go index eb700a99f..70d92c74e 100644 --- a/server/internal/handler/daemon_test.go +++ b/server/internal/handler/daemon_test.go @@ -6,12 +6,29 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/go-chi/chi/v5" "github.com/multica-ai/multica/server/internal/middleware" ) +func setHandlerTestWorkspaceRepos(t *testing.T, repos []map[string]string) { + t.Helper() + data, err := json.Marshal(repos) + if err != nil { + t.Fatalf("marshal repos: %v", err) + } + if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, data, testWorkspaceID); err != nil { + t.Fatalf("update workspace repos: %v", err) + } + t.Cleanup(func() { + if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte("[]"), testWorkspaceID); err != nil { + t.Fatalf("reset workspace repos: %v", err) + } + }) +} + // newDaemonTokenRequest creates an HTTP request with daemon token context set // (simulating DaemonAuth middleware for mdt_ tokens). func newDaemonTokenRequest(method, path string, body any, workspaceID, daemonID string) *http.Request { @@ -52,6 +69,9 @@ func TestDaemonRegister_WithDaemonToken(t *testing.T) { if !ok || len(runtimes) == 0 { t.Fatalf("DaemonRegister: expected runtimes in response, got %v", resp) } + if _, ok := resp["repos_version"].(string); !ok { + t.Fatalf("DaemonRegister: expected repos_version in response, got %v", resp) + } // Clean up: deregister the runtime. rt := runtimes[0].(map[string]any) @@ -180,3 +200,107 @@ func TestGetTaskStatus_WithDaemonToken_CrossWorkspace(t *testing.T) { t.Fatalf("GetTaskStatus with correct workspace token: expected 200, got %d: %s", w.Code, w.Body.String()) } } + +func TestGetDaemonWorkspaceRepos_WithDaemonToken(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + + setHandlerTestWorkspaceRepos(t, []map[string]string{ + {"url": "git@example.com:team/api.git", "description": "API"}, + {"url": " git@example.com:team/web.git ", "description": " Web "}, + }) + + w := httptest.NewRecorder() + req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, testWorkspaceID, "test-daemon-mdt") + req = withURLParam(req, "workspaceId", testWorkspaceID) + + testHandler.GetDaemonWorkspaceRepos(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GetDaemonWorkspaceRepos: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + WorkspaceID string `json:"workspace_id"` + Repos []map[string]string `json:"repos"` + ReposVersion string `json:"repos_version"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.WorkspaceID != testWorkspaceID { + t.Fatalf("expected workspace_id %s, got %s", testWorkspaceID, resp.WorkspaceID) + } + if len(resp.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(resp.Repos)) + } + if resp.Repos[1]["url"] != "git@example.com:team/web.git" { + t.Fatalf("expected trimmed repo URL, got %q", resp.Repos[1]["url"]) + } + if resp.ReposVersion == "" { + t.Fatal("expected repos_version to be set") + } +} + +func TestGetDaemonWorkspaceRepos_WithDaemonToken_WorkspaceMismatch(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + + w := httptest.NewRecorder() + req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, "00000000-0000-0000-0000-000000000000", "test-daemon-mdt") + req = withURLParam(req, "workspaceId", testWorkspaceID) + + testHandler.GetDaemonWorkspaceRepos(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("GetDaemonWorkspaceRepos with mismatched workspace: expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetDaemonWorkspaceRepos_VersionIgnoresOrderAndDescription(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + + setHandlerTestWorkspaceRepos(t, []map[string]string{ + {"url": "git@example.com:team/api.git", "description": "API"}, + {"url": "git@example.com:team/web.git", "description": "Web"}, + }) + + getReposVersion := func() string { + t.Helper() + w := httptest.NewRecorder() + req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, testWorkspaceID, "test-daemon-mdt") + req = withURLParam(req, "workspaceId", testWorkspaceID) + testHandler.GetDaemonWorkspaceRepos(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GetDaemonWorkspaceRepos: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + ReposVersion string `json:"repos_version"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + return resp.ReposVersion + } + + version1 := getReposVersion() + + if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte(`[{"url":"git@example.com:team/web.git","description":"frontend"},{"url":"git@example.com:team/api.git","description":"backend"}]`), testWorkspaceID); err != nil { + t.Fatalf("update workspace repos: %v", err) + } + version2 := getReposVersion() + if version1 != version2 { + t.Fatalf("expected repos_version to ignore order/description changes, got %s vs %s", version1, version2) + } + + if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte(`[{"url":"git@example.com:team/api.git","description":"backend"},{"url":"git@example.com:team/mobile.git","description":"mobile"}]`), testWorkspaceID); err != nil { + t.Fatalf("update workspace repos: %v", err) + } + version3 := getReposVersion() + if strings.EqualFold(version2, version3) { + t.Fatalf("expected repos_version to change when URL set changes, got %s", version3) + } +}