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.
This commit is contained in:
2026-06-14 12:14:09 +04:00
parent fab3b988b4
commit 82de744985
2 changed files with 115 additions and 62 deletions
+101 -36
View File
@@ -1,5 +1,22 @@
import { api, type Actor } from "./api"; 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 { export interface CreateDraftPayload {
name: string; name: string;
display_name: string; display_name: string;
@@ -18,60 +35,78 @@ export interface CreateDraftPayload {
}; };
} }
export interface BatchOperation { export type CreateOp = { op: "create"; coll: string; data: Record<string, any> };
collection: string; export type UpdateOp = { op: "update"; coll: string; key: string; data: Record<string, any> };
op: "insert" | "update" | "delete"; export type DeleteOp = { op: "delete"; coll: string; key: string };
_key?: string; export type CreateEdgeOp = {
_from?: string; op: "create_edge";
_to?: string; edge_coll: string;
[key: string]: any; from: string;
to: string;
role?: string;
label?: string;
data?: Record<string, any>;
};
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<string, string> {
return {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
};
} }
export const wizardApi = { export const wizardApi = {
async createDraft(payload: CreateDraftPayload, signal?: AbortSignal) { async createDraft(payload: CreateDraftPayload, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, { const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
method: "POST", method: "POST",
headers: { headers: authHeaders(),
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, body: JSON.stringify({ kind: "definition", status: "draft", ...payload }),
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "definition",
status: "draft",
...payload,
}),
signal, 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 }>; return res.json() as Promise<{ _key: string; name: string }>;
}, },
async updateDraftConfig(key: string, patch: any, signal?: AbortSignal) { async updateDraftConfig(key: string, patch: Record<string, any>, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/${key}`, { const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/${key}`, {
method: "PUT", method: "PUT",
headers: { headers: authHeaders(),
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
},
body: JSON.stringify(patch), body: JSON.stringify(patch),
signal, 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(); return res.json();
}, },
async applyBatch(flow_key: string, ops: BatchOperation[], actor: Actor, signal?: AbortSignal) { /**
const request_id = * Apply a batch of graph mutations. The third argument (`actor`) is accepted for
typeof crypto !== "undefined" && "randomUUID" in crypto * backwards compatibility with older call sites and is ignored — the backend resolves
? crypto.randomUUID() * actor identity from the Bearer token. The wizard's previous behaviour of putting
: `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; * `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`, { const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST", method: "POST",
headers: { headers: authHeaders(),
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, body: JSON.stringify({ request_id: newRequestId(), ops }),
"Content-Type": "application/json",
},
body: JSON.stringify({ flow_key, ops, actor, request_id }),
signal, signal,
}); });
if (!res.ok) { if (!res.ok) {
@@ -80,8 +115,38 @@ export const wizardApi = {
} }
return res.json(); return res.json();
}, },
async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) { 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" },
};
},
},
}; };
+14 -26
View File
@@ -103,24 +103,19 @@ export default function Wizard() {
if (!draft.flowKey || !actor) return; if (!draft.flowKey || !actor) return;
setWorking(true); setWorking(true);
try { try {
const ops: BatchOperation[] = draft.nodes.map(n => ({ const ops: BatchOperation[] = [
collection: "ea2_node", ...draft.nodes.map(n =>
op: "insert", wizardApi.ops.createNode(draft.flowKey!, {
flow_key: draft.flowKey, _key: n._key,
...n display_name: n.display_name,
})); dispatch_kind: n.dispatch_kind,
agent_capability: n.agent_capability,
// Link them sequentially })
for (let i = 0; i < draft.nodes.length - 1; i++) { ),
ops.push({ ...draft.nodes.slice(0, -1).map((n, i) =>
collection: "ea2_edge", wizardApi.ops.createEdge(draft.flowKey!, n._key, draft.nodes[i + 1]._key, "next")
op: "insert", ),
flow_key: draft.flowKey, ];
_from: `ea2_node/${draft.nodes[i]._key}`,
_to: `ea2_node/${draft.nodes[i+1]._key}`,
kind: "next"
});
}
await wizardApi.applyBatch(draft.flowKey, ops, actor); await wizardApi.applyBatch(draft.flowKey, ops, actor);
updateDraft({ step: "Generate" }); updateDraft({ step: "Generate" });
@@ -170,14 +165,7 @@ export default function Wizard() {
if (!draft.flowKey || !actor) return; if (!draft.flowKey || !actor) return;
setWorking(true); setWorking(true);
try { try {
const ops: BatchOperation[] = [ const ops: BatchOperation[] = [wizardApi.ops.publishFlow(draft.flowKey)];
{
collection: "ea2_flow",
op: "update",
_key: draft.flowKey,
status: "published"
}
];
await wizardApi.applyBatch(draft.flowKey, ops, actor); await wizardApi.applyBatch(draft.flowKey, ops, actor);
updateDraft({ step: "Publish" }); updateDraft({ step: "Publish" });
setPublishedKey(draft.flowKey); setPublishedKey(draft.flowKey);