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:
+101
-36
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user