Files
foundry-core/internal/platform/canvas_ai_reader.go
T
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

276 lines
8.1 KiB
Go

package platform
import (
"database/sql"
"encoding/json"
"net/http"
"sort"
"strings"
)
type canvasAIReader struct {
Load func(string) (canvasSnapshot, error)
Parent func(string) (subgraphRelation, error)
snapshots map[string]canvasSnapshot
documents map[string]map[string]any
parents map[string]subgraphRelation
parentKnown map[string]bool
used map[string]map[string]bool
}
func (reader *canvasAIReader) snapshot(board string) (canvasSnapshot, map[string]any, error) {
if snapshot, ok := reader.snapshots[board]; ok {
return snapshot, reader.documents[board], nil
}
snapshot, err := reader.Load(board)
if err != nil {
return canvasSnapshot{}, nil, err
}
var doc map[string]any
if err = json.Unmarshal(snapshot.Document, &doc); err != nil {
return canvasSnapshot{}, nil, err
}
reader.snapshots[board] = snapshot
reader.documents[board] = doc
return snapshot, doc, nil
}
func (reader *canvasAIReader) parent(board string) (subgraphRelation, error) {
if reader.parentKnown[board] {
return reader.parents[board], nil
}
relation, err := reader.Parent(board)
if err == sql.ErrNoRows {
err = nil
relation = subgraphRelation{}
}
if err != nil {
return relation, err
}
reader.parentKnown[board] = true
reader.parents[board] = relation
return relation, nil
}
func (reader *canvasAIReader) node(board, node string) (map[string]any, error) {
_, doc, err := reader.snapshot(board)
if err != nil {
return nil, err
}
for _, value := range doc["nodes"].([]any) {
n := value.(map[string]any)
if n["id"] == node {
return n["data"].(map[string]any), nil
}
}
return nil, errGraphOwnership
}
func (reader *canvasAIReader) resolve(board, node, handle, direction string, depth int) (string, error) {
if depth > 8 {
return "", errGraphCapacity
}
data, err := reader.node(board, node)
if err != nil {
return "", err
}
if data["kind"] != "frame" {
if !canvasChoice(data["kind"], "text", "script", "plain-text", "image", "plain-image") {
return "", errGraphOwnership
}
return canvasNodeKey(board, node), nil
}
sub, _ := data["subgraph"].(map[string]any)
child, _ := sub["boardId"].(string)
relation, err := reader.parent(child)
if err != nil {
return "", err
}
if relation.Parent != board || relation.Frame != node || relation.Archived {
return "", errGraphOwnership
}
ports, _ := data["ports"].([]any)
for _, value := range ports {
p := value.(map[string]any)
if p["direction"] == direction && "port:"+p["id"].(string) == handle {
return reader.resolve(child, p["nodeId"].(string), p["handle"].(string), direction, depth+1)
}
}
return "", errGraphOwnership
}
// Follow only real edges and the port chains exposing this node. Empty or
// unrelated child boards are never traversed merely because their frame exists.
func (reader *canvasAIReader) edges(board, node string, outgoing bool) ([]canvasAIEdgePlan, error) {
type exposure struct {
Board, Node, Handle string
Depth int
}
queue := []exposure{{Board: board, Node: node}}
seen := map[string]bool{}
found := map[string]canvasAIEdgePlan{}
end, direction := "target", "input"
if outgoing {
end, direction = "source", "output"
}
key := canvasNodeKey(board, node)
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
identity := current.Board + "/" + current.Node + "/" + current.Handle
if seen[identity] {
continue
}
seen[identity] = true
if current.Depth > 8 {
return nil, errGraphCapacity
}
_, doc, err := reader.snapshot(current.Board)
if err != nil {
return nil, err
}
for _, value := range doc["edges"].([]any) {
edge := value.(map[string]any)
if edge[end] != current.Node {
continue
}
handle, _ := edge[end+"Handle"].(string)
if current.Handle != "" && handle != current.Handle {
continue
}
data, _ := edge["data"].(map[string]any)
execution, _ := data["execution"].(map[string]any)
if execution["enabled"] == false {
continue
}
sh, _ := edge["sourceHandle"].(string)
th, _ := edge["targetHandle"].(string)
source, err := reader.resolve(current.Board, edge["source"].(string), sh, "output", 0)
if err != nil {
return nil, err
}
target, err := reader.resolve(current.Board, edge["target"].(string), th, "input", 0)
if err != nil {
return nil, err
}
if (outgoing && source != key) || (!outgoing && target != key) {
continue
}
id := edge["id"].(string)
if reader.used[current.Board] == nil {
reader.used[current.Board] = map[string]bool{}
}
reader.used[current.Board][id] = true
logical := canvasAIEdgePlan{ID: canvasNodeKey(current.Board, id), Source: source, Target: target}
found[logical.ID] = logical
}
relation, err := reader.parent(current.Board)
if err != nil {
return nil, err
}
if relation.Parent == "" {
continue
}
if relation.Archived {
return nil, errGraphOwnership
}
frame, err := reader.node(relation.Parent, relation.Frame)
if err != nil {
return nil, err
}
sub, _ := frame["subgraph"].(map[string]any)
if sub["boardId"] != current.Board {
return nil, errGraphOwnership
}
ports, _ := frame["ports"].([]any)
for _, value := range ports {
p := value.(map[string]any)
if p["nodeId"] == current.Node && p["direction"] == direction && (current.Handle == "" || p["handle"] == current.Handle) {
queue = append(queue, exposure{Board: relation.Parent, Node: relation.Frame, Handle: "port:" + p["id"].(string), Depth: current.Depth + 1})
}
}
}
result := []canvasAIEdgePlan{}
for _, edge := range found {
result = append(result, edge)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result, nil
}
func (reader *canvasAIReader) plan(board, node string, propagate bool) (canvasAIPlan, error) {
reader.snapshots = map[string]canvasSnapshot{}
reader.documents = map[string]map[string]any{}
reader.parents = map[string]subgraphRelation{}
reader.parentKnown = map[string]bool{}
reader.used = map[string]map[string]bool{}
queue := []string{canvasNodeKey(board, node)}
selected := map[string]bool{}
for len(queue) > 0 {
key := queue[0]
queue = queue[1:]
if selected[key] {
continue
}
selected[key] = true
if len(selected) > 64 {
return canvasAIPlan{}, errGraphCapacity
}
parts := strings.SplitN(key, "/", 2)
data, err := reader.node(parts[0], parts[1])
if err != nil {
return canvasAIPlan{}, err
}
ai, _ := data["ai"].(map[string]any)
model, _ := ai["modelId"].(string)
if key != canvasNodeKey(board, node) && (ai["enabled"] != true || model == "") {
continue
}
if _, err = reader.edges(parts[0], parts[1], false); err != nil {
return canvasAIPlan{}, err
}
if propagate {
edges, err := reader.edges(parts[0], parts[1], true)
if err != nil {
return canvasAIPlan{}, err
}
for _, edge := range edges {
queue = append(queue, edge.Target)
}
}
}
filtered := map[string]canvasSnapshot{}
for id, snapshot := range reader.snapshots {
doc := reader.documents[id]
copy := map[string]any{}
for key, value := range doc {
copy[key] = value
}
edges := []any{}
for _, value := range doc["edges"].([]any) {
edge := value.(map[string]any)
if reader.used[id][edge["id"].(string)] {
edges = append(edges, value)
}
}
copy["edges"] = edges
snapshot.Document, _ = json.Marshal(copy)
filtered[id] = snapshot
}
return buildCanvasAIPlan(filtered, board, node, propagate)
}
func loadCanvasAIPlan(r *http.Request, tx *sql.Tx, project, workspace, account string, parent canvasSnapshot, node string, propagate bool) (canvasAIPlan, error) {
reader := canvasAIReader{Load: func(board string) (canvasSnapshot, error) {
_, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
if err != nil {
return canvasSnapshot{}, err
}
if role == "viewer" {
return canvasSnapshot{}, sql.ErrNoRows
}
if board == parent.BoardID {
return parent, nil
}
return loadCanvas(r, tx, project, workspace, board)
}, Parent: func(board string) (subgraphRelation, error) {
return readSubgraphRelation(r, tx, project, workspace, board)
}}
return reader.plan(parent.BoardID, node, propagate)
}