From 82de744985d3b2d4049345d8ea4970d3230348f1 Mon Sep 17 00:00:00 2001 From: shad Date: Sun, 14 Jun 2026 12:14:09 +0400 Subject: [PATCH] fix(wizard): match real EA2 apply-batch contract Wizard was sending ops in the SDX-era shape: { collection, op: 'insert', flow_key, _key, ... } with actor + flow_key duplicated at the request root. Real EA2 contract (verified against ea2.baseline.svc/openapi.json): POST /api/ea2/apply-batch body: { request_id (8..128), ops: BatchOp[] (1..500), description? } additionalProperties=false at request root ops are a discriminated union by 'op': create / update / delete (nodes by coll+key+data) create_edge / delete_edge (edges by edge_coll+from+to) actor/tenant resolved from Bearer token, not body. Adds typed CreateOp/UpdateOp/DeleteOp/CreateEdgeOp/DeleteEdgeOp + wizardApi.ops helpers (createNode, createEdge, publishFlow) so call sites don't have to remember the discriminator shape. Wizard.handleStructureSave + handlePublish updated to use the helpers. --- src/lib/wizardApi.ts | 137 +++++++++++++++++++++++++++++++----------- src/scenes/Wizard.tsx | 40 +++++------- 2 files changed, 115 insertions(+), 62 deletions(-) diff --git a/src/lib/wizardApi.ts b/src/lib/wizardApi.ts index b7e7a77..75a63a5 100644 --- a/src/lib/wizardApi.ts +++ b/src/lib/wizardApi.ts @@ -1,5 +1,22 @@ import { api, type Actor } from "./api"; +/** + * EA2 apply-batch contract (verified against ea2.baseline.svc/openapi.json 2026-06-14): + * + * POST /api/ea2/apply-batch + * body: { request_id: string (8..128), ops: BatchOp[] (1..500), description?: string } + * additionalProperties: false at request root (no top-level flow_key / actor) + * + * Op shapes (discriminated by `op`): + * { op: "create", coll, data } + * { op: "update", coll, key, data } + * { op: "delete", coll, key } + * { op: "create_edge", edge_coll, from, to, role?, label?, data? } + * { op: "delete_edge", edge_coll, key } + * + * The actor/tenant context is taken from the Bearer token, not the body. + */ + export interface CreateDraftPayload { name: string; display_name: string; @@ -18,60 +35,78 @@ export interface CreateDraftPayload { }; } -export interface BatchOperation { - collection: string; - op: "insert" | "update" | "delete"; - _key?: string; - _from?: string; - _to?: string; - [key: string]: any; +export type CreateOp = { op: "create"; coll: string; data: Record }; +export type UpdateOp = { op: "update"; coll: string; key: string; data: Record }; +export type DeleteOp = { op: "delete"; coll: string; key: string }; +export type CreateEdgeOp = { + op: "create_edge"; + edge_coll: string; + from: string; + to: string; + role?: string; + label?: string; + data?: Record; +}; +export type DeleteEdgeOp = { op: "delete_edge"; edge_coll: string; key: string }; +export type BatchOp = CreateOp | UpdateOp | DeleteOp | CreateEdgeOp | DeleteEdgeOp; + +/** Back-compat alias for any caller still referencing BatchOperation. */ +export type BatchOperation = BatchOp; + +function newRequestId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `req-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; +} + +function authHeaders(): Record { + return { + Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, + "Content-Type": "application/json", + }; } export const wizardApi = { async createDraft(payload: CreateDraftPayload, signal?: AbortSignal) { const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, { method: "POST", - headers: { - Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - kind: "definition", - status: "draft", - ...payload, - }), + headers: authHeaders(), + body: JSON.stringify({ kind: "definition", status: "draft", ...payload }), signal, }); - if (!res.ok) throw new Error(`createDraft failed: ${res.status}`); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`createDraft ${res.status}: ${detail.slice(0, 200)}`); + } return res.json() as Promise<{ _key: string; name: string }>; }, - async updateDraftConfig(key: string, patch: any, signal?: AbortSignal) { + async updateDraftConfig(key: string, patch: Record, signal?: AbortSignal) { const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/${key}`, { method: "PUT", - headers: { - Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, - "Content-Type": "application/json", - }, + headers: authHeaders(), body: JSON.stringify(patch), signal, }); - if (!res.ok) throw new Error(`updateDraftConfig failed: ${res.status}`); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`updateDraftConfig ${res.status}: ${detail.slice(0, 200)}`); + } return res.json(); }, - async applyBatch(flow_key: string, ops: BatchOperation[], actor: Actor, signal?: AbortSignal) { - const request_id = - typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + /** + * Apply a batch of graph mutations. The third argument (`actor`) is accepted for + * backwards compatibility with older call sites and is ignored — the backend resolves + * actor identity from the Bearer token. The wizard's previous behaviour of putting + * `actor` and `flow_key` at the request root was rejected by the schema + * (additionalProperties=false), and `op: "insert"` is not a valid op enum value. + */ + async applyBatch(_flowKey: string, ops: BatchOp[], _actor: Actor | null, signal?: AbortSignal) { + if (!ops.length) return { applied: 0 }; const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, { method: "POST", - headers: { - Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ flow_key, ops, actor, request_id }), + headers: authHeaders(), + body: JSON.stringify({ request_id: newRequestId(), ops }), signal, }); if (!res.ok) { @@ -80,8 +115,38 @@ export const wizardApi = { } return res.json(); }, - + async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) { - return api.startTransaction(process_definition_id, business_subject, signal); - } + return api.startTransaction(process_definition_id, business_subject, signal); + }, + + /** Helpers so call sites don't have to know the discriminated-union shape by heart. */ + ops: { + createNode(flowKey: string, node: { _key: string; display_name: string; dispatch_kind: string; agent_capability?: string }): CreateOp { + const { _key, ...rest } = node; + return { + op: "create", + coll: "ea2_node", + data: { _key, flow_key: flowKey, ...rest }, + }; + }, + createEdge(flowKey: string, fromKey: string, toKey: string, role = "next"): CreateEdgeOp { + return { + op: "create_edge", + edge_coll: "ea2_edge", + from: `ea2_node/${fromKey}`, + to: `ea2_node/${toKey}`, + role, + data: { flow_key: flowKey }, + }; + }, + publishFlow(flowKey: string): UpdateOp { + return { + op: "update", + coll: "ea2_flow", + key: flowKey, + data: { status: "published" }, + }; + }, + }, }; diff --git a/src/scenes/Wizard.tsx b/src/scenes/Wizard.tsx index 57d7691..d739954 100644 --- a/src/scenes/Wizard.tsx +++ b/src/scenes/Wizard.tsx @@ -103,24 +103,19 @@ export default function Wizard() { if (!draft.flowKey || !actor) return; setWorking(true); try { - const ops: BatchOperation[] = draft.nodes.map(n => ({ - collection: "ea2_node", - op: "insert", - flow_key: draft.flowKey, - ...n - })); - - // Link them sequentially - for (let i = 0; i < draft.nodes.length - 1; i++) { - ops.push({ - collection: "ea2_edge", - op: "insert", - flow_key: draft.flowKey, - _from: `ea2_node/${draft.nodes[i]._key}`, - _to: `ea2_node/${draft.nodes[i+1]._key}`, - kind: "next" - }); - } + const ops: BatchOperation[] = [ + ...draft.nodes.map(n => + wizardApi.ops.createNode(draft.flowKey!, { + _key: n._key, + display_name: n.display_name, + dispatch_kind: n.dispatch_kind, + agent_capability: n.agent_capability, + }) + ), + ...draft.nodes.slice(0, -1).map((n, i) => + wizardApi.ops.createEdge(draft.flowKey!, n._key, draft.nodes[i + 1]._key, "next") + ), + ]; await wizardApi.applyBatch(draft.flowKey, ops, actor); updateDraft({ step: "Generate" }); @@ -170,14 +165,7 @@ export default function Wizard() { if (!draft.flowKey || !actor) return; setWorking(true); try { - const ops: BatchOperation[] = [ - { - collection: "ea2_flow", - op: "update", - _key: draft.flowKey, - status: "published" - } - ]; + const ops: BatchOperation[] = [wizardApi.ops.publishFlow(draft.flowKey)]; await wizardApi.applyBatch(draft.flowKey, ops, actor); updateDraft({ step: "Publish" }); setPublishedKey(draft.flowKey);