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

185 lines
6.7 KiB
Go

package platform
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"time"
)
type canvasSnapshot struct {
BoardID string `json:"board_id"`
Version int64 `json:"version"`
Document json.RawMessage `json:"document"`
}
func loadCanvas(r *http.Request, tx *sql.Tx, project, workspace, board string) (canvasSnapshot, error) {
result := canvasSnapshot{BoardID: board}
err := tx.QueryRowContext(r.Context(), `SELECT canvas_version,COALESCE(canvas_document,'{"schema_version":1,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 FOR UPDATE`, project, workspace, board).Scan(&result.Version, &result.Document)
return result, err
}
func (s *service) createSubgraph(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
if !acceptsCanvasV2(r) {
reject(w, 409, "canvas_schema_upgrade_required")
return nil
}
key := canvasIdempotencyKey(r)
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_idempotency_key")
return nil
}
expected, ok := expectedVersion(w, r)
if !ok {
return nil
}
var input struct {
Frame struct {
ID string `json:"id"`
Title string `json:"title"`
Position map[string]any `json:"position"`
Width float64 `json:"width"`
Height float64 `json:"height"`
LabelMode string `json:"labelMode"`
} `json:"frame"`
}
if !decode(w, r, &input) {
return nil
}
f := input.Frame
if !canvasID.MatchString(f.ID) || !canvasString(f.Title, 300) || !canvasPoint(f.Position) || !canvasNumber(f.Width, 32, 100000) || !canvasNumber(f.Height, 32, 100000) || !canvasChoice(f.LabelMode, "hidden", "fixed", "scaled") {
reject(w, 400, "invalid_canvas")
return nil
}
payload, _ := json.Marshal(input)
digest := sha256.Sum256(payload)
hash := hex.EncodeToString(digest[:])
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
workspace, board := r.PathValue("workspace"), r.PathValue("material")
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
if err != nil {
return err
}
if kind != "board" {
reject(w, 400, "invalid_material")
return nil
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
var previousHash string
var previous json.RawMessage
var status int
err = tx.QueryRowContext(r.Context(), `SELECT payload_hash,response_status,response_body FROM canvas_transactions WHERE project=$1 AND account_id=$2 AND board_id=$3 AND action='create_subgraph' AND idempotency_key=$4 AND workspace_id=$5`, project, account, board, key, workspace).Scan(&previousHash, &status, &previous)
if err == nil {
if previousHash != hash {
reject(w, 409, "idempotency_conflict")
return nil
}
var result struct {
Parent canvasSnapshot `json:"parent"`
}
_ = json.Unmarshal(previous, &result)
setVersion(w, result.Parent.Version)
reply(w, status, previous)
return nil
}
if err != sql.ErrNoRows {
return err
}
parent, err := loadCanvas(r, tx, project, workspace, board)
if err != nil {
return err
}
if expected != parent.Version {
reject(w, 412, "version_conflict")
return nil
}
var doc map[string]any
if err = json.Unmarshal(parent.Document, &doc); err != nil {
return err
}
nodes := doc["nodes"].([]any)
for _, v := range nodes {
if v.(map[string]any)["id"] == f.ID {
reject(w, 409, "ownership_conflict")
return nil
}
}
if len(nodes) >= 500 {
reject(w, 413, "graph_limit_exceeded")
return nil
}
var childID, slug string
err = tx.QueryRowContext(r.Context(), `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board',$3,$4,'{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}') RETURNING id::text,slug`, project, workspace, f.Title, account).Scan(&childID, &slug)
if err != nil {
return err
}
if err = validateSubgraphPlacement(r, tx, project, workspace, board, childID); err != nil {
return err
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_subgraphs(project,workspace_id,parent_board_id,frame_id,child_board_id) VALUES($1,$2,$3,$4,$5)`, project, workspace, board, f.ID, childID)
if err != nil {
return err
}
node := map[string]any{"id": f.ID, "type": "frame", "position": f.Position, "width": f.Width, "height": f.Height, "data": map[string]any{
"kind": "frame", "title": f.Title, "text": "", "rotation": 0, "labelMode": f.LabelMode,
"subgraph": map[string]any{"boardId": childID}, "ports": []any{},
"presentation": map[string]any{"collapsed": false, "contentScale": 1, "expandedSize": map[string]any{"width": f.Width, "height": f.Height}, "collapsedSize": map[string]any{"width": 240, "height": 140}},
}}
doc["nodes"] = append(nodes, node)
doc["schema_version"] = 2
raw, _ := json.Marshal(doc)
if len(raw) > canvasLimit {
reject(w, 413, "canvas_too_large")
return nil
}
normalized, ok := normalizeCanvas(raw)
if !ok {
reject(w, 400, "invalid_canvas")
return nil
}
before := parent
parent.Document = normalized
parent.Version++
_, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=$5 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, board, parent.Document, parent.Version)
if err != nil {
return err
}
child, err := loadCanvas(r, tx, project, workspace, childID)
if err != nil {
return err
}
var transaction string
var receiptExpiry time.Time
err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text,now()+interval '7 days'`).Scan(&transaction, &receiptExpiry)
if err != nil {
return err
}
result := response{"transaction_id": transaction, "frame_id": f.ID, "child_board": response{"id": childID, "slug": slug, "workspace_id": workspace}, "parent": parent, "child": child, "undo_receipt": response{"transaction_id": transaction, "valid_for_seconds": 604800, "expires_at": receiptExpiry}}
encoded, _ := json.Marshal(result)
savedBefore, _ := json.Marshal([]canvasSnapshot{before})
versions, _ := json.Marshal(map[string]int64{board: parent.Version, childID: child.Version})
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions) VALUES($1,$2,$3,$4,$5,'create_subgraph',$6,$7,201,$8,$9,$10)`, project, workspace, board, transaction, account, key, hash, encoded, savedBefore, versions)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, parent.Version)
reply(w, 201, result)
return nil
}