mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
* feat(daemon): add worktree_pool mode for local_directory (MUL-3483) ## What changed Squad workflows bound to the same `local_directory` resource used to serialise on a single path mutex — a documented pain point from GitHub issue #4377. This introduces an opt-in `worktree_pool` mode on the `local_directory` project resource. When enabled, each task gets its own `git worktree add` under a daemon-managed pool root, so sibling tasks on the same base repo now run truly in parallel while `git worktree add/remove/prune` stays serialised behind a per-repo mutex. ## Shape - `local_directory.resource_ref` gains three optional fields: `mode` ("in_place" default / "worktree_pool"), `pool_root` (defaults to `<parent>/.multica-worktrees/<base>`), `max_parallel` (defaults to 4). Legacy rows are byte-identical after round-trip: the server validator strips the pool fields on the default in_place path so older clients keep behaving exactly as before. - New `WorktreePoolManager` (`server/internal/daemon/worktree_pool.go`) owns pool allocation, per-repo git-metadata mutex, and cleanup. - `acquireLocalDirectoryLockIfNeeded` now branches on the ref's mode. in_place stays on `LocalPathLocker` and the shared tree; worktree_pool routes through the pool manager, publishes a lease keyed by task ID, and pins the agent to the freshly allocated worktree in `execenv.PrepareParams.LocalWorkDir`. - Pool saturation is a structured wait_reason (`worktree_pool saturated (N/M) on <path> (holders: ...)`), retrying on the existing cancel-poll interval — same UX as the historical path-mutex wait. ## Safety guardrails (also known footguns from prior art) - Repos with initialised submodules are refused up front. Multi-checkout of a superproject is explicitly unsupported by `git worktree(1)` BUGS and the per-worktree `modules/` directories bloat disk by pool size ×. - Dirty worktrees are NEVER `--force` removed on release. If the agent left uncommitted changes behind we keep the directory (and free the slot) so users can inspect. This is the failure mode claude-code#55724 documented and the pool must not regress into. - The per-repo mutex covers every `git worktree add/remove/prune` and `submodule status` invocation for a given base, matching the in-process-queue fix Anthropic settled on for claude-code#34645 (`.git/config.lock` races on concurrent add). - Task UUID is the source of truth for both branch (`multica/<uuid>`) and worktree path (`<pool_root>/<uuid>`) so a single agent running multiple worker tasks in parallel can never collide. - Non-empty leftover directories at the target path abort the allocation instead of silently starting the agent in an unknown state. ## Explicit MVP non-goals (deferred, tracked as follow-up work) - Windows worktree-remove retry (permission-denied on locked handles). - Detached-HEAD fast path for read-only exploration tasks. - `post-checkout` hook opt-out / serialisation. - Automatic `git lfs install`. - UI surfacing of the pool state / dirty worktree list. ## Tests - `worktree_pool_test.go` (new): full acquire→release lifecycle, parallel allocation, saturation with holder list, slot re-use after release, dirty-worktree preservation, concurrent-acquire serialisation (the config.lock guard), submodule refusal, missing base rejection, pool root auto-mkdir, non-empty leftover refusal, ctx cancel. - Handler validator gains three rejection cases (unknown mode, relative pool_root, negative max_parallel) and a round-trip test that pins the normalised JSON shape for both modes. - Daemon `localDirectoryRef` helpers get a defaults test and the pool root path derivation is pinned. ## Wire-compat and rollout - Default off. Existing rows keep the historical shape (no `mode`, `pool_root`, or `max_parallel` in the JSON) and behave exactly as before. - Opt-in via `--ref '{"local_path":"...","daemon_id":"...","mode":"worktree_pool"}'` today. CLI flag shortcuts (`--mode`, `--pool-root`, `--max-parallel`) can follow in a small tail PR — not blocking. - No DB migration. No UI change required. Co-authored-by: multica-agent <github@multica.ai> * feat(daemon): address worktree_pool review nits (MUL-3483) Follow-up to #4986. Three non-blocking review points from GPT-Boy: 1. **Daemon integration test for lease → runTask plumbing.** `TestAcquireLocalDirectory_WorktreePoolPublishesLease` (and its in_place counterpart) pin the exact contract runTask relies on when it reads `d.localLeases.Load(task.ID)` and feeds `lease.WorkDir` into `execenv.PrepareParams.LocalWorkDir`. A future refactor that drops the Store, mistypes the key, or swaps back to `assignment.AbsPath` on the pool branch will now fail here rather than silently defeat the whole point of worktree_pool mode. 2. **Untracked-only dirty case now classifies as dirty.** `worktreeIsDirty` used `--untracked-files=no`, which meant a worktree with only untracked files was reported "clean" and hit the `git worktree remove` branch — git itself would then refuse the removal because the file exists (so no data was lost), but the log path lied about what happened on disk. Switching to `--untracked-files=normal` routes agents' fresh drafts directly through the "leaving on disk for user inspection" branch, and `TestWorktreePool_UntrackedOnlyIsKept` pins the guarantee so nobody quietly reverts the flag later. 3. **Skill doc note on default `pool_root` location.** `multica-projects-and-resources/SKILL.md` now spells out the three new ref fields (`mode`, `pool_root`, `max_parallel`), the default `<parent>/.multica-worktrees/<repo>` location (next to the repo, not inside it), the write-permission requirement on the parent directory, and the submodule restriction — so agents advising self-host users hit the right doc line rather than reading source. Existing test suite still green: - `go vet ./...` clean - `go test ./internal/daemon/... ./internal/handler/... ./internal/service/...` all pass Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
1154 lines
41 KiB
Go
1154 lines
41 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestProjectResourceLifecycle(t *testing.T) {
|
|
// Create a project to attach resources to.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Resource lifecycle project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
defer func() {
|
|
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), req)
|
|
}()
|
|
|
|
// Attach a github_repo resource.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{
|
|
"url": "https://github.com/multica-ai/multica",
|
|
"ref": "release/v2",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProjectResource: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created ProjectResourceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode CreateProjectResource: %v", err)
|
|
}
|
|
if created.ResourceType != "github_repo" {
|
|
t.Errorf("created.ResourceType = %q, want github_repo", created.ResourceType)
|
|
}
|
|
var ref struct {
|
|
URL string `json:"url"`
|
|
Ref string `json:"ref"`
|
|
}
|
|
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
|
|
t.Fatalf("decode resource_ref: %v", err)
|
|
}
|
|
if ref.URL != "https://github.com/multica-ai/multica" {
|
|
t.Errorf("created.ResourceRef.url = %q", ref.URL)
|
|
}
|
|
if ref.Ref != "release/v2" {
|
|
t.Errorf("created.ResourceRef.ref = %q, want release/v2", ref.Ref)
|
|
}
|
|
|
|
// Listing must include the new resource.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.ListProjectResources(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListProjectResources: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var listResp struct {
|
|
Resources []ProjectResourceResponse `json:"resources"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if listResp.Total != 1 || len(listResp.Resources) != 1 {
|
|
t.Fatalf("list returned %d resources, want 1", listResp.Total)
|
|
}
|
|
if listResp.Resources[0].ID != created.ID {
|
|
t.Errorf("list[0].ID = %q, want %q", listResp.Resources[0].ID, created.ID)
|
|
}
|
|
|
|
// Duplicate attach must conflict (UNIQUE on project_id + type + ref).
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{
|
|
"url": "https://github.com/multica-ai/multica",
|
|
"ref": "release/v2",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("duplicate CreateProjectResource: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Invalid URL must reject at the validator level.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{"url": "not-a-url"},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("invalid URL: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Unknown resource_type must reject.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "unknown_type",
|
|
"resource_ref": map[string]any{"foo": "bar"},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("unknown type: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Delete the resource.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/projects/"+project.ID+"/resources/"+created.ID, nil)
|
|
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
|
testHandler.DeleteProjectResource(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteProjectResource: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// After deletion the list should be empty.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.ListProjectResources(w, req)
|
|
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
|
|
t.Fatalf("decode post-delete list: %v", err)
|
|
}
|
|
if listResp.Total != 0 {
|
|
t.Errorf("post-delete list: total = %d, want 0", listResp.Total)
|
|
}
|
|
}
|
|
|
|
// TestProjectResourceAcceptsSSHRepoURLs covers GitHub issue #2484: SSH and
|
|
// scp-like git URLs must be accepted alongside https URLs, because workspace
|
|
// repos configured with an SSH remote previously got rejected when attached
|
|
// to a project.
|
|
func TestProjectResourceAcceptsSSHRepoURLs(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "SSH repo URL acceptance",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
|
r = withURLParam(r, "id", project.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
|
|
cases := []struct {
|
|
name string
|
|
url string
|
|
}{
|
|
{"scp-like", "git@github.com:multica-ai/multica.git"},
|
|
{"ssh-scheme", "ssh://git@github.com/multica-ai/multica.git"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{"url": tc.url},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProjectResource(%s): expected 201, got %d: %s", tc.url, w.Code, w.Body.String())
|
|
}
|
|
var created ProjectResourceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
var ref struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
|
|
t.Fatalf("decode resource_ref: %v", err)
|
|
}
|
|
if ref.URL != tc.url {
|
|
t.Errorf("ref.url = %q, want %q", ref.URL, tc.url)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsValidGitRepoURL(t *testing.T) {
|
|
good := []string{
|
|
"https://github.com/multica-ai/multica",
|
|
"https://github.com/multica-ai/multica.git",
|
|
"http://github.example.com/x/y",
|
|
"ssh://git@github.com/multica-ai/multica.git",
|
|
"ssh://git@github.com:22/multica-ai/multica.git",
|
|
"git@github.com:multica-ai/multica.git",
|
|
"git@gitlab.example.com:group/sub/repo.git",
|
|
}
|
|
bad := []string{
|
|
"",
|
|
"not-a-url",
|
|
"github.com/multica-ai/multica", // no scheme, no scp-style colon
|
|
"https://", // empty host
|
|
"git@github.com", // missing :path
|
|
"git@:foo/bar", // missing host
|
|
"git@github.com:", // missing path
|
|
"ftp://example.com/repo", // unsupported scheme
|
|
"file:///tmp/repo", // unsupported scheme
|
|
"some random text with spaces",
|
|
"github.com:org/repo@branch", // '@' after ':' belongs to the path, not user
|
|
"foo:bar@baz", // '@' after ':' with no scheme
|
|
":foo/bar", // leading ':' with no host
|
|
}
|
|
for _, s := range good {
|
|
if !isValidGitRepoURL(s) {
|
|
t.Errorf("isValidGitRepoURL(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range bad {
|
|
if isValidGitRepoURL(s) {
|
|
t.Errorf("isValidGitRepoURL(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProjectResourceLocalDirectoryLifecycle covers the full CRUD path for the
|
|
// local_directory resource type added in MUL-2662. Unlike github_repo, the
|
|
// ref schema requires local_path + daemon_id and forbids any path that isn't
|
|
// absolute. Two project-scoped resources pointing at the same daemon_id /
|
|
// local_path on different projects must be allowed — Bohan explicitly chose
|
|
// not to add a UNIQUE(daemon_id, local_path) constraint.
|
|
func TestProjectResourceLocalDirectoryLifecycle(t *testing.T) {
|
|
createProject := func(title string) ProjectResponse {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject(%s): %d %s", title, w.Code, w.Body.String())
|
|
}
|
|
var p ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&p); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
deleteProject := func(id string) {
|
|
r := newRequest("DELETE", "/api/projects/"+id, nil)
|
|
r = withURLParam(r, "id", id)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}
|
|
|
|
projectA := createProject("Local directory project A")
|
|
defer deleteProject(projectA.ID)
|
|
projectB := createProject("Local directory project B")
|
|
defer deleteProject(projectB.ID)
|
|
|
|
const (
|
|
daemonID = "daemon-aaaa-bbbb-cccc"
|
|
localPath = "/Users/foo/work/my-game"
|
|
)
|
|
|
|
// Happy path: attach local_directory resource with label.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects/"+projectA.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": daemonID,
|
|
"label": "Game Repo",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", projectA.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProjectResource: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created ProjectResourceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode CreateProjectResource: %v", err)
|
|
}
|
|
if created.ResourceType != "local_directory" {
|
|
t.Errorf("ResourceType = %q, want local_directory", created.ResourceType)
|
|
}
|
|
var ref struct {
|
|
LocalPath string `json:"local_path"`
|
|
DaemonID string `json:"daemon_id"`
|
|
Label string `json:"label"`
|
|
}
|
|
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
|
|
t.Fatalf("decode resource_ref: %v", err)
|
|
}
|
|
if ref.LocalPath != localPath || ref.DaemonID != daemonID || ref.Label != "Game Repo" {
|
|
t.Errorf("ref = %+v, want {%q, %q, Game Repo}", ref, localPath, daemonID)
|
|
}
|
|
|
|
// Listing must include the new resource.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/projects/"+projectA.ID+"/resources", nil)
|
|
req = withURLParam(req, "id", projectA.ID)
|
|
testHandler.ListProjectResources(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListProjectResources: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var listResp struct {
|
|
Resources []ProjectResourceResponse `json:"resources"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if listResp.Total != 1 || listResp.Resources[0].ID != created.ID {
|
|
t.Fatalf("list mismatch: %+v", listResp)
|
|
}
|
|
|
|
// Same (daemon_id, local_path) on a different project must succeed —
|
|
// the design explicitly allows the same directory to back multiple
|
|
// projects, contrast with github_repo's per-project UNIQUE check.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+projectB.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": daemonID,
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", projectB.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("same path on project B: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Duplicate attach on the same project must still conflict — the
|
|
// UNIQUE(project_id, resource_type, resource_ref) row constraint
|
|
// remains in effect.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+projectA.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": daemonID,
|
|
"label": "Game Repo",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", projectA.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("duplicate on same project: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Delete the resource on project A.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/projects/"+projectA.ID+"/resources/"+created.ID, nil)
|
|
req = withURLParams(req, "id", projectA.ID, "resourceId", created.ID)
|
|
testHandler.DeleteProjectResource(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteProjectResource: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestProjectResourceLocalDirectoryValidation pins the schema rejection
|
|
// surface for local_directory: missing path, missing daemon, relative paths,
|
|
// and malformed JSON must all return 400. These are the only client-visible
|
|
// errors agents will hit, so freezing them as tests prevents accidental
|
|
// loosening when someone touches the validator.
|
|
func TestProjectResourceLocalDirectoryValidation(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Local directory validation",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
|
r = withURLParam(r, "id", project.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
|
|
cases := []struct {
|
|
name string
|
|
ref any
|
|
}{
|
|
{"missing local_path", map[string]any{"daemon_id": "d1"}},
|
|
{"blank local_path", map[string]any{"local_path": " ", "daemon_id": "d1"}},
|
|
{"relative local_path", map[string]any{"local_path": "work/my-game", "daemon_id": "d1"}},
|
|
{"home-shorthand path", map[string]any{"local_path": "~/work/my-game", "daemon_id": "d1"}},
|
|
{"missing daemon_id", map[string]any{"local_path": "/Users/foo/work"}},
|
|
{"blank daemon_id", map[string]any{"local_path": "/Users/foo/work", "daemon_id": ""}},
|
|
{"wrong type in payload", map[string]any{"local_path": 42, "daemon_id": "d1"}},
|
|
{"unknown mode", map[string]any{"local_path": "/Users/foo/work", "daemon_id": "d1", "mode": "garbled"}},
|
|
{"pool_root relative", map[string]any{"local_path": "/Users/foo/work", "daemon_id": "d1", "mode": "worktree_pool", "pool_root": "relative/path"}},
|
|
{"max_parallel negative", map[string]any{"local_path": "/Users/foo/work", "daemon_id": "d1", "mode": "worktree_pool", "max_parallel": -1}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": tc.ref,
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateLocalDirectoryRef_WorktreePoolRoundTrip pins the shape of the
|
|
// normalised JSON returned by validateLocalDirectoryRef for both modes.
|
|
// The zero-mode ("" / in_place) case is the historical wire format and
|
|
// must NOT emit `mode` / `pool_root` / `max_parallel` even when the
|
|
// client sent explicit zero values — that keeps rows byte-stable across
|
|
// clients that never learnt about the pool fields (MUL-3483).
|
|
func TestValidateLocalDirectoryRef_WorktreePoolRoundTrip(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{
|
|
name: "default mode strips pool fields",
|
|
in: `{"local_path":"/Users/x/repo","daemon_id":"d1","mode":"in_place","pool_root":"/tmp/ignored","max_parallel":8}`,
|
|
want: `{"local_path":"/Users/x/repo","daemon_id":"d1"}`,
|
|
},
|
|
{
|
|
name: "empty mode is equivalent to in_place",
|
|
in: `{"local_path":"/Users/x/repo","daemon_id":"d1"}`,
|
|
want: `{"local_path":"/Users/x/repo","daemon_id":"d1"}`,
|
|
},
|
|
{
|
|
name: "worktree_pool preserves fields",
|
|
in: `{"local_path":"/Users/x/repo","daemon_id":"d1","mode":"worktree_pool","pool_root":"/tmp/pool","max_parallel":3}`,
|
|
want: `{"local_path":"/Users/x/repo","daemon_id":"d1","mode":"worktree_pool","pool_root":"/tmp/pool","max_parallel":3}`,
|
|
},
|
|
{
|
|
name: "worktree_pool without pool_root or max_parallel keeps mode",
|
|
in: `{"local_path":"/Users/x/repo","daemon_id":"d1","mode":"worktree_pool"}`,
|
|
want: `{"local_path":"/Users/x/repo","daemon_id":"d1","mode":"worktree_pool"}`,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out, err := validateLocalDirectoryRef(json.RawMessage(tc.in))
|
|
if err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
if string(out) != tc.want {
|
|
t.Fatalf("mismatch:\n got: %s\nwant: %s", string(out), tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsAbsoluteLocalPath(t *testing.T) {
|
|
good := []string{
|
|
"/Users/foo/work",
|
|
"/",
|
|
"/a",
|
|
`C:\Users\foo`,
|
|
`C:/Users/foo`,
|
|
`d:\code\repo`,
|
|
`\\server\share\path`,
|
|
}
|
|
bad := []string{
|
|
"",
|
|
"work/my-game",
|
|
"./relative",
|
|
"../relative",
|
|
"~/work",
|
|
"C:relative",
|
|
"C:",
|
|
`\foo`,
|
|
"file:///tmp",
|
|
}
|
|
for _, s := range good {
|
|
if !isAbsoluteLocalPath(s) {
|
|
t.Errorf("isAbsoluteLocalPath(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range bad {
|
|
if isAbsoluteLocalPath(s) {
|
|
t.Errorf("isAbsoluteLocalPath(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateProjectAttachesResources(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Project with bundled resources",
|
|
"resources": []map[string]any{
|
|
{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
|
|
},
|
|
},
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject with resources: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
Resources []ProjectResourceResponse `json:"resources"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+resp.ID, nil)
|
|
r = withURLParam(r, "id", resp.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
|
|
if len(resp.Resources) != 1 || resp.Resources[0].ResourceType != "github_repo" {
|
|
t.Fatalf("response resources mismatch: %+v", resp.Resources)
|
|
}
|
|
}
|
|
|
|
// TestProjectResourceCountBreadcrumb asserts the resource_count breadcrumb
|
|
// surfaces on GetProject and ListProjects so agents know to call
|
|
// /api/projects/{id}/resources without inlining the sub-collection.
|
|
func TestProjectResourceCountBreadcrumb(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Resource count breadcrumb",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
|
r = withURLParam(r, "id", project.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
|
|
getCount := func() int64 {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/projects/"+project.ID, nil)
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.GetProject(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetProject: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var resp ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode GetProject: %v", err)
|
|
}
|
|
return resp.ResourceCount
|
|
}
|
|
if got := getCount(); got != 0 {
|
|
t.Errorf("initial GetProject ResourceCount = %d, want 0", got)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/breadcrumb"},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProjectResource: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if got := getCount(); got != 1 {
|
|
t.Errorf("after attach GetProject ResourceCount = %d, want 1", got)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/projects?workspace_id="+testWorkspaceID, nil)
|
|
testHandler.ListProjects(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListProjects: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var list struct {
|
|
Projects []ProjectResponse `json:"projects"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&list); err != nil {
|
|
t.Fatalf("decode ListProjects: %v", err)
|
|
}
|
|
found := false
|
|
for _, p := range list.Projects {
|
|
if p.ID == project.ID {
|
|
found = true
|
|
if p.ResourceCount != 1 {
|
|
t.Errorf("ListProjects[%s].ResourceCount = %d, want 1", p.ID, p.ResourceCount)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("project %s not found in ListProjects response", project.ID)
|
|
}
|
|
|
|
var number int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
|
|
WHERE id = $1 RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&number); err != nil {
|
|
t.Fatalf("next issue number: %v", err)
|
|
}
|
|
var issueID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO issue (
|
|
workspace_id, creator_type, creator_id, title, status, priority,
|
|
project_id, number, position
|
|
)
|
|
VALUES ($1, 'member', $2, 'Project update stats breadcrumb', 'done', 'none', $3, $4, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, project.ID, number).Scan(&issueID); err != nil {
|
|
t.Fatalf("create project issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// UpdateProject must preserve the breadcrumb. A title-only PUT used to
|
|
// reset derived counts to 0 because UpdateProject didn't reload them.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID, map[string]any{
|
|
"title": "Resource count breadcrumb (updated)",
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.UpdateProject(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateProject: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var updated ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode UpdateProject: %v", err)
|
|
}
|
|
if updated.ResourceCount != 1 {
|
|
t.Errorf("UpdateProject ResourceCount = %d, want 1", updated.ResourceCount)
|
|
}
|
|
if updated.IssueCount != 1 || updated.DoneCount != 1 {
|
|
t.Errorf("UpdateProject issue stats = %d/%d, want 1/1", updated.DoneCount, updated.IssueCount)
|
|
}
|
|
}
|
|
|
|
// TestCreateProjectWithResourcesEchoesCount asserts the create-with-resources
|
|
// echo carries resource_count matching the attached resources, so the HTTP
|
|
// response and the published project:created event agree.
|
|
func TestCreateProjectWithResourcesEchoesCount(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Create echo with resource_count",
|
|
"resources": []map[string]any{
|
|
{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/echo-count"},
|
|
},
|
|
},
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject with resources: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
ResourceCount int64 `json:"resource_count"`
|
|
Resources []ProjectResourceResponse `json:"resources"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+resp.ID, nil)
|
|
r = withURLParam(r, "id", resp.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
if resp.ResourceCount != 1 || len(resp.Resources) != 1 {
|
|
t.Errorf("CreateProject echo: resource_count=%d resources=%d, want 1/1", resp.ResourceCount, len(resp.Resources))
|
|
}
|
|
}
|
|
|
|
func TestCreateProjectRollsBackOnInvalidResource(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Project that should not exist",
|
|
"resources": []map[string]any{
|
|
{
|
|
"resource_type": "github_repo",
|
|
"resource_ref": map[string]any{"url": "not-a-url"},
|
|
},
|
|
},
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateProject with invalid resource: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Confirm no project survived (transactional rollback). Listing all projects
|
|
// in the workspace and checking for the title is enough.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/projects?workspace_id="+testWorkspaceID, nil)
|
|
testHandler.ListProjects(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListProjects: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var list struct {
|
|
Projects []ProjectResponse `json:"projects"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
for _, p := range list.Projects {
|
|
if p.Title == "Project that should not exist" {
|
|
t.Errorf("invalid resource should have rolled back project create, but found %s", p.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProjectResourceUpdateLifecycle covers the PUT endpoint added in MUL-2662:
|
|
// editing label / position / resource_ref independently must succeed, and a
|
|
// missing resource_type swap is enforced implicitly because the request body
|
|
// has no resource_type field.
|
|
func TestProjectResourceUpdateLifecycle(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Update lifecycle project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
|
r = withURLParam(r, "id", project.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
|
|
// Seed one local_directory resource we will mutate.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/a",
|
|
"daemon_id": "d1",
|
|
"label": "A",
|
|
},
|
|
"label": "outer",
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProjectResource: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var created ProjectResourceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode CreateProjectResource: %v", err)
|
|
}
|
|
|
|
// Update only the label; ref/position/type must stay untouched.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
|
"label": "renamed",
|
|
})
|
|
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
|
testHandler.UpdateProjectResource(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateProjectResource label-only: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var updated ProjectResourceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode UpdateProjectResource: %v", err)
|
|
}
|
|
if updated.Label == nil || *updated.Label != "renamed" {
|
|
t.Errorf("after label edit: label = %v, want renamed", updated.Label)
|
|
}
|
|
var ref localDirectoryRef
|
|
if err := json.Unmarshal(updated.ResourceRef, &ref); err != nil {
|
|
t.Fatalf("decode resource_ref: %v", err)
|
|
}
|
|
if ref.LocalPath != "/Users/foo/work/a" || ref.DaemonID != "d1" || ref.Label != "A" {
|
|
t.Errorf("label-only update leaked into resource_ref: %+v", ref)
|
|
}
|
|
|
|
// Update the ref payload (move to a new daemon path) and bump position.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/b",
|
|
"daemon_id": "d2",
|
|
"label": "B",
|
|
},
|
|
"position": 5,
|
|
})
|
|
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
|
testHandler.UpdateProjectResource(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateProjectResource ref+position: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode UpdateProjectResource: %v", err)
|
|
}
|
|
if err := json.Unmarshal(updated.ResourceRef, &ref); err != nil {
|
|
t.Fatalf("decode resource_ref: %v", err)
|
|
}
|
|
if ref.LocalPath != "/Users/foo/work/b" || ref.DaemonID != "d2" || ref.Label != "B" {
|
|
t.Errorf("ref-update mismatch: %+v", ref)
|
|
}
|
|
if updated.Position != 5 {
|
|
t.Errorf("position = %d, want 5", updated.Position)
|
|
}
|
|
if updated.Label == nil || *updated.Label != "renamed" {
|
|
t.Errorf("label should survive ref edit, got %v", updated.Label)
|
|
}
|
|
|
|
// Explicit null clears the outer label.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
|
"label": nil,
|
|
})
|
|
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
|
testHandler.UpdateProjectResource(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateProjectResource label=null: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode UpdateProjectResource: %v", err)
|
|
}
|
|
if updated.Label != nil {
|
|
t.Errorf("label should be cleared, got %v", *updated.Label)
|
|
}
|
|
|
|
// Bad ref payload must reject with 400 (relative path).
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+created.ID, map[string]any{
|
|
"resource_ref": map[string]any{"local_path": "relative/path", "daemon_id": "d3"},
|
|
})
|
|
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
|
|
testHandler.UpdateProjectResource(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("relative path: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Unknown resource id must 404.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/00000000-0000-0000-0000-000000000000", map[string]any{
|
|
"label": "ghost",
|
|
})
|
|
req = withURLParams(req, "id", project.ID, "resourceId", "00000000-0000-0000-0000-000000000000")
|
|
testHandler.UpdateProjectResource(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("missing resource: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestProjectResourceLocalDirectoryDaemonScopedConflict pins the project-level
|
|
// conflict check for local_directory: one row per daemon per project. The
|
|
// daemon-side resolver picks the first match by daemon_id, so silently
|
|
// allowing two rows on the same daemon — even at distinct paths — would let
|
|
// the agent write into whichever sorts first. The DB UNIQUE constraint only
|
|
// catches identical ref JSON; this check covers the broader invariant.
|
|
func TestProjectResourceLocalDirectoryDaemonScopedConflict(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Local dir daemon-scoped conflict",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
|
t.Fatalf("decode CreateProject: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
|
r = withURLParam(r, "id", project.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
|
|
const (
|
|
daemonID = "d-scoped"
|
|
otherDaemon = "d-other"
|
|
localPath = "/Users/foo/work/scoped"
|
|
)
|
|
|
|
// First attach succeeds.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": daemonID,
|
|
"label": "first",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("first attach: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var first ProjectResourceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&first); err != nil {
|
|
t.Fatalf("decode first: %v", err)
|
|
}
|
|
|
|
// Same (daemon_id, local_path) with a different label must 409 — the
|
|
// embedded label is human metadata, not a discriminator.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": daemonID,
|
|
"label": "different label",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("same daemon same path create: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// A second row on the same daemon at a DIFFERENT path must also 409 —
|
|
// the daemon-scoped invariant rejects more than one local_directory
|
|
// per (project, daemon), even if the paths differ.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/other",
|
|
"daemon_id": daemonID,
|
|
"label": "other path",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("same daemon different path create: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Adding the same path on a DIFFERENT daemon is allowed — each daemon
|
|
// gets to register exactly one local_directory.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": otherDaemon,
|
|
"label": "other-machine",
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", project.ID)
|
|
testHandler.CreateProjectResource(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("other daemon attach: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var second ProjectResourceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&second); err != nil {
|
|
t.Fatalf("decode other-daemon row: %v", err)
|
|
}
|
|
|
|
// An UPDATE that drives the other-daemon row onto the first daemon must
|
|
// also 409 — the first daemon already has a registration.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+second.ID, map[string]any{
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": daemonID,
|
|
"label": "fresh",
|
|
},
|
|
})
|
|
req = withURLParams(req, "id", project.ID, "resourceId", second.ID)
|
|
testHandler.UpdateProjectResource(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("update onto existing daemon: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Editing the same row in place (different label, same target) must
|
|
// succeed — the conflict check ignores the row being updated.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/projects/"+project.ID+"/resources/"+first.ID, map[string]any{
|
|
"resource_ref": map[string]any{
|
|
"local_path": localPath,
|
|
"daemon_id": daemonID,
|
|
"label": "renamed inline",
|
|
},
|
|
})
|
|
req = withURLParams(req, "id", project.ID, "resourceId", first.ID)
|
|
testHandler.UpdateProjectResource(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("in-place rename: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateProjectBundledLocalDirectoryDaemonConflict pins the second leg of
|
|
// the daemon-scoped invariant: a single POST /api/projects that bundles two
|
|
// local_directory resources on the same daemon — same path, same daemon
|
|
// with different labels, or different paths on the same daemon — must
|
|
// reject with 400 before any DB work.
|
|
func TestCreateProjectBundledLocalDirectoryDaemonConflict(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Bundled label shadow",
|
|
"resources": []map[string]any{
|
|
{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/dup",
|
|
"daemon_id": "d-bundle",
|
|
"label": "first",
|
|
},
|
|
},
|
|
{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/dup",
|
|
"daemon_id": "d-bundle",
|
|
"label": "second label",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bundled label shadow: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Confirm the rollback: no project with the title should exist.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/projects?workspace_id="+testWorkspaceID, nil)
|
|
testHandler.ListProjects(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListProjects: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var list struct {
|
|
Projects []ProjectResponse `json:"projects"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
for _, p := range list.Projects {
|
|
if p.Title == "Bundled label shadow" {
|
|
t.Errorf("expected no project to survive bundled-create rejection, but found %s", p.ID)
|
|
}
|
|
}
|
|
|
|
// Two distinct paths on the same daemon must ALSO 400 — the invariant
|
|
// is "one local_directory per (project, daemon)", not "one per (project,
|
|
// daemon, path)".
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Bundled distinct paths same daemon",
|
|
"resources": []map[string]any{
|
|
{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/a",
|
|
"daemon_id": "d-bundle",
|
|
"label": "A",
|
|
},
|
|
},
|
|
{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/b",
|
|
"daemon_id": "d-bundle",
|
|
"label": "B",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("distinct-paths same daemon bundle: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// A bundle with one row per daemon is allowed — each daemon owns its
|
|
// own local_directory.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Bundled per-daemon rows",
|
|
"resources": []map[string]any{
|
|
{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/a",
|
|
"daemon_id": "d-bundle-1",
|
|
"label": "A",
|
|
},
|
|
},
|
|
{
|
|
"resource_type": "local_directory",
|
|
"resource_ref": map[string]any{
|
|
"local_path": "/Users/foo/work/b",
|
|
"daemon_id": "d-bundle-2",
|
|
"label": "B",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("per-daemon bundle: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
Resources []ProjectResourceResponse `json:"resources"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
defer func() {
|
|
r := newRequest("DELETE", "/api/projects/"+resp.ID, nil)
|
|
r = withURLParam(r, "id", resp.ID)
|
|
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
|
}()
|
|
if len(resp.Resources) != 2 {
|
|
t.Errorf("per-daemon bundle: expected 2 resources, got %d", len(resp.Resources))
|
|
}
|
|
}
|