Files
canvas-frontend/src/lib/wizardApi.ts
T
canvas-bot 5abb3595fc
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
fix(wizard): retry createDraft on 500/502/503/504 + human error message
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.
2026-06-15 19:42:32 +04:00

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" },
};
},
},
};