mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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:
co-authored by
multica-agent
parent
26d7ba8964
commit
2ac3faebb9
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user