diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index c666327..088ddd4 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -4,7 +4,8 @@ on: [push, pull_request] jobs: check: runs-on: ces-control - timeout-minutes: 10 + # Cold toolchain installation can exceed ten minutes on the existing runner. + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -68,8 +69,19 @@ jobs: print("Negative control result:", control if control in known else "unclassified") sys.exit("Slack client preflight could not verify the configured credentials") PYSLACK - - name: Verify schema migration in an isolated database - run: bash deploy/check-schema.sh + - name: Fetch pinned headless HTTP integration client + env: + HEADLESS_CLIENT_REVISION: dcbbdad25de7c3b1d6bee317df6074da2c0642eb + run: | + set -eu + mkdir -p .headless-client + curl --fail --silent --show-error --location \ + "https://gitea.entroia.com/Zayden-Jung/Endoleap/archive/$HEADLESS_CLIENT_REVISION.tar.gz" \ + | tar -xz --strip-components=1 -C .headless-client + test -f .headless-client/tests/headless/foundry.integration.mjs + printf 'Pinned Endoleap client: %s\n' "$HEADLESS_CLIENT_REVISION" + - name: Verify schema migration and real headless HTTP client in an isolated database + run: HEADLESS_CLIENT_SOURCE="$PWD/.headless-client" bash deploy/check-schema.sh - name: Smoke test HTTP routes env: PUBLIC_ORIGIN: https://api.example.com diff --git a/db/008-canvas-model.sql b/db/008-canvas-model.sql new file mode 100644 index 0000000..e4a040d --- /dev/null +++ b/db/008-canvas-model.sql @@ -0,0 +1,103 @@ +BEGIN; +-- Canonical rows are separate from the legacy snapshot. Adoption is an explicit, +-- version-checked transaction; installing this schema does not migrate user data. +CREATE TABLE canvas_model_projects ( + project text NOT NULL, + workspace_id uuid NOT NULL, + root_id uuid NOT NULL, + document_format_version integer NOT NULL DEFAULT 1 CHECK(document_format_version=1), + migration_id uuid NOT NULL, + source_versions jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(project,workspace_id,root_id), + FOREIGN KEY(project,workspace_id,root_id) REFERENCES materials ON DELETE CASCADE +); +CREATE TABLE canvas_model_canvases ( + project text NOT NULL, + workspace_id uuid NOT NULL, + root_id uuid NOT NULL, + canvas_id uuid NOT NULL, + parent_canvas_id uuid, + entry_element_id text, + version bigint NOT NULL CHECK(version>0), + archived boolean NOT NULL DEFAULT false, + PRIMARY KEY(project,workspace_id,canvas_id), + UNIQUE(project,canvas_id), + UNIQUE(project,workspace_id,root_id,canvas_id), + CHECK((parent_canvas_id IS NULL)=(entry_element_id IS NULL)), + CHECK(parent_canvas_id IS NOT NULL OR canvas_id=root_id), + FOREIGN KEY(project,workspace_id,root_id) REFERENCES canvas_model_projects ON DELETE CASCADE, + FOREIGN KEY(project,workspace_id,canvas_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY(project,workspace_id,root_id,parent_canvas_id) REFERENCES canvas_model_canvases(project,workspace_id,root_id,canvas_id) DEFERRABLE INITIALLY DEFERRED +); +CREATE UNIQUE INDEX canvas_model_active_entry ON canvas_model_canvases(project,workspace_id,entry_element_id) WHERE NOT archived; +CREATE TABLE canvas_model_elements ( + project text NOT NULL, + workspace_id uuid NOT NULL, + root_id uuid NOT NULL, + element_id text NOT NULL CHECK(element_id ~ '^[A-Za-z0-9_-]{1,128}$'), + owner_canvas_id uuid NOT NULL, + incarnation bigint NOT NULL CHECK(incarnation>0 AND incarnation<=9007199254740991), + deleted_incarnation bigint NOT NULL DEFAULT 0 CHECK(deleted_incarnation>=0 AND deleted_incarnation<=incarnation), + record jsonb NOT NULL, + deleted boolean NOT NULL DEFAULT false, + PRIMARY KEY(project,element_id), + UNIQUE(project,workspace_id,element_id), + CHECK(jsonb_typeof(record)='object' AND record->>'id'=element_id AND record->>'ownerCanvasId'=owner_canvas_id::text AND (record->>'incarnation')::bigint=incarnation), + CHECK(NOT deleted OR deleted_incarnation=incarnation), + FOREIGN KEY(project,workspace_id,root_id) REFERENCES canvas_model_projects ON DELETE CASCADE, + FOREIGN KEY(project,workspace_id,root_id,owner_canvas_id) REFERENCES canvas_model_canvases(project,workspace_id,root_id,canvas_id) DEFERRABLE INITIALLY DEFERRED +); +CREATE INDEX canvas_model_owner ON canvas_model_elements(project,workspace_id,root_id,owner_canvas_id) WHERE NOT deleted; +ALTER TABLE canvas_model_canvases ADD CONSTRAINT canvas_model_entry_fk FOREIGN KEY(project,workspace_id,entry_element_id) REFERENCES canvas_model_elements(project,workspace_id,element_id) DEFERRABLE INITIALLY DEFERRED; +CREATE TABLE canvas_model_identity_map ( + project text NOT NULL, + workspace_id uuid NOT NULL, + root_id uuid NOT NULL, + source_canvas_id uuid NOT NULL, + source_structure text NOT NULL CHECK(source_structure IN('node','connection')), + source_element_id text NOT NULL, + element_id text NOT NULL, + PRIMARY KEY(project,workspace_id,root_id,source_canvas_id,source_structure,source_element_id), + UNIQUE(project,element_id), + FOREIGN KEY(project,workspace_id,root_id) REFERENCES canvas_model_projects ON DELETE CASCADE, + FOREIGN KEY(project,workspace_id,element_id) REFERENCES canvas_model_elements(project,workspace_id,element_id) DEFERRABLE INITIALLY DEFERRED +); +-- Logical operations cannot acquire a second successful execution under a new request key. +CREATE TABLE canvas_model_requests ( + project text NOT NULL, + workspace_id uuid NOT NULL, + account_id uuid NOT NULL, + request_id uuid NOT NULL, + root_id uuid NOT NULL, + action text NOT NULL, + protocol_version integer NOT NULL, + payload_hash text NOT NULL, + response_status integer NOT NULL, + response_body jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(project,workspace_id,account_id,request_id), + FOREIGN KEY(project,workspace_id,root_id) REFERENCES materials ON DELETE RESTRICT, + FOREIGN KEY(project,account_id) REFERENCES project_accounts +); +CREATE TABLE canvas_model_operations ( + project text NOT NULL, + workspace_id uuid NOT NULL, + operation_id uuid NOT NULL, + request_id uuid NOT NULL, + account_id uuid NOT NULL, + PRIMARY KEY(project,workspace_id,operation_id), + FOREIGN KEY(project,workspace_id,account_id,request_id) REFERENCES canvas_model_requests DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY(project,account_id) REFERENCES project_accounts +); +CREATE FUNCTION foundry_guard_canonical_canvas() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF (NEW.canvas_document IS DISTINCT FROM OLD.canvas_document OR NEW.canvas_version IS DISTINCT FROM OLD.canvas_version) + AND EXISTS(SELECT 1 FROM canvas_model_canvases c WHERE c.project=NEW.project AND c.workspace_id=NEW.workspace_id AND c.canvas_id=NEW.id) + AND current_setting('foundry.canonical_writer',true) IS DISTINCT FROM 'on' THEN + RAISE EXCEPTION 'canonical_write_required' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER canonical_canvas_write_barrier BEFORE UPDATE OF canvas_document,canvas_version ON materials FOR EACH ROW EXECUTE FUNCTION foundry_guard_canonical_canvas(); +COMMIT; diff --git a/db/009-canvas-ai-operations.sql b/db/009-canvas-ai-operations.sql new file mode 100644 index 0000000..0b0b886 --- /dev/null +++ b/db/009-canvas-ai-operations.sql @@ -0,0 +1,6 @@ +BEGIN; +-- Legacy requests retain NULL. Canonical operation identity cannot be replayed +-- under another transport key to admit a second paid execution. +ALTER TABLE canvas_ai_runs ADD COLUMN logical_operation_id uuid; +CREATE UNIQUE INDEX canvas_ai_logical_operation ON canvas_ai_runs(project,workspace_id,account_id,logical_operation_id) WHERE logical_operation_id IS NOT NULL; +COMMIT; diff --git a/db/010-canvas-ai-receipts.sql b/db/010-canvas-ai-receipts.sql new file mode 100644 index 0000000..f996970 --- /dev/null +++ b/db/010-canvas-ai-receipts.sql @@ -0,0 +1,29 @@ +BEGIN; +-- Keep transport receipts independently from execution retention. Their absence +-- must never be used as proof that a paid invocation did not happen. +CREATE TABLE canvas_ai_requests ( + project text NOT NULL, + workspace_id uuid NOT NULL, + account_id uuid NOT NULL, + request_id uuid NOT NULL, + board_id uuid NOT NULL, + run_id uuid, + action text NOT NULL CHECK(action IN('run.start','run.cancel','run.apply')), + operation_id uuid NOT NULL, + payload_hash text NOT NULL, + outcome text NOT NULL CHECK(outcome IN('applied','not-applied')), + response_status integer NOT NULL, + response_body jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(project,workspace_id,account_id,request_id), + FOREIGN KEY(project,workspace_id) REFERENCES workspaces(project,id) ON DELETE CASCADE +); +-- A known zero-effect rejection may be explicitly replanned under another key; +-- a completed logical action must never execute twice under different keys. +CREATE UNIQUE INDEX canvas_ai_request_operation ON canvas_ai_requests(project,workspace_id,account_id,operation_id) WHERE outcome='applied'; +-- Preserve accepted canonical requests that predate this receipt table. +INSERT INTO canvas_ai_requests(project,workspace_id,account_id,request_id,board_id,action,operation_id,payload_hash,outcome,response_status,response_body) +SELECT project,workspace_id,account_id,idempotency_key,board_id,'run.start',logical_operation_id,payload_hash,'applied',202,create_response +FROM canvas_ai_runs WHERE logical_operation_id IS NOT NULL AND create_response->>'protocol_version'='2' +ORDER BY created_at,id ON CONFLICT DO NOTHING; +COMMIT; diff --git a/db/011-canvas-ai-target-location.sql b/db/011-canvas-ai-target-location.sql new file mode 100644 index 0000000..f543637 --- /dev/null +++ b/db/011-canvas-ai-target-location.sql @@ -0,0 +1,9 @@ +BEGIN; +-- The frozen board remains the plan origin; result versions belong to the board +-- that actually received the output after any owner-only move. +ALTER TABLE canvas_ai_steps ADD COLUMN applied_board_id uuid; +ALTER TABLE canvas_ai_steps ADD FOREIGN KEY(project,workspace_id,applied_board_id) REFERENCES materials(project,workspace_id,id); +UPDATE canvas_ai_steps SET applied_board_id=board_id WHERE applied_version IS NOT NULL; +CREATE UNIQUE INDEX canvas_ai_canonical_target_busy ON canvas_ai_steps(project,workspace_id,(frozen_input->>'canonical_root'),node_id,(frozen_input->>'target_incarnation')) + WHERE status IN('queued','running') AND frozen_input->>'canonical_root' IS NOT NULL; +COMMIT; diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh index 941baa7..629e098 100644 --- a/deploy/check-schema.sh +++ b/deploy/check-schema.sh @@ -78,3 +78,18 @@ for package in platform auth research; do -e RESEARCH_TEST_DATABASE_URL=postgres://foundry_research_fixture@127.0.0.1:5432/postgres?sslmode=disable \ --entrypoint /opt/integration-tests postgres:17-alpine -test.v done + +# Run the actual pinned Endoleap runtime against this build's authenticated HTTP +# routes and the same disposable database. Only this remote container needs Node. +if [[ -n "${HEADLESS_CLIENT_SOURCE:-}" ]]; then + test -f "$HEADLESS_CLIENT_SOURCE/tests/headless/foundry.integration.mjs" + docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=32m,mode=1777 \ + --mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/integration-tests,readonly" \ + --mount "type=bind,source=$HEADLESS_CLIENT_SOURCE,target=/opt/headless-client,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ + -e FOUNDRY_HEADLESS_SOURCE=/opt/headless-client \ + --entrypoint /opt/integration-tests node:24-bookworm-slim \ + -test.v -test.run '^TestHeadlessClientHTTPIntegration$' -test.timeout 3m +fi diff --git a/internal/platform/assets.go b/internal/platform/assets.go index 27cbbfa..8473046 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) { @@ -216,6 +271,53 @@ func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account } return false, nil } + +// Metadata uses the same read policy as original bytes; it is not a persistent grant. +func (s *service) getAssetMetadata(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + id, workspace := r.PathValue("asset"), r.PathValue("workspace") + if !uuidSlugPattern.MatchString(id) { + reject(w, 404, "not_found") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var locked string + err = tx.QueryRowContext(r.Context(), `SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE`, project, workspace).Scan(&locked) + if err != nil { + return err + } + allowed, err := assetReadable(r, tx, project, workspace, id, account) + if err != nil { + return err + } + if !allowed { + reject(w, 404, "not_found") + return nil + } + var info assetInfo + var digest string + err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height, &digest) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + info.URL = s.assetURL(project, workspace, id) + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response{"metadata_version": 1, "asset": info, "sha256": digest}) + return nil +} + func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, account string) error { if r.Method != http.MethodGet { reject(w, 405, "method_not_allowed") diff --git a/internal/platform/canvas_ai_apply.go b/internal/platform/canvas_ai_apply.go index ebd6bc3..ba71516 100644 --- a/internal/platform/canvas_ai_apply.go +++ b/internal/platform/canvas_ai_apply.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" ) @@ -13,7 +14,19 @@ func (s *service) startCanvasStep(ctx context.Context, run canvasQueueRun, step return false, err } defer tx.Rollback() - _, role, permissionErr := lockMaterial(ctx, tx, run.Project, run.Workspace, step.BoardID, run.Account) + if err = lockCanvasAIWorkspace(ctx, tx, run.Project, run.Workspace); err != nil { + return false, err + } + board, identityOK, err := resolveCanvasAITarget(ctx, tx, run.Project, run.Workspace, step) + if err != nil { + return false, err + } + if !identityOK { + tx.Rollback() + _, err = s.finishCanvasStep(ctx, run, step, "", "target_removed", false) + return false, err + } + _, role, permissionErr := lockMaterial(ctx, tx, run.Project, run.Workspace, board, run.Account) if permissionErr != nil || role == "viewer" { tx.Rollback() if permissionErr != nil && permissionErr != sql.ErrNoRows { @@ -32,12 +45,12 @@ func (s *service) startCanvasStep(ctx context.Context, run canvasQueueRun, step _, err = s.finishCanvasStep(ctx, run, step, "", "generation_cancelled", false) return false, err } - snapshot, err := loadCanvas(queueRequest(ctx), tx, run.Project, run.Workspace, step.BoardID) + snapshot, err := loadCanvas(queueRequest(ctx), tx, run.Project, run.Workspace, board) if err != nil { return false, err } data := canvasNodeData(snapshot.Document)[step.NodeID] - if data == nil || canvasTargetFingerprint(step.BoardID, step.NodeID, data) != step.Fingerprint { + if !identityOK || data == nil || canvasTargetFingerprint(step.BoardID, step.NodeID, data) != step.Fingerprint { tx.Rollback() _, err = s.finishCanvasStep(ctx, run, step, "", "target_changed", false) return false, err @@ -103,7 +116,7 @@ func (s *service) finishCanvasStepOutput(ctx context.Context, run canvasQueueRun status = "skipped" case "target_changed": status = "conflict" - case "generation_cancelled": + case "generation_cancelled", "target_removed": status = "cancelled" default: if code != "" { @@ -114,6 +127,16 @@ func (s *service) finishCanvasStepOutput(ctx context.Context, run canvasQueueRun status = "cancelled" code = "generation_cancelled" } + board, identityOK, err := resolveCanvasAITarget(ctx, tx, run.Project, run.Workspace, step) + if err != nil { + return "", err + } + eventBoard := board + if !identityOK { + eventBoard = step.BoardID + } + var appliedBoard any + var appliedEvent response var output any var version any if code == "" && started { @@ -136,10 +159,7 @@ func (s *service) finishCanvasStepOutput(ctx context.Context, run canvasQueueRun patch["text"] = generated.Text } - if err = appendAIEvent(ctx, tx, run.Project, run.Workspace, run.ID, "step.output", response{"step_id": step.ID, "board_id": step.BoardID, "node_id": step.NodeID, "output": output}); err != nil { - return "", err - } - _, role, permissionErr := lockMaterial(ctx, tx, run.Project, run.Workspace, step.BoardID, run.Account) + _, role, permissionErr := lockCanvasAIRunRoute(ctx, tx, run.Project, run.Workspace, eventBoard, run.Account) if permissionErr != nil && permissionErr != sql.ErrNoRows { return "", permissionErr } @@ -147,53 +167,62 @@ func (s *service) finishCanvasStepOutput(ctx context.Context, run canvasQueueRun status = "failed" code = "permission_revoked" } else { - snapshot, err := loadCanvas(queueRequest(ctx), tx, run.Project, run.Workspace, step.BoardID) + snapshot, err := loadCanvas(queueRequest(ctx), tx, run.Project, run.Workspace, eventBoard) if err != nil { return "", err } - var doc map[string]any - if err = json.Unmarshal(snapshot.Document, &doc); err != nil { - return "", err - } - var target map[string]any - for _, v := range doc["nodes"].([]any) { - n := v.(map[string]any) - if n["id"] == step.NodeID { - target = n["data"].(map[string]any) + target := canvasNodeData(snapshot.Document)[step.NodeID] + if image, ok := output.(response); ok && image["kind"] == "image" && identityOK { + if _, err = tx.ExecContext(ctx, `INSERT INTO canvas_asset_bindings(project,workspace_id,asset_id,board_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, run.Project, run.Workspace, image["assetId"], eventBoard); err != nil { + return "", err } } - if target == nil || canvasTargetFingerprint(step.BoardID, step.NodeID, target) != step.Fingerprint { - status = "conflict" - code = "target_changed" + if !identityOK || target == nil || canvasTargetFingerprint(step.BoardID, step.NodeID, target) != step.Fingerprint { + status, code = "conflict", "target_changed" } else { - for key, value := range patch { - target[key] = value - } - encoded, _ := json.Marshal(doc) - normalized, ok := normalizeCanvas(encoded) - if !ok { - status = "failed" - code = "generation_output_too_large" + change, err := prepareCanvasAIPatch(queueRequest(ctx), tx, run.Project, run.Workspace, snapshot, step.NodeID, patch) + if err != nil { + if !errors.Is(err, errAIInvalidPatch) && !errors.Is(err, errCanvasModel) && !errors.Is(err, errGraphCapacity) { + return "", err + } + status, code = "failed", "generation_output_too_large" } else { - snapshot.Document = normalized - snapshot.Version++ - if err = s.saveCanvas(queueRequest(ctx), tx, run.Project, run.Workspace, snapshot); err != nil { - return "", err - } - version = snapshot.Version - if err = appendAIEvent(ctx, tx, run.Project, run.Workspace, run.ID, "step.applied", response{"step_id": step.ID, "board_id": step.BoardID, "node_id": step.NodeID, "patch": patch, "canvas_version": snapshot.Version}); err != nil { + if err = s.saveCanvasAIChange(queueRequest(ctx), tx, run.Project, run.Workspace, run.Account, change); err != nil { return "", err } + version = change.Snapshot.Version + appliedBoard = eventBoard + applied := response{"step_id": step.ID, "board_id": eventBoard, "node_id": step.NodeID, "patch": patch, "canvas_version": change.Snapshot.Version} + change.addTargetContext(applied, step) + appliedEvent = applied } } } } + if output != nil { + event := response{"step_id": step.ID, "board_id": eventBoard, "origin_board_id": step.BoardID, "canonical_root": step.CanonicalRoot, "target_incarnation": step.TargetIncarnation, "node_id": step.NodeID, "output": output} + if status == "conflict" && identityOK && step.CanonicalRoot != "" && eventBoard != step.BoardID { + model, e := loadCanvasModel(ctx, tx, run.Project, run.Workspace, step.CanonicalRoot) + if e != nil { + return "", e + } + event["model"], event["document_format_version"], event["canvas_version"] = model.view(nil), canvasModelFormat, model.Versions[eventBoard] + } + if err = appendAIEvent(ctx, tx, run.Project, run.Workspace, run.ID, "step.output", event); err != nil { + return "", err + } + } + if appliedEvent != nil { + if err = appendAIEvent(ctx, tx, run.Project, run.Workspace, run.ID, "step.applied", appliedEvent); err != nil { + return "", err + } + } var outputJSON any if output != nil { encoded, _ := json.Marshal(output) outputJSON = encoded } - _, err = tx.ExecContext(ctx, `UPDATE canvas_ai_steps SET status=$5,output=$6,error_code=NULLIF($7,''),applied_version=$8,completed_at=now() WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND id=$4`, run.Project, run.Workspace, run.ID, step.ID, status, outputJSON, code, version) + _, err = tx.ExecContext(ctx, `UPDATE canvas_ai_steps SET status=$5,output=$6,error_code=NULLIF($7,''),applied_version=$8,applied_board_id=$9,completed_at=now() WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND id=$4`, run.Project, run.Workspace, run.ID, step.ID, status, outputJSON, code, version, appliedBoard) if err != nil { return "", err } diff --git a/internal/platform/canvas_ai_cancel.go b/internal/platform/canvas_ai_cancel.go index f2b85ac..2a5c71a 100644 --- a/internal/platform/canvas_ai_cancel.go +++ b/internal/platform/canvas_ai_cancel.go @@ -22,29 +22,70 @@ func (s *service) cancelCanvasAIRun(w http.ResponseWriter, r *http.Request, proj reject(w, 413, "context_too_large") return nil } - input := map[string]any{} - if len(raw) > 0 && (json.Unmarshal(raw, &input) != nil || input == nil || len(input) > 0) { + var input struct { + ProtocolVersion int `json:"protocol_version,omitempty"` + RequestID string `json:"request_id,omitempty"` + OperationID string `json:"operation_id,omitempty"` + } + if len(raw) > 0 && (json.Unmarshal(raw, &input) != nil || string(raw) == "null") { reject(w, 400, "invalid_ai_request") return nil } - hash := payloadDigest(input) + var fields map[string]any + if len(raw) > 0 { + if json.Unmarshal(raw, &fields) != nil || fields == nil { + reject(w, 400, "invalid_ai_request") + return nil + } + for key := range fields { + if key != "protocol_version" && key != "request_id" && key != "operation_id" { + reject(w, 400, "invalid_ai_request") + return nil + } + } + } + canonical := input.ProtocolVersion != 0 || input.RequestID != "" || input.OperationID != "" + if canonical && (input.ProtocolVersion != 2 || input.RequestID != key || !uuidSlugPattern.MatchString(input.OperationID) || !acceptsCanvasV2(r)) { + reject(w, 400, "invalid_ai_protocol") + return nil + } + if !canonical && len(fields) > 0 { + reject(w, 400, "invalid_ai_request") + return nil + } + hash := payloadDigest(map[string]any{}) + if canonical { + hash = payloadDigest(input) + } workspace, board := r.PathValue("workspace"), r.PathValue("material") tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() - _, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) + _, role, err := lockCanvasAIRunRoute(r.Context(), tx, project, workspace, board, account) if err != nil { return err } + var receipt *canvasAIRequest + if canonical { + receipt = &canvasAIRequest{Key: key, Hash: hash, Action: "run.cancel", Board: board, Run: id, Operation: input.OperationID} + if done, e := beginCanvasAIRequest(w, r, tx, project, workspace, account, receipt); done || e != nil { + return e + } + } + rejectRequest := func(status int, code string) error { + return rejectCanvasAIRequest(w, r, tx, project, workspace, account, receipt, status, code) + } if role == "viewer" { - reject(w, 403, "forbidden") - return nil + return rejectRequest(403, "forbidden") } var status string var rawPlan json.RawMessage err = tx.QueryRowContext(r.Context(), `SELECT status,plan FROM canvas_ai_runs WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 FOR UPDATE`, project, workspace, board, id).Scan(&status, &rawPlan) + if err == sql.ErrNoRows { + return rejectRequest(404, "not_found") + } if err != nil { return err } @@ -67,13 +108,19 @@ func (s *service) cancelCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return err } for _, step := range plan.Steps { - _, role, err := lockMaterial(r.Context(), tx, project, workspace, step.BoardID, account) + target, ok, e := resolveCanvasAITarget(r.Context(), tx, project, workspace, step) + if e != nil { + return e + } + if !ok { + target = step.BoardID + } + _, role, err := lockCanvasAIRunRoute(r.Context(), tx, project, workspace, target, account) if err != nil { return err } if role == "viewer" { - reject(w, 403, "forbidden") - return nil + return rejectRequest(403, "forbidden") } } if status == "queued" || status == "running" { @@ -119,7 +166,7 @@ func (s *service) cancelCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return err } } - rows, err := tx.QueryContext(r.Context(), `SELECT id,board_id::text,node_id,status,applied_version FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND run_id=$3 ORDER BY ordinal`, project, workspace, id) + rows, err := tx.QueryContext(r.Context(), `SELECT id,COALESCE(applied_board_id,board_id)::text,node_id,status,applied_version FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND run_id=$3 ORDER BY ordinal`, project, workspace, id) if err != nil { return err } @@ -145,14 +192,14 @@ func (s *service) cancelCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return err } result := response{"run_id": id, "status": status, "applied": applied, "cancelled_step_ids": cancelledSteps, "worker_stop_grace_ms": 1000} + if receipt != nil { + result["run_status"], result["status"] = status, "applied" + bindCanvasAIReceipt(result, receipt) + } encoded, _ := json.Marshal(result) _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_mutations(project,workspace_id,run_id,account_id,action,idempotency_key,payload_hash,response_body) VALUES($1,$2,$3,$4,'cancel',$5,$6,$7)`, project, workspace, id, account, key, hash, encoded) if err != nil { return err } - if err = tx.Commit(); err != nil { - return err - } - reply(w, 200, result) - return nil + return commitCanvasAIReceipt(w, r, tx, project, workspace, account, receipt, 200, "applied", result) } diff --git a/internal/platform/canvas_ai_config.go b/internal/platform/canvas_ai_config.go index ae6bfe2..0066ed2 100644 --- a/internal/platform/canvas_ai_config.go +++ b/internal/platform/canvas_ai_config.go @@ -43,7 +43,9 @@ func validCanvasAI(value any, kind string) bool { } // Fingerprints exclude geometry and visual styles so a result can be applied -// after a concurrent drag. Content/configuration edits invalidate the result. +// after a concurrent drag. The board is the immutable plan origin, not the +// current canonical owner; canonical target identity is checked separately. +// Content/configuration edits invalidate the result. func canvasTargetFingerprint(board, node string, data map[string]any) string { target := map[string]any{"board_id": board, "node_id": node, "kind": data["kind"], "ai": data["ai"]} if canvasChoice(data["kind"], "image", "plain-image") { diff --git a/internal/platform/canvas_ai_explicit_apply.go b/internal/platform/canvas_ai_explicit_apply.go index 20ae1ab..7792f5c 100644 --- a/internal/platform/canvas_ai_explicit_apply.go +++ b/internal/platform/canvas_ai_explicit_apply.go @@ -22,8 +22,13 @@ func (s *service) applyCanvasAIOutput(w http.ResponseWriter, r *http.Request, pr return nil } var input struct { - NodeID string `json:"node_id"` - OutputID string `json:"output_id"` + ProtocolVersion int `json:"protocol_version,omitempty"` + RequestID string `json:"request_id,omitempty"` + OperationID string `json:"operation_id,omitempty"` + TargetIncarnation int64 `json:"target_incarnation,omitempty"` + TargetCanvasID string `json:"target_canvas_id,omitempty"` + NodeID string `json:"node_id"` + OutputID string `json:"output_id"` } if !decode(w, r, &input) { return nil @@ -32,23 +37,43 @@ func (s *service) applyCanvasAIOutput(w http.ResponseWriter, r *http.Request, pr reject(w, 400, "invalid_ai_request") return nil } + canonical := input.ProtocolVersion != 0 || input.RequestID != "" || input.OperationID != "" || input.TargetIncarnation != 0 || input.TargetCanvasID != "" + if canonical && (input.ProtocolVersion != 2 || input.RequestID != key || !uuidSlugPattern.MatchString(input.OperationID) || input.TargetIncarnation < 1 || !uuidSlugPattern.MatchString(input.TargetCanvasID) || !acceptsCanvasV2(r)) { + reject(w, 400, "invalid_ai_protocol") + return nil + } hash := payloadDigest(input) + if canonical { + hash = payloadDigest(response{"input": input, "if_match": expected}) + } workspace, board := r.PathValue("workspace"), r.PathValue("material") tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() - _, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) + _, role, err := lockCanvasAIRunRoute(r.Context(), tx, project, workspace, board, account) if err != nil { return err } + var receipt *canvasAIRequest + if canonical { + receipt = &canvasAIRequest{Key: key, Hash: hash, Action: "run.apply", Board: board, Run: id, Operation: input.OperationID} + if done, e := beginCanvasAIRequest(w, r, tx, project, workspace, account, receipt); done || e != nil { + return e + } + } + rejectRequest := func(status int, code string) error { + return rejectCanvasAIRequest(w, r, tx, project, workspace, account, receipt, status, code) + } if role == "viewer" { - reject(w, 403, "forbidden") - return nil + return rejectRequest(403, "forbidden") } var runStatus string err = tx.QueryRowContext(r.Context(), `SELECT status FROM canvas_ai_runs WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 FOR UPDATE`, project, workspace, board, id).Scan(&runStatus) + if err == sql.ErrNoRows { + return rejectRequest(404, "not_found") + } if err != nil { return err } @@ -75,28 +100,14 @@ func (s *service) applyCanvasAIOutput(w http.ResponseWriter, r *http.Request, pr var outputRaw, frozenRaw json.RawMessage var applied sql.NullInt64 err = tx.QueryRowContext(r.Context(), `SELECT id,board_id::text,node_id,status,output,frozen_input,applied_version FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND output->>'output_id'=$4 FOR UPDATE`, project, workspace, id, input.OutputID).Scan(&stepID, &targetBoard, &node, &status, &outputRaw, &frozenRaw, &applied) + if err == sql.ErrNoRows { + return rejectRequest(404, "not_found") + } if err != nil { return err } if node != input.NodeID || status != "conflict" || applied.Valid || runStatus == "cancelled" { - reject(w, 409, "output_not_applicable") - return nil - } - _, role, err = lockMaterial(r.Context(), tx, project, workspace, targetBoard, account) - if err != nil { - return err - } - if role == "viewer" { - reject(w, 403, "forbidden") - return nil - } - snapshot, err := loadCanvas(r, tx, project, workspace, targetBoard) - if err != nil { - return err - } - if snapshot.Version != expected { - reject(w, 412, "version_conflict") - return nil + return rejectRequest(409, "output_not_applicable") } var output struct { Kind string `json:"kind"` @@ -111,32 +122,55 @@ func (s *service) applyCanvasAIOutput(w http.ResponseWriter, r *http.Request, pr if err = json.Unmarshal(frozenRaw, &frozen); err != nil { return err } + if canonical && (frozen.TargetIncarnation != input.TargetIncarnation || frozen.CanonicalRoot == "") { + return rejectRequest(409, "target_changed") + } + targetBoard, identityOK, err := resolveCanvasAITarget(r.Context(), tx, project, workspace, frozen) + if err != nil { + return err + } + if !identityOK { + return rejectRequest(409, "target_changed") + } + if canonical && targetBoard != input.TargetCanvasID { + return rejectRequest(409, "target_changed") + } + _, role, err = lockMaterial(r.Context(), tx, project, workspace, targetBoard, account) + if err != nil { + return err + } + if role == "viewer" { + return rejectRequest(403, "forbidden") + } + snapshot, err := loadCanvas(r, tx, project, workspace, targetBoard) + if err != nil { + return err + } + if snapshot.Version != expected { + return rejectRequest(412, "version_conflict") + } patch := response{} switch output.Kind { case "text": if !canvasChoice(frozen.Kind, "text", "script", "plain-text") || !utf8.ValidString(output.Text) || utf8.RuneCountInString(output.Text) > 100000 { - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } patch["text"] = output.Text case "image": if !canvasChoice(frozen.Kind, "image", "plain-image") || !uuidSlugPattern.MatchString(output.AssetID) || output.Src != s.assetURL(project, workspace, output.AssetID) { - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } readable, err := assetReadable(r, tx, project, workspace, output.AssetID, account) if err != nil { return err } if !readable { - reject(w, 404, "not_found") - return nil + return rejectRequest(404, "not_found") } patch["src"] = output.Src patch["assetId"] = output.AssetID default: - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } var doc map[string]any @@ -151,44 +185,38 @@ func (s *service) applyCanvasAIOutput(w http.ResponseWriter, r *http.Request, pr } } if target == nil { - reject(w, 404, "not_found") - return nil + return rejectRequest(404, "not_found") } if target["kind"] != frozen.Kind { - reject(w, 409, "ownership_conflict") - return nil + return rejectRequest(409, "ownership_conflict") } - for key, value := range patch { - target[key] = value + change, err := prepareCanvasAIPatch(r, tx, project, workspace, snapshot, node, patch) + if err != nil { + return rejectRequest(413, "canvas_too_large") } - encoded, _ := json.Marshal(doc) - normalized, ok := normalizeCanvas(encoded) - if !ok { - reject(w, 413, "canvas_too_large") - return nil - } - snapshot.Document = normalized - snapshot.Version++ - if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil { + snapshot = change.Snapshot + if err = s.saveCanvasAIChange(r, tx, project, workspace, account, change); err != nil { return err } - _, err = tx.ExecContext(r.Context(), `UPDATE canvas_ai_steps SET applied_version=$5 WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND id=$4`, project, workspace, id, stepID, snapshot.Version) + + _, err = tx.ExecContext(r.Context(), `UPDATE canvas_ai_steps SET applied_version=$5,applied_board_id=$6 WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND id=$4`, project, workspace, id, stepID, snapshot.Version, targetBoard) if err != nil { return err } result := response{"run_id": id, "step_id": stepID, "board_id": targetBoard, "node_id": node, "output_id": input.OutputID, "canvas_version": snapshot.Version, "patch": patch} + change.addTargetContext(result, frozen) + if receipt != nil { + result["status"] = "applied" + bindCanvasAIReceipt(result, receipt) + } if err = appendAIEvent(r.Context(), tx, project, workspace, id, "step.applied", result); err != nil { return err } - encoded, _ = json.Marshal(result) + encoded, _ := json.Marshal(result) _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_mutations(project,workspace_id,run_id,account_id,action,idempotency_key,payload_hash,response_body) VALUES($1,$2,$3,$4,'apply',$5,$6,$7)`, project, workspace, id, account, key, hash, encoded) if err != nil { return err } - if err = tx.Commit(); err != nil { - return err - } setVersion(w, snapshot.Version) - reply(w, 200, result) - return nil + return commitCanvasAIReceipt(w, r, tx, project, workspace, account, receipt, 200, "applied", result) } diff --git a/internal/platform/canvas_ai_image_inputs.go b/internal/platform/canvas_ai_image_inputs.go index 1b0b606..1c5c79a 100644 --- a/internal/platform/canvas_ai_image_inputs.go +++ b/internal/platform/canvas_ai_image_inputs.go @@ -166,7 +166,17 @@ func (s *service) hydrateCanvasTask(ctx context.Context, run canvasQueueRun, ste return err } defer tx.Rollback() - _, role, err := lockMaterial(ctx, tx, run.Project, run.Workspace, step.BoardID, run.Account) + if err = lockCanvasAIWorkspace(ctx, tx, run.Project, run.Workspace); err != nil { + return err + } + board, ok, err := resolveCanvasAITarget(ctx, tx, run.Project, run.Workspace, step) + if err != nil { + return err + } + if !ok { + return errors.New("target_removed") + } + _, role, err := lockMaterial(ctx, tx, run.Project, run.Workspace, board, run.Account) if err != nil { return err } diff --git a/internal/platform/canvas_ai_model.go b/internal/platform/canvas_ai_model.go new file mode 100644 index 0000000..a088ca1 --- /dev/null +++ b/internal/platform/canvas_ai_model.go @@ -0,0 +1,267 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" +) + +var errAITargetChanged = errors.New("target_changed") +var errAIInvalidPatch = errors.New("invalid_ai_patch") + +// The snapshot is an AI reader projection only. Canonical changes retain the +// original rows, including unknown envelopes, geometry and reference metadata. +type canvasAIChange struct { + Snapshot canvasSnapshot + Before *canvasModel + After *canvasModel + Incarnation int64 +} + +// Until a narrower read-set protocol exists, admission binds every active +// canvas in this canonical project. Archived versions are not graph inputs. +// Workspace locking keeps validation, plan freezing and admission atomic. +func validateCanvasAIVersions(model canvasModel, board string, header int64, expected map[string]int64) string { + if len(expected) != len(model.Canvases) || expected[board] != header { + return "invalid_ai_versions" + } + for id := range model.Canvases { + version, ok := expected[id] + if !ok || version < 1 || version > 9007199254740991 { + return "invalid_ai_versions" + } + if version != model.Versions[id] { + return "version_conflict" + } + } + return "" +} + +func prepareModelAIPatch(before canvasModel, board, node string, patch response) (canvasModel, error) { + element := before.Elements[node] + if element == nil || element["structure"] != "node" || element["ownerCanvasId"] != board || modelInt(element["dataVersion"]) != 1 || !canvasChoice(element["componentId"], "text", "script", "plain-text", "image", "plain-image") { + return canvasModel{}, errAITargetChanged + } + after := cloneModel(before) + data := after.Elements[node]["data"].(map[string]any) + for key, value := range patch { + if !canvasChoice(key, "ai", "text", "src", "assetId") { + return canvasModel{}, errAIInvalidPatch + } + data[key] = value + } + if before.Versions[board] < 1 || before.Versions[board] >= 9007199254740991 { + return canvasModel{}, errModelConflict + } + after.Versions[board]++ + if err := validateCanvasModel(after); err != nil { + return canvasModel{}, err + } + return after, nil +} +func prepareCanvasAIPatch(r *http.Request, tx *sql.Tx, project, workspace string, snapshot canvasSnapshot, node string, patch response) (canvasAIChange, error) { + change := canvasAIChange{Snapshot: snapshot} + root, err := canonicalRoot(r.Context(), tx, project, workspace, snapshot.BoardID) + if err != nil { + return change, err + } + if root != "" { + before, err := loadCanvasModel(r.Context(), tx, project, workspace, root) + if err != nil { + return change, err + } + if before.Versions[snapshot.BoardID] != snapshot.Version { + return change, errModelConflict + } + after, err := prepareModelAIPatch(before, snapshot.BoardID, node, patch) + if err != nil { + return change, err + } + projection, err := projectCanvasModel(after) + if err != nil { + return change, err + } + change.Before, change.After = &before, &after + change.Incarnation = modelInt(before.Elements[node]["incarnation"]) + change.Snapshot.Document, change.Snapshot.Version = projection[snapshot.BoardID], after.Versions[snapshot.BoardID] + return change, nil + } + var document map[string]any + if err = json.Unmarshal(snapshot.Document, &document); err != nil { + return change, err + } + target := canvasNodeData(snapshot.Document)[node] + if target == nil { + return change, errAITargetChanged + } + // The data lookup owns a separate decode. Update the staged document itself. + for _, raw := range document["nodes"].([]any) { + element := raw.(map[string]any) + if element["id"] != node { + continue + } + data := element["data"].(map[string]any) + for key, value := range patch { + if !canvasChoice(key, "ai", "text", "src", "assetId") { + return change, errAIInvalidPatch + } + data[key] = value + } + } + document["schema_version"] = float64(2) + encoded, err := json.Marshal(document) + if err != nil { + return change, err + } + normalized, ok := normalizeCanvas(encoded) + if !ok { + return change, errAIInvalidPatch + } + change.Snapshot.Document = normalized + change.Snapshot.Version++ + return change, nil +} +func (s *service) saveCanvasAIChange(r *http.Request, tx *sql.Tx, project, workspace, account string, change canvasAIChange) error { + if change.After == nil { + return s.saveCanvas(r, tx, project, workspace, change.Snapshot) + } + return s.saveCanvasModel(r, tx, project, workspace, account, *change.Before, *change.After, map[string]bool{change.Snapshot.BoardID: true}) +} +func (change canvasAIChange) addCanonical(result response) { + if change.After == nil { + return + } + board := change.Snapshot.BoardID + result["document_format_version"], result["target_incarnation"] = canvasModelFormat, change.Incarnation + result["model"] = change.After.view(map[string]int64{board: change.Snapshot.Version}) +} + +func canvasAIIdentity(ctx context.Context, tx *sql.Tx, project, workspace, board, node string) (string, int64, string, error) { + root, err := canonicalRoot(ctx, tx, project, workspace, board) + if err != nil || root == "" { + return root, 0, "", err + } + var incarnation, version int64 + var kind string + err = tx.QueryRowContext(ctx, `SELECT incarnation,record->>'componentId',(record->>'dataVersion')::bigint FROM canvas_model_elements WHERE project=$1 AND workspace_id=$2 AND root_id=$3 AND element_id=$4 AND owner_canvas_id=$5 AND NOT deleted AND record->>'structure'='node'`, project, workspace, root, node, board).Scan(&incarnation, &kind, &version) + if err == sql.ErrNoRows { + return root, 0, "", nil + } + if err != nil { + return root, 0, "", err + } + if version != 1 || !canvasChoice(kind, "text", "script", "plain-text", "image", "plain-image") { + return root, 0, "", nil + } + return root, incarnation, kind, nil +} +func bindCanvasAIIdentities(ctx context.Context, tx *sql.Tx, project, workspace string, plan *canvasAIPlan) error { + for i, step := range plan.Steps { + root, incarnation, kind, err := canvasAIIdentity(ctx, tx, project, workspace, step.BoardID, step.NodeID) + if err != nil { + return err + } + if root != "" && (incarnation < 1 || kind != step.Kind) { + return errAITargetChanged + } + plan.Steps[i].CanonicalRoot, plan.Steps[i].TargetIncarnation = root, incarnation + } + return nil +} + +// Run URLs remain anchored at their admission board, even after that partition +// is archived. Canonical ownership grants are inherited from the same root. +func lockCanvasAIRunRoute(ctx context.Context, tx *sql.Tx, project, workspace, board, account string) (string, string, error) { + root, err := canonicalRoot(ctx, tx, project, workspace, board) + if err != nil { + return "", "", err + } + if root != "" { + board = root + } + return lockMaterial(ctx, tx, project, workspace, board, account) +} +func lockCanvasAIWorkspace(ctx context.Context, tx *sql.Tx, project, workspace string) error { + var id string + return tx.QueryRowContext(ctx, `SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE`, project, workspace).Scan(&id) +} + +// Caller holds the workspace lock. Frozen identity is stable; owner is resolved +// afresh at execution, hydration and write time, never written into the plan. +func resolveCanvasAITarget(ctx context.Context, tx *sql.Tx, project, workspace string, step canvasAIStepPlan) (string, bool, error) { + if step.CanonicalRoot == "" { + root, err := canonicalRoot(ctx, tx, project, workspace, step.BoardID) + return step.BoardID, err == nil && root == "" && step.TargetIncarnation == 0, err + } + var board, kind string + var incarnation, version int64 + err := tx.QueryRowContext(ctx, `SELECT e.owner_canvas_id::text,e.incarnation,e.record->>'componentId',(e.record->>'dataVersion')::bigint FROM canvas_model_elements e JOIN canvas_model_canvases c ON c.project=e.project AND c.workspace_id=e.workspace_id AND c.canvas_id=e.owner_canvas_id AND c.root_id=e.root_id WHERE e.project=$1 AND e.workspace_id=$2 AND e.root_id=$3 AND e.element_id=$4 AND NOT e.deleted AND NOT c.archived AND e.record->>'structure'='node'`, project, workspace, step.CanonicalRoot, step.NodeID).Scan(&board, &incarnation, &kind, &version) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return board, incarnation == step.TargetIncarnation && incarnation > 0 && kind == step.Kind && version == 1, nil +} +func canvasAIIdentityMatches(ctx context.Context, tx *sql.Tx, project, workspace string, step canvasAIStepPlan) (bool, error) { + _, ok, err := resolveCanvasAITarget(ctx, tx, project, workspace, step) + return ok, err +} +func (change canvasAIChange) addTargetContext(result response, step canvasAIStepPlan) { + change.addCanonical(result) + if step.CanonicalRoot != "" { + result["origin_board_id"], result["canonical_root"] = step.BoardID, step.CanonicalRoot + if change.After != nil && change.Snapshot.BoardID != step.BoardID { + result["model"] = change.After.view(nil) + } + } +} + +// Whole identity transitions cancel old jobs even when delete/restore happens +// in a single atomic batch and the compatibility projection looks identical. +func invalidateCanonicalAIIdentities(r *http.Request, tx *sql.Tx, project, workspace string, before, after canvasModel) error { + removed := map[string][]any{} + for id, element := range before.Elements { + if element["structure"] != "node" { + continue + } + next := after.Elements[id] + if next != nil && modelInt(next["incarnation"]) == modelInt(element["incarnation"]) && next["componentId"] == element["componentId"] && modelInt(next["dataVersion"]) == modelInt(element["dataVersion"]) { + continue + } + rows, err := tx.QueryContext(r.Context(), `SELECT DISTINCT board_id::text FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND frozen_input->>'canonical_root'=$3 AND node_id=$4 AND frozen_input->>'target_incarnation'=$5::bigint::text AND status IN('queued','running')`, project, workspace, before.Root, id, modelInt(element["incarnation"])) + if err != nil { + return err + } + boards := []string{} + for rows.Next() { + var board string + if err = rows.Scan(&board); err != nil { + rows.Close() + return err + } + boards = append(boards, board) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + for _, board := range boards { + removed[board] = append(removed[board], map[string]any{"id": id, "data": map[string]any{"kind": element["componentId"]}}) + } + } + for _, board := range sortedKeys(removed) { + raw, err := json.Marshal(map[string]any{"nodes": removed[board]}) + if err != nil { + return err + } + if err = invalidateCanvasAITargets(r, tx, project, workspace, board, raw, json.RawMessage(`{"nodes":[]}`)); err != nil { + return err + } + } + return nil +} diff --git a/internal/platform/canvas_ai_model_integration_test.go b/internal/platform/canvas_ai_model_integration_test.go new file mode 100644 index 0000000..a5d407a --- /dev/null +++ b/internal/platform/canvas_ai_model_integration_test.go @@ -0,0 +1,536 @@ +package platform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "image" + "image/png" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" +) + +func TestCanonicalAIAdmissionAndLifecycle(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 [32]byte + _, _ = rand.Read(entropy[:]) + project := "canonical-ai-" + hex.EncodeToString(entropy[:8]) + token := base64.RawURLEncoding.EncodeToString(entropy[:]) + digest := sha256.Sum256([]byte(token)) + var account, workspace, root 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) + } + defer func() { + for _, table := range []string{"canvas_ai_runs", "canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_assets", "canvas_subgraphs", "workspaces", "project_accounts"} { + _, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project) + } + }() + 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(digest[:]), project, account); err != nil { + t.Fatal(err) + } + if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Canonical AI',$2) RETURNING id::text`, project, account).Scan(&workspace); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin')`, project, workspace, account); err != nil { + t.Fatal(err) + } + if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Canonical AI',$3,$4) RETURNING id::text`, project, workspace, account, json.RawMessage(canvasFixture)).Scan(&root); err != nil { + t.Fatal(err) + } + temp, err := os.MkdirTemp("", "fc-canonical-ai-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(temp) + socket := filepath.Join(temp, "worker.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + var pngBytes bytes.Buffer + if err = png.Encode(&pngBytes, image.NewRGBA(image.Rect(0, 0, 3, 2))); err != nil { + t.Fatal(err) + } + hooks := make(chan func(), 1) + var executions atomic.Int32 + worker := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/canvas/models" { + reply(w, 200, response{"models": []any{codex.CanvasModel{ID: "text", Available: true, InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}, MaxInputBytes: 1 << 20, MaxExecutionMS: 90000}, codex.CanvasModel{ID: "image", Available: true, InputModalities: []string{"text", "image"}, OutputModalities: []string{"image"}, MaxInputBytes: 1 << 20, MaxExecutionMS: 180000}}}) + return + } + if r.URL.Path != "/canvas/exec" { + reject(w, 404, "not_found") + return + } + var task codex.CanvasTask + if json.NewDecoder(r.Body).Decode(&task) != nil { + reject(w, 400, "invalid_request") + return + } + executions.Add(1) + select { + case hook := <-hooks: + hook() + default: + } + if task.OutputModality == "image" { + reply(w, 200, response{"output": codex.CanvasOutput{Kind: "image", Image: &codex.CanvasImage{MIME: "image/png", Data: pngBytes.Bytes()}}}) + } else { + reply(w, 200, response{"result": "generated result"}) + } + })} + go worker.Serve(listener) + defer worker.Close() + s := &service{db: db, origin: "https://api.example.com", canvasAI: codex.NewCanvasClient(socket)} + callbacks := map[string]string{project: "https://app.example.com/auth/callback"} + mux := http.NewServeMux() + prefix := "/projects/{project}/workspaces/{workspace}/materials/{material}/canvas" + mux.HandleFunc(prefix+"/model", s.protected(callbacks, s.getCanvasModel)) + mux.HandleFunc(prefix+"/model/migrate", s.protected(callbacks, s.migrateCanonicalCanvas)) + mux.HandleFunc(prefix+"/ai/runs", s.protected(callbacks, s.createCanvasAIRun)) + mux.HandleFunc(prefix+"/ai/runs/{run}", s.protected(callbacks, s.getCanvasAIRun)) + mux.HandleFunc(prefix+"/ai/runs/{run}/apply", s.protected(callbacks, s.applyCanvasAIOutput)) + mux.HandleFunc(prefix+"/ai/runs/{run}/cancel", s.protected(callbacks, s.cancelCanvasAIRun)) + mux.HandleFunc(prefix+"/ai/operations/{request}", s.protected(callbacks, s.getCanvasAIAdmission)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/{asset}", s.protected(callbacks, s.getAsset)) + base := func(board string) string { + return "/projects/" + project + "/workspaces/" + workspace + "/materials/" + board + "/canvas" + } + call := func(method, path string, input any, status int, version int64, key string) response { + t.Helper() + raw, _ := json.Marshal(input) + r := httptest.NewRequest(method, path, bytes.NewReader(raw)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2") + if version > 0 { + r.Header.Set("If-Match", `"`+strconv.FormatInt(version, 10)+`"`) + } + if key != "" { + r.Header.Set("Idempotency-Key", key) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status { + t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status) + } + result := response{} + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + read := func() canvasModel { + t.Helper() + tx, e := db.BeginTx(ctx, nil) + if e != nil { + t.Fatal(e) + } + defer tx.Rollback() + model, e := loadCanvasModel(ctx, tx, project, workspace, root) + if e != nil { + t.Fatal(e) + } + return model + } + mutate := func(edit func(*canvasModel)) { + t.Helper() + tx, e := db.BeginTx(ctx, nil) + if e != nil { + t.Fatal(e) + } + defer tx.Rollback() + if _, _, e = lockMaterial(ctx, tx, project, workspace, root, account); e != nil { + t.Fatal(e) + } + before, e := loadCanvasModel(ctx, tx, project, workspace, root) + if e != nil { + t.Fatal(e) + } + after := cloneModel(before) + edit(&after) + participants := map[string]bool{} + for id := range after.Canvases { + after.Versions[id] = before.Versions[id] + 1 + participants[id] = true + } + for id := range before.Canvases { + if _, active := after.Canvases[id]; !active { + after.Versions[id] = before.Versions[id] + 1 + participants[id] = true + } + } + if e = validateCanvasModel(after); e != nil { + t.Fatal(e) + } + if e = s.saveCanvasModel(queueRequest(ctx), tx, project, workspace, account, before, after, participants); e != nil { + t.Fatal(e) + } + if e = tx.Commit(); e != nil { + t.Fatal(e) + } + } + migrationKey := modelTestID(6000) + migration := call("POST", base(root)+"/model/migrate", response{"protocol_version": 2, "request_id": migrationKey, "expected_versions": map[string]int64{root: 1}}, 200, 0, migrationKey) + node := "" + for _, entry := range migration["identity_map"].([]any) { + mapping := entry.(map[string]any) + if mapping["sourceId"] == "a" { + node = mapping["elementId"].(string) + } + } + if node == "" { + t.Fatal("missing migrated target") + } + mutate(func(model *canvasModel) { + model.Elements[node]["geometry"].(map[string]any)["width"] = float64(9000) + model.Elements[node]["opaque"] = map[string]any{"kept": true} + model.Elements[node]["data"].(map[string]any)["extension"] = map[string]any{"constructor": map[string]any{"kept": true}} + model.Elements["future"] = modelTestNode("future", root) + model.Elements["future"]["componentId"] = "future-component" + model.Elements["future"]["dataVersion"] = float64(12) + }) + next := 6100 + command := func(board, target, model string, incarnation int64) (response, string) { + next += 2 + key := modelTestID(next) + return response{"protocol_version": 2, "request_id": key, "operation_id": modelTestID(next + 1), "target_incarnation": incarnation, "expected_versions": func() map[string]int64 { + model := read() + versions := map[string]int64{} + for id := range model.Canvases { + versions[id] = model.Versions[id] + } + return versions + }(), "node_id": target, "model_id": model, "prompt": "Generate", "parameters": response{}, "propagate": false}, key + } + start := func(board, target, model string, incarnation int64) response { + input, key := command(board, target, model, incarnation) + return call("POST", base(board)+"/ai/runs", input, 202, read().Versions[board], key) + } + execute := func(id string) { + t.Helper() + run, e := s.claimCanvasRun(ctx) + if e != nil || run == nil || run.ID != id { + t.Fatalf("claim %v %v", run, e) + } + s.executeCanvasRun(ctx, *run) + } + input, key := command(root, node, "text", 1) + sourceVersion := read().Versions[root] + admitted := call("POST", base(root)+"/ai/runs", input, 202, sourceVersion, key) + if admitted["admission"] != "accepted" || admitted["target_incarnation"] != float64(1) || admitted["model"] == nil { + t.Fatal("canonical admission lost identity/base") + } + retry := call("POST", base(root)+"/ai/runs", input, 202, sourceVersion, key) + if !modelEqual(admitted, retry) { + t.Fatal("admission retry changed result") + } + call("POST", base(root)+"/ai/runs", input, 409, sourceVersion+1, key) + queried := call("GET", base(root)+"/ai/operations/"+key, nil, 200, 0, "") + if !modelEqual(admitted, queried) { + t.Fatal("lost admission response not recoverable") + } + unknown := call("GET", base(root)+"/ai/operations/"+modelTestID(6099), nil, 404, 0, "") + if unknown["status"] != "unknown" || unknown["terminal"] == true { + t.Fatal("404 pretended zero execution") + } + duplicate := response{} + for k, v := range input { + duplicate[k] = v + } + duplicateKey := modelTestID(6098) + duplicate["request_id"] = duplicateKey + call("POST", base(root)+"/ai/runs", duplicate, 409, read().Versions[root], duplicateKey) + mutate(func(model *canvasModel) { model.Elements[node]["geometry"].(map[string]any)["x"] = float64(33) }) + execute(admitted["run_id"].(string)) + saved := read() + data := saved.Elements[node]["data"].(map[string]any) + if data["text"] != "generated result" || saved.Elements[node]["geometry"].(map[string]any)["x"] != float64(33) || saved.Elements[node]["geometry"].(map[string]any)["width"] != float64(9000) || saved.Elements["future"] == nil || saved.Elements[node]["opaque"] == nil || data["extension"] == nil { + t.Fatal("AI lost canonical fields, geometry or future records") + } + // A content conflict retains its output; explicit application is still a narrow canonical write. + conflictRun := start(root, node, "text", 1) + hooks <- func() { + mutate(func(model *canvasModel) { model.Elements[node]["data"].(map[string]any)["text"] = "user edit" }) + } + execute(conflictRun["run_id"].(string)) + conflictPath := base(root) + "/ai/runs/" + conflictRun["run_id"].(string) + conflict := call("GET", conflictPath, nil, 200, 0, "") + if conflict["status"] != "conflict" { + t.Fatal("changed content was overwritten") + } + outputID := conflict["outputs"].([]any)[0].(map[string]any)["output"].(map[string]any)["output_id"].(string) + applyKey := modelTestID(6200) + expected := read().Versions[root] + applyInput := response{"protocol_version": 2, "request_id": applyKey, "operation_id": modelTestID(6199), "target_incarnation": 1, "target_canvas_id": root, "node_id": node, "output_id": outputID} + applied := call("POST", conflictPath+"/apply", applyInput, 200, expected, applyKey) + again := call("POST", conflictPath+"/apply", applyInput, 200, expected, applyKey) + if !modelEqual(applied, again) || applied["model"] == nil || read().Elements[node]["geometry"].(map[string]any)["width"] != float64(9000) { + t.Fatal("explicit canonical application changed geometry or receipt") + } + applyReceipt := call("GET", base(root)+"/ai/operations/"+applyKey, nil, 200, 0, "") + if !modelEqual(applied, applyReceipt) || applied["action"] != "run.apply" || applied["status"] != "applied" { + t.Fatal("explicit apply receipt is not recoverable") + } + call("POST", conflictPath+"/apply", applyInput, 409, expected+1, applyKey) + // Keep one unapplied old output, then replace the target incarnation atomically. + stale := start(root, node, "text", 1) + hooks <- func() { + mutate(func(model *canvasModel) { model.Elements[node]["data"].(map[string]any)["text"] = "keep this" }) + } + execute(stale["run_id"].(string)) + stalePath := base(root) + "/ai/runs/" + stale["run_id"].(string) + staleResult := call("GET", stalePath, nil, 200, 0, "") + staleOutput := staleResult["outputs"].([]any)[0].(map[string]any)["output"].(map[string]any)["output_id"].(string) + old := start(root, node, "text", 1) + run, e := s.claimCanvasRun(ctx) + if e != nil || run == nil || run.ID != old["run_id"] { + t.Fatalf("claim stale %v", e) + } + if ok, e := s.startCanvasStep(ctx, *run, run.Plan.Steps[0]); e != nil || !ok { + t.Fatalf("start stale %v", e) + } + mutate(func(model *canvasModel) { + model.Elements[node]["incarnation"] = float64(2) + model.Tombstones[node] = 1 + model.TombstoneOwners[node] = root + }) + state, e := s.finishCanvasStep(ctx, *run, run.Plan.Steps[0], "late output", "", true) + if e != nil || state != "cancelled" { + t.Fatalf("restored target accepted old callback %s %v", state, e) + } + if e = s.completeCanvasRun(ctx, *run, nil); e != nil { + t.Fatal(e) + } + if read().Elements[node]["data"].(map[string]any)["text"] != "keep this" { + t.Fatal("restore was overwritten") + } + staleApplyKey := modelTestID(6201) + staleApply := call("POST", stalePath+"/apply", response{"protocol_version": 2, "request_id": staleApplyKey, "operation_id": modelTestID(6205), "target_incarnation": 1, "target_canvas_id": root, "node_id": node, "output_id": staleOutput}, 409, read().Versions[root], staleApplyKey) + if staleApply["status"] != "not-applied" || staleApply["terminal"] != true || !modelEqual(staleApply, call("GET", base(root)+"/ai/operations/"+staleApplyKey, nil, 200, 0, "")) { + t.Fatal("stale apply has no queryable zero-effect receipt") + } + + bad, badKey := command(root, node, "text", 1) + call("POST", base(root)+"/ai/runs", bad, 409, read().Versions[root], badKey) + restored := start(root, node, "text", 2) + cancelPath := base(root) + "/ai/runs/" + restored["run_id"].(string) + "/cancel" + cancelKey := modelTestID(6202) + cancelInput := response{"protocol_version": 2, "request_id": cancelKey, "operation_id": modelTestID(6203)} + cancelled := call("POST", cancelPath, cancelInput, 200, 0, cancelKey) + cancelAgain := call("POST", cancelPath, cancelInput, 200, 0, cancelKey) + cancelReceipt := call("GET", base(root)+"/ai/operations/"+cancelKey, nil, 200, 0, "") + if cancelled["status"] != "applied" || cancelled["run_status"] != "cancelled" || !modelEqual(cancelled, cancelAgain) || !modelEqual(cancelled, cancelReceipt) { + t.Fatal("cancel receipt changed on retry/query") + } + duplicateCancelKey := modelTestID(6204) + call("POST", cancelPath, response{"protocol_version": 2, "request_id": duplicateCancelKey, "operation_id": modelTestID(6203)}, 409, 0, duplicateCancelKey) + if executions.Load() != 3 { + t.Fatal("duplicate/cancelled/old execution called the model") + } + // Image output resolves a managed resource while retaining the element envelope. + imageID := modelTestID(6300) + mutate(func(model *canvasModel) { + record := modelTestNode(imageID, root) + record["componentId"] = "plain-image" + record["data"].(map[string]any)["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes.Bytes()) + record["geometry"].(map[string]any)["width"] = float64(10000) + model.Elements[imageID] = record + }) + imageRun := start(root, imageID, "image", 1) + execute(imageRun["run_id"].(string)) + imageRecord := read().Elements[imageID] + imageData := imageRecord["data"].(map[string]any) + if imageRecord["geometry"].(map[string]any)["width"] != float64(10000) || imageData["assetId"] == nil || !strings.HasPrefix(modelString(imageData["src"]), s.assetURL(project, workspace, "")) { + t.Fatal("image result lost geometry or resource identity") + } + request := httptest.NewRequest("GET", modelString(imageData["src"]), nil) + request.Header.Set("Authorization", "Bearer "+token) + download := httptest.NewRecorder() + mux.ServeHTTP(download, request) + if download.Code != 200 || !bytes.Equal(download.Body.Bytes(), pngBytes.Bytes()) { + t.Fatal("canonical generated asset is unreadable") + } + moved := start(root, imageID, "image", 1) + moving, e := s.claimCanvasRun(ctx) + if e != nil || moving == nil || moving.ID != moved["run_id"] { + t.Fatalf("claim move %v", e) + } + if ok, e := s.startCanvasStep(ctx, *moving, moving.Plan.Steps[0]); e != nil || !ok { + t.Fatalf("start move %v", e) + } + child, frame := modelTestID(6301), modelTestID(6302) + mutate(func(model *canvasModel) { + entry := modelTestNode(frame, root) + entry["componentId"] = "frame" + model.Elements[frame] = entry + model.Canvases[child] = modelCanvas{ID: child, Parent: &modelParent{CanvasID: root, EntryElementID: frame}} + model.Elements[imageID]["ownerCanvasId"] = child + }) + busyInput, busyKey := command(child, imageID, "image", 1) + busy := call("POST", base(child)+"/ai/runs", busyInput, 409, read().Versions[child], busyKey) + if busy["error"] != "node_busy" { + t.Fatal("owner move admitted a duplicate paid task") + } + beforeMovedOutput := read() + state, e = s.finishCanvasStepOutput(ctx, *moving, moving.Plan.Steps[0], codex.CanvasOutput{Kind: "image", Image: &codex.CanvasImage{MIME: "image/png", Data: pngBytes.Bytes()}}, "", true) + if e != nil || state != "succeeded" { + t.Fatalf("owner-only move rejected output %s %v", state, e) + } + afterMovedOutput := read() + if afterMovedOutput.Elements[imageID]["ownerCanvasId"] != child || afterMovedOutput.Versions[root] != beforeMovedOutput.Versions[root] || afterMovedOutput.Versions[child] != beforeMovedOutput.Versions[child]+1 || afterMovedOutput.Elements[imageID]["geometry"].(map[string]any)["width"] != float64(10000) { + t.Fatal("relocated result rewrote ownership, geometry or the origin version") + } + movedStatus := call("GET", base(root)+"/ai/runs/"+moving.ID, nil, 200, 0, "") + foundRelocation := false + for _, value := range movedStatus["events"].([]any) { + event := value.(map[string]any) + if event["type"] == "step.applied" { + foundRelocation = event["board_id"] == child && event["origin_board_id"] == root && event["canonical_root"] == root && event["model"].(map[string]any)["complete"] == true + } + } + if !foundRelocation || movedStatus["applied_versions"].(map[string]any)[child] != float64(afterMovedOutput.Versions[child]) { + t.Fatal("moved output lost current-owner version or canonical context") + } + + if e = s.completeCanvasRun(ctx, *moving, nil); e != nil { + t.Fatal(e) + } + staleInput, staleKey := command(child, imageID, "image", 1) + staleVersions := staleInput["expected_versions"].(map[string]int64) + staleVersions[root]-- // The target version is current; its ancestor context is not. + beforeRejected := read() + denied := call("POST", base(child)+"/ai/runs", staleInput, 412, read().Versions[child], staleKey) + if denied["status"] != "not-applied" || denied["terminal"] != true || denied["action"] != "run.start" || !modelEqual(beforeRejected, read()) { + t.Fatal("rejection was not a zero-effect receipt") + } + if queried := call("GET", base(child)+"/ai/operations/"+staleKey, nil, 200, 0, ""); !modelEqual(denied, queried) { + t.Fatal("negative admission receipt lost") + } + // A later context change cannot turn an old rejected key into a paid execution. + mutate(func(model *canvasModel) { model.Elements[imageID]["geometry"].(map[string]any)["x"] = float64(123) }) + if retried := call("POST", base(child)+"/ai/runs", staleInput, 412, beforeRejected.Versions[child], staleKey); !modelEqual(denied, retried) { + t.Fatal("rejected request was reevaluated") + } + missingRunKey := modelTestID(6400) + missingCancel := call("POST", base(child)+"/ai/runs/"+modelTestID(6401)+"/cancel", response{"protocol_version": 2, "request_id": missingRunKey, "operation_id": modelTestID(6402)}, 404, 0, missingRunKey) + if missingCancel["status"] != "not-applied" || missingCancel["terminal"] != true { + t.Fatal("missing run cancel has no stable rejection") + } + if queried := call("GET", base(child)+"/ai/operations/"+missingRunKey, nil, 200, 0, ""); !modelEqual(missingCancel, queried) { + t.Fatal("negative cancel receipt lost") + } + missingInput, missingKey := command(child, imageID, "image", 1) + delete(missingInput, "expected_versions") + call("POST", base(child)+"/ai/runs", missingInput, 400, read().Versions[child], missingKey) + // An explicit replan can reuse a logical operation after its known zero-effect rejection. + replanned := response{} + for key, value := range staleInput { + replanned[key] = value + } + replanKey := modelTestID(6405) + replanned["request_id"], replanned["expected_versions"] = replanKey, read().Versions + childRun := call("POST", base(child)+"/ai/runs", replanned, 202, read().Versions[child], replanKey) + + execute(childRun["run_id"].(string)) + beforeLateCancel := read() + lateKey := modelTestID(6406) + lateCancel := call("POST", base(child)+"/ai/runs/"+childRun["run_id"].(string)+"/cancel", response{"protocol_version": 2, "request_id": lateKey, "operation_id": modelTestID(6407)}, 200, 0, lateKey) + if lateCancel["run_status"] != "succeeded" || !modelEqual(beforeLateCancel, read()) { + t.Fatal("late cancellation pretended to undo a completed result") + } + if read().Elements[imageID]["ownerCanvasId"] != child { + t.Fatal("child result moved its target back") + } + queuedMove := start(child, imageID, "image", 1) + mutate(func(model *canvasModel) { + model.Elements[imageID]["ownerCanvasId"] = root + delete(model.Canvases, child) + }) + execute(queuedMove["run_id"].(string)) + archivedRoute := base(child) + "/ai/runs/" + queuedMove["run_id"].(string) + archivedResult := call("GET", archivedRoute, nil, 200, 0, "") + if archivedResult["status"] != "succeeded" || read().Elements[imageID]["ownerCanvasId"] != root { + t.Fatal("queued move or archived admission route stopped the run") + } + lateArchiveKey := modelTestID(6500) + if result := call("POST", archivedRoute+"/cancel", response{"protocol_version": 2, "request_id": lateArchiveKey, "operation_id": modelTestID(6501)}, 200, 0, lateArchiveKey); result["run_status"] != "succeeded" { + t.Fatal("archived route lost cancellation identity") + } + // Content changes still conflict after a move; explicit application targets the current owner. + movedConflict := start(root, node, "text", 2) + hooks <- func() { + mutate(func(model *canvasModel) { + model.Canvases[child] = modelCanvas{ID: child, Parent: &modelParent{CanvasID: root, EntryElementID: frame}} + model.Elements[node]["ownerCanvasId"] = child + model.Elements[node]["data"].(map[string]any)["text"] = "keep moved edit" + }) + } + execute(movedConflict["run_id"].(string)) + movedConflictPath := base(root) + "/ai/runs/" + movedConflict["run_id"].(string) + conflictMovedResult := call("GET", movedConflictPath, nil, 200, 0, "") + if conflictMovedResult["status"] != "conflict" || read().Elements[node]["data"].(map[string]any)["text"] != "keep moved edit" { + t.Fatal("move allowed a content conflict overwrite") + } + movedOutput := conflictMovedResult["outputs"].([]any)[0].(map[string]any)["output"].(map[string]any)["output_id"].(string) + movedApplyKey := modelTestID(6502) + movedApplied := call("POST", movedConflictPath+"/apply", response{"protocol_version": 2, "request_id": movedApplyKey, "operation_id": modelTestID(6503), "node_id": node, "output_id": movedOutput, "target_incarnation": 2, "target_canvas_id": child}, 200, read().Versions[child], movedApplyKey) + if movedApplied["board_id"] != child || movedApplied["origin_board_id"] != root || read().Elements[node]["ownerCanvasId"] != child { + t.Fatal("explicit application wrote to the frozen owner") + } + doomed := start(child, node, "text", 2) + mutate(func(model *canvasModel) { + record := model.Elements[node] + model.DeadRecords[node] = record + model.Tombstones[node] = 2 + model.TombstoneOwners[node] = child + delete(model.Elements, node) + for id, element := range model.Elements { + if element["structure"] == "connection" && (element["source"].(map[string]any)["elementId"] == node || element["target"].(map[string]any)["elementId"] == node) { + model.DeadRecords[id] = element + model.Tombstones[id] = modelInt(element["incarnation"]) + model.TombstoneOwners[id] = modelString(element["ownerCanvasId"]) + delete(model.Elements, id) + } + } + }) + execute(doomed["run_id"].(string)) + if result := call("GET", base(child)+"/ai/runs/"+doomed["run_id"].(string), nil, 200, 0, ""); result["status"] != "cancelled" { + t.Fatal("deleting a moved target failed to revoke its old job") + } + + revoked := start(root, imageID, "image", 1) + mutate(func(model *canvasModel) { model.Elements[imageID]["ownerCanvasId"] = child }) + beforeRevoked := executions.Load() + if _, e = db.ExecContext(ctx, `UPDATE workspace_members SET role='viewer' WHERE project=$1 AND workspace_id=$2 AND account_id=$3`, project, workspace, account); e != nil { + t.Fatal(e) + } + execute(revoked["run_id"].(string)) + if result := call("GET", base(root)+"/ai/runs/"+revoked["run_id"].(string), nil, 200, 0, ""); result["status"] != "failed" || executions.Load() != beforeRevoked { + t.Fatal("moved target executed after write permission was revoked") + } + +} diff --git a/internal/platform/canvas_ai_model_test.go b/internal/platform/canvas_ai_model_test.go new file mode 100644 index 0000000..7bfa052 --- /dev/null +++ b/internal/platform/canvas_ai_model_test.go @@ -0,0 +1,97 @@ +package platform + +import ( + "encoding/json" + "testing" +) + +func TestCanonicalAIPatchPreservesOpaqueModelAndGeometry(t *testing.T) { + before := modelTestDocument() + before.Elements["a"]["geometry"].(map[string]any)["width"] = float64(9000) + before.Elements["a"]["extension"] = map[string]any{"kept": true} + before.Elements["a"]["data"].(map[string]any)["opaque"] = map[string]any{"constructor": map[string]any{"kept": true}} + before.Elements["future"] = modelTestNode("future", modelRootID) + before.Elements["future"]["dataVersion"] = float64(12) + original := cloneModel(before) + after, err := prepareModelAIPatch(before, modelRootID, "a", response{"text": "generated"}) + if err != nil { + t.Fatal(err) + } + if !modelEqual(before, original) { + t.Fatal("AI staging changed its source") + } + if after.Elements["a"]["data"].(map[string]any)["text"] != "generated" || after.Versions[modelRootID] != 2 { + t.Fatal("AI text/version missing") + } + if !modelEqual(after.Elements["a"]["geometry"], before.Elements["a"]["geometry"]) || !modelEqual(after.Elements["a"]["extension"], before.Elements["a"]["extension"]) || !modelEqual(after.Elements["future"], before.Elements["future"]) { + t.Fatal("AI staging replaced unrelated fields") + } + if _, err = prepareModelAIPatch(before, modelRootID, "a", response{"geometry": map[string]any{}}); err != errAIInvalidPatch { + t.Fatalf("geometry patch was accepted: %v", err) + } + if _, err = prepareModelAIPatch(before, modelRootID, "future", response{"text": "lost"}); err != errAITargetChanged { + t.Fatalf("future schema was interpreted: %v", err) + } + if _, err = prepareModelAIPatch(before, modelChildID, "a", response{"text": "wrong owner"}); err != errAITargetChanged { + t.Fatalf("wrong owner was accepted: %v", err) + } +} + +func TestCanonicalAIProjectionDoesNotExecuteFutureConnections(t *testing.T) { + model := modelTestDocument() + model.Elements["b"] = modelTestNode("b", modelRootID) + model.Elements["future-edge"] = map[string]any{"id": "future-edge", "incarnation": float64(1), "structure": "connection", "componentId": "connection", "dataVersion": float64(9), "ownerCanvasId": modelRootID, "source": map[string]any{"elementId": "a"}, "target": map[string]any{"elementId": "b"}, "path": nil, "data": map[string]any{}} + if err := validateCanvasModel(model); err != nil { + t.Fatal(err) + } + projection, err := projectCanvasModel(model) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err = json.Unmarshal(projection[modelRootID], &document); err != nil { + t.Fatal(err) + } + if len(document["edges"].([]any)) != 0 || model.Elements["future-edge"] == nil { + t.Fatal("future connection was executed or removed") + } +} + +func TestCanonicalAIAdmissionVersions(t *testing.T) { + model := modelTestDocument() + model.Canvases[modelChildID] = modelCanvas{ID: modelChildID} + model.Versions[modelChildID] = 3 + // Include an archived version: it must not become an executable read dependency. + model.Versions["archived"] = 8 + valid := map[string]int64{} + for id := range model.Canvases { + valid[id] = model.Versions[id] + } + if code := validateCanvasAIVersions(model, modelRootID, valid[modelRootID], valid); code != "" { + t.Fatal(code) + } + for _, test := range []struct { + name string + versions map[string]int64 + header int64 + want string + }{ + {"missing", nil, 1, "invalid_ai_versions"}, + {"changed-header", valid, 99, "invalid_ai_versions"}, + {"incomplete", map[string]int64{modelRootID: 1}, 1, "invalid_ai_versions"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := validateCanvasAIVersions(model, modelRootID, test.header, test.versions); got != test.want { + t.Fatalf("got %q want %q", got, test.want) + } + }) + } + stale := map[string]int64{} + for id, version := range valid { + stale[id] = version + } + stale[modelChildID]++ + if code := validateCanvasAIVersions(model, modelRootID, valid[modelRootID], stale); code != "version_conflict" { + t.Fatal(code) + } +} diff --git a/internal/platform/canvas_ai_plan.go b/internal/platform/canvas_ai_plan.go index f418283..1d07abc 100644 --- a/internal/platform/canvas_ai_plan.go +++ b/internal/platform/canvas_ai_plan.go @@ -15,18 +15,20 @@ type canvasAIStaticInput struct { Content map[string]any `json:"content"` } type canvasAIStepPlan struct { - StaticInputs []canvasAIStaticInput `json:"static_inputs"` - MaxExecutionMS int `json:"max_execution_ms"` - ID string `json:"step_id"` - BoardID string `json:"board_id"` - NodeID string `json:"node_id"` - Kind string `json:"kind"` - ModelID string `json:"model_id"` - Config map[string]any `json:"config"` - FrozenContent map[string]any `json:"frozen_content"` - Fingerprint string `json:"target_fingerprint"` - Status string `json:"status"` - Dependencies []string `json:"dependencies"` + CanonicalRoot string `json:"canonical_root,omitempty"` + TargetIncarnation int64 `json:"target_incarnation,omitempty"` + StaticInputs []canvasAIStaticInput `json:"static_inputs"` + MaxExecutionMS int `json:"max_execution_ms"` + ID string `json:"step_id"` + BoardID string `json:"board_id"` + NodeID string `json:"node_id"` + Kind string `json:"kind"` + ModelID string `json:"model_id"` + Config map[string]any `json:"config"` + FrozenContent map[string]any `json:"frozen_content"` + Fingerprint string `json:"target_fingerprint"` + Status string `json:"status"` + Dependencies []string `json:"dependencies"` } type canvasAIEdgePlan struct { ID string `json:"logical_edge_id"` diff --git a/internal/platform/canvas_ai_query.go b/internal/platform/canvas_ai_query.go index 32a9766..b6d3cfb 100644 --- a/internal/platform/canvas_ai_query.go +++ b/internal/platform/canvas_ai_query.go @@ -14,7 +14,7 @@ func publicAIPlan(plan canvasAIPlan) response { for _, source := range step.StaticInputs { static = append(static, response{"logical_edge_id": source.EdgeID, "board_id": source.BoardID, "node_id": source.NodeID, "kind": source.Kind, "source": "frozen_snapshot"}) } - steps = append(steps, response{"step_id": step.ID, "board_id": step.BoardID, "node_id": step.NodeID, "kind": step.Kind, "model_id": step.ModelID, "max_execution_ms": step.MaxExecutionMS, "status": step.Status, "dependencies": step.Dependencies, "static_inputs": static}) + steps = append(steps, response{"step_id": step.ID, "board_id": step.BoardID, "node_id": step.NodeID, "target_incarnation": step.TargetIncarnation, "canonical_root": step.CanonicalRoot, "kind": step.Kind, "model_id": step.ModelID, "max_execution_ms": step.MaxExecutionMS, "status": step.Status, "dependencies": step.Dependencies, "static_inputs": static}) } return response{"steps": steps, "edges": plan.Edges, "frozen_versions": plan.Versions} } @@ -51,7 +51,7 @@ func (s *service) getCanvasAIRun(w http.ResponseWriter, r *http.Request, project return err } defer tx.Rollback() - if _, _, err = lockMaterial(r.Context(), tx, project, workspace, board, account); err != nil { + if _, _, err = lockCanvasAIRunRoute(r.Context(), tx, project, workspace, board, account); err != nil { return err } var status string @@ -68,14 +68,21 @@ func (s *service) getCanvasAIRun(w http.ResponseWriter, r *http.Request, project } checked := map[string]bool{board: true} for _, step := range plan.Steps { - if !checked[step.BoardID] { - if _, _, err = lockMaterial(r.Context(), tx, project, workspace, step.BoardID, account); err != nil { - return err + target, ok, e := resolveCanvasAITarget(r.Context(), tx, project, workspace, step) + if e != nil { + return e + } + if !ok { + target = step.BoardID + } + if !checked[target] { + if _, _, e = lockCanvasAIRunRoute(r.Context(), tx, project, workspace, target, account); e != nil { + return e } - checked[step.BoardID] = true + checked[target] = true } } - rows, err := tx.QueryContext(r.Context(), `SELECT id,board_id::text,node_id,status,error_code,applied_version,CASE WHEN output IS NULL THEN NULL ELSE output-'text' END FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND run_id=$3 ORDER BY ordinal`, project, workspace, id) + rows, err := tx.QueryContext(r.Context(), `SELECT id,COALESCE(applied_board_id,board_id)::text,node_id,status,error_code,applied_version,CASE WHEN output IS NULL THEN NULL ELSE output-'text' END FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND run_id=$3 ORDER BY ordinal`, project, workspace, id) if err != nil { return err } @@ -158,3 +165,45 @@ func (s *service) getCanvasAIRun(w http.ResponseWriter, r *http.Request, project reply(w, 200, result) return nil } + +// Resolve admission by the original client key, before the caller knows a run ID. +// Absence is unknown, not a terminal proof that an earlier invocation had no effect. +func (s *service) getCanvasAIAdmission(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 + } + workspace, board := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, _, err = lockCanvasAIRunRoute(r.Context(), tx, project, workspace, board, account); err != nil { + return err + } + var body json.RawMessage + err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_ai_requests WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND request_id=$5`, project, workspace, board, account, key).Scan(&body) + if err == nil { + reply(w, 200, body) + return nil + } + if err != sql.ErrNoRows { + return err + } + err = tx.QueryRowContext(r.Context(), `SELECT create_response FROM canvas_ai_runs WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND idempotency_key=$5`, project, workspace, board, account, key).Scan(&body) + if err == sql.ErrNoRows { + reply(w, 404, response{"protocol_version": 2, "request_id": key, "status": "unknown", "error": "ai_admission_unknown"}) + return nil + } + if err != nil { + return err + } + reply(w, 200, body) + return nil +} diff --git a/internal/platform/canvas_ai_receipts.go b/internal/platform/canvas_ai_receipts.go new file mode 100644 index 0000000..5d1c908 --- /dev/null +++ b/internal/platform/canvas_ai_receipts.go @@ -0,0 +1,83 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "net/http" +) + +type canvasAIRequest struct { + Key, Hash, Action, Board, Run, Operation string +} + +// The workspace lock is acquired by the caller before this savepoint. Rejected +// requests roll back every staged effect and retain that lock while recording +// their zero-effect receipt, so a response can never race a second invocation. +func beginCanvasAIRequest(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, workspace, account string, request *canvasAIRequest) (bool, error) { + if request == nil { + return false, nil + } + var board, run, action, hash string + var status int + var body json.RawMessage + err := tx.QueryRowContext(r.Context(), `SELECT board_id::text,COALESCE(run_id::text,''),action,payload_hash,response_status,response_body FROM canvas_ai_requests WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND request_id=$4`, project, workspace, account, request.Key).Scan(&board, &run, &action, &hash, &status, &body) + if err == nil { + if board != request.Board || run != request.Run || action != request.Action || hash != request.Hash { + reject(w, 409, "idempotency_conflict") + return true, nil + } + reply(w, status, body) + return true, nil + } + if err != sql.ErrNoRows { + return false, err + } + var completed bool + err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_ai_requests WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND operation_id=$4 AND outcome='applied')`, project, workspace, account, request.Operation).Scan(&completed) + if err != nil { + return false, err + } + if completed { + reject(w, 409, "operation_identity_conflict") + return true, nil + } + _, err = tx.ExecContext(r.Context(), `SAVEPOINT canvas_ai_effects`) + return false, err +} +func bindCanvasAIReceipt(body response, request *canvasAIRequest) { + if request == nil { + return + } + body["protocol_version"], body["request_id"], body["operation_id"], body["action"] = 2, request.Key, request.Operation, request.Action + if request.Run != "" { + body["run_id"] = request.Run + } +} +func commitCanvasAIReceipt(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, workspace, account string, request *canvasAIRequest, status int, outcome string, body response) error { + if request != nil { + bindCanvasAIReceipt(body, request) + raw, err := json.Marshal(body) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_requests(project,workspace_id,account_id,request_id,board_id,run_id,action,operation_id,payload_hash,outcome,response_status,response_body) VALUES($1,$2,$3,$4,$5,NULLIF($6,'')::uuid,$7,$8,$9,$10,$11,$12)`, project, workspace, account, request.Key, request.Board, request.Run, request.Action, request.Operation, request.Hash, outcome, status, raw) + if err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } + reply(w, status, body) + return nil +} +func rejectCanvasAIRequest(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, workspace, account string, request *canvasAIRequest, status int, code string) error { + if request == nil { + reject(w, status, code) + return nil + } + if _, err := tx.ExecContext(r.Context(), `ROLLBACK TO SAVEPOINT canvas_ai_effects`); err != nil { + return err + } + return commitCanvasAIReceipt(w, r, tx, project, workspace, account, request, status, "not-applied", response{"status": "not-applied", "terminal": true, "error": code}) +} diff --git a/internal/platform/canvas_ai_runs.go b/internal/platform/canvas_ai_runs.go index 49c5453..1f3bdf1 100644 --- a/internal/platform/canvas_ai_runs.go +++ b/internal/platform/canvas_ai_runs.go @@ -47,11 +47,16 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return nil } var input struct { - NodeID string `json:"node_id"` - ModelID string `json:"model_id"` - Prompt string `json:"prompt"` - Parameters map[string]any `json:"parameters"` - Propagate bool `json:"propagate"` + ProtocolVersion int `json:"protocol_version,omitempty"` + OperationID string `json:"operation_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + TargetIncarnation int64 `json:"target_incarnation,omitempty"` + ExpectedVersions map[string]int64 `json:"expected_versions,omitempty"` + NodeID string `json:"node_id"` + ModelID string `json:"model_id"` + Prompt string `json:"prompt"` + Parameters map[string]any `json:"parameters"` + Propagate bool `json:"propagate"` } decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() @@ -59,6 +64,10 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj reject(w, 400, "invalid_ai_request") return nil } + if input.OperationID != "" && !uuidSlugPattern.MatchString(input.OperationID) { + reject(w, 400, "invalid_ai_request") + return nil + } if input.Parameters == nil { input.Parameters = map[string]any{} } @@ -68,6 +77,10 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return nil } hash := payloadDigest(input) + if input.ProtocolVersion != 0 { + // The version header is part of the immutable canonical admission request. + hash = payloadDigest(response{"input": input, "if_match": expected}) + } workspace, board := r.PathValue("workspace"), r.PathValue("material") tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { @@ -78,13 +91,21 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj if err != nil { return err } + var receipt *canvasAIRequest + if input.ProtocolVersion == 2 && input.RequestID == key && uuidSlugPattern.MatchString(input.OperationID) && input.TargetIncarnation > 0 { + receipt = &canvasAIRequest{Key: key, Hash: hash, Action: "run.start", Board: board, Operation: input.OperationID} + if done, e := beginCanvasAIRequest(w, r, tx, project, workspace, account, receipt); done || e != nil { + return e + } + } + rejectRequest := func(status int, code string) error { + return rejectCanvasAIRequest(w, r, tx, project, workspace, account, receipt, status, code) + } if kind != "board" { - reject(w, 400, "invalid_material") - return nil + return rejectRequest(400, "invalid_material") } if role == "viewer" { - reject(w, 403, "forbidden") - return nil + return rejectRequest(403, "forbidden") } var previousHash string var previous json.RawMessage @@ -105,13 +126,22 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj if err != sql.ErrNoRows { return err } + if input.OperationID != "" { + var admitted bool + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_ai_runs WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND logical_operation_id=$4)`, project, workspace, account, input.OperationID).Scan(&admitted); err != nil { + return err + } + if admitted { + reject(w, 409, "operation_identity_conflict") + return nil + } + } parent, err := loadCanvas(r, tx, project, workspace, board) if err != nil { return err } if parent.Version != expected { - reject(w, 412, "version_conflict") - return nil + return rejectRequest(412, "version_conflict") } var doc map[string]any _ = json.Unmarshal(parent.Document, &doc) @@ -123,42 +153,59 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj } } if target == nil { - reject(w, 404, "not_found") - return nil + return rejectRequest(404, "not_found") } if !canvasChoice(target["kind"], "text", "script", "plain-text", "image", "plain-image") { - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } - target["ai"] = map[string]any{"enabled": true, "modelId": input.ModelID, "prompt": input.Prompt, "parameters": input.Parameters, "propagate": input.Propagate} - doc["schema_version"] = 2 - encoded, _ := json.Marshal(doc) - normalized, ok := normalizeCanvas(encoded) - if !ok { - reject(w, 400, "invalid_ai_request") - return nil + root, incarnation, _, err := canvasAIIdentity(r.Context(), tx, project, workspace, board, input.NodeID) + if err != nil { + return err + } + if root != "" { + if input.ProtocolVersion != 2 || input.RequestID != key || !uuidSlugPattern.MatchString(input.OperationID) || input.TargetIncarnation < 1 { + return rejectRequest(400, "invalid_ai_protocol") + } + if input.TargetIncarnation != incarnation { + return rejectRequest(409, "target_changed") + } + } else if input.ProtocolVersion != 0 || input.RequestID != "" || input.TargetIncarnation != 0 || input.OperationID != "" || input.ExpectedVersions != nil { + return rejectRequest(409, "canonical_migration_required") + } + change, err := prepareCanvasAIPatch(r, tx, project, workspace, parent, input.NodeID, response{"ai": map[string]any{"enabled": true, "modelId": input.ModelID, "prompt": input.Prompt, "parameters": input.Parameters, "propagate": input.Propagate}}) + if err != nil { + return rejectRequest(400, "invalid_ai_request") + } + if change.Before != nil { + if code := validateCanvasAIVersions(*change.Before, board, expected, input.ExpectedVersions); code != "" { + status := http.StatusBadRequest + if code == "version_conflict" { + status = http.StatusPreconditionFailed + } + return rejectRequest(status, code) + } } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() models, err := s.canvasAI.Models(ctx) if err != nil { - reject(w, 503, "codex_unavailable") - return nil + return rejectRequest(503, "codex_unavailable") } catalogue := map[string]codex.CanvasModel{} for _, model := range models { catalogue[model.ID] = model } - parent.Document = normalized - parent.Version++ + parent = change.Snapshot plan, err := loadCanvasAIPlan(r, tx, project, workspace, account, parent, input.NodeID, input.Propagate) if err != nil { if err.Error() == "execution_cycle" { - reject(w, 409, "execution_cycle") + return rejectRequest(409, "execution_cycle") } else { - reject(w, 413, "graph_limit_exceeded") + return rejectRequest(413, "graph_limit_exceeded") } - return nil + } + if err = bindCanvasAIIdentities(r.Context(), tx, project, workspace, &plan); err != nil { + return err } stepKinds := map[string]string{} for _, step := range plan.Steps { @@ -173,16 +220,14 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj } model, exists := catalogue[step.ModelID] if !exists || !model.Available { - reject(w, 422, "model_unavailable") - return nil + return rejectRequest(422, "model_unavailable") } modality := "text" if canvasChoice(step.Kind, "image", "plain-image") { modality = "image" } if !canvasModelSupports(model, modality) { - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } imageSources := map[string]bool{} if canvasChoice(step.Kind, "image", "plain-image") { @@ -199,12 +244,10 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj } } if len(imageSources) > 0 && !canvasModelAccepts(model, "image") { - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } if len(imageSources) > 4 { - reject(w, 413, "context_too_large") - return nil + return rejectRequest(413, "context_too_large") } plan.Steps[index].MaxExecutionMS = model.MaxExecutionMS if canvasChoice(step.Kind, "image", "plain-image") { @@ -215,8 +258,7 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj frozenImages[step.ID] = content } if e != nil { - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } plan.Steps[index].FrozenContent = content } @@ -233,35 +275,34 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj frozenImages[key] = content } if e != nil { - reject(w, 422, "model_incompatible") - return nil + return rejectRequest(422, "model_incompatible") } plan.Steps[index].StaticInputs[staticIndex].Content = content } if _, e := assembleCanvasTask(plan, plan.Steps[index], nil); e != nil { - reject(w, 413, "context_too_large") - return nil + return rejectRequest(413, "context_too_large") } parameters, _ := step.Config["parameters"].(map[string]any) if len(parameters) > 0 { - reject(w, 400, "invalid_ai_request") - return nil + return rejectRequest(400, "invalid_ai_request") } prompt, _ := step.Config["prompt"].(string) text, _ := step.FrozenContent["text"].(string) if len(prompt)+len(text) > model.MaxInputBytes { - reject(w, 413, "context_too_large") - return nil + return rejectRequest(413, "context_too_large") } var busy bool - 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 node_id=$4 AND status IN('queued','running'))`, project, workspace, step.BoardID, step.NodeID).Scan(&busy) + if step.CanonicalRoot != "" { + err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND frozen_input->>'canonical_root'=$3 AND node_id=$4 AND frozen_input->>'target_incarnation'=$5::bigint::text AND status IN('queued','running'))`, project, workspace, step.CanonicalRoot, step.NodeID, step.TargetIncarnation).Scan(&busy) + } else { + 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 node_id=$4 AND status IN('queued','running'))`, project, workspace, step.BoardID, step.NodeID).Scan(&busy) + } if err != nil { return err } if busy { - reject(w, 409, "node_busy") - return nil + return rejectRequest(409, "node_busy") } } // A project-scoped advisory lock makes the queue limit atomic across workspaces. @@ -276,8 +317,7 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj } if queued >= 128 { w.Header().Set("Retry-After", "5") - reject(w, 429, "queue_full") - return nil + return rejectRequest(429, "queue_full") } var id string err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&id) @@ -287,12 +327,17 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj planJSON, _ := json.Marshal(plan) poll := "/projects/" + url.PathEscape(project) + "/workspaces/" + workspace + "/materials/" + board + "/canvas/ai/runs/" + id result := response{"run_id": id, "status": "queued", "poll_url": poll, "plan": publicAIPlan(plan), "canvas_version": parent.Version} + if root != "" { + result["protocol_version"], result["request_id"], result["admission"], result["operation_id"] = 2, key, "accepted", input.OperationID + change.addCanonical(result) + } + bindCanvasAIReceipt(result, receipt) resultJSON, _ := json.Marshal(result) - _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_runs(project,workspace_id,board_id,id,account_id,idempotency_key,payload_hash,status,plan,create_response) VALUES($1,$2,$3,$4,$5,$6,$7,'queued',$8,$9)`, project, workspace, board, id, account, key, hash, planJSON, resultJSON) + _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_runs(project,workspace_id,board_id,id,account_id,idempotency_key,payload_hash,status,plan,create_response,logical_operation_id) VALUES($1,$2,$3,$4,$5,$6,$7,'queued',$8,$9,NULLIF($10,'')::uuid)`, project, workspace, board, id, account, key, hash, planJSON, resultJSON, input.OperationID) if err != nil { return err } - if err = s.saveCanvas(r, tx, project, workspace, parent); err != nil { + if err = s.saveCanvasAIChange(r, tx, project, workspace, account, change); err != nil { return err } if err = appendAIEvent(r.Context(), tx, project, workspace, id, "run.queued", response{"status": "queued"}); err != nil { @@ -308,10 +353,6 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return err } } - if err = tx.Commit(); err != nil { - return err - } setVersion(w, parent.Version) - reply(w, 202, result) - return nil + return commitCanvasAIReceipt(w, r, tx, project, workspace, account, receipt, 202, "applied", result) } diff --git a/internal/platform/canvas_capabilities.go b/internal/platform/canvas_capabilities.go index 4b17366..305b091 100644 --- a/internal/platform/canvas_capabilities.go +++ b/internal/platform/canvas_capabilities.go @@ -39,17 +39,18 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro } } } - ai := response{"available": textAvailable || imageAvailable, "text": textAvailable, "image": imageAvailable} + ai := response{"canonical_target_identity": true, "owner_relocation": true, "admission_protocol_version": 2, "admission_receipt_query": true, "mutation_protocol_version": 2, "terminal_rejection_receipts": true, "available": textAvailable || imageAvailable, "text": textAvailable, "image": imageAvailable} if modelErr != nil { ai["unavailable_reason"] = "codex_unavailable" } reply(w, 200, response{ - "contract_version": "canvas-v2-2026-11-06", + "contract_version": "canvas-headless-2026-11-07", + "canonical_model": response{"document_format_version": canvasModelFormat, "protocol_version": canvasModelProtocol, "explicit_migration": true, "stable_element_ids": true, "atomic_multi_canvas": true, "logical_operation_dedup": true, "legacy_snapshot_writes": false}, "supported_schema_versions": []int{1, 2}, "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/canvas_headless_client_integration_test.go b/internal/platform/canvas_headless_client_integration_test.go new file mode 100644 index 0000000..de91788 --- /dev/null +++ b/internal/platform/canvas_headless_client_integration_test.go @@ -0,0 +1,83 @@ +package platform + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Runs the real Endoleap runtime against authenticated Foundry HTTP and PostgreSQL. +// This opt-in is enabled by the existing remote schema runner, never local builds. +func TestHeadlessClientHTTPIntegration(t *testing.T) { + source, dsn := os.Getenv("FOUNDRY_HEADLESS_SOURCE"), os.Getenv("PLATFORM_TEST_DATABASE_URL") + if source == "" { + t.Skip("requires remote headless checkout") + } + if dsn == "" { + t.Fatal("headless integration requires isolated PostgreSQL") + } + 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 := "headless-" + hex.EncodeToString(entropy[:]) + defer func() { + for _, table := range []string{"canvas_ai_runs", "canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_assets", "canvas_subgraphs", "workspaces", "project_accounts"} { + _, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project) + } + }() + var account, workspace, root 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 tokenBytes [32]byte + _, _ = rand.Read(tokenBytes[:]) + token := base64.RawURLEncoding.EncodeToString(tokenBytes[:]) + 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) + } + if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Headless integration',$2) RETURNING id::text`, project, account).Scan(&workspace); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin')`, project, workspace, account); err != nil { + t.Fatal(err) + } + legacy := json.RawMessage(`{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"preserved","frameId":"f"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`) + if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Root',$3,$4) RETURNING id::text`, project, workspace, account, legacy).Scan(&root); err != nil { + t.Fatal(err) + } + mux := headlessWorkerRoutes(t, db, map[string]string{project: "https://app.example.com/auth/callback"}, project, account) + + server := httptest.NewServer(mux) + defer server.Close() + commandContext, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + command := exec.CommandContext(commandContext, "node", filepath.Join(source, "tests/headless/foundry.integration.mjs")) + command.Env = append(os.Environ(), "FOUNDRY_HTTP_URL="+server.URL, "FOUNDRY_TEST_PROJECT="+project, "FOUNDRY_TEST_ACCOUNT="+account, "FOUNDRY_TEST_WORKSPACE="+workspace, "FOUNDRY_TEST_ROOT="+root, "FOUNDRY_TEST_TOKEN="+token, "FOUNDRY_TEST_AI_WORKER=1") + output, err := command.CombinedOutput() + // The fixture token is ephemeral, but never print it even on client failures. + report := strings.ReplaceAll(string(output), token, "[fixture token redacted]") + if err != nil { + t.Fatalf("headless HTTP client failed: %v\n%s", err, report) + } + if !strings.Contains(report, `"status":"ok"`) || !strings.Contains(report, `"runtimeAI":true`) { + t.Fatalf("headless HTTP client did not return success: %s", report) + } + t.Log(report) +} diff --git a/internal/platform/canvas_headless_worker_integration_test.go b/internal/platform/canvas_headless_worker_integration_test.go new file mode 100644 index 0000000..6fe034b --- /dev/null +++ b/internal/platform/canvas_headless_worker_integration_test.go @@ -0,0 +1,155 @@ +package platform + +import ( + "bytes" + "database/sql" + "encoding/json" + "image" + "image/color" + "image/png" + "net" + "net/http" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" +) + +// Only the generation transport is deterministic. Admission, claims, events, +// canonical writes and action receipts use the real service and isolated DB. +func headlessWorkerRoutes(t *testing.T, db *sql.DB, callbacks map[string]string, project, account string) http.Handler { + t.Helper() + type gate struct { + entered, release chan struct{} + started, released sync.Once + } + var mu sync.Mutex + var held *gate + var executions atomic.Int32 + var picture bytes.Buffer + pixels := image.NewRGBA(image.Rect(0, 0, 3, 2)) + pixels.Set(0, 0, color.RGBA{B: 255, A: 255}) + if err := png.Encode(&picture, pixels); err != nil { + t.Fatal(err) + } + socket := filepath.Join(t.TempDir(), "worker.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + worker := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/canvas/models" { + reply(w, 200, response{"models": []any{ + codex.CanvasModel{ID: "fixture-text", Label: "Fixture text", Available: true, InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}, MaxInputBytes: 1 << 20, MaxExecutionMS: 90000}, + codex.CanvasModel{ID: "fixture-image", Label: "Fixture image", Available: true, InputModalities: []string{"text", "image"}, OutputModalities: []string{"image"}, MaxInputBytes: 1 << 20, MaxExecutionMS: 90000}, + }}) + return + } + if r.URL.Path != "/canvas/exec" { + reject(w, 404, "not_found") + return + } + var task codex.CanvasTask + if json.NewDecoder(r.Body).Decode(&task) != nil { + reject(w, 400, "invalid_request") + return + } + executions.Add(1) + mu.Lock() + current := held + mu.Unlock() + if current != nil { + current.started.Do(func() { close(current.entered) }) + select { + case <-current.release: + case <-r.Context().Done(): + return + } + mu.Lock() + if held == current { + held = nil + } + mu.Unlock() + } + if task.OutputModality == "image" { + reply(w, 200, response{"output": codex.CanvasOutput{Kind: "image", Image: &codex.CanvasImage{MIME: "image/png", Data: picture.Bytes()}}}) + } else { + reply(w, 200, response{"result": "headless fixture output"}) + } + })} + go worker.Serve(listener) + t.Cleanup(func() { + mu.Lock() + if held != nil { + held.released.Do(func() { close(held.release) }) + } + mu.Unlock() + worker.Close() + }) + s := &service{db: db, origin: "https://api.example.com", canvasAI: codex.NewCanvasClient(socket)} + mux := http.NewServeMux() + prefix := "/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai" + mux.HandleFunc("GET "+prefix+"/models", s.protected(callbacks, s.canvasAIModels)) + mux.HandleFunc("POST "+prefix+"/runs", s.protected(callbacks, s.createCanvasAIRun)) + mux.HandleFunc("GET "+prefix+"/runs/{run}", s.protected(callbacks, s.getCanvasAIRun)) + mux.HandleFunc("GET "+prefix+"/operations/{request}", s.protected(callbacks, s.getCanvasAIAdmission)) + mux.HandleFunc("POST "+prefix+"/runs/{run}/cancel", s.protected(callbacks, s.cancelCanvasAIRun)) + mux.HandleFunc("POST "+prefix+"/runs/{run}/apply", s.protected(callbacks, s.applyCanvasAIOutput)) + // Private test controls are not registered by production Register(). + mux.HandleFunc("POST "+prefix+"/fixture/{action}", s.protected(callbacks, func(w http.ResponseWriter, r *http.Request, p, a string) error { + if p != project || a != account { + reject(w, 403, "forbidden") + return nil + } + switch r.PathValue("action") { + case "arm": + mu.Lock() + held = &gate{entered: make(chan struct{}), release: make(chan struct{})} + mu.Unlock() + case "wait": + mu.Lock() + current := held + mu.Unlock() + if current == nil { + reject(w, 409, "worker_not_armed") + return nil + } + select { + case <-current.entered: + case <-r.Context().Done(): + return nil + } + case "release": + mu.Lock() + if held != nil { + held.released.Do(func() { close(held.release) }) + } + mu.Unlock() + case "execute": + run, err := s.claimCanvasRun(r.Context()) + if err != nil || run == nil || run.Project != project || run.Account != account || run.Workspace != r.PathValue("workspace") { + reject(w, 409, "fixture_run_unavailable") + return nil + } + s.executeCanvasRun(r.Context(), *run) + case "stats": + default: + reject(w, 404, "not_found") + return nil + } + reply(w, 200, response{"executions": executions.Load()}) + return nil + })) + regular := http.NewServeMux() + Register(regular, db, callbacks, "https://api.example.com", nil) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/canvas/ai/") { + mux.ServeHTTP(w, r) + } else { + regular.ServeHTTP(w, r) + } + }) +} diff --git a/internal/platform/canvas_model.go b/internal/platform/canvas_model.go new file mode 100644 index 0000000..abd4195 --- /dev/null +++ b/internal/platform/canvas_model.go @@ -0,0 +1,450 @@ +package platform + +import ( + "encoding/json" + "errors" + "math" + "reflect" + "sort" + "strings" +) + +const canvasModelFormat = 1 +const canvasModelProtocol = 2 + +var errCanvasModel = errors.New("invalid_canvas_model") +var errModelConflict = errors.New("canvas_model_field_conflict") + +type modelParent struct { + CanvasID string `json:"canvasId"` + EntryElementID string `json:"entryElementId"` +} +type modelCanvas struct { + ID string `json:"id"` + Parent *modelParent `json:"parent"` +} +type canvasModel struct { + Root string + Canvases map[string]modelCanvas + Elements map[string]map[string]any + Tombstones map[string]int64 + TombstoneOwners map[string]string + DeadRecords map[string]map[string]any + Versions map[string]int64 +} +type modelView struct { + Format int `json:"documentFormatVersion"` + ProjectID string `json:"projectId"` + Canvases []modelCanvas `json:"canvases"` + Elements []map[string]any `json:"elements"` + Tombstones map[string]int64 `json:"tombstones"` + Versions map[string]int64 `json:"versions"` + Complete bool `json:"complete"` + Removed []string `json:"removedCanvasIds"` +} +type modelTarget struct { + ID string `json:"id"` + Incarnation int64 `json:"incarnation"` +} +type modelChange struct { + Kind string `json:"kind"` + ID string `json:"id,omitempty"` + Target modelTarget `json:"target,omitempty"` + Field string `json:"field,omitempty"` + Before json.RawMessage `json:"before"` + After json.RawMessage `json:"after"` + BeforePresent *bool `json:"beforePresent,omitempty"` + AfterPresent *bool `json:"afterPresent,omitempty"` +} +type modelOperation struct { + ID string `json:"id"` + Changes []modelChange `json:"changes"` +} +type modelBatch struct { + ProtocolVersion int `json:"protocol_version"` + RequestID string `json:"request_id"` + Expected map[string]int64 `json:"expected_versions"` + Operations []modelOperation `json:"operations"` +} + +func emptyModel(root string) canvasModel { + return canvasModel{Root: root, Canvases: map[string]modelCanvas{}, Elements: map[string]map[string]any{}, Tombstones: map[string]int64{}, TombstoneOwners: map[string]string{}, DeadRecords: map[string]map[string]any{}, Versions: map[string]int64{}} +} +func modelString(value any) string { text, _ := value.(string); return text } +func modelInt(value any) int64 { + number, ok := value.(float64) + if !ok || number < 1 || number > 9007199254740991 || math.Trunc(number) != number { + return 0 + } + return int64(number) +} +func modelJSONSafe(value any, depth int) bool { + if depth > 64 { + return false + } + switch value := value.(type) { + case nil, bool, string: + return true + case float64: + return !math.IsNaN(value) && !math.IsInf(value, 0) + case []any: + for _, item := range value { + if !modelJSONSafe(item, depth+1) { + return false + } + } + return true + case map[string]any: + for _, item := range value { + if !modelJSONSafe(item, depth+1) { + return false + } + } + return true + } + return false +} +func modelEqual(left, right any) bool { + // Round-trip numeric Go structs and JSON records to the same JSON value domain. + a, ea := json.Marshal(left) + b, eb := json.Marshal(right) + if ea != nil || eb != nil { + return false + } + var av, bv any + return json.Unmarshal(a, &av) == nil && json.Unmarshal(b, &bv) == nil && reflect.DeepEqual(av, bv) +} +func (model canvasModel) view(participants map[string]int64) modelView { + view := modelView{Format: canvasModelFormat, ProjectID: model.Root, Canvases: []modelCanvas{}, Elements: []map[string]any{}, Tombstones: map[string]int64{}, Versions: map[string]int64{}, Complete: participants == nil, Removed: []string{}} + for _, id := range sortedKeys(model.Canvases) { + if participants != nil { + if _, exists := participants[id]; !exists { + continue + } + } + view.Canvases = append(view.Canvases, model.Canvases[id]) + view.Versions[id] = model.Versions[id] + } + for _, id := range sortedKeys(model.Elements) { + element := model.Elements[id] + if participants != nil { + if _, exists := participants[modelString(element["ownerCanvasId"])]; !exists { + continue + } + } + view.Elements = append(view.Elements, element) + } + for id, incarnation := range model.Tombstones { + owner := model.TombstoneOwners[id] + if live := model.Elements[id]; live != nil { + owner = modelString(live["ownerCanvasId"]) + } + if participants != nil { + if _, exists := participants[owner]; !exists { + continue + } + } + view.Tombstones[id] = incarnation + } + for _, id := range sortedKeys(model.Versions) { + if participants != nil { + if _, exists := participants[id]; !exists { + continue + } + } + view.Versions[id] = model.Versions[id] + if _, exists := model.Canvases[id]; !exists { + view.Removed = append(view.Removed, id) + } + } + return view +} +func sortedKeys[T any](values map[string]T) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} +func cloneModel(model canvasModel) canvasModel { + raw, _ := json.Marshal(model) + var copy canvasModel + _ = json.Unmarshal(raw, ©) + return copy +} +func modelAncestors(model canvasModel, id string) ([]string, error) { + path, seen := []string{}, map[string]bool{} + for id != "" { + canvas, exists := model.Canvases[id] + if !exists || seen[id] || len(path) > 8 { + return nil, errCanvasModel + } + seen[id] = true + path = append(path, id) + id = "" + if canvas.Parent != nil { + id = canvas.Parent.CanvasID + } + } + return path, nil +} +func modelCommonCanvas(model canvasModel, left, right string) (string, error) { + a, err := modelAncestors(model, left) + if err != nil { + return "", err + } + b, err := modelAncestors(model, right) + if err != nil { + return "", err + } + seen := map[string]bool{} + for _, id := range a { + seen[id] = true + } + for _, id := range b { + if seen[id] { + return id, nil + } + } + return "", errCanvasModel +} +func validateCanvasModel(model canvasModel) error { + if !uuidSlugPattern.MatchString(model.Root) || len(model.Canvases) == 0 || len(model.Canvases) > 256 || len(model.Elements) > 128000 { + return errCanvasModel + } + roots, entries, counts := 0, map[string]bool{}, map[string][2]int{} + for id, canvas := range model.Canvases { + if id != canvas.ID || !uuidSlugPattern.MatchString(id) { + return errCanvasModel + } + if _, err := modelAncestors(model, id); err != nil { + return err + } + if canvas.Parent == nil { + roots++ + if id != model.Root { + return errCanvasModel + } + continue + } + entry := model.Elements[canvas.Parent.EntryElementID] + if entry == nil || entry["structure"] != "node" || entry["ownerCanvasId"] != canvas.Parent.CanvasID || entries[canvas.Parent.EntryElementID] { + return errCanvasModel + } + entries[canvas.Parent.EntryElementID] = true + } + if roots != 1 { + return errCanvasModel + } + for id, incarnation := range model.Tombstones { + if !canvasID.MatchString(id) || incarnation < 1 || incarnation > 9007199254740991 { + return errCanvasModel + } + } + for id, element := range model.Elements { + if !modelJSONSafe(element, 0) { + return errCanvasModel + } + owner, component := modelString(element["ownerCanvasId"]), modelString(element["componentId"]) + _, canvasExists := model.Canvases[owner] + incarnation, version := modelInt(element["incarnation"]), modelInt(element["dataVersion"]) + data, dataOK := element["data"].(map[string]any) + if !canvasID.MatchString(id) || element["id"] != id || !canvasExists || incarnation == 0 || incarnation <= model.Tombstones[id] || version == 0 || component == "" || len(component) > 128 || !dataOK || data == nil { + return errCanvasModel + } + encoded, err := json.Marshal(element) + if err != nil || len(encoded) > 1<<20 { + return errCanvasModel + } + count := counts[owner] + switch element["structure"] { + case "node": + count[0]++ + geometry, ok := element["geometry"].(map[string]any) + if !ok || !canvasNumber(geometry["x"], -1e6, 1e6) || !canvasNumber(geometry["y"], -1e6, 1e6) || !canvasNumber(geometry["width"], 0.000001, 1e6) || !canvasNumber(geometry["height"], 0.000001, 1e6) || !canvasNumber(geometry["rotation"], 0, math.Nextafter(360, 0)) { + return errCanvasModel + } + if version == 1 && canvasChoice(component, "text", "image", "script", "plain-text", "plain-image", "icon", "frame") { + // Canonical frames can inherit rotation while changing owners. The + // legacy snapshot writer's zero-angle restriction does not apply here. + if !canvasString(data["title"], 300) || !canvasString(data["text"], 100000) { + return errCanvasModel + } + if ai, exists := data["ai"]; exists && !validCanvasAI(ai, component) { + return errCanvasModel + } + if asset, exists := data["assetId"]; exists && (!canvasChoice(component, "image", "plain-image") || !uuidSlugPattern.MatchString(modelString(asset))) { + return errCanvasModel + } + } + case "connection": + count[1]++ + source, sourceOK := element["source"].(map[string]any) + target, targetOK := element["target"].(map[string]any) + if !sourceOK || !targetOK { + return errCanvasModel + } + a, b := model.Elements[modelString(source["elementId"])], model.Elements[modelString(target["elementId"])] + if a == nil || b == nil || a["structure"] != "node" || b["structure"] != "node" { + return errCanvasModel + } + for _, endpoint := range []map[string]any{source, target} { + if port, exists := endpoint["portId"]; exists && (!canvasString(port, 256) || modelString(port) == "") { + return errCanvasModel + } + } + common, err := modelCommonCanvas(model, modelString(a["ownerCanvasId"]), modelString(b["ownerCanvasId"])) + if err != nil || common != owner { + return errCanvasModel + } + default: + return errCanvasModel + } + if count[0] > 500 || count[1] > 2000 { + return errGraphCapacity + } + counts[owner] = count + } + return nil +} + +// Pure transaction reducer. Unknown component data/envelope fields are retained. +func applyModelOperations(initial canvasModel, operations []modelOperation) (canvasModel, map[string]bool, error) { + model, touched, seen := cloneModel(initial), map[string]bool{}, map[string]bool{} + if len(operations) == 0 || len(operations) > 2500 { + return model, nil, errCanvasModel + } + changeCount := 0 + for _, operation := range operations { + if !uuidSlugPattern.MatchString(operation.ID) || seen[operation.ID] || len(operation.Changes) == 0 { + return model, nil, errCanvasModel + } + seen[operation.ID] = true + for _, change := range operation.Changes { + changeCount++ + if changeCount > 10000 { + return model, nil, errGraphCapacity + } + var before, after any + if json.Unmarshal(change.Before, &before) != nil || json.Unmarshal(change.After, &after) != nil { + return model, nil, errCanvasModel + } + switch change.Kind { + case "field": + element := model.Elements[change.Target.ID] + if element == nil || modelInt(element["incarnation"]) != change.Target.Incarnation { + return model, nil, errModelConflict + } + touched[modelString(element["ownerCanvasId"])] = true + if strings.HasPrefix(change.Field, "data.") { + key := strings.TrimPrefix(change.Field, "data.") + if key == "" || canvasChoice(key, "__proto__", "constructor", "prototype") { + return model, nil, errCanvasModel + } + data := element["data"].(map[string]any) + prior, present := data[key] + if present != (change.BeforePresent == nil || *change.BeforePresent) || !modelEqual(prior, before) { + return model, nil, errModelConflict + } + if change.AfterPresent != nil && !*change.AfterPresent { + delete(data, key) + } else { + data[key] = after + } + } else { + if !canvasChoice(change.Field, "geometry", "ownerCanvasId", "source", "target", "path") { + return model, nil, errCanvasModel + } + if !modelEqual(element[change.Field], before) { + return model, nil, errModelConflict + } + element[change.Field] = after + if change.Field == "ownerCanvasId" { + touched[modelString(after)] = true + } + } + case "element": + if !canvasID.MatchString(change.ID) || !modelEqual(model.Elements[change.ID], before) { + return model, nil, errModelConflict + } + if prior := model.Elements[change.ID]; prior != nil { + touched[modelString(prior["ownerCanvasId"])] = true + } + if after == nil { + if prior := model.Elements[change.ID]; prior != nil { + model.Tombstones[change.ID] = modelInt(prior["incarnation"]) + model.TombstoneOwners[change.ID] = modelString(prior["ownerCanvasId"]) + model.DeadRecords[change.ID] = prior + } + delete(model.Elements, change.ID) + } else { + record, ok := after.(map[string]any) + if !ok || record["id"] != change.ID { + return model, nil, errCanvasModel + } + if before == nil && modelInt(record["incarnation"]) <= model.Tombstones[change.ID] { + return model, nil, errModelConflict + } + if prior := model.Elements[change.ID]; prior != nil && modelInt(record["incarnation"]) != modelInt(prior["incarnation"]) { + return model, nil, errModelConflict + } + model.Elements[change.ID] = record + touched[modelString(record["ownerCanvasId"])] = true + } + case "canvas": + prior, exists := model.Canvases[change.ID] + var priorValue any + if exists { + priorValue = prior + } + if !uuidSlugPattern.MatchString(change.ID) || !modelEqual(priorValue, before) { + return model, nil, errModelConflict + } + touched[change.ID] = true + if prior.Parent != nil { + touched[prior.Parent.CanvasID] = true + } + if after == nil { + delete(model.Canvases, change.ID) + } else { + var canvas modelCanvas + if json.Unmarshal(change.After, &canvas) != nil || canvas.ID != change.ID { + return model, nil, errCanvasModel + } + model.Canvases[change.ID] = canvas + if canvas.Parent != nil { + touched[canvas.Parent.CanvasID] = true + } + } + default: + return model, nil, errCanvasModel + } + } + } + if err := validateCanvasModel(model); err != nil { + return model, nil, err + } + // Endpoint owners are read participants, guarding against delete/restore races in child canvases. + for _, operation := range operations { + for _, change := range operation.Changes { + if change.Kind == "element" && string(change.Before) == "null" || change.Kind == "field" && (change.Field == "source" || change.Field == "target") { + id := change.ID + if change.Kind == "field" { + id = change.Target.ID + } + element := model.Elements[id] + if element != nil && element["structure"] == "connection" { + for _, field := range []string{"source", "target"} { + endpoint := element[field].(map[string]any) + touched[modelString(model.Elements[modelString(endpoint["elementId"])]["ownerCanvasId"])] = true + } + } + } + } + } + for id := range touched { + model.Versions[id] = initial.Versions[id] + 1 + } + return model, touched, nil +} diff --git a/internal/platform/canvas_model_atomic_integration_test.go b/internal/platform/canvas_model_atomic_integration_test.go new file mode 100644 index 0000000..4b42fd6 --- /dev/null +++ b/internal/platform/canvas_model_atomic_integration_test.go @@ -0,0 +1,389 @@ +package platform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" +) + +type canonicalAtomicFixture struct { + db *sql.DB + mux *http.ServeMux + project, account, workspace, root, token string + legacy json.RawMessage +} + +func newCanonicalAtomicFixture(t *testing.T) *canonicalAtomicFixture { + t.Helper() + 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) + } + t.Cleanup(func() { db.Close() }) + var entropy [12]byte + _, _ = rand.Read(entropy[:]) + f := &canonicalAtomicFixture{db: db, project: "canonical-atomic-" + hex.EncodeToString(entropy[:])} + ctx := context.Background() + t.Cleanup(func() { + for _, table := range []string{"canvas_ai_runs", "canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_subgraphs", "workspaces", "project_accounts"} { + _, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", f.project) + } + }) + if err = db.QueryRowContext(ctx, `INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text`, f.project).Scan(&f.account); err != nil { + t.Fatal(err) + } + var tokenBytes [32]byte + _, _ = rand.Read(tokenBytes[:]) + f.token = base64.RawURLEncoding.EncodeToString(tokenBytes[:]) + hash := sha256.Sum256([]byte(f.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[:]), f.project, f.account); err != nil { + t.Fatal(err) + } + if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Canonical atomic',$2) RETURNING id::text`, f.project, f.account).Scan(&f.workspace); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin')`, f.project, f.workspace, f.account); err != nil { + t.Fatal(err) + } + f.legacy = json.RawMessage(`{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"material","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"text","title":"a","text":"preserved","frameId":"f"}},{"id":"b","type":"material","position":{"x":180,"y":300},"width":240,"height":72,"data":{"kind":"text","title":"b","text":"preserved","frameId":"f"}},{"id":"c","type":"material","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"text","title":"c","text":"unrelated"}}],"edges":[{"id":"e","source":"a","target":"b"}],"viewport":{"x":0,"y":0,"zoom":1}}`) + if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Root',$3,$4) RETURNING id::text`, f.project, f.workspace, f.account, f.legacy).Scan(&f.root); err != nil { + t.Fatal(err) + } + f.mux = http.NewServeMux() + Register(f.mux, db, map[string]string{f.project: "https://app.example.com/auth/callback"}, "https://api.example.com", nil) + return f +} + +func (f *canonicalAtomicFixture) path() string { + return "/projects/" + f.project + "/workspaces/" + f.workspace + "/materials/" + f.root + "/canvas/model" +} + +func (f *canonicalAtomicFixture) request(ctx context.Context, method, path, key string, raw []byte) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, bytes.NewReader(raw)).WithContext(ctx) + r.Header.Set("Authorization", "Bearer "+f.token) + r.Header.Set("X-Expected-Account", f.account) + r.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2") + if key != "" { + r.Header.Set("Idempotency-Key", key) + } + w := httptest.NewRecorder() + f.mux.ServeHTTP(w, r) + return w +} + +func canonicalAtomicResponse(t *testing.T, w *httptest.ResponseRecorder, status int) response { + t.Helper() + if w.Code != status { + t.Fatalf("canonical response got %d %s want %d", w.Code, w.Body.String(), status) + } + var body response + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body +} + +func (f *canonicalAtomicFixture) read(t *testing.T) canvasModel { + t.Helper() + tx, err := f.db.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + model, err := loadCanvasModel(context.Background(), tx, f.project, f.workspace, f.root) + if err != nil { + t.Fatal(err) + } + return model +} + +func (f *canonicalAtomicFixture) concurrent(t *testing.T, path string, keys []string, bodies [][]byte) []*httptest.ResponseRecorder { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + type result struct { + index int + writer *httptest.ResponseRecorder + } + start, done := make(chan struct{}), make(chan result, len(keys)) + for i := range keys { + go func(index int) { + <-start + done <- result{index, f.request(ctx, "POST", path, keys[index], bodies[index])} + }(i) + } + close(start) + out := make([]*httptest.ResponseRecorder, len(keys)) + for range keys { + select { + case value := <-done: + out[value.index] = value.writer + case <-ctx.Done(): + t.Fatal("canonical concurrent requests did not finish") + } + } + return out +} + +func TestCanonicalConcurrentMigrationAndOwnershipWrites(t *testing.T) { + f := newCanonicalAtomicFixture(t) + ctx := context.Background() + key := modelTestID(8000) + input := response{"protocol_version": 2, "request_id": key, "expected_versions": map[string]int64{f.root: 1}} + raw := modelJSON(input) + replies := f.concurrent(t, f.path()+"/migrate", []string{key, key, key, key}, [][]byte{raw, raw, raw, raw}) + first := canonicalAtomicResponse(t, replies[0], 200) + for _, reply := range replies[1:] { + if !modelEqual(first, canonicalAtomicResponse(t, reply, 200)) { + t.Fatal("concurrent migration allocated different identities or receipts") + } + } + ids := map[string]string{} + for _, value := range first["identity_map"].([]any) { + mapping := value.(map[string]any) + ids[mapping["sourceId"].(string)] = mapping["elementId"].(string) + } + before := f.read(t) + child := modelString(before.Elements[ids["a"]]["ownerCanvasId"]) + if len(before.Canvases) != 2 || len(ids) != 5 || child == f.root || before.Versions[f.root] != 2 || before.Versions[child] != 1 { + t.Fatal("concurrent migration duplicated the implicit partition or version barrier") + } + key = modelTestID(8001) + edit := modelBatch{ProtocolVersion: 2, RequestID: key, Expected: map[string]int64{child: 1}, Operations: []modelOperation{{ID: modelTestID(8002), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: ids["a"], Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("renamed")}}}}} + raw = modelJSON(edit) + replies = f.concurrent(t, f.path()+"/operations", []string{key, key, key, key}, [][]byte{raw, raw, raw, raw}) + first = canonicalAtomicResponse(t, replies[0], 200) + for _, reply := range replies[1:] { + if !modelEqual(first, canonicalAtomicResponse(t, reply, 200)) { + t.Fatal("concurrent edit retry changed its original receipt") + } + } + before = f.read(t) + if before.Versions[child] != 2 || before.Versions[f.root] != 2 || modelString(before.Elements[ids["a"]]["data"].(map[string]any)["title"]) != "renamed" { + t.Fatal("concurrent edit executed more than once") + } + var batches []modelBatch + var keys []string + var bodies [][]byte + for index, node := range []string{ids["a"], ids["b"]} { + key := modelTestID(8100 + index) + batch := modelBatch{ProtocolVersion: 2, RequestID: key, Expected: map[string]int64{f.root: 2, child: 2}, Operations: []modelOperation{{ID: modelTestID(8200 + index), Changes: []modelChange{ + {Kind: "field", Target: modelTarget{ID: node, Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(child), After: modelJSON(f.root)}, + {Kind: "field", Target: modelTarget{ID: ids["e"], Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(child), After: modelJSON(f.root)}, + }}}} + batches, keys, bodies = append(batches, batch), append(keys, key), append(bodies, modelJSON(batch)) + } + replies = f.concurrent(t, f.path()+"/operations", keys, bodies) + winner, successes := -1, 0 + for index, reply := range replies { + status := 412 + if reply.Code == 200 { + winner, successes, status = index, successes+1, 200 + } + body := canonicalAtomicResponse(t, reply, status) + queried := canonicalAtomicResponse(t, f.request(ctx, "GET", f.path()+"/operations/"+keys[index], "", nil), 200) + if !modelEqual(body, queried) || status == 412 && (body["status"] != "not-applied" || body["terminal"] != true || body["error"] != "version_conflict") { + t.Fatal("a competing write lost its queryable applied or zero-effect receipt") + } + } + if successes != 1 { + t.Fatal("competing conditional ownership writes did not have exactly one winner") + } + after := f.read(t) + if err := validateCanvasModel(after); err != nil { + t.Fatal(err) + } + nodes := []string{ids["a"], ids["b"]} + if after.Versions[f.root] != 3 || after.Versions[child] != 3 || modelString(after.Elements[nodes[winner]]["ownerCanvasId"]) != f.root || modelString(after.Elements[nodes[1-winner]]["ownerCanvasId"]) != child || modelString(after.Elements[ids["e"]]["ownerCanvasId"]) != f.root || !modelEqual(before.Elements[ids["c"]], after.Elements[ids["c"]]) || !modelEqual(before.Canvases, after.Canvases) { + t.Fatal("competing ownership writes published a partial node/edge/tree or advanced versions twice") + } + var count int + if err := f.db.QueryRowContext(ctx, `SELECT count(*) FROM canvas_model_operations WHERE project=$1`, f.project).Scan(&count); err != nil || count != 2 { + t.Fatalf("logical operations: %d %v", count, err) + } + changed := batches[winner] + changed.Operations[0].ID = modelTestID(8999) + body := canonicalAtomicResponse(t, f.request(ctx, "POST", f.path()+"/operations", keys[winner], modelJSON(changed)), 409) + if body["error"] != "idempotency_conflict" || !modelEqual(after, f.read(t)) { + t.Fatal("same key with different bytes changed the canonical model") + } +} + +func TestCanonicalMigrationLateCancellationRollsBack(t *testing.T) { + f := newCanonicalAtomicFixture(t) + ctx, stop := context.WithTimeout(context.Background(), 20*time.Second) + defer stop() + barrier, err := f.db.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + defer barrier.Rollback() + if _, err = barrier.ExecContext(ctx, `LOCK TABLE canvas_model_identity_map IN ACCESS EXCLUSIVE MODE`); err != nil { + t.Fatal(err) + } + var blocker int + if err = barrier.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&blocker); err != nil { + t.Fatal(err) + } + key := modelTestID(8300) + input := response{"protocol_version": 2, "request_id": key, "expected_versions": map[string]int64{f.root: 1}} + requestContext, cancel := context.WithCancel(ctx) + defer cancel() + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- f.request(requestContext, "POST", f.path()+"/migrate", key, modelJSON(input)) }() + // Observe the actual late INSERT blocked behind this transaction. A timer + // alone cannot prove migration reached canonical/material/subgraph writes. + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + observed := false + for !observed { + if err = f.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE usename=current_user AND query LIKE 'INSERT INTO canvas_model_identity_map%' AND $1=ANY(pg_blocking_pids(pid)))`, blocker).Scan(&observed); err != nil { + t.Fatal(err) + } + if observed { + break + } + select { + case reply := <-done: + t.Fatalf("migration did not reach its late write: %d %s", reply.Code, reply.Body.String()) + case <-ctx.Done(): + t.Fatal("migration never reached its late identity-map INSERT") + case <-ticker.C: + } + } + cancel() + select { + case reply := <-done: + canonicalAtomicResponse(t, reply, 503) + case <-ctx.Done(): + t.Fatal("cancelled migration did not finish") + } + if err = barrier.Rollback(); err != nil { + t.Fatal(err) + } + for _, table := range []string{"canvas_model_projects", "canvas_model_canvases", "canvas_model_elements", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_operations", "canvas_subgraphs"} { + var count int + if err = f.db.QueryRowContext(ctx, "SELECT count(*) FROM "+table+" WHERE project=$1", f.project).Scan(&count); err != nil || count != 0 { + t.Fatalf("late cancellation left %s rows: %d %v", table, count, err) + } + } + var count int + var version int64 + var source json.RawMessage + if err = f.db.QueryRowContext(ctx, `SELECT count(*) FROM materials WHERE project=$1`, f.project).Scan(&count); err != nil || count != 1 { + t.Fatalf("synthetic partition material survived rollback: %d %v", count, err) + } + if err = f.db.QueryRowContext(ctx, `SELECT canvas_version,canvas_document FROM materials WHERE project=$1 AND id=$2`, f.project, f.root).Scan(&version, &source); err != nil || version != 1 || !modelEqual(f.legacy, source) { + t.Fatalf("late cancellation changed legacy source/version: %d %v", version, err) + } + // Infrastructure failure saved no result. The same original request can be + // retried safely, and all subsequent retries must return its one identity map. + first := canonicalAtomicResponse(t, f.request(ctx, "POST", f.path()+"/migrate", key, modelJSON(input)), 200) + retry := canonicalAtomicResponse(t, f.request(ctx, "POST", f.path()+"/migrate", key, modelJSON(input)), 200) + if !modelEqual(first, retry) || len(f.read(t).Canvases) != 2 { + t.Fatal("retry after rollback duplicated identities or the implicit partition") + } +} + +func TestCanonicalMigrationPermissionVersionAndRunGuards(t *testing.T) { + f := newCanonicalAtomicFixture(t) + ctx := context.Background() + input := func(key string, version int64) response { + return response{"protocol_version": 2, "request_id": key, "expected_versions": map[string]int64{f.root: version}} + } + call := func(key string, version int64, status int) response { + t.Helper() + return canonicalAtomicResponse(t, f.request(ctx, "POST", f.path()+"/migrate", key, modelJSON(input(key, version))), status) + } + assertLegacy := func(version int64) { + t.Helper() + for _, table := range []string{"canvas_model_projects", "canvas_model_canvases", "canvas_model_elements", "canvas_model_identity_map", "canvas_subgraphs"} { + var count int + if err := f.db.QueryRowContext(ctx, "SELECT count(*) FROM "+table+" WHERE project=$1", f.project).Scan(&count); err != nil || count != 0 { + t.Fatalf("rejected migration left %s rows: %d %v", table, count, err) + } + } + var count int + var current int64 + var source json.RawMessage + if err := f.db.QueryRowContext(ctx, `SELECT count(*) FROM materials WHERE project=$1`, f.project).Scan(&count); err != nil || count != 1 { + t.Fatalf("rejected migration created a material: %d %v", count, err) + } + if err := f.db.QueryRowContext(ctx, `SELECT canvas_version,canvas_document FROM materials WHERE project=$1 AND id=$2`, f.project, f.root).Scan(¤t, &source); err != nil || current != version || !modelEqual(f.legacy, source) { + t.Fatalf("rejected migration changed legacy source/version: %d %v", current, err) + } + } + if _, err := f.db.ExecContext(ctx, `UPDATE workspace_members SET role='viewer' WHERE project=$1 AND workspace_id=$2 AND account_id=$3`, f.project, f.workspace, f.account); err != nil { + t.Fatal(err) + } + if call(modelTestID(8500), 1, 403)["error"] != "forbidden" { + t.Fatal("viewer crossed the identity migration barrier") + } + assertLegacy(1) + if _, err := f.db.ExecContext(ctx, `UPDATE workspace_members SET role='admin' WHERE project=$1 AND workspace_id=$2 AND account_id=$3`, f.project, f.workspace, f.account); err != nil { + t.Fatal(err) + } + staleKey := modelTestID(8501) + rejected := call(staleKey, 2, 412) + if rejected["status"] != "not-applied" || rejected["terminal"] != true || rejected["error"] != "migration_version_conflict" { + t.Fatal("stale source migration did not produce a zero-effect receipt") + } + assertLegacy(1) + // Advance the old writer to the formerly rejected expected version. That + // cannot turn the same frozen negative request into a migration later. + if _, err := f.db.ExecContext(ctx, `UPDATE materials SET canvas_version=2 WHERE project=$1 AND id=$2`, f.project, f.root); err != nil { + t.Fatal(err) + } + if !modelEqual(rejected, call(staleKey, 2, 412)) { + t.Fatal("a source version change reevaluated a rejected migration key") + } + assertLegacy(2) + run := modelTestID(8400) + if _, err := f.db.ExecContext(ctx, `INSERT INTO canvas_ai_runs(project,workspace_id,board_id,id,account_id,idempotency_key,payload_hash,status,plan,create_response) VALUES($1,$2,$3,$4,$5,$6,'fixture','queued','{}','{}')`, f.project, f.workspace, f.root, run, f.account, modelTestID(8401)); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecContext(ctx, `INSERT INTO canvas_ai_steps(project,workspace_id,run_id,id,board_id,node_id,ordinal,status,model_id,frozen_input,target_fingerprint) VALUES($1,$2,$3,'fixture',$4,'a',0,'queued','fixture','{}','fixture')`, f.project, f.workspace, run, f.root); err != nil { + t.Fatal(err) + } + for index, status := range []string{"queued", "running"} { + if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_runs SET status=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, f.project, f.workspace, run, status); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_steps SET status=$4 WHERE project=$1 AND workspace_id=$2 AND run_id=$3`, f.project, f.workspace, run, status); err != nil { + t.Fatal(err) + } + body := call(modelTestID(8600+index), 2, 409) + if body["status"] != "not-applied" || body["terminal"] != true || body["error"] != "migration_waiting_for_runs" { + t.Fatal("identity migration crossed queued or outcome-unknown execution") + } + assertLegacy(2) + } + if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_steps SET status='cancelled' WHERE project=$1 AND workspace_id=$2 AND run_id=$3`, f.project, f.workspace, run); err != nil { + t.Fatal(err) + } + if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_runs SET status='cancelled' WHERE project=$1 AND workspace_id=$2 AND id=$3`, f.project, f.workspace, run); err != nil { + t.Fatal(err) + } + if call(modelTestID(8601), 2, 409)["error"] != "migration_waiting_for_runs" { + t.Fatal("settling paid work changed the frozen rejection") + } + call(modelTestID(8602), 2, 200) + model := f.read(t) + if model.Versions[f.root] != 3 || len(model.Canvases) != 2 { + t.Fatal("explicit migration after execution settled lost its source version barrier") + } +} diff --git a/internal/platform/canvas_model_guard.go b/internal/platform/canvas_model_guard.go new file mode 100644 index 0000000..37072db --- /dev/null +++ b/internal/platform/canvas_model_guard.go @@ -0,0 +1,41 @@ +package platform + +import ( + "net/http" + "strings" +) + +// Old frozen receipts remain readable. Old whole-snapshot/ownership writers may +// not edit a migrated tree, even when their old client has not seen the barrier. +func (s *service) guardLegacyCanonicalRequest(w http.ResponseWriter, r *http.Request, project, account string) (bool, error) { + if r.PathValue("material") == "" { + return false, nil + } + path := r.URL.Path + legacy := strings.HasSuffix(path, "/canvas") + if r.Method != http.MethodGet { + for _, part := range []string{"/canvas/subgraphs", "/canvas/connections", "/canvas/transactions", "/canvas/operations"} { + legacy = legacy || strings.Contains(path, part) + } + } + if !legacy { + return false, nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return false, err + } + defer tx.Rollback() + if _, _, err = lockMaterial(r.Context(), tx, project, r.PathValue("workspace"), r.PathValue("material"), account); err != nil { + return false, err + } + root, err := canonicalRoot(r.Context(), tx, project, r.PathValue("workspace"), r.PathValue("material")) + if err != nil { + return false, err + } + if root == "" { + return false, nil + } + reply(w, 409, response{"error": "canonical_write_required", "root_id": root, "model_path": "canvas/model"}) + return true, nil +} diff --git a/internal/platform/canvas_model_http.go b/internal/platform/canvas_model_http.go new file mode 100644 index 0000000..40800bb --- /dev/null +++ b/internal/platform/canvas_model_http.go @@ -0,0 +1,496 @@ +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 + } + if r.Header.Get("Accept") == modelStreamType { + writeCanvasModelStream(r.Context(), w, model) + return nil + } + w.Header().Add("Vary", "Accept") + 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 +} diff --git a/internal/platform/canvas_model_integration_test.go b/internal/platform/canvas_model_integration_test.go new file mode 100644 index 0000000..9e3995d --- /dev/null +++ b/internal/platform/canvas_model_integration_test.go @@ -0,0 +1,398 @@ +package platform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestCanonicalModelMigrationAndIncrementalLifecycle(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 := "canonical-" + hex.EncodeToString(entropy[:]) + defer func() { + for _, table := range []string{"canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_subgraphs", "workspaces", "project_accounts"} { + _, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project) + } + }() + var account, workspace, root 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 tokenBytes [32]byte + _, _ = rand.Read(tokenBytes[:]) + token := base64.RawURLEncoding.EncodeToString(tokenBytes[:]) + 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) + } + if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Canonical',$2) RETURNING id::text`, project, account).Scan(&workspace); err != nil { + t.Fatal(err) + } + if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin')`, project, workspace, account); err != nil { + t.Fatal(err) + } + legacy := json.RawMessage(`{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"preserved","frameId":"f"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`) + if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Root',$3,$4) RETURNING id::text`, project, workspace, account, legacy).Scan(&root); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + Register(mux, db, map[string]string{project: "https://app.example.com/auth/callback"}, "https://api.example.com", nil) + base := "/projects/" + project + "/workspaces/" + workspace + "/materials/" + root + "/canvas" + call := func(method, path string, input any, key string, status int) response { + t.Helper() + raw, _ := json.Marshal(input) + request := httptest.NewRequest(method, path, bytes.NewReader(raw)) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2") + if key != "" { + request.Header.Set("Idempotency-Key", key) + } + writer := httptest.NewRecorder() + mux.ServeHTTP(writer, request) + if writer.Code != status { + t.Fatalf("%s %s got %d %s want %d", method, path, writer.Code, writer.Body.String(), status) + } + out := response{} + if writer.Body.Len() > 0 { + if err := json.Unmarshal(writer.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return out + } + read := func() canvasModel { + t.Helper() + tx, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + model, err := loadCanvasModel(ctx, tx, project, workspace, root) + if err != nil { + t.Fatal(err) + } + return model + } + if call("GET", base+"/model", nil, "", 409)["error"] != "canonical_migration_required" { + t.Fatal("legacy read silently migrated") + } + var count int + if err = db.QueryRowContext(ctx, `SELECT count(*) FROM canvas_model_projects WHERE project=$1`, project).Scan(&count); err != nil || count != 0 { + t.Fatal("GET created a canonical project") + } + migrationKey := modelTestID(1000) + migrationInput := response{"protocol_version": 2, "request_id": migrationKey, "expected_versions": map[string]int64{root: 1}} + migration := call("POST", base+"/model/migrate", migrationInput, migrationKey, 200) + if migration["status"] != "applied" { + t.Fatal("migration missing applied receipt") + } + retry := call("POST", base+"/model/migrate", migrationInput, migrationKey, 200) + if !modelEqual(migration["identity_map"], retry["identity_map"]) { + t.Fatal("migration retry allocated a second identity map") + } + identities := migration["identity_map"].([]any) + nodeID, frameID := "", "" + for _, item := range identities { + mapping := item.(map[string]any) + switch mapping["sourceId"] { + case "a": + nodeID = mapping["elementId"].(string) + case "f": + frameID = mapping["elementId"].(string) + } + } + model := read() + if nodeID == "" || frameID == "" || len(model.Canvases) != 2 { + t.Fatal("migration lost identities or an implicit partition") + } + child := modelString(model.Elements[nodeID]["ownerCanvasId"]) + if child == root || model.Canvases[child].Parent.EntryElementID != frameID || model.Versions[root] != 2 || model.Versions[child] != 1 { + t.Fatal("canonical owning tree or version barrier is incorrect") + } + if call("PATCH", base, response{"document": legacy}, "", 409)["error"] != "canonical_write_required" { + t.Fatal("old snapshot writer can overwrite canonical records") + } + call("GET", base, nil, "", 409) + if _, err = db.ExecContext(ctx, `UPDATE materials SET canvas_document=$4,canvas_version=canvas_version+1 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, root, legacy); err == nil { + t.Fatal("database identity barrier allowed an old writer") + } + edit := func(n int, versions map[string]int64, changes ...modelChange) response { + t.Helper() + key := modelTestID(n) + input := modelBatch{ProtocolVersion: 2, RequestID: key, Expected: versions, Operations: []modelOperation{{ID: modelTestID(n + 10000), Changes: changes}}} + return call("POST", base+"/model/operations", input, key, 200) + } + titleChange := modelChange{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("renamed")} + first := edit(1001, map[string]int64{child: 1}, titleChange) + if first["model"].(map[string]any)["complete"] != false { + t.Fatal("operation receipt pretends to be a full project snapshot") + } + model = read() + if model.Versions[root] != 2 || model.Versions[child] != 2 || model.Elements[nodeID]["data"].(map[string]any)["text"] != "preserved" { + t.Fatal("child edit changed unrelated canvas/version/content") + } + requestKey := modelTestID(1001) + repeat := modelBatch{ProtocolVersion: 2, RequestID: requestKey, Expected: map[string]int64{child: 1}, Operations: []modelOperation{{ID: modelTestID(11001), Changes: []modelChange{titleChange}}}} + call("POST", base+"/model/operations", repeat, requestKey, 200) + if read().Versions[child] != 2 { + t.Fatal("exact retry executed twice") + } + if call("GET", base+"/model/operations/"+requestKey, nil, "", 200)["status"] != "applied" { + t.Fatal("lost ACK cannot be queried") + } + if call("GET", base+"/model/operations/"+modelTestID(9999), nil, "", 404)["status"] != "unknown" { + t.Fatal("missing receipt claimed zero writes") + } + // Move is a row owner update, retaining ElementId and incarnation. + geometry := model.Elements[nodeID]["geometry"] + edit(1002, map[string]int64{root: 2, child: 2}, modelChange{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(child), After: modelJSON(root)}, modelChange{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "geometry", Before: modelJSON(geometry), After: modelJSON(map[string]any{"x": 150, "y": 260, "width": 240, "height": 72, "rotation": 0})}) + model = read() + if model.Elements[nodeID]["ownerCanvasId"] != root || modelInt(model.Elements[nodeID]["incarnation"]) != 1 { + t.Fatal("move recreated the element") + } + // Future component/envelope bytes survive editing another object and legacy projections. + opaqueID := modelTestID(3000) + opaque := modelTestNode(opaqueID, root) + opaque["componentId"], opaque["dataVersion"], opaque["data"], opaque["futureEnvelope"] = "future.plugin", float64(9), map[string]any{"raw": []any{map[string]any{"keep": true}}}, map[string]any{"extension": "retained"} + edit(1003, map[string]int64{root: 3}, modelChange{Kind: "element", ID: opaqueID, Before: modelJSON(nil), After: modelJSON(opaque)}) + model = read() + if !modelEqual(model.Elements[opaqueID], opaque) { + t.Fatal("future component data changed") + } + edit(1004, map[string]int64{root: 4}, modelChange{Kind: "element", ID: nodeID, Before: modelJSON(model.Elements[nodeID]), After: modelJSON(nil)}) + model = read() + if model.Tombstones[nodeID] != 1 { + t.Fatal("delete did not persist its lifecycle tombstone") + } + restored := modelTestNode(nodeID, root) + restored["componentId"] = "plain-text" + restored["incarnation"] = float64(2) + restored["data"].(map[string]any)["title"] = "restored" + edit(1005, map[string]int64{root: 5}, modelChange{Kind: "element", ID: nodeID, Before: modelJSON(nil), After: modelJSON(restored)}) + staleKey := modelTestID(1006) + stale := modelBatch{ProtocolVersion: 2, RequestID: staleKey, Expected: map[string]int64{root: 6}, Operations: []modelOperation{{ID: modelTestID(11006), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "data.title", Before: modelJSON("restored"), After: modelJSON("stale callback")}}}}} + if call("POST", base+"/model/operations", stale, staleKey, 409)["status"] != "not-applied" { + t.Fatal("stale callback did not receive a zero-write receipt") + } + if call("GET", base+"/model/operations/"+staleKey, nil, "", 200)["terminal"] != true { + t.Fatal("terminal rejection was not queryable") + } + model = read() + if model.Versions[root] != 6 || !modelEqual(model.Elements[opaqueID], opaque) || modelInt(model.Elements[nodeID]["incarnation"]) != 2 { + t.Fatal("stale callback or unrelated update damaged state") + } + duplicateKey := modelTestID(1007) + duplicate := modelBatch{ProtocolVersion: 2, RequestID: duplicateKey, Expected: map[string]int64{root: 6}, Operations: []modelOperation{{ID: modelTestID(11002), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 2}, Field: "data.title", Before: modelJSON("restored"), After: modelJSON("duplicate effect")}}}}} + if call("POST", base+"/model/operations", duplicate, duplicateKey, 409)["error"] != "operation_already_applied" { + t.Fatal("logical operation was executed under a new request ID") + } + var owner string + var incarnation int64 + if err = db.QueryRowContext(ctx, `SELECT owner_canvas_id::text,incarnation FROM canvas_model_elements WHERE project=$1 AND element_id=$2 AND NOT deleted`, project, nodeID).Scan(&owner, &incarnation); err != nil || owner != root || incarnation != 2 { + t.Fatal("canonical row authority disagrees with the API") + } + if call("GET", base+"/model", nil, "", 200)["model"].(map[string]any)["complete"] != true { + t.Fatal("initial read has no explicit coverage") + } + // Negotiate the same authenticated canonical read as a streamed transfer. + streamRequest := httptest.NewRequest("GET", base+"/model", nil) + streamRequest.Header.Set("Authorization", "Bearer "+token) + streamRequest.Header.Set("Accept", modelStreamType) + streamWriter := httptest.NewRecorder() + mux.ServeHTTP(streamWriter, streamRequest) + streamed := streamRecords(t, streamWriter) + if streamWriter.Code != 200 || streamWriter.Header().Get("Content-Type") != modelStreamType || len(streamed) < 2 || streamed[0]["projectId"] != root || streamed[len(streamed)-1]["kind"] != "complete" || !modelEqual(streamed[0]["versions"], read().Versions) { + t.Fatal("authenticated stream lost canonical snapshot coverage") + } + // An object created and removed inside one transaction still has a lifecycle fact. + ghostID := modelTestID(4000) + ghost := modelTestNode(ghostID, root) + edit(1008, map[string]int64{root: 6}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(nil), After: modelJSON(ghost)}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(ghost), After: modelJSON(nil)}) + model = read() + if model.Tombstones[ghostID] != 1 || model.Elements[ghostID] != nil { + t.Fatal("in-batch create/delete lost its tombstone") + } + ghost["incarnation"] = float64(2) + edit(1009, map[string]int64{root: 7}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(nil), After: modelJSON(ghost)}) + if modelInt(read().Elements[ghostID]["incarnation"]) != 2 { + t.Fatal("restore after an in-batch delete did not preserve identity") + } + // Session changes cannot send an old account's queued request as a new account. + request := httptest.NewRequest("GET", base+"/model", nil) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("X-Expected-Account", modelTestID(7777)) + writer := httptest.NewRecorder() + mux.ServeHTTP(writer, request) + if writer.Code != 409 { + t.Fatal("expected-account barrier was ignored") + } + // A new partition's entry, owning canvas and versions publish atomically. + newCanvas, newFrame := modelTestID(5000), modelTestID(5001) + frame := modelTestNode(newFrame, root) + frame["componentId"] = "frame" + canvas := modelCanvas{ID: newCanvas, Parent: &modelParent{CanvasID: root, EntryElementID: newFrame}} + edit(1010, map[string]int64{root: 8, newCanvas: 0}, modelChange{Kind: "canvas", ID: newCanvas, Before: modelJSON(nil), After: modelJSON(canvas)}, modelChange{Kind: "element", ID: newFrame, Before: modelJSON(nil), After: modelJSON(frame)}) + model = read() + if model.Versions[newCanvas] != 1 || model.Canvases[newCanvas].Parent.EntryElementID != newFrame { + t.Fatal("partition creation was not atomic") + } + edit(1011, map[string]int64{root: 9, newCanvas: 1}, modelChange{Kind: "canvas", ID: newCanvas, Before: modelJSON(canvas), After: modelJSON(nil)}, modelChange{Kind: "element", ID: newFrame, Before: modelJSON(frame), After: modelJSON(nil)}) + model = read() + if _, exists := model.Canvases[newCanvas]; exists || model.Versions[newCanvas] != 2 { + t.Fatal("partition archive lost its version fence") + } + frame["incarnation"] = float64(2) + edit(1012, map[string]int64{root: 10, newCanvas: 2}, modelChange{Kind: "element", ID: newFrame, Before: modelJSON(nil), After: modelJSON(frame)}, modelChange{Kind: "canvas", ID: newCanvas, Before: modelJSON(nil), After: modelJSON(canvas)}) + model = read() + if model.Versions[newCanvas] != 3 || modelInt(model.Elements[newFrame]["incarnation"]) != 2 { + t.Fatal("partition restore reused a stale lifecycle or version") + } + + // A cross-scale entry move writes ownership, child binding and presentation units together. + innerCanvas, innerFrame, memberID := modelTestID(5100), modelTestID(5101), modelTestID(5102) + container := modelTestNode(innerFrame, root) + container["componentId"] = "frame" + originalPresentation := response{"contentScale": .75, "future": response{"keep": "metadata"}} + container["data"].(map[string]any)["presentation"] = originalPresentation + member := modelTestNode(memberID, innerCanvas) + member["data"].(map[string]any)["fontSize"] = float64(17) + inner := modelCanvas{ID: innerCanvas, Parent: &modelParent{CanvasID: root, EntryElementID: innerFrame}} + absent := false + edit(1013, map[string]int64{root: model.Versions[root], newCanvas: model.Versions[newCanvas], innerCanvas: 0}, + modelChange{Kind: "field", Target: modelTarget{ID: newFrame, Incarnation: 2}, Field: "data.presentation", Before: modelJSON(nil), BeforePresent: &absent, After: modelJSON(response{"contentScale": 2})}, + modelChange{Kind: "element", ID: innerFrame, Before: modelJSON(nil), After: modelJSON(container)}, + modelChange{Kind: "canvas", ID: innerCanvas, Before: modelJSON(nil), After: modelJSON(inner)}, + modelChange{Kind: "element", ID: memberID, Before: modelJSON(nil), After: modelJSON(member)}) + model = read() + oldGeometry := model.Elements[innerFrame]["geometry"] + containerGeometry := oldGeometry.(map[string]any) + normalizedGeometry := response{"x": (containerGeometry["x"].(float64) - 24) / 2, "y": (containerGeometry["y"].(float64) - 36) / 2, "width": containerGeometry["width"].(float64) / 2, "height": containerGeometry["height"].(float64) / 2, "rotation": containerGeometry["rotation"]} + normalizedPresentation := response{"contentScale": .75, "unitScale": .5, "future": response{"keep": "metadata"}} + movedInner := inner + movedInner.Parent = &modelParent{CanvasID: newCanvas, EntryElementID: innerFrame} + edit(1014, map[string]int64{root: model.Versions[root], newCanvas: model.Versions[newCanvas], innerCanvas: model.Versions[innerCanvas]}, + modelChange{Kind: "field", Target: modelTarget{ID: innerFrame, Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(root), After: modelJSON(newCanvas)}, + modelChange{Kind: "field", Target: modelTarget{ID: innerFrame, Incarnation: 1}, Field: "geometry", Before: modelJSON(oldGeometry), After: modelJSON(normalizedGeometry)}, + modelChange{Kind: "field", Target: modelTarget{ID: innerFrame, Incarnation: 1}, Field: "data.presentation", Before: modelJSON(originalPresentation), After: modelJSON(normalizedPresentation)}, + modelChange{Kind: "canvas", ID: innerCanvas, Before: modelJSON(inner), After: modelJSON(movedInner)}) + model = read() + if modelString(model.Elements[innerFrame]["ownerCanvasId"]) != newCanvas || model.Canvases[innerCanvas].Parent.CanvasID != newCanvas || !modelEqual(model.Elements[innerFrame]["data"].(map[string]any)["presentation"], normalizedPresentation) || !modelEqual(model.Elements[memberID], member) { + t.Fatal("scaled container move changed its member or lost the atomic unit/ownership boundary") + } + if call("GET", base+"/model/operations/"+modelTestID(1014), nil, "", 200)["status"] != "applied" { + t.Fatal("scaled move receipt was not queryable by its original identity") + } + edit(1015, map[string]int64{root: model.Versions[root], newCanvas: model.Versions[newCanvas], innerCanvas: model.Versions[innerCanvas]}, + modelChange{Kind: "field", Target: modelTarget{ID: innerFrame, Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(newCanvas), After: modelJSON(root)}, + modelChange{Kind: "field", Target: modelTarget{ID: innerFrame, Incarnation: 1}, Field: "geometry", Before: modelJSON(normalizedGeometry), After: modelJSON(oldGeometry)}, + modelChange{Kind: "field", Target: modelTarget{ID: innerFrame, Incarnation: 1}, Field: "data.presentation", Before: modelJSON(normalizedPresentation), After: modelJSON(originalPresentation)}, + modelChange{Kind: "canvas", ID: innerCanvas, Before: modelJSON(movedInner), After: modelJSON(inner)}) + model = read() + if _, exists := model.Elements[innerFrame]["data"].(map[string]any)["presentation"].(map[string]any)["unitScale"]; exists || !modelEqual(model.Elements[memberID], member) || model.Canvases[innerCanvas].Parent.CanvasID != root { + t.Fatal("scaled move inverse did not restore absent unit metadata and the original child binding") + } + // Several logical edits share one atomic request while retaining every deduplication identity. + batchKey, firstLogical, secondLogical := modelTestID(1016), modelTestID(22001), modelTestID(22002) + batchVersion := model.Versions[innerCanvas] + batch := modelBatch{ProtocolVersion: 2, RequestID: batchKey, Expected: map[string]int64{innerCanvas: batchVersion}, Operations: []modelOperation{ + {ID: firstLogical, Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON(memberID), After: modelJSON("intermediate")}}}, + {ID: secondLogical, Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("intermediate"), After: modelJSON("batched")}}}, + }} + batched := call("POST", base+"/model/operations", batch, batchKey, 200) + if !modelEqual(batched["operation_ids"], []string{firstLogical, secondLogical}) { + t.Fatal("batch receipt did not cover every original logical ID") + } + model = read() + if model.Versions[innerCanvas] != batchVersion+1 || modelString(model.Elements[memberID]["data"].(map[string]any)["title"]) != "batched" { + t.Fatal("batch lost ordered conditions or advanced the canvas version more than once") + } + if !modelEqual(call("GET", base+"/model/operations/"+batchKey, nil, "", 200), batched) || !modelEqual(call("POST", base+"/model/operations", batch, batchKey, 200), batched) { + t.Fatal("batch query/retry did not return its original receipt") + } + batch.RequestID = modelTestID(1017) + batch.Expected[innerCanvas] = model.Versions[innerCanvas] + batch.Operations = batch.Operations[:1] + if call("POST", base+"/model/operations", batch, batch.RequestID, 409)["error"] != "operation_already_applied" { + t.Fatal("a covered logical ID was executable under a new request key") + } + // A later failed condition cannot publish the earlier edit or consume its logical identity. + failedKey := modelTestID(1018) + batch.RequestID = failedKey + batch.Operations = []modelOperation{ + {ID: modelTestID(22003), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("batched"), After: modelJSON("must roll back")}}}, + {ID: modelTestID(22004), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("wrong before"), After: modelJSON("invalid")}}}, + } + if call("POST", base+"/model/operations", batch, failedKey, 409)["status"] != "not-applied" { + t.Fatal("failed batch did not produce a zero-write terminal receipt") + } + model = read() + if model.Versions[innerCanvas] != batchVersion+1 || modelString(model.Elements[memberID]["data"].(map[string]any)["title"]) != "batched" { + t.Fatal("a failed batch leaked its successful prefix") + } + var consumed int + if err = db.QueryRowContext(ctx, `SELECT count(*) FROM canvas_model_operations WHERE project=$1 AND operation_id=$2`, project, modelTestID(22003)).Scan(&consumed); err != nil || consumed != 0 { + t.Fatal("a rejected atomic prefix consumed a logical operation ID") + } + + // A compacted wire projection retains prefix IDs with conditional no-ops, including in a child scope. + projectedKey, guardLogical, effectLogical := modelTestID(1019), modelTestID(22005), modelTestID(22006) + projectedGhost := modelTestID(5200) + finalGhost := modelTestNode(projectedGhost, innerCanvas) + finalGhost["data"].(map[string]any)["text"] = "composed final value" + parentVersion := model.Versions[root] + projection := modelBatch{ProtocolVersion: 2, RequestID: projectedKey, Expected: map[string]int64{innerCanvas: model.Versions[innerCanvas]}, Operations: []modelOperation{ + {ID: guardLogical, Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("batched"), After: modelJSON("batched")}}}, + {ID: effectLogical, Changes: []modelChange{ + {Kind: "field", Target: modelTarget{ID: memberID, Incarnation: 1}, Field: "data.title", Before: modelJSON("batched"), After: modelJSON("projected final")}, + {Kind: "element", ID: projectedGhost, Before: modelJSON(nil), After: modelJSON(finalGhost)}, + {Kind: "element", ID: projectedGhost, Before: modelJSON(finalGhost), After: modelJSON(nil)}, + }}, + }} + projectedReceipt := call("POST", base+"/model/operations", projection, projectedKey, 200) + model = read() + if !modelEqual(projectedReceipt["operation_ids"], []string{guardLogical, effectLogical}) || model.Versions[innerCanvas] != batchVersion+2 || model.Versions[root] != parentVersion { + t.Fatal("projected prefix lost its logical coverage or widened the child writer scope") + } + if modelString(model.Elements[memberID]["data"].(map[string]any)["title"]) != "projected final" || model.Tombstones[projectedGhost] != 1 || !modelEqual(model.DeadRecords[projectedGhost], finalGhost) { + t.Fatal("projected effect lost final data, tombstone or archived lifecycle record") + } + if !modelEqual(call("GET", base+"/model/operations/"+projectedKey, nil, "", 200), projectedReceipt) { + t.Fatal("compacted projection receipt was not recoverable by its original key") + } + + // A locally canceled incarnation was never sent; undo may first publish incarnation 2. + locallyElidedID := modelTestID(5201) + locallyRestored := modelTestNode(locallyElidedID, innerCanvas) + locallyRestored["incarnation"] = float64(2) + edit(1020, map[string]int64{innerCanvas: model.Versions[innerCanvas]}, modelChange{Kind: "element", ID: locallyElidedID, Before: modelJSON(nil), After: modelJSON(locallyRestored)}) + model = read() + if modelInt(model.Elements[locallyElidedID]["incarnation"]) != 2 || model.Tombstones[locallyElidedID] != 0 { + t.Fatal("local elision undo reused a canceled life or invented an ACK tombstone") + } + staleElisionKey := modelTestID(1021) + staleElision := modelBatch{ProtocolVersion: 2, RequestID: staleElisionKey, Expected: map[string]int64{innerCanvas: model.Versions[innerCanvas]}, Operations: []modelOperation{{ID: modelTestID(22007), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: locallyElidedID, Incarnation: 1}, Field: "data.title", Before: modelJSON(locallyElidedID), After: modelJSON("stale local callback")}}}}} + if call("POST", base+"/model/operations", staleElision, staleElisionKey, 409)["status"] != "not-applied" || !modelEqual(read().Elements[locallyElidedID], locallyRestored) { + t.Fatal("a callback from the locally canceled incarnation changed its restored successor") + } + +} diff --git a/internal/platform/canvas_model_migration.go b/internal/platform/canvas_model_migration.go new file mode 100644 index 0000000..5938634 --- /dev/null +++ b/internal/platform/canvas_model_migration.go @@ -0,0 +1,282 @@ +package platform + +import ( + "encoding/json" + "fmt" + "strings" +) + +type modelIdentity struct { + SourceCanvasID string `json:"sourceCanvasId"` + Structure string `json:"structure"` + SourceID string `json:"sourceId"` + ElementID string `json:"elementId"` +} +type modelMigration struct { + Model canvasModel + Identities []modelIdentity + NewCanvases []string +} +type modelSpace struct{ X, Y, Scale float64 } + +// No source document is changed. IDs are allocated once inside the eventual +// migration transaction and the complete mapping is stored with its receipt. +func migrateCanvasModel(root string, snapshots map[string]canvasSnapshot, relations []subgraphRelation, allocate func() string) (modelMigration, error) { + result := modelMigration{Model: emptyModel(root), Identities: []modelIdentity{}, NewCanvases: []string{}} + documents, nodes := map[string]map[string]any{}, map[string]map[string]any{} + identities, children, synthetic := map[string]string{}, map[string]string{}, map[string]bool{} + used := map[string]bool{} + for board := range snapshots { + used[board] = true + } + nextID := func() (string, error) { + id := allocate() + if !uuidSlugPattern.MatchString(id) || used[id] { + return "", errCanvasModel + } + used[id] = true + return id, nil + } + identityKey := func(board, structure, id string) string { return board + "/" + structure + "/" + id } + for _, board := range sortedKeys(snapshots) { + if !uuidSlugPattern.MatchString(board) || snapshots[board].Version < 1 { + return result, errCanvasModel + } + normalized, valid := normalizeCanvas(snapshots[board].Document) + if !valid { + return result, errCanvasModel + } + var document map[string]any + if json.Unmarshal(normalized, &document) != nil { + return result, errCanvasModel + } + documents[board] = document + result.Model.Canvases[board] = modelCanvas{ID: board} + result.Model.Versions[board] = snapshots[board].Version + 1 + for _, collection := range []string{"nodes", "edges"} { + structure := "node" + if collection == "edges" { + structure = "connection" + } + for _, value := range document[collection].([]any) { + record := value.(map[string]any) + old := record["id"].(string) + id, err := nextID() + if err != nil { + return result, err + } + identities[identityKey(board, structure, old)] = id + result.Identities = append(result.Identities, modelIdentity{SourceCanvasID: board, Structure: structure, SourceID: old, ElementID: id}) + if structure == "node" { + nodes[canvasNodeKey(board, old)] = record + } + } + } + } + for _, relation := range relations { + if relation.Archived { + continue + } + key := canvasNodeKey(relation.Parent, relation.Frame) + frame := nodes[key] + if frame == nil || documents[relation.Child] == nil || children[key] != "" || relation.Child == root { + return result, errCanvasModel + } + data := frame["data"].(map[string]any) + sub, _ := data["subgraph"].(map[string]any) + if data["kind"] != "frame" || sub["boardId"] != relation.Child || result.Model.Canvases[relation.Child].Parent != nil { + return result, errCanvasModel + } + children[key] = relation.Child + result.Model.Canvases[relation.Child] = modelCanvas{ID: relation.Child, Parent: &modelParent{CanvasID: relation.Parent, EntryElementID: identities[identityKey(relation.Parent, "node", relation.Frame)]}} + } + // Ordinary legacy frameId groups also become actual canvases, even if empty. + for _, key := range sortedKeys(nodes) { + node := nodes[key] + data := node["data"].(map[string]any) + if data["kind"] != "frame" { + continue + } + if _, linked := data["subgraph"]; linked && children[key] == "" { + return result, errCanvasModel + } + if children[key] != "" { + continue + } + id, err := nextID() + if err != nil { + return result, err + } + board := strings.SplitN(key, "/", 2)[0] + children[key], synthetic[key] = id, true + result.Model.Canvases[id] = modelCanvas{ID: id, Parent: &modelParent{CanvasID: board, EntryElementID: identities[identityKey(board, "node", node["id"].(string))]}} + result.Model.Versions[id] = 1 + result.NewCanvases = append(result.NewCanvases, id) + } + for _, board := range sortedKeys(documents) { + for index, value := range documents[board]["nodes"].([]any) { + node := value.(map[string]any) + original := node["data"].(map[string]any) + data := map[string]any{} + for key, value := range original { + data[key] = value + } + kind := data["kind"].(string) + owner := board + geometry := legacyModelGeometry(node, kind) + if frameID, exists := data["frameId"].(string); exists { + frame := nodes[canvasNodeKey(board, frameID)] + child := children[canvasNodeKey(board, frameID)] + if frame == nil || child == "" { + return result, errCanvasModel + } + position := frame["position"].(map[string]any) + frameData := frame["data"].(map[string]any) + scale := modelContentScale(frameData) + geometry["x"] = (geometry["x"].(float64) - position["x"].(float64) - 24) / scale + geometry["y"] = (geometry["y"].(float64) - position["y"].(float64) - 36) / scale + geometry["width"], geometry["height"] = geometry["width"].(float64)/scale, geometry["height"].(float64)/scale + owner = child + } + for _, key := range []string{"kind", "rotation", "frameId", "subgraph", "ports"} { + delete(data, key) + } + id := identities[identityKey(board, "node", node["id"].(string))] + result.Model.Elements[id] = map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": kind, "dataVersion": float64(1), "structure": "node", "geometry": geometry, "data": data, "orderKey": fmt.Sprintf("%s/%012d", board, index)} + } + } + spaces, err := modelSpaces(result.Model) + if err != nil { + return result, err + } + resolve := func(board, node, handle, direction string) (map[string]any, error) { + seen := map[string]bool{} + for depth := 0; depth <= 8; depth++ { + key := canvasNodeKey(board, node) + legacy := nodes[key] + if legacy == nil || seen[key+"/"+handle] { + return nil, errCanvasModel + } + seen[key+"/"+handle] = true + data := legacy["data"].(map[string]any) + if data["kind"] != "frame" || !strings.HasPrefix(handle, "port:") { + endpoint := map[string]any{"elementId": identities[identityKey(board, "node", node)]} + if handle != "" { + endpoint["portId"] = handle + } + return endpoint, nil + } + ports, _ := data["ports"].([]any) + found := false + for _, value := range ports { + port := value.(map[string]any) + if port["id"] == strings.TrimPrefix(handle, "port:") && port["direction"] == direction { + node, handle = modelString(port["nodeId"]), modelString(port["handle"]) + if !synthetic[key] { + board = children[key] + } + found = true + break + } + } + if !found { + return nil, errCanvasModel + } + } + return nil, errCanvasModel + } + for _, board := range sortedKeys(documents) { + for index, value := range documents[board]["edges"].([]any) { + edge := value.(map[string]any) + source, err := resolve(board, modelString(edge["source"]), modelString(edge["sourceHandle"]), "output") + if err != nil { + return result, err + } + target, err := resolve(board, modelString(edge["target"]), modelString(edge["targetHandle"]), "input") + if err != nil { + return result, err + } + owner, err := modelCommonCanvas(result.Model, modelString(result.Model.Elements[source["elementId"].(string)]["ownerCanvasId"]), modelString(result.Model.Elements[target["elementId"].(string)]["ownerCanvasId"])) + if err != nil { + return result, err + } + from, to := spaces[board], spaces[owner] + projectEdgePath(edge, from.Scale/to.Scale, (from.X-to.X)/to.Scale, (from.Y-to.Y)/to.Scale) + data, _ := edge["data"].(map[string]any) + if data == nil { + data = map[string]any{} + } + path := data["path"] + delete(data, "path") + id := identities[identityKey(board, "connection", edge["id"].(string))] + result.Model.Elements[id] = map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": "connection", "dataVersion": float64(1), "structure": "connection", "source": source, "target": target, "path": path, "data": data, "orderKey": fmt.Sprintf("%s/%012d", board, index)} + } + } + if err := validateCanvasModel(result.Model); err != nil { + return result, err + } + return result, nil +} +func modelContentScale(data map[string]any) float64 { + presentation, _ := data["presentation"].(map[string]any) + if scale, ok := presentation["contentScale"].(float64); ok && scale > 0 { + return scale + } + return 1 +} +func legacyModelGeometry(node map[string]any, kind string) map[string]any { + width, height := float64(260), float64(200) + switch kind { + case "plain-text": + width, height = 240, 72 + case "plain-image": + width, height = 320, 240 + case "icon": + width, height = 80, 80 + case "frame": + width, height = 640, 400 + } + if value, exists := node["width"].(float64); exists { + width = value + } + if value, exists := node["height"].(float64); exists { + height = value + } + position := node["position"].(map[string]any) + data := node["data"].(map[string]any) + rotation, _ := data["rotation"].(float64) + return map[string]any{"x": position["x"], "y": position["y"], "width": width, "height": height, "rotation": rotation} +} +func modelSpaces(model canvasModel) (map[string]modelSpace, error) { + result := map[string]modelSpace{model.Root: {Scale: 1}} + remaining := len(model.Canvases) - 1 + for depth := 0; remaining > 0 && depth < 9; depth++ { + progress := false + for _, id := range sortedKeys(model.Canvases) { + canvas := model.Canvases[id] + if _, exists := result[id]; exists || canvas.Parent == nil { + continue + } + parent, exists := result[canvas.Parent.CanvasID] + if !exists { + continue + } + entry := model.Elements[canvas.Parent.EntryElementID] + if entry == nil { + return nil, errCanvasModel + } + geometry := entry["geometry"].(map[string]any) + data := entry["data"].(map[string]any) + result[id] = modelSpace{X: parent.X + (geometry["x"].(float64)+24)*parent.Scale, Y: parent.Y + (geometry["y"].(float64)+36)*parent.Scale, Scale: parent.Scale * modelContentScale(data)} + remaining-- + progress = true + } + if !progress { + return nil, errCanvasModel + } + } + if remaining != 0 { + return nil, errCanvasModel + } + return result, nil +} diff --git a/internal/platform/canvas_model_store.go b/internal/platform/canvas_model_store.go new file mode 100644 index 0000000..c8994e1 --- /dev/null +++ b/internal/platform/canvas_model_store.go @@ -0,0 +1,320 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "sort" +) + +func canonicalRoot(ctx context.Context, tx *sql.Tx, project, workspace, board string) (string, error) { + var root string + err := tx.QueryRowContext(ctx, `SELECT root_id::text FROM canvas_model_canvases WHERE project=$1 AND workspace_id=$2 AND canvas_id=$3`, project, workspace, board).Scan(&root) + if err == sql.ErrNoRows { + return "", nil + } + return root, err +} +func loadCanvasModel(ctx context.Context, tx *sql.Tx, project, workspace, root string) (canvasModel, error) { + model := emptyModel(root) + rows, err := tx.QueryContext(ctx, `SELECT canvas_id::text,parent_canvas_id::text,entry_element_id,version,archived FROM canvas_model_canvases WHERE project=$1 AND workspace_id=$2 AND root_id=$3 ORDER BY canvas_id`, project, workspace, root) + if err != nil { + return model, err + } + for rows.Next() { + var canvas modelCanvas + var parent, entry sql.NullString + var version int64 + var archived bool + if err = rows.Scan(&canvas.ID, &parent, &entry, &version, &archived); err != nil { + rows.Close() + return model, err + } + if parent.Valid { + canvas.Parent = &modelParent{CanvasID: parent.String, EntryElementID: entry.String} + } + model.Versions[canvas.ID] = version + if !archived { + model.Canvases[canvas.ID] = canvas + } + } + err = rows.Err() + rows.Close() + if err != nil { + return model, err + } + rows, err = tx.QueryContext(ctx, `SELECT element_id,record,deleted,deleted_incarnation FROM canvas_model_elements WHERE project=$1 AND workspace_id=$2 AND root_id=$3 ORDER BY element_id`, project, workspace, root) + if err != nil { + return model, err + } + defer rows.Close() + for rows.Next() { + var id string + var raw json.RawMessage + var deleted bool + var dead int64 + if err = rows.Scan(&id, &raw, &deleted, &dead); err != nil { + return model, err + } + if dead > 0 { + model.Tombstones[id] = dead + } + var element map[string]any + if err = json.Unmarshal(raw, &element); err != nil { + return model, err + } + if dead > 0 { + model.TombstoneOwners[id] = modelString(element["ownerCanvasId"]) + } + if !deleted { + model.Elements[id] = element + } else { + model.DeadRecords[id] = element + } + } + if err = rows.Err(); err != nil { + return model, err + } + return model, validateCanvasModel(model) +} + +// Only changed element rows are written. canvas_document is a compatibility +// projection for internal AI readers, never the source of a canonical GET/replay. +func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspace, account string, before, after canvasModel, participants map[string]bool) error { + ctx := r.Context() + if err := invalidateCanonicalAIIdentities(r, tx, project, workspace, before, after); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `SELECT set_config('foundry.canonical_writer','on',true)`); err != nil { + return err + } + projection, err := projectCanvasModel(after) + if err != nil { + return err + } + for _, id := range sortedKeys(participants) { + canvas, active := after.Canvases[id] + if active { + if _, existed := before.Versions[id]; !existed { + title := "Canvas" + if canvas.Parent != nil { + data := after.Elements[canvas.Parent.EntryElementID]["data"].(map[string]any) + if value := modelString(data["title"]); value != "" { + title = value + } + } + if _, err = tx.ExecContext(ctx, `INSERT INTO materials(project,workspace_id,id,kind,title,created_by) VALUES($1,$2,$3,'board',$4,$5)`, project, workspace, id, title, account); err != nil { + return err + } + } + var parent, entry any + if canvas.Parent != nil { + parent, entry = canvas.Parent.CanvasID, canvas.Parent.EntryElementID + } + _, err = tx.ExecContext(ctx, `INSERT INTO canvas_model_canvases(project,workspace_id,root_id,canvas_id,parent_canvas_id,entry_element_id,version) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(project,workspace_id,canvas_id) DO UPDATE SET parent_canvas_id=EXCLUDED.parent_canvas_id,entry_element_id=EXCLUDED.entry_element_id,version=EXCLUDED.version,archived=false`, project, workspace, after.Root, id, parent, entry, after.Versions[id]) + } else { + _, err = tx.ExecContext(ctx, `UPDATE canvas_model_canvases SET archived=true,version=$4 WHERE project=$1 AND workspace_id=$2 AND canvas_id=$3`, project, workspace, id, after.Versions[id]) + } + if err != nil { + return err + } + } + for _, id := range sortedKeys(after.Elements) { + element := after.Elements[id] + if modelEqual(before.Elements[id], element) && before.Tombstones[id] == after.Tombstones[id] { + continue + } + raw, err := json.Marshal(element) + if err != nil { + return err + } + written, err := tx.ExecContext(ctx, `INSERT INTO canvas_model_elements(project,workspace_id,root_id,element_id,owner_canvas_id,incarnation,deleted_incarnation,record) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(project,element_id) DO UPDATE SET owner_canvas_id=EXCLUDED.owner_canvas_id,incarnation=EXCLUDED.incarnation,deleted_incarnation=EXCLUDED.deleted_incarnation,record=EXCLUDED.record,deleted=false WHERE canvas_model_elements.workspace_id=EXCLUDED.workspace_id AND canvas_model_elements.root_id=EXCLUDED.root_id`, project, workspace, after.Root, id, element["ownerCanvasId"], modelInt(element["incarnation"]), after.Tombstones[id], raw) + if err != nil { + return err + } + if count, err := written.RowsAffected(); err != nil { + return err + } else if count != 1 { + return errModelConflict + } + } + for _, id := range sortedKeys(after.Tombstones) { + if after.Elements[id] != nil || before.Elements[id] == nil && before.Tombstones[id] == after.Tombstones[id] { + continue + } + record := after.DeadRecords[id] + if record == nil { + return errCanvasModel + } + raw, err := json.Marshal(record) + if err != nil { + return err + } + written, err := tx.ExecContext(ctx, `INSERT INTO canvas_model_elements(project,workspace_id,root_id,element_id,owner_canvas_id,incarnation,deleted_incarnation,record,deleted) VALUES($1,$2,$3,$4,$5,$6,$6,$7,true) ON CONFLICT(project,element_id) DO UPDATE SET owner_canvas_id=EXCLUDED.owner_canvas_id,incarnation=EXCLUDED.incarnation,deleted_incarnation=EXCLUDED.deleted_incarnation,record=EXCLUDED.record,deleted=true WHERE canvas_model_elements.workspace_id=EXCLUDED.workspace_id AND canvas_model_elements.root_id=EXCLUDED.root_id`, project, workspace, after.Root, id, record["ownerCanvasId"], after.Tombstones[id], raw) + if err != nil { + return err + } + if count, err := written.RowsAffected(); err != nil { + return err + } else if count != 1 { + return errModelConflict + } + } + // Archive relations first; parent/entry changes then install the final owning tree. + for _, id := range sortedKeys(participants) { + if id != after.Root { + if _, err = tx.ExecContext(ctx, `UPDATE canvas_subgraphs SET archived_at=now() WHERE project=$1 AND workspace_id=$2 AND child_board_id=$3`, project, workspace, id); err != nil { + return err + } + } + } + canvases := sortedKeys(after.Canvases) + sort.SliceStable(canvases, func(i, j int) bool { + a, _ := modelAncestors(after, canvases[i]) + b, _ := modelAncestors(after, canvases[j]) + return len(a) < len(b) + }) + for _, id := range canvases { + canvas := after.Canvases[id] + if !participants[id] || canvas.Parent == nil { + continue + } + _, err = tx.ExecContext(ctx, `INSERT INTO canvas_subgraphs(project,workspace_id,parent_board_id,frame_id,child_board_id) VALUES($1,$2,$3,$4,$5) ON CONFLICT(project,workspace_id,child_board_id) DO UPDATE SET parent_board_id=EXCLUDED.parent_board_id,frame_id=EXCLUDED.frame_id,archived_at=NULL`, project, workspace, canvas.Parent.CanvasID, canvas.Parent.EntryElementID, id) + if err != nil { + return err + } + } + for _, id := range sortedKeys(participants) { + if _, active := after.Canvases[id]; !active { + _, err = tx.ExecContext(ctx, `UPDATE materials SET canvas_version=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id, after.Versions[id]) + if err != nil { + return err + } + continue + } + // Asset bindings still run through service checks; canonical identity + // invalidation has already run on the normalized before/after models. + if err = s.saveCanvas(r, tx, project, workspace, canvasSnapshot{BoardID: id, Version: after.Versions[id], Document: projection[id]}); err != nil { + return err + } + } + return nil +} + +func projectCanvasModel(model canvasModel) (map[string]json.RawMessage, error) { + documents := map[string]map[string]any{} + for id := range model.Canvases { + documents[id] = map[string]any{"schema_version": float64(2), "nodes": []any{}, "edges": []any{}, "viewport": map[string]any{"x": float64(0), "y": float64(0), "zoom": float64(1)}} + } + elements := sortedKeys(model.Elements) + sort.SliceStable(elements, func(i, j int) bool { + return modelString(model.Elements[elements[i]]["orderKey"]) < modelString(model.Elements[elements[j]]["orderKey"]) + }) + childByEntry := map[string]string{} + for id, canvas := range model.Canvases { + if canvas.Parent != nil { + childByEntry[canvas.Parent.EntryElementID] = id + } + } + for _, id := range elements { + element := model.Elements[id] + if element["structure"] != "node" { + continue + } + kind := modelString(element["componentId"]) + if modelInt(element["dataVersion"]) != 1 || !canvasChoice(kind, "text", "script", "image", "plain-text", "plain-image", "icon", "frame") { + continue + } + data := map[string]any{} + for key, value := range element["data"].(map[string]any) { + data[key] = value + } + data["kind"] = kind + geometry := element["geometry"].(map[string]any) + data["rotation"] = geometry["rotation"] + typ := "material" + switch kind { + case "plain-text": + typ = "plainText" + case "plain-image": + typ = "plainImage" + case "icon": + typ = "icon" + case "frame": + typ = "frame" + } + if child := childByEntry[id]; child != "" { + data["subgraph"], data["ports"] = map[string]any{"boardId": child}, []any{} + } + node := map[string]any{"id": id, "type": typ, "position": map[string]any{"x": geometry["x"], "y": geometry["y"]}, "width": geometry["width"], "height": geometry["height"], "data": data} + owner := modelString(element["ownerCanvasId"]) + documents[owner]["nodes"] = append(documents[owner]["nodes"].([]any), node) + } + for _, id := range elements { + element := model.Elements[id] + if element["structure"] != "connection" || element["componentId"] != "connection" || modelInt(element["dataVersion"]) != 1 { + continue + } + owner := modelString(element["ownerCanvasId"]) + endpoints := []canvasConnectionEndpoint{} + skip := false + for index, field := range []string{"source", "target"} { + endpoint := element[field].(map[string]any) + node := model.Elements[modelString(endpoint["elementId"])] + board := modelString(node["ownerCanvasId"]) + if connectionNode(documents[board], modelString(node["id"])) == nil { + skip = true + break + } + handle := modelString(endpoint["portId"]) + if handle == "" { + handle = "left" + if index == 0 { + handle = "right" + } + } + path := []subgraphRelation{} + for next := board; next != owner; { + canvas := model.Canvases[next] + if canvas.Parent == nil { + return nil, errCanvasModel + } + path = append(path, subgraphRelation{Parent: canvas.Parent.CanvasID, Frame: canvas.Parent.EntryElementID, Child: next}) + next = canvas.Parent.CanvasID + } + direction := "output" + if index == 1 { + direction = "input" + } + resolved, _, err := exposeConnectionEndpoint(canvasConnectionEndpoint{Board: board, Node: modelString(node["id"]), Handle: handle}, direction, path, documents) + // Direct frame/icon endpoints are canonical concepts; old AI readers cannot execute them. + if err != nil { + skip = true + break + } + endpoints = append(endpoints, resolved) + } + if skip { + continue + } + data := map[string]any{} + for key, value := range element["data"].(map[string]any) { + data[key] = value + } + if element["path"] != nil { + data["path"] = element["path"] + } + edge := map[string]any{"id": id, "source": endpoints[0].Node, "target": endpoints[1].Node, "sourceHandle": endpoints[0].Handle, "targetHandle": endpoints[1].Handle, "data": data} + documents[owner]["edges"] = append(documents[owner]["edges"].([]any), edge) + } + result := map[string]json.RawMessage{} + for id, document := range documents { + raw, err := json.Marshal(document) + if err != nil { + return nil, err + } + result[id] = raw + } + return result, nil +} diff --git a/internal/platform/canvas_model_stream.go b/internal/platform/canvas_model_stream.go new file mode 100644 index 0000000..8c5cd96 --- /dev/null +++ b/internal/platform/canvas_model_stream.go @@ -0,0 +1,111 @@ +package platform + +import ( + "context" + "encoding/json" + "net/http" +) + +const modelStreamType = "application/vnd.foundry.canvas-model+ndjson;version=1" + +// The caller captures a coherent model while holding the workspace lock, then +// releases its transaction before writing. A slow reader must not hold a writer. +// This streams transfer/encoding; it does not stream database row acquisition. +func writeCanvasModelStream(ctx context.Context, w http.ResponseWriter, model canvasModel) { + token := newModelID() + if token == "" { + reject(w, 500, "model_stream_unavailable") + return + } + view := model.view(nil) + // A count covers direct members only; an empty canvas is explicit too. + counts := map[string]int{} + owners := map[string]string{} + for _, canvas := range view.Canvases { + counts[canvas.ID] = 0 + } + for _, element := range view.Elements { + owner := modelString(element["ownerCanvasId"]) + if _, ok := counts[owner]; !ok { + reject(w, 500, "invalid_model_coverage") + return + } + id := modelString(element["id"]) + if id == "" || owners[id] != "" { + reject(w, 500, "invalid_model_identity") + return + } + owners[id] = owner + counts[owner]++ + } + header := response{"streamVersion": 1, "kind": "header", "sequence": 0, "snapshotToken": token, + "documentFormatVersion": canvasModelFormat, "projectId": model.Root, "canvases": view.Canvases, + "versions": view.Versions, "tombstones": view.Tombstones, "removedCanvasIds": view.Removed, "elementCount": len(view.Elements), "canvasElementCounts": counts, "elementOwners": owners} + const maxLine = 8 * 1024 * 1024 + const maxBytes = 64 * 1024 * 1024 + sequence, total := 0, 0 + started := false + write := func(value response) bool { + if ctx.Err() != nil { + return false + } + raw, err := json.Marshal(value) + if err != nil || len(raw) > maxLine || total+len(raw)+1 > maxBytes || sequence >= 4096 { + if !started { + reject(w, 413, "model_stream_budget") + } + return false + } + if !started { + w.Header().Set("Content-Type", modelStreamType) + w.Header().Set("Cache-Control", "no-store") + w.Header().Add("Vary", "Accept") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + started = true + } + total += len(raw) + 1 + if n, writeErr := w.Write(append(raw, '\n')); writeErr != nil || n != len(raw)+1 { + return false + } + sequence++ + // A missing terminal record is an explicit incomplete stream at the client. + // Never append an unrelated JSON error after streaming has begun. + return http.NewResponseController(w).Flush() == nil + } + if !write(header) { + return + } + batch := []map[string]any{} + batchBytes := 0 + flush := func() bool { + if len(batch) == 0 { + return true + } + if !write(response{"streamVersion": 1, "kind": "elements", "sequence": sequence, "snapshotToken": token, "elements": batch}) { + return false + } + batch, batchBytes = []map[string]any{}, 0 + return true + } + for _, element := range view.Elements { + if ctx.Err() != nil { + return + } + raw, err := json.Marshal(element) + if err != nil { + return + } + if len(batch) > 0 && (len(batch) == 64 || batchBytes+len(raw) > 512*1024) { + if !flush() { + return + } + } + batch = append(batch, element) + batchBytes += len(raw) + } + if !flush() { + return + } + write(response{"streamVersion": 1, "kind": "complete", "sequence": sequence, "snapshotToken": token, "elementCount": len(view.Elements)}) +} diff --git a/internal/platform/canvas_model_stream_test.go b/internal/platform/canvas_model_stream_test.go new file mode 100644 index 0000000..b6dc281 --- /dev/null +++ b/internal/platform/canvas_model_stream_test.go @@ -0,0 +1,137 @@ +package platform + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "testing" +) + +func streamRecords(t *testing.T, writer *httptest.ResponseRecorder) []response { + t.Helper() + var records []response + scanner := bufio.NewScanner(writer.Body) + for scanner.Scan() { + var record response + if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + t.Fatal(err) + } + records = append(records, record) + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + return records +} +func TestCanonicalModelStreamFramesAndSnapshot(t *testing.T) { + model := modelTestDocument() + for i := 0; i < 129; i++ { + id := fmt.Sprintf("extra-%03d", i) + model.Elements[id] = modelTestNode(id, modelRootID) + } + model.Tombstones["deleted"] = 2 + model.TombstoneOwners["deleted"] = modelRootID + writer := httptest.NewRecorder() + writeCanvasModelStream(context.Background(), writer, model) + if writer.Code != 200 || writer.Header().Get("Content-Type") != modelStreamType || !writer.Flushed { + t.Fatal("stream was not negotiated/flushed", writer.Code, writer.Header()) + } + records := streamRecords(t, writer) + if len(records) != 5 || records[0]["kind"] != "header" || records[4]["kind"] != "complete" { + t.Fatal("missing stream framing", len(records)) + } + token := modelString(records[0]["snapshotToken"]) + if !uuidSlugPattern.MatchString(token) || records[0]["projectId"] != modelRootID || !modelEqual(records[0]["versions"], model.Versions) || !modelEqual(records[0]["tombstones"], model.Tombstones) { + t.Fatal("stream header lost snapshot identity") + } + owners := records[0]["elementOwners"].(map[string]any) + if len(owners) != len(model.Elements) { + t.Fatal("identity manifest must cover unloaded elements too") + } + for id, element := range model.Elements { + if owners[id] != element["ownerCanvasId"] { + t.Fatal("identity manifest mixed owners", id) + } + } + seen := map[string]bool{} + for i, record := range records { + if record["sequence"] != float64(i) || record["snapshotToken"] != token || record["streamVersion"] != float64(1) { + t.Fatal("mixed or unordered snapshot") + } + if record["kind"] == "elements" { + items := record["elements"].([]any) + if len(items) == 0 || len(items) > 64 { + t.Fatal("unbounded record batch") + } + for _, item := range items { + element := item.(map[string]any) + id := modelString(element["id"]) + if seen[id] || !modelEqual(element, model.Elements[id]) { + t.Fatal("duplicated or corrupted stream element") + } + seen[id] = true + } + } + } + if len(seen) != len(model.Elements) || records[4]["elementCount"] != float64(len(seen)) { + t.Fatal("terminal coverage mismatch") + } +} + +type cancelStreamWriter struct { + *httptest.ResponseRecorder + cancel context.CancelFunc +} + +func (writer cancelStreamWriter) Flush() { + writer.ResponseRecorder.Flush() + writer.cancel() +} +func TestCanonicalModelStreamCancellationNeverClaimsCompletion(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + writer := httptest.NewRecorder() + writeCanvasModelStream(ctx, cancelStreamWriter{writer, cancel}, modelTestDocument()) + records := streamRecords(t, writer) + if len(records) != 1 || records[0]["kind"] != "header" { + t.Fatal("cancelled stream wrote a tail", len(records)) + } +} + +func TestCanonicalModelStreamDeclaresDirectScopeCountsIncludingEmptyCanvases(t *testing.T) { + model := modelTestDocument() + empty, nested := modelTestID(41), modelTestID(42) + model.Canvases[empty] = modelCanvas{ID: empty, Parent: &modelParent{CanvasID: model.Root, EntryElementID: "frame-empty"}} + model.Canvases[nested] = modelCanvas{ID: nested, Parent: &modelParent{CanvasID: model.Root, EntryElementID: "frame-nested"}} + model.Versions[empty], model.Versions[nested] = 1, 1 + model.Elements["frame-empty"] = modelTestNode("frame-empty", model.Root) + model.Elements["frame-nested"] = modelTestNode("frame-nested", model.Root) + model.Elements["nested-node"] = modelTestNode("nested-node", nested) + writer := httptest.NewRecorder() + writeCanvasModelStream(context.Background(), writer, model) + records := streamRecords(t, writer) + if !modelEqual(records[0]["canvasElementCounts"], map[string]int{model.Root: 3, empty: 0, nested: 1}) { + t.Fatal("scope counts must count direct members, preserve empty scopes and match the captured snapshot") + } + bad := modelTestDocument() + bad.Elements["a"]["ownerCanvasId"] = "foreign" + failed := httptest.NewRecorder() + writeCanvasModelStream(context.Background(), failed, bad) + if failed.Code != 500 || failed.Flushed { + t.Fatal("invalid owner must fail before publishing a scope certificate") + } +} + +func TestCanonicalModelStreamRejectsAmbiguousIdentityManifest(t *testing.T) { + for _, id := range []string{"", "a"} { + model := modelTestDocument() + model.Elements["other"] = modelTestNode(id, modelRootID) + writer := httptest.NewRecorder() + writeCanvasModelStream(context.Background(), writer, model) + if writer.Code != 500 || writer.Flushed { + t.Fatal("ambiguous identity was published before rejection") + } + } +} diff --git a/internal/platform/canvas_model_test.go b/internal/platform/canvas_model_test.go new file mode 100644 index 0000000..0550d2d --- /dev/null +++ b/internal/platform/canvas_model_test.go @@ -0,0 +1,244 @@ +package platform + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "testing" +) + +const modelRootID = "10000000-0000-4000-8000-000000000001" +const modelChildID = "10000000-0000-4000-8000-000000000002" +const modelLegacyFrame = `{"id":"f","type":"frame","position":{"x":100,"y":200},"width":640,"height":400,"data":{"kind":"frame","title":"Partition","text":"","labelMode":"fixed"}}` + +// The schema runner mounts only the test binary, not the source tree. +// +//go:embed testdata/foundry-model-operation-v2.json +var canonicalFrontendFixture []byte + +func modelJSON(value any) json.RawMessage { raw, _ := json.Marshal(value); return raw } +func modelTestID(n int) string { return fmt.Sprintf("90000000-0000-4000-8000-%012d", n) } +func modelTestAllocator() func() string { + next := 0 + return func() string { next++; return modelTestID(next) } +} +func modelTestNode(id, owner string) map[string]any { + return map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": "text", "dataVersion": float64(1), "structure": "node", "data": map[string]any{"title": id, "text": "preserved"}, "geometry": map[string]any{"x": float64(0), "y": float64(0), "width": float64(260), "height": float64(200), "rotation": float64(0)}} +} +func modelTestDocument() canvasModel { + model := emptyModel(modelRootID) + model.Canvases[modelRootID] = modelCanvas{ID: modelRootID} + model.Versions[modelRootID] = 1 + model.Elements["a"] = modelTestNode("a", modelRootID) + return model +} + +func TestCanonicalFramesRetainRotationAcrossOwnerChanges(t *testing.T) { + model := modelTestDocument() + frame := modelTestNode("f", modelRootID) + frame["componentId"] = "frame" + frame["geometry"].(map[string]any)["rotation"] = float64(30) + model.Elements["f"] = frame + model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}} + model.Versions[modelChildID] = 1 + member := modelTestNode("nested-frame", modelChildID) + member["componentId"] = "frame" + model.Elements["nested-frame"] = member + if err := validateCanvasModel(model); err != nil { + t.Fatalf("valid canonical frame rotation rejected: %v", err) + } + geometry := member["geometry"].(map[string]any) + moved := map[string]any{"x": float64(100), "y": float64(80), "width": float64(520), "height": float64(400), "rotation": float64(30)} + after, touched, err := applyModelOperations(model, []modelOperation{{ID: modelTestID(80), Changes: []modelChange{ + {Kind: "field", Target: modelTarget{ID: "nested-frame", Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(modelChildID), After: modelJSON(modelRootID)}, + {Kind: "field", Target: modelTarget{ID: "nested-frame", Incarnation: 1}, Field: "geometry", Before: modelJSON(geometry), After: modelJSON(moved)}, + }}}) + if err != nil || !touched[modelRootID] || !touched[modelChildID] { + t.Fatalf("rotated frame owner change failed: %v", err) + } + if after.Elements["nested-frame"]["ownerCanvasId"] != modelRootID || !modelEqual(after.Elements["nested-frame"]["geometry"], moved) || model.Elements["nested-frame"]["ownerCanvasId"] != modelChildID { + t.Fatal("owner change lost geometry or changed the source model") + } + projection, err := projectCanvasModel(after) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err := json.Unmarshal(projection[modelRootID], &document); err != nil { + t.Fatal(err) + } + rotated := 0 + for _, raw := range document["nodes"].([]any) { + node := raw.(map[string]any) + if node["id"] != "f" && node["id"] != "nested-frame" { + continue + } + rotated++ + if node["data"].(map[string]any)["rotation"] != float64(30) { + t.Fatal("reader projection erased canonical rotation") + } + } + if rotated != 2 { + t.Fatal("reader projection omitted a rotated frame") + } + patched, err := prepareModelAIPatch(after, modelRootID, "a", response{"text": "new text"}) + if err != nil || patched.Elements["f"]["geometry"].(map[string]any)["rotation"] != float64(30) { + t.Fatalf("unrelated canonical AI patch rejected or rewound rotation: %v", err) + } + bad := cloneModel(after) + bad.Elements["f"]["geometry"].(map[string]any)["rotation"] = float64(360) + if validateCanvasModel(bad) == nil { + t.Fatal("out-of-range canonical rotation accepted") + } + legacy := json.RawMessage(`{"schema_version":1,"nodes":[{"id":"f","type":"frame","position":{"x":0,"y":0},"width":640,"height":400,"data":{"kind":"frame","title":"F","text":"","rotation":30}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`) + if _, valid := normalizeCanvas(legacy); valid { + t.Fatal("canonical support changed the legacy snapshot write contract") + } +} + +func TestCanonicalMigrationResolvesCanvasLocalIdentitiesAndPorts(t *testing.T) { + root := `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"plain-text","title":"parent","text":""}},{"id":"f","type":"frame","position":{"x":100,"y":200},"width":640,"height":400,"data":{"kind":"frame","title":"F","text":"","subgraph":{"boardId":"` + modelChildID + `"},"ports":[{"id":"p","nodeId":"a","handle":"right","direction":"output"}]}}],"edges":[{"id":"e","source":"f","sourceHandle":"port:p","target":"a","targetHandle":"left"}],"viewport":{"x":0,"y":0,"zoom":1}}` + child := `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":10,"y":20},"width":240,"height":72,"data":{"kind":"plain-text","title":"child","text":""}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}` + snapshots := map[string]canvasSnapshot{modelRootID: {BoardID: modelRootID, Version: 3, Document: json.RawMessage(root)}, modelChildID: {BoardID: modelChildID, Version: 7, Document: json.RawMessage(child)}} + result, err := migrateCanvasModel(modelRootID, snapshots, []subgraphRelation{{Parent: modelRootID, Frame: "f", Child: modelChildID}}, modelTestAllocator()) + if err != nil { + t.Fatal(err) + } + ids := map[string]string{} + for _, mapping := range result.Identities { + ids[mapping.SourceCanvasID+"/"+mapping.Structure+"/"+mapping.SourceID] = mapping.ElementID + } + parentA, childA := ids[modelRootID+"/node/a"], ids[modelChildID+"/node/a"] + if parentA == childA || parentA == "" || childA == "" { + t.Fatal("canvas-local node IDs were merged") + } + edge := result.Model.Elements[ids[modelRootID+"/connection/e"]] + if edge["source"].(map[string]any)["elementId"] != childA || edge["target"].(map[string]any)["elementId"] != parentA || edge["ownerCanvasId"] != modelRootID { + t.Fatal("logical endpoint or common ancestor owner was lost") + } + if result.Model.Canvases[modelChildID].Parent.EntryElementID != ids[modelRootID+"/node/f"] || result.Model.Versions[modelRootID] != 4 || result.Model.Versions[modelChildID] != 8 { + t.Fatal("migration did not publish an identity/version barrier") + } + if string(snapshots[modelRootID].Document) != root || string(snapshots[modelChildID].Document) != child { + t.Fatal("migration mutated source snapshots") + } + projection, err := projectCanvasModel(result.Model) + if err != nil { + t.Fatal(err) + } + for _, raw := range projection { + if _, valid := normalizeCanvas(raw); !valid { + t.Fatalf("invalid compatibility projection: %s", raw) + } + } +} +func TestCanonicalMigrationTurnsImplicitFramesIntoOwningCanvases(t *testing.T) { + root := `{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"","frameId":"f"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}` + result, err := migrateCanvasModel(modelRootID, map[string]canvasSnapshot{modelRootID: {BoardID: modelRootID, Version: 1, Document: json.RawMessage(root)}}, nil, modelTestAllocator()) + if err != nil { + t.Fatal(err) + } + if len(result.NewCanvases) != 1 || len(result.Model.Canvases) != 2 { + t.Fatal("legacy partition was not given a canvas") + } + var node map[string]any + for _, mapping := range result.Identities { + if mapping.SourceID == "a" { + node = result.Model.Elements[mapping.ElementID] + } + } + geometry := node["geometry"].(map[string]any) + if node["ownerCanvasId"] != result.NewCanvases[0] || geometry["x"] != float64(26) || geometry["y"] != float64(24) { + t.Fatal("membership or owner-local geometry changed") + } + if _, exists := node["data"].(map[string]any)["frameId"]; exists { + t.Fatal("second ownership fact leaked into canonical data") + } +} +func TestCanonicalOperationsPreserveOpaquePayloadAndStableMoveIdentity(t *testing.T) { + model := modelTestDocument() + frame := modelTestNode("f", modelRootID) + frame["componentId"] = "frame" + model.Elements["f"] = frame + model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}} + model.Versions[modelChildID] = 1 + future := modelTestNode("future", modelRootID) + future["componentId"], future["dataVersion"], future["data"], future["futureEnvelope"] = "plugin.future", float64(42), map[string]any{"nested": map[string]any{"list": []any{"x", float64(2)}}}, map[string]any{"retain": true} + future["data"].(map[string]any)["constructor"] = map[string]any{"retain": true} + future["data"].(map[string]any)["__proto__"] = map[string]any{"ordinary": "JSON data"} + model.Elements["future"] = future + op := modelOperation{ID: modelTestID(1), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(modelRootID), After: modelJSON(modelChildID)}}} + after, touched, err := applyModelOperations(model, []modelOperation{op}) + if err != nil { + t.Fatal(err) + } + if after.Elements["a"]["id"] != "a" || after.Elements["a"]["ownerCanvasId"] != modelChildID || !touched[modelRootID] || !touched[modelChildID] { + t.Fatal("move changed identity or omitted a participant") + } + if !modelEqual(after.Elements["future"], future) || model.Elements["a"]["ownerCanvasId"] != modelRootID { + t.Fatal("opaque payload or old snapshot was mutated") + } +} +func TestCanonicalDeleteRestoreRejectsOldIncarnation(t *testing.T) { + model := modelTestDocument() + deleted, _, err := applyModelOperations(model, []modelOperation{{ID: modelTestID(1), Changes: []modelChange{{Kind: "element", ID: "a", Before: modelJSON(model.Elements["a"]), After: modelJSON(nil)}}}}) + if err != nil { + t.Fatal(err) + } + if deleted.Tombstones["a"] != 1 { + t.Fatal("missing tombstone") + } + restored := modelTestNode("a", modelRootID) + restored["incarnation"] = float64(2) + live, _, err := applyModelOperations(deleted, []modelOperation{{ID: modelTestID(2), Changes: []modelChange{{Kind: "element", ID: "a", Before: modelJSON(nil), After: modelJSON(restored)}}}}) + if err != nil { + t.Fatal(err) + } + _, _, err = applyModelOperations(live, []modelOperation{{ID: modelTestID(3), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("stale")}}}}) + if err != errModelConflict || live.Elements["a"]["data"].(map[string]any)["title"] != "a" { + t.Fatal("old callback claimed the restored incarnation") + } +} +func TestCanonicalValidationRejectsPartialChangesAndWrongEdgeOwner(t *testing.T) { + model := modelTestDocument() + before := modelJSON(model) + operations := []modelOperation{{ID: modelTestID(1), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("changed")}, {Kind: "field", Target: modelTarget{ID: "missing", Incarnation: 1}, Field: "geometry", Before: modelJSON(nil), After: modelJSON(nil)}}}} + if _, _, err := applyModelOperations(model, operations); err == nil { + t.Fatal("invalid second change was accepted") + } + if string(modelJSON(model)) != string(before) { + t.Fatal("failed batch changed the source") + } + frame := modelTestNode("f", modelRootID) + frame["componentId"] = "frame" + model.Elements["f"] = frame + model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}} + model.Versions[modelChildID] = 1 + model.Elements["child"] = modelTestNode("child", modelChildID) + model.Elements["e"] = map[string]any{"id": "e", "ownerCanvasId": modelChildID, "incarnation": float64(1), "componentId": "connection", "dataVersion": float64(1), "structure": "connection", "data": map[string]any{}, "source": map[string]any{"elementId": "a"}, "target": map[string]any{"elementId": "child"}, "path": nil} + if validateCanvasModel(model) == nil { + t.Fatal("cross-child connection acquired a child owner") + } +} + +func TestCanonicalFrontendProtocolFixture(t *testing.T) { + var input modelBatch + decoder := json.NewDecoder(bytes.NewReader(canonicalFrontendFixture)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + t.Fatal(err) + } + if input.ProtocolVersion != canvasModelProtocol || !uuidSlugPattern.MatchString(input.RequestID) || input.Expected[modelRootID] != 3 { + t.Fatal("frontend fixture envelope is incompatible") + } + before := modelTestDocument() + before.Versions[modelRootID] = 3 + after, touched, err := applyModelOperations(before, input.Operations) + if err != nil { + t.Fatal(err) + } + if !touched[modelRootID] || after.Versions[modelRootID] != 4 || after.Elements["a"]["data"].(map[string]any)["title"] != "renamed" || after.Elements["a"]["data"].(map[string]any)["text"] != "preserved" { + t.Fatal("frontend fixture did not apply exactly its intended field change") + } +} diff --git a/internal/platform/canvas_operations.go b/internal/platform/canvas_operations.go new file mode 100644 index 0000000..321255b --- /dev/null +++ b/internal/platform/canvas_operations.go @@ -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 +} diff --git a/internal/platform/canvas_operations_http.go b/internal/platform/canvas_operations_http.go new file mode 100644 index 0000000..77a2092 --- /dev/null +++ b/internal/platform/canvas_operations_http.go @@ -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 +} diff --git a/internal/platform/canvas_operations_integration_test.go b/internal/platform/canvas_operations_integration_test.go new file mode 100644 index 0000000..fefa3ed --- /dev/null +++ b/internal/platform/canvas_operations_integration_test.go @@ -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") + } +} diff --git a/internal/platform/canvas_operations_test.go b/internal/platform/canvas_operations_test.go new file mode 100644 index 0000000..9aac2c6 --- /dev/null +++ b/internal/platform/canvas_operations_test.go @@ -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) + } +} diff --git a/internal/platform/http.go b/internal/platform/http.go index 9792af4..d963638 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") + 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 } @@ -88,6 +88,10 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H account := session.AccountID if err == nil { auth.WriteSessionHeaders(w, session) + if expected := r.Header.Get("X-Expected-Account"); expected != "" && expected != account { + reject(w, 409, "identity_changed") + return + } } if err == nil && r.PathValue("workspace") != "" { var id string @@ -103,12 +107,21 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H r.SetPathValue("material", id) } } + if err == nil { + var stopped bool + stopped, err = s.guardLegacyCanonicalRequest(w, r, project, account) + if stopped { + return + } + } if err == nil { err = handle(w, r, project, account) } if err != nil { var pg *pgconn.PgError switch { + case errors.As(err, &pg) && pg.Code == "55000" && pg.Message == "canonical_write_required": + reject(w, 409, "canonical_write_required") case errors.Is(err, errSubgraphCycle): reject(w, 409, "subgraph_cycle") case errors.Is(err, errSubgraphDepth): @@ -137,16 +150,25 @@ 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/metadata/{asset}", s.protected(callbacks, s.getAssetMetadata)) 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)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/models", s.protected(callbacks, s.canvasAIModels)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/operations/{request}", s.protected(callbacks, s.getCanvasAIAdmission)) mux.HandleFunc("POST /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs", s.protected(callbacks, s.createCanvasAIRun)) mux.HandleFunc("GET /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs", s.protected(callbacks, s.listCanvasAIRuns)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}", s.protected(callbacks, s.getCanvasAIRun)) 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("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations", s.protected(callbacks, s.submitCanvasOperations)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations/{request}", s.protected(callbacks, s.getCanvasOperation)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model", s.protected(callbacks, s.getCanvasModel)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model/migrate", s.protected(callbacks, s.migrateCanonicalCanvas)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model/operations", s.protected(callbacks, s.submitCanvasModelOperations)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model/operations/{request}", s.protected(callbacks, s.getCanvasModelOperation)) 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)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index f481e9c..42a9535 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) @@ -554,9 +584,36 @@ func TestOnboardingAndSharing(t *testing.T) { t.Fatal("preview changed small original or privacy headers") } } + // Metadata discloses the same immutable identity only after a fresh read permission check. + metadataPath := workspacePath + "/assets/metadata/" + uploaded["id"].(string) + for _, sample := range []struct { + token string + status int + }{{tokens[0], 200}, {tokens[1], 404}, {tokens[2], 404}} { + request := httptest.NewRequest("GET", metadataPath, nil) + request.Header.Set("Authorization", "Bearer "+sample.token) + result := httptest.NewRecorder() + mux.ServeHTTP(result, request) + if result.Code != sample.status { + t.Fatalf("metadata permission got %d want %d: %s", result.Code, sample.status, result.Body.String()) + } + if result.Code == 200 { + var metadata response + if err := json.Unmarshal(result.Body.Bytes(), &metadata); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(assetPNG.Bytes()) + if metadata["metadata_version"] != float64(1) || metadata["sha256"] != hex.EncodeToString(digest[:]) || !modelEqual(metadata["asset"], uploaded) { + t.Fatalf("invalid metadata identity: %s", result.Body.String()) + } + if result.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("invalid metadata privacy header: %q", result.Header().Get("Cache-Control")) + } + } + } // Neither a snapshot nor an asset response may keep the workspace locked // while a slow downstream consumer is accepting its body. - for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string)} { + for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string), metadataPath} { var probeErr error writer := &readLockProbe{ResponseRecorder: httptest.NewRecorder(), probe: func() { lockTx, e := db.BeginTx(ctx, nil) @@ -649,6 +706,62 @@ func TestOnboardingAndSharing(t *testing.T) { t.Fatalf("stable URL lost child asset binding: %v count=%d", err, managedBindings) } call("POST", assetCanvasPath+"/subgraphs/asset-frame/dissolve", tokens[0], response{"expected_versions": map[string]int64{assetBoard["id"].(string): 5, assetChild: 2}}, 200, "", accept, "40000000-0000-4000-8000-000000000003") + // A warm metadata/original cache is not a future permission grant. An external + // collaborator can read through a source board grant, but a new canonical + // target write must recheck asset readability after that grant is revoked. + sourceGrantPath := workspacePath + "/materials/" + assetBoard["id"].(string) + "/grants" + call("POST", sourceGrantPath, tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + call("GET", metadataPath, tokens[2], nil, 200) + download(tokens[2], 200) + targetBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Asset adoption target", "content": response{}}, 201) + targetID := targetBoard["id"].(string) + targetPath := workspacePath + "/materials/" + targetID + targetGrantPath := targetPath + "/grants" + call("POST", targetGrantPath, tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204) + migrationID := modelTestID(9200) + call("POST", targetPath+"/canvas/model/migrate", tokens[0], response{"protocol_version": 2, "request_id": migrationID, "expected_versions": map[string]int64{targetID: 1}}, 200, "", accept, migrationID) + targetModel := call("GET", targetPath+"/canvas/model", tokens[2], nil, 200)["model"].(map[string]any) + targetVersion := int64(targetModel["versions"].(map[string]any)[targetID].(float64)) + adoptedElement := modelTestNode(modelTestID(9201), targetID) + adoptedElement["componentId"] = "image" + adoptedElement["data"] = map[string]any{"title": "Adopted", "text": "", "assetId": uploaded["id"], "src": uploaded["url"]} + adoptionRequest := func(requestID, operationID string) modelBatch { + return modelBatch{ProtocolVersion: 2, RequestID: requestID, Expected: map[string]int64{targetID: targetVersion}, Operations: []modelOperation{{ID: operationID, Changes: []modelChange{{Kind: "element", ID: modelString(adoptedElement["id"]), Before: modelJSON(nil), After: modelJSON(adoptedElement)}}}}} + } + call("DELETE", sourceGrantPath+"/"+accounts[2], tokens[0], nil, 204) + call("GET", metadataPath, tokens[2], nil, 404) + download(tokens[2], 404) + deniedID := modelTestID(9202) + deniedBody := adoptionRequest(deniedID, modelTestID(9203)) + denied := call("POST", targetPath+"/canvas/model/operations", tokens[2], deniedBody, 400, "", accept, deniedID) + if denied["status"] != "not-applied" || denied["terminal"] != true || denied["error"] != "invalid_asset_reference" { + t.Fatalf("revoked source did not yield a definitive no-write receipt: %v", denied) + } + unchanged := call("GET", targetPath+"/canvas/model", tokens[2], nil, 200)["model"].(map[string]any) + if !modelEqual(unchanged, targetModel) { + t.Fatal("revoked-source adoption changed target model or versions") + } + var targetBindings int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM canvas_asset_bindings WHERE project=$1 AND asset_id=$2 AND board_id=$3", project, uploaded["id"], targetID).Scan(&targetBindings); err != nil || targetBindings != 0 { + t.Fatalf("rejected adoption left a target binding: %d %v", targetBindings, err) + } + // Restoring source access does not change the frozen rejected request's result. + call("POST", sourceGrantPath, tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + call("GET", metadataPath, tokens[2], nil, 200) + replayedDenial := call("POST", targetPath+"/canvas/model/operations", tokens[2], deniedBody, 400, "", accept, deniedID) + if !modelEqual(denied, replayedDenial) { + t.Fatal("permission restoration changed a terminal receipt") + } + acceptedID := modelTestID(9204) + call("POST", targetPath+"/canvas/model/operations", tokens[2], adoptionRequest(acceptedID, modelTestID(9205)), 200, "", accept, acceptedID) + call("DELETE", sourceGrantPath+"/"+accounts[2], tokens[0], nil, 204) + // The independently authorized target now owns a binding; source revocation + // must not revoke a resource still readable through that target. + call("GET", metadataPath, tokens[2], nil, 200) + download(tokens[2], 200) + call("DELETE", targetGrantPath+"/"+accounts[2], tokens[0], nil, 204) + call("GET", metadataPath, tokens[2], nil, 404) + download(tokens[2], 404) download(tokens[1], 200) upload([]byte("not an image"), "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 400) diff --git a/internal/platform/subgraph_migration_http.go b/internal/platform/subgraph_migration_http.go index 555155b..639b085 100644 --- a/internal/platform/subgraph_migration_http.go +++ b/internal/platform/subgraph_migration_http.go @@ -44,14 +44,22 @@ func (s *service) saveCanvas(r *http.Request, tx *sql.Tx, project, workspace str if err := tx.QueryRowContext(r.Context(), `SELECT COALESCE(canvas_document,'{"schema_version":1,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 FOR UPDATE`, project, workspace, snapshot.BoardID).Scan(&before); err != nil { return err } - if err := invalidateCanvasAITargets(r, tx, project, workspace, snapshot.BoardID, before, snapshot.Document); err != nil { + root, err := canonicalRoot(r.Context(), tx, project, workspace, snapshot.BoardID) + if err != nil { return err } + // Canonical lifecycle invalidation runs once against normalized identities. + // Its compatibility projection must not mistake an owner move for deletion. + if root == "" { + if err := invalidateCanvasAITargets(r, tx, project, workspace, snapshot.BoardID, before, snapshot.Document); err != nil { + return err + } + } if err := s.attachCanvasAssets(r, tx, project, workspace, snapshot.BoardID, snapshot.Document); err != nil { return err } - _, err := tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=$5 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, snapshot.BoardID, snapshot.Document, snapshot.Version) + _, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=$5 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, snapshot.BoardID, snapshot.Document, snapshot.Version) return err } diff --git a/internal/platform/testdata/foundry-model-operation-v2.json b/internal/platform/testdata/foundry-model-operation-v2.json new file mode 100644 index 0000000..165b8ea --- /dev/null +++ b/internal/platform/testdata/foundry-model-operation-v2.json @@ -0,0 +1,26 @@ +{ + "protocol_version": 2, + "request_id": "90000000-0000-4000-8000-000000000002", + "expected_versions": { + "10000000-0000-4000-8000-000000000001": 3 + }, + "operations": [ + { + "id": "90000000-0000-4000-8000-000000000001", + "changes": [ + { + "kind": "field", + "target": { + "id": "a", + "incarnation": 1 + }, + "field": "data.title", + "before": "a", + "after": "renamed", + "beforePresent": true, + "afterPresent": true + } + ] + } + ] +}