@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
@@ -0,0 +1,554 @@
1
+ // Deterministic canvas graph validation and destructive-impact reporting.
2
+ //
3
+ // Split out of canvas-agent-guide.mjs in guide version 1.2.0: guide prose is now
4
+ // editable markdown under ../guides/, and validation is code. The two no longer
5
+ // share a file. Validation still cites guide topics, so it imports the guide URI
6
+ // helpers — the dependency runs one way only (validation -> guide).
7
+ import { GAVANA_CANVAS_GUIDE_INDEX_URI, GAVANA_CANVAS_GUIDE_VERSION, canvasGuideUriForId } from "./canvas-agent-guide.mjs";
8
+
9
+ const MAX_FINDINGS_PER_CODE = 50;
10
+ const MAX_OVERLAP_COMPARISONS = 100_000;
11
+ const MAX_OVERLAP_GRID_CELLS_PER_NODE = 64;
12
+ const MAX_VALIDATION_COORDINATE = 10_000_000;
13
+ const MAX_VALIDATION_DIMENSION = 10_000;
14
+ const OVERLAP_GRID_SIZE = 2_048;
15
+
16
+ export function validateCanvasGraph(canvasValue, options = {}) {
17
+ const canvas = normalizeCanvas(canvasValue);
18
+ const graph = graphFindings(canvas, options);
19
+ const combined = dedupeAndLimitFindings([...(Array.isArray(options.additionalFindings) ? options.additionalFindings : []), ...graph.findings], graph.truncatedCodes);
20
+ const sorted = combined.findings;
21
+ const summary = {
22
+ errors: sorted.filter((finding) => finding.severity === "error").length,
23
+ warnings: sorted.filter((finding) => finding.severity === "warning").length,
24
+ info: sorted.filter((finding) => finding.severity === "info").length,
25
+ truncated: combined.truncatedCodes.length > 0,
26
+ ...(combined.truncatedCodes.length ? { truncatedCodes: combined.truncatedCodes } : {}),
27
+ };
28
+ return {
29
+ guideVersion: GAVANA_CANVAS_GUIDE_VERSION,
30
+ scope: options.scope === "proposed" ? "proposed" : "current",
31
+ canvas: {
32
+ handle: String(canvas.handle || canvas.id || ""),
33
+ revision: String(canvas.revision || ""),
34
+ nodeCount: canvas.nodes.length,
35
+ connectionCount: canvas.connections.length,
36
+ },
37
+ summary: { ...summary, passed: summary.errors === 0, reviewRequired: summary.errors > 0 || summary.warnings > 0 || summary.truncated },
38
+ findings: sorted,
39
+ completionReview: buildCompletionReview(canvas, sorted),
40
+ };
41
+ }
42
+
43
+ export function canvasDestructiveImpact(canvasValue, appliedValue = {}) {
44
+ const canvas = normalizeCanvas(canvasValue);
45
+ const nodesById = new Map(canvas.nodes.map((node) => [cleanId(node.id), node]));
46
+ const connectionsById = new Map(canvas.connections.map((connection) => [cleanId(connection.id), connection]));
47
+ const deletedNodeIds = Array.isArray(appliedValue.deletedNodeIds) ? appliedValue.deletedNodeIds.map(cleanId).filter((id) => nodesById.has(id)) : [];
48
+ const deletedConnectionIds = Array.isArray(appliedValue.deletedConnectionIds) ? appliedValue.deletedConnectionIds.map(cleanId).filter((id) => connectionsById.has(id)) : [];
49
+ const findings = [];
50
+ const coveredConnections = new Set();
51
+
52
+ for (const nodeId of deletedNodeIds) {
53
+ const node = nodesById.get(nodeId);
54
+ const attached = deletedConnectionIds.filter((connectionId) => {
55
+ const connection = connectionsById.get(connectionId);
56
+ return connection?.fromNodeId === nodeId || connection?.toNodeId === nodeId;
57
+ });
58
+ attached.forEach((connectionId) => coveredConnections.add(connectionId));
59
+ findings.push(
60
+ makeFinding("proposed_destructive_change", "warning", `The proposed batch deletes ${nodeHandle(nodeId)}${node?.title ? ` (${node.title})` : ""} and ${attached.length} attached connection(s). Apply only after explicit user intent.`, {
61
+ nodeIds: [nodeId],
62
+ connectionIds: attached,
63
+ guideId: "existing-canvases",
64
+ }),
65
+ );
66
+ }
67
+ for (const connectionId of deletedConnectionIds.filter((id) => !coveredConnections.has(id))) {
68
+ findings.push(
69
+ makeFinding("proposed_destructive_change", "warning", `The proposed batch deletes ${connectionHandle(connectionId)}. Apply only after explicit user intent.`, {
70
+ connectionIds: [connectionId],
71
+ guideId: "existing-canvases",
72
+ }),
73
+ );
74
+ }
75
+
76
+ return {
77
+ deletedNodeHandles: deletedNodeIds.map(nodeHandle),
78
+ deletedConnectionHandles: deletedConnectionIds.map(connectionHandle),
79
+ mediaNodeHandles: deletedNodeIds.filter((id) => nodesById.get(id)?.type === "image" || nodesById.get(id)?.type === "video").map(nodeHandle),
80
+ generatedNodeHandles: deletedNodeIds
81
+ .filter((id) => {
82
+ const node = nodesById.get(id);
83
+ return Boolean(node?.metadata?.generationRunId || node?.metadata?.aiJobId || node?.metadata?.actionId || node?.metadata?.variationSourceId);
84
+ })
85
+ .map(nodeHandle),
86
+ findings,
87
+ };
88
+ }
89
+
90
+
91
+ function normalizeCanvas(value) {
92
+ const source = isRecord(value?.canvas) ? value.canvas : isRecord(value) ? value : {};
93
+ return {
94
+ ...source,
95
+ nodes: Array.isArray(source.nodes) ? source.nodes.filter(isRecord).map(cloneRecord) : [],
96
+ connections: Array.isArray(source.connections) ? source.connections.filter(isRecord).map(cloneRecord) : [],
97
+ };
98
+ }
99
+
100
+ function graphFindings(canvas, validationOptions = {}) {
101
+ const findings = [];
102
+ const truncatedCodes = new Set();
103
+ const nodesById = new Map(canvas.nodes.map((node) => [cleanId(node.id), node]));
104
+ const incoming = new Map();
105
+ const outgoing = new Map();
106
+ const frameConnections = new Map();
107
+ const finding = (code, severity, message, options = {}) => {
108
+ const nodeIds = options.nodeIds || [];
109
+ const connectionIds = options.connectionIds || [];
110
+ const blocksWrite = proposedFindingTouchesEnforcedWork(validationOptions, nodeIds, connectionIds) && (severity === "error" || code === "node_overlap" || code === "section_content_overflow");
111
+ const blocksCompletion = nodeIds.some((id) => agentOwnsNode(nodesById.get(cleanId(id)))) || blocksWrite;
112
+ return makeFinding(code, blocksWrite ? "error" : severity, message, {
113
+ ...options,
114
+ ...(blocksWrite ? { blocksWrite: true } : {}),
115
+ ...(blocksCompletion ? { blocksCompletion: true } : {}),
116
+ });
117
+ };
118
+
119
+ for (const node of canvas.nodes) {
120
+ if (isBox(node)) continue;
121
+ const id = cleanId(node.id);
122
+ findings.push(
123
+ finding(
124
+ "invalid_node_geometry",
125
+ "error",
126
+ `${nodeHandle(id)} has invalid or oversized geometry. Positions must be finite within ${MAX_VALIDATION_COORDINATE} canvas units, and dimensions must be finite, positive, and no larger than ${MAX_VALIDATION_DIMENSION}.`,
127
+ {
128
+ nodeIds: [id],
129
+ guideId: "sections-layout",
130
+ },
131
+ ),
132
+ );
133
+ }
134
+
135
+ for (const connection of canvas.connections) {
136
+ const id = cleanId(connection.id);
137
+ const fromId = cleanId(connection.fromNodeId || connection.from);
138
+ const toId = cleanId(connection.toNodeId || connection.to);
139
+ const from = nodesById.get(fromId);
140
+ const to = nodesById.get(toId);
141
+ if (!from || !to) {
142
+ findings.push(
143
+ finding("broken_connection", "error", `${connectionHandle(id)} points to a missing node.`, {
144
+ nodeIds: [fromId, toId].filter(Boolean),
145
+ connectionIds: id ? [id] : [],
146
+ guideId: "connections",
147
+ }),
148
+ );
149
+ continue;
150
+ }
151
+ if (fromId === toId) findings.push(finding("unclear_connection", "error", `${connectionHandle(id)} connects a node to itself.`, { nodeIds: [fromId], connectionIds: [id], guideId: "connections" }));
152
+ if (!connection.hidden) {
153
+ incoming.set(toId, (incoming.get(toId) || 0) + 1);
154
+ outgoing.set(fromId, (outgoing.get(fromId) || 0) + 1);
155
+ }
156
+ if (from.metadata?.isSection === true || to.metadata?.isSection === true) {
157
+ findings.push(finding("unclear_connection", "warning", `${connectionHandle(id)} uses a Section as a workflow endpoint.`, { nodeIds: [fromId, toId], connectionIds: [id], guideId: "connections" }));
158
+ }
159
+ const mode = connection.mode;
160
+ if (mode === "list" && from.metadata?.isList !== true && to.metadata?.isList !== true) {
161
+ findings.push(finding("unclear_connection", "warning", `${connectionHandle(id)} is a list edge but neither endpoint is a List.`, { nodeIds: [fromId, toId], connectionIds: [id], guideId: "connections" }));
162
+ }
163
+ if (mode === "reference" && from.type !== "image") {
164
+ findings.push(finding("unclear_connection", "warning", `${connectionHandle(id)} is a reference edge whose source is not an image.`, { nodeIds: [fromId, toId], connectionIds: [id], guideId: "connections" }));
165
+ }
166
+ if ((mode === "first-frame" || mode === "last-frame") && (from.type !== "image" || to.type !== "video")) {
167
+ findings.push(finding("unclear_connection", "error", `${connectionHandle(id)} uses ${mode} but must connect an image to a video.`, { nodeIds: [fromId, toId], connectionIds: [id], guideId: "connections" }));
168
+ }
169
+ // Hidden prompt edges are provider plumbing the server ignores when it
170
+ // resolves a prompt, so they take part in neither the endpoint check
171
+ // nor the one-prompt-per-target rule.
172
+ if (mode === "prompt" && !connection.hidden && (!(from.type === "text" || from.type === "sticky") || to.type !== "image")) {
173
+ findings.push(finding("unclear_connection", "error", `${connectionHandle(id)} uses prompt but must connect a text or sticky note to an image node.`, { nodeIds: [fromId, toId], connectionIds: [id], guideId: "connections" }));
174
+ }
175
+ if (mode === "first-frame" || mode === "last-frame" || (mode === "prompt" && !connection.hidden)) {
176
+ const key = `${toId}:${mode}`;
177
+ const ids = frameConnections.get(key) || [];
178
+ ids.push(id);
179
+ frameConnections.set(key, ids);
180
+ }
181
+ if (!mode && (from.type === "image" || from.type === "video") && (to.type === "image" || to.type === "video")) {
182
+ findings.push(
183
+ finding("unclear_connection", "info", `${connectionHandle(id)} connects media without a reference or frame mode. Confirm that normal transformation flow is intended.`, {
184
+ nodeIds: [fromId, toId],
185
+ connectionIds: [id],
186
+ guideId: "connections",
187
+ }),
188
+ );
189
+ }
190
+ }
191
+
192
+ const connectionKeys = new Map();
193
+ for (const connection of canvas.connections.filter((entry) => !entry.hidden)) {
194
+ const key = `${cleanId(connection.fromNodeId || connection.from)}:${cleanId(connection.toNodeId || connection.to)}:${String(connection.mode || "normal")}`;
195
+ const ids = connectionKeys.get(key) || [];
196
+ ids.push(cleanId(connection.id));
197
+ connectionKeys.set(key, ids);
198
+ }
199
+ for (const ids of connectionKeys.values()) {
200
+ if (ids.length > 1) findings.push(finding("duplicate_connection", "warning", `Duplicate directed connections: ${ids.map(connectionHandle).join(", ")}.`, { connectionIds: ids, guideId: "connections" }));
201
+ }
202
+ for (const [key, ids] of frameConnections.entries()) {
203
+ if (ids.length < 2) continue;
204
+ const [targetId, mode] = key.split(":");
205
+ findings.push(finding("unclear_connection", "error", `${nodeHandle(targetId)} has more than one ${mode} connection.`, { nodeIds: [targetId], connectionIds: ids, guideId: "connections" }));
206
+ }
207
+
208
+ const ordinaryNodes = canvas.nodes.filter((node) => node.metadata?.isSection !== true && !intentionalOverlay(node) && isBox(node));
209
+ const overlapBuckets = new Map();
210
+ let overlapFindingCount = 0;
211
+ let overlapComparisonCount = 0;
212
+ overlapSearch: for (const node of ordinaryNodes) {
213
+ const cells = boxGridCells(node);
214
+ if (!cells) {
215
+ truncatedCodes.add("node_overlap");
216
+ continue;
217
+ }
218
+ const candidates = new Set();
219
+ for (const cell of cells) {
220
+ for (const candidate of overlapBuckets.get(cell) || []) candidates.add(candidate);
221
+ }
222
+ for (const candidate of candidates) {
223
+ overlapComparisonCount += 1;
224
+ if (overlapComparisonCount > MAX_OVERLAP_COMPARISONS) {
225
+ truncatedCodes.add("node_overlap");
226
+ break overlapSearch;
227
+ }
228
+ if (relatedOverlay(candidate, node)) continue;
229
+ const overlapWidth = Math.min(candidate.position.x + candidate.width, node.position.x + node.width) - Math.max(candidate.position.x, node.position.x);
230
+ const overlapHeight = Math.min(candidate.position.y + candidate.height, node.position.y + node.height) - Math.max(candidate.position.y, node.position.y);
231
+ if (overlapWidth <= 8 || overlapHeight <= 8) continue;
232
+ if (overlapFindingCount >= MAX_FINDINGS_PER_CODE) {
233
+ truncatedCodes.add("node_overlap");
234
+ break overlapSearch;
235
+ }
236
+ findings.push(finding("node_overlap", "warning", `${nodeHandle(candidate.id)} overlaps ${nodeHandle(node.id)}.`, { nodeIds: [candidate.id, node.id], guideId: "sections-layout" }));
237
+ overlapFindingCount += 1;
238
+ }
239
+ for (const cell of cells) {
240
+ const bucket = overlapBuckets.get(cell) || [];
241
+ bucket.push(node);
242
+ overlapBuckets.set(cell, bucket);
243
+ }
244
+ }
245
+
246
+ const sections = canvas.nodes.filter((node) => isRecord(node.metadata) && node.metadata.isSection === true && isBox(node));
247
+
248
+ for (const node of canvas.nodes) {
249
+ const id = cleanId(node.id);
250
+ const metadata = isRecord(node.metadata) ? node.metadata : {};
251
+ const section = sectionOwnerFor(node, sections);
252
+ if (metadata.isSection === true && (node.type !== "text" || !isBox(node) || node.width < 200 || node.height < 160)) {
253
+ findings.push(finding("malformed_section", "error", `${nodeHandle(id)} is marked as a Section but must be a text node at least 200 by 160.`, { nodeIds: [id], guideId: "notes-text-sections" }));
254
+ }
255
+ const agentCreated = typeof metadata.agentCreatedAt === "string" && metadata.isSection !== true && !metadata.listOutputId && !metadata.batchRootId && !metadata.recipeInstance && !metadata.isVisualRecipe;
256
+ if (agentCreated && !section) {
257
+ findings.push(finding("unsectioned_node", "info", `${nodeHandle(id)} is agent-created but sits outside every Section. Group agent work in a titled Section (one task = one Section).`, { nodeIds: [id], guideId: "sections-layout" }));
258
+ }
259
+ if (metadata.isSection !== true && section && !frameIsContainedBySection(node, section)) {
260
+ findings.push(
261
+ finding("section_content_overflow", "warning", `${nodeHandle(id)} escapes its containing Section ${nodeHandle(section.id)}. Keep the full node frame inside the Section bounds.`, {
262
+ nodeIds: [id, cleanId(section.id)],
263
+ guideId: "sections-layout",
264
+ }),
265
+ );
266
+ }
267
+ const content = typeof metadata.content === "string" ? metadata.content.trim() : "";
268
+ const looksLikeHeader = node.type === "text" && metadata.isSection !== true && content.length <= 160 && ((isBox(node) && node.width >= 520 && node.height <= 180) || Number(metadata.fontSize) >= 28);
269
+ if (looksLikeHeader && !(incoming.get(id) || outgoing.get(id))) {
270
+ findings.push(finding("fake_section_header", "warning", `${nodeHandle(id)} looks like a region header but is not a Section.`, { nodeIds: [id], guideId: "notes-text-sections" }));
271
+ }
272
+ const generated = (node.type === "image" || node.type === "video") && Boolean(metadata.generationRunId || metadata.aiJobId || metadata.actionId || metadata.variationSourceId);
273
+ const hasParent = Boolean(metadata.batchRootId || metadata.listOutputId);
274
+ if (generated && !(incoming.get(id) || hasParent)) {
275
+ findings.push(finding("unconnected_generated_output", "warning", `${nodeHandle(id)} is a generated output without an incoming relationship or output parent.`, { nodeIds: [id], guideId: "generated-assets" }));
276
+ }
277
+ const isMedia = node.type === "image" || node.type === "video";
278
+ const hasMediaResult = Boolean(metadata.storageKey || metadata.content || metadata.posterUrl);
279
+ if (isMedia && !hasMediaResult && !(incoming.get(id) || outgoing.get(id) || hasParent)) {
280
+ findings.push(finding("orphan_media_placeholder", "warning", `${nodeHandle(id)} is an empty media placeholder with no relationship to a prompt, source, List, or stage.`, { nodeIds: [id], guideId: "generated-assets" }));
281
+ }
282
+ for (const [field, values] of lineageReferences(metadata)) {
283
+ const missing = values.filter((value) => !nodesById.has(value));
284
+ if (missing.length) {
285
+ findings.push(
286
+ finding("broken_generated_lineage", "error", `${nodeHandle(id)} has missing ${field} reference(s): ${missing.map(nodeHandle).join(", ")}.`, {
287
+ nodeIds: [id, ...missing],
288
+ guideId: "generated-assets",
289
+ }),
290
+ );
291
+ }
292
+ }
293
+ }
294
+ return { findings, truncatedCodes: Array.from(truncatedCodes) };
295
+ }
296
+
297
+ /**
298
+ * Detect the most specific candidate Section by center point so validation can
299
+ * diagnose a frame that visibly escapes it. Persisted section membership uses
300
+ * the stricter full-frame contract and therefore never accepts that overflow.
301
+ */
302
+ function sectionOwnerFor(node, sections) {
303
+ if (!isBox(node) || node.metadata?.isSection === true) return null;
304
+ const centerX = node.position.x + node.width / 2;
305
+ const centerY = node.position.y + node.height / 2;
306
+ let owner = null;
307
+ for (const section of sections) {
308
+ if (cleanId(section.id) === cleanId(node.id)) continue;
309
+ const withinCenter = centerX >= section.position.x && centerX <= section.position.x + section.width && centerY >= section.position.y && centerY <= section.position.y + section.height;
310
+ if (!withinCenter) continue;
311
+ const area = section.width * section.height;
312
+ const ownerArea = owner ? owner.width * owner.height : Number.POSITIVE_INFINITY;
313
+ if (!owner || area < ownerArea || (area === ownerArea && cleanId(section.id) < cleanId(owner.id))) owner = section;
314
+ }
315
+ return owner;
316
+ }
317
+
318
+ function frameIsContainedBySection(node, section) {
319
+ return node.position.x >= section.position.x && node.position.y >= section.position.y && node.position.x + node.width <= section.position.x + section.width && node.position.y + node.height <= section.position.y + section.height;
320
+ }
321
+
322
+ function proposedFindingTouchesEnforcedWork(options, nodeIds, connectionIds) {
323
+ if (options.scope !== "proposed" || !isRecord(options.enforcement)) return false;
324
+ const enforcedNodes = new Set(Array.isArray(options.enforcement.nodeIds) ? options.enforcement.nodeIds.map(cleanId).filter(Boolean) : []);
325
+ const enforcedConnections = new Set(Array.isArray(options.enforcement.connectionIds) ? options.enforcement.connectionIds.map(cleanId).filter(Boolean) : []);
326
+ return nodeIds.some((id) => enforcedNodes.has(cleanId(id))) || connectionIds.some((id) => enforcedConnections.has(cleanId(id)));
327
+ }
328
+
329
+ function agentOwnsNode(node) {
330
+ return Boolean(isRecord(node?.metadata) && typeof node.metadata.agentCreatedAt === "string");
331
+ }
332
+
333
+ function buildCompletionReview(canvas, findings) {
334
+ const generatedOutputs = canvas.nodes.filter(isGeneratedOutput);
335
+ const durableOutputs = generatedOutputs.filter(hasDurableOutput);
336
+ const pendingOutputs = generatedOutputs.filter((node) => node.metadata?.aiJobStatus === "queued" || node.metadata?.aiJobStatus === "running" || node.metadata?.outputValidationStatus === "checking");
337
+ const failedOutputs = generatedOutputs.filter((node) => node.metadata?.status === "error" || node.metadata?.aiJobStatus === "failed" || node.metadata?.aiJobStatus === "canceled");
338
+ const overlap = reviewArea(findings, "node_overlap");
339
+ const containment = reviewArea(findings, "section_content_overflow", "unsectioned_node");
340
+ const referenceLineage = reviewArea(findings, "broken_generated_lineage", "unconnected_generated_output", "orphan_media_placeholder");
341
+ const productFidelity = productFidelityReview(canvas, generatedOutputs);
342
+ const blockingFindings = findings.filter((finding) => finding.blocksWrite === true || finding.blocksCompletion === true);
343
+ const blockingFindingCodes = uniqueSorted(blockingFindings.map((finding) => finding.code));
344
+ const advisoryFindingCodes = uniqueSorted(findings.filter((finding) => !blockingFindings.includes(finding)).map((finding) => finding.code));
345
+ const doneClaimAllowed = blockingFindings.length === 0 && productFidelity.status !== "needs-review";
346
+
347
+ return {
348
+ status: doneClaimAllowed ? "ready" : blockingFindings.length ? "blocked" : "needs-review",
349
+ doneClaimAllowed,
350
+ instruction: "Do not claim Done while blocking findings remain or product-fidelity review is needed.",
351
+ outputs: {
352
+ count: generatedOutputs.length,
353
+ durableCount: durableOutputs.length,
354
+ pendingCount: pendingOutputs.length,
355
+ failedCount: failedOutputs.length,
356
+ handles: generatedOutputs.map((node) => nodeHandle(node.id)).sort(),
357
+ },
358
+ overlap,
359
+ containment,
360
+ referenceLineage,
361
+ productFidelity,
362
+ blockingFindingCodes,
363
+ advisoryFindingCodes,
364
+ };
365
+ }
366
+
367
+ function reviewArea(findings, ...codes) {
368
+ const matched = findings.filter((finding) => codes.includes(finding.code));
369
+ const blocking = matched.some((finding) => finding.blocksWrite === true || finding.blocksCompletion === true);
370
+ return {
371
+ status: !matched.length ? "clear" : blocking ? "blocked" : "needs-review",
372
+ findingCodes: uniqueSorted(matched.map((finding) => finding.code)),
373
+ nodeHandles: uniqueSorted(matched.flatMap((finding) => finding.nodeHandles || [])),
374
+ connectionHandles: uniqueSorted(matched.flatMap((finding) => finding.connectionHandles || [])),
375
+ };
376
+ }
377
+
378
+ function productFidelityReview(canvas, generatedOutputs) {
379
+ const referenceTargets = generatedOutputs.filter((node) => outputHasReferenceEvidence(node, canvas.connections));
380
+ if (!referenceTargets.length) {
381
+ return {
382
+ status: "not-applicable",
383
+ reviewedOutputCount: 0,
384
+ needsReviewOutputHandles: [],
385
+ evidenceMissingOutputHandles: [],
386
+ };
387
+ }
388
+ const needsReview = referenceTargets.filter((node) => node.metadata?.outputValidationStatus === "needs-review" || node.metadata?.outputValidationStatus === "checking");
389
+ const evidenceMissing = referenceTargets.filter((node) => !node.metadata?.outputValidationStatus || node.metadata?.outputValidationStatus === "skipped");
390
+ return {
391
+ status: needsReview.length || evidenceMissing.length ? "needs-review" : "passed",
392
+ reviewedOutputCount: referenceTargets.length - needsReview.length - evidenceMissing.length,
393
+ needsReviewOutputHandles: needsReview.map((node) => nodeHandle(node.id)).sort(),
394
+ evidenceMissingOutputHandles: evidenceMissing.map((node) => nodeHandle(node.id)).sort(),
395
+ reasons: referenceTargets
396
+ .filter((node) => typeof node.metadata?.outputValidationReason === "string" && node.metadata.outputValidationReason)
397
+ .map((node) => ({ nodeHandle: nodeHandle(node.id), reason: node.metadata.outputValidationReason }))
398
+ .sort((left, right) => left.nodeHandle.localeCompare(right.nodeHandle)),
399
+ };
400
+ }
401
+
402
+ function isGeneratedOutput(node) {
403
+ if (node.type !== "image" && node.type !== "video") return false;
404
+ const metadata = isRecord(node.metadata) ? node.metadata : {};
405
+ return Boolean(metadata.generationRunId || metadata.aiJobId || metadata.actionId || metadata.variationSourceId);
406
+ }
407
+
408
+ function hasDurableOutput(node) {
409
+ const metadata = isRecord(node.metadata) ? node.metadata : {};
410
+ return Boolean(metadata.storageKey || metadata.content || metadata.posterUrl) && metadata.status !== "error";
411
+ }
412
+
413
+ function outputHasReferenceEvidence(node, connections) {
414
+ const metadata = isRecord(node.metadata) ? node.metadata : {};
415
+ if (Array.isArray(metadata.referenceHandles) && metadata.referenceHandles.length) return true;
416
+ if (Array.isArray(metadata.referenceNodeIds) && metadata.referenceNodeIds.length) return true;
417
+ if (Array.isArray(metadata.references) && metadata.references.length) return true;
418
+ return connections.some((connection) => cleanId(connection.toNodeId || connection.to) === cleanId(node.id) && connection.mode === "reference");
419
+ }
420
+
421
+ function uniqueSorted(values) {
422
+ return Array.from(new Set(values.filter(Boolean))).sort();
423
+ }
424
+
425
+ function lineageReferences(metadata) {
426
+ const result = [];
427
+ for (const field of ["batchRootId", "listOutputId", "firstFrameNodeId", "lastFrameNodeId", "listSourceId", "listGeneratorId", "variationSourceId", "primaryImageId"]) {
428
+ const value = cleanId(metadata[field]);
429
+ if (value) result.push([field, [value]]);
430
+ }
431
+ for (const field of ["batchChildIds", "generatedOutputChildIds", "referenceNodeIds", "listRunTargetIds"]) {
432
+ const values = Array.isArray(metadata[field]) ? metadata[field].map(cleanId).filter(Boolean) : [];
433
+ if (values.length) result.push([field, values]);
434
+ }
435
+ if (Array.isArray(metadata.listItems)) {
436
+ metadata.listItems.forEach((item, index) => {
437
+ if (!isRecord(item)) return;
438
+ const sourceNodeId = cleanId(item.sourceNodeId);
439
+ if (sourceNodeId) result.push([`listItems[${index}].sourceNodeId`, [sourceNodeId]]);
440
+ if (Array.isArray(item.referenceBindings)) {
441
+ const values = item.referenceBindings
442
+ .filter(isRecord)
443
+ .map((binding) => cleanId(binding.nodeId))
444
+ .filter(Boolean);
445
+ if (values.length) result.push([`listItems[${index}].referenceBindings`, values]);
446
+ }
447
+ });
448
+ }
449
+ return result;
450
+ }
451
+
452
+ function boxGridCells(node) {
453
+ const startX = Math.floor(node.position.x / OVERLAP_GRID_SIZE);
454
+ const endX = Math.floor((node.position.x + node.width - 1) / OVERLAP_GRID_SIZE);
455
+ const startY = Math.floor(node.position.y / OVERLAP_GRID_SIZE);
456
+ const endY = Math.floor((node.position.y + node.height - 1) / OVERLAP_GRID_SIZE);
457
+ const columns = endX - startX + 1;
458
+ const rows = endY - startY + 1;
459
+ if (!Number.isSafeInteger(columns) || !Number.isSafeInteger(rows) || columns <= 0 || rows <= 0 || columns * rows > MAX_OVERLAP_GRID_CELLS_PER_NODE) return null;
460
+ const cells = [];
461
+ for (let x = startX; x <= endX; x += 1) {
462
+ for (let y = startY; y <= endY; y += 1) cells.push(`${x}:${y}`);
463
+ }
464
+ return cells;
465
+ }
466
+
467
+ function makeFinding(code, severity, message, options = {}) {
468
+ const guideUri = canvasGuideUriForId(options.guideId || "validation-recovery");
469
+ return {
470
+ code,
471
+ severity,
472
+ message,
473
+ ...(options.nodeIds?.length ? { nodeHandles: Array.from(new Set(options.nodeIds.filter(Boolean).map(nodeHandle))).sort() } : {}),
474
+ ...(options.connectionIds?.length ? { connectionHandles: Array.from(new Set(options.connectionIds.filter(Boolean).map(connectionHandle))).sort() } : {}),
475
+ ...(options.blocksWrite ? { blocksWrite: true } : {}),
476
+ ...(options.blocksCompletion ? { blocksCompletion: true } : {}),
477
+ guideUri: guideUri || GAVANA_CANVAS_GUIDE_INDEX_URI,
478
+ };
479
+ }
480
+
481
+ function dedupeAndLimitFindings(findings, alreadyTruncated = []) {
482
+ const seen = new Set();
483
+ const counts = new Map();
484
+ const truncatedCodes = new Set(alreadyTruncated);
485
+ // Sort by severity before applying the per-code cap so truncation only ever
486
+ // drops the least severe findings of a code. Otherwise fifty advisory
487
+ // findings could crowd an error-severity finding of the same code out of
488
+ // the result — and out of the apply-batch validation gate.
489
+ const limited = findings
490
+ .slice()
491
+ .sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message))
492
+ .filter((entry) => {
493
+ const key = JSON.stringify([entry.code, entry.severity, entry.message, entry.nodeHandles, entry.connectionHandles]);
494
+ if (seen.has(key)) return false;
495
+ seen.add(key);
496
+ const count = counts.get(entry.code) || 0;
497
+ counts.set(entry.code, count + 1);
498
+ if (count >= MAX_FINDINGS_PER_CODE) truncatedCodes.add(entry.code);
499
+ return count < MAX_FINDINGS_PER_CODE;
500
+ });
501
+ return { findings: limited, truncatedCodes: Array.from(truncatedCodes).sort() };
502
+ }
503
+
504
+ function intentionalOverlay(node) {
505
+ return Boolean(node.metadata?.isBatchRoot || node.metadata?.isGeneratedOutputList);
506
+ }
507
+
508
+ function relatedOverlay(left, right) {
509
+ const leftMetadata = isRecord(left.metadata) ? left.metadata : {};
510
+ const rightMetadata = isRecord(right.metadata) ? right.metadata : {};
511
+ const relatedIds = [leftMetadata.batchRootId, leftMetadata.listOutputId, rightMetadata.batchRootId, rightMetadata.listOutputId].map(cleanId);
512
+ return relatedIds.includes(cleanId(left.id)) || relatedIds.includes(cleanId(right.id));
513
+ }
514
+
515
+ function isRecord(value) {
516
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
517
+ }
518
+
519
+ function cloneRecord(value) {
520
+ return typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
521
+ }
522
+
523
+ function isPoint(value) {
524
+ return isRecord(value) && Number.isFinite(value.x) && Math.abs(value.x) <= MAX_VALIDATION_COORDINATE && Number.isFinite(value.y) && Math.abs(value.y) <= MAX_VALIDATION_COORDINATE;
525
+ }
526
+
527
+ function isBox(value) {
528
+ return isPoint(value.position) && isValidationDimension(value.width) && isValidationDimension(value.height);
529
+ }
530
+
531
+ function isValidationDimension(value) {
532
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= MAX_VALIDATION_DIMENSION;
533
+ }
534
+
535
+ function cleanId(value) {
536
+ return String(value || "")
537
+ .trim()
538
+ .replace(/^(?:node|connection):/, "")
539
+ .slice(0, 240);
540
+ }
541
+
542
+ function nodeHandle(value) {
543
+ const id = cleanId(value);
544
+ return id ? `node:${id}` : "node:(missing)";
545
+ }
546
+
547
+ function connectionHandle(value) {
548
+ const id = cleanId(value);
549
+ return id ? `connection:${id}` : "connection:(missing)";
550
+ }
551
+
552
+ function severityRank(value) {
553
+ return value === "error" ? 0 : value === "warning" ? 1 : 2;
554
+ }