Files
foundry-core/internal/platform/canvas_operations_http.go
T
codex-bot 19884b2a90
Foundry Go checks / check (push) Successful in 2m40s
Foundry Go checks / check (pull_request) Successful in 2m39s
Stage atomic canvas operation batches and durable receipts
2026-11-07 22:05:06 +08:00

219 lines
7.9 KiB
Go

package platform
import (
"database/sql"
"encoding/json"
"io"
"net/http"
"sort"
"time"
)
const canvasOperationsAction = "headless.operations.v1"
func (s *service) submitCanvasOperations(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
}
var input canvasOperationBatch
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, canvasLimit))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF || input.ProtocolVersion != canvasOperationsProtocol || input.RequestID != key || len(input.Operations) == 0 || len(input.Operations) > 2500 || len(input.ExpectedVersions) == 0 || len(input.ExpectedVersions) > 64 {
reject(w, 400, "invalid_canvas_operation_batch")
return nil
}
workspace, board := r.PathValue("workspace"), r.PathValue("material")
if _, ok := input.ExpectedVersions[board]; !ok {
reject(w, 428, "version_required")
return nil
}
participants := make([]string, 0, len(input.ExpectedVersions))
for id, version := range input.ExpectedVersions {
if !uuidSlugPattern.MatchString(id) || version < 1 {
reject(w, 400, "invalid_canvas_operation_batch")
return nil
}
participants = append(participants, id)
}
for _, operation := range input.Operations {
if _, ok := input.ExpectedVersions[operation.CanvasID]; !ok {
reject(w, 428, "version_required")
return nil
}
}
sort.Strings(participants)
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
// lockMaterial serializes the workspace; sorted participants preserve a stable lock order.
for _, id := range participants {
kind, role, e := lockMaterial(r.Context(), tx, project, workspace, id, account)
if e != nil {
return e
}
if kind != "board" {
reject(w, 400, "invalid_material")
return nil
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
}
// Resolve the same request before comparing expected versions: exact retry after lost ACK succeeds.
done, err := transactionRetry(w, r, tx, project, account, board, canvasOperationsAction, key, payloadDigest(input))
if err != nil {
return err
}
if done {
return nil
}
before := make([]canvasSnapshot, 0, len(participants))
initial := map[string]canvasSnapshot{}
for _, id := range participants {
snapshot, e := loadCanvas(r, tx, project, workspace, id)
if e != nil {
return e
}
if snapshot.Version != input.ExpectedVersions[id] {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 412, response{"error": "version_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
initial[id] = snapshot
before = append(before, snapshot)
}
staged, err := applyCanvasOperations(initial, input.Operations)
if err != nil {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_canvas_operation", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
snapshots := make([]canvasSnapshot, 0, len(participants))
versions := map[string]int64{}
// Validate all participant documents before writing any of them.
for _, id := range participants {
snapshot := staged[id]
valid, e := validateCanvasRelations(r, tx, project, workspace, id, initial[id].Document, snapshot.Document)
if e != nil {
return e
}
if !valid {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 409, response{"error": "ownership_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
if !s.validateCanvasAIModels(w, r, snapshot.Document) {
return nil
}
valid, e = s.validateCanvasAssets(r, tx, project, workspace, account, snapshot.Document)
if e != nil {
return e
}
if !valid {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_asset_reference", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
snapshots = append(snapshots, snapshot)
versions[id] = snapshot.Version
}
for _, snapshot := range snapshots {
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
return err
}
}
operationIDs := make([]string, 0, len(input.Operations))
for _, operation := range input.Operations {
operationIDs = append(operationIDs, operation.ID)
}
body := response{"protocol_version": canvasOperationsProtocol, "status": "applied", "request_id": key, "operation_ids": operationIDs, "snapshots": snapshots, "applied_versions": versions, "parent": staged[board]}
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 200, body, before, versions)
}
// A terminal rejection also reserves the frozen key/body. Losing that response must
// not turn a definite zero-write result into a future execution of the same request.
func commitCanvasOperationReceipt(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, account, board string, input canvasOperationBatch, status int, body response, before []canvasSnapshot, versions map[string]int64) error {
var transactionID string
if err := tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&transactionID); err != nil {
return err
}
body["transaction_id"] = transactionID
body["protocol_version"] = canvasOperationsProtocol
expires := time.Now().Add(7 * 24 * time.Hour)
bodyRaw, err := json.Marshal(body)
if err != nil {
return err
}
beforeRaw, err := json.Marshal(before)
if err != nil {
return err
}
versionsRaw, err := json.Marshal(versions)
if err != nil {
return err
}
_, 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,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, project, r.PathValue("workspace"), board, transactionID, account, canvasOperationsAction, input.RequestID, payloadDigest(input), status, bodyRaw, beforeRaw, versionsRaw, expires)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
if version := versions[board]; version > 0 {
setVersion(w, version)
}
reply(w, status, body)
return nil
}
func (s *service) getCanvasOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
key := r.PathValue("request")
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_request_id")
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")
if _, _, err = lockMaterial(r.Context(), tx, project, workspace, board, account); err != nil {
return err
}
var raw json.RawMessage
err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_transactions WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND action=$5 AND idempotency_key=$6`, project, workspace, board, account, canvasOperationsAction, key).Scan(&raw)
if err == sql.ErrNoRows {
reply(w, 404, response{"status": "unknown", "error": "operation_receipt_unknown", "request_id": key})
return nil
}
if err != nil {
return err
}
var result struct {
Snapshots []canvasSnapshot `json:"snapshots"`
}
if err = json.Unmarshal(raw, &result); err != nil {
return err
}
for _, snapshot := range result.Snapshots {
if _, _, err = lockMaterial(r.Context(), tx, project, workspace, snapshot.BoardID, account); err != nil {
return err
}
}
if err = tx.Rollback(); err != nil {
return err
}
reply(w, 200, raw)
return nil
}