@gavana.ai/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE.md +7 -0
  3. package/README.md +237 -0
  4. package/bin/craftboard.mjs +5 -0
  5. package/bin/gavana.mjs +5 -0
  6. package/guides/connections.md +35 -0
  7. package/guides/examples-common-mistakes.md +29 -0
  8. package/guides/existing-canvases.md +19 -0
  9. package/guides/generated-assets.md +29 -0
  10. package/guides/getting-started.md +26 -0
  11. package/guides/notes-text-sections.md +44 -0
  12. package/guides/paid-action-safety.md +22 -0
  13. package/guides/prompt-lists.md +20 -0
  14. package/guides/sections-layout.md +43 -0
  15. package/guides/validation-recovery.md +33 -0
  16. package/package.json +44 -0
  17. package/src/canvas-agent-guide.mjs +133 -0
  18. package/src/canvas-agent-validation.mjs +554 -0
  19. package/src/canvas-layout.mjs +287 -0
  20. package/src/capabilities.mjs +61 -0
  21. package/src/client.mjs +1141 -0
  22. package/src/commands.mjs +259 -0
  23. package/src/config.mjs +197 -0
  24. package/src/guide-sources.mjs +86 -0
  25. package/src/runner.mjs +1968 -0
  26. package/src/tools/action_get.mjs +16 -0
  27. package/src/tools/action_list.mjs +21 -0
  28. package/src/tools/action_run.mjs +60 -0
  29. package/src/tools/agent_canvas_get.mjs +15 -0
  30. package/src/tools/asset_get.mjs +16 -0
  31. package/src/tools/asset_list.mjs +17 -0
  32. package/src/tools/asset_upload.mjs +24 -0
  33. package/src/tools/campaign_cancel.mjs +16 -0
  34. package/src/tools/campaign_get.mjs +16 -0
  35. package/src/tools/campaign_plan.mjs +31 -0
  36. package/src/tools/campaign_review.mjs +24 -0
  37. package/src/tools/campaign_start.mjs +19 -0
  38. package/src/tools/canvas_apply_batch.mjs +35 -0
  39. package/src/tools/canvas_create.mjs +15 -0
  40. package/src/tools/canvas_get.mjs +16 -0
  41. package/src/tools/canvas_list.mjs +17 -0
  42. package/src/tools/canvas_render.mjs +34 -0
  43. package/src/tools/canvas_validate.mjs +34 -0
  44. package/src/tools/connection_create.mjs +38 -0
  45. package/src/tools/connection_delete.mjs +31 -0
  46. package/src/tools/definitions.mjs +111 -0
  47. package/src/tools/guide_get.mjs +16 -0
  48. package/src/tools/guide_search.mjs +16 -0
  49. package/src/tools/helpers.mjs +66 -0
  50. package/src/tools/image_edit.mjs +8 -0
  51. package/src/tools/image_generate.mjs +8 -0
  52. package/src/tools/image_tool.mjs +56 -0
  53. package/src/tools/image_variations.mjs +8 -0
  54. package/src/tools/job_cancel.mjs +16 -0
  55. package/src/tools/job_get.mjs +17 -0
  56. package/src/tools/job_wait.mjs +18 -0
  57. package/src/tools/model_get.mjs +16 -0
  58. package/src/tools/model_list.mjs +23 -0
  59. package/src/tools/node_create.mjs +36 -0
  60. package/src/tools/node_delete.mjs +31 -0
  61. package/src/tools/node_get.mjs +16 -0
  62. package/src/tools/node_move.mjs +37 -0
  63. package/src/tools/node_resize.mjs +37 -0
  64. package/src/tools/node_update.mjs +36 -0
  65. package/src/tools/progress.mjs +101 -0
  66. package/src/tools/provider_list.mjs +17 -0
  67. package/src/tools/recipe_fork.mjs +32 -0
  68. package/src/tools/recipe_get.mjs +19 -0
  69. package/src/tools/recipe_run.mjs +61 -0
  70. package/src/tools/recipe_search.mjs +17 -0
  71. package/src/tools/registry.mjs +550 -0
  72. package/src/tools/run_cancel.mjs +16 -0
  73. package/src/tools/run_get.mjs +17 -0
  74. package/src/tools/run_wait.mjs +18 -0
  75. package/src/tools/schemas.mjs +165 -0
  76. package/src/tools/video_generate.mjs +37 -0
  77. package/src/version.mjs +12 -0
