Files
foundry-core/internal/platform/subgraph_migration_http.go
codex-bot 56b6181598
Foundry Go checks / check (push) Failing after 2m49s
Foundry Go checks / check (pull_request) Failing after 2m41s
Resolve canonical AI targets at their current canvas owner
2026-11-08 01:16:28 +08:00

224 lines
8.0 KiB
Go

package platform
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"time"
)
func transactionRetry(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, account, board, action, key, hash string) (bool, error) {
var previousHash string
var status int
var previous json.RawMessage
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=$4 AND idempotency_key=$5 AND workspace_id=$6`, project, account, board, action, key, r.PathValue("workspace")).Scan(&previousHash, &status, &previous)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
if hash != previousHash {
reject(w, 409, "idempotency_conflict")
return true, nil
}
var result struct {
Parent canvasSnapshot `json:"parent"`
}
_ = json.Unmarshal(previous, &result)
if result.Parent.Version > 0 {
setVersion(w, result.Parent.Version)
}
reply(w, status, previous)
return true, nil
}
func payloadDigest(v any) string {
raw, _ := json.Marshal(v)
h := sha256.Sum256(raw)
return hex.EncodeToString(h[:])
}
func (s *service) saveCanvas(r *http.Request, tx *sql.Tx, project, workspace string, snapshot canvasSnapshot) error {
var before json.RawMessage
if err := tx.QueryRowContext(r.Context(), `SELECT 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, snapshot.BoardID).Scan(&before); err != nil {
return err
}
root, err := canonicalRoot(r.Context(), tx, project, workspace, snapshot.BoardID)
if err != nil {
return err
}
// Canonical lifecycle invalidation runs once against normalized identities.
// Its compatibility projection must not mistake an owner move for deletion.
if root == "" {
if err := invalidateCanvasAITargets(r, tx, project, workspace, snapshot.BoardID, before, snapshot.Document); err != nil {
return err
}
}
if err := s.attachCanvasAssets(r, tx, project, workspace, snapshot.BoardID, snapshot.Document); err != nil {
return err
}
_, 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, snapshot.BoardID, snapshot.Document, snapshot.Version)
return err
}
func (s *service) migrateSubgraphs(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
}
var input struct {
DryRun *bool `json:"dry_run"`
PlanID string `json:"plan_id"`
}
if !decode(w, r, &input) {
return nil
}
if input.DryRun == nil {
reject(w, 400, "invalid_migration_request")
return nil
}
key := canvasIdempotencyKey(r)
var expected int64
if !*input.DryRun {
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_idempotency_key")
return nil
}
var ok bool
expected, ok = expectedVersion(w, r)
if !ok {
return nil
}
}
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
}
if !*input.DryRun {
done, err := transactionRetry(w, r, tx, project, account, board, "migrate_subgraphs", key, payloadDigest(input))
if err != nil {
return err
}
if done {
return nil
}
}
parent, err := loadCanvas(r, tx, project, workspace, board)
if err != nil {
return err
}
plan := legacyPlan(project, workspace, parent)
// Preflight runs the actual transform with placeholder canonical IDs. It
// never inserts materials or relation records.
placeholders := map[string]string{}
for _, f := range plan.Frames {
placeholders[f.FrameID] = "00000000-0000-0000-0000-000000000000"
}
if len(placeholders) > 0 {
if _, _, err := migrateLegacyDocument(parent.Document, placeholders); err != nil {
plan.Issues = append(plan.Issues, "invalid_geometry_or_capacity")
}
}
var depth int
err = tx.QueryRowContext(r.Context(), `WITH RECURSIVE ancestors AS (SELECT $3::uuid AS id,0 AS depth UNION ALL SELECT s.parent_board_id,a.depth+1 FROM ancestors a JOIN canvas_subgraphs s ON s.project=$1 AND s.workspace_id=$2 AND s.child_board_id=a.id WHERE a.depth<8 AND s.archived_at IS NULL) SELECT max(depth) FROM ancestors`, project, workspace, board).Scan(&depth)
if err != nil {
return err
}
if depth >= 8 && plan.ChildCount > 0 {
plan.Issues = append(plan.Issues, "subgraph_depth_exceeded")
}
if *input.DryRun {
setVersion(w, parent.Version)
reply(w, 200, plan)
return nil
}
if expected != parent.Version || input.PlanID != plan.PlanID {
reject(w, 412, "version_conflict")
return nil
}
if len(plan.Issues) > 0 {
reply(w, 413, response{"error": "graph_limit_exceeded", "details": plan.Issues})
return nil
}
before := parent
children := map[string]string{}
childInfo := []any{}
nodeData := canvasNodeData(parent.Document)
for _, frame := range plan.Frames {
var child, slug string
err = tx.QueryRowContext(r.Context(), `INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'board',$3,$4) RETURNING id::text,slug`, project, workspace, nodeData[frame.FrameID]["title"], account).Scan(&child, &slug)
if err != nil {
return err
}
source, _ := json.Marshal(response{"frame_id": frame.FrameID, "source_digest": plan.SourceDigest})
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_subgraphs(project,workspace_id,parent_board_id,frame_id,child_board_id,migration_source) VALUES($1,$2,$3,$4,$5,$6)`, project, workspace, board, frame.FrameID, child, source)
if err != nil {
return err
}
children[frame.FrameID] = child
childInfo = append(childInfo, response{"frame_id": frame.FrameID, "id": child, "slug": slug, "workspace_id": workspace})
}
snapshots := []canvasSnapshot{}
versions := map[string]int64{board: parent.Version}
if len(children) > 0 {
migrated, documents, err := migrateLegacyDocument(parent.Document, children)
if err != nil {
return err
}
parent.Document = migrated
parent.Version++
if err = s.saveCanvas(r, tx, project, workspace, parent); err != nil {
return err
}
versions[board] = parent.Version
for _, f := range plan.Frames {
child := canvasSnapshot{BoardID: children[f.FrameID], Version: 1, Document: documents[f.FrameID]}
if err = s.saveCanvas(r, tx, project, workspace, child); err != nil {
return err
}
snapshots = append(snapshots, child)
versions[child.BoardID] = 1
}
}
var id string
var receiptExpiry time.Time
err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text,now()+interval '7 days'`).Scan(&id, &receiptExpiry)
if err != nil {
return err
}
result := response{"transaction_id": id, "parent": parent, "children": snapshots, "child_boards": childInfo, "plan": plan, "migrated_count": len(children), "undo_receipt": response{"transaction_id": id, "valid_for_seconds": 604800, "expires_at": receiptExpiry}}
responseJSON, _ := json.Marshal(result)
beforeJSON, _ := json.Marshal([]canvasSnapshot{before})
versionsJSON, _ := json.Marshal(versions)
_, 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,'migrate_subgraphs',$6,$7,200,$8,$9,$10)`, project, workspace, board, id, account, key, payloadDigest(input), responseJSON, beforeJSON, versionsJSON)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, parent.Version)
reply(w, 200, result)
return nil
}