492 lines
17 KiB
Go
492 lines
17 KiB
Go
package platform
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
)
|
|
|
|
const modelMigrateAction = "model.migrate.v1"
|
|
const modelEditAction = "model.operations.v2"
|
|
|
|
func newModelID() string {
|
|
var value [16]byte
|
|
if _, err := rand.Read(value[:]); err != nil {
|
|
return ""
|
|
}
|
|
value[6], value[8] = value[6]&0x0f|0x40, value[8]&0x3f|0x80
|
|
text := hex.EncodeToString(value[:])
|
|
return text[:8] + "-" + text[8:12] + "-" + text[12:16] + "-" + text[16:20] + "-" + text[20:]
|
|
}
|
|
func legacyModelRoot(r *http.Request, tx *sql.Tx, project, workspace, board string) (string, error) {
|
|
seen := map[string]bool{}
|
|
for len(seen) <= 8 {
|
|
if seen[board] {
|
|
return "", errCanvasModel
|
|
}
|
|
seen[board] = true
|
|
relation, err := readSubgraphRelation(r, tx, project, workspace, board)
|
|
if err == sql.ErrNoRows {
|
|
return board, nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if relation.Archived {
|
|
return "", sql.ErrNoRows
|
|
}
|
|
board = relation.Parent
|
|
}
|
|
return "", errCanvasModel
|
|
}
|
|
func collectLegacyModel(r *http.Request, tx *sql.Tx, project, workspace, root string) (map[string]canvasSnapshot, []subgraphRelation, error) {
|
|
rows, err := tx.QueryContext(r.Context(), `WITH RECURSIVE tree(id,path) AS (SELECT $3::uuid,ARRAY[$3::uuid] UNION ALL SELECT s.child_board_id,t.path||s.child_board_id FROM tree t JOIN canvas_subgraphs s ON s.project=$1 AND s.workspace_id=$2 AND s.parent_board_id=t.id WHERE s.archived_at IS NULL AND NOT s.child_board_id=ANY(t.path) AND cardinality(t.path)<=8) SELECT m.id::text,m.canvas_version,COALESCE(m.canvas_document,'{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM tree t JOIN materials m ON m.project=$1 AND m.workspace_id=$2 AND m.id=t.id WHERE m.kind='board' ORDER BY m.id LIMIT 257`, project, workspace, root)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
snapshots := map[string]canvasSnapshot{}
|
|
for rows.Next() {
|
|
var snapshot canvasSnapshot
|
|
if err = rows.Scan(&snapshot.BoardID, &snapshot.Version, &snapshot.Document); err != nil {
|
|
rows.Close()
|
|
return nil, nil, err
|
|
}
|
|
snapshots[snapshot.BoardID] = snapshot
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(snapshots) == 0 || len(snapshots) > 256 {
|
|
return nil, nil, errGraphCapacity
|
|
}
|
|
rows, err = tx.QueryContext(r.Context(), `SELECT parent_board_id::text,frame_id,child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND archived_at IS NULL ORDER BY child_board_id`, project, workspace)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer rows.Close()
|
|
relations := []subgraphRelation{}
|
|
for rows.Next() {
|
|
var relation subgraphRelation
|
|
if err = rows.Scan(&relation.Parent, &relation.Frame, &relation.Child); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if _, exists := snapshots[relation.Parent]; exists {
|
|
relations = append(relations, relation)
|
|
}
|
|
}
|
|
return snapshots, relations, rows.Err()
|
|
}
|
|
func modelRequestRetry(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, workspace, account, root, action, key, hash string) (bool, error) {
|
|
var previousRoot, previousAction, previousHash string
|
|
var status int
|
|
var body json.RawMessage
|
|
err := tx.QueryRowContext(r.Context(), `SELECT root_id::text,action,payload_hash,response_status,response_body FROM canvas_model_requests WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND request_id=$4`, project, workspace, account, key).Scan(&previousRoot, &previousAction, &previousHash, &status, &body)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if previousRoot != root || previousAction != action || previousHash != hash {
|
|
reject(w, 409, "idempotency_conflict")
|
|
return true, nil
|
|
}
|
|
reply(w, status, body)
|
|
return true, nil
|
|
}
|
|
func commitModelReceipt(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, workspace, account, root, action, key, hash string, status int, body response) error {
|
|
body["request_id"], body["protocol_version"] = key, canvasModelProtocol
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_requests(project,workspace_id,account_id,request_id,root_id,action,protocol_version,payload_hash,response_status,response_body) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, project, workspace, account, key, root, action, canvasModelProtocol, hash, status, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
reply(w, status, body)
|
|
return nil
|
|
}
|
|
func decodeModelRequest(w http.ResponseWriter, r *http.Request, input any) bool {
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, canvasLimit))
|
|
decoder.DisallowUnknownFields()
|
|
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
|
|
reject(w, 400, "invalid_model_request")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *service) getCanvasModel(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodGet {
|
|
reject(w, 405, "method_not_allowed")
|
|
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, _, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if kind != "board" {
|
|
reject(w, 400, "invalid_material")
|
|
return nil
|
|
}
|
|
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if root == "" {
|
|
root, err = legacyModelRoot(r, tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snapshots, _, err := collectLegacyModel(r, tx, project, workspace, root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
versions := map[string]int64{}
|
|
for id, snapshot := range snapshots {
|
|
versions[id] = snapshot.Version
|
|
}
|
|
reply(w, 409, response{"error": "canonical_migration_required", "root_id": root, "source_versions": versions, "document_format_version": canvasModelFormat})
|
|
return nil
|
|
}
|
|
model, err := loadCanvasModel(r.Context(), tx, project, workspace, root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Rollback(); err != nil {
|
|
return err
|
|
}
|
|
reply(w, 200, response{"model": model.view(nil)})
|
|
return nil
|
|
}
|
|
|
|
func (s *service) migrateCanonicalCanvas(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodPost {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
var input struct {
|
|
ProtocolVersion int `json:"protocol_version"`
|
|
RequestID string `json:"request_id"`
|
|
Expected map[string]int64 `json:"expected_versions"`
|
|
}
|
|
if !decodeModelRequest(w, r, &input) {
|
|
return nil
|
|
}
|
|
key := canvasIdempotencyKey(r)
|
|
if input.ProtocolVersion != canvasModelProtocol || !uuidSlugPattern.MatchString(key) || input.RequestID != key || len(input.Expected) == 0 || len(input.Expected) > 256 {
|
|
reject(w, 400, "invalid_model_migration")
|
|
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
|
|
}
|
|
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
already := root != ""
|
|
if !already {
|
|
root, err = legacyModelRoot(r, tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
done, err := modelRequestRetry(w, r, tx, project, workspace, account, root, modelMigrateAction, key, payloadDigest(input))
|
|
if err != nil || done {
|
|
return err
|
|
}
|
|
rejectMigration := func(status int, code string) error {
|
|
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelMigrateAction, key, payloadDigest(input), status, response{"status": "not-applied", "terminal": true, "error": code})
|
|
}
|
|
if already {
|
|
return rejectMigration(409, "canonical_migration_already_applied")
|
|
}
|
|
snapshots, relations, err := collectLegacyModel(r, tx, project, workspace, root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(snapshots) != len(input.Expected) {
|
|
return rejectMigration(412, "migration_version_conflict")
|
|
}
|
|
for id, snapshot := range snapshots {
|
|
if input.Expected[id] != snapshot.Version {
|
|
return rejectMigration(412, "migration_version_conflict")
|
|
}
|
|
}
|
|
// Active or outcome-unknown paid work must settle before the identity barrier.
|
|
for id := range snapshots {
|
|
var busy bool
|
|
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND status IN('queued','running'))`, project, workspace, id).Scan(&busy); err != nil {
|
|
return err
|
|
}
|
|
if busy {
|
|
return rejectMigration(409, "migration_waiting_for_runs")
|
|
}
|
|
}
|
|
migration, err := migrateCanvasModel(root, snapshots, relations, newModelID)
|
|
if err != nil {
|
|
return rejectMigration(422, "model_migration_failed")
|
|
}
|
|
versionsRaw, _ := json.Marshal(input.Expected)
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_projects(project,workspace_id,root_id,migration_id,source_versions) VALUES($1,$2,$3,$4,$5)`, project, workspace, root, key, versionsRaw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
before := emptyModel(root)
|
|
for id, snapshot := range snapshots {
|
|
before.Versions[id] = snapshot.Version
|
|
}
|
|
participants := map[string]bool{}
|
|
for id := range migration.Model.Canvases {
|
|
participants[id] = true
|
|
}
|
|
if err = s.saveCanvasModel(r, tx, project, workspace, account, before, migration.Model, participants); err != nil {
|
|
return err
|
|
}
|
|
for _, identity := range migration.Identities {
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_identity_map(project,workspace_id,root_id,source_canvas_id,source_structure,source_element_id,element_id) VALUES($1,$2,$3,$4,$5,$6,$7)`, project, workspace, root, identity.SourceCanvasID, identity.Structure, identity.SourceID, identity.ElementID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelMigrateAction, key, payloadDigest(input), 200, response{"status": "applied", "model": migration.Model.view(nil), "identity_map": migration.Identities, "source_versions": input.Expected})
|
|
}
|
|
|
|
func (s *service) submitCanvasModelOperations(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodPost {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
var input modelBatch
|
|
if !decodeModelRequest(w, r, &input) {
|
|
return nil
|
|
}
|
|
key := canvasIdempotencyKey(r)
|
|
if input.ProtocolVersion != canvasModelProtocol || !uuidSlugPattern.MatchString(key) || input.RequestID != key || len(input.Expected) == 0 || len(input.Expected) > 256 || len(input.Operations) == 0 || len(input.Operations) > 2500 {
|
|
reject(w, 400, "invalid_model_request")
|
|
return nil
|
|
}
|
|
for id, version := range input.Expected {
|
|
if !uuidSlugPattern.MatchString(id) || version < 0 || version > 9007199254740990 {
|
|
reject(w, 400, "invalid_model_version")
|
|
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
|
|
}
|
|
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if root == "" {
|
|
reject(w, 409, "canonical_migration_required")
|
|
return nil
|
|
}
|
|
done, err := modelRequestRetry(w, r, tx, project, workspace, account, root, modelEditAction, key, payloadDigest(input))
|
|
if err != nil || done {
|
|
return err
|
|
}
|
|
rejectBatch := func(status int, code string) error {
|
|
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelEditAction, key, payloadDigest(input), status, response{"status": "not-applied", "terminal": true, "error": code})
|
|
}
|
|
before, err := loadCanvasModel(r.Context(), tx, project, workspace, root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for id, expected := range input.Expected {
|
|
if before.Versions[id] != expected {
|
|
return rejectBatch(412, "version_conflict")
|
|
}
|
|
}
|
|
for _, operation := range input.Operations {
|
|
if !uuidSlugPattern.MatchString(operation.ID) {
|
|
return rejectBatch(400, "invalid_model_operation")
|
|
}
|
|
var used bool
|
|
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_model_operations WHERE project=$1 AND workspace_id=$2 AND operation_id=$3)`, project, workspace, operation.ID).Scan(&used); err != nil {
|
|
return err
|
|
}
|
|
if used {
|
|
return rejectBatch(409, "operation_already_applied")
|
|
}
|
|
}
|
|
after, touched, err := applyModelOperations(before, input.Operations)
|
|
if err != nil {
|
|
code := "invalid_model_operation"
|
|
if err == errModelConflict {
|
|
code = "canvas_model_field_conflict"
|
|
}
|
|
return rejectBatch(409, code)
|
|
}
|
|
for id := range touched {
|
|
if _, exists := input.Expected[id]; !exists {
|
|
return rejectBatch(428, "version_required")
|
|
}
|
|
}
|
|
for id := range input.Expected {
|
|
if _, existed := before.Versions[id]; !existed {
|
|
if _, created := after.Canvases[id]; !created {
|
|
return rejectBatch(409, "invalid_model_participant")
|
|
}
|
|
var exists bool
|
|
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3)`, project, workspace, id).Scan(&exists); err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return rejectBatch(409, "canvas_identity_conflict")
|
|
}
|
|
}
|
|
if !touched[id] {
|
|
touched[id] = true
|
|
after.Versions[id] = before.Versions[id] + 1
|
|
}
|
|
}
|
|
for id, element := range after.Elements {
|
|
if before.Elements[id] == nil {
|
|
var foreign bool
|
|
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_model_elements WHERE project=$1 AND element_id=$2 AND (workspace_id<>$3 OR root_id<>$4))`, project, id, workspace, root).Scan(&foreign); err != nil {
|
|
return err
|
|
}
|
|
if foreign {
|
|
return rejectBatch(409, "element_identity_conflict")
|
|
}
|
|
}
|
|
if element["structure"] == "node" {
|
|
data := element["data"].(map[string]any)
|
|
if asset := modelString(data["assetId"]); asset != "" {
|
|
prior := before.Elements[id]
|
|
if prior == nil || !modelEqual(prior["data"].(map[string]any)["assetId"], data["assetId"]) {
|
|
readable, e := assetReadable(r, tx, project, workspace, asset, account)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if !readable {
|
|
return rejectBatch(400, "invalid_asset_reference")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for id := range after.DeadRecords {
|
|
if before.Elements[id] != nil || before.Tombstones[id] > 0 {
|
|
continue
|
|
}
|
|
var foreign bool
|
|
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_model_elements WHERE project=$1 AND element_id=$2 AND (workspace_id<>$3 OR root_id<>$4))`, project, id, workspace, root).Scan(&foreign); err != nil {
|
|
return err
|
|
}
|
|
if foreign {
|
|
return rejectBatch(409, "element_identity_conflict")
|
|
}
|
|
}
|
|
if err = s.saveCanvasModel(r, tx, project, workspace, account, before, after, touched); err != nil {
|
|
return err
|
|
}
|
|
ids := []string{}
|
|
for _, operation := range input.Operations {
|
|
ids = append(ids, operation.ID)
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_operations(project,workspace_id,operation_id,request_id,account_id) VALUES($1,$2,$3,$4,$5)`, project, workspace, operation.ID, key, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelEditAction, key, payloadDigest(input), 200, response{"status": "applied", "operation_ids": ids, "model": after.view(input.Expected)})
|
|
}
|
|
func (s *service) getCanvasModelOperation(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
|
|
}
|
|
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if root == "" {
|
|
root, err = legacyModelRoot(r, tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var body json.RawMessage
|
|
err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_model_requests WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND request_id=$4 AND root_id=$5`, project, workspace, account, key, root).Scan(&body)
|
|
if err == sql.ErrNoRows {
|
|
reply(w, 404, response{"status": "unknown", "error": "operation_receipt_unknown", "request_id": key})
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Rollback(); err != nil {
|
|
return err
|
|
}
|
|
reply(w, 200, body)
|
|
return nil
|
|
}
|
|
|
|
func modelExists(ctx context.Context, tx *sql.Tx, project, workspace, board string) (bool, error) {
|
|
root, err := canonicalRoot(ctx, tx, project, workspace, board)
|
|
return root != "", err
|
|
}
|