Files
codex-bot d6ee8942f1
Foundry Go checks / check (push) Successful in 2m40s
Foundry Go checks / check (pull_request) Successful in 2m36s
Implement Canvas V2 subgraphs, assets and asynchronous AI execution
2026-11-06 18:36:00 +08:00

165 lines
4.8 KiB
Go

package platform
import (
"database/sql"
"encoding/json"
"net/http"
"reflect"
"strings"
)
func validCanvasFrameExtensions(d map[string]any, v2 bool) bool {
for _, key := range []string{"subgraph", "ports", "presentation"} {
value, exists := d[key]
if !exists {
continue
}
if !v2 || d["kind"] != "frame" {
return false
}
switch key {
case "subgraph":
m, ok := value.(map[string]any)
if !ok || !canvasFields(m, "boardId") {
return false
}
id, ok := m["boardId"].(string)
if !ok || !uuidSlugPattern.MatchString(id) {
return false
}
case "ports":
ports, ok := value.([]any)
if !ok || len(ports) > 4000 {
return false
}
seen := map[string]bool{}
for _, v := range ports {
p, ok := v.(map[string]any)
if !ok || !canvasFields(p, "id", "direction", "nodeId", "handle") {
return false
}
id, ok := p["id"].(string)
if !ok || !canvasID.MatchString(id) || seen[id] {
return false
}
seen[id] = true
node, ok := p["nodeId"].(string)
if !ok || !canvasID.MatchString(node) || !canvasChoice(p["direction"], "input", "output") || !validCanvasPortHandle(p["handle"]) {
return false
}
}
case "presentation":
m, ok := value.(map[string]any)
if !ok || !canvasFields(m, "collapsed", "contentScale", "expandedSize", "collapsedSize") {
return false
}
if v, exists := m["collapsed"]; exists {
if _, ok := v.(bool); !ok {
return false
}
}
if v, exists := m["contentScale"]; exists && !canvasNumber(v, .05, 4) {
return false
}
for _, key := range []string{"expandedSize", "collapsedSize"} {
if v, exists := m[key]; exists {
size, ok := v.(map[string]any)
if !ok || !canvasFields(size, "width", "height") || !canvasNumber(size["width"], 32, 100000) || !canvasNumber(size["height"], 32, 100000) {
return false
}
}
}
}
}
return true
}
func canvasNodeData(raw json.RawMessage) map[string]map[string]any {
var doc map[string]any
_ = json.Unmarshal(raw, &doc)
result := map[string]map[string]any{}
nodes, _ := doc["nodes"].([]any)
for _, v := range nodes {
n := v.(map[string]any)
result[n["id"].(string)] = n["data"].(map[string]any)
}
return result
}
// Ordinary saves cannot create, replace, detach or retarget ownership/ports.
// Cross-board commands validate these changes while holding the workspace lock.
func validateCanvasRelations(r *http.Request, tx *sql.Tx, project, workspace, board string, before, after json.RawMessage) (bool, error) {
old, next := canvasNodeData(before), canvasNodeData(after)
rows, err := tx.QueryContext(r.Context(), `SELECT frame_id,child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND archived_at IS NULL`, project, workspace, board)
if err != nil {
return false, err
}
linked := map[string]string{}
for rows.Next() {
var frame, child string
if err = rows.Scan(&frame, &child); err != nil {
rows.Close()
return false, err
}
linked[frame] = child
}
err = rows.Err()
rows.Close()
if err != nil {
return false, err
}
for frame, child := range linked {
d := next[frame]
sub, _ := d["subgraph"].(map[string]any)
if d["kind"] != "frame" || sub["boardId"] != child || !reflect.DeepEqual(old[frame]["ports"], d["ports"]) {
return false, nil
}
}
for id, d := range next {
if sub, exists := d["subgraph"]; exists {
m, _ := sub.(map[string]any)
if linked[id] == "" || m["boardId"] != linked[id] {
return false, nil
}
}
if ports, ok := d["ports"].([]any); ok && len(ports) > 0 && linked[id] == "" {
return false, nil
}
if frame, ok := d["frameId"].(string); ok && linked[frame] != "" {
return false, nil
}
}
// A child node referenced by its parent's ports must not disappear or change kind.
var parentRaw json.RawMessage
err = tx.QueryRowContext(r.Context(), `SELECT m.canvas_document FROM canvas_subgraphs s JOIN materials m ON m.project=s.project AND m.workspace_id=s.workspace_id AND m.id=s.parent_board_id WHERE s.project=$1 AND s.workspace_id=$2 AND s.child_board_id=$3 AND s.archived_at IS NULL`, project, workspace, board).Scan(&parentRaw)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
for _, d := range canvasNodeData(parentRaw) {
sub, _ := d["subgraph"].(map[string]any)
if sub["boardId"] != board {
continue
}
ports, _ := d["ports"].([]any)
for _, v := range ports {
p := v.(map[string]any)
id, _ := p["nodeId"].(string)
if next[id] == nil || next[id]["kind"] != old[id]["kind"] {
return false, nil
}
}
}
return true, nil
}
func validCanvasPortHandle(value any) bool {
if canvasChoice(value, "top", "right", "bottom", "left") {
return true
}
h, ok := value.(string)
return ok && strings.HasPrefix(h, "port:") && canvasID.MatchString(strings.TrimPrefix(h, "port:"))
}