package/src/client.mjs ADDED
@@ -0,0 +1,1141 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+
4
+ import { allocateCanvasRow, CANVAS_COORDINATE_LIMIT, CANVAS_SECTION_PADDING, CANVAS_SIBLING_SPACING } from "./canvas-layout.mjs";
5
+ import { GAVANA_CLI_VERSION } from "./version.mjs";
6
+
7
+ const API_PREFIX = "/api/canvas-agent/v1";
8
+ // Matches the server's generated-image reframe ceiling (fitGeneratedImageNodeSize).
9
+ const GROWN_FRAME_LIMIT = 640;
10
+ // Matches the server-side maximum node width/height.
11
+ const MAX_NODE_DIMENSION = 10_000;
12
+ const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "canceled", "expired"]);
13
+ const AGENT_CANVAS_DESTINATION = "agent-canvas";
14
+ const NEW_CANVAS_DESTINATION = "new-canvas";
15
+ const SUPPORTED_CAMPAIGN_ASPECT_RATIOS = new Set(["1:1", "4:5", "3:4", "16:9", "9:16"]);
16
+
17
+ export class CanvasAgentApiError extends Error {
18
+ constructor(message, options = {}) {
19
+ super(message);
20
+ this.name = "CanvasAgentApiError";
21
+ this.status = Number(options.status || 0);
22
+ this.details = options.details;
23
+ this.fields = options.fields;
24
+ this.requestId = options.requestId;
25
+ this.code = options.code || errorCodeForStatus(this.status);
26
+ this.cause = options.cause;
27
+ }
28
+ }
29
+
30
+ export function createCanvasAgentClient(options = {}) {
31
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
32
+ const token = cleanToken(options.token);
33
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
34
+ const surface = options.surface === "mcp" ? "mcp" : options.surface === "api" ? "api" : "cli";
35
+ const sleep = options.sleep || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
36
+ // Read from the manifest-backed constant, never a literal. It sat at
37
+ // "gavana-cli/0.1.0" through the 0.1.1 release, so every API request and
38
+ // every analytics event from both the CLI and the local MCP server reported a
39
+ // version that had not shipped for months.
40
+ const userAgent = String(options.userAgent || `gavana-cli/${GAVANA_CLI_VERSION}`)
41
+ .trim()
42
+ .slice(0, 200);
43
+ const analyticsContext = new AsyncLocalStorage();
44
+
45
+ if (typeof fetchImpl !== "function") throw new CanvasAgentApiError("This runtime does not provide fetch.", { code: "configuration" });
46
+
47
+ const request = async (pathname, requestOptions = {}) => {
48
+ const url = new URL(`${API_PREFIX}${pathname}`, `${baseUrl}/`);
49
+ for (const [key, value] of Object.entries(requestOptions.query || {})) {
50
+ if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
51
+ }
52
+ const headers = new Headers(requestOptions.headers || {});
53
+ headers.set("Authorization", `Bearer ${token}`);
54
+ headers.set("Accept", requestOptions.responseType === "text" ? "image/svg+xml, text/plain;q=0.9, application/json;q=0.8" : requestOptions.responseType === "response" ? "video/*" : "application/json");
55
+ headers.set("X-Gavana-Agent-Surface", surface);
56
+ const activeAnalytics = analyticsContext.getStore();
57
+ if (activeAnalytics?.traceId) headers.set("X-Gavana-Trace-Id", activeAnalytics.traceId);
58
+ if (activeAnalytics?.parentEventId) headers.set("X-Gavana-Parent-Event-Id", activeAnalytics.parentEventId);
59
+ if (activeAnalytics?.invocationId) headers.set("X-Gavana-Invocation-Id", activeAnalytics.invocationId);
60
+ if (userAgent) headers.set("User-Agent", userAgent);
61
+ let body;
62
+ if (requestOptions.body !== undefined) {
63
+ headers.set("Content-Type", "application/json");
64
+ body = JSON.stringify(requestOptions.body);
65
+ } else if (requestOptions.bytes !== undefined) {
66
+ const bytes = Buffer.from(requestOptions.bytes);
67
+ headers.set("Content-Type", "application/octet-stream");
68
+ if (requestOptions.fileName) headers.set("X-Gavana-File-Name", String(requestOptions.fileName));
69
+ body = bytes;
70
+ }
71
+
72
+ const method = requestOptions.method || "GET";
73
+ let response;
74
+ for (let attempt = 0; attempt < 3; attempt += 1) {
75
+ try {
76
+ response = await fetchImpl(url, {
77
+ method,
78
+ headers,
79
+ body,
80
+ cache: "no-store",
81
+ signal: requestOptions.signal,
82
+ });
83
+ } catch (error) {
84
+ if (error?.name === "AbortError" || error?.name === "TimeoutError") {
85
+ throw new CanvasAgentApiError("Gavana request timed out.", { code: "timeout", cause: error });
86
+ }
87
+ if (method === "GET" && attempt < 2) {
88
+ await sleep(250 * 2 ** attempt);
89
+ continue;
90
+ }
91
+ throw new CanvasAgentApiError(`Could not reach Gavana at ${baseUrl}.`, { code: "network", cause: error });
92
+ }
93
+ if (method === "GET" && attempt < 2 && [429, 502, 503, 504].includes(response.status)) {
94
+ await sleep(retryDelayMs(response.headers.get("retry-after"), attempt));
95
+ continue;
96
+ }
97
+ break;
98
+ }
99
+ if (!response) throw new CanvasAgentApiError(`Could not reach Gavana at ${baseUrl}.`, { code: "network" });
100
+ const requestId = response.headers.get("x-request-id") || response.headers.get("request-id") || undefined;
101
+
102
+ if (!response.ok) {
103
+ const text = await response.text().catch(() => "");
104
+ let payload;
105
+ try {
106
+ payload = text ? JSON.parse(text) : undefined;
107
+ } catch {
108
+ payload = undefined;
109
+ }
110
+ const message = payload?.error?.message || payload?.message || response.statusText || `Gavana request failed (${response.status}).`;
111
+ throw new CanvasAgentApiError(message, {
112
+ status: response.status,
113
+ code: payload?.error?.code || payload?.code || errorCodeForStatus(response.status),
114
+ details: payload?.error?.details || payload?.details,
115
+ fields: payload?.error?.fields || payload?.fields,
116
+ requestId,
117
+ });
118
+ }
119
+
120
+ if (requestOptions.responseType === "text") return response.text();
121
+ if (requestOptions.responseType === "response") return response;
122
+ if (response.status === 204) return null;
123
+ const text = await response.text();
124
+ if (!text) return null;
125
+ try {
126
+ const payload = JSON.parse(text);
127
+ return requestId && payload && typeof payload === "object" && !Array.isArray(payload) && payload.requestId === undefined ? { ...payload, requestId } : payload;
128
+ } catch (error) {
129
+ throw new CanvasAgentApiError("Gavana returned invalid JSON.", { status: 502, code: "invalid_response", requestId, cause: error });
130
+ }
131
+ };
132
+
133
+ const runWithAnalyticsContext = (context, handler) => analyticsContext.run(normalizeAnalyticsContext(context), handler);
134
+ const reportAnalyticsEvent = async (input = {}) => {
135
+ try {
136
+ const context = analyticsContext.getStore() || {};
137
+ const response = await fetchImpl(new URL("/api/agent-analytics/events", `${baseUrl}/`), {
138
+ method: "POST",
139
+ headers: {
140
+ Authorization: `Bearer ${token}`,
141
+ Accept: "application/json",
142
+ "Content-Type": "application/json",
143
+ "X-Gavana-Agent-Surface": surface,
144
+ ...(userAgent ? { "User-Agent": userAgent } : {}),
145
+ },
146
+ body: JSON.stringify({
147
+ ...input,
148
+ kind: input.kind || (surface === "mcp" ? "mcp_tool" : "cli_command"),
149
+ traceId: input.traceId || context.traceId || randomUUID(),
150
+ parentEventId: input.parentEventId || context.parentEventId,
151
+ invocationId: input.invocationId || context.invocationId || randomUUID(),
152
+ }),
153
+ cache: "no-store",
154
+ keepalive: true,
155
+ signal: AbortSignal.timeout(750),
156
+ });
157
+ if (!response.ok) return null;
158
+ return await response.json().catch(() => null);
159
+ } catch {
160
+ return null;
161
+ }
162
+ };
163
+
164
+ const applyOperations = async (canvasReference, input, requestOptions = {}) => {
165
+ const canvas = parseStableHandle(canvasReference, "canvas");
166
+ return request(`/canvases/${encodeURIComponent(canvas.id)}/operations`, {
167
+ method: "POST",
168
+ query: { ownerUid: canvas.ownerUid },
169
+ body: input,
170
+ signal: requestOptions.signal,
171
+ });
172
+ };
173
+
174
+ const validateOperations = async (canvasReference, input, requestOptions = {}) => {
175
+ const canvas = parseStableHandle(canvasReference, "canvas");
176
+ return request(`/canvases/${encodeURIComponent(canvas.id)}/operations`, {
177
+ method: "POST",
178
+ query: { ownerUid: canvas.ownerUid },
179
+ body: { ...input, validateOnly: true },
180
+ signal: requestOptions.signal,
181
+ });
182
+ };
183
+
184
+ const createWorkflow = async (canvasReference, input, requestOptions = {}) => {
185
+ const canvas = parseStableHandle(canvasReference, "canvas");
186
+ return request(`/canvases/${encodeURIComponent(canvas.id)}/workflows`, {
187
+ method: "POST",
188
+ query: { ownerUid: canvas.ownerUid },
189
+ body: input,
190
+ signal: requestOptions.signal,
191
+ });
192
+ };
193
+
194
+ const createCanvas = (input, requestOptions = {}) => request("/canvases", { method: "POST", body: input, signal: requestOptions.signal });
195
+
196
+ const getCanvas = (canvasReference, requestOptions = {}) => {
197
+ const canvas = parseStableHandle(canvasReference, "canvas");
198
+ return request(`/canvases/${encodeURIComponent(canvas.id)}`, {
199
+ query: { ownerUid: canvas.ownerUid },
200
+ signal: requestOptions.signal,
201
+ });
202
+ };
203
+
204
+ const getOrCreateAgentCanvas = (requestOptions = {}) =>
205
+ request("/canvases", {
206
+ method: "POST",
207
+ body: { canvasType: "agent", destination: AGENT_CANVAS_DESTINATION },
208
+ signal: requestOptions.signal,
209
+ });
210
+
211
+ const resolveCanvasDestination = async (destination, destinationOptions = {}) => {
212
+ const reference = String(destination || "").trim();
213
+ if (!reference) throw new CanvasAgentApiError("Image destination is required.", { code: "usage" });
214
+ if (reference === AGENT_CANVAS_DESTINATION) return getOrCreateAgentCanvas(destinationOptions);
215
+ if (reference !== NEW_CANVAS_DESTINATION) return getCanvas(reference, destinationOptions);
216
+
217
+ const idempotencyKey = String(destinationOptions.idempotencyKey || "").trim();
218
+ if (idempotencyKey.length < 8 || idempotencyKey.length > 200) throw new CanvasAgentApiError("A retry-safe 8-200 character idempotency key is required when destination is new-canvas.", { code: "usage" });
219
+ const digest = createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 24);
220
+ const canvasId = `agent-output-${digest}`;
221
+ try {
222
+ return await createCanvas({ id: canvasId, title: cleanDestinationTitle(destinationOptions.title) }, destinationOptions);
223
+ } catch (error) {
224
+ if (!(error instanceof CanvasAgentApiError) || error.status !== 409) throw error;
225
+ return getCanvas(canvasId, destinationOptions);
226
+ }
227
+ };
228
+
229
+ const prepareImageDestination = async (input = {}, requestOptions = {}) => {
230
+ const idempotencyKey = String(input.idempotencyKey || "").trim();
231
+ if (idempotencyKey.length < 8 || idempotencyKey.length > 200) throw new CanvasAgentApiError("idempotencyKey must contain 8-200 characters.", { code: "usage" });
232
+ const canvasResult =
233
+ input.canvasResult ||
234
+ (await resolveCanvasDestination(input.destination || input.canvasId, {
235
+ ...requestOptions,
236
+ idempotencyKey,
237
+ title: input.canvasTitle,
238
+ }));
239
+ const canvasHandle = canvasResult?.canvas?.handle;
240
+ if (!canvasHandle) throw new CanvasAgentApiError("Gavana did not return a destination canvas handle.", { code: "invalid_response" });
241
+
242
+ const count = boundedOutputCount(input.count);
243
+ const operation = ["generate", "edit", "variations", "action"].includes(input.operation) ? input.operation : "generate";
244
+ const digest = createHash("sha256").update(`${operation}:${idempotencyKey}`).digest("hex").slice(0, 24);
245
+ const suppliedTargets = [...(Array.isArray(input.targetNodeIds) ? input.targetNodeIds : []), ...(input.targetNodeId ? [input.targetNodeId] : [])].map((target) => {
246
+ const parsed = parseStableHandle(target, "node");
247
+ return `node:${parsed.id}`;
248
+ });
249
+ if (suppliedTargets.length) {
250
+ if (!input.requireIdleTargets) {
251
+ return {
252
+ canvasId: canvasHandle,
253
+ destination: String(input.destination || input.canvasId || canvasHandle),
254
+ baseRevision: input.baseRevision || canvasResult.canvas.revision,
255
+ targetNodeIds: suppliedTargets,
256
+ canvas: canvasResult.canvas,
257
+ };
258
+ }
259
+ // Validated explicit targets join the same claim protocol as
260
+ // automatic matching: the claim replays on exact retries, so a
261
+ // retry after generation started is not rejected by the idle check.
262
+ const prompt = String(input.prompt || "").trim();
263
+ const nodeById = new Map((canvasResult.canvas.nodes || []).map((node) => [node.id, node]));
264
+ for (const handle of suppliedTargets) {
265
+ const node = nodeById.get(handle.slice("node:".length));
266
+ if (!node) throw new CanvasAgentApiError(`Target ${handle} was not found on this canvas.`, { code: "not_found" });
267
+ if (node.type !== "image") throw new CanvasAgentApiError(`Target ${handle} must be an image node.`, { code: "usage" });
268
+ if (claimedByInvocation(node, digest, prompt)) continue;
269
+ if (node.metadata?.agentTargetClaim) throw new CanvasAgentApiError(`Target ${handle} is already claimed by another image invocation. Pick a different node or reuse that invocation's idempotency key.`, { code: "conflict" });
270
+ if (!emptyIdleImageTarget(node)) {
271
+ throw new CanvasAgentApiError(`Target ${handle} already has content or an active generation. Pick an empty idle image node or omit targetNodeId.`, { code: "conflict" });
272
+ }
273
+ }
274
+ const applied = await applyOperations(
275
+ canvasHandle,
276
+ {
277
+ baseRevision: input.baseRevision || canvasResult.canvas.revision,
278
+ idempotencyKey: `image-target-${digest}`,
279
+ operations: suppliedTargets.map((handle) => ({
280
+ type: "node.update",
281
+ nodeId: handle,
282
+ patch: { metadata: { prompt, agentTargetClaim: digest } },
283
+ })),
284
+ },
285
+ requestOptions,
286
+ );
287
+ return {
288
+ canvasId: canvasHandle,
289
+ destination: String(input.destination || input.canvasId || canvasHandle),
290
+ baseRevision: applied.canvas.revision,
291
+ targetNodeIds: suppliedTargets,
292
+ canvas: applied.canvas,
293
+ };
294
+ }
295
+
296
+ const targetIds = Array.from({ length: count }, (_, index) => `agent-image-${digest}-${index + 1}`);
297
+ const targetNodeIds = targetIds.map((id) => `node:${id}`);
298
+ const existingNodeIds = new Set((canvasResult.canvas.nodes || []).map((node) => node.id));
299
+ const existingTargetCount = targetIds.filter((id) => existingNodeIds.has(id)).length;
300
+ if (existingTargetCount && existingTargetCount !== targetIds.length) {
301
+ throw new CanvasAgentApiError("Automatic image targets are incomplete. Use a new idempotency key or pass explicit --target nodes.", { code: "conflict" });
302
+ }
303
+ if (existingTargetCount === targetIds.length) {
304
+ return {
305
+ canvasId: canvasHandle,
306
+ destination: String(input.destination || input.canvasId || canvasHandle),
307
+ baseRevision: input.baseRevision || canvasResult.canvas.revision,
308
+ targetNodeIds,
309
+ canvas: canvasResult.canvas,
310
+ };
311
+ }
312
+
313
+ // Before creating fresh targets, fill existing empty targets that match
314
+ // this invocation — typically idle "Generate" placeholders left by an
315
+ // earlier failed or abandoned attempt. Claimed nodes are stamped with
316
+ // this invocation's target digest so an exact retry re-selects the same
317
+ // nodes even after they start loading; the claim shares the automatic
318
+ // target-creation idempotency key, so the retry replays byte-identical
319
+ // operations instead of minting different targets.
320
+ const reusableTargets = reusableIdleImageTargets(canvasResult.canvas.nodes || [], canvasResult.canvas.connections || [], input, digest);
321
+ if (reusableTargets.length >= count) {
322
+ const reused = reusableTargets.slice(0, count);
323
+ const reusablePrompt = String(input.prompt || "").trim();
324
+ const applied = await applyOperations(
325
+ canvasHandle,
326
+ {
327
+ baseRevision: input.baseRevision || canvasResult.canvas.revision,
328
+ idempotencyKey: `image-target-${digest}`,
329
+ operations: reused.map((node) => ({
330
+ type: "node.update",
331
+ nodeId: `node:${node.id}`,
332
+ patch: { metadata: { prompt: reusablePrompt, agentTargetClaim: digest } },
333
+ })),
334
+ },
335
+ requestOptions,
336
+ );
337
+ return {
338
+ canvasId: canvasHandle,
339
+ destination: String(input.destination || input.canvasId || canvasHandle),
340
+ baseRevision: applied.canvas.revision,
341
+ targetNodeIds: reused.map((node) => `node:${node.id}`),
342
+ canvas: applied.canvas,
343
+ };
344
+ }
345
+
346
+ const dimensions = automaticTargetDimensions(input.size, input.targetWidth, input.targetHeight);
347
+ // Explicit targetX/targetY keeps its historical exact placement; an
348
+ // omitted position delegates to the shared free-region allocator so
349
+ // automatic targets never stack on existing content. Successful jobs
350
+ // reframe placeholders up to GROWN_FRAME_LIMIT while preserving their
351
+ // centers, so multi-output cells (and the Section wrapped around them)
352
+ // reserve that grown footprint up front.
353
+ const explicitOrigin = input.targetX !== undefined || input.targetY !== undefined ? automaticTargetOrigin(canvasResult.canvas.nodes || [], dimensions, input.targetX, input.targetY) : null;
354
+ const sectioned = count > 1 && !explicitOrigin;
355
+ const cellSize = sectioned ? { width: Math.max(dimensions.width, GROWN_FRAME_LIMIT), height: Math.max(dimensions.height, GROWN_FRAME_LIMIT) } : dimensions;
356
+ const cellPositions = explicitOrigin
357
+ ? targetIds.map((_, index) => ({ x: explicitOrigin.x + index * (dimensions.width + CANVAS_SIBLING_SPACING), y: explicitOrigin.y }))
358
+ : allocateCanvasRow(
359
+ canvasResult.canvas.nodes || [],
360
+ targetIds.map(() => cellSize),
361
+ );
362
+ const positions = cellPositions.map((cell) => ({ x: cell.x + Math.round((cellSize.width - dimensions.width) / 2), y: cell.y + Math.round((cellSize.height - dimensions.height) / 2) }));
363
+ const title = cleanTargetTitle(input.targetTitle, operation);
364
+ const operations = targetIds.map((id, index) => ({
365
+ type: "node.create",
366
+ clientId: `image-target-${index + 1}`,
367
+ node: {
368
+ id,
369
+ type: "image",
370
+ title: count > 1 ? `${title} ${index + 1}` : title,
371
+ position: positions[index],
372
+ width: dimensions.width,
373
+ height: dimensions.height,
374
+ ...(input.prompt ? { metadata: { prompt: String(input.prompt).slice(0, 8_000) } } : {}),
375
+ },
376
+ }));
377
+ // One task = one Section: a multi-output cluster is wrapped in a titled
378
+ // Section sized to its grown-cell footprint plus 48 units of padding,
379
+ // so batch generations stop scattering loose siblings across the
380
+ // shared canvas — and finished frames still fit inside the wrapper.
381
+ if (sectioned) {
382
+ const right = Math.max(...cellPositions.map((cell) => cell.x)) + cellSize.width;
383
+ const bottom = Math.max(...cellPositions.map((cell) => cell.y)) + cellSize.height;
384
+ // Padding clamps into the coordinate bound: a row allocated at the
385
+ // very edge trades a little padding on that edge instead of
386
+ // producing an out-of-range Section that fails the whole batch.
387
+ const sectionLeft = Math.max(cellPositions[0].x - CANVAS_SECTION_PADDING, -CANVAS_COORDINATE_LIMIT);
388
+ const sectionTop = Math.max(cellPositions[0].y - CANVAS_SECTION_PADDING, -CANVAS_COORDINATE_LIMIT);
389
+ const sectionWidth = Math.min(right + CANVAS_SECTION_PADDING, CANVAS_COORDINATE_LIMIT) - sectionLeft;
390
+ const sectionHeight = Math.min(bottom + CANVAS_SECTION_PADDING, CANVAS_COORDINATE_LIMIT) - sectionTop;
391
+ // Oversized custom targets can push the wrapper past the node
392
+ // dimension limit; the cluster then ships unwrapped rather than
393
+ // failing the whole batch before generation.
394
+ if (sectionWidth <= MAX_NODE_DIMENSION && sectionHeight <= MAX_NODE_DIMENSION) {
395
+ operations.unshift({
396
+ type: "node.create",
397
+ clientId: "image-target-section",
398
+ node: {
399
+ id: `agent-image-section-${digest}`,
400
+ type: "text",
401
+ title,
402
+ position: { x: sectionLeft, y: sectionTop },
403
+ width: sectionWidth,
404
+ height: sectionHeight,
405
+ metadata: { isSection: true, content: "", sectionAutoFit: { version: 1, owner: "agent", state: "auto-fit", padding: 48 } },
406
+ },
407
+ });
408
+ }
409
+ }
410
+ let applied;
411
+ try {
412
+ applied = await applyOperations(
413
+ canvasHandle,
414
+ {
415
+ baseRevision: input.baseRevision || canvasResult.canvas.revision,
416
+ idempotencyKey: `image-target-${digest}`,
417
+ operations,
418
+ },
419
+ requestOptions,
420
+ );
421
+ } catch (error) {
422
+ if (error instanceof CanvasAgentApiError && error.status === 409 && String(error.message).includes("already used for a different canvas batch")) {
423
+ // This invocation previously claimed targets that are no longer
424
+ // selectable (for example a claimed node was since filled by
425
+ // someone else). Fail loudly instead of overwriting content or
426
+ // minting orphan duplicates under a reused key.
427
+ throw new CanvasAgentApiError("This idempotencyKey already claimed image targets that are no longer available. Use a new idempotency key for a new generation, or check the original job with its run handle.", { status: 409, code: "conflict", cause: error });
428
+ }
429
+ throw error;
430
+ }
431
+ return {
432
+ canvasId: canvasHandle,
433
+ destination: String(input.destination || input.canvasId || canvasHandle),
434
+ baseRevision: applied.canvas.revision,
435
+ targetNodeIds,
436
+ canvas: applied.canvas,
437
+ };
438
+ };
439
+
440
+ return {
441
+ baseUrl,
442
+ request,
443
+ runWithAnalyticsContext,
444
+ reportAnalyticsEvent,
445
+ getAuthStatus: (requestOptions = {}) => request("/auth/status", requestOptions),
446
+ listCanvases: (requestOptions = {}) =>
447
+ request("/canvases", {
448
+ query: paginationQuery(requestOptions, 25),
449
+ signal: requestOptions.signal,
450
+ }),
451
+ createCanvas,
452
+ getCanvas,
453
+ getOrCreateAgentCanvas,
454
+ resolveCanvasDestination,
455
+ prepareImageDestination,
456
+ createWorkflow,
457
+ searchRecipes: (query = "", requestOptions = {}) =>
458
+ request("/recipes", {
459
+ query: { q: cleanOptionalText(query, "Recipe search query", 240), ...paginationQuery(requestOptions, 100) },
460
+ signal: requestOptions.signal,
461
+ }),
462
+ getRecipe: (recipeReference, version, requestOptions = {}) => {
463
+ const recipe = parseRequiredStableHandle(recipeReference, "recipe");
464
+ return request(`/recipes/${encodeURIComponent(recipe.id)}`, {
465
+ query: { version: cleanOptionalText(version, "Recipe version", 80) },
466
+ signal: requestOptions.signal,
467
+ });
468
+ },
469
+ forkRecipe: async (recipeReference, input, requestOptions = {}) => {
470
+ const recipe = parseRequiredStableHandle(recipeReference, "recipe");
471
+ const { canvas, ...normalized } = normalizeRecipeForkInput(input);
472
+ const baseRevision = normalized.baseRevision || (await getCanvas(stableHandle(canvas), requestOptions)).canvas?.revision;
473
+ if (!baseRevision) throw new CanvasAgentApiError("Read the canvas before forking a Recipe so its current revision is available.", { code: "invalid_response" });
474
+ return request(`/recipes/${encodeURIComponent(recipe.id)}/fork`, {
475
+ method: "POST",
476
+ body: { ...normalized, baseRevision },
477
+ signal: requestOptions.signal,
478
+ });
479
+ },
480
+ startRecipeRun: async (recipeReference, input, requestOptions = {}) => {
481
+ const recipe = parseRequiredStableHandle(recipeReference, "recipe");
482
+ const source = requireRecord(input, "Recipe run");
483
+ const canvas = parseStableHandle(source.canvasId, "canvas");
484
+ const canvasHandle = stableHandle(canvas);
485
+ const baseRevision = cleanOptionalText(source.baseRevision, "Canvas base revision", 200) || (await getCanvas(canvasHandle, requestOptions)).canvas?.revision;
486
+ if (!baseRevision) throw new CanvasAgentApiError("Read the canvas before starting a Recipe so its current revision is available.", { code: "invalid_response" });
487
+ return request(`/recipes/${encodeURIComponent(recipe.id)}/runs`, {
488
+ method: "POST",
489
+ body: {
490
+ ...source,
491
+ canvasId: canvasHandle,
492
+ baseRevision,
493
+ idempotencyKey: requiredIdempotencyKey(source.idempotencyKey),
494
+ },
495
+ signal: requestOptions.signal,
496
+ });
497
+ },
498
+ planCampaign: (input, requestOptions = {}) =>
499
+ request("/campaigns/plan", {
500
+ method: "POST",
501
+ body: normalizeCampaignPlanInput(input),
502
+ signal: requestOptions.signal,
503
+ }),
504
+ startCampaign: (input, requestOptions = {}) =>
505
+ request("/campaigns/start", {
506
+ method: "POST",
507
+ body: normalizeCampaignStartInput(input),
508
+ signal: requestOptions.signal,
509
+ }),
510
+ getCampaign: (runReference, requestOptions = {}) => {
511
+ const run = parseRequiredStableHandle(runReference, "run");
512
+ return request(`/campaigns/${encodeURIComponent(run.id)}`, { signal: requestOptions.signal });
513
+ },
514
+ reviewCampaign: (runReference, input, requestOptions = {}) => {
515
+ const run = parseRequiredStableHandle(runReference, "run");
516
+ return request(`/campaigns/${encodeURIComponent(run.id)}/review`, {
517
+ method: "POST",
518
+ body: normalizeCampaignReviewInput(input),
519
+ signal: requestOptions.signal,
520
+ });
521
+ },
522
+ cancelCampaign: (runReference, requestOptions = {}) => {
523
+ const run = parseRequiredStableHandle(runReference, "run");
524
+ return request(`/campaigns/${encodeURIComponent(run.id)}`, {
525
+ method: "DELETE",
526
+ signal: requestOptions.signal,
527
+ });
528
+ },
529
+ renderCanvas: (canvasReference, requestOptions = {}) => {
530
+ const canvas = parseStableHandle(canvasReference, "canvas");
531
+ return request(`/canvases/${encodeURIComponent(canvas.id)}/render`, {
532
+ query: { ownerUid: canvas.ownerUid },
533
+ responseType: "text",
534
+ signal: requestOptions.signal,
535
+ });
536
+ },
537
+ getNode: (canvasReference, nodeReference, requestOptions = {}) => {
538
+ const canvas = parseStableHandle(canvasReference, "canvas");
539
+ const node = parseStableHandle(nodeReference, "node");
540
+ return request(`/canvases/${encodeURIComponent(canvas.id)}/nodes/${encodeURIComponent(node.id)}`, {
541
+ query: { ownerUid: canvas.ownerUid },
542
+ signal: requestOptions.signal,
543
+ });
544
+ },
545
+ applyOperations,
546
+ validateOperations,
547
+ listAssets: (canvasReference, requestOptions = {}) => {
548
+ const canvas = canvasReference ? parseStableHandle(canvasReference, "canvas") : undefined;
549
+ return request("/assets", {
550
+ query: { canvasId: canvas?.id, ownerUid: canvas?.ownerUid, ...paginationQuery(requestOptions, 200) },
551
+ signal: requestOptions.signal,
552
+ });
553
+ },
554
+ getAsset: (assetReference, requestOptions = {}) => {
555
+ const asset = parseStableHandle(assetReference, "asset");
556
+ return request(`/assets/${encodeURIComponent(asset.id)}`, {
557
+ query: { ownerUid: asset.ownerUid },
558
+ signal: requestOptions.signal,
559
+ });
560
+ },
561
+ uploadAsset: (input, requestOptions = {}) => {
562
+ if (!input || !input.bytes) throw new CanvasAgentApiError("Image bytes are required.", { code: "usage" });
563
+ const canvas = input.canvasReference ? parseStableHandle(input.canvasReference, "canvas") : undefined;
564
+ return request("/assets", {
565
+ method: "POST",
566
+ query: { canvasId: canvas?.id, ownerUid: canvas?.ownerUid },
567
+ bytes: input.bytes,
568
+ fileName: input.fileName,
569
+ signal: requestOptions.signal,
570
+ });
571
+ },
572
+ listConnections: (requestOptions = {}) =>
573
+ request("/providers", {
574
+ query: paginationQuery(requestOptions, 100),
575
+ signal: requestOptions.signal,
576
+ }),
577
+ listModels: (filters = {}, requestOptions = {}) =>
578
+ request("/models", {
579
+ query: {
580
+ q: cleanOptionalText(filters.query, "Model search query", 240),
581
+ provider: cleanOptionalText(filters.provider, "Model provider", 80),
582
+ capability: cleanOptionalText(filters.capability, "Model capability", 80),
583
+ ...paginationQuery(requestOptions, 100),
584
+ },
585
+ signal: requestOptions.signal,
586
+ }),
587
+ getModel: (modelReference, requestOptions = {}) => {
588
+ const model = parseStableHandle(modelReference, "model");
589
+ return request(`/models/${encodeURIComponent(model.id)}`, { signal: requestOptions.signal });
590
+ },
591
+ listActions: (query = "", requestOptions = {}) =>
592
+ request("/actions", {
593
+ query: { q: cleanOptionalText(query, "Action search query", 240), ...paginationQuery(requestOptions, 100) },
594
+ signal: requestOptions.signal,
595
+ }),
596
+ getAction: (actionReference, requestOptions = {}) => {
597
+ const action = parseStableHandle(actionReference, "action");
598
+ return request(`/actions/${encodeURIComponent(action.id)}`, { signal: requestOptions.signal });
599
+ },
600
+ startAction: (actionReference, input, requestOptions = {}) => {
601
+ const action = parseStableHandle(actionReference, "action");
602
+ return request(`/actions/${encodeURIComponent(action.id)}/runs`, {
603
+ method: "POST",
604
+ body: input,
605
+ signal: requestOptions.signal,
606
+ });
607
+ },
608
+ startImage: async (operation, input, requestOptions = {}) => {
609
+ if (operation !== "generate" && operation !== "edit" && operation !== "variations") {
610
+ throw new CanvasAgentApiError("Image operation must be generate, edit, or variations.", { code: "usage" });
611
+ }
612
+ let body = input;
613
+ if (input?.canvasId === AGENT_CANVAS_DESTINATION) {
614
+ const resolved = await getOrCreateAgentCanvas(requestOptions);
615
+ body = { ...input, canvasId: resolved.canvas.handle, baseRevision: input.baseRevision || resolved.canvas.revision };
616
+ }
617
+ return request(`/images/${operation}`, {
618
+ method: "POST",
619
+ body,
620
+ signal: requestOptions.signal,
621
+ });
622
+ },
623
+ importImage: (input, requestOptions = {}) =>
624
+ request("/images/import", {
625
+ method: "POST",
626
+ body: input,
627
+ signal: requestOptions.signal,
628
+ }),
629
+ importProductReferencePack: (input, requestOptions = {}) =>
630
+ request("/product-references/import", {
631
+ method: "POST",
632
+ body: input,
633
+ signal: requestOptions.signal,
634
+ }),
635
+ startVideo: (input, requestOptions = {}) =>
636
+ request("/videos/generate", {
637
+ method: "POST",
638
+ body: input,
639
+ signal: requestOptions.signal,
640
+ }),
641
+ getJob: (jobReference, requestOptions = {}) => {
642
+ const job = parseStableHandle(jobReference, "job");
643
+ return request(`/jobs/${encodeURIComponent(job.id)}`, { signal: requestOptions.signal });
644
+ },
645
+ cancelJob: (jobReference, requestOptions = {}) => {
646
+ const job = parseStableHandle(jobReference, "job");
647
+ return request(`/jobs/${encodeURIComponent(job.id)}`, { method: "DELETE", signal: requestOptions.signal });
648
+ },
649
+ downloadJobOutput: (jobReference, requestOptions = {}) => {
650
+ const job = parseStableHandle(jobReference, "job");
651
+ return request(`/jobs/${encodeURIComponent(job.id)}/output`, { responseType: "response", signal: requestOptions.signal });
652
+ },
653
+ waitForJob: async (jobReference, waitOptions = {}) => {
654
+ const timeoutMs = positiveNumber(waitOptions.timeoutMs, 15 * 60_000);
655
+ const intervalMs = positiveNumber(waitOptions.intervalMs, 1_500, true);
656
+ const startedAt = Date.now();
657
+ let lastStatus = "";
658
+ while (Date.now() - startedAt <= timeoutMs) {
659
+ const result = await request(`/jobs/${encodeURIComponent(parseStableHandle(jobReference, "job").id)}`, { signal: waitOptions.signal });
660
+ if (result?.status !== lastStatus) {
661
+ lastStatus = String(result?.status || "");
662
+ await waitOptions.onProgress?.(result);
663
+ }
664
+ if (TERMINAL_RUN_STATUSES.has(result?.status)) return result;
665
+ if (waitOptions.signal?.aborted) throw new CanvasAgentApiError("Waiting for the job was canceled.", { code: "canceled" });
666
+ await sleep(intervalMs);
667
+ }
668
+ throw new CanvasAgentApiError(`Job did not finish within ${Math.round(timeoutMs / 1000)} seconds. Resume with job wait ${jobReference}.`, { code: "timeout" });
669
+ },
670
+ getRun: (runReference, requestOptions = {}) => {
671
+ const run = parseSharedRunHandle(runReference);
672
+ return request(`/runs/${encodeURIComponent(run.id)}`, { signal: requestOptions.signal });
673
+ },
674
+ cancelRun: (runReference, requestOptions = {}) => {
675
+ const run = parseSharedRunHandle(runReference);
676
+ return request(`/runs/${encodeURIComponent(run.id)}`, { method: "DELETE", signal: requestOptions.signal });
677
+ },
678
+ waitForRun: async (runReference, waitOptions = {}) => {
679
+ const run = parseSharedRunHandle(runReference);
680
+ const timeoutMs = positiveNumber(waitOptions.timeoutMs, 15 * 60_000);
681
+ const intervalMs = positiveNumber(waitOptions.intervalMs, 1_500, true);
682
+ const startedAt = Date.now();
683
+ let lastStatus = "";
684
+ while (Date.now() - startedAt <= timeoutMs) {
685
+ const result = await request(`/runs/${encodeURIComponent(run.id)}`, { signal: waitOptions.signal });
686
+ if (result?.status !== lastStatus) {
687
+ lastStatus = String(result?.status || "");
688
+ await waitOptions.onProgress?.(result);
689
+ }
690
+ if (TERMINAL_RUN_STATUSES.has(result?.status)) return result;
691
+ if (waitOptions.signal?.aborted) throw new CanvasAgentApiError("Waiting for the Run was canceled.", { code: "canceled" });
692
+ await sleep(intervalMs);
693
+ }
694
+ throw new CanvasAgentApiError(`Run did not finish within ${Math.round(timeoutMs / 1000)} seconds. Resume with run wait run:${run.id}.`, { code: "timeout" });
695
+ },
696
+ };
697
+ }
698
+
699
+ function normalizeAnalyticsContext(value = {}) {
700
+ return {
701
+ traceId: safeAnalyticsName(value.traceId) || randomUUID(),
702
+ parentEventId: safeAnalyticsName(value.parentEventId) || undefined,
703
+ invocationId: safeAnalyticsName(value.invocationId) || randomUUID(),
704
+ };
705
+ }
706
+
707
+ function safeAnalyticsName(value) {
708
+ const cleaned = String(value || "")
709
+ .trim()
710
+ .slice(0, 180);
711
+ return /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(cleaned) ? cleaned : "";
712
+ }
713
+
714
+ function retryDelayMs(retryAfter, attempt) {
715
+ const seconds = Number(retryAfter);
716
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(10_000, seconds * 1_000);
717
+ if (retryAfter) {
718
+ const date = Date.parse(retryAfter);
719
+ if (Number.isFinite(date)) return Math.max(0, Math.min(10_000, date - Date.now()));
720
+ }
721
+ return 250 * 2 ** attempt;
722
+ }
723
+
724
+ function parseSharedRunHandle(value) {
725
+ return parseStableHandle(value, "run");
726
+ }
727
+
728
+ export function parseStableHandle(value, expectedKind) {
729
+ const raw = String(value || "").trim();
730
+ if (!raw) throw new CanvasAgentApiError(`${capitalize(expectedKind)} reference is required.`, { code: "usage" });
731
+ const parts = raw.split(":");
732
+ if (expectedKind === "model") {
733
+ const key = parts.length === 1 ? parts[0] : parts.length === 2 && parts[0] === "model" ? parts[1] : "";
734
+ if (!/^[A-Za-z0-9_-]{8,600}$/.test(key)) throw invalidHandle(expectedKind);
735
+ return { kind: expectedKind, id: key };
736
+ }
737
+ if (expectedKind === "canvas" || expectedKind === "asset") {
738
+ if (parts.length === 1) return { kind: expectedKind, id: validateId(parts[0], capitalize(expectedKind)) };
739
+ if (parts[0] !== expectedKind) throw invalidHandle(expectedKind);
740
+ if (parts.length === 2) return { kind: expectedKind, id: validateId(parts[1], capitalize(expectedKind)) };
741
+ if (parts.length === 3) {
742
+ return {
743
+ kind: expectedKind,
744
+ ownerUid: validateId(parts[1], `${capitalize(expectedKind)} owner`),
745
+ id: validateId(parts[2], capitalize(expectedKind)),
746
+ };
747
+ }
748
+ throw invalidHandle(expectedKind);
749
+ }
750
+ if (parts.length === 1) return { kind: expectedKind, id: validateId(parts[0], capitalize(expectedKind)) };
751
+ if (parts.length !== 2 || parts[0] !== expectedKind) throw invalidHandle(expectedKind);
752
+ return { kind: expectedKind, id: validateId(parts[1], capitalize(expectedKind)) };
753
+ }
754
+
755
+ export function parseRequiredStableHandle(value, expectedKind) {
756
+ const raw = String(value || "").trim();
757
+ if (!raw.startsWith(`${expectedKind}:`)) throw invalidHandle(expectedKind);
758
+ return parseStableHandle(raw, expectedKind);
759
+ }
760
+
761
+ function normalizeCampaignPlanInput(input) {
762
+ const source = requireRecord(input, "campaign plan");
763
+ const canvas = parseRequiredStableHandle(source.canvasId, "canvas");
764
+ const recipe = parseRequiredStableHandle(source.recipeId, "recipe");
765
+ const brandKit = isRecord(source.brandKit) ? source.brandKit : {};
766
+ const product = normalizeCampaignProductReference(source.product ?? source.productReference);
767
+ const brandKitId = normalizeOptionalCampaignBrandKitId(source.brandKitId ?? brandKit.id);
768
+ const brandKitRevision = normalizeOptionalInteger(source.brandKitRevision ?? brandKit.revision, "Brand Kit revision", 1);
769
+ const brief = normalizeOptionalCampaignBrief(source.brief);
770
+ const finalOutputCount = normalizeOptionalInteger(source.finalOutputCount, "Final output count", 1, 4);
771
+ const aspectRatios = normalizeCampaignAspectRatios(source.aspectRatios);
772
+ const idempotencyKey = requiredIdempotencyKey(source.idempotencyKey);
773
+ const recipeVersion = cleanOptionalText(source.recipeVersion, "Recipe version", 80);
774
+
775
+ return {
776
+ canvasId: stableHandle(canvas),
777
+ recipeId: stableHandle(recipe),
778
+ ...(recipeVersion ? { recipeVersion } : {}),
779
+ ...(product ? { product } : {}),
780
+ ...(brandKitId ? { brandKitId } : {}),
781
+ ...(brandKitRevision !== undefined ? { brandKitRevision } : {}),
782
+ ...(brief !== undefined ? { brief } : {}),
783
+ ...(finalOutputCount !== undefined ? { finalOutputCount } : {}),
784
+ ...(aspectRatios ? { aspectRatios } : {}),
785
+ idempotencyKey,
786
+ };
787
+ }
788
+
789
+ function normalizeRecipeForkInput(input) {
790
+ const source = requireRecord(input, "Recipe fork");
791
+ const canvas = parseRequiredStableHandle(source.canvasId, "canvas");
792
+ const version = cleanOptionalText(source.version, "Recipe version", 80);
793
+ const baseRevision = cleanOptionalText(source.baseRevision, "Canvas base revision", 200);
794
+ const x = normalizeOptionalRecipeForkCoordinate(source.x, "Recipe fork x");
795
+ const y = normalizeOptionalRecipeForkCoordinate(source.y, "Recipe fork y");
796
+ return {
797
+ canvas,
798
+ canvasId: canvas.id,
799
+ ...(canvas.ownerUid ? { ownerUid: canvas.ownerUid } : {}),
800
+ ...(version ? { version } : {}),
801
+ ...(baseRevision ? { baseRevision } : {}),
802
+ ...(x !== undefined ? { x } : {}),
803
+ ...(y !== undefined ? { y } : {}),
804
+ idempotencyKey: requiredIdempotencyKey(source.idempotencyKey),
805
+ };
806
+ }
807
+
808
+ function normalizeOptionalRecipeForkCoordinate(value, label) {
809
+ if (value === undefined || value === null || value === "") return undefined;
810
+ const coordinate = Number(value);
811
+ if (!Number.isFinite(coordinate) || coordinate < -100_000 || coordinate > 100_000) {
812
+ throw new CanvasAgentApiError(`${label} must be a finite canvas coordinate between -100000 and 100000.`, { code: "usage" });
813
+ }
814
+ return Math.round(coordinate);
815
+ }
816
+
817
+ function normalizeCampaignStartInput(input) {
818
+ const source = requireRecord(input, "campaign start");
819
+ const campaign = parseRequiredStableHandle(source.campaignId, "campaign");
820
+ return {
821
+ campaignId: stableHandle(campaign),
822
+ idempotencyKey: requiredIdempotencyKey(source.idempotencyKey),
823
+ };
824
+ }
825
+
826
+ function normalizeCampaignReviewInput(input) {
827
+ const source = requireRecord(input, "campaign review");
828
+ const approved = source.approvedOutputNodeIds ?? source.approvedOutputs;
829
+ if (!Array.isArray(approved) || approved.length !== 2) {
830
+ throw new CanvasAgentApiError("Campaign review requires exactly two approved output node handles.", { code: "usage" });
831
+ }
832
+ const approvedOutputNodeIds = approved.map((candidate) => {
833
+ const value = isRecord(candidate) ? (candidate.handle ?? candidate.nodeId) : candidate;
834
+ return stableHandle(parseRequiredStableHandle(value, "node"));
835
+ });
836
+ if (new Set(approvedOutputNodeIds).size !== approvedOutputNodeIds.length) {
837
+ throw new CanvasAgentApiError("Campaign review approvals must reference two different output nodes.", { code: "usage" });
838
+ }
839
+ return {
840
+ approvedOutputNodeIds,
841
+ idempotencyKey: requiredIdempotencyKey(source.idempotencyKey),
842
+ };
843
+ }
844
+
845
+ function normalizeCampaignProductReference(value) {
846
+ if (value === undefined || value === null) return undefined;
847
+ const reference = isRecord(value) ? (value.handle ?? value.nodeId ?? value.assetId) : value;
848
+ const raw = String(reference ?? "").trim();
849
+ if (!raw) return undefined;
850
+ if (raw.length > 400) {
851
+ throw new CanvasAgentApiError("Campaign product reference must contain at most 400 characters.", { code: "usage" });
852
+ }
853
+ const kind = raw.startsWith("node:") ? "node" : raw.startsWith("asset:") ? "asset" : "";
854
+ return kind ? stableHandle(parseRequiredStableHandle(raw, kind)) : raw;
855
+ }
856
+
857
+ function normalizeOptionalCampaignBrandKitId(value) {
858
+ if (value === undefined || value === null || value === "") return undefined;
859
+ return validateId(String(value).replace(/^brand-kit:/, ""), "Brand Kit");
860
+ }
861
+
862
+ function normalizeOptionalCampaignBrief(value) {
863
+ if (value === undefined || value === null) return undefined;
864
+ const brief = String(value).trim();
865
+ if (!brief) return undefined;
866
+ if (brief.length > 8_000) {
867
+ throw new CanvasAgentApiError("Campaign brief must contain at most 8000 characters.", { code: "usage" });
868
+ }
869
+ return brief;
870
+ }
871
+
872
+ function normalizeOptionalInteger(value, label, minimum, maximum = Number.MAX_SAFE_INTEGER) {
873
+ if (value === undefined || value === null || value === "") return undefined;
874
+ return requiredInteger(value, label, minimum, maximum);
875
+ }
876
+
877
+ function paginationQuery(options = {}, maximum = 100) {
878
+ if (options.limit !== undefined && options.limit !== null && options.limit !== "" && typeof options.limit !== "string" && typeof options.limit !== "number") {
879
+ throw new CanvasAgentApiError(`Page limit must be an integer between 1 and ${maximum}.`, { code: "usage" });
880
+ }
881
+ const limit = options.limit === undefined || options.limit === null || options.limit === "" ? undefined : requiredInteger(options.limit, "Page limit", 1, maximum);
882
+ if (options.cursor !== undefined && options.cursor !== null && typeof options.cursor !== "string") {
883
+ throw new CanvasAgentApiError("Page cursor must be a string.", { code: "usage" });
884
+ }
885
+ const cursor = cleanOptionalText(options.cursor, "Page cursor", 8_192);
886
+ return {
887
+ ...(limit !== undefined ? { limit } : {}),
888
+ ...(cursor ? { cursor } : {}),
889
+ };
890
+ }
891
+
892
+ function normalizeCampaignAspectRatios(value) {
893
+ if (value === undefined || value === null) return undefined;
894
+ if (!Array.isArray(value) || value.length !== 1) {
895
+ throw new CanvasAgentApiError("Campaign V1 aspectRatios must contain exactly one supported ratio.", { code: "usage" });
896
+ }
897
+ const ratios = value.map((ratio) => String(ratio || "").trim());
898
+ if (ratios.some((ratio) => !SUPPORTED_CAMPAIGN_ASPECT_RATIOS.has(ratio))) {
899
+ throw new CanvasAgentApiError("Campaign aspectRatios support 1:1, 4:5, 3:4, 16:9, and 9:16.", { code: "usage" });
900
+ }
901
+ return ratios;
902
+ }
903
+
904
+ function requireRecord(value, label) {
905
+ if (!isRecord(value)) throw new CanvasAgentApiError(`${capitalize(label)} input must be a JSON object.`, { code: "usage" });
906
+ return value;
907
+ }
908
+
909
+ function isRecord(value) {
910
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
911
+ }
912
+
913
+ function cleanOptionalText(value, label, maximum) {
914
+ if (value === undefined || value === null || value === "") return "";
915
+ const text = String(value).trim();
916
+ if (text.length > maximum) throw new CanvasAgentApiError(`${label} must contain at most ${maximum} characters.`, { code: "usage" });
917
+ return text;
918
+ }
919
+
920
+ function requiredInteger(value, label, minimum, maximum = Number.MAX_SAFE_INTEGER) {
921
+ const number = Number(value);
922
+ if (!Number.isInteger(number) || number < minimum || number > maximum) {
923
+ const range = maximum === Number.MAX_SAFE_INTEGER ? `at least ${minimum}` : `between ${minimum} and ${maximum}`;
924
+ throw new CanvasAgentApiError(`${label} must be an integer ${range}.`, { code: "usage" });
925
+ }
926
+ return number;
927
+ }
928
+
929
+ function requiredIdempotencyKey(value) {
930
+ const key = String(value || "").trim();
931
+ if (key.length < 8 || key.length > 200) {
932
+ throw new CanvasAgentApiError("idempotencyKey must contain 8-200 characters.", { code: "usage" });
933
+ }
934
+ return key;
935
+ }
936
+
937
+ function stableHandle(reference) {
938
+ if ((reference.kind === "canvas" || reference.kind === "asset") && reference.ownerUid) return `${reference.kind}:${reference.ownerUid}:${reference.id}`;
939
+ return `${reference.kind}:${reference.id}`;
940
+ }
941
+
942
+ function cleanDestinationTitle(value) {
943
+ const title = String(value || "Agent image output")
944
+ .replace(/[\r\n]/g, " ")
945
+ .trim()
946
+ .slice(0, 160);
947
+ return title || "Agent image output";
948
+ }
949
+
950
+ function cleanTargetTitle(value, operation) {
951
+ const fallback = operation === "edit" ? "Agent image edit" : operation === "variations" ? "Agent image variation" : operation === "action" ? "Image Action result" : "Agent image";
952
+ const title = String(value || fallback)
953
+ .replace(/[\r\n]/g, " ")
954
+ .trim()
955
+ .slice(0, 160);
956
+ return title || fallback;
957
+ }
958
+
959
+ function boundedOutputCount(value) {
960
+ const count = value === undefined ? 1 : Number(value);
961
+ if (!Number.isInteger(count) || count < 1 || count > 4) throw new CanvasAgentApiError("Image output count must be between 1 and 4.", { code: "usage" });
962
+ return count;
963
+ }
964
+
965
+ function emptyIdleImageTarget(node) {
966
+ const metadata = node?.metadata || {};
967
+ if (metadata.storageKey || metadata.aiJobId) return false;
968
+ if (typeof metadata.content === "string" && metadata.content.trim()) return false;
969
+ if (metadata.status !== undefined && metadata.status !== "idle") return false;
970
+ if (metadata.listOutputId || metadata.batchRootId || metadata.isSection || metadata.isList || metadata.recipeInstance || metadata.isVisualRecipe) return false;
971
+ return true;
972
+ }
973
+
974
+ function matchesOptionalInvocationText(metadataValue, inputValue) {
975
+ return String(metadataValue || "").trim() === String(inputValue || "").trim();
976
+ }
977
+
978
+ /**
979
+ * True when this invocation's earlier claim on the node is still trustworthy:
980
+ * the claim digest matches and the node still carries the claimed prompt. A
981
+ * claim never overrides the current state of a node someone else repurposed —
982
+ * a changed prompt breaks the match and the invocation fails loudly instead of
983
+ * overwriting foreign work.
984
+ */
985
+ function claimedByInvocation(node, digest, prompt) {
986
+ const metadata = node?.metadata || {};
987
+ return Boolean(digest) && metadata.agentTargetClaim === digest && String(metadata.prompt || "").trim() === prompt;
988
+ }
989
+
990
+ function workflowOwnedNodeIds(connections) {
991
+ const owned = new Set();
992
+ for (const connection of Array.isArray(connections) ? connections : []) {
993
+ if (connection?.fromRecipePortNodeId || connection?.toRecipePortNodeId || connection?.recipeMaterializedOutput) {
994
+ owned.add(connection.fromNodeId);
995
+ owned.add(connection.toNodeId);
996
+ }
997
+ }
998
+ return owned;
999
+ }
1000
+
1001
+ /**
1002
+ * Image targets this invocation may fill instead of creating new nodes.
1003
+ *
1004
+ * Fresh candidates must be empty and idle, carry exactly the requested prompt
1005
+ * and generation parameters, be unclaimed, and must not belong to a workflow:
1006
+ * recipe-owned placeholders are filled by their own runs, so automatic reuse
1007
+ * never touches a node with a recipe port edge or a workflow-output id. Nodes
1008
+ * claimed by this invocation's digest stay candidates while they still carry
1009
+ * the claimed prompt and are not workflow-owned, which keeps exact retries
1010
+ * selecting the same nodes — and therefore replaying the same claim — after
1011
+ * generation started, without ever adopting a node that was repurposed since.
1012
+ * Sorted deterministically for identical selection across calls.
1013
+ */
1014
+ export function reusableIdleImageTargets(nodes, connections, input, digest) {
1015
+ const prompt = String(input?.prompt || "").trim();
1016
+ if (!prompt) return [];
1017
+ const workflowOwned = workflowOwnedNodeIds(connections);
1018
+ return (Array.isArray(nodes) ? nodes : [])
1019
+ .filter((node) => {
1020
+ if (node?.type !== "image") return false;
1021
+ const metadata = node.metadata || {};
1022
+ if (workflowOwned.has(node.id) || String(node.id).startsWith("workflow-output-")) return false;
1023
+ if (metadata.agentTargetClaim) return claimedByInvocation(node, digest, prompt);
1024
+ if (!emptyIdleImageTarget(node)) return false;
1025
+ if (String(metadata.prompt || "").trim() !== prompt) return false;
1026
+ return matchesOptionalInvocationText(metadata.model, input.model) && matchesOptionalInvocationText(metadata.size, input.size) && matchesOptionalInvocationText(metadata.quality, input.quality);
1027
+ })
1028
+ .sort((left, right) => (left.position?.y ?? 0) - (right.position?.y ?? 0) || (left.position?.x ?? 0) - (right.position?.x ?? 0) || String(left.id).localeCompare(String(right.id)));
1029
+ }
1030
+
1031
+ function automaticTargetDimensions(size, requestedWidth, requestedHeight) {
1032
+ const sizeMatch = String(size || "").match(/^(\d{2,5})x(\d{2,5})$/i);
1033
+ const aspect = sizeMatch ? Number(sizeMatch[2]) / Number(sizeMatch[1]) : 1;
1034
+ const width = finiteCanvasNumber(requestedWidth) || 340;
1035
+ const height = finiteCanvasNumber(requestedHeight) || Math.max(180, Math.min(720, Math.round(width * aspect)));
1036
+ return { width, height };
1037
+ }
1038
+
1039
+ function automaticTargetOrigin(nodes, dimensions, requestedX, requestedY) {
1040
+ const bottom = nodes.reduce((maximum, node) => {
1041
+ const y = finiteCanvasNumber(node?.position?.y, true) || 0;
1042
+ const height = finiteCanvasNumber(node?.height) || dimensions.height;
1043
+ return Math.max(maximum, y + height);
1044
+ }, -80);
1045
+ return {
1046
+ x: finiteCanvasNumber(requestedX, true) ?? 0,
1047
+ y: finiteCanvasNumber(requestedY, true) ?? bottom + 80,
1048
+ };
1049
+ }
1050
+
1051
+ function finiteCanvasNumber(value, allowZero = false) {
1052
+ if (value === undefined || value === null || value === "") return undefined;
1053
+ const number = Number(value);
1054
+ if (!Number.isFinite(number)) throw new CanvasAgentApiError("Automatic target position and size values must be numbers.", { code: "usage" });
1055
+ if (!allowZero && number <= 0) throw new CanvasAgentApiError("Automatic target width and height must be positive.", { code: "usage" });
1056
+ return number;
1057
+ }
1058
+
1059
+ export function normalizeBaseUrl(value) {
1060
+ const raw = String(value || "").trim();
1061
+ if (!raw) throw new CanvasAgentApiError("Gavana base URL is required. Run auth login or set GAVANA_BASE_URL.", { code: "configuration" });
1062
+ let url;
1063
+ try {
1064
+ url = new URL(raw);
1065
+ } catch (error) {
1066
+ throw new CanvasAgentApiError("Gavana base URL is invalid.", { code: "configuration", cause: error });
1067
+ }
1068
+ const localHttp = url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1");
1069
+ if (url.protocol !== "https:" && !localHttp) throw new CanvasAgentApiError("Gavana base URL must use HTTPS, except on local loopback.", { code: "configuration" });
1070
+ if (url.username || url.password || url.search || url.hash) {
1071
+ throw new CanvasAgentApiError("Gavana base URL cannot contain credentials, query parameters, or a fragment.", { code: "configuration" });
1072
+ }
1073
+ return url.toString().replace(/\/+$/, "");
1074
+ }
1075
+
1076
+ export function markdownForResult(result) {
1077
+ if (result?.status === "succeeded" && Array.isArray(result.images)) {
1078
+ const lines = [`Created ${result.images.length} image${result.images.length === 1 ? "" : "s"}`, ""];
1079
+ result.images.forEach((image, index) => {
1080
+ lines.push(image.markdown || `[Preview image ${index + 1}](${image.previewUrl})`);
1081
+ lines.push(`${image.assetId} · ${image.nodeId}`);
1082
+ lines.push("");
1083
+ });
1084
+ if (result.destination?.canvasId) lines.push(`Destination: ${result.destination.canvasId}`);
1085
+ if (result.canvasUrl) lines.push(`Review the full canvas: ${result.canvasUrl}`);
1086
+ if (Array.isArray(result.warnings) && result.warnings.length) {
1087
+ lines.push("", ...result.warnings.map((warning) => `Warning: ${warning}`));
1088
+ }
1089
+ return lines.join("\n").trim();
1090
+ }
1091
+ const preview = result?.asset?.previewUrl ? result.asset : result?.node?.previewUrl ? result.node : null;
1092
+ if (preview) {
1093
+ const label = preview.name || preview.title || preview.handle || "Gavana image";
1094
+ return [`![${safeMarkdownAlt(label)}](${preview.previewUrl})`, preview.handle || preview.assetId || preview.node || ""].filter(Boolean).join("\n\n");
1095
+ }
1096
+ return `\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``;
1097
+ }
1098
+
1099
+ function cleanToken(value) {
1100
+ const token = String(value || "").trim();
1101
+ if (!token) throw new CanvasAgentApiError("Agent token is required. Run auth login or set GAVANA_AGENT_TOKEN.", { code: "configuration" });
1102
+ if (/\s/.test(token)) throw new CanvasAgentApiError("Agent token is invalid.", { code: "configuration" });
1103
+ return token;
1104
+ }
1105
+
1106
+ function validateId(value, label) {
1107
+ const id = String(value || "").trim();
1108
+ if (!/^[A-Za-z0-9_-]{1,180}$/.test(id)) throw new CanvasAgentApiError(`${label} reference is invalid.`, { code: "usage" });
1109
+ return id;
1110
+ }
1111
+
1112
+ function invalidHandle(kind) {
1113
+ return new CanvasAgentApiError(`Expected ${kind}:<id>${kind === "canvas" ? " or canvas:<ownerUid>:<id>" : ""}.`, { code: "usage" });
1114
+ }
1115
+
1116
+ function capitalize(value) {
1117
+ return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
1118
+ }
1119
+
1120
+ function safeMarkdownAlt(value) {
1121
+ return String(value || "Gavana image")
1122
+ .replace(/[\[\]\n\r]/g, " ")
1123
+ .slice(0, 100);
1124
+ }
1125
+
1126
+ function positiveNumber(value, fallback, allowZero = false) {
1127
+ const number = Number(value ?? fallback);
1128
+ if (!Number.isFinite(number) || number < (allowZero ? 0 : 1)) return fallback;
1129
+ return number;
1130
+ }
1131
+
1132
+ function errorCodeForStatus(status) {
1133
+ if (status === 400 || status === 422) return "validation";
1134
+ if (status === 401) return "unauthorized";
1135
+ if (status === 403) return "forbidden";
1136
+ if (status === 404) return "not_found";
1137
+ if (status === 409) return "conflict";
1138
+ if (status === 429) return "rate_limited";
1139
+ if (status >= 500) return "upstream";
1140
+ return "request_failed";
1141
+ }