639 lines
40 KiB
Markdown
639 lines
40 KiB
Markdown
# 造物工坊 · Foundry Core
|
||
|
||
共享后端的第一版提供 Google 登录。各项目通过同一个 HTTPS API 登录,在私有 PostgreSQL 的认证库中维护独立账号和会话。项目业务库可分别配置独立的数据库和角色。
|
||
|
||
## 服务端配置
|
||
|
||
1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https://<API 域名>/auth/google/callback`。所有项目信任同一个客户端,因此共享 Google 同意页面的应用名称。
|
||
2. 在服务器私网准备 PostgreSQL 13 或以上版本。在认证库执行一次 `db/001-auth.sql`(使用 `psql -v ON_ERROR_STOP=1`),给运行账号授予这四张表所需的查询及增删改权限。
|
||
3. 按 `.env.example` 注入运行环境变量。每个项目配置一个固定的前端回调地址,本地开发可用 localhost。将 Google 客户端密钥和数据库连接串存入服务器的密钥管理设施。
|
||
4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署工作流仅处理 `main` 上影响后端或部署的改动,将二进制启动在服务器 Docker 私网,由 Traefik 提供 HTTPS。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。
|
||
5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。
|
||
|
||
## 共享论文库(Thesisly)
|
||
|
||
可选只读接入见[共享论文 API](docs/research-api.md)。Thesisly 使用同一后端的 `/projects/{project}/research/papers` 列表、搜索和详情;只有配置服务器上的 `FOUNDRY_RESEARCH_DATABASE_URL` 时才启用。连接串保存在受保护的 `research.env`,不放入前端或 Actions Variables。当前仍等待 Paper Atlas 全量 PostgreSQL 迁移完成及真实数据验收,尚未启用线上论文接口。
|
||
|
||
## Actions 部署配置
|
||
|
||
在仓库的 Actions Secrets 配置 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET`,在 Actions Variables 配置 `FOUNDRY_PROJECTS`。变量值为 JSON 对象,例如 `{"prompt":"http://localhost:17321/auth/callback"}`。部署读取服务器上预先配置的 `/opt/endoleap-infra/foundry/database.env`,OAuth 值由 Actions 在启动容器时注入。
|
||
|
||
Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/google/callback`。工作流会验证数据库就绪、匿名请求返回 401 和 HTTPS 就绪。前端接入后,可在浏览器用 Google 测试账号验证完整登录、回调、兑换和会话。
|
||
|
||
## 前端接入
|
||
|
||
1. 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。
|
||
2. 顶层导航到 `GET /projects/<project>/auth/google?challenge=<challenge>`。Google 回到共享 API 后,API 将浏览器重定向至已配置的项目回调。
|
||
3. 回调页从 URL fragment 读取 `code` 或 `error`,用 history.replaceState 清掉 fragment。收到 code 时,向 `POST /projects/<project>/auth/exchange` 发送 `X-Foundry-Code` 和 `X-Foundry-Verifier` 请求头,随后清理暂存的 verifier。code 有效期一分钟,仅可使用一次。
|
||
4. 将返回的八小时会话凭证保存在内存中,使用 `Authorization: Bearer <access_token>` 调用 `GET /projects/<project>/auth/me`。通过 `POST /projects/<project>/auth/logout` 撤销会话并清理本地凭证。刷新页面后,内存凭证需要通过重新登录获取。
|
||
|
||
同一个 Google 账号首次进入不同项目时,会分别创建用户 ID。会话绑定其签发项目。Google 账号的标识 `sub` 用于认领同一项目的既有账号,邮箱只作为资料字段。当前注册模式为开放注册。
|
||
|
||
## 验收
|
||
|
||
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。
|
||
|
||
## Runtime HTTPS trust
|
||
|
||
The Go backend needs an operating-system CA bundle for Google and Slack HTTPS
|
||
requests. The `node:24-bookworm-slim` runtime does not include that trust store;
|
||
Node's own successful HTTPS checks do not validate Go's trust configuration.
|
||
`deploy/release.sh` mounts the deployment host's
|
||
`/etc/ssl/certs/ca-certificates.crt` read-only and sets `SSL_CERT_FILE` to that
|
||
path. The deployment fails before switching traffic if the host bundle is
|
||
missing or unreadable in the candidate. Keep TLS certificate verification
|
||
enabled and maintain the deployment host's trusted CA package.
|
||
|
||
## Codex execution
|
||
|
||
When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object
|
||
containing only a nonempty `prompt` string and returns `{"result":"…"}`.
|
||
Use `Authorization: Bearer <token>` with a dedicated 32-byte random token
|
||
encoded as 64 hex characters. Ordinary OAuth sessions cannot execute Codex.
|
||
Missing, unknown and revoked tokens return 401. A valid token for a different
|
||
project returns 403. Requests are limited to 32 KiB, CLI output to 512 KiB,
|
||
and each execution to 90 seconds. The worker accepts one execution at a time
|
||
and returns 429 when busy.
|
||
|
||
On the deployment host, provision `/opt/endoleap-infra/foundry/codex` with:
|
||
|
||
- `grants/projects.json` containing a JSON object mapping lowercase hex SHA-256
|
||
digests of the **decoded** token bytes to configured project names. Use mode
|
||
0700 for the directory and 0600 for the file, readable by UID 1000. Rotate or
|
||
revoke tokens by atomically replacing the file within the mounted directory.
|
||
A revocation applies to subsequent requests.
|
||
- `home` owned by UID 1000 with an isolated Codex login in `auth.json`.
|
||
Keep this home dedicated to the worker. Supply the login only and keep user
|
||
configuration, MCP servers, skills, and other workload files out of it.
|
||
|
||
With that directory configured, the next normal Foundry deployment starts the
|
||
worker using the Linux binary built by ces-control and mounts the worker socket
|
||
and grants directory read-only. Normal backend deployments replace the worker with the same reviewed Linux build
|
||
and verify its private model catalog, restoring the previous worker if startup
|
||
fails. For manual maintenance, stop the managed worker before running
|
||
`bash deploy/codex-worker.sh /absolute/path/to/foundry`. The worker
|
||
runs the installed Codex CLI with fixed read-only, no-approval arguments and
|
||
disables command execution tools. It receives prompts via stdin and returns
|
||
the final agent message. The worker has no published port and Foundry receives
|
||
neither the Codex home nor the Docker socket. Keep both containers on the same
|
||
host for Unix socket access.
|
||
|
||
### Board Canvas snapshots and slugs
|
||
|
||
Apply `db/003-canvas.sql` through the normal migration runner before starting this
|
||
version. `GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas`
|
||
returns `{version, document}` and a quoted `ETag`. An untouched board returns
|
||
version 1 and an empty schema-1 graph. `PATCH` takes `{document: <full snapshot>}`
|
||
and the quoted `If-Match`; it atomically replaces the snapshot and returns
|
||
`{version}` with the new `ETag`. Canvas and material metadata have separate
|
||
versions. GET uses existing material read permissions; PATCH requires editor or
|
||
admin. Missing versions return 428, stale versions 412 `version_conflict`, invalid
|
||
graphs 400 `invalid_canvas`, and bodies over 5 MiB (UTF-8 bytes) 413. PNG/JPEG/WebP/
|
||
GIF data URLs up to 2 MiB decoded and HTTPS image URLs are supported. Temporary
|
||
node selection/drag/measurement state and mirrored styles are discarded.
|
||
|
||
Workspace and material create/get/list responses include `slug`. Create and
|
||
metadata PATCH accept an optional slug (1–100 lowercase ASCII letters/digits,
|
||
with single hyphens between segments; UUID-form custom values are reserved).
|
||
Workspace slugs are unique within a project, material slugs within a workspace;
|
||
duplicates return 409. Defaults use the ASCII name/title and UUID, falling back
|
||
to a generic prefix for names without ASCII letters/digits. Existing records are
|
||
backfilled. Renaming leaves slugs stable unless explicitly supplied. Workspace
|
||
and material path parameters accept either ID or slug, with unchanged permissions.
|
||
Canvas uses its board material's slug; there is no separate Canvas identity.
|
||
Slugs enable frontend SEO URLs but do not make private content public or add
|
||
search-engine metadata. Changing a custom slug does not retain an old alias.
|
||
|
||
Frontend follow-up: allowlist the Canvas proxy route and set its body limit to
|
||
at least 5 MiB; load the server snapshot, offer migration of old local drafts,
|
||
serialize autosaves and handle 412 explicitly. Restore node style dimensions
|
||
from width/height and render edges as Bézier curves. These frontend changes are
|
||
outside this Core delivery; local drafts alone are not database persistence.
|
||
|
||
Canvas schema 1 supports unframed images: `type: "plainImage"` with
|
||
`data.kind: "plain-image"`. They require the same validated `data.src` as image
|
||
cards, support geometry/rotation/frame membership, and cannot connect to edges.
|
||
This is a snapshot-format extension with no additional migration.
|
||
|
||
Canvas schema 1 also supports frames: `type: "frame"` with `data.kind: "frame"`.
|
||
Frame names use `data.title`; optional `data.labelMode` is `hidden`, `fixed`, or
|
||
`scaled` (frontend default: `fixed`). Frames cannot rotate, connect to edges,
|
||
or belong to other frames. Any non-frame node can specify `data.frameId`, which
|
||
must reference a frame in the same snapshot; forward references are supported.
|
||
Membership is explicit and independent of geometry: moving a frame over existing
|
||
content does not add members. The frontend assigns membership on content drop
|
||
and implements label zoom behavior. Core preserves the supplied membership and
|
||
label mode without inferring them from positions. Renderer-only `zIndex` is
|
||
ignored alongside transient state; restore frame layering in the frontend.
|
||
This extends existing JSONB snapshots and requires no additional migration.
|
||
|
||
### Browser session lifetime
|
||
|
||
Sessions use random, revocable bearer tokens rather than JWTs. New email and OAuth
|
||
logins have a 30-day idle lifetime; authenticated requests atomically renew that
|
||
idle deadline, capped at 90 days from issuance. Idle-expired, absolute-expired,
|
||
revoked and wrong-project tokens are never revived. Logout removes the session.
|
||
|
||
Apply `db/004-session-lifetime.sql` before running this version. Existing sessions
|
||
keep their prior idle expiry, so expired logins remain expired. For legacy rows,
|
||
the absolute 90-day cap starts at migration time because issuance was not recorded;
|
||
an unexpired legacy session gains the new idle lifetime on its next authenticated
|
||
request. New rows record their actual creation time and absolute deadline.
|
||
|
||
Login and exchange responses return `expires_in: 2592000`. Protected platform
|
||
responses and `/auth/me` include `X-Foundry-Session-Expires-In` with the remaining
|
||
seconds after renewal, shortened near the absolute deadline. The frontend proxy
|
||
must renew its HttpOnly cookie using that value, with a maximum of 30 days. It must
|
||
not extend a cookie beyond the backend-provided remaining lifetime, or expose the
|
||
session token to client JavaScript. CORS exposes the lifetime header alongside
|
||
ETag where applicable. The absolute deadline stays server-authoritative.
|
||
|
||
### Canvas V2 contract
|
||
|
||
Contract version: `canvas-v2-2026-11-06`. These routes are implemented on the
|
||
Canvas V2 work branch; they become available after that branch is reviewed,
|
||
merged and deployed. Do not infer production availability from this document.
|
||
|
||
`B` below means `/projects/{project}/workspaces/{workspace}/materials/{board}/canvas`.
|
||
Workspace and board path segments accept canonical UUIDs or existing slugs.
|
||
Responses and child relationships use canonical IDs. Authentication is the
|
||
existing **user session Bearer token**, with inherited material/workspace
|
||
permissions; the old project Codex service token is not accepted as a user login.
|
||
Reader/viewer accounts may read; mutations require editor/admin access.
|
||
The existing 30-day idle renewal and 90-day absolute session expiry are unchanged.
|
||
|
||
#### Snapshot compatibility and bounds
|
||
|
||
Use `Accept: application/vnd.foundry.canvas+json;version=2` for V2 clients.
|
||
GET remains `{version,document}`; PATCH accepts `{document}` and returns `{version}`.
|
||
The actual schema is in `document.schema_version` and `X-Canvas-Schema-Version`;
|
||
`ETag: "N"` is the independent canvas version. PATCH requires `If-Match: "N"`:
|
||
missing versions return 428, stale versions return 412 with zero writes.
|
||
V1 is still supported. Once a board is V2, legacy GET/PATCH and schema downgrades
|
||
return 409 `canvas_schema_upgrade_required`; no lossy V1 projection is returned.
|
||
GET does not create or migrate child boards.
|
||
|
||
Each canvas permits 500 nodes, 2,000 edges and 5 MiB of snapshot request data.
|
||
Coordinates and control vectors are finite, within ±1,000,000. Ordinary node
|
||
width/height are 20–3,000; V2 frame dimensions are 32–100,000. Ephemeral selection,
|
||
dragging, measured geometry and editor gesture state are not saved.
|
||
|
||
V2 edges preserve typed `data.appearance`, `animation`, `path` and `execution`:
|
||
|
||
| Field | Values / bounds |
|
||
| --- | --- |
|
||
| appearance | six-digit hex color; width 0.5–20; opacity 0.05–1; solid/dashed/dotted |
|
||
| head / tail | none/open-arrow/triangle/circle/diamond/square; size 4–32; boolean filled |
|
||
| animation | off/always/executing; flow/dash/pulse; forward/reverse; durationMs 250–30,000; hex color |
|
||
| path | bezier-chain; optional startControl/endControl; up to 64 ordered, uniquely identified knots |
|
||
| knot | position and relative in/out vectors; corner/smooth/symmetric; collinearity/length tolerance 1e-6 |
|
||
| execution.enabled | boolean; omitted means enabled; animation direction does not reverse dependencies |
|
||
|
||
Omitted appearance/animation fields remain omitted and use the original client
|
||
defaults (color `#AA968B`, width 1.5, solid, no markers, animation off).
|
||
No arbitrary CSS, SVG path strings, marker URLs or callbacks are accepted.
|
||
Text/script/image/plain-text/plain-image nodes can connect; icons cannot.
|
||
Frames connect through declared `port:<id>` handles, including nested port chains.
|
||
|
||
`GET B/capabilities` reports schema versions, actual worker availability and
|
||
limits. A configured catalog alone does not imply that every model can generate
|
||
images: image variants are enabled only for backend models explicitly verified
|
||
by the operator.
|
||
|
||
#### Subgraphs and cross-canvas transactions
|
||
|
||
Each linked frame owns one child board in the same project/workspace. The maximum
|
||
nesting depth is eight. The parent stores the frame and boundary edges; member
|
||
nodes and internal edges have exactly one owner, the child canvas. Child reads
|
||
and writes inherit the parent chain, including when addressed directly by ID.
|
||
Material lists omit child boards by default; `include_subgraphs=true` includes
|
||
accessible children with `parent_board_id` and `frame_id`.
|
||
|
||
Frame data retains `subgraph.boardId`, `ports`, and presentation:
|
||
`collapsed`, `contentScale` (0.05–4), `expandedSize`, `collapsedSize`.
|
||
Collapsing/scaling presentation does not rewrite child content. Projection is
|
||
`frame.position + (24,36) + contentScale * child.position`; viewport is independent.
|
||
|
||
| Route | Required input / behavior |
|
||
| --- | --- |
|
||
| POST B/connections | `{source,target,edge,expected_versions}` + Idempotency-Key; atomically creates/reuses boundary port chains and stores one edge in the lowest common ancestor |
|
||
| POST B/subgraphs | Parent If-Match; `{frame:{id,title,position,width,height,labelMode}}`; returns 201, child_board, parent/child snapshots and receipt |
|
||
| POST B/subgraphs/{frame}/members | `{source_board_id,target_board_id,node_ids,expected_versions}`; parent/child or siblings coordinated by their common parent |
|
||
| POST B/subgraphs/{frame}/dissolve | `{expected_versions}`; preserves content in the parent and archives the emptied child |
|
||
| POST B/subgraphs/migrate | `{dry_run:true}` creates no data; returns plan_id, digest, parent version and explicit members/edges/issues |
|
||
| POST B/subgraphs/migrate | `{dry_run:false,plan_id}` plus parent If-Match; stale plans return 412 |
|
||
| POST B/transactions/{id}/undo | `{expected_versions}`; restores snapshots and relationships atomically, increments current versions |
|
||
|
||
All mutating commands above require a UUID `Idempotency-Key` (case-insensitive).
|
||
Scope is project/workspace/user/resource/action. Canonical payload retries return
|
||
the same operation; a different payload returns 409 `idempotency_conflict`.
|
||
Records remain available for at least 24 hours. Read-only migration preflight
|
||
requires editor/admin but does not require an idempotency key or create a plan row.
|
||
|
||
`expected_versions` must include **every modified canvas**, including an ancestor
|
||
whose boundary port must change and child canvases whose ownership changes.
|
||
Missing versions return 428; any stale version returns 412 before commit.
|
||
All snapshots, versions, relationships, idempotency and receipt records share a
|
||
single SQL transaction. Boundary edges retain their IDs/styles/paths; ports are
|
||
stable and deduplicated. Node ID collisions and graph capacity errors reject the
|
||
entire command. Ordinary PATCH cannot detach linked frames, retarget ownership
|
||
or remove nodes still referenced across a boundary.
|
||
|
||
Undo receipts have an absolute `expires_at` and a seven-day lifetime; retries do
|
||
not extend it. Expired undo returns 409 `undo_receipt_expired`. Undo also checks
|
||
recorded after-state and current relationships, so incompatible edits return 412.
|
||
Undoing a later operation can restore a compatible state for an earlier undo
|
||
without resetting version counters. Redo uses the original command and a new key.
|
||
|
||
Migration considers only explicit legacy `frameId` membership, including empty
|
||
frames. It preserves internal and boundary edges, image references and styling,
|
||
converts coordinates/control points, and skips already linked frames. Original
|
||
snapshots and mapping data remain in the operation record for rollback.
|
||
|
||
#### Assets
|
||
|
||
`POST /projects/{project}/workspaces/{workspace}/assets` takes one multipart part
|
||
named `image` (or `file`) and an Idempotency-Key. PNG/JPEG/WebP/GIF are decoded,
|
||
not trusted by filename. The limit is 20 MiB and 40 million decoded pixels.
|
||
The response is `{id,mime_type,bytes,width,height,url}`. PostgreSQL stores the
|
||
encoded object, metadata and bindings atomically.
|
||
|
||
`GET .../assets/{id}` requires a user session and returns the actual image bytes
|
||
with the detected MIME and `nosniff`. Unbound objects are readable only by their
|
||
uploader or workspace admins; bound objects require access to an owning material
|
||
through its current parent chain. URLs are stable HTTPS application URLs, never
|
||
worker files or expiring signed URLs. Browser integrations should use an
|
||
authenticated fetch/proxy; do not persist temporary blob URLs in snapshots.
|
||
`data.assetId` is optional when `src` is already a stable managed URL. Moves,
|
||
dissolve and undo retain bindings; referenced images are not prematurely collected.
|
||
|
||
AI image inputs are frozen as authorized asset references. Existing inline images
|
||
and public HTTPS node images can be validated and frozen by Core. Fetches enforce
|
||
HTTPS, DNS/IP checks, bounded redirects/bytes/time, and reject private, loopback,
|
||
link-local and cloud metadata addresses. The worker receives validated bytes over
|
||
private Unix IPC, not arbitrary external download instructions.
|
||
|
||
#### AI models and runs
|
||
|
||
`GET B/ai/models` returns opaque IDs derived from the worker's real CLI catalog,
|
||
modalities, availability, input/timeout bounds and parameter schemas. Current CLI
|
||
variants expose an empty parameter object with `additionalProperties:false`;
|
||
raw CLI arguments and unknown parameters are rejected. No token, auth file, raw
|
||
CLI output or service configuration is included in browser responses.
|
||
|
||
Node `data.ai` stores enabled/modelId/prompt/propagate/parameters. Prompt length
|
||
is at most 16,000 characters; parameters encode to at most 16 KiB. Global AI panel
|
||
state, tool position and animation progress are client state, not snapshot data.
|
||
|
||
| Route | Input / result |
|
||
| --- | --- |
|
||
| POST B/ai/runs | If-Match + Idempotency-Key; `{node_id,model_id,prompt,parameters,propagate}`; returns 202 with run_id, poll_url, plan, canvas_version and ETag |
|
||
| GET B/ai/runs/{id} | after_seq >= 0, limit 1–100; status, plan, steps, events, next_seq, has_more, output metadata and applied_versions |
|
||
| GET B/ai/runs | status=active or a terminal status; include_descendants=true/false; limit 1–50; opaque next_cursor |
|
||
| POST B/ai/runs/{id}/cancel | Idempotency-Key; empty object/body; returns preserved applied steps and cancelled steps |
|
||
| POST B/ai/runs/{id}/apply | If-Match + Idempotency-Key; `{node_id,output_id}`; explicitly applies retained conflict output to its current target canvas |
|
||
|
||
Run admission atomically saves node configuration, increments the canvas version,
|
||
freezes the plan/fingerprints and records the task and key. A node has at most one
|
||
active step; another key targeting a busy node returns 409 `node_busy`. Queue
|
||
capacity is 128 active runs per project (429 `queue_full` with Retry-After).
|
||
Admission body is at most 256 KiB. Assembled model text is at most 1 MiB; inputs
|
||
allow four distinct images totaling 20 MiB. Text timeout is 90 seconds; verified
|
||
image variants use 180 seconds. The old `/codex/exec` limits remain unchanged.
|
||
|
||
Propagation is opt-in. It resolves nested ports to logical nodes, rejects cycles
|
||
and plans over 64 steps/depth 32 before invoking the worker, and stops at
|
||
unconfigured nodes. A topological schedule runs each node once; diamond joins
|
||
wait for every participating predecessor. Other referenced inputs are frozen
|
||
from their original snapshots. Incoming logical edge IDs order inputs stably.
|
||
Only connected canvases/port chains/static inputs are read, not every child board.
|
||
|
||
Runs persist across HTTP disconnects and Core restarts. Queue leases serialize
|
||
execution; expired uncertain executions fail with `execution_interrupted` rather
|
||
than being silently reissued. A worker's explicit pre-execution busy response is
|
||
safe to requeue, with completed predecessor results recovered from the database.
|
||
|
||
Worker output updates only current target text or src/assetId in a SQL transaction.
|
||
Node identity, dimensions, position, title and styling are retained. Content/AI
|
||
configuration changes produce conflict and retain the output; geometry-only edits
|
||
do not. Deletion, recreation, kind/ownership changes, revoked permission or cancel
|
||
prevent late writes. Image output must be an actual decoded managed image;
|
||
textual URLs are never accepted as completed image generation.
|
||
|
||
Events have monotonic per-run seq values and are replayable through polling.
|
||
Run states: queued/running/succeeded/partially_succeeded/failed/cancelled/conflict.
|
||
Step states additionally include skipped. Edge active/completed/failed/skipped
|
||
events drive execution-only animation; reverse visual animation does not reverse
|
||
execution. Cancellation commits terminal step states before acknowledging, retains
|
||
already applied results, and stops the worker through request cancellation at its
|
||
next heartbeat. It never implicitly rolls back applied output.
|
||
|
||
Text output is capped at 100,000 characters; failures keep original content.
|
||
Poll event pages are bounded to 100 events and approximately 800 KiB of event data.
|
||
Output metadata includes output IDs and image asset metadata; text content patches
|
||
are in replayable step.output/step.applied events. Runs/events are retained for at
|
||
least 30 days; referenced assets have no automatic destructive cleanup.
|
||
|
||
Errors use `{error,message?,details?,request_id?}`. Important statuses include
|
||
400 invalid_canvas/invalid_ai_request, 409 schema/idempotency/ownership/cycle
|
||
conflicts, 412 version_conflict, 428 version_required, 413 graph/context/asset
|
||
limits, 422 model_unavailable/model_incompatible and 429 queue_full. Asynchronous
|
||
generation failures are terminal step/run results, not successful empty content.
|
||
|
||
#### Remote verification and deployment
|
||
|
||
Normal `ces-control` CI runs formatting, tests, builds, migration checks and
|
||
isolated PostgreSQL integration tests. `deploy/check-canvas-real.sh bin/foundry`
|
||
is an explicit remote-runner-only real-model verification; it does not run in
|
||
normal CI. Set `FOUNDRY_CANVAS_IMAGE_MODELS` to backend names already verified for
|
||
image output. The check starts a temporary CLI worker and database and removes
|
||
both afterwards; the Core/test container never receives CLI login files.
|
||
|
||
On 2026-11-06, the isolated real check succeeded with text model
|
||
`codex-18061daa6ba6e3a576dda453d0df839f` and image variant
|
||
`codex-42cfda1d607e416417a1a0ac5c73d437`, backed by the installed CLI's
|
||
`gpt-6.1-sol`. The image decoded as PNG, 1254×1254, 857,723 bytes. These are
|
||
verification observations, not a hardcoded catalog or a production deployment
|
||
claim; temporary run/asset records were cleaned up. Always query the live catalog.
|
||
The isolated text run was `a38ea762-d3bf-41c2-a43a-679e5785f3d7`; the image run
|
||
was `6c0beaa0-d182-45b5-9075-110fcc800218` and its authenticated reread asset was
|
||
`bf2733bc-8aca-4bca-97dc-3cfa52d48270`, SHA-256
|
||
`2ffda6b1603fd0bab27f63479d7f2ad316b65f663afc57ba3f8fefa92a8edce4`.
|
||
These cleaned-up isolated IDs are audit observations, not live production resources.
|
||
|
||
For production, the worker can load operator-controlled `codex/canvas.env` with
|
||
`FOUNDRY_CANVAS_IMAGE_MODELS` containing verified backend names. Replace the worker
|
||
with the reviewed Linux build through the normal deployment process, apply the
|
||
numbered migrations and confirm capabilities before migrating a real canvas.
|
||
|
||
Production deployment of merge commit `b4e537b4adedeb524971cbbdd3f52c61257b10da`
|
||
succeeded on the confirmed ECS runner on 2026-11-06. Main checks
|
||
[run 912](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/912)
|
||
and deployment
|
||
[run 913](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/913)
|
||
both succeeded. The base URL is `https://api.foundry.entroia.com`.
|
||
The deployed capabilities endpoint reported contract `canvas-v2-2026-11-06`
|
||
with both text and image available; the worker uses the backend verified above.
|
||
|
||
Only the user-selected existing test canvas was migrated through the production
|
||
HTTPS API, using a temporary operator session with an absolute three-minute
|
||
expiry and the existing account's editor/admin permissions. Each preflight or
|
||
verification session was revoked immediately afterwards; no credential was
|
||
printed or persisted. The dry-run reported no issues, one legacy frame and two
|
||
explicit members. The version-bound migration committed atomically:
|
||
|
||
| Item | Verified value |
|
||
| --- | --- |
|
||
| Project | `endoleap` |
|
||
| Canonical workspace | `8d775c90-74c9-4109-a1ad-6d0ad3a00ac7` |
|
||
| Canonical parent board | `a9f9a985-e99a-43eb-b088-7edc9301452f` |
|
||
| Parent version/schema | 209 / V1 → 210 / V2 |
|
||
| Frame | `f2a00495-3740-43a6-aa52-611e4dd8520f` |
|
||
| Canonical child board | `9c9bd83b-2444-41f8-8e0e-bfd6d8873577` |
|
||
| Child version | 1 / V2 |
|
||
| Transaction | `4a6e6fee-683e-4025-8ef8-47092a80331b` |
|
||
| Undo expiry | `2026-11-13T10:56:40.241092Z` |
|
||
| Nodes | 11 preserved: parent 9, child 2 |
|
||
| Edges | 2 preserved in parent: 1 mapped boundary edge, 1 unchanged parent edge |
|
||
|
||
Authenticated V2 rereads matched the committed snapshots. The two explicit
|
||
members retained their content, image references, dimensions and styles, with
|
||
positions converted to child coordinates. The boundary edge retained its ID,
|
||
style and path, and references a stable port targeting the original node/handle.
|
||
Retrying the same operation returned the same transaction; a fresh dry-run
|
||
reported zero additional children. A legacy GET returned
|
||
409 `canvas_schema_upgrade_required`. No other canvas was migrated.
|
||
|
||
The source snapshot, plan, complete transaction response, reread snapshots and
|
||
summary are retained with mode 0600 under
|
||
`/opt/endoleap-infra/foundry/canvas-v2-migration-a9f9a985-e99a-43eb-b088-7edc9301452f`
|
||
on the ECS host, in addition to the database receipt. The frontend V2 integration
|
||
remains a separate task; use a V2-aware client for this migrated test canvas.
|
||
|
||
#### Creating new cross-boundary connections
|
||
|
||
Ordinary Canvas PATCH preserves existing frame ports and cannot add or retarget
|
||
ports. Migration/movement-generated ports are reusable, but creating a new
|
||
external connection to an unexposed child node requires this dedicated command:
|
||
|
||
`POST B/connections` with user session authorization,
|
||
`Accept: application/vnd.foundry.canvas+json;version=2`, JSON content type and a
|
||
UUID `Idempotency-Key`. Availability is advertised as
|
||
`capabilities.subgraphs.connections: true`; clients must check the deployed
|
||
capability before calling it.
|
||
|
||
`B` identifies the **lowest common ancestor board** of the two actual endpoints.
|
||
The coordinator may itself be a nested board. Both endpoints must be in this
|
||
same project/workspace and inside its active subgraph tree. A direct endpoint on
|
||
B is valid, as are siblings and descendants up to the existing depth limit.
|
||
When both endpoints are in the same board, use that board as B.
|
||
|
||
```json
|
||
{
|
||
"source": {"board_id":"<child-board-uuid>","node_id":"child-text","handle":"right"},
|
||
"target": {"board_id":"<parent-board-uuid>","node_id":"parent-image","handle":"left"},
|
||
"edge": {
|
||
"id":"new-edge",
|
||
"data": {
|
||
"appearance":{"color":"#AA968B","width":2},
|
||
"animation":{"mode":"executing","effect":"flow","direction":"forward","durationMs":1600},
|
||
"execution":{"enabled":true}
|
||
}
|
||
},
|
||
"expected_versions": {"<parent-board-uuid>":210,"<child-board-uuid>":1}
|
||
}
|
||
```
|
||
|
||
Use canonical board UUIDs for endpoints and version keys. Eligible real node
|
||
kinds are text/script/plain-text/image/plain-image; handles must be one of
|
||
`top/right/bottom/left`. Do not submit projected node IDs, frame IDs as generated
|
||
content, or `port:*` handles. The server exposes a real endpoint through each
|
||
containing frame, creating or reusing a port with the matching
|
||
`direction/nodeId/handle` tuple. Existing matching ports retain their IDs,
|
||
including migration-created or custom stable IDs. A source chain uses output
|
||
ports, and a target chain uses input ports; these are distinct. No unbound port
|
||
or writable arbitrary child reference is accepted.
|
||
|
||
The expected_versions map must contain **exactly every board on both paths**,
|
||
including the endpoint boards and B. For a grandchild-to-parent connection it
|
||
contains parent, intermediate child and grandchild; for siblings it contains
|
||
both children and their parent. No separate If-Match header is required for this
|
||
multi-board operation. Missing participants return 428 `version_required`;
|
||
when a nonempty map has missing/extra participant keys, the response includes
|
||
`details.required_board_ids`. Any stale version returns 412 `version_conflict`.
|
||
All participants require current editor/admin access. Calling a higher ancestor
|
||
instead of the lowest common ancestor returns 409 `connection_owner_mismatch`.
|
||
An unrelated/invalid ownership path returns 409 `ownership_conflict`; resources
|
||
outside the caller's scope retain the normal 403/404 behavior.
|
||
|
||
Only `edge.id` and optional typed V2 `edge.data` are accepted. Its appearance,
|
||
animation, execution flag and bezier-chain path follow the normal V2 validation.
|
||
All knots are already in B's local coordinates; port creation does not move node
|
||
geometry or re-layout the edge. An existing edge ID in B returns 409
|
||
`edge_id_conflict`. Graph, port and snapshot capacity failures return 413 without
|
||
saving any ports, edges, versions or receipt.
|
||
|
||
Success is 201 with the coordinator's ETag and:
|
||
|
||
```json
|
||
{
|
||
"transaction_id":"<uuid>",
|
||
"parent":{"board_id":"<coordinator>","version":211,"document":{}},
|
||
"snapshots":[
|
||
{"board_id":"<coordinator>","version":211,"document":{}},
|
||
{"board_id":"<child-board-uuid>","version":2,"document":{}}
|
||
],
|
||
"connection":{
|
||
"board_id":"<coordinator>",
|
||
"edge":{"id":"new-edge","source":"<frame-id>","sourceHandle":"port:<stable-id>","target":"parent-image","targetHandle":"left","data":{}},
|
||
"source":{"board_id":"<child-board-uuid>","node_id":"child-text","handle":"right"},
|
||
"target":{"board_id":"<parent-board-uuid>","node_id":"parent-image","handle":"left"}
|
||
},
|
||
"created_ports":[{"board_id":"<coordinator>","frame_id":"<frame-id>","port_id":"<stable-id>"}],
|
||
"undo_receipt":{"transaction_id":"<uuid>","valid_for_seconds":604800,"expires_at":"<RFC3339 timestamp>"}
|
||
}
|
||
```
|
||
|
||
The JSON above abbreviates document values and shows a one-level example; nested
|
||
connections include every intermediate board in snapshots. The response edge is
|
||
the stored owner-board edge, while source/target preserve the logical endpoint
|
||
identities. created_ports contains only newly allocated ports; reused ports are
|
||
available in the snapshots. Each participating board advances exactly one
|
||
version (including endpoint boards whose node content remains unchanged),
|
||
consistent with other cross-board commands. Flush pending saves before creating
|
||
the connection, then absorb **all** returned versions/snapshots before resuming
|
||
autosave. Retrying an identical body with the same key returns the original 201
|
||
response and does not advance versions again. Changing the body, including its
|
||
version map, with that key returns 409 `idempotency_conflict`.
|
||
|
||
Undo uses the existing `POST B/transactions/{transaction_id}/undo` endpoint with
|
||
a new key and all current participant versions. It restores the previous edge
|
||
and port arrays atomically; subsequent incompatible edits cause 412. Existing
|
||
ports reused by the command remain after undo. Redo submits the command with
|
||
fresh versions and a new key. Ordinary PATCH can continue to edit/remove edges
|
||
using already exposed ports, but cannot retarget or directly delete the ports.
|
||
|
||
#### Reproducible session API examples
|
||
|
||
These examples require Bash, curl and jq, an editor/admin login session, and an
|
||
explicitly selected board. Set `SESSION_TOKEN` in memory; use the user session,
|
||
never the legacy Codex service token. Run mutations only on a board intended for
|
||
V2 testing. Each successful mutation changes versions: reread before the next
|
||
operation. Reuse the same key and identical body when retrying an operation.
|
||
|
||
```bash
|
||
API=https://api.foundry.entroia.com
|
||
PROJECT=your-project
|
||
WORKSPACE=your-workspace-id-or-slug
|
||
BOARD=your-board-id-or-slug
|
||
B="$API/projects/$PROJECT/workspaces/$WORKSPACE/materials/$BOARD/canvas"
|
||
api() {
|
||
curl --fail-with-body --silent --show-error \
|
||
-H "Authorization: Bearer $SESSION_TOKEN" \
|
||
-H 'Accept: application/vnd.foundry.canvas+json;version=2' "$@"
|
||
}
|
||
# V2-aware read; capabilities report actual deployment and model availability.
|
||
api "$B/capabilities"
|
||
snapshot=$(api "$B")
|
||
version=$(jq -r '.version' <<<"$snapshot")
|
||
# Save an unchanged document, explicitly upgrading the envelope to V2.
|
||
payload=$(jq '{document:(.document | .schema_version=2)}' <<<"$snapshot")
|
||
api -X PATCH "$B" -H 'Content-Type: application/json' \
|
||
-H "If-Match: \"$version\"" --data-binary "$payload"
|
||
|
||
# Create an empty real child with its parent frame, atomically.
|
||
version=$(api "$B" | jq -r '.version')
|
||
create_key=$(uuidgen)
|
||
api -X POST "$B/subgraphs" -H 'Content-Type: application/json' \
|
||
-H "If-Match: \"$version\"" -H "Idempotency-Key: $create_key" \
|
||
--data '{"frame":{"id":"example-frame","title":"Example","position":{"x":100,"y":100},"width":640,"height":400,"labelMode":"fixed"}}'
|
||
|
||
# Read-only old-frame preflight. Inspect issues/counts/mappings before executing.
|
||
plan=$(api -X POST "$B/subgraphs/migrate" -H 'Content-Type: application/json' \
|
||
--data '{"dry_run":true}')
|
||
printf '%s\n' "$plan" | jq .
|
||
```
|
||
|
||
After reviewing that preflight, execute precisely its returned plan with a new
|
||
UUID key and the unchanged parent version. Any intervening change returns 412;
|
||
repeat preflight instead of forcing a stale migration.
|
||
|
||
```bash
|
||
version=$(api "$B" | jq -r '.version')
|
||
migrate_key=$(uuidgen)
|
||
payload=$(jq '{dry_run:false,plan_id:.plan_id}' <<<"$plan")
|
||
api -X POST "$B/subgraphs/migrate" -H 'Content-Type: application/json' \
|
||
-H "If-Match: \"$version\"" -H "Idempotency-Key: $migrate_key" \
|
||
--data-binary "$payload"
|
||
|
||
models=$(api "$B/ai/models")
|
||
printf '%s\n' "$models" | jq .
|
||
TEXT_MODEL=$(jq -r '[.models[] | select(.available and (.output_modalities | index("text")))][0].id // empty' <<<"$models")
|
||
IMAGE_MODEL=$(jq -r '[.models[] | select(.available and (.output_modalities | index("image")))][0].id // empty' <<<"$models")
|
||
# Supply existing eligible node IDs from the selected board.
|
||
TEXT_NODE=your-text-node
|
||
IMAGE_NODE=your-image-node
|
||
run_node() {
|
||
local node="$1" model="$2" propagate="$3" prompt="$4" version key payload
|
||
test -n "$model" || { printf 'No available model for this modality\n' >&2; return 1; }
|
||
version=$(api "$B" | jq -r '.version')
|
||
key=$(uuidgen)
|
||
payload=$(jq -n --arg node "$node" --arg model "$model" --arg prompt "$prompt" \
|
||
--argjson propagate "$propagate" \
|
||
'{node_id:$node,model_id:$model,prompt:$prompt,parameters:{},propagate:$propagate}')
|
||
api -X POST "$B/ai/runs" -H 'Content-Type: application/json' \
|
||
-H "If-Match: \"$version\"" -H "Idempotency-Key: $key" --data-binary "$payload"
|
||
}
|
||
text_run=$(run_node "$TEXT_NODE" "$TEXT_MODEL" false 'Summarize this content')
|
||
run_id=$(jq -r '.run_id' <<<"$text_run")
|
||
api "$B/ai/runs/$run_id?after_seq=0&limit=100"
|
||
api "$B/ai/runs?status=active&include_descendants=true&limit=50"
|
||
```
|
||
|
||
Wait for a terminal status before starting another run on the same node. Continue
|
||
polling from `next_seq` while `has_more` is true, then retain the cursor for the
|
||
next poll. Image generation and propagation use the same asynchronous contract:
|
||
|
||
```bash
|
||
image_run=$(run_node "$IMAGE_NODE" "$IMAGE_MODEL" false 'Generate an image from this reference')
|
||
image_run_id=$(jq -r '.run_id' <<<"$image_run")
|
||
api "$B/ai/runs/$image_run_id?after_seq=0&limit=100"
|
||
# Once succeeded, outputs[].output.src is an authenticated managed image URL.
|
||
# Fetch that URL with api and --output to verify the actual image bytes.
|
||
# Configure downstream node data.ai and execution edges before propagation.
|
||
propagated_run=$(run_node "$TEXT_NODE" "$TEXT_MODEL" true 'Transform and propagate')
|
||
api "$B/ai/runs/$(jq -r '.run_id' <<<"$propagated_run")?after_seq=0&limit=100"
|
||
```
|
||
|
||
The live catalog, rather than the isolated verification IDs above, determines
|
||
whether each request can run. A 202 response acknowledges a queued job; use its
|
||
terminal status and authenticated canvas/asset rereads as completion evidence.
|
||
|
||
|
||
### Managed image previews
|
||
|
||
Original asset bytes remain immutable and are used by the ordinary asset URL,
|
||
downloads and AI inputs. Authenticated `GET .../assets/{id}?variant=preview`
|
||
returns one display rendition bounded to 1024 pixels on its longest edge. Opaque previews use JPEG quality 82, transparency stays PNG, GIF preserves
|
||
the original animation, and JPEG EXIF orientation is applied. Small originals
|
||
may be returned unchanged when this is more efficient. No preview URL belongs
|
||
in node `data.src`: persist the original managed URL and optional original
|
||
`assetId`, then select the display variant only in the renderer.
|
||
|
||
Permissions are checked on every read, including cache hits. Originals are not
|
||
replaced. Generation is serialized and the immutable encoded preview cache is
|
||
bounded to 32 MiB / 128 entries. Current capabilities advertise `assets.previews`
|
||
and `assets.preview_max_edge`. No database migration is needed. Asset reads and
|
||
Canvas GET release their permission transaction before encoding/writing the
|
||
response. Uploads release the initial workspace lock before receiving a large
|
||
multipart body and recheck current write permission before committing.
|
||
|
||
Legacy inline images must be migrated through an explicit editor action: upload
|
||
unchanged bytes with idempotency keys, then save the same node IDs/geometry with
|
||
managed asset references and the current canvas version. Opening a canvas does
|
||
not migrate it or cause an implicit PATCH.
|
||
|
||
#### Atomic member drop positions
|
||
|
||
`POST B/subgraphs/{frame}/members` additionally accepts optional `positions`, an object with exactly one `{x,y}` entry for each `node_ids` item, expressed in the **source board's local coordinates**. Both coordinates are required, finite and within ±1,000,000. Capabilities advertise `subgraphs.member_positions: true`. Omission preserves legacy behavior. Positions, ownership, edge/port conversion, versions and undo receipt commit in one transaction; no preceding coordinate PATCH is needed. Validation failures or stale versions perform zero writes. The positions participate in the idempotency payload digest; retries must keep the entire body and key unchanged. Undo restores the pre-drop snapshot, including original positions.
|