Files
foundry-core/internal/platform/subgraph_dissolve_http.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

214 lines
6.6 KiB
Go

package platform
import (
"encoding/json"
"net/http"
"sort"
"time"
)
func (s *service) dissolveSubgraph(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)
frame := r.PathValue("frame")
if !uuidSlugPattern.MatchString(key) || !canvasID.MatchString(frame) {
reject(w, 400, "invalid_subgraph_request")
return nil
}
var input struct {
ExpectedVersions map[string]int64 `json:"expected_versions"`
}
if !decode(w, r, &input) {
return nil
}
if len(input.ExpectedVersions) == 0 {
reject(w, 428, "version_required")
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")
_, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
action := "dissolve_subgraph/" + frame
hash := payloadDigest(input)
done, err := transactionRetry(w, r, tx, project, account, board, action, key, hash)
if err != nil {
return err
}
if done {
return nil
}
var child string
err = tx.QueryRowContext(r.Context(), `SELECT child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND frame_id=$4 AND archived_at IS NULL`, project, workspace, board, frame).Scan(&child)
if err != nil {
return err
}
relation, err := readSubgraphRelation(r, tx, project, workspace, child)
if err != nil {
return err
}
archivedRelation := relation
archivedRelation.Archived = true
changes := []subgraphRelationChange{{Before: relation, After: archivedRelation}}
participants := []string{board, child}
rows, err := tx.QueryContext(r.Context(), `SELECT child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND archived_at IS NULL ORDER BY child_board_id`, project, workspace, child)
if err != nil {
return err
}
nested := []string{}
for rows.Next() {
var id string
if err = rows.Scan(&id); err != nil {
rows.Close()
return err
}
nested = append(nested, id)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for _, id := range nested {
before, err := readSubgraphRelation(r, tx, project, workspace, id)
if err != nil {
return err
}
after := before
after.Parent = board
changes = append(changes, subgraphRelationChange{Before: before, After: after})
participants = append(participants, id)
}
parentBefore, err := loadCanvas(r, tx, project, workspace, board)
if err != nil {
return err
}
childBefore, err := loadCanvas(r, tx, project, workspace, child)
if err != nil {
return err
}
members := []string{}
for node := range canvasNodeData(childBefore.Document) {
members = append(members, node)
}
repair, err := planAncestorPortRepair(r, tx, project, workspace, board, child, account, members, parentBefore.Document)
if err != nil {
return err
}
if repair != nil {
participants = append(participants, repair.Ancestor)
}
sort.Strings(participants)
if len(input.ExpectedVersions) != len(participants) {
reject(w, 428, "version_required")
return nil
}
snapshots := map[string]canvasSnapshot{}
beforeSnapshots := []canvasSnapshot{}
for _, id := range participants {
_, role, err := lockMaterial(r.Context(), tx, project, workspace, id, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
snapshot, err := loadCanvas(r, tx, project, workspace, id)
if err != nil {
return err
}
if _, exists := input.ExpectedVersions[id]; !exists {
reject(w, 428, "version_required")
return nil
}
if input.ExpectedVersions[id] != snapshot.Version {
reject(w, 412, "version_conflict")
return nil
}
snapshots[id] = snapshot
beforeSnapshots = append(beforeSnapshots, snapshot)
}
parent, childSnapshot := snapshots[board], snapshots[child]
parentDoc, childDoc, err := dissolveDocuments(parent.Document, childSnapshot.Document, frame)
if err != nil {
if err == errGraphOwnership {
reject(w, 409, "ownership_conflict")
} else {
reject(w, 413, "graph_limit_exceeded")
}
return nil
}
if repair != nil {
documents := map[string]json.RawMessage{board: parentDoc, repair.Ancestor: snapshots[repair.Ancestor].Document}
if err = applyAncestorPortRepair(repair, board, board, documents); err != nil {
return err
}
parentDoc = documents[board]
ancestor := snapshots[repair.Ancestor]
ancestor.Document = documents[repair.Ancestor]
snapshots[repair.Ancestor] = ancestor
}
parent.Document = parentDoc
childSnapshot.Document = childDoc
snapshots[board] = parent
snapshots[child] = childSnapshot
// Reparent nested children first. The archived child keeps a recovery record,
// while live ownership immediately follows the nodes flattened into the parent.
for i := len(changes) - 1; i >= 0; i-- {
if err = writeSubgraphRelation(r, tx, project, workspace, changes[i].After); err != nil {
return err
}
}
updated := []canvasSnapshot{}
versions := map[string]int64{}
for _, id := range participants {
snapshot := snapshots[id]
snapshot.Version++
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
return err
}
updated = append(updated, snapshot)
versions[id] = snapshot.Version
snapshots[id] = snapshot
}
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": snapshots[board], "snapshots": updated, "archived_board_ids": []string{child}, "undo_receipt": response{"transaction_id": id, "valid_for_seconds": 604800, "expires_at": receiptExpiry}}
encoded, _ := json.Marshal(result)
beforeJSON, _ := json.Marshal(beforeSnapshots)
versionsJSON, _ := json.Marshal(versions)
relationsJSON, _ := json.Marshal(changes)
_, 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,relation_changes) VALUES($1,$2,$3,$4,$5,$6,$7,$8,200,$9,$10,$11,$12)`, project, workspace, board, id, account, action, key, hash, encoded, beforeJSON, versionsJSON, relationsJSON)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, versions[board])
reply(w, 200, result)
return nil
}