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>
610 lines
21 KiB
Go
610 lines
21 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// ProjectResourceResponse is the JSON shape returned by the project resource API.
|
|
type ProjectResourceResponse struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceRef json.RawMessage `json:"resource_ref"`
|
|
Label *string `json:"label"`
|
|
Position int32 `json:"position"`
|
|
CreatedAt string `json:"created_at"`
|
|
CreatedBy *string `json:"created_by"`
|
|
}
|
|
|
|
func projectResourceToResponse(r db.ProjectResource) ProjectResourceResponse {
|
|
ref := json.RawMessage(r.ResourceRef)
|
|
if len(ref) == 0 {
|
|
ref = json.RawMessage("{}")
|
|
}
|
|
return ProjectResourceResponse{
|
|
ID: uuidToString(r.ID),
|
|
ProjectID: uuidToString(r.ProjectID),
|
|
WorkspaceID: uuidToString(r.WorkspaceID),
|
|
ResourceType: r.ResourceType,
|
|
ResourceRef: ref,
|
|
Label: textToPtr(r.Label),
|
|
Position: r.Position,
|
|
CreatedAt: timestampToString(r.CreatedAt),
|
|
CreatedBy: uuidToPtr(r.CreatedBy),
|
|
}
|
|
}
|
|
|
|
// CreateProjectResourceRequest is the body for POST /api/projects/{id}/resources.
|
|
type CreateProjectResourceRequest struct {
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceRef json.RawMessage `json:"resource_ref"`
|
|
Label *string `json:"label"`
|
|
Position *int32 `json:"position"`
|
|
}
|
|
|
|
// UpdateProjectResourceRequest is the body for PUT /api/projects/{id}/resources/{resourceId}.
|
|
// resource_type cannot change after creation — pick a new type by deleting and
|
|
// re-adding. Every field is optional; omitted fields keep their current value.
|
|
type UpdateProjectResourceRequest struct {
|
|
ResourceRef json.RawMessage `json:"resource_ref"`
|
|
Label *string `json:"label"`
|
|
Position *int32 `json:"position"`
|
|
}
|
|
|
|
// validateAndNormalizeResourceRef checks the payload for a known resource_type.
|
|
// New types are added here without schema migration; unknown types are rejected
|
|
// at the API boundary so a typo can't slip through and produce a resource the
|
|
// daemon/UI doesn't understand.
|
|
func validateAndNormalizeResourceRef(resourceType string, ref json.RawMessage) (json.RawMessage, error) {
|
|
if len(ref) == 0 {
|
|
return nil, errors.New("resource_ref is required")
|
|
}
|
|
switch resourceType {
|
|
case "github_repo":
|
|
return validateGithubRepoRef(ref)
|
|
case "local_directory":
|
|
return validateLocalDirectoryRef(ref)
|
|
default:
|
|
return nil, fmt.Errorf("unknown resource_type %q", resourceType)
|
|
}
|
|
}
|
|
|
|
type githubRepoRef struct {
|
|
URL string `json:"url"`
|
|
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
|
|
Ref string `json:"ref,omitempty"`
|
|
}
|
|
|
|
func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
|
|
var payload githubRepoRef
|
|
if err := json.Unmarshal(ref, &payload); err != nil {
|
|
return nil, fmt.Errorf("invalid github_repo payload: %w", err)
|
|
}
|
|
payload.URL = strings.TrimSpace(payload.URL)
|
|
if payload.URL == "" {
|
|
return nil, errors.New("github_repo: url is required")
|
|
}
|
|
if !isValidGitRepoURL(payload.URL) {
|
|
return nil, errors.New("github_repo: url must be a valid http(s) or ssh git URL")
|
|
}
|
|
payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint)
|
|
payload.Ref = strings.TrimSpace(payload.Ref)
|
|
out, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// localDirectoryRef is the JSONB shape stored for resource_type=local_directory.
|
|
// It pins a project to an existing directory on a specific user machine, so
|
|
// agent tasks run in-place rather than in an isolated git worktree. The
|
|
// daemon_id scopes the path to one daemon registration — the same string path
|
|
// on a different machine is a different resource. The optional label is a
|
|
// human-readable hint used by the UI; the row-level project_resource.label
|
|
// column remains the generic column for any resource type.
|
|
//
|
|
// Mode selects how the daemon binds tasks to the underlying path. The default
|
|
// "in_place" (or empty for backward compat) keeps the historical behaviour:
|
|
// every task locks the shared real path and runs directly in the user's
|
|
// directory. "worktree_pool" opts into MUL-3483's parallel mode — the daemon
|
|
// creates a per-task `git worktree add` under PoolRoot (bounded by
|
|
// MaxParallel) so sibling tasks on the same repo actually run concurrently
|
|
// instead of serialising on one working tree. PoolRoot and MaxParallel are
|
|
// hints; the daemon substitutes safe defaults when they are empty / 0.
|
|
type localDirectoryRef struct {
|
|
LocalPath string `json:"local_path"`
|
|
DaemonID string `json:"daemon_id"`
|
|
Label string `json:"label,omitempty"`
|
|
Mode string `json:"mode,omitempty"` // "in_place" (default) | "worktree_pool"
|
|
PoolRoot string `json:"pool_root,omitempty"` // absolute directory under daemon control; empty → daemon default
|
|
MaxParallel int `json:"max_parallel,omitempty"` // 0 → daemon default
|
|
}
|
|
|
|
// localDirectoryModeInPlace / localDirectoryModeWorktreePool are the only
|
|
// values the server accepts for `mode`. Kept as private constants because
|
|
// clients build the JSON directly; the daemon has its own copy of the same
|
|
// strings to avoid an import cycle between handler and daemon.
|
|
const (
|
|
localDirectoryModeInPlace = "in_place"
|
|
localDirectoryModeWorktreePool = "worktree_pool"
|
|
)
|
|
|
|
func validateLocalDirectoryRef(ref json.RawMessage) (json.RawMessage, error) {
|
|
var payload localDirectoryRef
|
|
if err := json.Unmarshal(ref, &payload); err != nil {
|
|
return nil, fmt.Errorf("invalid local_directory payload: %w", err)
|
|
}
|
|
payload.LocalPath = strings.TrimSpace(payload.LocalPath)
|
|
if payload.LocalPath == "" {
|
|
return nil, errors.New("local_directory: local_path is required")
|
|
}
|
|
if !isAbsoluteLocalPath(payload.LocalPath) {
|
|
return nil, errors.New("local_directory: local_path must be an absolute path")
|
|
}
|
|
payload.DaemonID = strings.TrimSpace(payload.DaemonID)
|
|
if payload.DaemonID == "" {
|
|
return nil, errors.New("local_directory: daemon_id is required")
|
|
}
|
|
payload.Label = strings.TrimSpace(payload.Label)
|
|
payload.Mode = strings.TrimSpace(payload.Mode)
|
|
switch payload.Mode {
|
|
case "", localDirectoryModeInPlace:
|
|
// Preserve the historical shape on the wire: never emit `mode`
|
|
// when the request meant "default". This keeps existing rows and
|
|
// clients that never learnt about `mode` byte-for-byte identical
|
|
// after a round-trip.
|
|
payload.Mode = ""
|
|
payload.PoolRoot = ""
|
|
payload.MaxParallel = 0
|
|
case localDirectoryModeWorktreePool:
|
|
payload.PoolRoot = strings.TrimSpace(payload.PoolRoot)
|
|
if payload.PoolRoot != "" && !isAbsoluteLocalPath(payload.PoolRoot) {
|
|
return nil, errors.New("local_directory: pool_root must be an absolute path when provided")
|
|
}
|
|
if payload.MaxParallel < 0 {
|
|
return nil, errors.New("local_directory: max_parallel must be non-negative")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("local_directory: unsupported mode %q (want %q or %q)", payload.Mode, localDirectoryModeInPlace, localDirectoryModeWorktreePool)
|
|
}
|
|
out, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// isAbsoluteLocalPath checks the path looks absolute on either POSIX or
|
|
// Windows daemons. The server can't know which OS the daemon runs on, so we
|
|
// accept the union: a leading "/" (POSIX), a UNC prefix "\\", or a drive
|
|
// letter like "C:\" or "C:/". The daemon still verifies existence at run
|
|
// time — this is a typo guard, not a filesystem check.
|
|
func isAbsoluteLocalPath(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
if s[0] == '/' {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(s, `\\`) {
|
|
return true
|
|
}
|
|
if len(s) >= 3 && isDriveLetter(s[0]) && s[1] == ':' && (s[2] == '\\' || s[2] == '/') {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isDriveLetter(b byte) bool {
|
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
|
|
}
|
|
|
|
// isValidGitRepoURL accepts the three forms a user can paste from GitHub's
|
|
// "Code" menu: https://, ssh:// (with explicit scheme), and the scp-like
|
|
// shorthand `git@host:owner/repo.git`. The check is intentionally lax — we are
|
|
// guarding against pasted garbage like "not-a-url", not enforcing a strict
|
|
// grammar — because the actual fetch happens client-side via `git clone` and
|
|
// the user gets a clearer error from git than from us.
|
|
func isValidGitRepoURL(s string) bool {
|
|
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
|
switch u.Scheme {
|
|
case "http", "https", "ssh", "git":
|
|
return true
|
|
}
|
|
}
|
|
// scp-like ssh shorthand: [user@]host:path with a non-empty host and path,
|
|
// and no spaces. Reject anything that looks like a URL with a scheme
|
|
// (those should go through url.Parse above).
|
|
if strings.Contains(s, " ") || strings.Contains(s, "://") {
|
|
return false
|
|
}
|
|
colon := strings.Index(s, ":")
|
|
if colon <= 0 || colon == len(s)-1 {
|
|
return false
|
|
}
|
|
// In scp-like ssh shorthand `[user@]host:path`, `@` is only meaningful
|
|
// as a user separator before the first ':'. If '@' appears at or after
|
|
// the colon it is not the user separator — reject as malformed rather
|
|
// than guess (and avoid a slice-bounds panic from blindly slicing).
|
|
at := strings.Index(s, "@")
|
|
if at >= colon {
|
|
return false
|
|
}
|
|
hostStart := 0
|
|
if at >= 0 {
|
|
hostStart = at + 1
|
|
}
|
|
host := s[hostStart:colon]
|
|
path := s[colon+1:]
|
|
if host == "" || path == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// loadProjectForResource resolves the project, enforces workspace ownership,
|
|
// and returns its DB row. Used by all project_resource handlers.
|
|
func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) {
|
|
projectUUID, ok := parseUUIDOrBadRequest(w, projectIDParam, "project id")
|
|
if !ok {
|
|
return db.Project{}, false
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id")
|
|
if !ok {
|
|
return db.Project{}, false
|
|
}
|
|
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: projectUUID, WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "project not found")
|
|
return db.Project{}, false
|
|
}
|
|
return project, true
|
|
}
|
|
|
|
// ListProjectResources returns the resources attached to a project.
|
|
func (h *Handler) ListProjectResources(w http.ResponseWriter, r *http.Request) {
|
|
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
resources, err := h.Queries.ListProjectResources(r.Context(), project.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list project resources")
|
|
return
|
|
}
|
|
resp := make([]ProjectResourceResponse, len(resources))
|
|
for i, res := range resources {
|
|
resp[i] = projectResourceToResponse(res)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"resources": resp, "total": len(resp)})
|
|
}
|
|
|
|
// CreateProjectResource attaches a new resource to a project.
|
|
func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) {
|
|
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req CreateProjectResourceRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
req.ResourceType = strings.TrimSpace(req.ResourceType)
|
|
if req.ResourceType == "" {
|
|
writeError(w, http.StatusBadRequest, "resource_type is required")
|
|
return
|
|
}
|
|
normalizedRef, err := validateAndNormalizeResourceRef(req.ResourceType, req.ResourceRef)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, req.ResourceType, normalizedRef, pgtype.UUID{}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to check existing resources")
|
|
return
|
|
} else if conflict {
|
|
writeError(w, http.StatusConflict, "this daemon already has a local_directory attached to the project; remove it before adding another")
|
|
return
|
|
}
|
|
|
|
var label pgtype.Text
|
|
if req.Label != nil && strings.TrimSpace(*req.Label) != "" {
|
|
label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true}
|
|
}
|
|
var position int32
|
|
if req.Position != nil {
|
|
position = *req.Position
|
|
} else {
|
|
// Append after existing resources.
|
|
count, _ := h.Queries.CountProjectResources(r.Context(), project.ID)
|
|
position = int32(count)
|
|
}
|
|
|
|
creator, _ := h.parseUserUUIDOrZero(userID)
|
|
resource, err := h.Queries.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{
|
|
ProjectID: project.ID,
|
|
WorkspaceID: project.WorkspaceID,
|
|
ResourceType: req.ResourceType,
|
|
ResourceRef: normalizedRef,
|
|
Label: label,
|
|
Position: position,
|
|
CreatedBy: creator,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "this resource is already attached to the project")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create project resource")
|
|
return
|
|
}
|
|
|
|
resp := projectResourceToResponse(resource)
|
|
h.publish(
|
|
protocol.EventProjectResourceCreated,
|
|
uuidToString(project.WorkspaceID),
|
|
"member",
|
|
userID,
|
|
map[string]any{"resource": resp, "project_id": uuidToString(project.ID)},
|
|
)
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// UpdateProjectResource edits an existing resource's ref/label/position.
|
|
// resource_type is immutable — re-pointing a resource at a different type is
|
|
// almost always a different conceptual entity, so the caller should delete and
|
|
// re-add instead. Omitted fields keep their current value, including the
|
|
// `label` JSON null vs. missing distinction (missing = keep, explicit "" =
|
|
// clear).
|
|
func (h *Handler) UpdateProjectResource(w http.ResponseWriter, r *http.Request) {
|
|
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id")
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
existing, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{
|
|
ID: resourceUUID, WorkspaceID: project.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "project resource not found")
|
|
return
|
|
}
|
|
if uuidToString(existing.ProjectID) != uuidToString(project.ID) {
|
|
writeError(w, http.StatusNotFound, "project resource not found")
|
|
return
|
|
}
|
|
|
|
// Decode into a raw map first so we can tell "field omitted" from
|
|
// "field present with zero value" — the label clear case in particular
|
|
// relies on this distinction.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
nextRef := json.RawMessage(existing.ResourceRef)
|
|
if rawRef, ok := raw["resource_ref"]; ok {
|
|
normalized, err := validateAndNormalizeResourceRef(existing.ResourceType, rawRef)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
nextRef = normalized
|
|
}
|
|
|
|
if conflict, err := h.findLocalDirectoryConflict(r.Context(), project.ID, existing.ResourceType, nextRef, existing.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to check existing resources")
|
|
return
|
|
} else if conflict {
|
|
writeError(w, http.StatusConflict, "another local_directory on this daemon is already attached to the project")
|
|
return
|
|
}
|
|
|
|
nextLabel := existing.Label
|
|
if rawLabel, ok := raw["label"]; ok {
|
|
var labelStr *string
|
|
if err := json.Unmarshal(rawLabel, &labelStr); err != nil {
|
|
writeError(w, http.StatusBadRequest, "label must be a string or null")
|
|
return
|
|
}
|
|
if labelStr == nil || strings.TrimSpace(*labelStr) == "" {
|
|
nextLabel = pgtype.Text{}
|
|
} else {
|
|
nextLabel = pgtype.Text{String: strings.TrimSpace(*labelStr), Valid: true}
|
|
}
|
|
}
|
|
|
|
nextPosition := existing.Position
|
|
if rawPos, ok := raw["position"]; ok {
|
|
var pos *int32
|
|
if err := json.Unmarshal(rawPos, &pos); err != nil {
|
|
writeError(w, http.StatusBadRequest, "position must be an integer")
|
|
return
|
|
}
|
|
if pos != nil {
|
|
nextPosition = *pos
|
|
}
|
|
}
|
|
|
|
updated, err := h.Queries.UpdateProjectResource(r.Context(), db.UpdateProjectResourceParams{
|
|
ID: existing.ID,
|
|
ResourceRef: nextRef,
|
|
Label: nextLabel,
|
|
Position: nextPosition,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "this resource is already attached to the project")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update project resource")
|
|
return
|
|
}
|
|
|
|
resp := projectResourceToResponse(updated)
|
|
h.publish(
|
|
protocol.EventProjectResourceUpdated,
|
|
uuidToString(project.WorkspaceID),
|
|
"member",
|
|
userID,
|
|
map[string]any{"resource": resp, "project_id": uuidToString(project.ID)},
|
|
)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// findLocalDirectoryConflict enforces "at most one local_directory resource
|
|
// per (project, daemon)". The daemon picks the first matching daemon_id row
|
|
// out of a task's resources (findLocalDirectoryAssignment), so letting a
|
|
// project carry two rows for the same daemon would mean the agent silently
|
|
// writes into whichever happens to come back first — a safety hazard for a
|
|
// feature that operates directly on the user's real working directory.
|
|
//
|
|
// The DB-level UNIQUE(project_id, resource_type, resource_ref) constraint
|
|
// alone is not enough here: it only fires on full ref-JSON equality, so a
|
|
// different local_path or even a typoed label on the same daemon would slip
|
|
// through. We do the daemon-scoped check here in application code instead.
|
|
//
|
|
// `excludeID` lets the update path ignore the row being edited.
|
|
func (h *Handler) findLocalDirectoryConflict(ctx context.Context, projectID pgtype.UUID, resourceType string, normalizedRef json.RawMessage, excludeID pgtype.UUID) (bool, error) {
|
|
if resourceType != "local_directory" {
|
|
return false, nil
|
|
}
|
|
var incoming localDirectoryRef
|
|
if err := json.Unmarshal(normalizedRef, &incoming); err != nil {
|
|
return false, err
|
|
}
|
|
rows, err := h.Queries.ListProjectResources(ctx, projectID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, row := range rows {
|
|
if row.ResourceType != "local_directory" {
|
|
continue
|
|
}
|
|
if excludeID.Valid && uuidToString(row.ID) == uuidToString(excludeID) {
|
|
continue
|
|
}
|
|
var existing localDirectoryRef
|
|
if err := json.Unmarshal(row.ResourceRef, &existing); err != nil {
|
|
continue
|
|
}
|
|
// Daemon-scoped uniqueness: one local_directory per daemon per
|
|
// project. Different daemons can each carry one row (one per
|
|
// user device); the daemon-side resolver routes each daemon to
|
|
// its own assignment by daemon_id.
|
|
if existing.DaemonID == incoming.DaemonID {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// DeleteProjectResource removes a resource from a project.
|
|
func (h *Handler) DeleteProjectResource(w http.ResponseWriter, r *http.Request) {
|
|
project, ok := h.loadProjectForResource(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
resourceUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "resourceId"), "resource id")
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
resource, err := h.Queries.GetProjectResourceInWorkspace(r.Context(), db.GetProjectResourceInWorkspaceParams{
|
|
ID: resourceUUID, WorkspaceID: project.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "project resource not found")
|
|
return
|
|
}
|
|
if uuidToString(resource.ProjectID) != uuidToString(project.ID) {
|
|
writeError(w, http.StatusNotFound, "project resource not found")
|
|
return
|
|
}
|
|
if err := h.Queries.DeleteProjectResource(r.Context(), resource.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete project resource")
|
|
return
|
|
}
|
|
h.publish(
|
|
protocol.EventProjectResourceDeleted,
|
|
uuidToString(project.WorkspaceID),
|
|
"member",
|
|
userID,
|
|
map[string]any{
|
|
"project_id": uuidToString(project.ID),
|
|
"resource_id": uuidToString(resource.ID),
|
|
},
|
|
)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// parseUserUUIDOrZero converts a user ID string to a pgtype.UUID, returning a
|
|
// zero value on any error so the caller can store NULL for created_by when the
|
|
// authenticated principal is not a workspace member (e.g. internal-server use).
|
|
func (h *Handler) parseUserUUIDOrZero(userID string) (pgtype.UUID, bool) {
|
|
if userID == "" {
|
|
return pgtype.UUID{}, false
|
|
}
|
|
u, err := parseUUIDLoose(userID)
|
|
if err != nil {
|
|
return pgtype.UUID{}, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
// parseUUIDLoose mirrors util.ParseUUID but lives here to avoid pulling util
|
|
// into a tiny one-off helper. Keep the body minimal.
|
|
func parseUUIDLoose(s string) (pgtype.UUID, error) {
|
|
var u pgtype.UUID
|
|
if err := u.Scan(s); err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// listProjectResourcesForProject is a small helper used by the daemon claim
|
|
// handler to attach project resources to outgoing tasks.
|
|
func (h *Handler) listProjectResourcesForProject(ctx context.Context, projectID pgtype.UUID) []db.ProjectResource {
|
|
if !projectID.Valid {
|
|
return nil
|
|
}
|
|
rows, err := h.Queries.ListProjectResources(ctx, projectID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return rows
|
|
}
|