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";
|
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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user