219 lines
7.9 KiB
Go
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
|
|
}
|