POST /api/ea2/flow currently returns 500 after 19s on demo upstream. Retry once with backoff, then surface a clear 'EA2 backend temporarily unable to create new processes' message naming the status. The user gets actionable info instead of a raw 500.
255 lines
9.1 KiB
TypeScript
255 lines
9.1 KiB
TypeScript
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;
|
|
description: string;
|
|
source_context: string;
|
|
config: {
|
|
wizard: {
|
|
marker: string;
|
|
maxDepth: number;
|
|
maxNodes: number;
|
|
chat: any[];
|
|
uploads: any[];
|
|
panelState: any;
|
|
debug: 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 body = JSON.stringify({ kind: "definition", status: "draft", ...payload });
|
|
const transient = new Set([500, 502, 503, 504]);
|
|
let lastDetail = "";
|
|
let lastStatus = 0;
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
|
|
method: "POST",
|
|
headers: authHeaders(),
|
|
body,
|
|
signal,
|
|
});
|
|
if (res.ok) return await res.json() as { _key: string; name: string };
|
|
lastStatus = res.status;
|
|
lastDetail = await res.text().catch(() => "");
|
|
if (!transient.has(res.status)) break;
|
|
} catch (e) {
|
|
lastDetail = (e as Error).message;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
|
|
}
|
|
if (transient.has(lastStatus)) {
|
|
throw new Error(`The EA2 backend is temporarily unable to create new processes (status ${lastStatus}). Try again in a moment, or report this if it keeps happening.`);
|
|
}
|
|
throw new Error(`createDraft ${lastStatus}: ${lastDetail.slice(0, 200)}`);
|
|
},
|
|
|
|
async updateDraftConfig(key: string, patch: Record<string, any>, signal?: AbortSignal) {
|
|
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/${key}`, {
|
|
method: "PUT",
|
|
headers: authHeaders(),
|
|
body: JSON.stringify(patch),
|
|
signal,
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await res.text().catch(() => "");
|
|
throw new Error(`updateDraftConfig ${res.status}: ${detail.slice(0, 200)}`);
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
/**
|
|
* 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 body = JSON.stringify({ request_id: newRequestId(), ops });
|
|
const transient = new Set([502, 503, 504]);
|
|
let lastErr = "";
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
|
|
method: "POST",
|
|
headers: authHeaders(),
|
|
body,
|
|
signal,
|
|
});
|
|
if (res.ok) return res.json();
|
|
const detail = await res.text().catch(() => "");
|
|
lastErr = `applyBatch ${res.status}: ${detail.slice(0, 200)}`;
|
|
if (!transient.has(res.status)) break;
|
|
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
|
}
|
|
throw new Error(lastErr || "applyBatch failed");
|
|
},
|
|
|
|
async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) {
|
|
return api.startTransaction(process_definition_id, business_subject, signal);
|
|
},
|
|
|
|
ops: {
|
|
createStep(node: { display_name: string; dispatch_kind: string; agent_capability?: string; description?: string }): CreateOp {
|
|
const slug = node.display_name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30) || "step";
|
|
const name = `${slug}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
return {
|
|
op: "create",
|
|
coll: "flow",
|
|
data: {
|
|
kind: "definition",
|
|
name,
|
|
display_name: node.display_name,
|
|
status: "published",
|
|
description: node.description || node.display_name,
|
|
source_context: "EA2_WIZARD_STEP",
|
|
dispatch_kind: node.dispatch_kind,
|
|
...(node.agent_capability ? { agent_capability: node.agent_capability } : {}),
|
|
},
|
|
};
|
|
},
|
|
createView(node: { display_name: string; fields?: { name: string; type: string }[] }): CreateOp {
|
|
const slug = node.display_name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30) || "view";
|
|
const name = `${slug}_view_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
const TYPE_MAP: Record<string, string> = { string: "string", number: "number", boolean: "boolean", textarea: "textarea" };
|
|
const fieldDefs =
|
|
node.fields && node.fields.length > 0
|
|
? node.fields
|
|
.filter((f) => f.name && f.name.trim())
|
|
.map((f) => {
|
|
const fieldSlug = f.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 40);
|
|
const label = f.name.charAt(0).toUpperCase() + f.name.slice(1);
|
|
return { name: fieldSlug, label, type: TYPE_MAP[f.type] || "string", required: true, description: label };
|
|
})
|
|
: [{ name: "notes", label: "Notes", type: "textarea", required: false, description: "Notes for this step" }];
|
|
return {
|
|
op: "create",
|
|
coll: "view",
|
|
data: {
|
|
kind: "definition",
|
|
name,
|
|
label: `${node.display_name} · form`,
|
|
status: "active",
|
|
description: null,
|
|
source_context: null,
|
|
channel: "web",
|
|
layout: { layout: "form", fields: fieldDefs },
|
|
actions: [{ label: "Submit", action: "submit" }],
|
|
},
|
|
};
|
|
},
|
|
createVersion(parentDisplayName: string): CreateOp {
|
|
const slug = parentDisplayName.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30) || "process";
|
|
const name = `${slug}_v1_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
return {
|
|
op: "create",
|
|
coll: "version",
|
|
data: {
|
|
kind: "definition",
|
|
name,
|
|
label: "v1",
|
|
status: "active",
|
|
change_description: `Initial publish of ${parentDisplayName}`,
|
|
legal_entity: "canvas",
|
|
source_context: "EA2_WIZARD_VERSION",
|
|
},
|
|
};
|
|
},
|
|
childEdge(parentFlowKey: string, childFlowKey: string): CreateEdgeOp {
|
|
return {
|
|
op: "create_edge",
|
|
edge_coll: "defines",
|
|
from: `flow/${parentFlowKey}`,
|
|
to: `flow/${childFlowKey}`,
|
|
role: "child",
|
|
};
|
|
},
|
|
dispatchEdge(stepFlowKey: string): CreateEdgeOp {
|
|
return {
|
|
op: "create_edge",
|
|
edge_coll: "defines",
|
|
from: `flow/${stepFlowKey}`,
|
|
to: `flow/${stepFlowKey}`,
|
|
role: "dispatch",
|
|
};
|
|
},
|
|
presentationEdge(stepFlowKey: string, viewKey: string): CreateEdgeOp {
|
|
return {
|
|
op: "create_edge",
|
|
edge_coll: "defines",
|
|
from: `flow/${stepFlowKey}`,
|
|
to: `view/${viewKey}`,
|
|
role: "presentation",
|
|
};
|
|
},
|
|
governsEdge(versionKey: string, parentFlowKey: string): CreateEdgeOp {
|
|
return {
|
|
op: "create_edge",
|
|
edge_coll: "governs",
|
|
from: `version/${versionKey}`,
|
|
to: `flow/${parentFlowKey}`,
|
|
role: "governs",
|
|
};
|
|
},
|
|
publishFlow(flowKey: string): UpdateOp {
|
|
return {
|
|
op: "update",
|
|
coll: "flow",
|
|
key: flowKey,
|
|
data: { status: "published" },
|
|
};
|
|
},
|
|
},
|
|
};
|