Merge pull request 'Add canonical canvas edits, identity migration and idempotent resource/AI operations' (#19) from codex/headless-canvas-v1 into main
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
+106
-4
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const canvasOperationsAction = "headless.operations.v1"
|
||||
|
||||
func (s *service) submitCanvasOperations(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodPost {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
if !acceptsCanvasV2(r) {
|
||||
reject(w, 409, "canvas_schema_upgrade_required")
|
||||
return nil
|
||||
}
|
||||
key := canvasIdempotencyKey(r)
|
||||
if !uuidSlugPattern.MatchString(key) {
|
||||
reject(w, 400, "invalid_idempotency_key")
|
||||
return nil
|
||||
}
|
||||
var input canvasOperationBatch
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, canvasLimit))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF || input.ProtocolVersion != canvasOperationsProtocol || input.RequestID != key || len(input.Operations) == 0 || len(input.Operations) > 2500 || len(input.ExpectedVersions) == 0 || len(input.ExpectedVersions) > 64 {
|
||||
reject(w, 400, "invalid_canvas_operation_batch")
|
||||
return nil
|
||||
}
|
||||
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
||||
if _, ok := input.ExpectedVersions[board]; !ok {
|
||||
reject(w, 428, "version_required")
|
||||
return nil
|
||||
}
|
||||
participants := make([]string, 0, len(input.ExpectedVersions))
|
||||
for id, version := range input.ExpectedVersions {
|
||||
if !uuidSlugPattern.MatchString(id) || version < 1 {
|
||||
reject(w, 400, "invalid_canvas_operation_batch")
|
||||
return nil
|
||||
}
|
||||
participants = append(participants, id)
|
||||
}
|
||||
for _, operation := range input.Operations {
|
||||
if _, ok := input.ExpectedVersions[operation.CanvasID]; !ok {
|
||||
reject(w, 428, "version_required")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
sort.Strings(participants)
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// lockMaterial serializes the workspace; sorted participants preserve a stable lock order.
|
||||
for _, id := range participants {
|
||||
kind, role, e := lockMaterial(r.Context(), tx, project, workspace, id, account)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if kind != "board" {
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Resolve the same request before comparing expected versions: exact retry after lost ACK succeeds.
|
||||
done, err := transactionRetry(w, r, tx, project, account, board, canvasOperationsAction, key, payloadDigest(input))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
before := make([]canvasSnapshot, 0, len(participants))
|
||||
initial := map[string]canvasSnapshot{}
|
||||
for _, id := range participants {
|
||||
snapshot, e := loadCanvas(r, tx, project, workspace, id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if snapshot.Version != input.ExpectedVersions[id] {
|
||||
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 412, response{"error": "version_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
|
||||
}
|
||||
initial[id] = snapshot
|
||||
before = append(before, snapshot)
|
||||
}
|
||||
staged, err := applyCanvasOperations(initial, input.Operations)
|
||||
if err != nil {
|
||||
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_canvas_operation", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
|
||||
}
|
||||
snapshots := make([]canvasSnapshot, 0, len(participants))
|
||||
versions := map[string]int64{}
|
||||
// Validate all participant documents before writing any of them.
|
||||
for _, id := range participants {
|
||||
snapshot := staged[id]
|
||||
valid, e := validateCanvasRelations(r, tx, project, workspace, id, initial[id].Document, snapshot.Document)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !valid {
|
||||
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 409, response{"error": "ownership_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
|
||||
}
|
||||
if !s.validateCanvasAIModels(w, r, snapshot.Document) {
|
||||
return nil
|
||||
}
|
||||
valid, e = s.validateCanvasAssets(r, tx, project, workspace, account, snapshot.Document)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !valid {
|
||||
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_asset_reference", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
|
||||
}
|
||||
snapshots = append(snapshots, snapshot)
|
||||
versions[id] = snapshot.Version
|
||||
}
|
||||
for _, snapshot := range snapshots {
|
||||
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
operationIDs := make([]string, 0, len(input.Operations))
|
||||
for _, operation := range input.Operations {
|
||||
operationIDs = append(operationIDs, operation.ID)
|
||||
}
|
||||
body := response{"protocol_version": canvasOperationsProtocol, "status": "applied", "request_id": key, "operation_ids": operationIDs, "snapshots": snapshots, "applied_versions": versions, "parent": staged[board]}
|
||||
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 200, body, before, versions)
|
||||
}
|
||||
|
||||
// A terminal rejection also reserves the frozen key/body. Losing that response must
|
||||
// not turn a definite zero-write result into a future execution of the same request.
|
||||
func commitCanvasOperationReceipt(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, account, board string, input canvasOperationBatch, status int, body response, before []canvasSnapshot, versions map[string]int64) error {
|
||||
var transactionID string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&transactionID); err != nil {
|
||||
return err
|
||||
}
|
||||
body["transaction_id"] = transactionID
|
||||
body["protocol_version"] = canvasOperationsProtocol
|
||||
expires := time.Now().Add(7 * 24 * time.Hour)
|
||||
bodyRaw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeRaw, err := json.Marshal(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versionsRaw, err := json.Marshal(versions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, project, r.PathValue("workspace"), board, transactionID, account, canvasOperationsAction, input.RequestID, payloadDigest(input), status, bodyRaw, beforeRaw, versionsRaw, expires)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if version := versions[board]; version > 0 {
|
||||
setVersion(w, version)
|
||||
}
|
||||
reply(w, status, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) getCanvasOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodGet {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
key := r.PathValue("request")
|
||||
if !uuidSlugPattern.MatchString(key) {
|
||||
reject(w, 400, "invalid_request_id")
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
||||
if _, _, err = lockMaterial(r.Context(), tx, project, workspace, board, account); err != nil {
|
||||
return err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_transactions WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND action=$5 AND idempotency_key=$6`, project, workspace, board, account, canvasOperationsAction, key).Scan(&raw)
|
||||
if err == sql.ErrNoRows {
|
||||
reply(w, 404, response{"status": "unknown", "error": "operation_receipt_unknown", "request_id": key})
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var result struct {
|
||||
Snapshots []canvasSnapshot `json:"snapshots"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, snapshot := range result.Snapshots {
|
||||
if _, _, err = lockMaterial(r.Context(), tx, project, workspace, snapshot.BoardID, account); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 200, raw)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanvasOperationsTransactions(t *testing.T) {
|
||||
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires an isolated integration database")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
var entropy [12]byte
|
||||
_, _ = rand.Read(entropy[:])
|
||||
project := "operations-" + hex.EncodeToString(entropy[:])
|
||||
defer func() {
|
||||
for _, table := range []string{"canvas_transactions", "workspaces", "project_accounts"} {
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project)
|
||||
}
|
||||
}()
|
||||
accounts, tokens := []string{}, []string{}
|
||||
for range 2 {
|
||||
var account string
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text`, project).Scan(&account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value [32]byte
|
||||
_, _ = rand.Read(value[:])
|
||||
token := base64.RawURLEncoding.EncodeToString(value[:])
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, base64.RawURLEncoding.EncodeToString(hash[:]), project, account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, tokens = append(accounts, account), append(tokens, token)
|
||||
}
|
||||
var workspace string
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Operations',$2) RETURNING id::text`, project, accounts[0]).Scan(&workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, role := range []string{"admin", "viewer"} {
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4)`, project, workspace, accounts[i], role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
boards := []string{}
|
||||
for range 2 {
|
||||
var board string
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Board',$3,$4) RETURNING id::text`, project, workspace, accounts[0], json.RawMessage(operationsFixture)).Scan(&board); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boards = append(boards, board)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, db, map[string]string{project: "https://app.example.com/auth/callback"}, "https://api.example.com", nil)
|
||||
path := "/projects/" + project + "/workspaces/" + workspace + "/materials/" + boards[0] + "/canvas/operations"
|
||||
key := func(n int) string { return fmt.Sprintf("a0000000-0000-4000-8000-%012d", n) }
|
||||
do := func(method, path, token string, input canvasOperationBatch) *httptest.ResponseRecorder {
|
||||
encoded, _ := json.Marshal(input)
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(encoded))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2")
|
||||
request.Header.Set("Idempotency-Key", input.RequestID)
|
||||
writer := httptest.NewRecorder()
|
||||
mux.ServeHTTP(writer, request)
|
||||
return writer
|
||||
}
|
||||
call := func(method, path, token string, input canvasOperationBatch, status int) response {
|
||||
t.Helper()
|
||||
writer := do(method, path, token, input)
|
||||
if writer.Code != status {
|
||||
t.Fatalf("%s %s got %d %s want %d", method, path, writer.Code, writer.Body.String(), status)
|
||||
}
|
||||
out := response{}
|
||||
if err := json.Unmarshal(writer.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
version := func(board string) int64 {
|
||||
t.Helper()
|
||||
var value int64
|
||||
if err := db.QueryRowContext(ctx, `SELECT canvas_version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, board).Scan(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
batch := func(n int, expected int64) canvasOperationBatch {
|
||||
return canvasOperationBatch{ProtocolVersion: 1, RequestID: key(n), ExpectedVersions: map[string]int64{boards[0]: expected}, Operations: []canvasOperation{{ID: key(n + 100), Type: "node.patch", CanvasID: boards[0], ElementID: "a", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"renamed"}`)}}}}
|
||||
}
|
||||
firstBody := batch(1, 1)
|
||||
first := call("POST", path, tokens[0], firstBody, 200)
|
||||
retried := call("POST", path, tokens[0], firstBody, 200)
|
||||
if first["transaction_id"] != retried["transaction_id"] || version(boards[0]) != 2 {
|
||||
t.Fatal("retry duplicated writes or changed receipt identity")
|
||||
}
|
||||
queried := call("GET", path+"/"+key(1), tokens[0], canvasOperationBatch{}, 200)
|
||||
if queried["status"] != "applied" || queried["transaction_id"] != first["transaction_id"] {
|
||||
t.Fatal("lost acknowledgment cannot be resolved by request identity")
|
||||
}
|
||||
altered := batch(1, 2)
|
||||
call("POST", path, tokens[0], altered, 409)
|
||||
call("GET", path+"/"+key(1), tokens[1], canvasOperationBatch{}, 404)
|
||||
call("POST", path, tokens[1], batch(2, 2), 403)
|
||||
unknown := call("GET", path+"/"+key(999), tokens[0], canvasOperationBatch{}, 404)
|
||||
if unknown["status"] != "unknown" || unknown["terminal"] == true {
|
||||
t.Fatal("missing receipt must not claim terminal zero writes")
|
||||
}
|
||||
// Terminal zero-write responses must remain queryable and reserve their frozen body.
|
||||
stale := batch(3, 1)
|
||||
rejected := call("POST", path, tokens[0], stale, 412)
|
||||
proof := call("GET", path+"/"+key(3), tokens[0], canvasOperationBatch{}, 200)
|
||||
if proof["status"] != "not-applied" || proof["terminal"] != true || proof["transaction_id"] != rejected["transaction_id"] {
|
||||
t.Fatal("terminal rejection was not persisted")
|
||||
}
|
||||
call("POST", path, tokens[0], batch(3, 2), 409)
|
||||
if version(boards[0]) != 2 {
|
||||
t.Fatal("rejected request performed a write")
|
||||
}
|
||||
// Concurrent exact retries serialize to one transaction and one version increment.
|
||||
concurrent := batch(4, 2)
|
||||
var wait sync.WaitGroup
|
||||
results := make(chan *httptest.ResponseRecorder, 4)
|
||||
for range 4 {
|
||||
wait.Add(1)
|
||||
go func() { defer wait.Done(); results <- do("POST", path, tokens[0], concurrent) }()
|
||||
}
|
||||
wait.Wait()
|
||||
close(results)
|
||||
for result := range results {
|
||||
if result.Code != 200 {
|
||||
t.Fatalf("concurrent retry got %d %s", result.Code, result.Body.String())
|
||||
}
|
||||
}
|
||||
if version(boards[0]) != 3 {
|
||||
t.Fatal("concurrent request executed more than once")
|
||||
}
|
||||
// A valid first participant and an invalid second participant produce no partial write.
|
||||
atomic := batch(5, 3)
|
||||
atomic.ExpectedVersions[boards[1]] = 1
|
||||
atomic.Operations = append(atomic.Operations, canvasOperation{ID: key(500), Type: "node.delete", CanvasID: boards[1], ElementID: "missing"})
|
||||
call("POST", path, tokens[0], atomic, 400)
|
||||
if version(boards[0]) != 3 || version(boards[1]) != 1 {
|
||||
t.Fatal("invalid multi-canvas batch partially committed")
|
||||
}
|
||||
if call("GET", path+"/"+key(5), tokens[0], canvasOperationBatch{}, 200)["status"] != "not-applied" {
|
||||
t.Fatal("invalid batch lost its terminal receipt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const operationsFixture = `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"plain-text","title":"original","text":"hello"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
|
||||
|
||||
func TestCanvasOperationBatchStagesAndMerges(t *testing.T) {
|
||||
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 7, Document: json.RawMessage(operationsFixture)}}
|
||||
operations := []canvasOperation{
|
||||
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"renamed"}`)}},
|
||||
{ID: "22222222-2222-4222-8222-222222222222", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"position": json.RawMessage(`{"x":90,"y":20}`)}},
|
||||
}
|
||||
result, err := applyCanvasOperations(initial, operations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["board"].Version != 8 {
|
||||
t.Fatal("version did not advance once")
|
||||
}
|
||||
var document struct {
|
||||
Nodes []struct {
|
||||
Position struct{ X float64 }
|
||||
Data struct {
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = json.Unmarshal(result["board"].Document, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document.Nodes[0].Data.Title != "renamed" || document.Nodes[0].Data.Text != "hello" || document.Nodes[0].Position.X != 90 {
|
||||
t.Fatal("field merge lost unrelated content")
|
||||
}
|
||||
if string(initial["board"].Document) != operationsFixture {
|
||||
t.Fatal("input snapshot mutated")
|
||||
}
|
||||
}
|
||||
func TestCanvasOperationBatchRejectsWithoutPartialOutput(t *testing.T) {
|
||||
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 1, Document: json.RawMessage(operationsFixture)}}
|
||||
for _, operation := range []canvasOperation{
|
||||
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"id": json.RawMessage(`"stolen"`)}},
|
||||
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "missing", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"no"}`)}},
|
||||
{ID: "11111111-1111-4111-8111-111111111111", Type: "edge.create", CanvasID: "board", ElementID: "edge", Record: json.RawMessage(`{"id":"edge","source":"a","target":"missing"}`)},
|
||||
} {
|
||||
if result, err := applyCanvasOperations(initial, []canvasOperation{operation}); err == nil || result != nil {
|
||||
t.Fatal("invalid batch partially accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestCanvasOperationBatchValidatesFinalGraphInsteadOfIntermediateState(t *testing.T) {
|
||||
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 1, Document: json.RawMessage(operationsFixture)}}
|
||||
operations := []canvasOperation{
|
||||
{ID: "11111111-1111-4111-8111-111111111111", Type: "edge.create", CanvasID: "board", ElementID: "edge", Record: json.RawMessage(`{"id":"edge","source":"a","target":"b"}`)},
|
||||
{ID: "22222222-2222-4222-8222-222222222222", Type: "node.create", CanvasID: "board", ElementID: "b", Record: json.RawMessage(`{"id":"b","type":"plainText","position":{"x":20,"y":20},"width":240,"height":72,"data":{"kind":"plain-text","title":"b","text":""}}`)},
|
||||
}
|
||||
if _, err := applyCanvasOperations(initial, operations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user