diff --git a/server/cmd/multica/cmd_project.go b/server/cmd/multica/cmd_project.go index b70abca67..4eec215f6 100644 --- a/server/cmd/multica/cmd_project.go +++ b/server/cmd/multica/cmd_project.go @@ -78,6 +78,13 @@ var projectResourceAddCmd = &cobra.Command{ RunE: runProjectResourceAdd, } +var projectResourceUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Edit an attached resource (ref payload, label, or position)", + Args: exactArgs(2), + RunE: runProjectResourceUpdate, +} + var projectResourceRemoveCmd = &cobra.Command{ Use: "remove ", Short: "Detach a resource from a project", @@ -100,6 +107,7 @@ func init() { projectResourceCmd.AddCommand(projectResourceListCmd) projectResourceCmd.AddCommand(projectResourceAddCmd) + projectResourceCmd.AddCommand(projectResourceUpdateCmd) projectResourceCmd.AddCommand(projectResourceRemoveCmd) // project list @@ -136,6 +144,19 @@ func init() { projectResourceAddCmd.Flags().String("label", "", "Optional human-readable label") projectResourceAddCmd.Flags().String("output", "json", "Output format: table or json") + // project resource update — mirrors `add` flags, but every field is + // optional so the caller can edit one thing at a time. + projectResourceUpdateCmd.Flags().String("url", "", "Shortcut: new repo URL (github_repo)") + projectResourceUpdateCmd.Flags().String("default-branch-hint", "", "Shortcut: new default branch hint (github_repo)") + projectResourceUpdateCmd.Flags().String("local-path", "", "Shortcut: new absolute local path (local_directory)") + projectResourceUpdateCmd.Flags().String("daemon-id", "", "Shortcut: new daemon id (local_directory)") + projectResourceUpdateCmd.Flags().String("ref-label", "", "Shortcut: new label embedded in resource_ref (local_directory)") + projectResourceUpdateCmd.Flags().String("ref", "", "Generic JSON resource_ref payload — overrides per-type shortcuts when set") + projectResourceUpdateCmd.Flags().String("label", "", "New human-readable label; pass an empty string to clear") + projectResourceUpdateCmd.Flags().Bool("clear-label", false, "Clear the human-readable label") + projectResourceUpdateCmd.Flags().Int32("position", 0, "New display position") + projectResourceUpdateCmd.Flags().String("output", "json", "Output format: table or json") + // project resource remove projectResourceRemoveCmd.Flags().String("output", "table", "Output format: table or json") @@ -601,6 +622,169 @@ func runProjectResourceAdd(cmd *cobra.Command, args []string) error { return cli.PrintJSON(os.Stdout, result) } +func runProjectResourceUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + projectRef, err := resolveProjectID(ctx, client, args[0]) + if err != nil { + return fmt.Errorf("resolve project: %w", err) + } + resourceRef, err := resolveProjectResourceID(ctx, client, projectRef.ID, args[1]) + if err != nil { + return fmt.Errorf("resolve project resource: %w", err) + } + + // Fetch the existing row so per-type shortcuts know which schema to + // emit. The server still validates, but doing the dispatch here means + // the user gets a clear "what does --url do here?" error before we + // round-trip. + var existing map[string]any + if err := client.GetJSON(ctx, "/api/projects/"+projectRef.ID+"/resources", &existing); err != nil { + return fmt.Errorf("list project resources: %w", err) + } + var resourceType string + if list, ok := existing["resources"].([]any); ok { + for _, raw := range list { + row, ok := raw.(map[string]any) + if !ok { + continue + } + if strVal(row, "id") == resourceRef.ID { + resourceType = strVal(row, "resource_type") + break + } + } + } + + body := map[string]any{} + + if rawRef, _ := cmd.Flags().GetString("ref"); strings.TrimSpace(rawRef) != "" { + var ref any + if err := json.Unmarshal([]byte(rawRef), &ref); err != nil { + return fmt.Errorf("--ref is not valid JSON: %w", err) + } + body["resource_ref"] = ref + } else { + ref, has, err := buildResourceRefFromFlags(cmd, resourceType) + if err != nil { + return err + } + if has { + body["resource_ref"] = ref + } + } + + clearLabel, _ := cmd.Flags().GetBool("clear-label") + if clearLabel { + body["label"] = nil + } else if cmd.Flags().Changed("label") { + label, _ := cmd.Flags().GetString("label") + body["label"] = label + } + + if cmd.Flags().Changed("position") { + pos, _ := cmd.Flags().GetInt32("position") + body["position"] = pos + } + + if len(body) == 0 { + return fmt.Errorf("nothing to update — pass --ref / --url / --local-path / --label / --position / --clear-label") + } + + var result map[string]any + if err := client.PutJSON(ctx, "/api/projects/"+projectRef.ID+"/resources/"+resourceRef.ID, body, &result); err != nil { + return fmt.Errorf("update project resource: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + headers := []string{"ID", "TYPE", "REF", "LABEL"} + rows := [][]string{{ + strVal(result, "id"), + strVal(result, "resource_type"), + summarizeResourceRef(result["resource_ref"]), + strVal(result, "label"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + return cli.PrintJSON(os.Stdout, result) +} + +// buildResourceRefFromFlags collects the per-type shortcut flags into a +// resource_ref payload. Returns (ref, true) only when the caller actually set +// at least one shortcut flag — that lets the update command tell "no change +// requested" apart from "change ref to empty object". For local_directory we +// only emit a partial ref when every required field is present so the server +// never sees a half-built `{daemon_id: …}` payload. +func buildResourceRefFromFlags(cmd *cobra.Command, resourceType string) (map[string]any, bool, error) { + switch resourceType { + case "github_repo": + urlSet := cmd.Flags().Changed("url") + hintSet := cmd.Flags().Changed("default-branch-hint") + if !urlSet && !hintSet { + return nil, false, nil + } + ref := map[string]any{} + if urlSet { + urlVal, _ := cmd.Flags().GetString("url") + urlVal = strings.TrimSpace(urlVal) + if urlVal == "" { + return nil, false, fmt.Errorf("--url cannot be empty") + } + ref["url"] = urlVal + } + if hintSet { + hint, _ := cmd.Flags().GetString("default-branch-hint") + ref["default_branch_hint"] = strings.TrimSpace(hint) + } + return ref, true, nil + case "local_directory": + pathSet := cmd.Flags().Changed("local-path") + daemonSet := cmd.Flags().Changed("daemon-id") + labelSet := cmd.Flags().Changed("ref-label") + if !pathSet && !daemonSet && !labelSet { + return nil, false, nil + } + ref := map[string]any{} + if pathSet { + pathVal, _ := cmd.Flags().GetString("local-path") + ref["local_path"] = strings.TrimSpace(pathVal) + } + if daemonSet { + daemonVal, _ := cmd.Flags().GetString("daemon-id") + ref["daemon_id"] = strings.TrimSpace(daemonVal) + } + if labelSet { + refLabel, _ := cmd.Flags().GetString("ref-label") + ref["label"] = strings.TrimSpace(refLabel) + } + // The ref must remain self-contained — partial updates would + // drop required fields server-side. Refuse early. + if _, ok := ref["local_path"]; !ok || !pathSet || strings.TrimSpace(ref["local_path"].(string)) == "" { + return nil, false, fmt.Errorf("local_directory --ref edits require --local-path (and --daemon-id)") + } + if _, ok := ref["daemon_id"]; !ok || !daemonSet || strings.TrimSpace(ref["daemon_id"].(string)) == "" { + return nil, false, fmt.Errorf("local_directory --ref edits require --daemon-id (and --local-path)") + } + return ref, true, nil + default: + // Unknown type or empty (resource not found) — caller must use --ref. + if cmd.Flags().Changed("url") || cmd.Flags().Changed("default-branch-hint") || + cmd.Flags().Changed("local-path") || cmd.Flags().Changed("daemon-id") || + cmd.Flags().Changed("ref-label") { + return nil, false, fmt.Errorf("no built-in shortcut for resource type %q; pass the full payload via --ref ''", resourceType) + } + return nil, false, nil + } +} + func runProjectResourceRemove(cmd *cobra.Command, args []string) error { client, err := newAPIClient(cmd) if err != nil { diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 7503ee4b3..a6ab2ad75 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -449,6 +449,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Delete("/", h.DeleteProject) r.Get("/resources", h.ListProjectResources) r.Post("/resources", h.CreateProjectResource) + r.Put("/resources/{resourceId}", h.UpdateProjectResource) r.Delete("/resources/{resourceId}", h.DeleteProjectResource) }) }) diff --git a/server/internal/handler/project_resource.go b/server/internal/handler/project_resource.go index 6af9f2c41..07964055f 100644 --- a/server/internal/handler/project_resource.go +++ b/server/internal/handler/project_resource.go @@ -54,6 +54,15 @@ type CreateProjectResourceRequest struct { 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 @@ -267,6 +276,14 @@ func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) 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 resource is already attached to the project") + return + } + var label pgtype.Text if req.Label != nil && strings.TrimSpace(*req.Label) != "" { label = pgtype.Text{String: strings.TrimSpace(*req.Label), Valid: true} @@ -310,6 +327,153 @@ func (h *Handler) CreateProjectResource(w http.ResponseWriter, r *http.Request) 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 resource on this project already targets that local_directory") + 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 catches the case where two resources on the same +// project point at the same (daemon_id, local_path) but use different labels. +// The DB-level UNIQUE(project_id, resource_type, resource_ref) constraint only +// fires when the entire ref JSON matches — label lives inside resource_ref, so +// a typo in the label field would otherwise let users attach the same working +// directory twice. 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 + } + if existing.DaemonID == incoming.DaemonID && existing.LocalPath == incoming.LocalPath { + 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")) diff --git a/server/internal/handler/project_resource_test.go b/server/internal/handler/project_resource_test.go index dab3f3d42..f57fa2fb6 100644 --- a/server/internal/handler/project_resource_test.go +++ b/server/internal/handler/project_resource_test.go @@ -658,3 +658,262 @@ func TestCreateProjectRollsBackOnInvalidResource(t *testing.T) { } } } + +// 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()) + } +} + +// TestProjectResourceLocalDirectoryLabelShadow pins the project-level conflict +// check for local_directory: two resources on the same project pointing at the +// same (daemon_id, local_path) must collide even when the embedded `label` +// differs. The DB UNIQUE(project_id, resource_type, resource_ref) constraint +// only matches the entire ref JSON, so a label typo would otherwise silently +// create a duplicate binding. +func TestProjectResourceLocalDirectoryLabelShadow(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Local dir label shadow", + }) + 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-shadow" + localPath = "/Users/foo/work/shadow" + ) + + // 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 still 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("label-shadow create: expected 409, got %d: %s", w.Code, w.Body.String()) + } + + // And an UPDATE that drives a different resource onto the same target + // must also 409. Seed a second resource at a non-conflicting path, then + // try to move it onto the first. + 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, + }, + }) + req = withURLParam(req, "id", project.ID) + testHandler.CreateProjectResource(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("second seed: %d %s", w.Code, w.Body.String()) + } + var second ProjectResourceResponse + if err := json.NewDecoder(w.Body).Decode(&second); err != nil { + t.Fatalf("decode second: %v", err) + } + + 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("label-shadow update: 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()) + } +} diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index 1c78a47e0..0fe844ba0 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -2314,7 +2314,7 @@ const updateAgentCustomEnv = `-- name: UpdateAgentCustomEnv :one UPDATE agent SET custom_env = $2, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level +RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, skills_local ` type UpdateAgentCustomEnvParams struct { @@ -2353,6 +2353,7 @@ func (q *Queries) UpdateAgentCustomEnv(ctx context.Context, arg UpdateAgentCusto &i.McpConfig, &i.Model, &i.ThinkingLevel, + &i.SkillsLocal, ) return i, err } diff --git a/server/pkg/db/generated/project_resource.sql.go b/server/pkg/db/generated/project_resource.sql.go index 9982b54b7..05c52d392 100644 --- a/server/pkg/db/generated/project_resource.sql.go +++ b/server/pkg/db/generated/project_resource.sql.go @@ -226,3 +226,41 @@ func (q *Queries) ListProjectResourcesForProjects(ctx context.Context, projectId } return items, nil } + +const updateProjectResource = `-- name: UpdateProjectResource :one +UPDATE project_resource +SET resource_ref = $2, + label = $3, + position = $4 +WHERE id = $1 +RETURNING id, project_id, workspace_id, resource_type, resource_ref, label, position, created_at, created_by +` + +type UpdateProjectResourceParams struct { + ID pgtype.UUID `json:"id"` + ResourceRef []byte `json:"resource_ref"` + Label pgtype.Text `json:"label"` + Position int32 `json:"position"` +} + +func (q *Queries) UpdateProjectResource(ctx context.Context, arg UpdateProjectResourceParams) (ProjectResource, error) { + row := q.db.QueryRow(ctx, updateProjectResource, + arg.ID, + arg.ResourceRef, + arg.Label, + arg.Position, + ) + var i ProjectResource + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.ResourceType, + &i.ResourceRef, + &i.Label, + &i.Position, + &i.CreatedAt, + &i.CreatedBy, + ) + return i, err +} diff --git a/server/pkg/db/queries/project_resource.sql b/server/pkg/db/queries/project_resource.sql index 741548e06..088b44171 100644 --- a/server/pkg/db/queries/project_resource.sql +++ b/server/pkg/db/queries/project_resource.sql @@ -23,6 +23,14 @@ INSERT INTO project_resource ( $1, $2, $3, $4, $5, $6, $7 ) RETURNING *; +-- name: UpdateProjectResource :one +UPDATE project_resource +SET resource_ref = $2, + label = $3, + position = $4 +WHERE id = $1 +RETURNING *; + -- name: DeleteProjectResource :exec DELETE FROM project_resource WHERE id = $1; diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index dd736ba68..21945136b 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -79,6 +79,7 @@ const ( EventProjectUpdated = "project:updated" EventProjectDeleted = "project:deleted" EventProjectResourceCreated = "project_resource:created" + EventProjectResourceUpdated = "project_resource:updated" EventProjectResourceDeleted = "project_resource:deleted" // Label events