Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 506f778ac3 | |||
| 36d69133b8 | |||
| 5db2bf3c98 | |||
| 3268c57bc6 | |||
| d00388504c | |||
| cc3068ace3 | |||
| 1605b5a9d6 | |||
| ca01ea3ea1 | |||
| 8395baa24f | |||
| daec39ead4 | |||
| 121ce7496c | |||
| c8f3940d26 | |||
| da6a5f8207 | |||
| 6131fb5d7b | |||
| 28ca98a506 | |||
| 95812c3a96 | |||
| efa916060a | |||
| cc2ca05cd4 | |||
| be2014cc31 | |||
| 6b07c19728 | |||
| 6a0a2e1912 | |||
| 7d7408a0ec | |||
| ee04a6372b | |||
| d6e877712a | |||
| 6d1a5b5c83 | |||
| 9cf6e4cedf | |||
| ad6ab30b5f | |||
| 789edcc61b | |||
| e38254821a | |||
| ff88865e91 | |||
| fb1bf4ab85 | |||
| ca90b3fea3 | |||
| 31258b995f | |||
| 24b27dcac4 | |||
| d6690c3764 | |||
| c9bb38454d | |||
| 2d4aefbdc0 | |||
| 56b6181598 | |||
| cdf48f1691 | |||
| f12c4ad8bd | |||
| 00ff738705 | |||
| fa76773efc | |||
| 0d7fc4dbe5 | |||
| bf8763e0de | |||
| 4a89137e38 | |||
| b7499e3815 | |||
| 12458e10dc | |||
| b85e8bd2ca | |||
| 19884b2a90 | |||
| 9f01aa3db8 | |||
| 026ee22c1e | |||
| 142c7b2d86 | |||
| 9dd166d5de | |||
| 1363792bb0 | |||
| 913bf4292b | |||
| 028e453d92 | |||
| 81cf7d9448 | |||
| 2207c85ca6 | |||
| 09c78d3ab8 | |||
| b2ae48e250 | |||
| 8abc79dc5b | |||
| 67765c17f4 | |||
| 630ed42d53 |
@@ -16,3 +16,10 @@ SMTP_FROM=noreply@notice.entroia.com
|
||||
# Optional Sign in with Slack.
|
||||
SLACK_CLIENT_ID=replace-me
|
||||
SLACK_CLIENT_SECRET=replace-me
|
||||
|
||||
# Optional shared paper corpus; enable only after PostgreSQL publication.
|
||||
# Store the real read-only connection string in the protected server research.env.
|
||||
FOUNDRY_RESEARCH_DATABASE_URL=
|
||||
FOUNDRY_RESEARCH_SCHEMA=research
|
||||
# Deployment migration container; independent of database/schema names.
|
||||
FOUNDRY_POSTGRES_CONTAINER=foundry-postgres
|
||||
|
||||
@@ -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,12 +69,24 @@ 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
|
||||
DATABASE_URL: postgres://foundry:fixture@127.0.0.1:5432/foundry
|
||||
FOUNDRY_RESEARCH_DATABASE_URL: ''
|
||||
GOOGLE_CLIENT_ID: fixture
|
||||
GOOGLE_CLIENT_SECRET: fixture
|
||||
FOUNDRY_PROJECTS: '{"notes":"http://localhost:3000/auth/callback"}'
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署工作流仅处理 `main` 上影响后端或部署的改动,将二进制启动在服务器 Docker 私网,由 Traefik 提供 HTTPS。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。
|
||||
5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。
|
||||
|
||||
## 共享论文库(Thesisly)
|
||||
|
||||
可选只读接入见[共享论文 API](docs/research-api.md)。Thesisly 使用同一后端的 `/projects/{project}/research/papers` 列表、搜索和详情;只有配置服务器上的 `FOUNDRY_RESEARCH_DATABASE_URL` 时才启用。连接串保存在受保护的 `research.env`,不放入前端或 Actions Variables。当前仍等待 Paper Atlas 全量 PostgreSQL 迁移完成及真实数据验收,尚未启用线上论文接口。
|
||||
|
||||
## Actions 部署配置
|
||||
|
||||
在仓库的 Actions Secrets 配置 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET`,在 Actions Variables 配置 `FOUNDRY_PROJECTS`。变量值为 JSON 对象,例如 `{"prompt":"http://localhost:17321/auth/callback"}`。部署读取服务器上预先配置的 `/opt/endoleap-infra/foundry/database.env`,OAuth 值由 Actions 在启动容器时注入。
|
||||
@@ -604,3 +608,31 @@ api "$B/ai/runs/$(jq -r '.run_id' <<<"$propagated_run")?after_seq=0&limit=100"
|
||||
The live catalog, rather than the isolated verification IDs above, determines
|
||||
whether each request can run. A 202 response acknowledges a queued job; use its
|
||||
terminal status and authenticated canvas/asset rereads as completion evidence.
|
||||
|
||||
|
||||
### Managed image previews
|
||||
|
||||
Original asset bytes remain immutable and are used by the ordinary asset URL,
|
||||
downloads and AI inputs. Authenticated `GET .../assets/{id}?variant=preview`
|
||||
returns one display rendition bounded to 1024 pixels on its longest edge. Opaque previews use JPEG quality 82, transparency stays PNG, GIF preserves
|
||||
the original animation, and JPEG EXIF orientation is applied. Small originals
|
||||
may be returned unchanged when this is more efficient. No preview URL belongs
|
||||
in node `data.src`: persist the original managed URL and optional original
|
||||
`assetId`, then select the display variant only in the renderer.
|
||||
|
||||
Permissions are checked on every read, including cache hits. Originals are not
|
||||
replaced. Generation is serialized and the immutable encoded preview cache is
|
||||
bounded to 32 MiB / 128 entries. Current capabilities advertise `assets.previews`
|
||||
and `assets.preview_max_edge`. No database migration is needed. Asset reads and
|
||||
Canvas GET release their permission transaction before encoding/writing the
|
||||
response. Uploads release the initial workspace lock before receiving a large
|
||||
multipart body and recheck current write permission before committing.
|
||||
|
||||
Legacy inline images must be migrated through an explicit editor action: upload
|
||||
unchanged bytes with idempotency keys, then save the same node IDs/geometry with
|
||||
managed asset references and the current canvas version. Opening a canvas does
|
||||
not migrate it or cause an implicit PATCH.
|
||||
|
||||
#### Atomic member drop positions
|
||||
|
||||
`POST B/subgraphs/{frame}/members` additionally accepts optional `positions`, an object with exactly one `{x,y}` entry for each `node_ids` item, expressed in the **source board's local coordinates**. Both coordinates are required, finite and within ±1,000,000. Capabilities advertise `subgraphs.member_positions: true`. Omission preserves legacy behavior. Positions, ownership, edge/port conversion, versions and undo receipt commit in one transaction; no preceding coordinate PATCH is needed. Validation failures or stale versions perform zero writes. The positions participate in the idempotency payload digest; retries must keep the entire body and key unchanged. Undo restores the pre-drop snapshot, including original positions.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex"
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/platform"
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/research"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
@@ -61,6 +62,33 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
var papers research.Repository
|
||||
if researchDSN := os.Getenv("FOUNDRY_RESEARCH_DATABASE_URL"); researchDSN != "" {
|
||||
researchConfig, err := pgx.ParseConfig(researchDSN)
|
||||
if err != nil {
|
||||
log.Fatal("Invalid research database configuration")
|
||||
}
|
||||
researchConfig.RuntimeParams["default_transaction_read_only"] = "on"
|
||||
researchConfig.RuntimeParams["application_name"] = "foundry-research"
|
||||
researchDB := stdlib.OpenDB(*researchConfig)
|
||||
defer researchDB.Close()
|
||||
researchDB.SetMaxOpenConns(2)
|
||||
researchDB.SetMaxIdleConns(2)
|
||||
researchDB.SetConnMaxIdleTime(5 * time.Minute)
|
||||
researchDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
repository, err := research.NewPostgresRepository(researchDB, env("FOUNDRY_RESEARCH_SCHEMA", "research"))
|
||||
if err != nil {
|
||||
log.Fatal("Invalid research database configuration")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err = repository.Check(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Fatal("Research database is unavailable or not published")
|
||||
}
|
||||
papers = repository
|
||||
}
|
||||
research.Register(mux, papers, callbacks)
|
||||
if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+18
-1
@@ -67,12 +67,29 @@ END $$;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
printf 'Migration preserved existing logins and enforced project isolation.\n'
|
||||
for package in platform auth; do
|
||||
psql < internal/research/testdata/corpus.sql
|
||||
for package in platform auth research; do
|
||||
CGO_ENABLED=0 go test -mod=readonly -c -o "bin/$package-tests" "./internal/$package"
|
||||
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=16m,mode=1777 \
|
||||
--mount "type=bind,source=$(pwd)/bin/$package-tests,target=/opt/integration-tests,readonly" \
|
||||
-e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \
|
||||
-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
|
||||
|
||||
+2
-1
@@ -2,12 +2,13 @@
|
||||
"""Apply checked migrations atomically from the trusted deployment runner."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--container", default="endoleap-dev-postgres-1")
|
||||
parser.add_argument("--container", default=os.environ.get("FOUNDRY_POSTGRES_CONTAINER", "foundry-postgres"))
|
||||
parser.add_argument("--database", default="foundry_auth")
|
||||
parser.add_argument("--role", default="foundry")
|
||||
args = parser.parse_args()
|
||||
|
||||
+5
-1
@@ -53,6 +53,10 @@ mail_env=()
|
||||
if [[ -s "$foundry/mail.env" ]]; then
|
||||
mail_env=(--env-file "$foundry/mail.env")
|
||||
fi
|
||||
research_env=()
|
||||
if [[ -s "$foundry/research.env" ]]; then
|
||||
research_env=(--env-file "$foundry/research.env")
|
||||
fi
|
||||
slack_env=()
|
||||
if [[ -n "${SLACK_CLIENT_ID:-}" || -n "${SLACK_CLIENT_SECRET:-}" ]]; then
|
||||
for name in SLACK_CLIENT_ID SLACK_CLIENT_SECRET; do
|
||||
@@ -118,7 +122,7 @@ if [[ -d "$codex_dir" ]]; then
|
||||
)
|
||||
fi
|
||||
|
||||
docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack_env[@]}" --network "$network" \
|
||||
docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack_env[@]}" "${research_env[@]}" --network "$network" \
|
||||
--env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \
|
||||
-e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \
|
||||
--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Manual operator step against the published Paper Atlas schema, outside a
|
||||
-- transaction. Core's SELECT-only runtime role must never execute this file.
|
||||
-- These indexes support the actual author search and exact category filter;
|
||||
-- the migration already owns the title/abstract English GIN index.
|
||||
SET lock_timeout = '2s';
|
||||
SET statement_timeout = '20min';
|
||||
SET maintenance_work_mem = '32MB';
|
||||
SET max_parallel_maintenance_workers = 0;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS paper_tags_tag_paper
|
||||
ON research.paper_tags(tag_id,paper_id);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS papers_authors_search_gin
|
||||
ON research.papers USING gin(to_tsvector('simple'::regconfig,authors_json));
|
||||
-- After an interrupted concurrent build, inspect pg_index.indisvalid before
|
||||
-- reuse. IF NOT EXISTS alone does not repair an invalid index.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Shared research API
|
||||
|
||||
This work belongs to Thesisly 研迹 and uses Foundry Core rather than a separate
|
||||
application backend. The corpus is public; account sessions and future private
|
||||
collections remain project scoped.
|
||||
|
||||
## Current implementation boundary
|
||||
|
||||
`internal/research` implements the HTTP contract and a PostgreSQL repository
|
||||
based on the fixed Paper Atlas `a3b677b` schema (TEXT/BIGINT columns, native
|
||||
English title/abstract search index). `cmd/foundry` enables it only when
|
||||
`FOUNDRY_RESEARCH_DATABASE_URL` is configured. The Paper Atlas migration is now published: 21 tables passed its independent
|
||||
count/content-hash acceptance. A dedicated `foundry_research_reader` role has
|
||||
SELECT on the ten tables consumed here, schema USAGE, and read-only defaults.
|
||||
Its connection is stored only in the protected server file below.
|
||||
The existing SQLite implementation was withdrawn. Real-corpus integration has
|
||||
been checked through a loopback Core and the local frontend. Formal production
|
||||
release still requires the user to merge and choose to deploy; this branch is
|
||||
not deployed by these checks. Very broad queries remain subject to the budget
|
||||
and performance limitation documented below.
|
||||
Do not add `FOUNDRY_RESEARCH_DATABASE`.
|
||||
|
||||
The server-side deployment may load a protected
|
||||
`/opt/endoleap-infra/foundry/research.env` containing
|
||||
`FOUNDRY_RESEARCH_DATABASE_URL` and `FOUNDRY_RESEARCH_SCHEMA` (default `research`).
|
||||
Keep the connection string out of Git, Actions variables, browser configuration
|
||||
and command arguments. Use a dedicated role with schema USAGE and table SELECT
|
||||
only. Core uses a separate two-connection pool, read-only connection defaults and
|
||||
read-only transactions. Each research transaction disables JIT locally: the
|
||||
measured count plan spent about 2.85 seconds compiling JIT functions, and its
|
||||
end-to-end execution exceeded eight seconds. This does not alter shared
|
||||
PostgreSQL settings or replace exact counts with estimates. Startup checks the published schema/SELECT permissions;
|
||||
an explicitly enabled but invalid connection fails candidate readiness.
|
||||
|
||||
Deployment migration defaults now follow the shared `foundry-postgres`
|
||||
container name. `FOUNDRY_POSTGRES_CONTAINER` or the existing `--container` option
|
||||
can select a different container without changing database/table names.
|
||||
|
||||
## Routes
|
||||
|
||||
- `GET /projects/{project}/research/papers`
|
||||
- `GET /projects/{project}/research/papers/{id}`
|
||||
|
||||
Only configured Foundry projects are accepted. Reading the public corpus does
|
||||
not require a session; these routes do not expose private data or accept writes.
|
||||
|
||||
List parameters are `q` (200 Unicode characters, 20 words), `searchIn`
|
||||
(`text` or `author`), `topic` (an exact arXiv category), `sort` (`recent` or
|
||||
`title`), `cursor` (optional, opaque, at most 32768 characters), and
|
||||
`pageSize` (1–50, default 8). Unknown, malformed
|
||||
or repeated parameters return 400. `recent` means descending stable paper ID,
|
||||
not publication date. Search terms must become bound query parameters, never
|
||||
raw SQL or user-supplied full-text syntax.
|
||||
|
||||
Title/abstract search uses the migration's English vector with
|
||||
`plainto_tsquery`; author search uses simple-language terms over the stored
|
||||
author JSON. Both treat input as terms, never full-text operators. Initial full-corpus measurements showed eight-second timeouts for text,
|
||||
author and topic counts; the unfiltered count took 7.25 seconds. Execution plans
|
||||
confirmed sequential scans for authors and category joins.
|
||||
`deploy/research/query-indexes.sql` supplies operator-run concurrent indexes for
|
||||
those two missing access paths. It is separate from Core startup and auth
|
||||
migrations; do not grant DDL to the runtime reader. Both indexes completed on the confirmed ECS (category 35.4 seconds, author
|
||||
436.47 seconds); measured end-to-end checks are documented below.
|
||||
|
||||
List responses contain `items`, `nextCursor`, `previousCursor`, `topics`, and
|
||||
`pageSize`. Empty cursor strings mean that direction is unavailable. There is
|
||||
no `total`, page number, total page count or deep-page cap. The old `page`
|
||||
parameter is rejected; coordinate the frontend/backend contract update.
|
||||
Each paper preserves original title/abstract, authors, source dates, topics and
|
||||
source links. Publication decisions need source evidence; absent decisions stay
|
||||
unknown. No invented repositories, metrics or generated research conclusions.
|
||||
|
||||
Lists use a read-only repeatable-read snapshot without `count(*)` or `OFFSET`.
|
||||
Keyset paging uses ID descending for recent order and `(title_key, id)` ascending
|
||||
for title order, including a reverse query for previous pages. The title key is
|
||||
kept in its stored codec and C collation. Cursors bind normalized query, search
|
||||
scope, topic, sort and page size; changing filters requires a fresh cursor. They
|
||||
are public row positions, not authorization tokens or guaranteed frozen snapshots
|
||||
across requests. Concurrent corpus edits may alter later pages.
|
||||
|
||||
Only pageSize+1 positions are selected; the extra position indicates more data,
|
||||
then full rows and batched metadata are loaded for at most pageSize papers.
|
||||
For recent order with only a topic filter, page directly through the existing
|
||||
`(tag_id,paper_id)` index and deduplicate evidence before LIMIT. For text/author
|
||||
search in recent order, try an ID interval of 512 near the cursor, expanding
|
||||
once to 4096 only if there are some matches but fewer than pageSize+1.
|
||||
Only a full page plus one proves that interval contains the next page. If neither bounded attempt fills the page,
|
||||
all local candidates are discarded and the complete indexed search is used;
|
||||
rare or older matches are never silently truncated. The fallback materializes
|
||||
matching IDs before sorting, avoiding an unbounded ordered heap scan for sparse
|
||||
terms. Title-order filtered queries still use that complete search and can time
|
||||
out for broad terms. No global database or proxy resource settings are changed.
|
||||
The
|
||||
HTTP layer permits two in-flight queries with an eight-second context budget.
|
||||
Busy requests return 429 with `Retry-After: 1`; deadlines return 504; storage
|
||||
errors return a generic 503 without database details. Missing papers return 404.
|
||||
|
||||
## Validation
|
||||
|
||||
`go test -race -count=1 ./internal/research` covers routing, query limits,
|
||||
Unicode, anonymous reads, rejected mutations, project lookup, errors, saturation
|
||||
and timeout slot release. Run compilation/tests only on the user-confirmed ECS
|
||||
runner. These transport tests do not prove a database connection, full-corpus
|
||||
query performance or production availability.
|
||||
|
||||
The PostgreSQL repository batches source/tag/identifier/submission queries in
|
||||
the same read-only repeatable-read transaction. Author tuples are displayed as
|
||||
given name, family name and suffix; an optional fourth affiliation field
|
||||
is excluded from the display name (observed in real arXiv records); null abstracts/dates remain null. Publication
|
||||
status comes from the latest observed decision, using decision date and ID as
|
||||
tie-breakers, and stays unknown when no decision exists. Unit checks cover
|
||||
author shapes, bound user values and schema qualification. The real Core detail
|
||||
and homepage HTTP routes have returned 200.
|
||||
The frontend has also rendered the actual 3,526,999-paper corpus through a
|
||||
loopback SSH tunnel: eight rows, matching detail title/source, desktop and
|
||||
mobile layout, no page exceptions or horizontal overflow in that check.
|
||||
The earlier count-based API returned 146 author records in 0.336 seconds. Before the cursor optimization, after paging/author fixes, category `cs.AR` returned 9,457 records in 4.906
|
||||
seconds, `CartoonGAN` returned three in 0.332 seconds, and the multi-term search
|
||||
`attention is all you need` returned 8,653 in 6.325 seconds. These were sequential
|
||||
manual HTTP checks, not a load test. Very broad text searches can still exceed
|
||||
the eight-second budget; clients retain search controls so users can narrow
|
||||
the query or retry. This known limit is not an estimated count or sample
|
||||
fallback. This is not a
|
||||
production deployment or an authentication acceptance result.
|
||||
|
||||
The existing `deploy/check-schema.sh` also loads the synthetic fixture in
|
||||
`internal/research/testdata/corpus.sql` inside its disposable PostgreSQL
|
||||
container (`--network none`, no host port). The research test process receives a
|
||||
separate SELECT-only fixture role via `RESEARCH_TEST_DATABASE_URL`. These
|
||||
integration checks execute real SQL for filtering, stable paging, nullable
|
||||
content, source/tag/identifier joins, latest decision tie-breakers, permission
|
||||
denial and SQL-backed HTTP routes. They never connect to the shared migration
|
||||
instance and do not establish full-corpus performance or live-data correctness.
|
||||
|
||||
## Text storage compatibility (repair 4e62710)
|
||||
|
||||
The Paper Atlas repair uses `nul-soh-v1`: NUL is stored as SOH/SOH, and a
|
||||
literal SOH as SOH/STX, preserving binary text ordering. Core reads the schema
|
||||
comment `paper-atlas:text-codec=nul-soh-v1` through `pg_catalog` at startup,
|
||||
restores SQL text before JSON parsing/API responses, and encodes bound search
|
||||
terms. An absent/unrelated schema comment keeps legacy text unchanged; an
|
||||
unsupported marked codec or malformed encoded text fails explicitly.
|
||||
|
||||
Schema comments are public metadata, so consumers need no privileges on the
|
||||
private migration controller. The research role still needs only schema USAGE
|
||||
and table SELECT. The codec adaptation is on the review branch, pushed with [skip ci] under the
|
||||
user's no-CI instruction; the published schema marker has been verified with
|
||||
the runtime reader.
|
||||
All research tests passed manually against disposable PostgreSQL on the
|
||||
confirmed ECS, including the encoded SQL fixture for schema detection, raw TEXT restoration,
|
||||
JSON author/metadata decoding, search and the HTTP round trip. Passing isolated
|
||||
fixtures does not establish full-corpus API performance.
|
||||
|
||||
|
||||
## Identity handoff
|
||||
|
||||
The existing production Core received a configuration-only reload using its
|
||||
unchanged release binary: the original projects were retained and thesisly was
|
||||
registered. Both Google and Slack identity records were observed in this
|
||||
project. The user independently confirmed each provider's real authorization,
|
||||
reload/new-tab persistence and logout behavior. Browser control was unavailable,
|
||||
so these browser results are attributed to user acceptance rather than an
|
||||
automated real-account test. This does not deploy the research work branch;
|
||||
mail delivery was not exercised.
|
||||
|
||||
|
||||
## Cursor regression coverage
|
||||
|
||||
The disposable PostgreSQL fixture covers forward/backward traversal, identical
|
||||
title keys with different IDs, published control-character encoding, sparse
|
||||
matches separated by a large ID gap, and fallback from the bounded interval.
|
||||
Invalid, oversized, cross-filter and wrong-version cursors are rejected before
|
||||
repository execution. The runtime reader retains SELECT-only privileges; no DDL
|
||||
or writes are added. Run tests manually on the confirmed ECS; skip CI for this
|
||||
iteration and retain `[skip ci]` in commit and PR titles.
|
||||
|
||||
|
||||
## Manual cursor measurements (2026-11-07)
|
||||
|
||||
On the confirmed ECS, after this task's compilation and disposable fixture had
|
||||
finished, sequential direct requests to the temporary Core returned: homepage
|
||||
65 ms, next/previous 39/21 ms, `graph` 345 ms, `CartoonGAN` 731 ms (3 papers),
|
||||
`cs.AR` 145 ms, title order 180 ms, author `Buhrman` 769 ms, and
|
||||
`attention is all you need` 1312 ms. Recent, topic and title cursor round trips
|
||||
returned the same first page without overlap. These are individual manual
|
||||
samples, not production latency guarantees; concurrent compilation earlier
|
||||
produced multi-second cold measurements. No proxy, database memory, CPU or
|
||||
production configuration was changed.
|
||||
|
||||
The final source compiled manually on the ECS; package unit checks and all ten
|
||||
selected cursor/PostgreSQL integration tests passed, including interval expansion
|
||||
and complete fallback. No CI or local build ran. The local authorized Next dev
|
||||
uses the temporary API through the existing SSH tunnel. Independent Chrome also
|
||||
verified real homepage/search navigation, a preserved document, one mobile
|
||||
header/main, no page exceptions and no horizontal overflow. The production Core
|
||||
was not replaced by these temporary preview updates.
|
||||
@@ -0,0 +1,242 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type assetPreviewResult struct {
|
||||
data []byte
|
||||
mime string
|
||||
}
|
||||
type assetPreviewEntry struct {
|
||||
key string
|
||||
value assetPreviewResult
|
||||
}
|
||||
|
||||
var previewMemory = struct {
|
||||
sync.Mutex
|
||||
entries map[string]*list.Element
|
||||
order *list.List
|
||||
bytes int
|
||||
}{entries: make(map[string]*list.Element), order: list.New()}
|
||||
var previewSlots = make(chan struct{}, 1)
|
||||
|
||||
func previewWidth(variant, width string) (int, error) {
|
||||
if variant == "" && width == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if variant == "preview" && (width == "" || width == "1024") {
|
||||
return 1024, nil
|
||||
}
|
||||
return 0, errors.New("invalid_asset_variant")
|
||||
}
|
||||
|
||||
// Cache only immutable bytes, after the caller checks current asset permission.
|
||||
// Bound both decoded concurrency and retained encoded data.
|
||||
func cachedAssetPreview(ctx context.Context, encoded []byte, mime string, width int) (assetPreviewResult, error) {
|
||||
key := fmt.Sprintf("%x:%d", sha256.Sum256(encoded), width)
|
||||
previewMemory.Lock()
|
||||
if entry := previewMemory.entries[key]; entry != nil {
|
||||
previewMemory.order.MoveToFront(entry)
|
||||
result := entry.Value.(assetPreviewEntry).value
|
||||
previewMemory.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
previewMemory.Unlock()
|
||||
select {
|
||||
case previewSlots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return assetPreviewResult{}, ctx.Err()
|
||||
}
|
||||
defer func() { <-previewSlots }()
|
||||
// Another request may have finished this same preview while we waited.
|
||||
previewMemory.Lock()
|
||||
if entry := previewMemory.entries[key]; entry != nil {
|
||||
result := entry.Value.(assetPreviewEntry).value
|
||||
previewMemory.order.MoveToFront(entry)
|
||||
previewMemory.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
previewMemory.Unlock()
|
||||
result, err := makeAssetPreview(encoded, mime, width)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if len(result.data) > 8<<20 {
|
||||
return result, nil
|
||||
}
|
||||
previewMemory.Lock()
|
||||
defer previewMemory.Unlock()
|
||||
for previewMemory.bytes+len(result.data) > 32<<20 || previewMemory.order.Len() >= 128 {
|
||||
tail := previewMemory.order.Back()
|
||||
if tail == nil {
|
||||
break
|
||||
}
|
||||
old := tail.Value.(assetPreviewEntry)
|
||||
previewMemory.bytes -= len(old.value.data)
|
||||
delete(previewMemory.entries, old.key)
|
||||
previewMemory.order.Remove(tail)
|
||||
}
|
||||
previewMemory.entries[key] = previewMemory.order.PushFront(assetPreviewEntry{key, result})
|
||||
previewMemory.bytes += len(result.data)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func makeAssetPreview(encoded []byte, mime string, width int) (assetPreviewResult, error) {
|
||||
original := assetPreviewResult{encoded, mime}
|
||||
// Keep animation intact. Original bytes remain the canonical AI/download input.
|
||||
if mime == "image/gif" {
|
||||
return original, nil
|
||||
}
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
if config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > assetPixelLimit {
|
||||
return original, errors.New("invalid_image_dimensions")
|
||||
}
|
||||
if config.Width <= width && config.Height <= width && len(encoded) <= 64<<10 {
|
||||
return original, nil
|
||||
}
|
||||
source, _, err := image.Decode(bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
orientation := jpegOrientation(encoded)
|
||||
if orientation != 1 {
|
||||
source = orientedAsset{source, orientation}
|
||||
}
|
||||
bounds := source.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w > width || h > width {
|
||||
if w >= h {
|
||||
h = max(1, h*width/w)
|
||||
w = width
|
||||
} else {
|
||||
w = max(1, w*width/h)
|
||||
h = width
|
||||
}
|
||||
}
|
||||
resized := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
draw.CatmullRom.Scale(resized, resized.Bounds(), source, source.Bounds(), draw.Src, nil)
|
||||
var output bytes.Buffer
|
||||
outputMime := "image/png"
|
||||
if resized.Opaque() {
|
||||
err = jpeg.Encode(&output, resized, &jpeg.Options{Quality: 82})
|
||||
outputMime = "image/jpeg"
|
||||
} else {
|
||||
err = png.Encode(&output, resized)
|
||||
}
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
if output.Len() >= len(encoded) && orientation == 1 {
|
||||
return original, nil
|
||||
}
|
||||
return assetPreviewResult{output.Bytes(), outputMime}, nil
|
||||
}
|
||||
|
||||
// JPEG EXIF orientation is applied to previews instead of silently rotating a
|
||||
// phone photograph after its EXIF metadata is removed by the image encoder.
|
||||
func jpegOrientation(data []byte) int {
|
||||
if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 {
|
||||
return 1
|
||||
}
|
||||
for at := 2; at+4 <= len(data); {
|
||||
if data[at] != 0xff {
|
||||
return 1
|
||||
}
|
||||
marker := data[at+1]
|
||||
if marker == 0xda || marker == 0xd9 {
|
||||
return 1
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(data[at+2 : at+4]))
|
||||
if length < 2 || at+2+length > len(data) {
|
||||
return 1
|
||||
}
|
||||
part := data[at+4 : at+2+length]
|
||||
if marker == 0xe1 && len(part) >= 14 && string(part[:6]) == "Exif\x00\x00" {
|
||||
tiff := part[6:]
|
||||
var order binary.ByteOrder
|
||||
switch string(tiff[:2]) {
|
||||
case "II":
|
||||
order = binary.LittleEndian
|
||||
case "MM":
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
if order.Uint16(tiff[2:4]) != 42 {
|
||||
return 1
|
||||
}
|
||||
offset := int(order.Uint32(tiff[4:8]))
|
||||
if offset < 8 || offset+2 > len(tiff) {
|
||||
return 1
|
||||
}
|
||||
count := int(order.Uint16(tiff[offset : offset+2]))
|
||||
for i := 0; i < count; i++ {
|
||||
entry := offset + 2 + i*12
|
||||
if entry+12 > len(tiff) {
|
||||
return 1
|
||||
}
|
||||
if order.Uint16(tiff[entry:entry+2]) == 0x112 && order.Uint16(tiff[entry+2:entry+4]) == 3 && order.Uint32(tiff[entry+4:entry+8]) == 1 {
|
||||
value := int(order.Uint16(tiff[entry+8 : entry+10]))
|
||||
if value >= 1 && value <= 8 {
|
||||
return value
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
at += 2 + length
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
type orientedAsset struct {
|
||||
image.Image
|
||||
orientation int
|
||||
}
|
||||
|
||||
func (im orientedAsset) Bounds() image.Rectangle {
|
||||
b := im.Image.Bounds()
|
||||
if im.orientation >= 5 {
|
||||
return image.Rect(0, 0, b.Dy(), b.Dx())
|
||||
}
|
||||
return image.Rect(0, 0, b.Dx(), b.Dy())
|
||||
}
|
||||
func (im orientedAsset) ColorModel() color.Model { return im.Image.ColorModel() }
|
||||
func (im orientedAsset) At(x, y int) color.Color {
|
||||
b := im.Image.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
switch im.orientation {
|
||||
case 2:
|
||||
x = w - 1 - x
|
||||
case 3:
|
||||
x, y = w-1-x, h-1-y
|
||||
case 4:
|
||||
y = h - 1 - y
|
||||
case 5:
|
||||
x, y = y, x
|
||||
case 6:
|
||||
x, y = y, h-1-x
|
||||
case 7:
|
||||
x, y = w-1-y, h-1-x
|
||||
case 8:
|
||||
x, y = w-1-y, x
|
||||
}
|
||||
return im.Image.At(b.Min.X+x, b.Min.Y+y)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssetPreviewDownscalesWithoutChangingOriginal(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 1600, 900))
|
||||
for y := 0; y < 900; y++ {
|
||||
for x := 0; x < 1600; x++ {
|
||||
source.SetNRGBA(x, y, color.NRGBA{uint8(x), uint8(y), uint8(x * y), 255})
|
||||
}
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := append([]byte(nil), encoded.Bytes()...)
|
||||
preview, err := makeAssetPreview(encoded.Bytes(), "image/png", 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(preview.data))
|
||||
if err != nil || config.Width != 512 || config.Height != 288 || format != "jpeg" {
|
||||
t.Fatalf("bad preview %s %dx%d %v", format, config.Width, config.Height, err)
|
||||
}
|
||||
if len(preview.data) >= len(original) || !bytes.Equal(original, encoded.Bytes()) {
|
||||
t.Fatal("preview must shrink without changing original")
|
||||
}
|
||||
cached, err := cachedAssetPreview(context.Background(), original, "image/png", 512)
|
||||
if err != nil || !bytes.Equal(cached.data, preview.data) {
|
||||
t.Fatal("cache result mismatch")
|
||||
}
|
||||
}
|
||||
func TestAssetPreviewPreservesTransparencyAndAnimation(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 800, 400))
|
||||
source.SetNRGBA(1, 1, color.NRGBA{255, 0, 0, 255})
|
||||
var encoded bytes.Buffer
|
||||
_ = png.Encode(&encoded, source)
|
||||
preview, err := makeAssetPreview(encoded.Bytes(), "image/png", 256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, format, err := image.Decode(bytes.NewReader(preview.data))
|
||||
if err != nil || format != "png" {
|
||||
t.Fatal("transparent image must stay PNG")
|
||||
}
|
||||
_, _, _, alpha := decoded.At(200, 100).RGBA()
|
||||
if alpha != 0 {
|
||||
t.Fatal("transparent background was lost")
|
||||
}
|
||||
original := []byte("GIF original animation bytes")
|
||||
gif, err := makeAssetPreview(original, "image/gif", 512)
|
||||
if err != nil || !bytes.Equal(gif.data, original) {
|
||||
t.Fatal("GIF animation changed")
|
||||
}
|
||||
}
|
||||
func TestAssetPreviewRejectsUnboundedWidths(t *testing.T) {
|
||||
for _, value := range []string{"0", "1", "256", "512", "2048", "999999", "-1", "abc"} {
|
||||
if _, err := previewWidth("preview", value); err == nil {
|
||||
t.Errorf("accepted width %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "1024"} {
|
||||
if _, err := previewWidth("preview", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n, err := previewWidth("", ""); err != nil || n != 0 {
|
||||
t.Fatal("original request changed")
|
||||
}
|
||||
if _, err := previewWidth("other", ""); err == nil {
|
||||
t.Fatal("unknown variant accepted")
|
||||
}
|
||||
if _, err := makeAssetPreview([]byte("invalid"), "image/png", 512); err == nil {
|
||||
t.Fatal("malformed image accepted")
|
||||
}
|
||||
}
|
||||
func TestAssetPreviewAppliesJPEGOrientation(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 800, 400))
|
||||
for y := 0; y < 400; y++ {
|
||||
for x := 0; x < 800; x++ {
|
||||
source.SetNRGBA(x, y, color.NRGBA{255, 0, 0, 255})
|
||||
}
|
||||
}
|
||||
var original bytes.Buffer
|
||||
_ = jpeg.Encode(&original, source, nil)
|
||||
tiff := make([]byte, 26)
|
||||
copy(tiff, "II")
|
||||
binary.LittleEndian.PutUint16(tiff[2:], 42)
|
||||
binary.LittleEndian.PutUint32(tiff[4:], 8)
|
||||
binary.LittleEndian.PutUint16(tiff[8:], 1)
|
||||
binary.LittleEndian.PutUint16(tiff[10:], 0x112)
|
||||
binary.LittleEndian.PutUint16(tiff[12:], 3)
|
||||
binary.LittleEndian.PutUint32(tiff[14:], 1)
|
||||
binary.LittleEndian.PutUint16(tiff[18:], 6)
|
||||
exif := append([]byte("Exif\x00\x00"), tiff...)
|
||||
segment := append([]byte{0xff, 0xe1, 0, byte(len(exif) + 2)}, exif...)
|
||||
encoded := append(append(append([]byte{}, original.Bytes()[:2]...), segment...), original.Bytes()[2:]...)
|
||||
if jpegOrientation(encoded) != 6 {
|
||||
t.Fatal("EXIF orientation not decoded")
|
||||
}
|
||||
preview, err := makeAssetPreview(encoded, "image/jpeg", 256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(preview.data))
|
||||
if err != nil || config.Width != 128 || config.Height != 256 {
|
||||
t.Fatalf("rotated dimensions %dx%d %v", config.Width, config.Height, err)
|
||||
}
|
||||
}
|
||||
func TestAllAssetOrientationMappings(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 3, 2))
|
||||
source.SetNRGBA(0, 0, color.NRGBA{255, 0, 0, 255})
|
||||
for orientation := 1; orientation <= 8; orientation++ {
|
||||
oriented := orientedAsset{source, orientation}
|
||||
red := 0
|
||||
for y := 0; y < oriented.Bounds().Dy(); y++ {
|
||||
for x := 0; x < oriented.Bounds().Dx(); x++ {
|
||||
r, _, _, _ := oriented.At(x, y).RGBA()
|
||||
if r > 0 {
|
||||
red++
|
||||
}
|
||||
}
|
||||
}
|
||||
if red != 1 {
|
||||
t.Errorf("orientation %d loses/repeats pixels", orientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
-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
|
||||
@@ -79,6 +91,10 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
// Do not hold a workspace lock during a slow multipart upload. Recheck below.
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, assetLimit+(64<<10))
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
@@ -123,24 +139,47 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
|
||||
reject(w, 400, "invalid_asset")
|
||||
return nil
|
||||
}
|
||||
tx, err = s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, role, err = lockWorkspace(r.Context(), tx, project, workspace, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
hash := hex.EncodeToString(digest[:])
|
||||
info := assetInfo{MIME: mime, Bytes: len(encoded), Width: width, Height: height}
|
||||
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
|
||||
}
|
||||
@@ -148,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) {
|
||||
@@ -199,11 +271,63 @@ 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")
|
||||
return nil
|
||||
}
|
||||
width, variantErr := previewWidth(r.URL.Query().Get("variant"), r.URL.Query().Get("width"))
|
||||
if variantErr != nil {
|
||||
reject(w, 400, "invalid_asset_variant")
|
||||
return nil
|
||||
}
|
||||
id := r.PathValue("asset")
|
||||
if !uuidSlugPattern.MatchString(id) {
|
||||
reject(w, 404, "not_found")
|
||||
@@ -235,6 +359,21 @@ func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, acco
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Permission and immutable bytes are now read consistently. Release locks
|
||||
// before resizing or waiting for a slow public client to consume the body.
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
variant := "original"
|
||||
if width > 0 {
|
||||
preview, e := cachedAssetPreview(r.Context(), encoded, mime, width)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
encoded, mime = preview.data, preview.mime
|
||||
variant = "preview"
|
||||
}
|
||||
w.Header().Set("X-Asset-Variant", variant)
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(encoded)))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -345,6 +345,9 @@ func (s *service) canvas(w http.ResponseWriter, r *http.Request, project, accoun
|
||||
return nil
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("X-Canvas-Schema-Version", strconv.Itoa(canvasSchema(document)))
|
||||
setVersion(w, version)
|
||||
reply(w, 200, response{"version": version, "document": document})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
models, modelErr := s.canvasAI.Models(ctx)
|
||||
@@ -36,16 +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},
|
||||
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true},
|
||||
"ai": ai,
|
||||
"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))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -19,7 +20,9 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
@@ -420,7 +423,8 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
}
|
||||
parentID := migrationBoard["id"].(string)
|
||||
memberPath := migrationPath + "/subgraphs/f/members"
|
||||
moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}
|
||||
moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": map[string]memberDropPosition{"a": {X: 40, Y: 50}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}
|
||||
call("POST", memberPath, tokens[0], response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": response{"wrong": response{"x": 1, "y": 2}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}, 400, "", accept, "eeeeeeee-1111-4111-8111-111111111111")
|
||||
movedMembers := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||
memberRetry := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||
if movedMembers["transaction_id"] != memberRetry["transaction_id"] {
|
||||
@@ -432,6 +436,10 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
targetRead := call("GET", targetCanvasPath, tokens[0], nil, 200, "", accept)
|
||||
sourceJSON, _ := json.Marshal(sourceRead["document"])
|
||||
targetJSON, _ := json.Marshal(targetRead["document"])
|
||||
dropPosition := canvasNodesByID(targetJSON)["a"]["position"].(map[string]any)
|
||||
if dropPosition["x"] != float64(140) || dropPosition["y"] != float64(250) {
|
||||
t.Fatalf("atomic drop geometry %v", dropPosition)
|
||||
}
|
||||
if canvasNodeData(sourceJSON)["a"] != nil || canvasNodeData(targetJSON)["a"] == nil {
|
||||
t.Fatal("member move duplicated or lost ownership")
|
||||
}
|
||||
@@ -487,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)
|
||||
@@ -501,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 {
|
||||
@@ -517,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)
|
||||
@@ -530,6 +568,123 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
t.Fatal("asset bytes changed")
|
||||
}
|
||||
}
|
||||
// Warm the immutable preview cache, then verify it never bypasses permission.
|
||||
for _, sample := range []struct {
|
||||
token string
|
||||
status int
|
||||
}{{tokens[0], 200}, {tokens[1], 404}, {tokens[2], 404}} {
|
||||
request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string)+"?variant=preview", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+sample.token)
|
||||
result := httptest.NewRecorder()
|
||||
mux.ServeHTTP(result, request)
|
||||
if result.Code != sample.status {
|
||||
t.Fatalf("preview permission got %d want %d", result.Code, sample.status)
|
||||
}
|
||||
if result.Code == 200 && (!bytes.Equal(result.Body.Bytes(), assetPNG.Bytes()) || result.Header().Get("Cache-Control") != "private, no-store") {
|
||||
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), metadataPath} {
|
||||
var probeErr error
|
||||
writer := &readLockProbe{ResponseRecorder: httptest.NewRecorder(), probe: func() {
|
||||
lockTx, e := db.BeginTx(ctx, nil)
|
||||
if e != nil {
|
||||
probeErr = e
|
||||
return
|
||||
}
|
||||
defer lockTx.Rollback()
|
||||
_, probeErr = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace)
|
||||
}}
|
||||
request := httptest.NewRequest("GET", path, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
request.Header.Set("Accept", accept)
|
||||
mux.ServeHTTP(writer, request)
|
||||
if writer.Code != 200 || probeErr != nil {
|
||||
t.Fatalf("read held workspace lock during response: status=%d error=%v", writer.Code, probeErr)
|
||||
}
|
||||
}
|
||||
// A slow multipart body must not hold the workspace lock either.
|
||||
func() {
|
||||
reader, pipe := io.Pipe()
|
||||
defer reader.Close()
|
||||
defer pipe.Close()
|
||||
form := multipart.NewWriter(pipe)
|
||||
headers := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
request := httptest.NewRequest("POST", workspacePath+"/assets", reader)
|
||||
request.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
request.Header.Set("Content-Type", form.FormDataContentType())
|
||||
request.Header.Set("Idempotency-Key", "99999999-9999-4999-8999-999999999998")
|
||||
result := httptest.NewRecorder()
|
||||
go func() { mux.ServeHTTP(result, request); close(done) }()
|
||||
go func() {
|
||||
part, e := form.CreateFormFile("image", "slow.png")
|
||||
close(headers)
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
<-release
|
||||
_, _ = part.Write(assetPNG.Bytes())
|
||||
_ = form.Close()
|
||||
_ = pipe.Close()
|
||||
}()
|
||||
select {
|
||||
case <-headers:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("upload did not consume headers")
|
||||
}
|
||||
lockTx, e := db.BeginTx(ctx, nil)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace)
|
||||
_ = lockTx.Rollback()
|
||||
unblock()
|
||||
if e != nil {
|
||||
t.Fatalf("upload held workspace lock while receiving body: %v", e)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("upload did not finish")
|
||||
}
|
||||
if result.Code != 201 {
|
||||
t.Fatalf("slow upload failed: %d", result.Code)
|
||||
}
|
||||
}()
|
||||
download(tokens[0], 200)
|
||||
download(tokens[1], 404)
|
||||
download(tokens[2], 404)
|
||||
@@ -551,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)
|
||||
|
||||
@@ -668,3 +879,19 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
t.Fatalf("click accounting incorrect %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A real SQL NOWAIT probe at the response-write boundary catches locks that
|
||||
// would otherwise remain invisible in a buffered httptest recorder.
|
||||
type readLockProbe struct {
|
||||
*httptest.ResponseRecorder
|
||||
probe func()
|
||||
}
|
||||
|
||||
func (w *readLockProbe) Write(body []byte) (int, error) {
|
||||
if w.probe != nil {
|
||||
probe := w.probe
|
||||
w.probe = nil
|
||||
probe()
|
||||
}
|
||||
return w.ResponseRecorder.Write(body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Positions are expressed in the source board's local coordinates. The existing
|
||||
// ownership transaction converts them into destination coordinates exactly once.
|
||||
type memberDropPosition struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
func validMemberDropPositions(ids []string, positions map[string]memberDropPosition) bool {
|
||||
if positions == nil {
|
||||
return true
|
||||
} // Backward-compatible old clients.
|
||||
if len(positions) != len(ids) {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
p, ok := positions[id]
|
||||
if !ok || seen[id] || math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.X, 0) || math.IsInf(p.Y, 0) || math.Abs(p.X) > 1e6 || math.Abs(p.Y) > 1e6 {
|
||||
return false
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func positionMemberDrop(raw json.RawMessage, positions map[string]memberDropPosition) (json.RawMessage, error) {
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := 0
|
||||
nodes, ok := doc["nodes"].([]any)
|
||||
if !ok {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
for _, v := range nodes {
|
||||
n, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
id, _ := n["id"].(string)
|
||||
if p, ok := positions[id]; ok {
|
||||
n["position"] = map[string]any{"x": p.X, "y": p.Y}
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != len(positions) {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
return json.Marshal(doc)
|
||||
}
|
||||
|
||||
func (p *memberDropPosition) UnmarshalJSON(raw []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) != 2 || fields["x"] == nil || fields["y"] == nil || string(fields["x"]) == "null" || string(fields["y"]) == "null" {
|
||||
return fmt.Errorf("invalid member position")
|
||||
}
|
||||
if err := json.Unmarshal(fields["x"], &p.X); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(fields["y"], &p.Y)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemberDropPositions(t *testing.T) {
|
||||
raw := memberFixture(t)
|
||||
source := "11111111-1111-4111-8111-111111111111"
|
||||
target := "22222222-2222-4222-8222-222222222222"
|
||||
original := string(raw[source])
|
||||
positions := map[string]memberDropPosition{"a": {X: 40, Y: 50}}
|
||||
positioned, err := positionMemberDrop(raw[source], positions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw[source] = positioned
|
||||
moved, err := moveMembersDocuments("parent", source, target, []string{"a"}, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pos := canvasNodesByID(moved[target])["a"]["position"].(map[string]any)
|
||||
if pos["x"] != float64(140) || pos["y"] != float64(250) {
|
||||
t.Fatalf("final drop position %v", pos)
|
||||
}
|
||||
if original == string(positioned) {
|
||||
t.Fatal("position not applied")
|
||||
}
|
||||
if _, err := positionMemberDrop(positioned, map[string]memberDropPosition{"missing": {}}); err == nil {
|
||||
t.Fatal("missing member accepted")
|
||||
}
|
||||
if !validMemberDropPositions([]string{"a"}, nil) || !validMemberDropPositions([]string{"a"}, positions) {
|
||||
t.Fatal("valid positions rejected")
|
||||
}
|
||||
for _, bad := range []map[string]memberDropPosition{{}, {"other": {}}, {"a": {X: 1e6 + 1}}, {"a": {Y: math.NaN()}}, {"a": {X: math.Inf(1)}}} {
|
||||
if validMemberDropPositions([]string{"a"}, bad) {
|
||||
t.Fatalf("bad positions accepted %v", bad)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{`{"x":1}`, `{"x":null,"y":1}`, `{"x":1,"y":2,"z":3}`} {
|
||||
var p memberDropPosition
|
||||
if json.Unmarshal([]byte(bad), &p) == nil {
|
||||
t.Fatal("malformed position accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,11 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
||||
return nil
|
||||
}
|
||||
var input struct {
|
||||
Source string `json:"source_board_id"`
|
||||
Target string `json:"target_board_id"`
|
||||
Nodes []string `json:"node_ids"`
|
||||
Expected map[string]int64 `json:"expected_versions"`
|
||||
Source string `json:"source_board_id"`
|
||||
Target string `json:"target_board_id"`
|
||||
Nodes []string `json:"node_ids"`
|
||||
Expected map[string]int64 `json:"expected_versions"`
|
||||
Positions map[string]memberDropPosition `json:"positions,omitempty"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return nil
|
||||
@@ -40,6 +41,10 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if !validMemberDropPositions(input.Nodes, input.Positions) {
|
||||
reject(w, 400, "invalid_subgraph_request")
|
||||
return nil
|
||||
}
|
||||
if len(input.Expected) == 0 {
|
||||
reject(w, 428, "version_required")
|
||||
return nil
|
||||
@@ -194,6 +199,14 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
||||
before = append(before, snapshot)
|
||||
}
|
||||
raw := map[string]json.RawMessage{parent: snapshots[parent].Document, input.Source: snapshots[input.Source].Document, input.Target: snapshots[input.Target].Document}
|
||||
if input.Positions != nil {
|
||||
positioned, err := positionMemberDrop(raw[input.Source], input.Positions)
|
||||
if err != nil {
|
||||
reject(w, 409, "ownership_conflict")
|
||||
return nil
|
||||
}
|
||||
raw[input.Source] = positioned
|
||||
}
|
||||
moved, err := moveMembersDocuments(parent, input.Source, input.Target, input.Nodes, raw)
|
||||
if err != nil {
|
||||
if err == errGraphOwnership {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Cursors locate a public corpus row, not an account or authentication token.
|
||||
// Binding to normalized filters prevents accidentally paging a different query.
|
||||
type position struct {
|
||||
Version int `json:"v"`
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Before bool `json:"before"`
|
||||
Filter string `json:"filter"`
|
||||
}
|
||||
|
||||
var errCursor = errors.New("invalid cursor")
|
||||
|
||||
func filterKey(f Filters) string {
|
||||
sort, search := f.Sort, f.SearchIn
|
||||
if sort == "" {
|
||||
sort = "recent"
|
||||
}
|
||||
if search == "" {
|
||||
search = "text"
|
||||
}
|
||||
data, _ := json.Marshal([]string{strings.TrimSpace(f.Query), f.Topic, sort, search, strconv.Itoa(f.PageSize)})
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
func encodeCursor(f Filters, p position, before bool) string {
|
||||
p.Version, p.Before, p.Filter = 1, before, filterKey(f)
|
||||
if f.Sort != "title" {
|
||||
p.Key = ""
|
||||
}
|
||||
data, _ := json.Marshal(p)
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
func decodeCursor(f Filters) (position, error) {
|
||||
var p position
|
||||
if f.Cursor == "" {
|
||||
return p, nil
|
||||
}
|
||||
if len(f.Cursor) > 32768 {
|
||||
return p, errCursor
|
||||
}
|
||||
data, err := base64.RawURLEncoding.Strict().DecodeString(f.Cursor)
|
||||
if err != nil {
|
||||
return p, errCursor
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&p) != nil || decoder.Decode(new(any)) != io.EOF || p.Version != 1 || p.ID <= 0 || p.Filter != filterKey(f) || strings.ContainsRune(p.Key, 0) || (f.Sort != "title" && p.Key != "") {
|
||||
return position{}, errCursor
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCursorValidation(t *testing.T) {
|
||||
f := Filters{Query: "graph", Sort: "title", SearchIn: "text", PageSize: 8}
|
||||
f.Cursor = encodeCursor(f, position{ID: 9223372036854775807, Key: "duplicate\x01title"}, true)
|
||||
p, err := decodeCursor(f)
|
||||
if err != nil || !p.Before || p.ID != 9223372036854775807 || p.Key != "duplicate\x01title" {
|
||||
t.Fatalf("roundtrip %+v %v", p, err)
|
||||
}
|
||||
if _, err := parseFilters("q=graph&sort=title&cursor=" + url.QueryEscape(f.Cursor)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range []string{"!", strings.Repeat("a", 32769), base64.RawURLEncoding.EncodeToString([]byte(`{"v":2}`)), f.Cursor + "="} {
|
||||
bad := f
|
||||
bad.Cursor = raw
|
||||
if _, err := decodeCursor(bad); err == nil {
|
||||
t.Fatal("accepted invalid cursor")
|
||||
}
|
||||
}
|
||||
for _, change := range []func(*Filters){func(f *Filters) { f.Query = "other" }, func(f *Filters) { f.Sort = "recent" }, func(f *Filters) { f.PageSize = 9 }, func(f *Filters) { f.SearchIn = "author" }, func(f *Filters) { f.Topic = "cs.AI" }} {
|
||||
bad := f
|
||||
change(&bad)
|
||||
if _, err := decodeCursor(bad); err == nil {
|
||||
t.Fatal("accepted mismatched filter")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package research serves the shared, public paper corpus. Account data and
|
||||
// private collections do not belong to these anonymous read-only endpoints.
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Source struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
ExternalID string `json:"externalId"`
|
||||
}
|
||||
|
||||
type Publication struct {
|
||||
Venue string `json:"venue"`
|
||||
Year int `json:"year"`
|
||||
Track string `json:"track"`
|
||||
Status string `json:"status"`
|
||||
EvidenceURL string `json:"evidenceUrl,omitempty"`
|
||||
}
|
||||
|
||||
type Paper struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Abstract *string `json:"abstract"`
|
||||
Authors []string `json:"authors"`
|
||||
PublishedAt *string `json:"publishedAt"`
|
||||
Topics []string `json:"topics"`
|
||||
Sources []Source `json:"sources"`
|
||||
Publications []Publication `json:"publications,omitempty"`
|
||||
DOI *string `json:"doi,omitempty"`
|
||||
Journal *string `json:"journal,omitempty"`
|
||||
Comments *string `json:"comments,omitempty"`
|
||||
}
|
||||
|
||||
type Filters struct {
|
||||
Query string
|
||||
SearchIn string
|
||||
Topic string
|
||||
Sort string
|
||||
Cursor string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Paper `json:"items"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
PreviousCursor string `json:"previousCursor"`
|
||||
Topics []string `json:"topics"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// Repository implementations must use a read-only transaction for each List,
|
||||
// so its metadata and page describe the same database snapshot.
|
||||
type Repository interface {
|
||||
List(context.Context, Filters) (Page, error)
|
||||
Get(context.Context, int64) (Paper, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repository Repository
|
||||
projects map[string]string
|
||||
slots chan struct{}
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Register adds only GET endpoints; no credentials or database errors are sent
|
||||
// to anonymous callers. A nil repository leaves the existing server unchanged.
|
||||
func Register(mux *http.ServeMux, repository Repository, projects map[string]string) {
|
||||
if repository == nil {
|
||||
return
|
||||
}
|
||||
s := &service{repository: repository, projects: projects, slots: make(chan struct{}, 2), timeout: 8 * time.Second}
|
||||
mux.HandleFunc("GET /projects/{project}/research/papers", s.serve)
|
||||
mux.HandleFunc("GET /projects/{project}/research/papers/{id}", s.serve)
|
||||
}
|
||||
|
||||
func reply(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func reject(w http.ResponseWriter, status int, code string) {
|
||||
reply(w, status, map[string]string{"error": code})
|
||||
}
|
||||
|
||||
var topicPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9.-]{0,79}$`)
|
||||
var positiveInteger = regexp.MustCompile(`^[1-9][0-9]*$`)
|
||||
|
||||
func parseFilters(raw string) (Filters, error) {
|
||||
f := Filters{SearchIn: "text", Sort: "recent", PageSize: 8}
|
||||
values, err := url.ParseQuery(raw)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
invalid := errors.New("invalid query")
|
||||
for key, value := range values {
|
||||
if len(value) != 1 {
|
||||
return f, invalid
|
||||
}
|
||||
switch key {
|
||||
case "q":
|
||||
if !utf8.ValidString(value[0]) || utf8.RuneCountInString(value[0]) > 200 || len(strings.Fields(value[0])) > 20 || strings.ContainsRune(value[0], 0) {
|
||||
return f, invalid
|
||||
}
|
||||
f.Query = strings.TrimSpace(value[0])
|
||||
case "searchIn":
|
||||
if value[0] != "text" && value[0] != "author" {
|
||||
return f, invalid
|
||||
}
|
||||
f.SearchIn = value[0]
|
||||
case "topic":
|
||||
if value[0] != "" && !topicPattern.MatchString(value[0]) {
|
||||
return f, invalid
|
||||
}
|
||||
f.Topic = value[0]
|
||||
case "sort":
|
||||
if value[0] != "recent" && value[0] != "title" {
|
||||
return f, invalid
|
||||
}
|
||||
f.Sort = value[0]
|
||||
case "cursor":
|
||||
f.Cursor = value[0]
|
||||
case "pageSize":
|
||||
if !positiveInteger.MatchString(value[0]) {
|
||||
return f, invalid
|
||||
}
|
||||
n, err := strconv.Atoi(value[0])
|
||||
if err != nil || n > 50 {
|
||||
return f, invalid
|
||||
}
|
||||
f.PageSize = n
|
||||
default:
|
||||
return f, invalid
|
||||
}
|
||||
}
|
||||
_, err = decodeCursor(f)
|
||||
return f, err
|
||||
}
|
||||
|
||||
func (s *service) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if _, exists := s.projects[r.PathValue("project")]; !exists {
|
||||
reject(w, 404, "not_found")
|
||||
return
|
||||
}
|
||||
var filters Filters
|
||||
var id int64
|
||||
var err error
|
||||
if value := r.PathValue("id"); value != "" {
|
||||
if !positiveInteger.MatchString(value) || r.URL.RawQuery != "" {
|
||||
reject(w, 400, "invalid_request")
|
||||
return
|
||||
}
|
||||
id, err = strconv.ParseInt(value, 10, 64)
|
||||
} else {
|
||||
filters, err = parseFilters(r.URL.RawQuery)
|
||||
}
|
||||
if err != nil {
|
||||
reject(w, 400, "invalid_request")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.slots <- struct{}{}:
|
||||
defer func() { <-s.slots }()
|
||||
default:
|
||||
w.Header().Set("Retry-After", "1")
|
||||
reject(w, 429, "research_busy")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.timeout)
|
||||
defer cancel()
|
||||
var result any
|
||||
if id != 0 {
|
||||
result, err = s.repository.Get(ctx, id)
|
||||
} else {
|
||||
result, err = s.repository.List(ctx, filters)
|
||||
}
|
||||
if err == nil {
|
||||
reply(w, 200, result)
|
||||
return
|
||||
}
|
||||
var state interface{ SQLState() string }
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
reject(w, 404, "not_found")
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded), errors.Is(err, context.DeadlineExceeded), errors.As(err, &state) && state.SQLState() == "57014":
|
||||
reject(w, 504, "research_timeout")
|
||||
default:
|
||||
reject(w, 503, "research_unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fixtureRepository struct {
|
||||
filters Filters
|
||||
id int64
|
||||
err error
|
||||
wait bool
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fixtureRepository) List(ctx context.Context, filters Filters) (Page, error) {
|
||||
f.calls++
|
||||
f.filters = filters
|
||||
if f.wait {
|
||||
<-ctx.Done()
|
||||
return Page{}, ctx.Err()
|
||||
}
|
||||
return Page{Items: []Paper{}, Topics: []string{}, PageSize: filters.PageSize}, f.err
|
||||
}
|
||||
|
||||
func (f *fixtureRepository) Get(ctx context.Context, id int64) (Paper, error) {
|
||||
f.calls++
|
||||
f.id = id
|
||||
return Paper{ID: "42", Title: "Original title", Authors: []string{}, Topics: []string{}, Sources: []Source{}}, f.err
|
||||
}
|
||||
|
||||
func request(handler http.Handler, method, path string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(method, path, nil))
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAnonymousReadRoutes(t *testing.T) {
|
||||
f := &fixtureRepository{}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, f, map[string]string{"thesisly": "http://localhost:17322/auth/callback"})
|
||||
w := request(mux, "GET", "/projects/thesisly/research/papers?q=graph&searchIn=author&sort=title&topic=cs.LG&pageSize=10")
|
||||
if w.Code != 200 || f.filters != (Filters{Query: "graph", SearchIn: "author", Topic: "cs.LG", Sort: "title", PageSize: 10}) {
|
||||
t.Fatalf("list: %d %+v", w.Code, f.filters)
|
||||
}
|
||||
var page Page
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil || page.Items == nil || page.Topics == nil {
|
||||
t.Fatalf("invalid page: %s", w.Body.String())
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatal("missing response headers")
|
||||
}
|
||||
w = request(mux, "GET", "/projects/thesisly/research/papers/42")
|
||||
if w.Code != 200 || f.id != 42 || !strings.Contains(w.Body.String(), `"abstract":null`) {
|
||||
t.Fatalf("detail: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := request(mux, "POST", "/projects/thesisly/research/papers").Code; got != 405 {
|
||||
t.Fatalf("mutation status %d", got)
|
||||
}
|
||||
if got := request(mux, "GET", "/projects/other/research/papers").Code; got != 404 {
|
||||
t.Fatalf("unknown project status %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidQueriesNeverReachRepository(t *testing.T) {
|
||||
queries := []string{"unknown=1", "q=x&q=y", "q=%ff", "q=%00", "q=%zz", "page=0", "page=1001", "page=-1", "page=1.5", "page=01", "pageSize=51", "pageSize=0", "pageSize=9999999999999999999999", "searchIn=sql", "sort=published", "topic=arxiv:cs.LG", "q=" + url.QueryEscape(strings.Repeat("字", 201)), "q=" + url.QueryEscape(strings.Repeat("word ", 21))}
|
||||
for _, query := range queries {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
f := &fixtureRepository{}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, f, map[string]string{"thesisly": "unused"})
|
||||
w := request(mux, "GET", "/projects/thesisly/research/papers?"+query)
|
||||
if w.Code != 400 || f.calls != 0 {
|
||||
t.Fatalf("status %d, calls %d", w.Code, f.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, path := range []string{"0", "-1", "1x", "9223372036854775808", "1?extra=1"} {
|
||||
mux := http.NewServeMux()
|
||||
f := &fixtureRepository{}
|
||||
Register(mux, f, map[string]string{"thesisly": "unused"})
|
||||
if w := request(mux, "GET", "/projects/thesisly/research/papers/"+path); w.Code != 400 || f.calls != 0 {
|
||||
t.Fatalf("invalid id %q: %d", path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDefaultsAndUnicode(t *testing.T) {
|
||||
f, err := parseFilters("q=" + url.QueryEscape(" 论文 Graph "))
|
||||
if err != nil || f.Query != "论文 Graph" || f.PageSize != 8 || f.SearchIn != "text" || f.Sort != "recent" {
|
||||
t.Fatalf("%+v %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
type databaseFailure string
|
||||
|
||||
func (failure databaseFailure) Error() string { return "private database error" }
|
||||
func (failure databaseFailure) SQLState() string { return string(failure) }
|
||||
|
||||
func TestStorageErrorsAreRedacted(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{sql.ErrNoRows, 404, "not_found"},
|
||||
{context.DeadlineExceeded, 504, "research_timeout"},
|
||||
{errors.Join(databaseFailure("57014"), errors.New("private statement")), 504, "research_timeout"},
|
||||
{databaseFailure("42501"), 503, "research_unavailable"},
|
||||
{errors.New("postgres://private-user:private-password@db/corpus"), 503, "research_unavailable"},
|
||||
} {
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, &fixtureRepository{err: test.err}, map[string]string{"thesisly": "unused"})
|
||||
w := request(mux, "GET", "/projects/thesisly/research/papers/42")
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.code) || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrencyAndTimeoutReleaseSlots(t *testing.T) {
|
||||
s := &service{repository: &fixtureRepository{wait: true}, projects: map[string]string{"thesisly": "unused"}, slots: make(chan struct{}, 2), timeout: time.Millisecond}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /projects/{project}/research/papers", s.serve)
|
||||
s.slots <- struct{}{}
|
||||
s.slots <- struct{}{}
|
||||
w := request(mux, "GET", "/projects/thesisly/research/papers")
|
||||
if w.Code != 429 || w.Header().Get("Retry-After") != "1" {
|
||||
t.Fatalf("busy %d", w.Code)
|
||||
}
|
||||
<-s.slots
|
||||
<-s.slots
|
||||
w = request(mux, "GET", "/projects/thesisly/research/papers")
|
||||
if w.Code != 504 || len(s.slots) != 0 {
|
||||
t.Fatalf("timeout %d, slots %d", w.Code, len(s.slots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidCursorsNeverReachRepository(t *testing.T) {
|
||||
f := Filters{Query: "graph", Sort: "recent", SearchIn: "text", PageSize: 8}
|
||||
valid := encodeCursor(f, position{ID: 42}, false)
|
||||
for _, query := range []string{"cursor=!", "cursor=" + strings.Repeat("a", 32769), "q=different&cursor=" + valid, "cursor=" + valid + "&cursor=" + valid, "page=1"} {
|
||||
repository := &fixtureRepository{}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, repository, map[string]string{"thesisly": "unused"})
|
||||
w := request(mux, "GET", "/projects/thesisly/research/papers?"+query)
|
||||
if w.Code != 400 || repository.calls != 0 {
|
||||
t.Fatalf("invalid cursor reached storage: status=%d calls=%d", w.Code, repository.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The fixed Paper Atlas migration a3b677b preserves business columns as TEXT
|
||||
// and BIGINT and creates the English title/abstract GIN expression below.
|
||||
// No schema creation, indexing or other writes belong to this repository.
|
||||
type PostgresRepository struct {
|
||||
db *sql.DB
|
||||
prefix string
|
||||
schema string
|
||||
encoded bool
|
||||
}
|
||||
|
||||
var schemaName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,35}$`)
|
||||
|
||||
func NewPostgresRepository(db *sql.DB, schema string) (*PostgresRepository, error) {
|
||||
if db == nil || !schemaName.MatchString(schema) {
|
||||
return nil, errors.New("invalid research database configuration")
|
||||
}
|
||||
return &PostgresRepository{db: db, prefix: `"` + schema + `".`, schema: schema}, nil
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) query(value string) string {
|
||||
return strings.ReplaceAll(value, "{schema}", r.prefix)
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) begin(ctx context.Context) (*sql.Tx, error) {
|
||||
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `SELECT set_config('statement_timeout','8000',true),
|
||||
set_config('lock_timeout','1000',true), set_config('work_mem','2MB',true),
|
||||
set_config('max_parallel_workers_per_gather','0',true), set_config('jit','off',true)`)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// Check resolves required columns and SELECT permissions without scanning the
|
||||
// corpus. The migration publishes the complete schema atomically.
|
||||
func (r *PostgresRepository) Check(ctx context.Context) error {
|
||||
tx, err := r.begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var annotation sql.NullString
|
||||
err = tx.QueryRowContext(ctx, `SELECT pg_catalog.obj_description(oid,'pg_namespace')
|
||||
FROM pg_catalog.pg_namespace WHERE nspname=$1`, r.schema).Scan(&annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := encodedSchema(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, r.query(`SELECT p.id,p.title,p.abstract,p.authors_json,p.first_published,p.title_key,
|
||||
r.external_id,r.url,r.metadata_json,so.name,i.scheme,i.value,t.namespace,t.value,
|
||||
s.id,e.year,e.track,v.acronym,d.status,d.evidence_url,d.observed_at,d.decision_date
|
||||
FROM {schema}papers p LEFT JOIN {schema}source_records r ON r.paper_id=p.id
|
||||
LEFT JOIN {schema}sources so ON so.id=r.source_id LEFT JOIN {schema}identifiers i ON i.paper_id=p.id
|
||||
LEFT JOIN {schema}paper_tags pt ON pt.paper_id=p.id LEFT JOIN {schema}tags t ON t.id=pt.tag_id
|
||||
LEFT JOIN {schema}submissions s ON s.paper_id=p.id LEFT JOIN {schema}editions e ON e.id=s.edition_id
|
||||
LEFT JOIN {schema}venues v ON v.id=e.venue_id LEFT JOIN {schema}decisions d ON d.submission_id=s.id WHERE false`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.encoded = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func whereClause(filters Filters) (string, []any) {
|
||||
clauses, values := []string{}, []any{}
|
||||
if filters.Query != "" {
|
||||
values = append(values, filters.Query)
|
||||
if filters.SearchIn == "author" {
|
||||
// JSON stores author strings or [family,given,suffix] tuples. Plain
|
||||
// full-text terms search names regardless of tuple display order.
|
||||
clauses = append(clauses, `to_tsvector('simple'::regconfig,p.authors_json) @@ plainto_tsquery('simple'::regconfig,$1)`)
|
||||
} else {
|
||||
clauses = append(clauses, `to_tsvector('english'::regconfig,coalesce(p.title,'') || ' ' || coalesce(p.abstract,'')) @@ plainto_tsquery('english'::regconfig,$1)`)
|
||||
}
|
||||
}
|
||||
if filters.Topic != "" {
|
||||
values = append(values, filters.Topic)
|
||||
clauses = append(clauses, fmt.Sprintf(`EXISTS(SELECT 1 FROM {schema}paper_tags pt
|
||||
JOIN {schema}tags t ON t.id=pt.tag_id
|
||||
WHERE pt.paper_id=p.id AND t.namespace='arxiv:category' AND t.value=$%d)`, len(values)))
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "", values
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), values
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func (r *PostgresRepository) scanPaper(row scanner) (Paper, error) {
|
||||
p := Paper{Authors: []string{}, Topics: []string{}, Sources: []Source{}}
|
||||
var id int64
|
||||
var abstract, published sql.NullString
|
||||
var authors string
|
||||
if err := row.Scan(&id, r.text(&p.Title), r.text(&abstract), r.text(&authors), r.text(&published)); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.ID = strconv.FormatInt(id, 10)
|
||||
if abstract.Valid {
|
||||
p.Abstract = &abstract.String
|
||||
}
|
||||
if published.Valid {
|
||||
p.PublishedAt = &published.String
|
||||
}
|
||||
var err error
|
||||
p.Authors, err = decodeAuthors(authors)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func decodeAuthors(raw string) ([]string, error) {
|
||||
var entries []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
||||
return nil, errors.New("invalid stored authors")
|
||||
}
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
var name string
|
||||
if json.Unmarshal(entry, &name) == nil {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var tuple []string
|
||||
if json.Unmarshal(entry, &tuple) != nil || len(tuple) == 0 || len(tuple) > 4 {
|
||||
return nil, errors.New("unsupported stored author")
|
||||
}
|
||||
// arXiv may append an affiliation after family, given and suffix.
|
||||
// It is source metadata, not part of the display name.
|
||||
if len(tuple) == 4 {
|
||||
tuple = tuple[:3]
|
||||
}
|
||||
if len(tuple) >= 2 {
|
||||
tuple[0], tuple[1] = tuple[1], tuple[0]
|
||||
}
|
||||
parts := []string{}
|
||||
for _, part := range tuple {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
if name = strings.Join(parts, " "); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) positions(ctx context.Context, tx *sql.Tx, query string, args []any) ([]position, error) {
|
||||
rows, err := tx.QueryContext(ctx, r.query(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
positions := []position{}
|
||||
for rows.Next() {
|
||||
var p position
|
||||
// Preserve the stored title key, including the published text codec.
|
||||
if err = rows.Scan(&p.ID, &p.Key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
positions = append(positions, p)
|
||||
}
|
||||
return positions, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) List(ctx context.Context, filters Filters) (Page, error) {
|
||||
result := Page{Items: []Paper{}, Topics: []string{}, PageSize: filters.PageSize}
|
||||
cursor, err := decodeCursor(filters)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
original := filters
|
||||
tx, err := r.begin(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if r.encoded {
|
||||
filters.Query = encodeText(filters.Query)
|
||||
filters.Topic = encodeText(filters.Topic)
|
||||
}
|
||||
where, args := whereClause(filters)
|
||||
if cursor.ID != 0 {
|
||||
clause := " AND "
|
||||
if where == "" {
|
||||
clause = " WHERE "
|
||||
}
|
||||
op := "<"
|
||||
if cursor.Before {
|
||||
op = ">"
|
||||
}
|
||||
args = append(args, cursor.ID)
|
||||
if filters.Sort == "title" {
|
||||
if cursor.Before {
|
||||
op = "<"
|
||||
} else {
|
||||
op = ">"
|
||||
}
|
||||
args = append(args, cursor.Key)
|
||||
where += fmt.Sprintf("%s(p.title_key,p.id) %s ($%d::text COLLATE \"C\",$%d)", clause, op, len(args), len(args)-1)
|
||||
} else {
|
||||
where += fmt.Sprintf("%sp.id %s $%d", clause, op, len(args))
|
||||
}
|
||||
}
|
||||
order := "p.id DESC"
|
||||
if filters.Sort == "title" {
|
||||
order = "p.title_key,p.id"
|
||||
}
|
||||
if cursor.Before {
|
||||
order = "p.id"
|
||||
if filters.Sort == "title" {
|
||||
order = "p.title_key DESC,p.id DESC"
|
||||
}
|
||||
}
|
||||
args = append(args, filters.PageSize+1)
|
||||
columns := "p.id,p.title_key"
|
||||
if filters.Sort != "title" {
|
||||
columns = "p.id, ''::text AS title_key"
|
||||
}
|
||||
// Search candidates are materialized to avoid scanning millions of wide
|
||||
// rows in ID order for sparse terms. There is no count or deep OFFSET.
|
||||
query := fmt.Sprintf("SELECT %s FROM {schema}papers p%s ORDER BY %s LIMIT $%d", columns, where, order, len(args))
|
||||
if filters.Query != "" || filters.Topic != "" {
|
||||
query = fmt.Sprintf("WITH matches AS MATERIALIZED (SELECT %s FROM {schema}papers p%s) SELECT p.id,p.title_key FROM matches p ORDER BY %s LIMIT $%d", columns, where, order, len(args))
|
||||
}
|
||||
positions := []position{}
|
||||
topicOnly := filters.Sort != "title" && filters.Topic != "" && filters.Query == ""
|
||||
if topicOnly {
|
||||
// The published paper_tags foreign key guarantees these IDs exist.
|
||||
// Walk the (tag_id,paper_id) index instead of joining all matching
|
||||
// paper rows before LIMIT. DISTINCT removes duplicate source evidence.
|
||||
topicArgs := []any{filters.Topic, filters.PageSize + 1}
|
||||
predicate := ""
|
||||
if cursor.ID != 0 {
|
||||
op := "<"
|
||||
if cursor.Before {
|
||||
op = ">"
|
||||
}
|
||||
topicArgs = append(topicArgs, cursor.ID)
|
||||
predicate = " AND pt.paper_id " + op + " $3"
|
||||
}
|
||||
direction := "DESC"
|
||||
if cursor.Before {
|
||||
direction = "ASC"
|
||||
}
|
||||
query := fmt.Sprintf(`SELECT DISTINCT pt.paper_id,''::text AS title_key FROM {schema}paper_tags pt
|
||||
WHERE pt.tag_id=(SELECT id FROM {schema}tags WHERE namespace='arxiv:category' AND value=$1)%s
|
||||
ORDER BY pt.paper_id %s LIMIT $2`, predicate, direction)
|
||||
positions, err = r.positions(ctx, tx, query, topicArgs)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if !topicOnly && filters.Sort != "title" && (filters.Query != "" || filters.Topic != "") {
|
||||
// Common terms can fill a page near its cursor. Probe a bounded ID
|
||||
// interval first; never scan the entire heap in order for a rare term.
|
||||
// If the interval has fewer than size+1 matches, discard it and use
|
||||
// the complete indexed search below. No matches are silently omitted.
|
||||
anchor := cursor.ID
|
||||
if anchor == 0 {
|
||||
if err = tx.QueryRowContext(ctx, r.query(`SELECT coalesce(max(id),0) FROM {schema}papers`)).Scan(&anchor); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
for _, window := range []int64{512, 4096} {
|
||||
bound, op := int64(0), ">="
|
||||
if anchor > window {
|
||||
bound = anchor - window
|
||||
}
|
||||
if cursor.Before {
|
||||
bound, op = int64(9223372036854775807), "<="
|
||||
if anchor <= 9223372036854775807-window {
|
||||
bound = anchor + window
|
||||
}
|
||||
}
|
||||
boundedArgs := append(append([]any{}, args...), bound)
|
||||
bounded := fmt.Sprintf("SELECT %s FROM {schema}papers p%s AND p.id %s $%d ORDER BY %s LIMIT $%d", columns, where, op, len(boundedArgs), order, len(args))
|
||||
positions, err = r.positions(ctx, tx, bounded, boundedArgs)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
// Expand once only when there is evidence of nearby matches.
|
||||
// Zero matches goes straight to the sparse-term index fallback.
|
||||
if len(positions) == 0 || len(positions) >= filters.PageSize+1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !topicOnly && len(positions) < filters.PageSize+1 {
|
||||
positions, err = r.positions(ctx, tx, query, args)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
more := len(positions) > filters.PageSize
|
||||
if more {
|
||||
positions = positions[:filters.PageSize]
|
||||
}
|
||||
if cursor.Before {
|
||||
for i, j := 0, len(positions)-1; i < j; i, j = i+1, j-1 {
|
||||
positions[i], positions[j] = positions[j], positions[i]
|
||||
}
|
||||
}
|
||||
if len(positions) > 0 {
|
||||
if (!cursor.Before && cursor.ID != 0) || (cursor.Before && more) {
|
||||
result.PreviousCursor = encodeCursor(original, positions[0], true)
|
||||
}
|
||||
if (!cursor.Before && more) || (cursor.Before && cursor.ID != 0) {
|
||||
result.NextCursor = encodeCursor(original, positions[len(positions)-1], false)
|
||||
}
|
||||
ids := make([]int64, len(positions))
|
||||
byID := map[string]Paper{}
|
||||
for i, p := range positions {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, r.query(`SELECT id,title,abstract,authors_json,first_published FROM {schema}papers WHERE id=ANY($1)`), ids)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for rows.Next() {
|
||||
p, scanErr := r.scanPaper(rows)
|
||||
if scanErr != nil {
|
||||
_ = rows.Close()
|
||||
return result, scanErr
|
||||
}
|
||||
byID[p.ID] = p
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, p := range positions {
|
||||
paper, exists := byID[strconv.FormatInt(p.ID, 10)]
|
||||
if !exists {
|
||||
return result, errors.New("inconsistent paper index")
|
||||
}
|
||||
result.Items = append(result.Items, paper)
|
||||
}
|
||||
}
|
||||
if err = r.enrich(ctx, tx, result.Items); err != nil {
|
||||
return result, err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, r.query(`SELECT value FROM {schema}tags WHERE namespace='arxiv:category' ORDER BY value`))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var topic string
|
||||
if err = rows.Scan(r.text(&topic)); err != nil {
|
||||
_ = rows.Close()
|
||||
return result, err
|
||||
}
|
||||
result.Topics = append(result.Topics, topic)
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, tx.Commit()
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) Get(ctx context.Context, id int64) (Paper, error) {
|
||||
tx, err := r.begin(ctx)
|
||||
if err != nil {
|
||||
return Paper{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
p, err := r.scanPaper(tx.QueryRowContext(ctx, r.query(`SELECT id,title,abstract,authors_json,first_published FROM {schema}papers WHERE id=$1`), id))
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
papers := []Paper{p}
|
||||
if err = r.enrich(ctx, tx, papers); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return papers[0], tx.Commit()
|
||||
}
|
||||
|
||||
// Batch metadata queries avoid per-paper round trips and share the page
|
||||
// snapshot. User values are bound; only the validated schema is interpolated.
|
||||
func (r *PostgresRepository) enrich(ctx context.Context, tx *sql.Tx, papers []Paper) error {
|
||||
if len(papers) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, len(papers))
|
||||
byID := map[int64]*Paper{}
|
||||
for i := range papers {
|
||||
id, err := strconv.ParseInt(papers[i].ID, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids[i] = id
|
||||
byID[id] = &papers[i]
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, r.query(`SELECT DISTINCT pt.paper_id,t.value
|
||||
FROM {schema}paper_tags pt JOIN {schema}tags t ON t.id=pt.tag_id
|
||||
WHERE pt.paper_id=ANY($1) AND t.namespace='arxiv:category' ORDER BY pt.paper_id,t.value`), ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var topic string
|
||||
if err = rows.Scan(&id, r.text(&topic)); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
byID[id].Topics = append(byID[id].Topics, topic)
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, r.query(`SELECT r.paper_id,s.name,r.external_id,r.url,r.metadata_json
|
||||
FROM {schema}source_records r JOIN {schema}sources s ON s.id=r.source_id
|
||||
WHERE r.paper_id=ANY($1) ORDER BY r.paper_id,r.id`), ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var source Source
|
||||
var metadata string
|
||||
if err = rows.Scan(&id, r.text(&source.Label), r.text(&source.ExternalID), r.text(&source.URL), r.text(&metadata)); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
p := byID[id]
|
||||
switch source.Label {
|
||||
case "arxiv":
|
||||
source.Label = "arXiv"
|
||||
case "openreview":
|
||||
source.Label = "OpenReview"
|
||||
case "pmlr":
|
||||
source.Label = "PMLR"
|
||||
}
|
||||
p.Sources = append(p.Sources, source)
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(metadata), &fields) == nil {
|
||||
for key, target := range map[string]**string{"doi": &p.DOI, "journal-ref": &p.Journal, "comments": &p.Comments} {
|
||||
var value string
|
||||
if *target == nil && json.Unmarshal(fields[key], &value) == nil && strings.TrimSpace(value) != "" {
|
||||
*target = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, r.query(`SELECT paper_id,value FROM {schema}identifiers WHERE paper_id=ANY($1) AND scheme='doi' ORDER BY paper_id,value`), ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var value string
|
||||
if err = rows.Scan(&id, r.text(&value)); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
if byID[id].DOI == nil && strings.TrimSpace(value) != "" {
|
||||
byID[id].DOI = &value
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, r.query(`SELECT s.paper_id,v.acronym,e.year,e.track,coalesce(d.status,'unknown'),coalesce(d.evidence_url,'')
|
||||
FROM {schema}submissions s JOIN {schema}editions e ON e.id=s.edition_id JOIN {schema}venues v ON v.id=e.venue_id
|
||||
LEFT JOIN LATERAL (SELECT status,evidence_url FROM {schema}decisions WHERE submission_id=s.id
|
||||
ORDER BY observed_at DESC,coalesce(decision_date,observed_at) DESC,id DESC LIMIT 1) d ON true
|
||||
WHERE s.paper_id=ANY($1) ORDER BY s.paper_id,e.year DESC,v.acronym,e.track,s.id`), ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var publication Publication
|
||||
if err = rows.Scan(&id, r.text(&publication.Venue), &publication.Year, r.text(&publication.Track), r.text(&publication.Status), r.text(&publication.EvidenceURL)); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
byID[id].Publications = append(byID[id].Publications, publication)
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func integrationRepository(t *testing.T) (*PostgresRepository, context.Context) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("RESEARCH_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires the disposable research fixture database")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
db.SetMaxOpenConns(2)
|
||||
repository, err := NewPostgresRepository(db, "research_fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
return repository, ctx
|
||||
}
|
||||
|
||||
func paperIDs(papers []Paper) []string {
|
||||
ids := []string{}
|
||||
for _, paper := range papers {
|
||||
ids = append(ids, paper.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestPostgresPageSearchAndFilters(t *testing.T) {
|
||||
r, ctx := integrationRepository(t)
|
||||
if err := r.Check(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
filters Filters
|
||||
ids []string
|
||||
}{
|
||||
{Filters{PageSize: 2}, []string{"5", "4"}},
|
||||
{Filters{PageSize: 2, Sort: "title"}, []string{"2", "5"}},
|
||||
{Filters{PageSize: 8, Query: "graph"}, []string{"3", "1"}},
|
||||
{Filters{PageSize: 8, Query: "graph OR zebra"}, []string{}},
|
||||
{Filters{PageSize: 8, Query: "Ada Smith", SearchIn: "author"}, []string{"1"}},
|
||||
{Filters{PageSize: 8, Topic: "cs.LG"}, []string{"1"}},
|
||||
{Filters{PageSize: 8, Topic: "cs.DC", Query: "graph"}, []string{"3"}},
|
||||
{Filters{PageSize: 8, Query: `x'); DROP TABLE papers;--`}, []string{}},
|
||||
} {
|
||||
page, err := r.List(ctx, test.filters)
|
||||
if err != nil {
|
||||
t.Fatalf("%+v: %v", test.filters, err)
|
||||
}
|
||||
if !reflect.DeepEqual(paperIDs(page.Items), test.ids) {
|
||||
t.Fatalf("%+v: ids=%v", test.filters, paperIDs(page.Items))
|
||||
}
|
||||
if !reflect.DeepEqual(page.Topics, []string{"cs.AI", "cs.DC", "cs.LG"}) {
|
||||
t.Fatalf("mixed tag namespaces: %v", page.Topics)
|
||||
}
|
||||
if page.PageSize != test.filters.PageSize {
|
||||
t.Fatal("lost paging metadata")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresMetadataPreservesEvidence(t *testing.T) {
|
||||
r, ctx := integrationRepository(t)
|
||||
p, err := r.Get(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Title != "Graph & learning <original>" || p.Abstract == nil || *p.Abstract != "Neural graph models preserve original abstracts." || !reflect.DeepEqual(p.Authors, []string{"Ada Smith", "The Research Group"}) {
|
||||
t.Fatalf("original content changed: %+v", p)
|
||||
}
|
||||
if !reflect.DeepEqual(p.Topics, []string{"cs.AI", "cs.LG"}) || len(p.Sources) != 1 || p.Sources[0].ExternalID != "fixture-1" || p.Sources[0].Label != "arXiv" {
|
||||
t.Fatalf("incorrect source/tag joins: %+v", p)
|
||||
}
|
||||
if p.DOI == nil || *p.DOI != "10.fixture/canonical" || p.Journal == nil || *p.Journal != "Journal Fixture" || p.Comments == nil || *p.Comments != "Original comment" {
|
||||
t.Fatalf("metadata missing: %+v", p)
|
||||
}
|
||||
if len(p.Publications) != 1 || p.Publications[0].Status != "withdrawn" || p.Publications[0].EvidenceURL != "https://example.test/decision/3" {
|
||||
t.Fatalf("latest decision ordering changed: %+v", p.Publications)
|
||||
}
|
||||
p, err = r.Get(ctx, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(p.Authors, []string{"Carla Network"}) || len(p.Sources) != 2 || p.DOI == nil || *p.DOI != "10.fixture/other" || len(p.Publications) != 1 || p.Publications[0].Status != "unknown" || p.Publications[0].EvidenceURL != "" {
|
||||
t.Fatalf("unknown decision/source mapping: %+v", p)
|
||||
}
|
||||
p, err = r.Get(ctx, 2)
|
||||
if err != nil || p.Abstract != nil || p.PublishedAt != nil || p.Sources == nil || p.Topics == nil {
|
||||
t.Fatalf("nullable data changed: %+v %v", p, err)
|
||||
}
|
||||
if _, err = r.Get(ctx, 99999); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("missing paper: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresCorpusIsReadOnlyAndHTTPWorks(t *testing.T) {
|
||||
r, ctx := integrationRepository(t)
|
||||
tx, err := r.begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var readOnly, isolation, jit string
|
||||
if err = tx.QueryRowContext(ctx, "SHOW transaction_read_only").Scan(&readOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, "SHOW transaction_isolation").Scan(&isolation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, "SHOW jit").Scan(&jit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
if readOnly != "on" || isolation != "repeatable read" || jit != "off" {
|
||||
t.Fatalf("unexpected read transaction settings: %s %s jit=%s", readOnly, isolation, jit)
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `UPDATE research_fixture.papers SET title='unexpected write' WHERE id=1`)
|
||||
var state interface{ SQLState() string }
|
||||
if !errors.As(err, &state) || state.SQLState() != "42501" {
|
||||
t.Fatalf("expected database permission denial, got %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, r, map[string]string{"thesisly": "http://localhost:17322/auth/callback"})
|
||||
w := request(mux, "GET", "/projects/thesisly/research/papers?q=graph")
|
||||
var page Page
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || len(page.Items) != 2 {
|
||||
t.Fatalf("SQL-backed HTTP list: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = request(mux, "GET", "/projects/thesisly/research/papers/1")
|
||||
var paper Paper
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &paper) != nil || paper.ID != "1" {
|
||||
t.Fatalf("SQL-backed HTTP detail: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w = request(mux, "GET", "/projects/thesisly/research/papers/99999"); w.Code != 404 {
|
||||
t.Fatalf("missing HTTP status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresPublishedTextCodec(t *testing.T) {
|
||||
legacy, ctx := integrationRepository(t)
|
||||
r, err := NewPostgresRepository(legacy.db, "research_encoded_fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = r.Check(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := r.Get(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Title != "Graph\x00original\x01" || p.Abstract == nil || *p.Abstract != "Text\x00tail" || !reflect.DeepEqual(p.Authors, []string{"Ada\x00Smith", "Group\x01Name"}) || p.Comments == nil || *p.Comments != "Original\x00comment" {
|
||||
t.Fatalf("published text was not restored: %+v", p)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, r, map[string]string{"thesisly": "http://localhost:17322/auth/callback"})
|
||||
w := request(mux, "GET", "/projects/thesisly/research/papers/1")
|
||||
var decoded Paper
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &decoded) != nil || decoded.Title != p.Title || decoded.Abstract == nil || *decoded.Abstract != *p.Abstract {
|
||||
t.Fatalf("HTTP lost original control characters: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
page, err := r.List(ctx, Filters{PageSize: 8, Query: "graph"})
|
||||
if err != nil || len(page.Items) != 2 || len(page.Items) != 2 || page.Items[1].Title != p.Title {
|
||||
t.Fatalf("encoded corpus search: %+v %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresCursorsTraverseBothDirections(t *testing.T) {
|
||||
r, ctx := integrationRepository(t)
|
||||
for _, sort := range []string{"recent", "title"} {
|
||||
f := Filters{Sort: sort, PageSize: 1}
|
||||
seen := []string{}
|
||||
cursors := []string{""}
|
||||
for {
|
||||
page, err := r.List(ctx, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("unexpected page %+v", page)
|
||||
}
|
||||
seen = append(seen, page.Items[0].ID)
|
||||
if len(seen) > 5 {
|
||||
t.Fatal("cursor cycle")
|
||||
}
|
||||
if len(seen) > 1 {
|
||||
back := f
|
||||
back.Cursor = page.PreviousCursor
|
||||
prior, err := r.List(ctx, back)
|
||||
if err != nil || len(prior.Items) != 1 || prior.Items[0].ID != seen[len(seen)-2] {
|
||||
t.Fatalf("reverse cursor: %+v %v", prior, err)
|
||||
}
|
||||
} else if page.PreviousCursor != "" {
|
||||
t.Fatal("first page has previous")
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
f.Cursor = page.NextCursor
|
||||
cursors = append(cursors, f.Cursor)
|
||||
}
|
||||
want := []string{"5", "4", "3", "2", "1"}
|
||||
if sort == "title" {
|
||||
want = []string{"2", "5", "1", "3", "4"}
|
||||
}
|
||||
if !reflect.DeepEqual(seen, want) {
|
||||
t.Fatalf("%s: %v", sort, seen)
|
||||
}
|
||||
f.Cursor = cursors[1]
|
||||
f.Query = "different"
|
||||
if _, err := r.List(ctx, f); err == nil {
|
||||
t.Fatal("cursor reused across filters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresEncodedTitleCursors(t *testing.T) {
|
||||
legacy, ctx := integrationRepository(t)
|
||||
r, err := NewPostgresRepository(legacy.db, "research_encoded_fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = r.Check(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := Filters{Sort: "title", PageSize: 1}
|
||||
seen := []string{}
|
||||
for {
|
||||
page, err := r.List(ctx, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("invalid encoded title page %+v", page)
|
||||
}
|
||||
seen = append(seen, page.Items[0].ID)
|
||||
if len(seen) > 5 {
|
||||
t.Fatal("cursor cycle")
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
f.Cursor = page.NextCursor
|
||||
}
|
||||
if !reflect.DeepEqual(seen, []string{"2", "5", "1", "3", "4"}) {
|
||||
t.Fatalf("encoded key paging %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBoundedProbeDoesNotTruncateSparseMatches(t *testing.T) {
|
||||
legacy, ctx := integrationRepository(t)
|
||||
r, err := NewPostgresRepository(legacy.db, "research_sparse_fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = r.Check(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, size := range []int{1, 2, 3} {
|
||||
f := Filters{Query: "graph", PageSize: size}
|
||||
seen := []string{}
|
||||
var previous []string
|
||||
for {
|
||||
page, err := r.List(ctx, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := paperIDs(page.Items)
|
||||
if len(ids) == 0 || len(ids) > size {
|
||||
t.Fatalf("invalid sparse page %+v", page)
|
||||
}
|
||||
if previous != nil {
|
||||
back := f
|
||||
back.Cursor = page.PreviousCursor
|
||||
prior, err := r.List(ctx, back)
|
||||
if err != nil || !reflect.DeepEqual(paperIDs(prior.Items), previous) {
|
||||
t.Fatalf("reverse gap %+v %v", prior, err)
|
||||
}
|
||||
}
|
||||
seen = append(seen, ids...)
|
||||
if len(seen) > 5 {
|
||||
t.Fatal("cursor cycle")
|
||||
}
|
||||
previous = ids
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
f.Cursor = page.NextCursor
|
||||
}
|
||||
if !reflect.DeepEqual(seen, []string{"10001", "10000", "8000", "3", "1"}) {
|
||||
t.Fatalf("size=%d lost sparse matches %v", size, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresTopicCursorSkipsDuplicateEvidence(t *testing.T) {
|
||||
legacy, ctx := integrationRepository(t)
|
||||
r, err := NewPostgresRepository(legacy.db, "research_sparse_fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := Filters{Topic: "cs.LG", PageSize: 1}
|
||||
seen := []string{}
|
||||
for {
|
||||
page, err := r.List(ctx, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("invalid topic page %+v", page)
|
||||
}
|
||||
seen = append(seen, page.Items[0].ID)
|
||||
if len(seen) > 3 {
|
||||
t.Fatal("cursor cycle")
|
||||
}
|
||||
if len(seen) > 1 {
|
||||
back := f
|
||||
back.Cursor = page.PreviousCursor
|
||||
prior, err := r.List(ctx, back)
|
||||
if err != nil || len(prior.Items) != 1 || prior.Items[0].ID != seen[len(seen)-2] {
|
||||
t.Fatalf("reverse topic %+v %v", prior, err)
|
||||
}
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
f.Cursor = page.NextCursor
|
||||
}
|
||||
if !reflect.DeepEqual(seen, []string{"10001", "10000", "1"}) {
|
||||
t.Fatalf("duplicate evidence or lost topic matches %v", seen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoredAuthorShapes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{`[]`, []string{}},
|
||||
{`[["Buhrman","Harry","","CWI"],["Family","Given","Jr.","University"]]`, []string{"Harry Buhrman", "Given Family Jr."}},
|
||||
{`["The BABAR Collaboration","B. Aubert"]`, []string{"The BABAR Collaboration", "B. Aubert"}},
|
||||
{`[["Balázs","C.",""],["Berger","E. L.",""],["Smith","Ada","Jr."]]`, []string{"C. Balázs", "E. L. Berger", "Ada Smith Jr."}},
|
||||
{`[" 张三 ",["Only family"],["","Only given",""],""]`, []string{"张三", "Only family", "Only given"}},
|
||||
} {
|
||||
got, err := decodeAuthors(test.raw)
|
||||
if err != nil || !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("%s: %#v, %v", test.raw, got, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(got)
|
||||
if string(encoded) == "null" {
|
||||
t.Fatal("missing authors must remain an array")
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`broken`, `{"unexpected":"object"}`, `[42]`, `[["a","b","c","d","e"]]`} {
|
||||
if _, err := decodeAuthors(raw); err == nil {
|
||||
t.Fatalf("accepted unsupported authors %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryValuesRemainBound(t *testing.T) {
|
||||
query := `graph'); DROP TABLE papers; -- OR author:*`
|
||||
where, args := whereClause(Filters{Query: query, Topic: "cs.LG", SearchIn: "text"})
|
||||
if strings.Contains(where, query) || !reflect.DeepEqual(args, []any{query, "cs.LG"}) {
|
||||
t.Fatalf("unbound user input: %s %#v", where, args)
|
||||
}
|
||||
if !strings.Contains(where, "plainto_tsquery('english'::regconfig,$1)") || !strings.Contains(where, "t.value=$2") {
|
||||
t.Fatalf("unexpected search contract: %s", where)
|
||||
}
|
||||
where, args = whereClause(Filters{Query: "Ada Smith", SearchIn: "author"})
|
||||
if !strings.Contains(where, "p.authors_json") || strings.Contains(where, "p.abstract") || !reflect.DeepEqual(args, []any{"Ada Smith"}) {
|
||||
t.Fatalf("author search includes unrelated text: %s", where)
|
||||
}
|
||||
where, args = whereClause(Filters{Topic: "cs.AI"})
|
||||
if !strings.Contains(where, "t.value=$1") || !reflect.DeepEqual(args, []any{"cs.AI"}) {
|
||||
t.Fatalf("topic-only binding: %s %#v", where, args)
|
||||
}
|
||||
where, args = whereClause(Filters{})
|
||||
if where != "" || len(args) != 0 {
|
||||
t.Fatal("empty filters changed the corpus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCannotEscapeQualification(t *testing.T) {
|
||||
for _, name := range []string{"", `research";DROP SCHEMA public;--`, "a.b", "Public", strings.Repeat("a", 37)} {
|
||||
if _, err := NewPostgresRepository(&sql.DB{}, name); err == nil {
|
||||
t.Fatalf("accepted schema %q", name)
|
||||
}
|
||||
}
|
||||
if _, err := NewPostgresRepository(nil, "research"); err == nil {
|
||||
t.Fatal("accepted nil database")
|
||||
}
|
||||
r, err := NewPostgresRepository(&sql.DB{}, "research_v2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := r.query(`SELECT p.id FROM {schema}papers p JOIN {schema}source_records s ON s.paper_id=p.id`)
|
||||
if got != `SELECT p.id FROM "research_v2".papers p JOIN "research_v2".source_records s ON s.paper_id=p.id` {
|
||||
t.Fatalf("unqualified corpus tables: %s", got)
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
-- Synthetic fixture for the disposable, network-isolated CI database only.
|
||||
-- Required columns/types follow Paper Atlas's fixed a3b677b migration.
|
||||
CREATE SCHEMA research_fixture;
|
||||
CREATE TABLE research_fixture.papers (
|
||||
id bigint PRIMARY KEY, title text NOT NULL, abstract text, title_key text COLLATE "C" NOT NULL,
|
||||
authors_json text NOT NULL, first_published text
|
||||
);
|
||||
CREATE INDEX papers_title_key ON research_fixture.papers(title_key);
|
||||
CREATE INDEX papers_search_gin ON research_fixture.papers USING gin
|
||||
(to_tsvector('english'::regconfig,coalesce(title,'') || ' ' || coalesce(abstract,'')));
|
||||
CREATE TABLE research_fixture.sources (id bigint PRIMARY KEY,name text NOT NULL);
|
||||
CREATE TABLE research_fixture.source_records (
|
||||
id bigint PRIMARY KEY,source_id bigint REFERENCES research_fixture.sources(id),
|
||||
paper_id bigint REFERENCES research_fixture.papers(id),external_id text,url text,metadata_json text
|
||||
);
|
||||
CREATE TABLE research_fixture.identifiers (scheme text,value text,paper_id bigint REFERENCES research_fixture.papers(id),PRIMARY KEY(scheme,value));
|
||||
CREATE TABLE research_fixture.tags (id bigint PRIMARY KEY,namespace text,value text,UNIQUE(namespace,value));
|
||||
CREATE TABLE research_fixture.paper_tags (
|
||||
paper_id bigint REFERENCES research_fixture.papers(id),tag_id bigint REFERENCES research_fixture.tags(id),
|
||||
evidence_key text,PRIMARY KEY(paper_id,tag_id,evidence_key)
|
||||
);
|
||||
CREATE TABLE research_fixture.venues (id bigint PRIMARY KEY,acronym text);
|
||||
CREATE TABLE research_fixture.editions (id bigint PRIMARY KEY,venue_id bigint REFERENCES research_fixture.venues(id),year bigint,track text);
|
||||
CREATE TABLE research_fixture.submissions (
|
||||
id bigint PRIMARY KEY,paper_id bigint REFERENCES research_fixture.papers(id),edition_id bigint REFERENCES research_fixture.editions(id)
|
||||
);
|
||||
CREATE TABLE research_fixture.decisions (
|
||||
id bigint PRIMARY KEY,submission_id bigint REFERENCES research_fixture.submissions(id),
|
||||
status text,evidence_url text,observed_at text,decision_date text
|
||||
);
|
||||
CREATE INDEX decisions_submission_order ON research_fixture.decisions(submission_id,observed_at DESC,id DESC);
|
||||
|
||||
INSERT INTO research_fixture.papers VALUES
|
||||
(1,'Graph & learning <original>','Neural graph models preserve original abstracts.','graph learning','[["Smith","Ada",""],"The Research Group"]','2024-01-02'),
|
||||
(2,'Alpha topology',NULL,'alpha topology','["Bruno Park"]',NULL),
|
||||
(3,'Graph systems','Graph systems and data.','graph systems','[["Network","Carla","","Fixture University"]]','2023-01-01'),
|
||||
(4,'Zebra methods','Evidence and uncertainty.','zebra methods','[]','2022-01-01'),
|
||||
(5,'Alpha Topology','Another independent record.','alpha topology','["Dana Other"]','2025-01-01');
|
||||
INSERT INTO research_fixture.sources VALUES(10,'arxiv'),(20,'pmlr');
|
||||
INSERT INTO research_fixture.source_records VALUES
|
||||
(101,10,1,'fixture-1','https://example.test/papers/1','{"journal-ref":"Journal Fixture","comments":"Original comment"}'),
|
||||
(202,20,3,'fixture-3','https://example.test/papers/3','{"doi":"10.fixture/other"}'),
|
||||
(203,10,3,'fixture-3-arxiv','https://example.test/papers/3/arxiv','{}');
|
||||
INSERT INTO research_fixture.identifiers VALUES('doi','10.fixture/canonical',1);
|
||||
INSERT INTO research_fixture.tags VALUES(1,'arxiv:category','cs.LG'),(2,'arxiv:category','cs.AI'),(3,'arxiv:category','cs.DC'),(4,'different:namespace','cs.LG');
|
||||
INSERT INTO research_fixture.paper_tags VALUES(1,1,'first'),(1,1,'second'),(1,2,'first'),(3,3,'first'),(2,4,'first');
|
||||
INSERT INTO research_fixture.venues VALUES(1,'ICLR'),(2,'ICML');
|
||||
INSERT INTO research_fixture.editions VALUES(7,1,2024,'main'),(8,2,2023,'main');
|
||||
INSERT INTO research_fixture.submissions VALUES(11,1,7),(12,3,8);
|
||||
INSERT INTO research_fixture.decisions VALUES
|
||||
(1,11,'accepted','https://example.test/decision/1','2024-02-01','2024-01-30'),
|
||||
(2,11,'rejected','https://example.test/decision/2','2024-02-01','2024-01-31'),
|
||||
(3,11,'withdrawn','https://example.test/decision/3','2024-02-01','2024-01-31'),
|
||||
(4,11,'accepted','https://example.test/decision/4','2024-01-01','2099-01-01');
|
||||
|
||||
CREATE ROLE foundry_research_fixture LOGIN;
|
||||
REVOKE ALL ON SCHEMA research_fixture FROM PUBLIC;
|
||||
GRANT USAGE ON SCHEMA research_fixture TO foundry_research_fixture;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA research_fixture TO foundry_research_fixture;
|
||||
|
||||
-- Separate encoded schema mirrors the published Paper Atlas nul-soh-v1 format.
|
||||
CREATE SCHEMA research_encoded_fixture;
|
||||
COMMENT ON SCHEMA research_encoded_fixture IS 'paper-atlas:text-codec=nul-soh-v1';
|
||||
DO $$
|
||||
DECLARE item text;
|
||||
BEGIN
|
||||
FOREACH item IN ARRAY ARRAY['papers','sources','source_records','identifiers','tags','paper_tags','venues','editions','submissions','decisions'] LOOP
|
||||
EXECUTE format('CREATE TABLE research_encoded_fixture.%I (LIKE research_fixture.%I INCLUDING ALL)',item,item);
|
||||
EXECUTE format('INSERT INTO research_encoded_fixture.%I SELECT * FROM research_fixture.%I',item,item);
|
||||
END LOOP;
|
||||
END $$;
|
||||
UPDATE research_encoded_fixture.papers
|
||||
SET title_key='graph'||chr(1)||chr(1)||'key'||chr(1)||chr(2),
|
||||
title='Graph'||chr(1)||chr(1)||'original'||chr(1)||chr(2),
|
||||
abstract='Text'||chr(1)||chr(1)||'tail',
|
||||
authors_json='["Ada\u0000Smith","Group\u0001Name"]'
|
||||
WHERE id=1;
|
||||
UPDATE research_encoded_fixture.source_records
|
||||
SET metadata_json='{"comments":"Original\u0000comment"}' WHERE id=101;
|
||||
REVOKE ALL ON SCHEMA research_encoded_fixture FROM PUBLIC;
|
||||
GRANT USAGE ON SCHEMA research_encoded_fixture TO foundry_research_fixture;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA research_encoded_fixture TO foundry_research_fixture;
|
||||
|
||||
-- A large ID gap proves the bounded probe cannot truncate sparse results.
|
||||
CREATE SCHEMA research_sparse_fixture;
|
||||
DO $$
|
||||
DECLARE item text;
|
||||
BEGIN
|
||||
FOREACH item IN ARRAY ARRAY['papers','sources','source_records','identifiers','tags','paper_tags','venues','editions','submissions','decisions'] LOOP
|
||||
EXECUTE format('CREATE TABLE research_sparse_fixture.%I (LIKE research_fixture.%I INCLUDING ALL)',item,item);
|
||||
EXECUTE format('INSERT INTO research_sparse_fixture.%I SELECT * FROM research_fixture.%I',item,item);
|
||||
END LOOP;
|
||||
END $$;
|
||||
INSERT INTO research_sparse_fixture.papers VALUES
|
||||
(8000,'Graph in expanded interval','Graph evidence.','graph expanded','[]',NULL),
|
||||
(10000,'Graph near cursor','Graph evidence.','graph near cursor','[]',NULL),
|
||||
(10001,'Graph first','Graph evidence.','graph first','[]',NULL);
|
||||
INSERT INTO research_sparse_fixture.paper_tags VALUES(10000,1,'first'),(10000,1,'second'),(10001,1,'first');
|
||||
REVOKE ALL ON SCHEMA research_sparse_fixture FROM PUBLIC;
|
||||
GRANT USAGE ON SCHEMA research_sparse_fixture TO foundry_research_fixture;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA research_sparse_fixture TO foundry_research_fixture;
|
||||
@@ -0,0 +1,88 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Matches Paper Atlas repair 4e62710's nul-soh-v1 protocol: NUL -> SOH/SOH and literal
|
||||
// SOH -> SOH/STX. All other bytes retain their original binary ordering.
|
||||
const textCodec = "nul-soh-v1"
|
||||
|
||||
func encodedSchema(annotation sql.NullString) (bool, error) {
|
||||
const prefix = "paper-atlas:text-codec="
|
||||
if !annotation.Valid || !strings.HasPrefix(annotation.String, prefix) {
|
||||
return false, nil
|
||||
}
|
||||
if strings.TrimPrefix(annotation.String, prefix) != textCodec {
|
||||
return false, errors.New("unsupported research text codec")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func encodeText(value string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(value, "\x01", "\x01\x02"), "\x00", "\x01\x01")
|
||||
}
|
||||
|
||||
func decodeText(value string) (string, error) {
|
||||
if !strings.ContainsRune(value, '\x01') {
|
||||
return value, nil
|
||||
}
|
||||
var result strings.Builder
|
||||
for {
|
||||
position := strings.IndexByte(value, 1)
|
||||
if position < 0 {
|
||||
result.WriteString(value)
|
||||
return result.String(), nil
|
||||
}
|
||||
result.WriteString(value[:position])
|
||||
if position+1 >= len(value) {
|
||||
return "", errors.New("invalid research text escape")
|
||||
}
|
||||
switch value[position+1] {
|
||||
case 1:
|
||||
result.WriteByte(0)
|
||||
case 2:
|
||||
result.WriteByte(1)
|
||||
default:
|
||||
return "", errors.New("invalid research text escape")
|
||||
}
|
||||
value = value[position+2:]
|
||||
}
|
||||
}
|
||||
|
||||
type textColumn struct {
|
||||
encoded bool
|
||||
target any
|
||||
}
|
||||
|
||||
func (column textColumn) Scan(source any) error {
|
||||
var value sql.NullString
|
||||
if err := value.Scan(source); err != nil {
|
||||
return err
|
||||
}
|
||||
if value.Valid && column.encoded {
|
||||
var err error
|
||||
value.String, err = decodeText(value.String)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch target := column.target.(type) {
|
||||
case *string:
|
||||
if !value.Valid {
|
||||
return errors.New("unexpected null research text")
|
||||
}
|
||||
*target = value.String
|
||||
case *sql.NullString:
|
||||
*target = value
|
||||
default:
|
||||
return errors.New("unsupported research text destination")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PostgresRepository) text(target any) sql.Scanner {
|
||||
return textColumn{encoded: r.encoded, target: target}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCorpusTextCodecRoundTripAndOrder(t *testing.T) {
|
||||
values := []string{"", "\x00", "\x00\x01", "\x01", "\x01\x02", "\x02", "A", "A\x00", "A\x01", "Ada 张三 Ω", "Z\x00\x01\x02尾"}
|
||||
for _, value := range values {
|
||||
encoded := encodeText(value)
|
||||
decoded, err := decodeText(encoded)
|
||||
if err != nil || decoded != value || strings.ContainsRune(encoded, 0) {
|
||||
t.Fatalf("round trip failed for %q", value)
|
||||
}
|
||||
for _, other := range values {
|
||||
if strings.Compare(value, other) != strings.Compare(encoded, encodeText(other)) {
|
||||
t.Fatalf("binary ordering changed for %q %q", value, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"\x01", "prefix\x01x", "\x01\x00"} {
|
||||
if _, err := decodeText(invalid); err == nil {
|
||||
t.Fatalf("accepted malformed escape %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorpusTextColumnsRespectCodecAndNulls(t *testing.T) {
|
||||
var required string
|
||||
if err := (textColumn{target: &required}).Scan("\x01\x01"); err != nil || required != "\x01\x01" {
|
||||
t.Fatal("legacy text was decoded")
|
||||
}
|
||||
if err := (textColumn{encoded: true, target: &required}).Scan([]byte("before\x01\x01after\x01\x02")); err != nil || required != "before\x00after\x01" {
|
||||
t.Fatalf("encoded text did not decode: %q %v", required, err)
|
||||
}
|
||||
var nullable sql.NullString
|
||||
if err := (textColumn{encoded: true, target: &nullable}).Scan(nil); err != nil || nullable.Valid {
|
||||
t.Fatal("null changed into empty text")
|
||||
}
|
||||
if err := (textColumn{encoded: true, target: &nullable}).Scan("\x01\x01"); err != nil || !nullable.Valid || nullable.String != "\x00" {
|
||||
t.Fatal("nullable text was not restored")
|
||||
}
|
||||
if err := (textColumn{encoded: true, target: &required}).Scan(nil); err == nil {
|
||||
t.Fatal("accepted null for required text")
|
||||
}
|
||||
if err := (textColumn{encoded: true, target: &required}).Scan("\x01x"); err == nil {
|
||||
t.Fatal("accepted corrupt encoded text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorpusSchemaCodecMarker(t *testing.T) {
|
||||
for _, annotation := range []sql.NullString{{}, {String: "unrelated schema description", Valid: true}} {
|
||||
encoded, err := encodedSchema(annotation)
|
||||
if encoded || err != nil {
|
||||
t.Fatal("legacy schema changed encoding")
|
||||
}
|
||||
}
|
||||
encoded, err := encodedSchema(sql.NullString{String: "paper-atlas:text-codec=nul-soh-v1", Valid: true})
|
||||
if !encoded || err != nil {
|
||||
t.Fatal("published codec was not recognized")
|
||||
}
|
||||
for _, name := range []string{"unknown", "", "legacy"} {
|
||||
if _, err := encodedSchema(sql.NullString{String: "paper-atlas:text-codec=" + name, Valid: true}); err == nil {
|
||||
t.Fatal("unknown codec was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user