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";
/**
* 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<string, any> };
export type UpdateOp = { op: "update"; coll: string; key: string; data: Record<string, any> };
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<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 = {
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<string, any>, 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" },
};
},
},
};
+14 -26
View File
@@ -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);