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 <[email protected]>
This commit is contained in:
Jiang Bohan
2026-05-26 14:09:44 +08:00
co-authored by multica-agent
parent 26d7ba8964
commit 2ac3faebb9
8 changed files with 657 additions and 1 deletions
+184
View File
@@ -78,6 +78,13 @@ var projectResourceAddCmd = &cobra.Command{
RunE: runProjectResourceAdd,
}
var projectResourceUpdateCmd = &cobra.Command{
Use: "update <project-id> <resource-id>",
Short: "Edit an attached resource (ref payload, label, or position)",
Args: exactArgs(2),
RunE: runProjectResourceUpdate,
}
var projectResourceRemoveCmd = &cobra.Command{
Use: "remove <project-id> <resource-id>",
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 '<json>'", resourceType)
}
return nil, false, nil
}
}
func runProjectResourceRemove(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
+1
View File
@@ -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)
})
})
+164
View File
@@ -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"))
@@ -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())
}
}
+2 -1
View File
@@ -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
}
@@ -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
}
@@ -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;
+1
View File
@@ -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