From 09c78d3ab89428aa54164f303cea8ebf195947e0 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 22:46:46 +0800 Subject: [PATCH] Accept final member positions in ownership transactions --- README.md | 4 ++ internal/platform/canvas_capabilities.go | 2 +- internal/platform/integration_test.go | 7 ++- internal/platform/member_drop.go | 73 ++++++++++++++++++++++ internal/platform/member_drop_test.go | 48 ++++++++++++++ internal/platform/subgraph_members_http.go | 21 +++++-- 6 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 internal/platform/member_drop.go create mode 100644 internal/platform/member_drop_test.go diff --git a/README.md b/README.md index 6884ed5..1ea1502 100644 --- a/README.md +++ b/README.md @@ -628,3 +628,7 @@ Legacy inline images must be migrated through an explicit editor action: upload unchanged bytes with idempotency keys, then save the same node IDs/geometry with managed asset references and the current canvas version. Opening a canvas does not migrate it or cause an implicit PATCH. + +#### Atomic member drop positions + +`POST B/subgraphs/{frame}/members` additionally accepts optional `positions`, an object with exactly one `{x,y}` entry for each `node_ids` item, expressed in the **source board's local coordinates**. Both coordinates are required, finite and within ±1,000,000. Capabilities advertise `subgraphs.member_positions: true`. Omission preserves legacy behavior. Positions, ownership, edge/port conversion, versions and undo receipt commit in one transaction; no preceding coordinate PATCH is needed. Validation failures or stale versions perform zero writes. The positions participate in the idempotency payload digest; retries must keep the entire body and key unchanged. Undo restores the pre-drop snapshot, including original positions. diff --git a/internal/platform/canvas_capabilities.go b/internal/platform/canvas_capabilities.go index 9a5e33e..4b17366 100644 --- a/internal/platform/canvas_capabilities.go +++ b/internal/platform/canvas_capabilities.go @@ -47,7 +47,7 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro "contract_version": "canvas-v2-2026-11-06", "supported_schema_versions": []int{1, 2}, "edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true}, - "subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true}, + "subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true}, "ai": ai, "assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true}, "limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000}, diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index d786eff..f481e9c 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -423,7 +423,8 @@ func TestOnboardingAndSharing(t *testing.T) { } parentID := migrationBoard["id"].(string) memberPath := migrationPath + "/subgraphs/f/members" - moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}} + moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": map[string]memberDropPosition{"a": {X: 40, Y: 50}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}} + call("POST", memberPath, tokens[0], response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": response{"wrong": response{"x": 1, "y": 2}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}, 400, "", accept, "eeeeeeee-1111-4111-8111-111111111111") movedMembers := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") memberRetry := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") if movedMembers["transaction_id"] != memberRetry["transaction_id"] { @@ -435,6 +436,10 @@ func TestOnboardingAndSharing(t *testing.T) { targetRead := call("GET", targetCanvasPath, tokens[0], nil, 200, "", accept) sourceJSON, _ := json.Marshal(sourceRead["document"]) targetJSON, _ := json.Marshal(targetRead["document"]) + dropPosition := canvasNodesByID(targetJSON)["a"]["position"].(map[string]any) + if dropPosition["x"] != float64(140) || dropPosition["y"] != float64(250) { + t.Fatalf("atomic drop geometry %v", dropPosition) + } if canvasNodeData(sourceJSON)["a"] != nil || canvasNodeData(targetJSON)["a"] == nil { t.Fatal("member move duplicated or lost ownership") } diff --git a/internal/platform/member_drop.go b/internal/platform/member_drop.go new file mode 100644 index 0000000..ef3db4d --- /dev/null +++ b/internal/platform/member_drop.go @@ -0,0 +1,73 @@ +package platform + +import ( + "encoding/json" + "fmt" + "math" +) + +// Positions are expressed in the source board's local coordinates. The existing +// ownership transaction converts them into destination coordinates exactly once. +type memberDropPosition struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +func validMemberDropPositions(ids []string, positions map[string]memberDropPosition) bool { + if positions == nil { + return true + } // Backward-compatible old clients. + if len(positions) != len(ids) { + return false + } + seen := map[string]bool{} + for _, id := range ids { + p, ok := positions[id] + if !ok || seen[id] || math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.X, 0) || math.IsInf(p.Y, 0) || math.Abs(p.X) > 1e6 || math.Abs(p.Y) > 1e6 { + return false + } + seen[id] = true + } + return true +} + +func positionMemberDrop(raw json.RawMessage, positions map[string]memberDropPosition) (json.RawMessage, error) { + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, err + } + found := 0 + nodes, ok := doc["nodes"].([]any) + if !ok { + return nil, errGraphOwnership + } + for _, v := range nodes { + n, ok := v.(map[string]any) + if !ok { + return nil, errGraphOwnership + } + id, _ := n["id"].(string) + if p, ok := positions[id]; ok { + n["position"] = map[string]any{"x": p.X, "y": p.Y} + found++ + } + } + if found != len(positions) { + return nil, errGraphOwnership + } + return json.Marshal(doc) +} + +func (p *memberDropPosition) UnmarshalJSON(raw []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + if len(fields) != 2 || fields["x"] == nil || fields["y"] == nil || string(fields["x"]) == "null" || string(fields["y"]) == "null" { + return fmt.Errorf("invalid member position") + } + if err := json.Unmarshal(fields["x"], &p.X); err != nil { + return err + } + return json.Unmarshal(fields["y"], &p.Y) +} diff --git a/internal/platform/member_drop_test.go b/internal/platform/member_drop_test.go new file mode 100644 index 0000000..6f85c57 --- /dev/null +++ b/internal/platform/member_drop_test.go @@ -0,0 +1,48 @@ +package platform + +import ( + "encoding/json" + "math" + "testing" +) + +func TestMemberDropPositions(t *testing.T) { + raw := memberFixture(t) + source := "11111111-1111-4111-8111-111111111111" + target := "22222222-2222-4222-8222-222222222222" + original := string(raw[source]) + positions := map[string]memberDropPosition{"a": {X: 40, Y: 50}} + positioned, err := positionMemberDrop(raw[source], positions) + if err != nil { + t.Fatal(err) + } + raw[source] = positioned + moved, err := moveMembersDocuments("parent", source, target, []string{"a"}, raw) + if err != nil { + t.Fatal(err) + } + pos := canvasNodesByID(moved[target])["a"]["position"].(map[string]any) + if pos["x"] != float64(140) || pos["y"] != float64(250) { + t.Fatalf("final drop position %v", pos) + } + if original == string(positioned) { + t.Fatal("position not applied") + } + if _, err := positionMemberDrop(positioned, map[string]memberDropPosition{"missing": {}}); err == nil { + t.Fatal("missing member accepted") + } + if !validMemberDropPositions([]string{"a"}, nil) || !validMemberDropPositions([]string{"a"}, positions) { + t.Fatal("valid positions rejected") + } + for _, bad := range []map[string]memberDropPosition{{}, {"other": {}}, {"a": {X: 1e6 + 1}}, {"a": {Y: math.NaN()}}, {"a": {X: math.Inf(1)}}} { + if validMemberDropPositions([]string{"a"}, bad) { + t.Fatalf("bad positions accepted %v", bad) + } + } + for _, bad := range []string{`{"x":1}`, `{"x":null,"y":1}`, `{"x":1,"y":2,"z":3}`} { + var p memberDropPosition + if json.Unmarshal([]byte(bad), &p) == nil { + t.Fatal("malformed position accepted") + } + } +} diff --git a/internal/platform/subgraph_members_http.go b/internal/platform/subgraph_members_http.go index 9f3cd6c..bb8651f 100644 --- a/internal/platform/subgraph_members_http.go +++ b/internal/platform/subgraph_members_http.go @@ -22,10 +22,11 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr return nil } var input struct { - Source string `json:"source_board_id"` - Target string `json:"target_board_id"` - Nodes []string `json:"node_ids"` - Expected map[string]int64 `json:"expected_versions"` + Source string `json:"source_board_id"` + Target string `json:"target_board_id"` + Nodes []string `json:"node_ids"` + Expected map[string]int64 `json:"expected_versions"` + Positions map[string]memberDropPosition `json:"positions,omitempty"` } if !decode(w, r, &input) { return nil @@ -40,6 +41,10 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr return nil } } + if !validMemberDropPositions(input.Nodes, input.Positions) { + reject(w, 400, "invalid_subgraph_request") + return nil + } if len(input.Expected) == 0 { reject(w, 428, "version_required") return nil @@ -194,6 +199,14 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr before = append(before, snapshot) } raw := map[string]json.RawMessage{parent: snapshots[parent].Document, input.Source: snapshots[input.Source].Document, input.Target: snapshots[input.Target].Document} + if input.Positions != nil { + positioned, err := positionMemberDrop(raw[input.Source], input.Positions) + if err != nil { + reject(w, 409, "ownership_conflict") + return nil + } + raw[input.Source] = positioned + } moved, err := moveMembersDocuments(parent, input.Source, input.Target, input.Nodes, raw) if err != nil { if err == errGraphOwnership { -- 2.52.0