Stage atomic canvas operation batches and durable receipts
Foundry Go checks / check (push) Successful in 2m40s
Foundry Go checks / check (pull_request) Successful in 2m39s

This commit is contained in:
2026-11-07 22:05:06 +08:00
parent 9f01aa3db8
commit 19884b2a90
5 changed files with 596 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
package platform
import (
"encoding/json"
"errors"
"fmt"
)
const canvasOperationsProtocol = 1
type canvasOperation struct {
ID string `json:"id"`
Type string `json:"type"`
CanvasID string `json:"canvas_id"`
ElementID string `json:"element_id"`
Record json.RawMessage `json:"record,omitempty"`
Patch map[string]json.RawMessage `json:"patch,omitempty"`
}
type canvasOperationBatch struct {
ProtocolVersion int `json:"protocol_version"`
RequestID string `json:"request_id"`
ExpectedVersions map[string]int64 `json:"expected_versions"`
Operations []canvasOperation `json:"operations"`
}
var errInvalidCanvasOperation = errors.New("invalid_canvas_operation")
// applyCanvasOperations stages a complete batch. Input snapshots are never mutated.
// HTTP/auth/version/idempotency are checked by the transaction coordinator.
func applyCanvasOperations(initial map[string]canvasSnapshot, operations []canvasOperation) (map[string]canvasSnapshot, error) {
if len(operations) == 0 || len(operations) > 2500 {
return nil, errInvalidCanvasOperation
}
documents := make(map[string]map[string]json.RawMessage, len(initial))
for id, snapshot := range initial {
var document map[string]json.RawMessage
if json.Unmarshal(snapshot.Document, &document) != nil {
return nil, errInvalidCanvasOperation
}
documents[id] = document
}
seen := map[string]bool{}
for _, operation := range operations {
if !uuidSlugPattern.MatchString(operation.ID) || seen[operation.ID] || !canvasID.MatchString(operation.ElementID) {
return nil, errInvalidCanvasOperation
}
seen[operation.ID] = true
document := documents[operation.CanvasID]
if document == nil {
return nil, errInvalidCanvasOperation
}
collection := ""
switch operation.Type {
case "node.create", "node.patch", "node.delete":
collection = "nodes"
case "edge.create", "edge.patch", "edge.delete":
collection = "edges"
default:
return nil, errInvalidCanvasOperation
}
var records []map[string]json.RawMessage
if json.Unmarshal(document[collection], &records) != nil {
return nil, errInvalidCanvasOperation
}
index := -1
for i, record := range records {
var id string
_ = json.Unmarshal(record["id"], &id)
if id == operation.ElementID {
index = i
break
}
}
switch operation.Type {
case "node.create", "edge.create":
if index >= 0 || len(operation.Record) == 0 || len(operation.Patch) != 0 {
return nil, fmt.Errorf("%w: create conflict", errInvalidCanvasOperation)
}
var record map[string]json.RawMessage
var id string
if json.Unmarshal(operation.Record, &record) != nil || json.Unmarshal(record["id"], &id) != nil || id != operation.ElementID {
return nil, errInvalidCanvasOperation
}
records = append(records, record)
case "node.delete", "edge.delete":
if index < 0 || len(operation.Record) != 0 || len(operation.Patch) != 0 {
return nil, fmt.Errorf("%w: missing delete target", errInvalidCanvasOperation)
}
records = append(records[:index], records[index+1:]...)
case "node.patch", "edge.patch":
if index < 0 || len(operation.Record) != 0 || len(operation.Patch) == 0 {
return nil, errInvalidCanvasOperation
}
for field, value := range operation.Patch {
allowed := field == "data" || collection == "nodes" && (field == "position" || field == "width" || field == "height") || collection == "edges" && (field == "source" || field == "target" || field == "sourceHandle" || field == "targetHandle")
if !allowed {
return nil, errInvalidCanvasOperation
}
if field == "data" {
// Merge individual data fields; a geometry-only patch never rolls back titles/assets.
var before, patch map[string]json.RawMessage
if json.Unmarshal(records[index][field], &before) != nil || json.Unmarshal(value, &patch) != nil || patch == nil {
return nil, errInvalidCanvasOperation
}
if before == nil {
before = map[string]json.RawMessage{}
}
for key, item := range patch {
if string(item) == "null" {
delete(before, key)
} else {
before[key] = item
}
}
encoded, err := json.Marshal(before)
if err != nil {
return nil, err
}
records[index][field] = encoded
} else {
records[index][field] = append(json.RawMessage(nil), value...)
}
}
}
encoded, err := json.Marshal(records)
if err != nil {
return nil, err
}
document[collection] = encoded
}
result := make(map[string]canvasSnapshot, len(initial))
for id, document := range documents {
raw, err := json.Marshal(document)
if err != nil {
return nil, err
}
normalized, valid := normalizeCanvas(raw)
if !valid {
return nil, errInvalidCanvasOperation
}
snapshot := initial[id]
snapshot.Document = normalized
snapshot.Version++
result[id] = snapshot
}
return result, nil
}
+218
View File
@@ -0,0 +1,218 @@
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
}
@@ -0,0 +1,166 @@
package platform
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
)
func TestCanvasOperationsTransactions(t *testing.T) {
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires an isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
var entropy [12]byte
_, _ = rand.Read(entropy[:])
project := "operations-" + hex.EncodeToString(entropy[:])
defer func() {
for _, table := range []string{"canvas_transactions", "workspaces", "project_accounts"} {
_, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project)
}
}()
accounts, tokens := []string{}, []string{}
for range 2 {
var account string
if err = db.QueryRowContext(ctx, `INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text`, project).Scan(&account); err != nil {
t.Fatal(err)
}
var value [32]byte
_, _ = rand.Read(value[:])
token := base64.RawURLEncoding.EncodeToString(value[:])
hash := sha256.Sum256([]byte(token))
if _, err = db.ExecContext(ctx, `INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, base64.RawURLEncoding.EncodeToString(hash[:]), project, account); err != nil {
t.Fatal(err)
}
accounts, tokens = append(accounts, account), append(tokens, token)
}
var workspace string
if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Operations',$2) RETURNING id::text`, project, accounts[0]).Scan(&workspace); err != nil {
t.Fatal(err)
}
for i, role := range []string{"admin", "viewer"} {
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4)`, project, workspace, accounts[i], role); err != nil {
t.Fatal(err)
}
}
boards := []string{}
for range 2 {
var board string
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Board',$3,$4) RETURNING id::text`, project, workspace, accounts[0], json.RawMessage(operationsFixture)).Scan(&board); err != nil {
t.Fatal(err)
}
boards = append(boards, board)
}
mux := http.NewServeMux()
Register(mux, db, map[string]string{project: "https://app.example.com/auth/callback"}, "https://api.example.com", nil)
path := "/projects/" + project + "/workspaces/" + workspace + "/materials/" + boards[0] + "/canvas/operations"
key := func(n int) string { return fmt.Sprintf("a0000000-0000-4000-8000-%012d", n) }
do := func(method, path, token string, input canvasOperationBatch) *httptest.ResponseRecorder {
encoded, _ := json.Marshal(input)
request := httptest.NewRequest(method, path, bytes.NewReader(encoded))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2")
request.Header.Set("Idempotency-Key", input.RequestID)
writer := httptest.NewRecorder()
mux.ServeHTTP(writer, request)
return writer
}
call := func(method, path, token string, input canvasOperationBatch, status int) response {
t.Helper()
writer := do(method, path, token, input)
if writer.Code != status {
t.Fatalf("%s %s got %d %s want %d", method, path, writer.Code, writer.Body.String(), status)
}
out := response{}
if err := json.Unmarshal(writer.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
version := func(board string) int64 {
t.Helper()
var value int64
if err := db.QueryRowContext(ctx, `SELECT canvas_version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, board).Scan(&value); err != nil {
t.Fatal(err)
}
return value
}
batch := func(n int, expected int64) canvasOperationBatch {
return canvasOperationBatch{ProtocolVersion: 1, RequestID: key(n), ExpectedVersions: map[string]int64{boards[0]: expected}, Operations: []canvasOperation{{ID: key(n + 100), Type: "node.patch", CanvasID: boards[0], ElementID: "a", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"renamed"}`)}}}}
}
firstBody := batch(1, 1)
first := call("POST", path, tokens[0], firstBody, 200)
retried := call("POST", path, tokens[0], firstBody, 200)
if first["transaction_id"] != retried["transaction_id"] || version(boards[0]) != 2 {
t.Fatal("retry duplicated writes or changed receipt identity")
}
queried := call("GET", path+"/"+key(1), tokens[0], canvasOperationBatch{}, 200)
if queried["status"] != "applied" || queried["transaction_id"] != first["transaction_id"] {
t.Fatal("lost acknowledgment cannot be resolved by request identity")
}
altered := batch(1, 2)
call("POST", path, tokens[0], altered, 409)
call("GET", path+"/"+key(1), tokens[1], canvasOperationBatch{}, 404)
call("POST", path, tokens[1], batch(2, 2), 403)
unknown := call("GET", path+"/"+key(999), tokens[0], canvasOperationBatch{}, 404)
if unknown["status"] != "unknown" || unknown["terminal"] == true {
t.Fatal("missing receipt must not claim terminal zero writes")
}
// Terminal zero-write responses must remain queryable and reserve their frozen body.
stale := batch(3, 1)
rejected := call("POST", path, tokens[0], stale, 412)
proof := call("GET", path+"/"+key(3), tokens[0], canvasOperationBatch{}, 200)
if proof["status"] != "not-applied" || proof["terminal"] != true || proof["transaction_id"] != rejected["transaction_id"] {
t.Fatal("terminal rejection was not persisted")
}
call("POST", path, tokens[0], batch(3, 2), 409)
if version(boards[0]) != 2 {
t.Fatal("rejected request performed a write")
}
// Concurrent exact retries serialize to one transaction and one version increment.
concurrent := batch(4, 2)
var wait sync.WaitGroup
results := make(chan *httptest.ResponseRecorder, 4)
for range 4 {
wait.Add(1)
go func() { defer wait.Done(); results <- do("POST", path, tokens[0], concurrent) }()
}
wait.Wait()
close(results)
for result := range results {
if result.Code != 200 {
t.Fatalf("concurrent retry got %d %s", result.Code, result.Body.String())
}
}
if version(boards[0]) != 3 {
t.Fatal("concurrent request executed more than once")
}
// A valid first participant and an invalid second participant produce no partial write.
atomic := batch(5, 3)
atomic.ExpectedVersions[boards[1]] = 1
atomic.Operations = append(atomic.Operations, canvasOperation{ID: key(500), Type: "node.delete", CanvasID: boards[1], ElementID: "missing"})
call("POST", path, tokens[0], atomic, 400)
if version(boards[0]) != 3 || version(boards[1]) != 1 {
t.Fatal("invalid multi-canvas batch partially committed")
}
if call("GET", path+"/"+key(5), tokens[0], canvasOperationBatch{}, 200)["status"] != "not-applied" {
t.Fatal("invalid batch lost its terminal receipt")
}
}
@@ -0,0 +1,63 @@
package platform
import (
"encoding/json"
"testing"
)
const operationsFixture = `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"plain-text","title":"original","text":"hello"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
func TestCanvasOperationBatchStagesAndMerges(t *testing.T) {
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 7, Document: json.RawMessage(operationsFixture)}}
operations := []canvasOperation{
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"renamed"}`)}},
{ID: "22222222-2222-4222-8222-222222222222", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"position": json.RawMessage(`{"x":90,"y":20}`)}},
}
result, err := applyCanvasOperations(initial, operations)
if err != nil {
t.Fatal(err)
}
if result["board"].Version != 8 {
t.Fatal("version did not advance once")
}
var document struct {
Nodes []struct {
Position struct{ X float64 }
Data struct {
Title string
Text string
}
}
}
if err = json.Unmarshal(result["board"].Document, &document); err != nil {
t.Fatal(err)
}
if document.Nodes[0].Data.Title != "renamed" || document.Nodes[0].Data.Text != "hello" || document.Nodes[0].Position.X != 90 {
t.Fatal("field merge lost unrelated content")
}
if string(initial["board"].Document) != operationsFixture {
t.Fatal("input snapshot mutated")
}
}
func TestCanvasOperationBatchRejectsWithoutPartialOutput(t *testing.T) {
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 1, Document: json.RawMessage(operationsFixture)}}
for _, operation := range []canvasOperation{
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"id": json.RawMessage(`"stolen"`)}},
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "missing", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"no"}`)}},
{ID: "11111111-1111-4111-8111-111111111111", Type: "edge.create", CanvasID: "board", ElementID: "edge", Record: json.RawMessage(`{"id":"edge","source":"a","target":"missing"}`)},
} {
if result, err := applyCanvasOperations(initial, []canvasOperation{operation}); err == nil || result != nil {
t.Fatal("invalid batch partially accepted")
}
}
}
func TestCanvasOperationBatchValidatesFinalGraphInsteadOfIntermediateState(t *testing.T) {
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 1, Document: json.RawMessage(operationsFixture)}}
operations := []canvasOperation{
{ID: "11111111-1111-4111-8111-111111111111", Type: "edge.create", CanvasID: "board", ElementID: "edge", Record: json.RawMessage(`{"id":"edge","source":"a","target":"b"}`)},
{ID: "22222222-2222-4222-8222-222222222222", Type: "node.create", CanvasID: "board", ElementID: "b", Record: json.RawMessage(`{"id":"b","type":"plainText","position":{"x":20,"y":20},"width":240,"height":72,"data":{"kind":"plain-text","title":"b","text":""}}`)},
}
if _, err := applyCanvasOperations(initial, operations); err != nil {
t.Fatal(err)
}
}
+2
View File
@@ -147,6 +147,8 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/cancel", s.protected(callbacks, s.cancelCanvasAIRun))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/apply", s.protected(callbacks, s.applyCanvasAIOutput))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/capabilities", s.protected(callbacks, s.canvasCapabilities))
mux.HandleFunc("POST /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations", s.protected(callbacks, s.submitCanvasOperations))
mux.HandleFunc("GET /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations/{request}", s.protected(callbacks, s.getCanvasOperation))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/connections", s.protected(callbacks, s.createCanvasConnection))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs", s.protected(callbacks, s.createSubgraph))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs/migrate", s.protected(callbacks, s.migrateSubgraphs))