Compare commits

..

52 Commits

Author SHA1 Message Date
codex-bot 506f778ac3 fix(canvas): retain canonical frame rotation across owner changes
Foundry Go checks / check (push) Successful in 3m26s
Foundry Go checks / check (pull_request) Successful in 3m28s
2026-11-11 18:53:06 +08:00
codex-bot 36d69133b8 ci(canvas): verify owning-canvas dissolution and receipt recovery
Foundry Go checks / check (push) Failing after 3m15s
Foundry Go checks / check (pull_request) Failing after 3m8s
2026-11-11 18:42:55 +08:00
codex-bot 5db2bf3c98 ci(canvas): verify the browser migration delivery revision
Foundry Go checks / check (push) Successful in 3m23s
Foundry Go checks / check (pull_request) Successful in 3m18s
2026-11-11 18:06:55 +08:00
codex-bot 3268c57bc6 ci(canvas): verify durable migration coordination with the actual client
Foundry Go checks / check (push) Successful in 3m27s
Foundry Go checks / check (pull_request) Successful in 3m36s
2026-11-11 17:11:14 +08:00
codex-bot d00388504c test(canvas): cover migration admission guards with valid legacy edges
Foundry Go checks / check (push) Successful in 3m36s
Foundry Go checks / check (pull_request) Successful in 3m25s
2026-11-11 16:35:31 +08:00
codex-bot cc3068ace3 test(canvas): verify canonical concurrency and late migration rollback
Foundry Go checks / check (push) Failing after 2m59s
Foundry Go checks / check (pull_request) Failing after 2m55s
2026-11-11 16:29:37 +08:00
codex-bot 1605b5a9d6 ci(canvas): verify managed result copy with durable source files
Foundry Go checks / check (push) Successful in 3m43s
Foundry Go checks / check (pull_request) Successful in 3m36s
2026-11-11 16:09:57 +08:00
codex-bot ca01ea3ea1 ci(canvas): verify host asset display and original generated bytes
Foundry Go checks / check (push) Failing after 3m18s
Foundry Go checks / check (pull_request) Failing after 3m45s
2026-11-11 16:04:44 +08:00
codex-bot 8395baa24f ci(canvas): verify cancellation cursor drain in real client
Foundry Go checks / check (push) Failing after 3m27s
Foundry Go checks / check (pull_request) Failing after 3m25s
2026-11-11 15:55:58 +08:00
codex-bot daec39ead4 ci(canvas): exercise controlled HTTP run polling
Foundry Go checks / check (push) Failing after 3m8s
Foundry Go checks / check (pull_request) Failing after 3m24s
2026-11-11 15:50:45 +08:00
codex-bot 121ce7496c ci(canvas): verify headless AI v2 negotiation fix
Foundry Go checks / check (push) Failing after 3m25s
Foundry Go checks / check (pull_request) Failing after 3m20s
2026-11-11 15:42:24 +08:00
codex-bot c8f3940d26 ci(canvas): pin AI protocol header trace
Foundry Go checks / check (push) Failing after 3m14s
Foundry Go checks / check (pull_request) Failing after 3m23s
2026-11-11 15:35:05 +08:00
codex-bot da6a5f8207 ci(canvas): expose AI admission recovery diagnostics
Foundry Go checks / check (push) Failing after 3m18s
Foundry Go checks / check (pull_request) Failing after 3m23s
2026-11-11 15:29:02 +08:00
codex-bot 6131fb5d7b Verify runtime AI coordination against real HTTP and database state
Foundry Go checks / check (push) Failing after 3m45s
Foundry Go checks / check (pull_request) Failing after 3m12s
2026-11-11 15:13:14 +08:00
codex-bot 28ca98a506 Verify current headless client through the remote HTTP integration runner
Foundry Go checks / check (push) Successful in 3m22s
Foundry Go checks / check (pull_request) Successful in 3m22s
2026-11-11 14:24:48 +08:00
codex-bot 95812c3a96 Verify asset permission revocation and immutable adoption rejection receipts
Foundry Go checks / check (push) Successful in 5m12s
Foundry Go checks / check (pull_request) Successful in 5m0s
2026-11-09 16:36:19 +08:00
codex-bot efa916060a Preserve private asset metadata headers and verify cross-project adoption
Foundry Go checks / check (push) Successful in 3m48s
Foundry Go checks / check (pull_request) Successful in 3m32s
2026-11-09 16:12:36 +08:00
codex-bot cc2ca05cd4 Pin real asset metadata and original byte verification client
Foundry Go checks / check (push) Failing after 3m28s
Foundry Go checks / check (pull_request) Failing after 2m55s
2026-11-09 15:55:20 +08:00
codex-bot be2014cc31 Avoid ambiguous asset metadata and operation receipt routes
Foundry Go checks / check (push) Failing after 3m7s
Foundry Go checks / check (pull_request) Failing after 3m28s
2026-11-09 15:49:58 +08:00
codex-bot 6b07c19728 Expose permission-checked immutable asset metadata for shared depot adoption
Foundry Go checks / check (push) Failing after 6m24s
Foundry Go checks / check (pull_request) Failing after 4m36s
2026-11-09 15:47:19 +08:00
codex-bot 6a0a2e1912 Pin upload Created-response fault injection coverage
Foundry Go checks / check (push) Successful in 3m7s
Foundry Go checks / check (pull_request) Successful in 2m59s
2026-11-09 14:43:25 +08:00
codex-bot 7d7408a0ec Verify scoped image upload and lost receipt recovery with the live headless client
Foundry Go checks / check (push) Failing after 3m5s
Foundry Go checks / check (pull_request) Failing after 3m1s
2026-11-09 14:37:30 +08:00
codex-bot ee04a6372b Pin live partial-scope import and deep-copy integration coverage
Foundry Go checks / check (push) Successful in 4m19s
Foundry Go checks / check (pull_request) Successful in 3m58s
2026-11-09 14:05:21 +08:00
codex-bot d6e877712a Pin corrected Foundry HTTP consumer transport
Foundry Go checks / check (push) Successful in 10m19s
Foundry Go checks / check (pull_request) Successful in 3m41s
2026-11-09 13:50:50 +08:00
codex-bot 6d1a5b5c83 Run pinned Endoleap headless runtime against real HTTP and PostgreSQL in CI
Foundry Go checks / check (push) Failing after 4m1s
Foundry Go checks / check (pull_request) Failing after 4m15s
2026-11-09 13:45:40 +08:00
codex-bot 9cf6e4cedf Bind all canvas element identities to their snapshot owners
Foundry Go checks / check (push) Successful in 2m53s
Foundry Go checks / check (pull_request) Successful in 2m55s
2026-11-09 04:11:26 +08:00
codex-bot ad6ab30b5f Declare snapshot-bound direct canvas membership counts in model streams
Foundry Go checks / check (push) Successful in 5m54s
Foundry Go checks / check (pull_request) Successful in 5m45s
2026-11-09 03:36:37 +08:00
codex-bot 789edcc61b ci: allow cold Go toolchain setup in backend checks
Foundry Go checks / check (push) Successful in 23m24s
Foundry Go checks / check (pull_request) Successful in 23m10s
2026-11-09 02:25:20 +08:00
codex-bot e38254821a Stream negotiated canonical model reads with snapshot framing
Foundry Go checks / check (push) Failing after 10m37s
Foundry Go checks / check (pull_request) Failing after 10m45s
2026-11-09 02:01:46 +08:00
codex-bot ff88865e91 test(canvas): restore a lifetime canceled before its first request
Foundry Go checks / check (push) Successful in 7m0s
Foundry Go checks / check (pull_request) Successful in 5m51s
2026-11-08 04:50:09 +08:00
codex-bot fb1bf4ab85 test(canvas): verify compacted logical coverage and lifecycle records
Foundry Go checks / check (push) Successful in 7m23s
Foundry Go checks / check (pull_request) Successful in 7m40s
2026-11-08 04:36:44 +08:00
codex-bot ca90b3fea3 test(canvas): verify ordered batch receipts and rollback
Foundry Go checks / check (push) Successful in 6m1s
Foundry Go checks / check (pull_request) Successful in 5m52s
2026-11-08 04:28:15 +08:00
codex-bot 31258b995f Avoid duplicate geometry fixture variable in scaled-move coverage
Foundry Go checks / check (push) Successful in 6m34s
Foundry Go checks / check (pull_request) Successful in 3m12s
2026-11-08 02:54:31 +08:00
codex-bot 24b27dcac4 Verify atomic scaled-container unit metadata and inverse writes
Foundry Go checks / check (push) Failing after 4m12s
Foundry Go checks / check (pull_request) Failing after 1m51s
2026-11-08 02:52:29 +08:00
codex-bot d6690c3764 Verify current-owner permissions before moved AI execution
Foundry Go checks / check (push) Successful in 3m4s
Foundry Go checks / check (pull_request) Successful in 2m54s
2026-11-08 01:24:53 +08:00
codex-bot c9bb38454d Preserve relocation context for conflicted AI outputs
Foundry Go checks / check (push) Successful in 3m13s
Foundry Go checks / check (pull_request) Successful in 2m57s
2026-11-08 01:20:06 +08:00
codex-bot 2d4aefbdc0 Remove dependent connections in moved-target deletion fixture
Foundry Go checks / check (push) Successful in 2m51s
Foundry Go checks / check (pull_request) Successful in 3m3s
2026-11-08 01:16:51 +08:00
codex-bot 56b6181598 Resolve canonical AI targets at their current canvas owner
Foundry Go checks / check (push) Failing after 2m49s
Foundry Go checks / check (pull_request) Failing after 2m41s
2026-11-08 01:16:28 +08:00
codex-bot cdf48f1691 Verify AI replan and cancellation completion boundaries
Foundry Go checks / check (push) Successful in 2m54s
Foundry Go checks / check (pull_request) Successful in 2m52s
2026-11-08 01:04:34 +08:00
codex-bot f12c4ad8bd Persist queryable AI action outcomes and zero-effect rejection receipts
Foundry Go checks / check (push) Successful in 2m51s
Foundry Go checks / check (pull_request) Successful in 3m6s
2026-11-08 00:53:11 +08:00
codex-bot 00ff738705 Bind canonical AI admission to the project version vector
Foundry Go checks / check (push) Successful in 2m52s
Foundry Go checks / check (pull_request) Successful in 2m58s
2026-11-08 00:28:13 +08:00
codex-bot fa76773efc Apply canonical AI outputs through scoped identity-checked changes
Foundry Go checks / check (push) Successful in 2m56s
Foundry Go checks / check (pull_request) Successful in 2m47s
2026-11-08 00:23:00 +08:00
codex-bot 0d7fc4dbe5 Preserve staged resource identities and expose upload receipts
Foundry Go checks / check (push) Successful in 2m56s
Foundry Go checks / check (pull_request) Successful in 2m49s
2026-11-07 23:18:20 +08:00
codex-bot bf8763e0de Preserve opaque JSON keys independently of field write guards
Foundry Go checks / check (push) Successful in 2m51s
Foundry Go checks / check (pull_request) Successful in 2m45s
2026-11-07 22:52:31 +08:00
codex-bot 4a89137e38 Embed protocol fixture for isolated integration test binaries
Foundry Go checks / check (push) Successful in 2m42s
Foundry Go checks / check (pull_request) Successful in 2m49s
2026-11-07 22:47:16 +08:00
codex-bot b7499e3815 Verify canonical partition restoration and frontend protocol fixture
Foundry Go checks / check (push) Failing after 3m6s
Foundry Go checks / check (pull_request) Failing after 2m36s
2026-11-07 22:40:39 +08:00
codex-bot 12458e10dc Fix canonical archive writes and retain scoped lifecycle evidence
Foundry Go checks / check (push) Successful in 2m41s
Foundry Go checks / check (pull_request) Successful in 2m40s
2026-11-07 22:34:42 +08:00
codex-bot b85e8bd2ca Add canonical canvas rows, stable identity migration and model operations
Foundry Go checks / check (push) Failing after 2m29s
Foundry Go checks / check (pull_request) Failing after 2m31s
2026-11-07 22:26:06 +08:00
codex-bot 19884b2a90 Stage atomic canvas operation batches and durable receipts
Foundry Go checks / check (push) Successful in 2m40s
Foundry Go checks / check (pull_request) Successful in 2m39s
2026-11-07 22:05:06 +08:00
Zayden-Jung 9f01aa3db8 Merge pull request '移除论文精确计数并优化游标与领域查询 [skip ci]' (#18) from codex/research-cursors into main
Reviewed-on: #18
2026-11-07 09:30:23 +00:00
codex-bot 026ee22c1e Page research without exact counts and bound candidate scans [skip ci] 2026-11-07 17:27:35 +08:00
Zayden-Jung 142c7b2d86 Merge pull request '共享 PostgreSQL 论文只读 API [skip ci]' (#17) from codex/research-api into main
Reviewed-on: #17
2026-11-07 08:23:36 +00:00
48 changed files with 6100 additions and 286 deletions
+15 -3
View File
@@ -4,7 +4,8 @@ on: [push, pull_request]
jobs:
check:
runs-on: ces-control
timeout-minutes: 10
# Cold toolchain installation can exceed ten minutes on the existing runner.
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -68,8 +69,19 @@ jobs:
print("Negative control result:", control if control in known else "unclassified")
sys.exit("Slack client preflight could not verify the configured credentials")
PYSLACK
- name: Verify schema migration in an isolated database
run: bash deploy/check-schema.sh
- name: Fetch pinned headless HTTP integration client
env:
HEADLESS_CLIENT_REVISION: dcbbdad25de7c3b1d6bee317df6074da2c0642eb
run: |
set -eu
mkdir -p .headless-client
curl --fail --silent --show-error --location \
"https://gitea.entroia.com/Zayden-Jung/Endoleap/archive/$HEADLESS_CLIENT_REVISION.tar.gz" \
| tar -xz --strip-components=1 -C .headless-client
test -f .headless-client/tests/headless/foundry.integration.mjs
printf 'Pinned Endoleap client: %s\n' "$HEADLESS_CLIENT_REVISION"
- name: Verify schema migration and real headless HTTP client in an isolated database
run: HEADLESS_CLIENT_SOURCE="$PWD/.headless-client" bash deploy/check-schema.sh
- name: Smoke test HTTP routes
env:
PUBLIC_ORIGIN: https://api.example.com
+103
View File
@@ -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;
+6
View File
@@ -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;
+29
View File
@@ -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;
+9
View File
@@ -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;
+15
View File
@@ -78,3 +78,18 @@ for package in platform auth research; do
-e RESEARCH_TEST_DATABASE_URL=postgres://foundry_research_fixture@127.0.0.1:5432/postgres?sslmode=disable \
--entrypoint /opt/integration-tests postgres:17-alpine -test.v
done
# Run the actual pinned Endoleap runtime against this build's authenticated HTTP
# routes and the same disposable database. Only this remote container needs Node.
if [[ -n "${HEADLESS_CLIENT_SOURCE:-}" ]]; then
test -f "$HEADLESS_CLIENT_SOURCE/tests/headless/foundry.integration.mjs"
docker run --rm --network "container:$container" --user 1000:1000 \
--read-only --cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=32m,mode=1777 \
--mount "type=bind,source=$(pwd)/bin/platform-tests,target=/opt/integration-tests,readonly" \
--mount "type=bind,source=$HEADLESS_CLIENT_SOURCE,target=/opt/headless-client,readonly" \
-e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \
-e FOUNDRY_HEADLESS_SOURCE=/opt/headless-client \
--entrypoint /opt/integration-tests node:24-bookworm-slim \
-test.v -test.run '^TestHeadlessClientHTTPIntegration$' -test.timeout 3m
fi
+60 -9
View File
@@ -46,7 +46,8 @@ 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`), `page` (1–1000), and `pageSize` (1–50, default 8). Unknown, malformed
`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.
@@ -61,17 +62,35 @@ 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`, `total`, `topics`, `page`, and `pageSize`.
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.
The repository counts and reads each list in one read-only snapshot.
Paging first materializes matching IDs, then joins only that page to the wide
paper rows. Filtered searches first materialize matching IDs without an
ORDER BY/LIMIT, then sort/page those IDs, preventing sparse terms from choosing
a backward heap scan with repeated text-vector computation. Activity sampling showed that selecting full rows before LIMIT
caused sparse category queries to read large portions of the paper heap. The
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.
@@ -95,7 +114,7 @@ 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.
Author search returned 146 records in 0.336 seconds. After paging/author fixes, category `cs.AR` returned 9,457 records in 4.906
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
@@ -143,3 +162,35 @@ 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.
+106 -4
View File
@@ -65,6 +65,18 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
reject(w, 400, "invalid_idempotency_key")
return nil
}
resourceID := strings.ToLower(r.Header.Get("X-Resource-Id"))
if resourceID != "" && !uuidSlugPattern.MatchString(resourceID) {
reject(w, 400, "invalid_resource_id")
return nil
}
respond := func(info assetInfo) {
if resourceID == "" {
reply(w, 201, info)
} else {
reply(w, 201, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
}
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
@@ -146,18 +158,28 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
var previous string
err = tx.QueryRowContext(r.Context(), `SELECT id::text,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &previous)
if err == nil {
if previous != hash {
if previous != hash || resourceID != "" && resourceID != info.ID {
reject(w, 409, "idempotency_conflict")
return nil
}
info.URL = s.assetURL(project, workspace, info.ID)
reply(w, 201, info)
respond(info)
return nil
}
if err != sql.ErrNoRows {
return err
}
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key).Scan(&info.ID)
if resourceID != "" {
var occupied bool
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3)`, project, workspace, resourceID).Scan(&occupied); err != nil {
return err
}
if occupied {
reject(w, 409, "resource_identity_conflict")
return nil
}
}
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key,id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,COALESCE(NULLIF($10,'')::uuid,gen_random_uuid())) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key, resourceID).Scan(&info.ID)
if err != nil {
return err
}
@@ -165,7 +187,40 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
return err
}
info.URL = s.assetURL(project, workspace, info.ID)
reply(w, 201, info)
respond(info)
return nil
}
// A missing receipt is unknown: callers may only retry the same resource/key/bytes.
func (s *service) assetOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
key, workspace := r.PathValue("request"), r.PathValue("workspace")
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_idempotency_key")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, _, err = lockWorkspace(r.Context(), tx, project, workspace, account); err != nil {
return err
}
var info assetInfo
err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height)
if err == sql.ErrNoRows {
reply(w, 404, response{"protocol_version": 2, "request_id": key, "status": "unknown", "error": "asset_receipt_unknown"})
return nil
}
if err != nil {
return err
}
info.URL = s.assetURL(project, workspace, info.ID)
reply(w, 200, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
return nil
}
func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account string) (bool, error) {
@@ -216,6 +271,53 @@ func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account
}
return false, nil
}
// Metadata uses the same read policy as original bytes; it is not a persistent grant.
func (s *service) getAssetMetadata(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
id, workspace := r.PathValue("asset"), r.PathValue("workspace")
if !uuidSlugPattern.MatchString(id) {
reject(w, 404, "not_found")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
var locked string
err = tx.QueryRowContext(r.Context(), `SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE`, project, workspace).Scan(&locked)
if err != nil {
return err
}
allowed, err := assetReadable(r, tx, project, workspace, id, account)
if err != nil {
return err
}
if !allowed {
reject(w, 404, "not_found")
return nil
}
var info assetInfo
var digest string
err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height, &digest)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
info.URL = s.assetURL(project, workspace, id)
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(response{"metadata_version": 1, "asset": info, "sha256": digest})
return nil
}
func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
+66 -37
View File
@@ -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
}
+62 -15
View File
@@ -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)
}
+3 -1
View File
@@ -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") {
+81 -53
View File
@@ -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)
}
+11 -1
View File
@@ -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
}
+267
View File
@@ -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")
}
}
+97
View File
@@ -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)
}
}
+14 -12
View File
@@ -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"`
+56 -7
View File
@@ -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
}
+83
View File
@@ -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})
}
+100 -59
View File
@@ -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)
}
+4 -3
View File
@@ -39,17 +39,18 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
}
}
}
ai := response{"available": textAvailable || imageAvailable, "text": textAvailable, "image": imageAvailable}
ai := response{"canonical_target_identity": true, "owner_relocation": true, "admission_protocol_version": 2, "admission_receipt_query": true, "mutation_protocol_version": 2, "terminal_rejection_receipts": true, "available": textAvailable || imageAvailable, "text": textAvailable, "image": imageAvailable}
if modelErr != nil {
ai["unavailable_reason"] = "codex_unavailable"
}
reply(w, 200, response{
"contract_version": "canvas-v2-2026-11-06",
"contract_version": "canvas-headless-2026-11-07",
"canonical_model": response{"document_format_version": canvasModelFormat, "protocol_version": canvasModelProtocol, "explicit_migration": true, "stable_element_ids": true, "atomic_multi_canvas": true, "logical_operation_dedup": true, "legacy_snapshot_writes": false},
"supported_schema_versions": []int{1, 2},
"edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true},
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true},
"ai": ai,
"assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true},
"assets": response{"client_resource_ids": true, "receipt_protocol_version": 2, "previews": true, "preview_max_edge": 1024, "originals_preserved": true},
"limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000},
"retention": response{"idempotency_min_seconds": 86400, "undo_receipt_seconds": 604800},
})
@@ -0,0 +1,83 @@
package platform
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// Runs the real Endoleap runtime against authenticated Foundry HTTP and PostgreSQL.
// This opt-in is enabled by the existing remote schema runner, never local builds.
func TestHeadlessClientHTTPIntegration(t *testing.T) {
source, dsn := os.Getenv("FOUNDRY_HEADLESS_SOURCE"), os.Getenv("PLATFORM_TEST_DATABASE_URL")
if source == "" {
t.Skip("requires remote headless checkout")
}
if dsn == "" {
t.Fatal("headless integration requires isolated PostgreSQL")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
var entropy [12]byte
_, _ = rand.Read(entropy[:])
project := "headless-" + hex.EncodeToString(entropy[:])
defer func() {
for _, table := range []string{"canvas_ai_runs", "canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_assets", "canvas_subgraphs", "workspaces", "project_accounts"} {
_, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project)
}
}()
var account, workspace, root string
if err = db.QueryRowContext(ctx, `INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text`, project).Scan(&account); err != nil {
t.Fatal(err)
}
var tokenBytes [32]byte
_, _ = rand.Read(tokenBytes[:])
token := base64.RawURLEncoding.EncodeToString(tokenBytes[:])
hash := sha256.Sum256([]byte(token))
if _, err = db.ExecContext(ctx, `INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, base64.RawURLEncoding.EncodeToString(hash[:]), project, account); err != nil {
t.Fatal(err)
}
if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Headless integration',$2) RETURNING id::text`, project, account).Scan(&workspace); err != nil {
t.Fatal(err)
}
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin')`, project, workspace, account); err != nil {
t.Fatal(err)
}
legacy := json.RawMessage(`{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"preserved","frameId":"f"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`)
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Root',$3,$4) RETURNING id::text`, project, workspace, account, legacy).Scan(&root); err != nil {
t.Fatal(err)
}
mux := headlessWorkerRoutes(t, db, map[string]string{project: "https://app.example.com/auth/callback"}, project, account)
server := httptest.NewServer(mux)
defer server.Close()
commandContext, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
command := exec.CommandContext(commandContext, "node", filepath.Join(source, "tests/headless/foundry.integration.mjs"))
command.Env = append(os.Environ(), "FOUNDRY_HTTP_URL="+server.URL, "FOUNDRY_TEST_PROJECT="+project, "FOUNDRY_TEST_ACCOUNT="+account, "FOUNDRY_TEST_WORKSPACE="+workspace, "FOUNDRY_TEST_ROOT="+root, "FOUNDRY_TEST_TOKEN="+token, "FOUNDRY_TEST_AI_WORKER=1")
output, err := command.CombinedOutput()
// The fixture token is ephemeral, but never print it even on client failures.
report := strings.ReplaceAll(string(output), token, "[fixture token redacted]")
if err != nil {
t.Fatalf("headless HTTP client failed: %v\n%s", err, report)
}
if !strings.Contains(report, `"status":"ok"`) || !strings.Contains(report, `"runtimeAI":true`) {
t.Fatalf("headless HTTP client did not return success: %s", report)
}
t.Log(report)
}
@@ -0,0 +1,155 @@
package platform
import (
"bytes"
"database/sql"
"encoding/json"
"image"
"image/color"
"image/png"
"net"
"net/http"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex"
)
// Only the generation transport is deterministic. Admission, claims, events,
// canonical writes and action receipts use the real service and isolated DB.
func headlessWorkerRoutes(t *testing.T, db *sql.DB, callbacks map[string]string, project, account string) http.Handler {
t.Helper()
type gate struct {
entered, release chan struct{}
started, released sync.Once
}
var mu sync.Mutex
var held *gate
var executions atomic.Int32
var picture bytes.Buffer
pixels := image.NewRGBA(image.Rect(0, 0, 3, 2))
pixels.Set(0, 0, color.RGBA{B: 255, A: 255})
if err := png.Encode(&picture, pixels); err != nil {
t.Fatal(err)
}
socket := filepath.Join(t.TempDir(), "worker.sock")
listener, err := net.Listen("unix", socket)
if err != nil {
t.Fatal(err)
}
worker := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/canvas/models" {
reply(w, 200, response{"models": []any{
codex.CanvasModel{ID: "fixture-text", Label: "Fixture text", Available: true, InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}, MaxInputBytes: 1 << 20, MaxExecutionMS: 90000},
codex.CanvasModel{ID: "fixture-image", Label: "Fixture image", Available: true, InputModalities: []string{"text", "image"}, OutputModalities: []string{"image"}, MaxInputBytes: 1 << 20, MaxExecutionMS: 90000},
}})
return
}
if r.URL.Path != "/canvas/exec" {
reject(w, 404, "not_found")
return
}
var task codex.CanvasTask
if json.NewDecoder(r.Body).Decode(&task) != nil {
reject(w, 400, "invalid_request")
return
}
executions.Add(1)
mu.Lock()
current := held
mu.Unlock()
if current != nil {
current.started.Do(func() { close(current.entered) })
select {
case <-current.release:
case <-r.Context().Done():
return
}
mu.Lock()
if held == current {
held = nil
}
mu.Unlock()
}
if task.OutputModality == "image" {
reply(w, 200, response{"output": codex.CanvasOutput{Kind: "image", Image: &codex.CanvasImage{MIME: "image/png", Data: picture.Bytes()}}})
} else {
reply(w, 200, response{"result": "headless fixture output"})
}
})}
go worker.Serve(listener)
t.Cleanup(func() {
mu.Lock()
if held != nil {
held.released.Do(func() { close(held.release) })
}
mu.Unlock()
worker.Close()
})
s := &service{db: db, origin: "https://api.example.com", canvasAI: codex.NewCanvasClient(socket)}
mux := http.NewServeMux()
prefix := "/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai"
mux.HandleFunc("GET "+prefix+"/models", s.protected(callbacks, s.canvasAIModels))
mux.HandleFunc("POST "+prefix+"/runs", s.protected(callbacks, s.createCanvasAIRun))
mux.HandleFunc("GET "+prefix+"/runs/{run}", s.protected(callbacks, s.getCanvasAIRun))
mux.HandleFunc("GET "+prefix+"/operations/{request}", s.protected(callbacks, s.getCanvasAIAdmission))
mux.HandleFunc("POST "+prefix+"/runs/{run}/cancel", s.protected(callbacks, s.cancelCanvasAIRun))
mux.HandleFunc("POST "+prefix+"/runs/{run}/apply", s.protected(callbacks, s.applyCanvasAIOutput))
// Private test controls are not registered by production Register().
mux.HandleFunc("POST "+prefix+"/fixture/{action}", s.protected(callbacks, func(w http.ResponseWriter, r *http.Request, p, a string) error {
if p != project || a != account {
reject(w, 403, "forbidden")
return nil
}
switch r.PathValue("action") {
case "arm":
mu.Lock()
held = &gate{entered: make(chan struct{}), release: make(chan struct{})}
mu.Unlock()
case "wait":
mu.Lock()
current := held
mu.Unlock()
if current == nil {
reject(w, 409, "worker_not_armed")
return nil
}
select {
case <-current.entered:
case <-r.Context().Done():
return nil
}
case "release":
mu.Lock()
if held != nil {
held.released.Do(func() { close(held.release) })
}
mu.Unlock()
case "execute":
run, err := s.claimCanvasRun(r.Context())
if err != nil || run == nil || run.Project != project || run.Account != account || run.Workspace != r.PathValue("workspace") {
reject(w, 409, "fixture_run_unavailable")
return nil
}
s.executeCanvasRun(r.Context(), *run)
case "stats":
default:
reject(w, 404, "not_found")
return nil
}
reply(w, 200, response{"executions": executions.Load()})
return nil
}))
regular := http.NewServeMux()
Register(regular, db, callbacks, "https://api.example.com", nil)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/canvas/ai/") {
mux.ServeHTTP(w, r)
} else {
regular.ServeHTTP(w, r)
}
})
}
+450
View File
@@ -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, &copy)
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(&current, &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")
}
}
+41
View File
@@ -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
}
+496
View File
@@ -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")
}
}
+282
View File
@@ -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
}
+320
View File
@@ -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
}
+111
View File
@@ -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")
}
}
}
+244
View File
@@ -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")
}
}
+147
View File
@@ -0,0 +1,147 @@
package platform
import (
"encoding/json"
"errors"
"fmt"
)
const canvasOperationsProtocol = 1
type canvasOperation struct {
ID string `json:"id"`
Type string `json:"type"`
CanvasID string `json:"canvas_id"`
ElementID string `json:"element_id"`
Record json.RawMessage `json:"record,omitempty"`
Patch map[string]json.RawMessage `json:"patch,omitempty"`
}
type canvasOperationBatch struct {
ProtocolVersion int `json:"protocol_version"`
RequestID string `json:"request_id"`
ExpectedVersions map[string]int64 `json:"expected_versions"`
Operations []canvasOperation `json:"operations"`
}
var errInvalidCanvasOperation = errors.New("invalid_canvas_operation")
// applyCanvasOperations stages a complete batch. Input snapshots are never mutated.
// HTTP/auth/version/idempotency are checked by the transaction coordinator.
func applyCanvasOperations(initial map[string]canvasSnapshot, operations []canvasOperation) (map[string]canvasSnapshot, error) {
if len(operations) == 0 || len(operations) > 2500 {
return nil, errInvalidCanvasOperation
}
documents := make(map[string]map[string]json.RawMessage, len(initial))
for id, snapshot := range initial {
var document map[string]json.RawMessage
if json.Unmarshal(snapshot.Document, &document) != nil {
return nil, errInvalidCanvasOperation
}
documents[id] = document
}
seen := map[string]bool{}
for _, operation := range operations {
if !uuidSlugPattern.MatchString(operation.ID) || seen[operation.ID] || !canvasID.MatchString(operation.ElementID) {
return nil, errInvalidCanvasOperation
}
seen[operation.ID] = true
document := documents[operation.CanvasID]
if document == nil {
return nil, errInvalidCanvasOperation
}
collection := ""
switch operation.Type {
case "node.create", "node.patch", "node.delete":
collection = "nodes"
case "edge.create", "edge.patch", "edge.delete":
collection = "edges"
default:
return nil, errInvalidCanvasOperation
}
var records []map[string]json.RawMessage
if json.Unmarshal(document[collection], &records) != nil {
return nil, errInvalidCanvasOperation
}
index := -1
for i, record := range records {
var id string
_ = json.Unmarshal(record["id"], &id)
if id == operation.ElementID {
index = i
break
}
}
switch operation.Type {
case "node.create", "edge.create":
if index >= 0 || len(operation.Record) == 0 || len(operation.Patch) != 0 {
return nil, fmt.Errorf("%w: create conflict", errInvalidCanvasOperation)
}
var record map[string]json.RawMessage
var id string
if json.Unmarshal(operation.Record, &record) != nil || json.Unmarshal(record["id"], &id) != nil || id != operation.ElementID {
return nil, errInvalidCanvasOperation
}
records = append(records, record)
case "node.delete", "edge.delete":
if index < 0 || len(operation.Record) != 0 || len(operation.Patch) != 0 {
return nil, fmt.Errorf("%w: missing delete target", errInvalidCanvasOperation)
}
records = append(records[:index], records[index+1:]...)
case "node.patch", "edge.patch":
if index < 0 || len(operation.Record) != 0 || len(operation.Patch) == 0 {
return nil, errInvalidCanvasOperation
}
for field, value := range operation.Patch {
allowed := field == "data" || collection == "nodes" && (field == "position" || field == "width" || field == "height") || collection == "edges" && (field == "source" || field == "target" || field == "sourceHandle" || field == "targetHandle")
if !allowed {
return nil, errInvalidCanvasOperation
}
if field == "data" {
// Merge individual data fields; a geometry-only patch never rolls back titles/assets.
var before, patch map[string]json.RawMessage
if json.Unmarshal(records[index][field], &before) != nil || json.Unmarshal(value, &patch) != nil || patch == nil {
return nil, errInvalidCanvasOperation
}
if before == nil {
before = map[string]json.RawMessage{}
}
for key, item := range patch {
if string(item) == "null" {
delete(before, key)
} else {
before[key] = item
}
}
encoded, err := json.Marshal(before)
if err != nil {
return nil, err
}
records[index][field] = encoded
} else {
records[index][field] = append(json.RawMessage(nil), value...)
}
}
}
encoded, err := json.Marshal(records)
if err != nil {
return nil, err
}
document[collection] = encoded
}
result := make(map[string]canvasSnapshot, len(initial))
for id, document := range documents {
raw, err := json.Marshal(document)
if err != nil {
return nil, err
}
normalized, valid := normalizeCanvas(raw)
if !valid {
return nil, errInvalidCanvasOperation
}
snapshot := initial[id]
snapshot.Document = normalized
snapshot.Version++
result[id] = snapshot
}
return result, nil
}
+218
View File
@@ -0,0 +1,218 @@
package platform
import (
"database/sql"
"encoding/json"
"io"
"net/http"
"sort"
"time"
)
const canvasOperationsAction = "headless.operations.v1"
func (s *service) submitCanvasOperations(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
if !acceptsCanvasV2(r) {
reject(w, 409, "canvas_schema_upgrade_required")
return nil
}
key := canvasIdempotencyKey(r)
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_idempotency_key")
return nil
}
var input canvasOperationBatch
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, canvasLimit))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF || input.ProtocolVersion != canvasOperationsProtocol || input.RequestID != key || len(input.Operations) == 0 || len(input.Operations) > 2500 || len(input.ExpectedVersions) == 0 || len(input.ExpectedVersions) > 64 {
reject(w, 400, "invalid_canvas_operation_batch")
return nil
}
workspace, board := r.PathValue("workspace"), r.PathValue("material")
if _, ok := input.ExpectedVersions[board]; !ok {
reject(w, 428, "version_required")
return nil
}
participants := make([]string, 0, len(input.ExpectedVersions))
for id, version := range input.ExpectedVersions {
if !uuidSlugPattern.MatchString(id) || version < 1 {
reject(w, 400, "invalid_canvas_operation_batch")
return nil
}
participants = append(participants, id)
}
for _, operation := range input.Operations {
if _, ok := input.ExpectedVersions[operation.CanvasID]; !ok {
reject(w, 428, "version_required")
return nil
}
}
sort.Strings(participants)
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
// lockMaterial serializes the workspace; sorted participants preserve a stable lock order.
for _, id := range participants {
kind, role, e := lockMaterial(r.Context(), tx, project, workspace, id, account)
if e != nil {
return e
}
if kind != "board" {
reject(w, 400, "invalid_material")
return nil
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
}
// Resolve the same request before comparing expected versions: exact retry after lost ACK succeeds.
done, err := transactionRetry(w, r, tx, project, account, board, canvasOperationsAction, key, payloadDigest(input))
if err != nil {
return err
}
if done {
return nil
}
before := make([]canvasSnapshot, 0, len(participants))
initial := map[string]canvasSnapshot{}
for _, id := range participants {
snapshot, e := loadCanvas(r, tx, project, workspace, id)
if e != nil {
return e
}
if snapshot.Version != input.ExpectedVersions[id] {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 412, response{"error": "version_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
initial[id] = snapshot
before = append(before, snapshot)
}
staged, err := applyCanvasOperations(initial, input.Operations)
if err != nil {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_canvas_operation", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
snapshots := make([]canvasSnapshot, 0, len(participants))
versions := map[string]int64{}
// Validate all participant documents before writing any of them.
for _, id := range participants {
snapshot := staged[id]
valid, e := validateCanvasRelations(r, tx, project, workspace, id, initial[id].Document, snapshot.Document)
if e != nil {
return e
}
if !valid {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 409, response{"error": "ownership_conflict", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
if !s.validateCanvasAIModels(w, r, snapshot.Document) {
return nil
}
valid, e = s.validateCanvasAssets(r, tx, project, workspace, account, snapshot.Document)
if e != nil {
return e
}
if !valid {
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 400, response{"error": "invalid_asset_reference", "status": "not-applied", "terminal": true, "request_id": key}, nil, nil)
}
snapshots = append(snapshots, snapshot)
versions[id] = snapshot.Version
}
for _, snapshot := range snapshots {
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
return err
}
}
operationIDs := make([]string, 0, len(input.Operations))
for _, operation := range input.Operations {
operationIDs = append(operationIDs, operation.ID)
}
body := response{"protocol_version": canvasOperationsProtocol, "status": "applied", "request_id": key, "operation_ids": operationIDs, "snapshots": snapshots, "applied_versions": versions, "parent": staged[board]}
return commitCanvasOperationReceipt(w, r, tx, project, account, board, input, 200, body, before, versions)
}
// A terminal rejection also reserves the frozen key/body. Losing that response must
// not turn a definite zero-write result into a future execution of the same request.
func commitCanvasOperationReceipt(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, account, board string, input canvasOperationBatch, status int, body response, before []canvasSnapshot, versions map[string]int64) error {
var transactionID string
if err := tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&transactionID); err != nil {
return err
}
body["transaction_id"] = transactionID
body["protocol_version"] = canvasOperationsProtocol
expires := time.Now().Add(7 * 24 * time.Hour)
bodyRaw, err := json.Marshal(body)
if err != nil {
return err
}
beforeRaw, err := json.Marshal(before)
if err != nil {
return err
}
versionsRaw, err := json.Marshal(versions)
if err != nil {
return err
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, project, r.PathValue("workspace"), board, transactionID, account, canvasOperationsAction, input.RequestID, payloadDigest(input), status, bodyRaw, beforeRaw, versionsRaw, expires)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
if version := versions[board]; version > 0 {
setVersion(w, version)
}
reply(w, status, body)
return nil
}
func (s *service) getCanvasOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
key := r.PathValue("request")
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_request_id")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
workspace, board := r.PathValue("workspace"), r.PathValue("material")
if _, _, err = lockMaterial(r.Context(), tx, project, workspace, board, account); err != nil {
return err
}
var raw json.RawMessage
err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_transactions WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND action=$5 AND idempotency_key=$6`, project, workspace, board, account, canvasOperationsAction, key).Scan(&raw)
if err == sql.ErrNoRows {
reply(w, 404, response{"status": "unknown", "error": "operation_receipt_unknown", "request_id": key})
return nil
}
if err != nil {
return err
}
var result struct {
Snapshots []canvasSnapshot `json:"snapshots"`
}
if err = json.Unmarshal(raw, &result); err != nil {
return err
}
for _, snapshot := range result.Snapshots {
if _, _, err = lockMaterial(r.Context(), tx, project, workspace, snapshot.BoardID, account); err != nil {
return err
}
}
if err = tx.Rollback(); err != nil {
return err
}
reply(w, 200, raw)
return nil
}
@@ -0,0 +1,166 @@
package platform
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
)
func TestCanvasOperationsTransactions(t *testing.T) {
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires an isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
var entropy [12]byte
_, _ = rand.Read(entropy[:])
project := "operations-" + hex.EncodeToString(entropy[:])
defer func() {
for _, table := range []string{"canvas_transactions", "workspaces", "project_accounts"} {
_, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project)
}
}()
accounts, tokens := []string{}, []string{}
for range 2 {
var account string
if err = db.QueryRowContext(ctx, `INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text`, project).Scan(&account); err != nil {
t.Fatal(err)
}
var value [32]byte
_, _ = rand.Read(value[:])
token := base64.RawURLEncoding.EncodeToString(value[:])
hash := sha256.Sum256([]byte(token))
if _, err = db.ExecContext(ctx, `INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, base64.RawURLEncoding.EncodeToString(hash[:]), project, account); err != nil {
t.Fatal(err)
}
accounts, tokens = append(accounts, account), append(tokens, token)
}
var workspace string
if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Operations',$2) RETURNING id::text`, project, accounts[0]).Scan(&workspace); err != nil {
t.Fatal(err)
}
for i, role := range []string{"admin", "viewer"} {
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4)`, project, workspace, accounts[i], role); err != nil {
t.Fatal(err)
}
}
boards := []string{}
for range 2 {
var board string
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Board',$3,$4) RETURNING id::text`, project, workspace, accounts[0], json.RawMessage(operationsFixture)).Scan(&board); err != nil {
t.Fatal(err)
}
boards = append(boards, board)
}
mux := http.NewServeMux()
Register(mux, db, map[string]string{project: "https://app.example.com/auth/callback"}, "https://api.example.com", nil)
path := "/projects/" + project + "/workspaces/" + workspace + "/materials/" + boards[0] + "/canvas/operations"
key := func(n int) string { return fmt.Sprintf("a0000000-0000-4000-8000-%012d", n) }
do := func(method, path, token string, input canvasOperationBatch) *httptest.ResponseRecorder {
encoded, _ := json.Marshal(input)
request := httptest.NewRequest(method, path, bytes.NewReader(encoded))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2")
request.Header.Set("Idempotency-Key", input.RequestID)
writer := httptest.NewRecorder()
mux.ServeHTTP(writer, request)
return writer
}
call := func(method, path, token string, input canvasOperationBatch, status int) response {
t.Helper()
writer := do(method, path, token, input)
if writer.Code != status {
t.Fatalf("%s %s got %d %s want %d", method, path, writer.Code, writer.Body.String(), status)
}
out := response{}
if err := json.Unmarshal(writer.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
version := func(board string) int64 {
t.Helper()
var value int64
if err := db.QueryRowContext(ctx, `SELECT canvas_version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, board).Scan(&value); err != nil {
t.Fatal(err)
}
return value
}
batch := func(n int, expected int64) canvasOperationBatch {
return canvasOperationBatch{ProtocolVersion: 1, RequestID: key(n), ExpectedVersions: map[string]int64{boards[0]: expected}, Operations: []canvasOperation{{ID: key(n + 100), Type: "node.patch", CanvasID: boards[0], ElementID: "a", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"renamed"}`)}}}}
}
firstBody := batch(1, 1)
first := call("POST", path, tokens[0], firstBody, 200)
retried := call("POST", path, tokens[0], firstBody, 200)
if first["transaction_id"] != retried["transaction_id"] || version(boards[0]) != 2 {
t.Fatal("retry duplicated writes or changed receipt identity")
}
queried := call("GET", path+"/"+key(1), tokens[0], canvasOperationBatch{}, 200)
if queried["status"] != "applied" || queried["transaction_id"] != first["transaction_id"] {
t.Fatal("lost acknowledgment cannot be resolved by request identity")
}
altered := batch(1, 2)
call("POST", path, tokens[0], altered, 409)
call("GET", path+"/"+key(1), tokens[1], canvasOperationBatch{}, 404)
call("POST", path, tokens[1], batch(2, 2), 403)
unknown := call("GET", path+"/"+key(999), tokens[0], canvasOperationBatch{}, 404)
if unknown["status"] != "unknown" || unknown["terminal"] == true {
t.Fatal("missing receipt must not claim terminal zero writes")
}
// Terminal zero-write responses must remain queryable and reserve their frozen body.
stale := batch(3, 1)
rejected := call("POST", path, tokens[0], stale, 412)
proof := call("GET", path+"/"+key(3), tokens[0], canvasOperationBatch{}, 200)
if proof["status"] != "not-applied" || proof["terminal"] != true || proof["transaction_id"] != rejected["transaction_id"] {
t.Fatal("terminal rejection was not persisted")
}
call("POST", path, tokens[0], batch(3, 2), 409)
if version(boards[0]) != 2 {
t.Fatal("rejected request performed a write")
}
// Concurrent exact retries serialize to one transaction and one version increment.
concurrent := batch(4, 2)
var wait sync.WaitGroup
results := make(chan *httptest.ResponseRecorder, 4)
for range 4 {
wait.Add(1)
go func() { defer wait.Done(); results <- do("POST", path, tokens[0], concurrent) }()
}
wait.Wait()
close(results)
for result := range results {
if result.Code != 200 {
t.Fatalf("concurrent retry got %d %s", result.Code, result.Body.String())
}
}
if version(boards[0]) != 3 {
t.Fatal("concurrent request executed more than once")
}
// A valid first participant and an invalid second participant produce no partial write.
atomic := batch(5, 3)
atomic.ExpectedVersions[boards[1]] = 1
atomic.Operations = append(atomic.Operations, canvasOperation{ID: key(500), Type: "node.delete", CanvasID: boards[1], ElementID: "missing"})
call("POST", path, tokens[0], atomic, 400)
if version(boards[0]) != 3 || version(boards[1]) != 1 {
t.Fatal("invalid multi-canvas batch partially committed")
}
if call("GET", path+"/"+key(5), tokens[0], canvasOperationBatch{}, 200)["status"] != "not-applied" {
t.Fatal("invalid batch lost its terminal receipt")
}
}
@@ -0,0 +1,63 @@
package platform
import (
"encoding/json"
"testing"
)
const operationsFixture = `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"plain-text","title":"original","text":"hello"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
func TestCanvasOperationBatchStagesAndMerges(t *testing.T) {
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 7, Document: json.RawMessage(operationsFixture)}}
operations := []canvasOperation{
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"renamed"}`)}},
{ID: "22222222-2222-4222-8222-222222222222", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"position": json.RawMessage(`{"x":90,"y":20}`)}},
}
result, err := applyCanvasOperations(initial, operations)
if err != nil {
t.Fatal(err)
}
if result["board"].Version != 8 {
t.Fatal("version did not advance once")
}
var document struct {
Nodes []struct {
Position struct{ X float64 }
Data struct {
Title string
Text string
}
}
}
if err = json.Unmarshal(result["board"].Document, &document); err != nil {
t.Fatal(err)
}
if document.Nodes[0].Data.Title != "renamed" || document.Nodes[0].Data.Text != "hello" || document.Nodes[0].Position.X != 90 {
t.Fatal("field merge lost unrelated content")
}
if string(initial["board"].Document) != operationsFixture {
t.Fatal("input snapshot mutated")
}
}
func TestCanvasOperationBatchRejectsWithoutPartialOutput(t *testing.T) {
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 1, Document: json.RawMessage(operationsFixture)}}
for _, operation := range []canvasOperation{
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "a", Patch: map[string]json.RawMessage{"id": json.RawMessage(`"stolen"`)}},
{ID: "11111111-1111-4111-8111-111111111111", Type: "node.patch", CanvasID: "board", ElementID: "missing", Patch: map[string]json.RawMessage{"data": json.RawMessage(`{"title":"no"}`)}},
{ID: "11111111-1111-4111-8111-111111111111", Type: "edge.create", CanvasID: "board", ElementID: "edge", Record: json.RawMessage(`{"id":"edge","source":"a","target":"missing"}`)},
} {
if result, err := applyCanvasOperations(initial, []canvasOperation{operation}); err == nil || result != nil {
t.Fatal("invalid batch partially accepted")
}
}
}
func TestCanvasOperationBatchValidatesFinalGraphInsteadOfIntermediateState(t *testing.T) {
initial := map[string]canvasSnapshot{"board": {BoardID: "board", Version: 1, Document: json.RawMessage(operationsFixture)}}
operations := []canvasOperation{
{ID: "11111111-1111-4111-8111-111111111111", Type: "edge.create", CanvasID: "board", ElementID: "edge", Record: json.RawMessage(`{"id":"edge","source":"a","target":"b"}`)},
{ID: "22222222-2222-4222-8222-222222222222", Type: "node.create", CanvasID: "board", ElementID: "b", Record: json.RawMessage(`{"id":"b","type":"plainText","position":{"x":20,"y":20},"width":240,"height":72,"data":{"kind":"plain-text","title":"b","text":""}}`)},
}
if _, err := applyCanvasOperations(initial, operations); err != nil {
t.Fatal(err)
}
}
+23 -1
View File
@@ -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))
+115 -2
View File
@@ -495,7 +495,7 @@ func TestOnboardingAndSharing(t *testing.T) {
if err := png.Encode(&assetPNG, image.NewRGBA(image.Rect(0, 0, 3, 2))); err != nil {
t.Fatal(err)
}
upload := func(encoded []byte, key string, status int) response {
upload := func(encoded []byte, key string, status int, resourceIDs ...string) response {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -509,6 +509,9 @@ func TestOnboardingAndSharing(t *testing.T) {
request.Header.Set("Authorization", "Bearer "+tokens[0])
request.Header.Set("Content-Type", writer.FormDataContentType())
request.Header.Set("Idempotency-Key", key)
if len(resourceIDs) > 0 {
request.Header.Set("X-Resource-Id", resourceIDs[0])
}
result := httptest.NewRecorder()
mux.ServeHTTP(result, request)
if result.Code != status {
@@ -525,6 +528,33 @@ func TestOnboardingAndSharing(t *testing.T) {
if uploaded["id"] != retriedAsset["id"] || uploaded["mime_type"] != "image/png" {
t.Fatal("asset idempotency or actual MIME detection")
}
// New uploads keep the resource identity allocated in the frontend durable cut.
resourceID, resourceKey := modelTestID(9100), modelTestID(9101)
managed := upload(assetPNG.Bytes(), resourceKey, 201, resourceID)
if managed["protocol_version"] != float64(2) || managed["request_id"] != resourceKey || managed["asset"].(map[string]any)["id"] != resourceID {
t.Fatal("upload did not retain client resource identity")
}
repeated := upload(assetPNG.Bytes(), resourceKey, 201, resourceID)
if !modelEqual(managed, repeated) {
t.Fatal("resource retry changed receipt")
}
lookup := call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[0], nil, 200)
if lookup["status"] != "applied" || !modelEqual(lookup["asset"], managed["asset"]) {
t.Fatal("lost upload acknowledgment cannot be recovered")
}
unknown := call("GET", workspacePath+"/assets/operations/"+modelTestID(9102), tokens[0], nil, 404)
if unknown["status"] != "unknown" || unknown["terminal"] == true {
t.Fatal("missing asset receipt misreported zero-write")
}
call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[2], nil, 404)
upload(assetPNG.Bytes(), resourceKey, 409, modelTestID(9103))
upload(assetPNG.Bytes(), modelTestID(9104), 409, resourceID)
var alternatePNG bytes.Buffer
if err := png.Encode(&alternatePNG, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
t.Fatal(err)
}
upload(alternatePNG.Bytes(), resourceKey, 409, resourceID)
upload(assetPNG.Bytes(), modelTestID(9105), 400, "invalid-resource")
download := func(token string, status int) {
t.Helper()
request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string), nil)
@@ -554,9 +584,36 @@ func TestOnboardingAndSharing(t *testing.T) {
t.Fatal("preview changed small original or privacy headers")
}
}
// Metadata discloses the same immutable identity only after a fresh read permission check.
metadataPath := workspacePath + "/assets/metadata/" + uploaded["id"].(string)
for _, sample := range []struct {
token string
status int
}{{tokens[0], 200}, {tokens[1], 404}, {tokens[2], 404}} {
request := httptest.NewRequest("GET", metadataPath, nil)
request.Header.Set("Authorization", "Bearer "+sample.token)
result := httptest.NewRecorder()
mux.ServeHTTP(result, request)
if result.Code != sample.status {
t.Fatalf("metadata permission got %d want %d: %s", result.Code, sample.status, result.Body.String())
}
if result.Code == 200 {
var metadata response
if err := json.Unmarshal(result.Body.Bytes(), &metadata); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(assetPNG.Bytes())
if metadata["metadata_version"] != float64(1) || metadata["sha256"] != hex.EncodeToString(digest[:]) || !modelEqual(metadata["asset"], uploaded) {
t.Fatalf("invalid metadata identity: %s", result.Body.String())
}
if result.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("invalid metadata privacy header: %q", result.Header().Get("Cache-Control"))
}
}
}
// Neither a snapshot nor an asset response may keep the workspace locked
// while a slow downstream consumer is accepting its body.
for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string)} {
for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string), metadataPath} {
var probeErr error
writer := &readLockProbe{ResponseRecorder: httptest.NewRecorder(), probe: func() {
lockTx, e := db.BeginTx(ctx, nil)
@@ -649,6 +706,62 @@ func TestOnboardingAndSharing(t *testing.T) {
t.Fatalf("stable URL lost child asset binding: %v count=%d", err, managedBindings)
}
call("POST", assetCanvasPath+"/subgraphs/asset-frame/dissolve", tokens[0], response{"expected_versions": map[string]int64{assetBoard["id"].(string): 5, assetChild: 2}}, 200, "", accept, "40000000-0000-4000-8000-000000000003")
// A warm metadata/original cache is not a future permission grant. An external
// collaborator can read through a source board grant, but a new canonical
// target write must recheck asset readability after that grant is revoked.
sourceGrantPath := workspacePath + "/materials/" + assetBoard["id"].(string) + "/grants"
call("POST", sourceGrantPath, tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
call("GET", metadataPath, tokens[2], nil, 200)
download(tokens[2], 200)
targetBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Asset adoption target", "content": response{}}, 201)
targetID := targetBoard["id"].(string)
targetPath := workspacePath + "/materials/" + targetID
targetGrantPath := targetPath + "/grants"
call("POST", targetGrantPath, tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204)
migrationID := modelTestID(9200)
call("POST", targetPath+"/canvas/model/migrate", tokens[0], response{"protocol_version": 2, "request_id": migrationID, "expected_versions": map[string]int64{targetID: 1}}, 200, "", accept, migrationID)
targetModel := call("GET", targetPath+"/canvas/model", tokens[2], nil, 200)["model"].(map[string]any)
targetVersion := int64(targetModel["versions"].(map[string]any)[targetID].(float64))
adoptedElement := modelTestNode(modelTestID(9201), targetID)
adoptedElement["componentId"] = "image"
adoptedElement["data"] = map[string]any{"title": "Adopted", "text": "", "assetId": uploaded["id"], "src": uploaded["url"]}
adoptionRequest := func(requestID, operationID string) modelBatch {
return modelBatch{ProtocolVersion: 2, RequestID: requestID, Expected: map[string]int64{targetID: targetVersion}, Operations: []modelOperation{{ID: operationID, Changes: []modelChange{{Kind: "element", ID: modelString(adoptedElement["id"]), Before: modelJSON(nil), After: modelJSON(adoptedElement)}}}}}
}
call("DELETE", sourceGrantPath+"/"+accounts[2], tokens[0], nil, 204)
call("GET", metadataPath, tokens[2], nil, 404)
download(tokens[2], 404)
deniedID := modelTestID(9202)
deniedBody := adoptionRequest(deniedID, modelTestID(9203))
denied := call("POST", targetPath+"/canvas/model/operations", tokens[2], deniedBody, 400, "", accept, deniedID)
if denied["status"] != "not-applied" || denied["terminal"] != true || denied["error"] != "invalid_asset_reference" {
t.Fatalf("revoked source did not yield a definitive no-write receipt: %v", denied)
}
unchanged := call("GET", targetPath+"/canvas/model", tokens[2], nil, 200)["model"].(map[string]any)
if !modelEqual(unchanged, targetModel) {
t.Fatal("revoked-source adoption changed target model or versions")
}
var targetBindings int
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM canvas_asset_bindings WHERE project=$1 AND asset_id=$2 AND board_id=$3", project, uploaded["id"], targetID).Scan(&targetBindings); err != nil || targetBindings != 0 {
t.Fatalf("rejected adoption left a target binding: %d %v", targetBindings, err)
}
// Restoring source access does not change the frozen rejected request's result.
call("POST", sourceGrantPath, tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
call("GET", metadataPath, tokens[2], nil, 200)
replayedDenial := call("POST", targetPath+"/canvas/model/operations", tokens[2], deniedBody, 400, "", accept, deniedID)
if !modelEqual(denied, replayedDenial) {
t.Fatal("permission restoration changed a terminal receipt")
}
acceptedID := modelTestID(9204)
call("POST", targetPath+"/canvas/model/operations", tokens[2], adoptionRequest(acceptedID, modelTestID(9205)), 200, "", accept, acceptedID)
call("DELETE", sourceGrantPath+"/"+accounts[2], tokens[0], nil, 204)
// The independently authorized target now owns a binding; source revocation
// must not revoke a resource still readable through that target.
call("GET", metadataPath, tokens[2], nil, 200)
download(tokens[2], 200)
call("DELETE", targetGrantPath+"/"+accounts[2], tokens[0], nil, 204)
call("GET", metadataPath, tokens[2], nil, 404)
download(tokens[2], 404)
download(tokens[1], 200)
upload([]byte("not an image"), "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 400)
+10 -2
View File
@@ -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
}
]
}
]
}
+65
View File
@@ -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
}
+34
View File
@@ -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")
}
}
}
+15 -20
View File
@@ -49,20 +49,20 @@ type Filters struct {
SearchIn string
Topic string
Sort string
Page int
Cursor string
PageSize int
}
type Page struct {
Items []Paper `json:"items"`
Total int64 `json:"total"`
Topics []string `json:"topics"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
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 count, topics and page describe the same database snapshot.
// 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)
@@ -102,7 +102,7 @@ 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", Page: 1, PageSize: 8}
f := Filters{SearchIn: "text", Sort: "recent", PageSize: 8}
values, err := url.ParseQuery(raw)
if err != nil {
return f, err
@@ -133,28 +133,23 @@ func parseFilters(raw string) (Filters, error) {
return f, invalid
}
f.Sort = value[0]
case "page", "pageSize":
case "cursor":
f.Cursor = value[0]
case "pageSize":
if !positiveInteger.MatchString(value[0]) {
return f, invalid
}
n, err := strconv.Atoi(value[0])
limit := 1000
if key == "pageSize" {
limit = 50
}
if err != nil || n > limit {
if err != nil || n > 50 {
return f, invalid
}
if key == "page" {
f.Page = n
} else {
f.PageSize = n
}
f.PageSize = n
default:
return f, invalid
}
}
return f, nil
_, err = decodeCursor(f)
return f, err
}
func (s *service) serve(w http.ResponseWriter, r *http.Request) {
+18 -4
View File
@@ -28,7 +28,7 @@ func (f *fixtureRepository) List(ctx context.Context, filters Filters) (Page, er
<-ctx.Done()
return Page{}, ctx.Err()
}
return Page{Items: []Paper{}, Topics: []string{}, Page: filters.Page, PageSize: filters.PageSize}, f.err
return Page{Items: []Paper{}, Topics: []string{}, PageSize: filters.PageSize}, f.err
}
func (f *fixtureRepository) Get(ctx context.Context, id int64) (Paper, error) {
@@ -47,8 +47,8 @@ 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&page=2&pageSize=10")
if w.Code != 200 || f.filters != (Filters{Query: "graph", SearchIn: "author", Topic: "cs.LG", Sort: "title", Page: 2, PageSize: 10}) {
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
@@ -95,7 +95,7 @@ func TestInvalidQueriesNeverReachRepository(t *testing.T) {
func TestQueryDefaultsAndUnicode(t *testing.T) {
f, err := parseFilters("q=" + url.QueryEscape(" 论文 Graph "))
if err != nil || f.Query != "论文 Graph" || f.Page != 1 || f.PageSize != 8 || f.SearchIn != "text" || f.Sort != "recent" {
if err != nil || f.Query != "论文 Graph" || f.PageSize != 8 || f.SearchIn != "text" || f.Sort != "recent" {
t.Fatalf("%+v %v", f, err)
}
}
@@ -143,3 +143,17 @@ func TestConcurrencyAndTimeoutReleaseSlots(t *testing.T) {
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)
}
}
}
+170 -35
View File
@@ -173,8 +173,31 @@ func decodeAuthors(raw string) ([]string, error) {
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{}, Page: filters.Page, PageSize: filters.PageSize}
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
@@ -185,56 +208,168 @@ func (r *PostgresRepository) List(ctx context.Context, filters Filters) (Page, e
filters.Topic = encodeText(filters.Topic)
}
where, args := whereClause(filters)
if err = tx.QueryRowContext(ctx, r.query(`SELECT count(*) FROM {schema}papers p`+where), args...).Scan(&result.Total); err != nil {
return result, err
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"
}
args = append(args, filters.PageSize, (filters.Page-1)*filters.PageSize)
// Select IDs before loading potentially TOASTed titles/abstracts. With
// sparse categories, selecting full rows here can make the planner walk
// hundreds of thousands of wide heap rows before finding eight matches.
candidateQuery := `SELECT p.id FROM {schema}papers p`
candidateOrder := order
prefix := ""
if where != "" {
columns := "p.id"
if cursor.Before {
order = "p.id"
if filters.Sort == "title" {
columns += ",p.title_key"
order = "p.title_key DESC,p.id DESC"
}
// Apply search before LIMIT/ORDER BY so a sparse term cannot turn
// into a backward heap scan that recomputes vectors for millions.
prefix = fmt.Sprintf(`matches AS MATERIALIZED (SELECT %s FROM {schema}papers p%s), `, columns, where)
candidateQuery = "SELECT id FROM matches"
candidateOrder = strings.ReplaceAll(order, "p.", "")
}
query := fmt.Sprintf(`WITH %spage_ids AS MATERIALIZED (
%s ORDER BY %s LIMIT $%d OFFSET $%d
) SELECT p.id,p.title,p.abstract,p.authors_json,p.first_published
FROM page_ids JOIN {schema}papers p ON p.id=page_ids.id ORDER BY %s`, prefix, candidateQuery, candidateOrder, len(args)-1, len(args), order)
rows, err := tx.QueryContext(ctx, r.query(query), args...)
if err != nil {
return result, err
args = append(args, filters.PageSize+1)
columns := "p.id,p.title_key"
if filters.Sort != "title" {
columns = "p.id, ''::text AS title_key"
}
for rows.Next() {
p, err := r.scanPaper(rows)
// 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 {
_ = rows.Close()
return result, err
}
result.Items = append(result.Items, p)
}
err = rows.Err()
_ = rows.Close()
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`))
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
}
@@ -271,7 +406,7 @@ func (r *PostgresRepository) Get(ctx context.Context, id int64) (Paper, error) {
return papers[0], tx.Commit()
}
// Batch metadata queries avoid per-paper round trips and share the count/page
// 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 {
+180 -17
View File
@@ -50,31 +50,28 @@ func TestPostgresPageSearchAndFilters(t *testing.T) {
}
for _, test := range []struct {
filters Filters
total int64
ids []string
}{
{Filters{Page: 1, PageSize: 2}, 5, []string{"5", "4"}},
{Filters{Page: 2, PageSize: 2}, 5, []string{"3", "2"}},
{Filters{Page: 3, PageSize: 2}, 5, []string{"1"}},
{Filters{Page: 1, PageSize: 2, Sort: "title"}, 5, []string{"2", "5"}},
{Filters{Page: 1, PageSize: 8, Query: "graph"}, 2, []string{"3", "1"}},
{Filters{Page: 1, PageSize: 8, Query: "graph OR zebra"}, 0, []string{}},
{Filters{Page: 1, PageSize: 8, Query: "Ada Smith", SearchIn: "author"}, 1, []string{"1"}},
{Filters{Page: 1, PageSize: 8, Topic: "cs.LG"}, 1, []string{"1"}},
{Filters{Page: 1, PageSize: 8, Topic: "cs.DC", Query: "graph"}, 1, []string{"3"}},
{Filters{Page: 1, PageSize: 8, Query: `x'); DROP TABLE papers;--`}, 0, []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 page.Total != test.total || !reflect.DeepEqual(paperIDs(page.Items), test.ids) {
t.Fatalf("%+v: total=%d ids=%v", test.filters, page.Total, paperIDs(page.Items))
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.Page != test.filters.Page || page.PageSize != test.filters.PageSize {
if page.PageSize != test.filters.PageSize {
t.Fatal("lost paging metadata")
}
}
@@ -143,7 +140,7 @@ func TestPostgresCorpusIsReadOnlyAndHTTPWorks(t *testing.T) {
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 || page.Total != 2 {
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")
@@ -179,8 +176,174 @@ func TestPostgresPublishedTextCodec(t *testing.T) {
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{Page: 1, PageSize: 8, Query: "graph"})
if err != nil || page.Total != 2 || len(page.Items) != 2 || page.Items[1].Title != p.Title {
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)
}
}
+21 -1
View File
@@ -70,7 +70,8 @@ BEGIN
END LOOP;
END $$;
UPDATE research_encoded_fixture.papers
SET title='Graph'||chr(1)||chr(1)||'original'||chr(1)||chr(2),
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;
@@ -79,3 +80,22 @@ 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;