mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* feat(project): add local_directory project_resource type (MUL-2662)
Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.
Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.
Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.
Co-authored-by: multica-agent <github@multica.ai>
* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)
Addresses the Elon review on PR #3263:
- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
matching handler, CLI `project resource update`, and a new
EventProjectResourceUpdated WS event. resource_type stays immutable;
ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
embedded label differs — the row-level UNIQUE only matches the full
ref JSON, so a label typo would otherwise let the same working
directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
invalid path) and the label-shadow conflict on both create and
update; the in-place rename still succeeds because the conflict
scan ignores the row being edited.
Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.
Co-authored-by: multica-agent <github@multica.ai>
* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)
Two follow-ups from MUL-2662 review round 2:
- CreateProject inline resources path now dedupes local_directory entries on
(daemon_id, local_path) before opening the transaction. The DB-level
UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
full JSON match, so two rows with the same target but different `label`
would otherwise slip past. Standalone POST/PUT already cover this via
findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
row before applying per-type shortcut flags, so `--default-branch-hint x`
on its own no longer constructs a payload missing `url` (which the server
400s on). Local_directory partial edits get the same merge behavior.
Co-authored-by: multica-agent <github@multica.ai>
* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)
* feat(desktop): local_directory project_resource UI (MUL-2665)
First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.
What's new for the renderer:
- ProjectResourcesSection grows a desktop-only "Add local directory"
button next to the existing GitHub-repo popover. Clicking it opens
Electron's native folder picker, validates the path through a new
IPC pair (existence + r/w), and submits a project_resource of
resource_type=local_directory with daemon_id pulled live from
daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
greys out when ref.daemon_id != this machine's daemon_id (with a
"only available on the machine that registered this directory"
tooltip). Delete stays enabled so users can drop stale registrations
from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
shows "Agent will work in-place at {label} ({path})" when the issue's
project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
that the daemon will publish when it dequeues a task but can't
acquire the path lock. The render is in place now so the daemon
sibling subtask can wire the status string without an additional UI
PR.
Plumbing:
- @multica/core/types gains LocalDirectoryResourceRef +
UpdateProjectResourceRequest, and the api client gets the matching
PUT method backed by the server endpoint that landed in
2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
local-directory:pick -> dialog.showOpenDialog (openDirectory)
local-directory:validate -> stat + access(R_OK + W_OK)
exposed through the preload as desktopAPI.pickDirectory /
validateLocalDirectory. View code talks to them via a thin
packages/views/platform helper that returns reason=unsupported on
web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
running flag from daemonAPI.onStatusChange so the renderer can do the
cross-device match without coupling to the desktop preload typings.
Tests:
- pickStageKeys gets a unit test covering the new stage and proving
the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
and chat.status_pill.stages.waiting_for_directory_release in both
locales.
Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
new state.
- Adding local_directory to the create-project modal's bulk
attach flow.
- Docs page refresh for project-resources.mdx — left for the
MUL-2618 umbrella sweep.
Co-authored-by: multica-agent <github@multica.ai>
* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)
Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)
* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)
Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:
- Validates the path (absolute, exists, daemon process can read+write,
not in the system-root / $HOME blacklist) and fails the task fast on
any precondition violation, with a user-readable reason.
- Serialises concurrent tasks on the same on-disk path via a
daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
lock is held for the entire task lifetime (claim → context write →
agent → result report).
- When the lock is contended, the daemon flips the row to a new
waiting_local_directory status on the server (carrying a wait_reason
like "<path> (held by task <short id>)") so the UI can render
"等待本地目录释放" instead of leaving the row silently in dispatched
past the sweeper timeout. The status accepts being woken into running
once the lock is acquired.
- Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
.gc_meta.json — the daemon's logbook for the run.
- Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
gcActionOrphan → gcActionSkip). The user's directory was never under
envRoot to begin with, so this is defense in depth.
- Skips execenv.Reuse for local_directory tasks because the prior
WorkDir is the user's path and reusing it through that code path
loses the envRoot association the GC loop needs. Prepare is cheap
here (no clone, no copy), so always running it is fine.
Server-side protocol changes:
- New CHECK value 'waiting_local_directory' on agent_task_queue.status
plus a wait_reason TEXT column (migration 109).
- All cancel / active / counted-as-running / orphan-recovery queries
expanded to include the new status; FailStaleTasks intentionally
excludes it (the daemon owns the wait).
- New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
StartAgentTask that accepts both dispatched and
waiting_local_directory as preconditions (and clears wait_reason on
the way through).
- New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
daemon Client.MarkTaskWaitingLocalDirectory.
Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.
The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.
This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)
Address the 3 must-fix items from Elon's review of PR #3274.
1. Status string unified. The server / daemon publish
`waiting_local_directory`; align views, locales, and the
pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
on a placeholder string). Without this, the daemon's wait state
never reached the pill once the two siblings merged.
2. validateLocalPath now also runs the blacklist against the
symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
redirect handled via `isBlacklistedRealPath` which compares
canonical forms. Without this, a symlink such as
`/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
while every daemon write still landed in the user's home. Tests
cover symlink-to-home, symlink-to-system-root, and the negative
case (symlink to a regular subdirectory).
3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
watcher inside `onWait` (lazy — the fast path stays free) so the
gap between dispatch and StartTask responds to server-side cancel
or row deletion. If the watcher fires while the daemon is parked
on the path mutex, the lock-wait context is cancelled, Acquire
returns promptly, and the helper exits silently the same way the
run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
exercises the path end-to-end with a fake server.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)
- validateLocalPath now always runs isBlacklistedRealPath on the
symlink-resolved path, not only when it differs from absPath. The old
guard let users type the canonical form of an OS-symlinked banned root
(e.g. /private/tmp, /private/etc, /private/var on macOS) straight
through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
onto filepath.VolumeName via a new isDriveRoot helper, so removable /
network drives mounted at G:..Z: and UNC \\server\share roots are also
blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
new TestIsDriveRoot covers the Windows generalisation (skipped on
POSIX runners by runtime guard).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)
Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.
- Add `waiting_local_directory` to `AgentTask.status` and the typed
`task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
status label, treat parked tasks the same as dispatched for time anchor /
transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
dispatched and queued, and surface a "is waiting for the local directory"
banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
`execution_log.status_waiting_local_directory` (en + zh-Hans).
Co-authored-by: multica-agent <github@multica.ai>
* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)
The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:
- server: `findLocalDirectoryConflict` rejects any second row sharing a
daemon_id, regardless of `local_path` or label. Bundled-create surface in
`CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
one row pinned to the current daemon (older API client / direct DB
writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
daemon owns a row on this project, with a hint and a defensive toast on
the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
* daemon: new `two local_directory rows on this daemon fail fast` /
`local_directory rows on different daemons coexist` cases.
* handler: rewrite the legacy `LabelShadow` cases as
`DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.
Co-authored-by: multica-agent <github@multica.ai>
* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)
Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.
Co-authored-by: multica-agent <github@multica.ai>
* feat(create-project): binary source picker — repos OR local directory
Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.
Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
only emits the resource matching the active sourceMode, so abandoned
picks never leak into the created project.
Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.
* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica J <j@multica.ai>
1068 lines
37 KiB
Go
1068 lines
37 KiB
Go
package handler
|
|
|
|
import (
|
|
"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"},
|
|
})
|
|
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"`
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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"},
|
|
})
|
|
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"}},
|
|
}
|
|
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())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// UpdateProject must preserve the breadcrumb. A title-only PUT used to
|
|
// reset resource_count to 0 because UpdateProject didn't reload the count.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|