@angflow/angular 0.3.6 → 0.3.8
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.
- package/dist/base.css +23 -0
- package/dist/esm/lib/agent/agent-bridge.service.d.ts +20 -4
- package/dist/esm/lib/agent/agent-bridge.service.js +364 -34
- package/dist/esm/lib/agent/agent-bridge.service.js.map +1 -1
- package/dist/esm/lib/agent/index.d.ts +2 -1
- package/dist/esm/lib/agent/index.js.map +1 -1
- package/dist/esm/lib/agent/op-log.d.ts +35 -0
- package/dist/esm/lib/agent/op-log.js +41 -0
- package/dist/esm/lib/agent/op-log.js.map +1 -0
- package/dist/esm/lib/agent/provide-agent-bridge.d.ts +12 -1
- package/dist/esm/lib/agent/provide-agent-bridge.js +4 -1
- package/dist/esm/lib/agent/provide-agent-bridge.js.map +1 -1
- package/dist/esm/lib/agent/tool-schemas.js +133 -8
- package/dist/esm/lib/agent/tool-schemas.js.map +1 -1
- package/dist/esm/lib/agent/types.d.ts +10 -0
- package/dist/esm/lib/components/a11y-descriptions/a11y-descriptions.component.js +2 -2
- package/dist/esm/lib/components/controls/control-button.component.d.ts +20 -0
- package/dist/esm/lib/components/controls/control-button.component.js +35 -0
- package/dist/esm/lib/components/controls/control-button.component.js.map +1 -0
- package/dist/esm/lib/components/controls/controls.component.d.ts +6 -0
- package/dist/esm/lib/components/controls/controls.component.js +22 -16
- package/dist/esm/lib/components/controls/controls.component.js.map +1 -1
- package/dist/esm/lib/components/edge-toolbar/edge-toolbar.component.js +5 -1
- package/dist/esm/lib/components/edge-toolbar/edge-toolbar.component.js.map +1 -1
- package/dist/esm/lib/components/edges/simple-bezier-edge.component.js +2 -2
- package/dist/esm/lib/components/edges/simple-bezier-edge.component.js.map +1 -1
- package/dist/esm/lib/components/handle/handle.component.d.ts +24 -3
- package/dist/esm/lib/components/handle/handle.component.js +64 -9
- package/dist/esm/lib/components/handle/handle.component.js.map +1 -1
- package/dist/esm/lib/components/minimap/minimap.component.d.ts +6 -0
- package/dist/esm/lib/components/minimap/minimap.component.js +32 -6
- package/dist/esm/lib/components/minimap/minimap.component.js.map +1 -1
- package/dist/esm/lib/components/node-resizer/node-resizer.component.d.ts +10 -0
- package/dist/esm/lib/components/node-resizer/node-resizer.component.js +70 -25
- package/dist/esm/lib/components/node-resizer/node-resizer.component.js.map +1 -1
- package/dist/esm/lib/components/node-toolbar/node-toolbar.component.js +50 -14
- package/dist/esm/lib/components/node-toolbar/node-toolbar.component.js.map +1 -1
- package/dist/esm/lib/components/selection-box/selection-box.component.js +62 -50
- package/dist/esm/lib/components/selection-box/selection-box.component.js.map +1 -1
- package/dist/esm/lib/container/edge-renderer/edge-renderer.component.d.ts +13 -2
- package/dist/esm/lib/container/edge-renderer/edge-renderer.component.js +61 -16
- package/dist/esm/lib/container/edge-renderer/edge-renderer.component.js.map +1 -1
- package/dist/esm/lib/container/ng-flow/ng-flow.component.d.ts +11 -3
- package/dist/esm/lib/container/ng-flow/ng-flow.component.js +77 -9
- package/dist/esm/lib/container/ng-flow/ng-flow.component.js.map +1 -1
- package/dist/esm/lib/container/node-renderer/node-renderer.component.js +14 -0
- package/dist/esm/lib/container/node-renderer/node-renderer.component.js.map +1 -1
- package/dist/esm/lib/container/pane/pane.component.d.ts +24 -9
- package/dist/esm/lib/container/pane/pane.component.js +128 -54
- package/dist/esm/lib/container/pane/pane.component.js.map +1 -1
- package/dist/esm/lib/directives/key-handler.directive.d.ts +7 -0
- package/dist/esm/lib/directives/key-handler.directive.js +18 -1
- package/dist/esm/lib/directives/key-handler.directive.js.map +1 -1
- package/dist/esm/lib/public-api.d.ts +6 -4
- package/dist/esm/lib/public-api.js +3 -1
- package/dist/esm/lib/public-api.js.map +1 -1
- package/dist/esm/lib/services/flow-history.service.d.ts +56 -0
- package/dist/esm/lib/services/flow-history.service.js +105 -0
- package/dist/esm/lib/services/flow-history.service.js.map +1 -0
- package/dist/esm/lib/services/flow-store.service.d.ts +32 -18
- package/dist/esm/lib/services/flow-store.service.js +67 -21
- package/dist/esm/lib/services/flow-store.service.js.map +1 -1
- package/dist/esm/lib/services/ng-flow.service.d.ts +62 -25
- package/dist/esm/lib/services/ng-flow.service.js +176 -19
- package/dist/esm/lib/services/ng-flow.service.js.map +1 -1
- package/dist/style.css +23 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DestroyRef, Inject, Injectable, InjectionToken, Injector, Optional, effect, inject, runInInjectionContext, signal, } from '@angular/core';
|
|
2
2
|
import { AgentHistory } from './history';
|
|
3
|
+
import { OpLog } from './op-log';
|
|
3
4
|
import { AGENT_TOOL_SCHEMAS } from './tool-schemas';
|
|
4
5
|
import * as i0 from "@angular/core";
|
|
5
6
|
/** Provider token holding the user-supplied transport(s). */
|
|
@@ -10,10 +11,17 @@ export const AGENT_HISTORY_OPTIONS = new InjectionToken('AngflowAgentHistoryOpti
|
|
|
10
11
|
export const AGENT_ON_ERROR = new InjectionToken('AngflowAgentOnError');
|
|
11
12
|
/** Optional host-provided layout function backing the `layout_nodes` tool. */
|
|
12
13
|
export const AGENT_LAYOUT = new InjectionToken('AngflowAgentLayout');
|
|
14
|
+
/** Optional host write-guard for mutating tools. */
|
|
15
|
+
export const AGENT_CAN_MUTATE = new InjectionToken('AngflowAgentCanMutate');
|
|
16
|
+
/** Optional sink receiving each applied mutating op (for host persistence/replay). */
|
|
17
|
+
export const AGENT_ON_OP = new InjectionToken('AngflowAgentOnOp');
|
|
18
|
+
/** Op-log config. `false` disables the op-log + get_changes_since. Default { maxOps: 1000 }. */
|
|
19
|
+
export const AGENT_OPLOG_OPTIONS = new InjectionToken('AngflowAgentOpLogOptions');
|
|
13
20
|
const ERROR_INVALID_PARAMS = -32602;
|
|
14
21
|
const ERROR_METHOD_NOT_FOUND = -32601;
|
|
15
22
|
const ERROR_FLOW_NOT_FOUND = -32000;
|
|
16
23
|
const ERROR_INTERNAL = -32603;
|
|
24
|
+
const ERROR_MUTATION_DENIED = -32001;
|
|
17
25
|
const MUTATING_TOOLS = new Set([
|
|
18
26
|
'add_node',
|
|
19
27
|
'add_nodes',
|
|
@@ -26,8 +34,16 @@ const MUTATING_TOOLS = new Set([
|
|
|
26
34
|
'delete_elements',
|
|
27
35
|
'set_nodes',
|
|
28
36
|
'set_edges',
|
|
37
|
+
'group_nodes',
|
|
38
|
+
'set_node_group',
|
|
39
|
+
'set_group_collapsed',
|
|
40
|
+
'dissolve_group',
|
|
29
41
|
]);
|
|
30
42
|
// apply_changes is treated specially — see dispatch logic.
|
|
43
|
+
// Tools that mutate the node-template registry. They change rendering state but
|
|
44
|
+
// not graph nodes/edges, so they are canMutate-gated and op-logged, but NOT put
|
|
45
|
+
// through the graph-history snapshot (an undo can't restore a template).
|
|
46
|
+
const TEMPLATE_TOOLS = new Set(['register_node_template', 'unregister_node_template']);
|
|
31
47
|
/** Max flow.state emission rate while any node drag is in progress. */
|
|
32
48
|
const DRAG_STATE_EMIT_INTERVAL_MS = 100;
|
|
33
49
|
/**
|
|
@@ -55,7 +71,7 @@ const DRAG_STATE_EMIT_INTERVAL_MS = 100;
|
|
|
55
71
|
* ```
|
|
56
72
|
*/
|
|
57
73
|
export class AngflowAgentBridge {
|
|
58
|
-
constructor(transports, historyOptions, onError, layoutFn) {
|
|
74
|
+
constructor(transports, historyOptions, onError, layoutFn, canMutate, onOp, opLogOptions) {
|
|
59
75
|
/** Schemas for every exposed tool — feed straight to a Claude / OpenAI tools array. */
|
|
60
76
|
this.toolSchemas = AGENT_TOOL_SCHEMAS;
|
|
61
77
|
this.flows = new Map();
|
|
@@ -64,6 +80,8 @@ export class AngflowAgentBridge {
|
|
|
64
80
|
this.destroyRef = inject(DestroyRef);
|
|
65
81
|
this.started = false;
|
|
66
82
|
this.nextInProcessId = 1;
|
|
83
|
+
// Shared across flows; mintId's per-flow collision check guarantees uniqueness.
|
|
84
|
+
this.nextNodeIdSeq = 0;
|
|
67
85
|
this.warnedOnBeforeDeleteBypass = false;
|
|
68
86
|
/** Bumped every time a flow registers/unregisters. Useful for diagnostics. */
|
|
69
87
|
this.registeredFlows = signal([], ...(ngDevMode ? [{ debugName: "registeredFlows" }] : /* istanbul ignore next */ []));
|
|
@@ -72,6 +90,9 @@ export class AngflowAgentBridge {
|
|
|
72
90
|
historyOptions === false ? null : new AgentHistory(historyOptions ?? undefined);
|
|
73
91
|
this.onError = onError ?? null;
|
|
74
92
|
this.layoutFn = layoutFn ?? null;
|
|
93
|
+
this.canMutate = canMutate ?? null;
|
|
94
|
+
this.onOp = onOp ?? null;
|
|
95
|
+
this.opLog = opLogOptions === false ? null : new OpLog(opLogOptions ?? undefined);
|
|
75
96
|
this.installHandlers();
|
|
76
97
|
this.start();
|
|
77
98
|
this.destroyRef.onDestroy(() => this.stop());
|
|
@@ -108,6 +129,7 @@ export class AngflowAgentBridge {
|
|
|
108
129
|
// restoring them into this service would corrupt it).
|
|
109
130
|
existing.dispose();
|
|
110
131
|
this.history?.dropFlow(id);
|
|
132
|
+
this.opLog?.dropFlow(id);
|
|
111
133
|
}
|
|
112
134
|
const dispose = this.watchFlow(id, flow);
|
|
113
135
|
this.flows.set(id, { service: flow, dispose });
|
|
@@ -122,6 +144,7 @@ export class AngflowAgentBridge {
|
|
|
122
144
|
entry.dispose();
|
|
123
145
|
this.flows.delete(id);
|
|
124
146
|
this.history?.dropFlow(id);
|
|
147
|
+
this.opLog?.dropFlow(id);
|
|
125
148
|
this.registeredFlows.set(Array.from(this.flows.keys()));
|
|
126
149
|
this.emit({ event: 'flow.unregistered', params: { flowId: id } });
|
|
127
150
|
}
|
|
@@ -129,17 +152,26 @@ export class AngflowAgentBridge {
|
|
|
129
152
|
getFlow(id) {
|
|
130
153
|
return this.flows.get(id)?.service;
|
|
131
154
|
}
|
|
155
|
+
/** Mint a unique node/group id for a flow (collision-safe vs agent-supplied ids). */
|
|
156
|
+
mintId(flow, prefix) {
|
|
157
|
+
let id;
|
|
158
|
+
do {
|
|
159
|
+
id = `${prefix}_${++this.nextNodeIdSeq}`;
|
|
160
|
+
} while (flow.getNode(id));
|
|
161
|
+
return id;
|
|
162
|
+
}
|
|
132
163
|
/**
|
|
133
164
|
* Invoke a tool directly without going through a transport. Behaves
|
|
134
165
|
* identically to a JSON-RPC request: captures a history snapshot, emits
|
|
135
166
|
* `flow.history` / `flow.state` events, and throws a structured error
|
|
136
167
|
* (with `code` and `data` attached) on failure.
|
|
137
168
|
*/
|
|
138
|
-
async callTool(method, params = {}) {
|
|
169
|
+
async callTool(method, params = {}, opts) {
|
|
139
170
|
const response = await this.dispatch({
|
|
140
171
|
id: `in-process:${this.nextInProcessId++}`,
|
|
141
172
|
method,
|
|
142
173
|
params,
|
|
174
|
+
source: opts?.source,
|
|
143
175
|
});
|
|
144
176
|
if ('error' in response) {
|
|
145
177
|
const err = new Error(response.error.message);
|
|
@@ -199,6 +231,25 @@ export class AngflowAgentBridge {
|
|
|
199
231
|
const flowId = this.findFlowId(flow);
|
|
200
232
|
const isApplyChanges = req.method === 'apply_changes';
|
|
201
233
|
const isLayout = req.method === 'layout_nodes';
|
|
234
|
+
// The guarded/recorded set: forward graph mutations only. Reads, selection,
|
|
235
|
+
// viewport, and undo/redo/clear_history are intentionally NOT gated — the
|
|
236
|
+
// guard protects against agent writes, not the host's own recovery ops.
|
|
237
|
+
const isMutating = MUTATING_TOOLS.has(req.method) || isApplyChanges || isLayout;
|
|
238
|
+
const isTemplateMutation = TEMPLATE_TOOLS.has(req.method);
|
|
239
|
+
const source = req.source;
|
|
240
|
+
if (this.canMutate && (isMutating || isTemplateMutation)) {
|
|
241
|
+
let verdict;
|
|
242
|
+
try {
|
|
243
|
+
verdict = await this.canMutate({ method: req.method, params }, source);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
this.reportError(err, { kind: 'dispatch', method: req.method });
|
|
247
|
+
verdict = false; // fail safe
|
|
248
|
+
}
|
|
249
|
+
if (verdict !== true) {
|
|
250
|
+
throw new MutationDeniedError(typeof verdict === 'string' && verdict ? verdict : 'Mutation denied by host.');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
202
253
|
// Pre-mutation snapshot for history capture. Skipped for non-mutating tools.
|
|
203
254
|
// Shallow-clone each element so subsequent in-place mutations (notably
|
|
204
255
|
// the drag fast-path in FlowStore) can't retroactively corrupt the
|
|
@@ -211,32 +262,34 @@ export class AngflowAgentBridge {
|
|
|
211
262
|
};
|
|
212
263
|
}
|
|
213
264
|
const result = await handler(flow, params);
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
265
|
+
// Whether this call counts as a recordable mutation (mirrors the history
|
|
266
|
+
// capture conditions). Computed independently of `this.history` so the
|
|
267
|
+
// op-log records even when history is disabled.
|
|
268
|
+
let recorded = false;
|
|
269
|
+
if (isMutating && flowId) {
|
|
217
270
|
if (isApplyChanges) {
|
|
218
271
|
const ops = params['ops'] ?? [];
|
|
219
|
-
|
|
220
|
-
o['op'] !== 'select_edges' &&
|
|
221
|
-
o['op'] !== 'deselect_all');
|
|
222
|
-
if (hasNonSelection) {
|
|
223
|
-
this.history.capture(flowId, snapshot);
|
|
224
|
-
this.emitHistory(flowId);
|
|
225
|
-
}
|
|
272
|
+
recorded = ops.some((o) => o['op'] !== 'select_nodes' && o['op'] !== 'select_edges' && o['op'] !== 'deselect_all');
|
|
226
273
|
}
|
|
227
274
|
else if (isLayout) {
|
|
228
|
-
// Capture only when at least one position was applied — an empty
|
|
229
|
-
// layout pass must not pollute the undo stack.
|
|
230
275
|
const positions = result?.positions ?? {};
|
|
231
|
-
|
|
232
|
-
this.history.capture(flowId, snapshot);
|
|
233
|
-
this.emitHistory(flowId);
|
|
234
|
-
}
|
|
276
|
+
recorded = Object.keys(positions).length > 0;
|
|
235
277
|
}
|
|
236
278
|
else {
|
|
279
|
+
recorded = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (recorded && flowId) {
|
|
283
|
+
if (this.history && snapshot) {
|
|
237
284
|
this.history.capture(flowId, snapshot);
|
|
238
|
-
this.emitHistory(flowId);
|
|
285
|
+
this.emitHistory(flowId, source);
|
|
239
286
|
}
|
|
287
|
+
this.recordOp(flowId, req.method, params, source);
|
|
288
|
+
}
|
|
289
|
+
// Template-registry mutations are op-logged (for get_changes_since
|
|
290
|
+
// visibility) but not history-snapshotted.
|
|
291
|
+
if (isTemplateMutation && flowId) {
|
|
292
|
+
this.recordOp(flowId, req.method, params, source);
|
|
240
293
|
}
|
|
241
294
|
return { id: req.id, result: result ?? null };
|
|
242
295
|
}
|
|
@@ -260,6 +313,9 @@ export class AngflowAgentBridge {
|
|
|
260
313
|
},
|
|
261
314
|
};
|
|
262
315
|
}
|
|
316
|
+
if (err instanceof MutationDeniedError) {
|
|
317
|
+
return { id: req.id, error: { code: ERROR_MUTATION_DENIED, message: err.message } };
|
|
318
|
+
}
|
|
263
319
|
// Anything that lands here is an unexpected throw inside a handler — a
|
|
264
320
|
// bug or an underlying service failure. Surface it via onError so the
|
|
265
321
|
// host can log / report it; the wire response is still -32603.
|
|
@@ -280,11 +336,26 @@ export class AngflowAgentBridge {
|
|
|
280
336
|
}
|
|
281
337
|
return null;
|
|
282
338
|
}
|
|
283
|
-
emitHistory(flowId) {
|
|
339
|
+
emitHistory(flowId, source) {
|
|
284
340
|
if (!this.history)
|
|
285
341
|
return;
|
|
286
342
|
const status = this.history.status(flowId);
|
|
287
|
-
this.emit({ event: 'flow.history', params: { flowId, ...status } });
|
|
343
|
+
this.emit({ event: 'flow.history', params: { flowId, ...status, ...(source ? { source } : {}) } });
|
|
344
|
+
}
|
|
345
|
+
recordOp(flowId, method, params, source) {
|
|
346
|
+
if (!this.opLog)
|
|
347
|
+
return;
|
|
348
|
+
// Shallow-clone params so a later in-place mutation of the caller's object
|
|
349
|
+
// can't corrupt a stored (replay-able) log entry.
|
|
350
|
+
const entry = this.opLog.append(flowId, { method, params: { ...params }, source });
|
|
351
|
+
if (this.onOp) {
|
|
352
|
+
try {
|
|
353
|
+
this.onOp(entry);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
this.reportError(err, { kind: 'dispatch', method });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
288
359
|
}
|
|
289
360
|
emit(evt) {
|
|
290
361
|
for (const t of this.transports) {
|
|
@@ -401,11 +472,59 @@ export class AngflowAgentBridge {
|
|
|
401
472
|
}
|
|
402
473
|
installHandlers() {
|
|
403
474
|
this.handlers.set('list_flows', () => Array.from(this.flows.keys()));
|
|
404
|
-
this.handlers.set('get_state', (flow) =>
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
475
|
+
this.handlers.set('get_state', (flow, params) => {
|
|
476
|
+
const hasGroup = params['groupId'] !== undefined;
|
|
477
|
+
const hasBounds = params['bounds'] !== undefined;
|
|
478
|
+
if (hasGroup && hasBounds) {
|
|
479
|
+
throw new InvalidParamsError('Pass either "groupId" or "bounds", not both.');
|
|
480
|
+
}
|
|
481
|
+
const allNodes = flow.getNodes();
|
|
482
|
+
const allEdges = flow.getEdges();
|
|
483
|
+
let nodes = allNodes;
|
|
484
|
+
let edges = allEdges;
|
|
485
|
+
if (hasGroup) {
|
|
486
|
+
const groupId = requireString(params, 'groupId');
|
|
487
|
+
if (!flow.getNode(groupId)) {
|
|
488
|
+
throw new InvalidParamsError(`get_state: no node with id "${groupId}".`);
|
|
489
|
+
}
|
|
490
|
+
const ids = descendantIdsOf(groupId, buildChildMap(allNodes));
|
|
491
|
+
nodes = allNodes.filter((n) => ids.has(n.id));
|
|
492
|
+
edges = inducedEdges(allEdges, ids);
|
|
493
|
+
}
|
|
494
|
+
else if (hasBounds) {
|
|
495
|
+
const rect = requireRect(params, 'bounds');
|
|
496
|
+
nodes = allNodes.filter((n) => flow.isNodeIntersecting(n, rect, true));
|
|
497
|
+
const idSet = new Set(nodes.map((n) => n.id));
|
|
498
|
+
edges = inducedEdges(allEdges, idSet);
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
nodes,
|
|
502
|
+
edges,
|
|
503
|
+
viewport: flow.getViewport(),
|
|
504
|
+
collapsedHiddenIds: flow.getCollapsedHiddenIds(),
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
this.handlers.set('get_summary', (flow) => {
|
|
508
|
+
const nodes = flow.getNodes();
|
|
509
|
+
const edges = flow.getEdges();
|
|
510
|
+
const childMap = buildChildMap(nodes);
|
|
511
|
+
const groups = nodes
|
|
512
|
+
.filter((n) => n.type === 'group')
|
|
513
|
+
.map((g) => ({
|
|
514
|
+
id: g.id,
|
|
515
|
+
label: nodeTitle(g),
|
|
516
|
+
collapsed: g.collapsed === true,
|
|
517
|
+
memberCount: descendantIdsOf(g.id, childMap).size,
|
|
518
|
+
}));
|
|
519
|
+
return {
|
|
520
|
+
counts: { nodes: nodes.length, edges: edges.length, groups: groups.length },
|
|
521
|
+
groups,
|
|
522
|
+
titles: nodes.map((n) => ({ id: n.id, type: n.type ?? 'default', label: nodeTitle(n) })),
|
|
523
|
+
viewport: flow.getViewport(),
|
|
524
|
+
bounds: nodes.length > 0 ? flow.getNodesBounds(nodes) : null,
|
|
525
|
+
collapsedHiddenIds: flow.getCollapsedHiddenIds(),
|
|
526
|
+
};
|
|
527
|
+
});
|
|
409
528
|
this.handlers.set('get_nodes', (flow) => flow.getNodes());
|
|
410
529
|
this.handlers.set('get_edges', (flow) => flow.getEdges());
|
|
411
530
|
this.handlers.set('get_node', (flow, params) => {
|
|
@@ -417,10 +536,79 @@ export class AngflowAgentBridge {
|
|
|
417
536
|
return flow.getEdge(id) ?? null;
|
|
418
537
|
});
|
|
419
538
|
this.handlers.set('add_node', (flow, params) => {
|
|
420
|
-
|
|
539
|
+
// Shallow-clone so we never mutate the caller's params object when minting.
|
|
540
|
+
const raw = { ...requireObject(params, 'node') };
|
|
541
|
+
if (raw['id'] == null || raw['id'] === '') {
|
|
542
|
+
raw['id'] = this.mintId(flow, 'node');
|
|
543
|
+
}
|
|
544
|
+
const node = validateNodeShape(raw, 'add_node');
|
|
421
545
|
flow.addNodes(node);
|
|
422
546
|
return flow.getNode(node.id) ?? null;
|
|
423
547
|
});
|
|
548
|
+
this.handlers.set('group_nodes', async (flow, params) => {
|
|
549
|
+
const nodeIds = optionalStringArray(params, 'nodeIds');
|
|
550
|
+
if (!nodeIds || nodeIds.length === 0) {
|
|
551
|
+
throw new InvalidParamsError('Param "nodeIds" must be a non-empty array of strings.');
|
|
552
|
+
}
|
|
553
|
+
for (const id of nodeIds) {
|
|
554
|
+
if (!flow.getNode(id))
|
|
555
|
+
throw new InvalidParamsError(`group_nodes: unknown node id "${id}".`);
|
|
556
|
+
}
|
|
557
|
+
const suppliedGroupId = typeof params['groupId'] === 'string' && params['groupId'] ? params['groupId'] : undefined;
|
|
558
|
+
if (suppliedGroupId && flow.getNode(suppliedGroupId)) {
|
|
559
|
+
throw new InvalidParamsError(`group_nodes: a node with id "${suppliedGroupId}" already exists.`);
|
|
560
|
+
}
|
|
561
|
+
const groupId = suppliedGroupId ?? this.mintId(flow, 'group');
|
|
562
|
+
const label = typeof params['label'] === 'string' ? params['label'] : undefined;
|
|
563
|
+
const collapsed = typeof params['collapsed'] === 'boolean' ? params['collapsed'] : undefined;
|
|
564
|
+
// padding/headerHeight: inline typeof (not optionalPositiveNumber) — 0 is a valid inset.
|
|
565
|
+
const padding = typeof params['padding'] === 'number' ? params['padding'] : undefined;
|
|
566
|
+
const headerHeight = typeof params['headerHeight'] === 'number' ? params['headerHeight'] : undefined;
|
|
567
|
+
await flow.groupNodes(nodeIds, { groupId, label, collapsed, padding, headerHeight });
|
|
568
|
+
return { groupId };
|
|
569
|
+
});
|
|
570
|
+
this.handlers.set('set_node_group', async (flow, params) => {
|
|
571
|
+
const nodeId = requireString(params, 'nodeId');
|
|
572
|
+
if (!flow.getNode(nodeId))
|
|
573
|
+
throw new InvalidParamsError(`set_node_group: unknown node id "${nodeId}".`);
|
|
574
|
+
const rawGroup = params['groupId'];
|
|
575
|
+
if (rawGroup !== null && typeof rawGroup !== 'string') {
|
|
576
|
+
throw new InvalidParamsError('Param "groupId" must be a string or null.');
|
|
577
|
+
}
|
|
578
|
+
const groupId = rawGroup;
|
|
579
|
+
if (groupId !== null) {
|
|
580
|
+
if (!flow.getNode(groupId))
|
|
581
|
+
throw new InvalidParamsError(`set_node_group: unknown group id "${groupId}".`);
|
|
582
|
+
if (groupId === nodeId || descendantIdsOf(nodeId, buildChildMap(flow.getNodes())).has(groupId)) {
|
|
583
|
+
throw new InvalidParamsError('set_node_group: groupId would create a cycle (it is the node or a descendant).');
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await flow.setNodeGroup(nodeId, groupId);
|
|
587
|
+
return { nodeId, groupId };
|
|
588
|
+
});
|
|
589
|
+
this.handlers.set('set_group_collapsed', (flow, params) => {
|
|
590
|
+
const groupId = requireString(params, 'groupId');
|
|
591
|
+
if (!flow.getNode(groupId))
|
|
592
|
+
throw new InvalidParamsError(`set_group_collapsed: unknown group id "${groupId}".`);
|
|
593
|
+
const collapsed = params['collapsed'];
|
|
594
|
+
if (typeof collapsed !== 'boolean')
|
|
595
|
+
throw new InvalidParamsError('Param "collapsed" must be a boolean.');
|
|
596
|
+
flow.setNodeCollapsed(groupId, collapsed);
|
|
597
|
+
return { groupId, collapsed };
|
|
598
|
+
});
|
|
599
|
+
this.handlers.set('dissolve_group', async (flow, params) => {
|
|
600
|
+
const groupId = requireString(params, 'groupId');
|
|
601
|
+
if (!flow.getNode(groupId))
|
|
602
|
+
throw new InvalidParamsError(`dissolve_group: unknown group id "${groupId}".`);
|
|
603
|
+
const memberIds = await flow.dissolveGroup(groupId);
|
|
604
|
+
return { dissolvedGroupId: groupId, memberIds };
|
|
605
|
+
});
|
|
606
|
+
this.handlers.set('get_group_bounds', (flow, params) => {
|
|
607
|
+
const groupId = requireString(params, 'groupId');
|
|
608
|
+
if (!flow.getNode(groupId))
|
|
609
|
+
return null;
|
|
610
|
+
return flow.getGroupBox(groupId);
|
|
611
|
+
});
|
|
424
612
|
this.handlers.set('add_edge', (flow, params) => {
|
|
425
613
|
const edge = validateEdgeShape(requireObject(params, 'edge'), 'add_edge');
|
|
426
614
|
flow.addEdges(edge);
|
|
@@ -429,12 +617,14 @@ export class AngflowAgentBridge {
|
|
|
429
617
|
this.handlers.set('update_node', (flow, params) => {
|
|
430
618
|
const id = requireString(params, 'id');
|
|
431
619
|
const patch = requireObject(params, 'patch');
|
|
620
|
+
validateStylePatch(patch, 'update_node/patch', 'node');
|
|
432
621
|
flow.updateNode(id, patch);
|
|
433
622
|
return flow.getNode(id) ?? null;
|
|
434
623
|
});
|
|
435
624
|
this.handlers.set('update_edge', (flow, params) => {
|
|
436
625
|
const id = requireString(params, 'id');
|
|
437
626
|
const patch = requireObject(params, 'patch');
|
|
627
|
+
validateStylePatch(patch, 'update_edge/patch', 'edge');
|
|
438
628
|
flow.updateEdge(id, patch);
|
|
439
629
|
return flow.getEdge(id) ?? null;
|
|
440
630
|
});
|
|
@@ -461,6 +651,7 @@ export class AngflowAgentBridge {
|
|
|
461
651
|
this.handlers.set('fit_view', (flow, params) => {
|
|
462
652
|
const padding = typeof params['padding'] === 'number' ? params['padding'] : undefined;
|
|
463
653
|
const duration = typeof params['duration'] === 'number' ? params['duration'] : undefined;
|
|
654
|
+
const minZoom = optionalPositiveNumber(params, 'minZoom');
|
|
464
655
|
const nodeIds = optionalStringArray(params, 'nodeIds');
|
|
465
656
|
const nodes = nodeIds
|
|
466
657
|
? nodeIds
|
|
@@ -468,7 +659,7 @@ export class AngflowAgentBridge {
|
|
|
468
659
|
.filter((n) => !!n)
|
|
469
660
|
.map((n) => ({ id: n.id }))
|
|
470
661
|
: undefined;
|
|
471
|
-
return flow.fitView({ padding, duration, nodes });
|
|
662
|
+
return flow.fitView({ padding, duration, minZoom, nodes });
|
|
472
663
|
});
|
|
473
664
|
this.handlers.set('set_viewport', (flow, params) => {
|
|
474
665
|
const viewport = requireObject(params, 'viewport');
|
|
@@ -590,7 +781,8 @@ export class AngflowAgentBridge {
|
|
|
590
781
|
const bounds = requireObject(params, 'bounds');
|
|
591
782
|
const padding = typeof params['padding'] === 'number' ? params['padding'] : undefined;
|
|
592
783
|
const duration = typeof params['duration'] === 'number' ? params['duration'] : undefined;
|
|
593
|
-
|
|
784
|
+
const minZoom = optionalPositiveNumber(params, 'minZoom');
|
|
785
|
+
return flow.fitBounds(bounds, { padding, duration, minZoom });
|
|
594
786
|
});
|
|
595
787
|
this.handlers.set('add_nodes', (flow, params) => {
|
|
596
788
|
const nodes = requireArray(params, 'nodes').map((n, i) => validateNodeShape(n, `add_nodes[${i}]`));
|
|
@@ -635,6 +827,23 @@ export class AngflowAgentBridge {
|
|
|
635
827
|
});
|
|
636
828
|
this.handlers.set('apply_changes', (flow, params) => {
|
|
637
829
|
const ops = requireArray(params, 'ops');
|
|
830
|
+
// Cap the AGGREGATE element count across all ops. Each add_nodes/add_edges
|
|
831
|
+
// op is already capped at MAX_BULK_ELEMENTS, but nothing bounded the
|
|
832
|
+
// product — 5000 add_nodes ops of 5000 nodes each would request ~25M
|
|
833
|
+
// inserts in one synchronous batch (main-thread freeze / OOM).
|
|
834
|
+
let requestedElements = 0;
|
|
835
|
+
for (const o of ops) {
|
|
836
|
+
const op = o['op'];
|
|
837
|
+
if (op === 'add_node' || op === 'add_edge')
|
|
838
|
+
requestedElements += 1;
|
|
839
|
+
else if (op === 'add_nodes')
|
|
840
|
+
requestedElements += Array.isArray(o['nodes']) ? o['nodes'].length : 0;
|
|
841
|
+
else if (op === 'add_edges')
|
|
842
|
+
requestedElements += Array.isArray(o['edges']) ? o['edges'].length : 0;
|
|
843
|
+
}
|
|
844
|
+
if (requestedElements > MAX_BULK_ELEMENTS) {
|
|
845
|
+
throw new InvalidParamsError(`apply_changes: the batch requests ${requestedElements} elements, exceeding the maximum of ${MAX_BULK_ELEMENTS} across all ops.`);
|
|
846
|
+
}
|
|
638
847
|
// apply_changes' delete_elements op takes the synchronous setNodes/
|
|
639
848
|
// setEdges path so the whole batch can roll back, which means
|
|
640
849
|
// onBeforeDelete is bypassed (it can't be awaited inside a rollback-
|
|
@@ -743,6 +952,13 @@ export class AngflowAgentBridge {
|
|
|
743
952
|
this.history.clear(flowId);
|
|
744
953
|
this.emitHistory(flowId);
|
|
745
954
|
});
|
|
955
|
+
this.handlers.set('get_changes_since', (flow, params) => {
|
|
956
|
+
const flowId = this.findFlowId(flow);
|
|
957
|
+
if (!this.opLog || !flowId)
|
|
958
|
+
return { ops: [], cursor: 0, truncated: false };
|
|
959
|
+
const since = typeof params['since'] === 'number' ? params['since'] : 0;
|
|
960
|
+
return this.opLog.since(flowId, since);
|
|
961
|
+
});
|
|
746
962
|
this.handlers.set('list_node_types', (flow) => ({ types: flow.getNodeTypeNames() }));
|
|
747
963
|
this.handlers.set('list_edge_types', (flow) => ({ types: flow.getEdgeTypeNames() }));
|
|
748
964
|
this.handlers.set('register_node_template', (flow, params) => {
|
|
@@ -779,6 +995,7 @@ export class AngflowAgentBridge {
|
|
|
779
995
|
}
|
|
780
996
|
const nodeSep = typeof params['nodeSep'] === 'number' ? params['nodeSep'] : undefined;
|
|
781
997
|
const rankSep = typeof params['rankSep'] === 'number' ? params['rankSep'] : undefined;
|
|
998
|
+
const minZoom = optionalPositiveNumber(params, 'minZoom');
|
|
782
999
|
const nodeIds = optionalStringArray(params, 'nodeIds');
|
|
783
1000
|
if (nodeIds) {
|
|
784
1001
|
for (const id of nodeIds) {
|
|
@@ -855,9 +1072,10 @@ export class AngflowAgentBridge {
|
|
|
855
1072
|
// position, keeping grouped children inside their group.
|
|
856
1073
|
await flow.setNodePositions(actuallyApplied, { coordinateSpace: 'absolute' });
|
|
857
1074
|
const shouldFit = params['fitView'] !== false;
|
|
1075
|
+
let fit = null;
|
|
858
1076
|
if (shouldFit && Object.keys(actuallyApplied).length > 0) {
|
|
859
1077
|
try {
|
|
860
|
-
await flow.fitView({});
|
|
1078
|
+
fit = await flow.fitView({ minZoom });
|
|
861
1079
|
}
|
|
862
1080
|
catch (err) {
|
|
863
1081
|
// Best-effort viewport fit: never fail the tool over a cosmetic step,
|
|
@@ -865,10 +1083,10 @@ export class AngflowAgentBridge {
|
|
|
865
1083
|
this.reportError(err, { kind: 'dispatch', method: 'layout_nodes' });
|
|
866
1084
|
}
|
|
867
1085
|
}
|
|
868
|
-
return { positions: actuallyApplied };
|
|
1086
|
+
return { positions: actuallyApplied, fit };
|
|
869
1087
|
});
|
|
870
1088
|
}
|
|
871
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: AngflowAgentBridge, deps: [{ token: AGENT_TRANSPORTS, optional: true }, { token: AGENT_HISTORY_OPTIONS, optional: true }, { token: AGENT_ON_ERROR, optional: true }, { token: AGENT_LAYOUT, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1089
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: AngflowAgentBridge, deps: [{ token: AGENT_TRANSPORTS, optional: true }, { token: AGENT_HISTORY_OPTIONS, optional: true }, { token: AGENT_ON_ERROR, optional: true }, { token: AGENT_LAYOUT, optional: true }, { token: AGENT_CAN_MUTATE, optional: true }, { token: AGENT_ON_OP, optional: true }, { token: AGENT_OPLOG_OPTIONS, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
872
1090
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: AngflowAgentBridge, providedIn: 'root' }); }
|
|
873
1091
|
}
|
|
874
1092
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: AngflowAgentBridge, decorators: [{
|
|
@@ -894,6 +1112,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
|
|
|
894
1112
|
}, {
|
|
895
1113
|
type: Inject,
|
|
896
1114
|
args: [AGENT_LAYOUT]
|
|
1115
|
+
}] }, { type: undefined, decorators: [{
|
|
1116
|
+
type: Optional
|
|
1117
|
+
}, {
|
|
1118
|
+
type: Inject,
|
|
1119
|
+
args: [AGENT_CAN_MUTATE]
|
|
1120
|
+
}] }, { type: undefined, decorators: [{
|
|
1121
|
+
type: Optional
|
|
1122
|
+
}, {
|
|
1123
|
+
type: Inject,
|
|
1124
|
+
args: [AGENT_ON_OP]
|
|
1125
|
+
}] }, { type: undefined, decorators: [{
|
|
1126
|
+
type: Optional
|
|
1127
|
+
}, {
|
|
1128
|
+
type: Inject,
|
|
1129
|
+
args: [AGENT_OPLOG_OPTIONS]
|
|
897
1130
|
}] }] });
|
|
898
1131
|
class FlowNotFoundError extends Error {
|
|
899
1132
|
}
|
|
@@ -902,6 +1135,8 @@ class InvalidParamsError extends Error {
|
|
|
902
1135
|
/** Tool exists in the catalog but the deployment lacks a required capability. Maps to -32601. */
|
|
903
1136
|
class MethodUnavailableError extends Error {
|
|
904
1137
|
}
|
|
1138
|
+
class MutationDeniedError extends Error {
|
|
1139
|
+
}
|
|
905
1140
|
class ApplyChangesError extends Error {
|
|
906
1141
|
constructor(failedIndex, message) {
|
|
907
1142
|
super(message);
|
|
@@ -995,6 +1230,7 @@ function executeOp(flow, op) {
|
|
|
995
1230
|
throw new InvalidParamsError('update_node: "patch" must be an object.');
|
|
996
1231
|
if (!flow.getNode(id))
|
|
997
1232
|
throw new InvalidParamsError(`update_node: node "${id}" not found.`);
|
|
1233
|
+
validateStylePatch(patch, 'apply_changes/update_node', 'node');
|
|
998
1234
|
flow.updateNode(id, patch);
|
|
999
1235
|
return flow.getNode(id) ?? null;
|
|
1000
1236
|
}
|
|
@@ -1019,6 +1255,7 @@ function executeOp(flow, op) {
|
|
|
1019
1255
|
throw new InvalidParamsError('update_edge: "patch" must be an object.');
|
|
1020
1256
|
if (!flow.getEdge(id))
|
|
1021
1257
|
throw new InvalidParamsError(`update_edge: edge "${id}" not found.`);
|
|
1258
|
+
validateStylePatch(patch, 'apply_changes/update_edge', 'edge');
|
|
1022
1259
|
flow.updateEdge(id, patch);
|
|
1023
1260
|
return flow.getEdge(id) ?? null;
|
|
1024
1261
|
}
|
|
@@ -1113,6 +1350,75 @@ function optionalStringArray(params, key) {
|
|
|
1113
1350
|
}
|
|
1114
1351
|
return value;
|
|
1115
1352
|
}
|
|
1353
|
+
function optionalPositiveNumber(params, key) {
|
|
1354
|
+
const value = params[key];
|
|
1355
|
+
if (value == null)
|
|
1356
|
+
return undefined;
|
|
1357
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
1358
|
+
throw new InvalidParamsError(`Param "${key}" must be a finite number greater than 0.`);
|
|
1359
|
+
}
|
|
1360
|
+
return value;
|
|
1361
|
+
}
|
|
1362
|
+
function requireRect(params, key) {
|
|
1363
|
+
const v = requireObject(params, key);
|
|
1364
|
+
for (const k of ['x', 'y', 'width', 'height']) {
|
|
1365
|
+
if (typeof v[k] !== 'number' || !Number.isFinite(v[k])) {
|
|
1366
|
+
throw new InvalidParamsError(`Param "${key}.${k}" must be a finite number.`);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return v;
|
|
1370
|
+
}
|
|
1371
|
+
/** Build a parentId → child-ids map from a node list in one pass. */
|
|
1372
|
+
function buildChildMap(nodes) {
|
|
1373
|
+
const map = new Map();
|
|
1374
|
+
for (const n of nodes) {
|
|
1375
|
+
if (n.parentId != null) {
|
|
1376
|
+
const arr = map.get(n.parentId);
|
|
1377
|
+
if (arr)
|
|
1378
|
+
arr.push(n.id);
|
|
1379
|
+
else
|
|
1380
|
+
map.set(n.parentId, [n.id]);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return map;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Nesting-aware descendant ids of a node (excludes the node itself; cycle-guarded).
|
|
1387
|
+
* Takes a prebuilt child map so callers that resolve many groups (e.g. get_summary)
|
|
1388
|
+
* build it once rather than per group.
|
|
1389
|
+
*/
|
|
1390
|
+
function descendantIdsOf(groupId, childrenByParent) {
|
|
1391
|
+
const out = new Set();
|
|
1392
|
+
// BFS with an index pointer rather than queue.shift() (which is O(n) per
|
|
1393
|
+
// dequeue) — keeps this O(n) for deep hierarchies up to the bulk cap.
|
|
1394
|
+
const queue = [...(childrenByParent.get(groupId) ?? [])];
|
|
1395
|
+
for (let head = 0; head < queue.length; head++) {
|
|
1396
|
+
const id = queue[head];
|
|
1397
|
+
if (out.has(id))
|
|
1398
|
+
continue; // self-parent / cycle guard
|
|
1399
|
+
out.add(id);
|
|
1400
|
+
const kids = childrenByParent.get(id);
|
|
1401
|
+
if (kids)
|
|
1402
|
+
queue.push(...kids);
|
|
1403
|
+
}
|
|
1404
|
+
return out;
|
|
1405
|
+
}
|
|
1406
|
+
/** Edges whose source AND target are both in the id set. */
|
|
1407
|
+
function inducedEdges(edges, nodeIds) {
|
|
1408
|
+
return edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
|
|
1409
|
+
}
|
|
1410
|
+
/** Best-effort display title for a node: data.label/title/name, else type, else id. */
|
|
1411
|
+
function nodeTitle(node) {
|
|
1412
|
+
const data = node.data;
|
|
1413
|
+
for (const key of ['label', 'title', 'name']) {
|
|
1414
|
+
const v = data?.[key];
|
|
1415
|
+
if (typeof v === 'string' && v.length > 0)
|
|
1416
|
+
return v;
|
|
1417
|
+
}
|
|
1418
|
+
if (typeof node.type === 'string' && node.type.length > 0)
|
|
1419
|
+
return node.type;
|
|
1420
|
+
return node.id;
|
|
1421
|
+
}
|
|
1116
1422
|
const BADGE_COLOR_SET = new Set(['slate', 'indigo', 'emerald', 'amber', 'rose']);
|
|
1117
1423
|
const HANDLE_POSITION_SET = new Set(['top', 'right', 'bottom', 'left']);
|
|
1118
1424
|
const KNOWN_SPEC_KEYS = new Set(['title', 'icon', 'accent', 'variant', 'badges', 'fields', 'body', 'handles']);
|
|
@@ -1199,8 +1505,12 @@ function validateTemplateSpec(value, ctx) {
|
|
|
1199
1505
|
* engines (`expression(`). Angular's style sanitization already blocks
|
|
1200
1506
|
* script execution, so this is a narrow redressing/beaconing guard rather
|
|
1201
1507
|
* than a full CSS allowlist.
|
|
1508
|
+
*
|
|
1509
|
+
* The backslash clause defeats CSS unicode-escape obfuscation — e.g. `\75 rl(…)`
|
|
1510
|
+
* decodes to `url(…)` in the browser and would otherwise slip past a plain
|
|
1511
|
+
* substring match. Legitimate inline node/edge styles never need a backslash.
|
|
1202
1512
|
*/
|
|
1203
|
-
const CSS_VALUE_BLOCKLIST = /url\s*\(|expression\s*\(
|
|
1513
|
+
const CSS_VALUE_BLOCKLIST = /url\s*\(|expression\s*\(|\\/i;
|
|
1204
1514
|
/** Shared style/className validation for agent-supplied nodes and edges. */
|
|
1205
1515
|
function validateStyleAndClassName(o, ctx, kind) {
|
|
1206
1516
|
const style = o['style'];
|
|
@@ -1209,11 +1519,17 @@ function validateStyleAndClassName(o, ctx, kind) {
|
|
|
1209
1519
|
throw new InvalidParamsError(`${ctx}: ${kind}.style must be a plain object of CSS property/value pairs.`);
|
|
1210
1520
|
}
|
|
1211
1521
|
for (const [prop, raw] of Object.entries(style)) {
|
|
1522
|
+
// Validate the KEY too: edge styles are rendered via raw `[attr.style]`
|
|
1523
|
+
// string concatenation, so a key like "x: url(...); y" would smuggle a
|
|
1524
|
+
// remote-fetch declaration past a value-only check.
|
|
1525
|
+
if (/[:;\\]|url\s*\(|expression\s*\(/i.test(prop)) {
|
|
1526
|
+
throw new InvalidParamsError(`${ctx}: ${kind}.style property name "${prop}" is invalid (must be a plain CSS property).`);
|
|
1527
|
+
}
|
|
1212
1528
|
if (typeof raw !== 'string' && typeof raw !== 'number') {
|
|
1213
1529
|
throw new InvalidParamsError(`${ctx}: ${kind}.style["${prop}"] must be a string or number.`);
|
|
1214
1530
|
}
|
|
1215
1531
|
if (typeof raw === 'string' && CSS_VALUE_BLOCKLIST.test(raw)) {
|
|
1216
|
-
throw new InvalidParamsError(`${ctx}: ${kind}.style["${prop}"] must not contain "url("
|
|
1532
|
+
throw new InvalidParamsError(`${ctx}: ${kind}.style["${prop}"] must not contain "url(", "expression(", or a "\\" escape.`);
|
|
1217
1533
|
}
|
|
1218
1534
|
}
|
|
1219
1535
|
}
|
|
@@ -1221,6 +1537,20 @@ function validateStyleAndClassName(o, ctx, kind) {
|
|
|
1221
1537
|
throw new InvalidParamsError(`${ctx}: ${kind}.className must be a string.`);
|
|
1222
1538
|
}
|
|
1223
1539
|
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Validate the style/className on a mutation patch (update_node / update_edge /
|
|
1542
|
+
* apply_changes update ops). Unlike validateNodeShape/validateEdgeShape this
|
|
1543
|
+
* runs on partial patches, so agent-supplied styles on the update paths get the
|
|
1544
|
+
* same url()/expression()/key guard as the add paths.
|
|
1545
|
+
*/
|
|
1546
|
+
function validateStylePatch(patch, ctx, kind) {
|
|
1547
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch))
|
|
1548
|
+
return;
|
|
1549
|
+
const p = patch;
|
|
1550
|
+
if ('style' in p || 'className' in p) {
|
|
1551
|
+
validateStyleAndClassName(p, ctx, kind);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1224
1554
|
/** Validate that `value` is a structurally valid Node payload for add_*. */
|
|
1225
1555
|
function validateNodeShape(value, ctx) {
|
|
1226
1556
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|