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 }; export type UpdateOp = { op: "update"; coll: string; key: string; data: Record }; 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; }; 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 { 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, 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", 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" }, }; }, }, };