From 0d7fc4dbe5cce438c6a8f23199ece87b94b60532 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 23:18:20 +0800 Subject: [PATCH] Preserve staged resource identities and expose upload receipts --- internal/platform/assets.go | 63 ++++++++++++++++++++++-- internal/platform/canvas_capabilities.go | 2 +- internal/platform/http.go | 3 +- internal/platform/integration_test.go | 32 +++++++++++- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/internal/platform/assets.go b/internal/platform/assets.go index 27cbbfa..b55891d 100644 --- a/internal/platform/assets.go +++ b/internal/platform/assets.go @@ -65,6 +65,18 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a reject(w, 400, "invalid_idempotency_key") return nil } + resourceID := strings.ToLower(r.Header.Get("X-Resource-Id")) + if resourceID != "" && !uuidSlugPattern.MatchString(resourceID) { + reject(w, 400, "invalid_resource_id") + return nil + } + respond := func(info assetInfo) { + if resourceID == "" { + reply(w, 201, info) + } else { + reply(w, 201, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info}) + } + } tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err @@ -146,18 +158,28 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a var previous string err = tx.QueryRowContext(r.Context(), `SELECT id::text,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &previous) if err == nil { - if previous != hash { + if previous != hash || resourceID != "" && resourceID != info.ID { reject(w, 409, "idempotency_conflict") return nil } info.URL = s.assetURL(project, workspace, info.ID) - reply(w, 201, info) + respond(info) return nil } if err != sql.ErrNoRows { return err } - err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key).Scan(&info.ID) + if resourceID != "" { + var occupied bool + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3)`, project, workspace, resourceID).Scan(&occupied); err != nil { + return err + } + if occupied { + reject(w, 409, "resource_identity_conflict") + return nil + } + } + err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key,id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,COALESCE(NULLIF($10,'')::uuid,gen_random_uuid())) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key, resourceID).Scan(&info.ID) if err != nil { return err } @@ -165,7 +187,40 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a return err } info.URL = s.assetURL(project, workspace, info.ID) - reply(w, 201, info) + respond(info) + return nil +} + +// A missing receipt is unknown: callers may only retry the same resource/key/bytes. +func (s *service) assetOperation(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + key, workspace := r.PathValue("request"), r.PathValue("workspace") + if !uuidSlugPattern.MatchString(key) { + reject(w, 400, "invalid_idempotency_key") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, _, err = lockWorkspace(r.Context(), tx, project, workspace, account); err != nil { + return err + } + var info assetInfo + err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height) + if err == sql.ErrNoRows { + reply(w, 404, response{"protocol_version": 2, "request_id": key, "status": "unknown", "error": "asset_receipt_unknown"}) + return nil + } + if err != nil { + return err + } + info.URL = s.assetURL(project, workspace, info.ID) + reply(w, 200, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info}) return nil } func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account string) (bool, error) { diff --git a/internal/platform/canvas_capabilities.go b/internal/platform/canvas_capabilities.go index 875691e..f553459 100644 --- a/internal/platform/canvas_capabilities.go +++ b/internal/platform/canvas_capabilities.go @@ -50,7 +50,7 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro "edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true}, "subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true}, "ai": ai, - "assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true}, + "assets": response{"client_resource_ids": true, "receipt_protocol_version": 2, "previews": true, "preview_max_edge": 1024, "originals_preserved": true}, "limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000}, "retention": response{"idempotency_min_seconds": 86400, "undo_receipt_seconds": 604800}, }) diff --git a/internal/platform/http.go b/internal/platform/http.go index bb83880..9ac60ae 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -76,7 +76,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H } if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account, X-Resource-Id") reply(w, 204, nil) return } @@ -150,6 +150,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi } mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets", s.protected(callbacks, s.uploadAsset)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/operations/{request}", s.protected(callbacks, s.assetOperation)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/{asset}", s.protected(callbacks, s.getAsset)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index f481e9c..d136bc1 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -495,7 +495,7 @@ func TestOnboardingAndSharing(t *testing.T) { if err := png.Encode(&assetPNG, image.NewRGBA(image.Rect(0, 0, 3, 2))); err != nil { t.Fatal(err) } - upload := func(encoded []byte, key string, status int) response { + upload := func(encoded []byte, key string, status int, resourceIDs ...string) response { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -509,6 +509,9 @@ func TestOnboardingAndSharing(t *testing.T) { request.Header.Set("Authorization", "Bearer "+tokens[0]) request.Header.Set("Content-Type", writer.FormDataContentType()) request.Header.Set("Idempotency-Key", key) + if len(resourceIDs) > 0 { + request.Header.Set("X-Resource-Id", resourceIDs[0]) + } result := httptest.NewRecorder() mux.ServeHTTP(result, request) if result.Code != status { @@ -525,6 +528,33 @@ func TestOnboardingAndSharing(t *testing.T) { if uploaded["id"] != retriedAsset["id"] || uploaded["mime_type"] != "image/png" { t.Fatal("asset idempotency or actual MIME detection") } + // New uploads keep the resource identity allocated in the frontend durable cut. + resourceID, resourceKey := modelTestID(9100), modelTestID(9101) + managed := upload(assetPNG.Bytes(), resourceKey, 201, resourceID) + if managed["protocol_version"] != float64(2) || managed["request_id"] != resourceKey || managed["asset"].(map[string]any)["id"] != resourceID { + t.Fatal("upload did not retain client resource identity") + } + repeated := upload(assetPNG.Bytes(), resourceKey, 201, resourceID) + if !modelEqual(managed, repeated) { + t.Fatal("resource retry changed receipt") + } + lookup := call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[0], nil, 200) + if lookup["status"] != "applied" || !modelEqual(lookup["asset"], managed["asset"]) { + t.Fatal("lost upload acknowledgment cannot be recovered") + } + unknown := call("GET", workspacePath+"/assets/operations/"+modelTestID(9102), tokens[0], nil, 404) + if unknown["status"] != "unknown" || unknown["terminal"] == true { + t.Fatal("missing asset receipt misreported zero-write") + } + call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[2], nil, 404) + upload(assetPNG.Bytes(), resourceKey, 409, modelTestID(9103)) + upload(assetPNG.Bytes(), modelTestID(9104), 409, resourceID) + var alternatePNG bytes.Buffer + if err := png.Encode(&alternatePNG, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil { + t.Fatal(err) + } + upload(alternatePNG.Bytes(), resourceKey, 409, resourceID) + upload(assetPNG.Bytes(), modelTestID(9105), 400, "invalid-resource") download := func(token string, status int) { t.Helper() request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string), nil)