@angflow/angular 0.3.6 → 0.3.7

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.
@@ -2,7 +2,8 @@ import { InjectionToken } from '@angular/core';
2
2
  import type { NgFlowService } from '../services/ng-flow.service';
3
3
  import type { AgentLayoutFn } from '../types/node-template';
4
4
  import type { AgentHistoryOptions } from './history';
5
- import type { AgentTransport } from './types';
5
+ import type { OpLogEntry, OpLogOptions } from './op-log';
6
+ import type { AgentTransport, AgentCanMutateFn } from './types';
6
7
  import * as i0 from "@angular/core";
7
8
  /** Provider token holding the user-supplied transport(s). */
8
9
  export declare const AGENT_TRANSPORTS: InjectionToken<AgentTransport[]>;
@@ -16,6 +17,12 @@ export declare const AGENT_ON_ERROR: InjectionToken<(err: unknown, ctx: {
16
17
  }) => void>;
17
18
  /** Optional host-provided layout function backing the `layout_nodes` tool. */
18
19
  export declare const AGENT_LAYOUT: InjectionToken<AgentLayoutFn>;
20
+ /** Optional host write-guard for mutating tools. */
21
+ export declare const AGENT_CAN_MUTATE: InjectionToken<AgentCanMutateFn>;
22
+ /** Optional sink receiving each applied mutating op (for host persistence/replay). */
23
+ export declare const AGENT_ON_OP: InjectionToken<(entry: OpLogEntry) => void>;
24
+ /** Op-log config. `false` disables the op-log + get_changes_since. Default { maxOps: 1000 }. */
25
+ export declare const AGENT_OPLOG_OPTIONS: InjectionToken<false | OpLogOptions>;
19
26
  /**
20
27
  * Routes JSON-RPC requests from one or more transports to registered
21
28
  * `NgFlowService` instances, and pushes change events back to the agent.
@@ -46,12 +53,16 @@ export declare class AngflowAgentBridge {
46
53
  private readonly flows;
47
54
  private readonly transports;
48
55
  private readonly history;
56
+ private readonly opLog;
57
+ private readonly onOp;
49
58
  private readonly handlers;
50
59
  private readonly injector;
51
60
  private readonly destroyRef;
52
61
  private readonly layoutFn;
62
+ private readonly canMutate;
53
63
  private started;
54
64
  private nextInProcessId;
65
+ private nextNodeIdSeq;
55
66
  private warnedOnBeforeDeleteBypass;
56
67
  /** Bumped every time a flow registers/unregisters. Useful for diagnostics. */
57
68
  readonly registeredFlows: import("@angular/core").WritableSignal<string[]>;
@@ -60,7 +71,7 @@ export declare class AngflowAgentBridge {
60
71
  kind: 'transport-start' | 'transport-send' | 'dispatch';
61
72
  transport?: AgentTransport;
62
73
  method?: string;
63
- }) => void) | null, layoutFn: AgentLayoutFn | null);
74
+ }) => void) | null, layoutFn: AgentLayoutFn | null, canMutate: AgentCanMutateFn | null, onOp: ((entry: OpLogEntry) => void) | null, opLogOptions: OpLogOptions | false | null);
64
75
  private reportError;
65
76
  /**
66
77
  * Register a flow under `id`. The bridge subscribes to its `nodes`,
@@ -75,23 +86,28 @@ export declare class AngflowAgentBridge {
75
86
  unregister(id: string): void;
76
87
  /** Look up a registered flow. */
77
88
  getFlow(id: string): NgFlowService | undefined;
89
+ /** Mint a unique node/group id for a flow (collision-safe vs agent-supplied ids). */
90
+ private mintId;
78
91
  /**
79
92
  * Invoke a tool directly without going through a transport. Behaves
80
93
  * identically to a JSON-RPC request: captures a history snapshot, emits
81
94
  * `flow.history` / `flow.state` events, and throws a structured error
82
95
  * (with `code` and `data` attached) on failure.
83
96
  */
84
- callTool(method: string, params?: Record<string, unknown>): Promise<unknown>;
97
+ callTool(method: string, params?: Record<string, unknown>, opts?: {
98
+ source?: string;
99
+ }): Promise<unknown>;
85
100
  private start;
86
101
  /** Stop all transports. Invoked automatically when the owning injector is destroyed. */
87
102
  private stop;
88
103
  private dispatch;
89
104
  private findFlowId;
90
105
  private emitHistory;
106
+ private recordOp;
91
107
  private emit;
92
108
  private resolveFlow;
93
109
  private watchFlow;
94
110
  private installHandlers;
95
- static ɵfac: i0.ɵɵFactoryDeclaration<AngflowAgentBridge, [{ optional: true; }, { optional: true; }, { optional: true; }, { optional: true; }]>;
111
+ static ɵfac: i0.ɵɵFactoryDeclaration<AngflowAgentBridge, [{ optional: true; }, { optional: true; }, { optional: true; }, { optional: true; }, { optional: true; }, { optional: true; }, { optional: true; }]>;
96
112
  static ɵprov: i0.ɵɵInjectableDeclaration<AngflowAgentBridge>;
97
113
  }
@@ -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,6 +34,10 @@ 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.
31
43
  /** Max flow.state emission rate while any node drag is in progress. */
@@ -55,7 +67,7 @@ const DRAG_STATE_EMIT_INTERVAL_MS = 100;
55
67
  * ```
56
68
  */
57
69
  export class AngflowAgentBridge {
58
- constructor(transports, historyOptions, onError, layoutFn) {
70
+ constructor(transports, historyOptions, onError, layoutFn, canMutate, onOp, opLogOptions) {
59
71
  /** Schemas for every exposed tool — feed straight to a Claude / OpenAI tools array. */
60
72
  this.toolSchemas = AGENT_TOOL_SCHEMAS;
61
73
  this.flows = new Map();
@@ -64,6 +76,8 @@ export class AngflowAgentBridge {
64
76
  this.destroyRef = inject(DestroyRef);
65
77
  this.started = false;
66
78
  this.nextInProcessId = 1;
79
+ // Shared across flows; mintId's per-flow collision check guarantees uniqueness.
80
+ this.nextNodeIdSeq = 0;
67
81
  this.warnedOnBeforeDeleteBypass = false;
68
82
  /** Bumped every time a flow registers/unregisters. Useful for diagnostics. */
69
83
  this.registeredFlows = signal([], ...(ngDevMode ? [{ debugName: "registeredFlows" }] : /* istanbul ignore next */ []));
@@ -72,6 +86,9 @@ export class AngflowAgentBridge {
72
86
  historyOptions === false ? null : new AgentHistory(historyOptions ?? undefined);
73
87
  this.onError = onError ?? null;
74
88
  this.layoutFn = layoutFn ?? null;
89
+ this.canMutate = canMutate ?? null;
90
+ this.onOp = onOp ?? null;
91
+ this.opLog = opLogOptions === false ? null : new OpLog(opLogOptions ?? undefined);
75
92
  this.installHandlers();
76
93
  this.start();
77
94
  this.destroyRef.onDestroy(() => this.stop());
@@ -108,6 +125,7 @@ export class AngflowAgentBridge {
108
125
  // restoring them into this service would corrupt it).
109
126
  existing.dispose();
110
127
  this.history?.dropFlow(id);
128
+ this.opLog?.dropFlow(id);
111
129
  }
112
130
  const dispose = this.watchFlow(id, flow);
113
131
  this.flows.set(id, { service: flow, dispose });
@@ -122,6 +140,7 @@ export class AngflowAgentBridge {
122
140
  entry.dispose();
123
141
  this.flows.delete(id);
124
142
  this.history?.dropFlow(id);
143
+ this.opLog?.dropFlow(id);
125
144
  this.registeredFlows.set(Array.from(this.flows.keys()));
126
145
  this.emit({ event: 'flow.unregistered', params: { flowId: id } });
127
146
  }
@@ -129,17 +148,26 @@ export class AngflowAgentBridge {
129
148
  getFlow(id) {
130
149
  return this.flows.get(id)?.service;
131
150
  }
151
+ /** Mint a unique node/group id for a flow (collision-safe vs agent-supplied ids). */
152
+ mintId(flow, prefix) {
153
+ let id;
154
+ do {
155
+ id = `${prefix}_${++this.nextNodeIdSeq}`;
156
+ } while (flow.getNode(id));
157
+ return id;
158
+ }
132
159
  /**
133
160
  * Invoke a tool directly without going through a transport. Behaves
134
161
  * identically to a JSON-RPC request: captures a history snapshot, emits
135
162
  * `flow.history` / `flow.state` events, and throws a structured error
136
163
  * (with `code` and `data` attached) on failure.
137
164
  */
138
- async callTool(method, params = {}) {
165
+ async callTool(method, params = {}, opts) {
139
166
  const response = await this.dispatch({
140
167
  id: `in-process:${this.nextInProcessId++}`,
141
168
  method,
142
169
  params,
170
+ source: opts?.source,
143
171
  });
144
172
  if ('error' in response) {
145
173
  const err = new Error(response.error.message);
@@ -199,6 +227,24 @@ export class AngflowAgentBridge {
199
227
  const flowId = this.findFlowId(flow);
200
228
  const isApplyChanges = req.method === 'apply_changes';
201
229
  const isLayout = req.method === 'layout_nodes';
230
+ // The guarded/recorded set: forward graph mutations only. Reads, selection,
231
+ // viewport, and undo/redo/clear_history are intentionally NOT gated — the
232
+ // guard protects against agent writes, not the host's own recovery ops.
233
+ const isMutating = MUTATING_TOOLS.has(req.method) || isApplyChanges || isLayout;
234
+ const source = req.source;
235
+ if (this.canMutate && isMutating) {
236
+ let verdict;
237
+ try {
238
+ verdict = await this.canMutate({ method: req.method, params }, source);
239
+ }
240
+ catch (err) {
241
+ this.reportError(err, { kind: 'dispatch', method: req.method });
242
+ verdict = false; // fail safe
243
+ }
244
+ if (verdict !== true) {
245
+ throw new MutationDeniedError(typeof verdict === 'string' && verdict ? verdict : 'Mutation denied by host.');
246
+ }
247
+ }
202
248
  // Pre-mutation snapshot for history capture. Skipped for non-mutating tools.
203
249
  // Shallow-clone each element so subsequent in-place mutations (notably
204
250
  // the drag fast-path in FlowStore) can't retroactively corrupt the
@@ -211,32 +257,29 @@ export class AngflowAgentBridge {
211
257
  };
212
258
  }
213
259
  const result = await handler(flow, params);
214
- // Commit the captured snapshot to history. For apply_changes, the handler
215
- // either succeeded entirely or threw and was rolled back already.
216
- if (snapshot && flowId && this.history) {
260
+ // Whether this call counts as a recordable mutation (mirrors the history
261
+ // capture conditions). Computed independently of `this.history` so the
262
+ // op-log records even when history is disabled.
263
+ let recorded = false;
264
+ if (isMutating && flowId) {
217
265
  if (isApplyChanges) {
218
266
  const ops = params['ops'] ?? [];
219
- const hasNonSelection = ops.some((o) => o['op'] !== 'select_nodes' &&
220
- o['op'] !== 'select_edges' &&
221
- o['op'] !== 'deselect_all');
222
- if (hasNonSelection) {
223
- this.history.capture(flowId, snapshot);
224
- this.emitHistory(flowId);
225
- }
267
+ recorded = ops.some((o) => o['op'] !== 'select_nodes' && o['op'] !== 'select_edges' && o['op'] !== 'deselect_all');
226
268
  }
227
269
  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
270
  const positions = result?.positions ?? {};
231
- if (Object.keys(positions).length > 0) {
232
- this.history.capture(flowId, snapshot);
233
- this.emitHistory(flowId);
234
- }
271
+ recorded = Object.keys(positions).length > 0;
235
272
  }
236
273
  else {
274
+ recorded = true;
275
+ }
276
+ }
277
+ if (recorded && flowId) {
278
+ if (this.history && snapshot) {
237
279
  this.history.capture(flowId, snapshot);
238
- this.emitHistory(flowId);
280
+ this.emitHistory(flowId, source);
239
281
  }
282
+ this.recordOp(flowId, req.method, params, source);
240
283
  }
241
284
  return { id: req.id, result: result ?? null };
242
285
  }
@@ -260,6 +303,9 @@ export class AngflowAgentBridge {
260
303
  },
261
304
  };
262
305
  }
306
+ if (err instanceof MutationDeniedError) {
307
+ return { id: req.id, error: { code: ERROR_MUTATION_DENIED, message: err.message } };
308
+ }
263
309
  // Anything that lands here is an unexpected throw inside a handler — a
264
310
  // bug or an underlying service failure. Surface it via onError so the
265
311
  // host can log / report it; the wire response is still -32603.
@@ -280,11 +326,26 @@ export class AngflowAgentBridge {
280
326
  }
281
327
  return null;
282
328
  }
283
- emitHistory(flowId) {
329
+ emitHistory(flowId, source) {
284
330
  if (!this.history)
285
331
  return;
286
332
  const status = this.history.status(flowId);
287
- this.emit({ event: 'flow.history', params: { flowId, ...status } });
333
+ this.emit({ event: 'flow.history', params: { flowId, ...status, ...(source ? { source } : {}) } });
334
+ }
335
+ recordOp(flowId, method, params, source) {
336
+ if (!this.opLog)
337
+ return;
338
+ // Shallow-clone params so a later in-place mutation of the caller's object
339
+ // can't corrupt a stored (replay-able) log entry.
340
+ const entry = this.opLog.append(flowId, { method, params: { ...params }, source });
341
+ if (this.onOp) {
342
+ try {
343
+ this.onOp(entry);
344
+ }
345
+ catch (err) {
346
+ this.reportError(err, { kind: 'dispatch', method });
347
+ }
348
+ }
288
349
  }
289
350
  emit(evt) {
290
351
  for (const t of this.transports) {
@@ -401,11 +462,59 @@ export class AngflowAgentBridge {
401
462
  }
402
463
  installHandlers() {
403
464
  this.handlers.set('list_flows', () => Array.from(this.flows.keys()));
404
- this.handlers.set('get_state', (flow) => ({
405
- nodes: flow.getNodes(),
406
- edges: flow.getEdges(),
407
- viewport: flow.getViewport(),
408
- }));
465
+ this.handlers.set('get_state', (flow, params) => {
466
+ const hasGroup = params['groupId'] !== undefined;
467
+ const hasBounds = params['bounds'] !== undefined;
468
+ if (hasGroup && hasBounds) {
469
+ throw new InvalidParamsError('Pass either "groupId" or "bounds", not both.');
470
+ }
471
+ const allNodes = flow.getNodes();
472
+ const allEdges = flow.getEdges();
473
+ let nodes = allNodes;
474
+ let edges = allEdges;
475
+ if (hasGroup) {
476
+ const groupId = requireString(params, 'groupId');
477
+ if (!flow.getNode(groupId)) {
478
+ throw new InvalidParamsError(`get_state: no node with id "${groupId}".`);
479
+ }
480
+ const ids = descendantIdsOf(groupId, buildChildMap(allNodes));
481
+ nodes = allNodes.filter((n) => ids.has(n.id));
482
+ edges = inducedEdges(allEdges, ids);
483
+ }
484
+ else if (hasBounds) {
485
+ const rect = requireRect(params, 'bounds');
486
+ nodes = allNodes.filter((n) => flow.isNodeIntersecting(n, rect, true));
487
+ const idSet = new Set(nodes.map((n) => n.id));
488
+ edges = inducedEdges(allEdges, idSet);
489
+ }
490
+ return {
491
+ nodes,
492
+ edges,
493
+ viewport: flow.getViewport(),
494
+ collapsedHiddenIds: flow.getCollapsedHiddenIds(),
495
+ };
496
+ });
497
+ this.handlers.set('get_summary', (flow) => {
498
+ const nodes = flow.getNodes();
499
+ const edges = flow.getEdges();
500
+ const childMap = buildChildMap(nodes);
501
+ const groups = nodes
502
+ .filter((n) => n.type === 'group')
503
+ .map((g) => ({
504
+ id: g.id,
505
+ label: nodeTitle(g),
506
+ collapsed: g.collapsed === true,
507
+ memberCount: descendantIdsOf(g.id, childMap).size,
508
+ }));
509
+ return {
510
+ counts: { nodes: nodes.length, edges: edges.length, groups: groups.length },
511
+ groups,
512
+ titles: nodes.map((n) => ({ id: n.id, type: n.type ?? 'default', label: nodeTitle(n) })),
513
+ viewport: flow.getViewport(),
514
+ bounds: nodes.length > 0 ? flow.getNodesBounds(nodes) : null,
515
+ collapsedHiddenIds: flow.getCollapsedHiddenIds(),
516
+ };
517
+ });
409
518
  this.handlers.set('get_nodes', (flow) => flow.getNodes());
410
519
  this.handlers.set('get_edges', (flow) => flow.getEdges());
411
520
  this.handlers.set('get_node', (flow, params) => {
@@ -417,10 +526,79 @@ export class AngflowAgentBridge {
417
526
  return flow.getEdge(id) ?? null;
418
527
  });
419
528
  this.handlers.set('add_node', (flow, params) => {
420
- const node = validateNodeShape(requireObject(params, 'node'), 'add_node');
529
+ // Shallow-clone so we never mutate the caller's params object when minting.
530
+ const raw = { ...requireObject(params, 'node') };
531
+ if (raw['id'] == null || raw['id'] === '') {
532
+ raw['id'] = this.mintId(flow, 'node');
533
+ }
534
+ const node = validateNodeShape(raw, 'add_node');
421
535
  flow.addNodes(node);
422
536
  return flow.getNode(node.id) ?? null;
423
537
  });
538
+ this.handlers.set('group_nodes', async (flow, params) => {
539
+ const nodeIds = optionalStringArray(params, 'nodeIds');
540
+ if (!nodeIds || nodeIds.length === 0) {
541
+ throw new InvalidParamsError('Param "nodeIds" must be a non-empty array of strings.');
542
+ }
543
+ for (const id of nodeIds) {
544
+ if (!flow.getNode(id))
545
+ throw new InvalidParamsError(`group_nodes: unknown node id "${id}".`);
546
+ }
547
+ const suppliedGroupId = typeof params['groupId'] === 'string' && params['groupId'] ? params['groupId'] : undefined;
548
+ if (suppliedGroupId && flow.getNode(suppliedGroupId)) {
549
+ throw new InvalidParamsError(`group_nodes: a node with id "${suppliedGroupId}" already exists.`);
550
+ }
551
+ const groupId = suppliedGroupId ?? this.mintId(flow, 'group');
552
+ const label = typeof params['label'] === 'string' ? params['label'] : undefined;
553
+ const collapsed = typeof params['collapsed'] === 'boolean' ? params['collapsed'] : undefined;
554
+ // padding/headerHeight: inline typeof (not optionalPositiveNumber) — 0 is a valid inset.
555
+ const padding = typeof params['padding'] === 'number' ? params['padding'] : undefined;
556
+ const headerHeight = typeof params['headerHeight'] === 'number' ? params['headerHeight'] : undefined;
557
+ await flow.groupNodes(nodeIds, { groupId, label, collapsed, padding, headerHeight });
558
+ return { groupId };
559
+ });
560
+ this.handlers.set('set_node_group', async (flow, params) => {
561
+ const nodeId = requireString(params, 'nodeId');
562
+ if (!flow.getNode(nodeId))
563
+ throw new InvalidParamsError(`set_node_group: unknown node id "${nodeId}".`);
564
+ const rawGroup = params['groupId'];
565
+ if (rawGroup !== null && typeof rawGroup !== 'string') {
566
+ throw new InvalidParamsError('Param "groupId" must be a string or null.');
567
+ }
568
+ const groupId = rawGroup;
569
+ if (groupId !== null) {
570
+ if (!flow.getNode(groupId))
571
+ throw new InvalidParamsError(`set_node_group: unknown group id "${groupId}".`);
572
+ if (groupId === nodeId || descendantIdsOf(nodeId, buildChildMap(flow.getNodes())).has(groupId)) {
573
+ throw new InvalidParamsError('set_node_group: groupId would create a cycle (it is the node or a descendant).');
574
+ }
575
+ }
576
+ await flow.setNodeGroup(nodeId, groupId);
577
+ return { nodeId, groupId };
578
+ });
579
+ this.handlers.set('set_group_collapsed', (flow, params) => {
580
+ const groupId = requireString(params, 'groupId');
581
+ if (!flow.getNode(groupId))
582
+ throw new InvalidParamsError(`set_group_collapsed: unknown group id "${groupId}".`);
583
+ const collapsed = params['collapsed'];
584
+ if (typeof collapsed !== 'boolean')
585
+ throw new InvalidParamsError('Param "collapsed" must be a boolean.');
586
+ flow.setNodeCollapsed(groupId, collapsed);
587
+ return { groupId, collapsed };
588
+ });
589
+ this.handlers.set('dissolve_group', async (flow, params) => {
590
+ const groupId = requireString(params, 'groupId');
591
+ if (!flow.getNode(groupId))
592
+ throw new InvalidParamsError(`dissolve_group: unknown group id "${groupId}".`);
593
+ const memberIds = await flow.dissolveGroup(groupId);
594
+ return { dissolvedGroupId: groupId, memberIds };
595
+ });
596
+ this.handlers.set('get_group_bounds', (flow, params) => {
597
+ const groupId = requireString(params, 'groupId');
598
+ if (!flow.getNode(groupId))
599
+ return null;
600
+ return flow.getGroupBox(groupId);
601
+ });
424
602
  this.handlers.set('add_edge', (flow, params) => {
425
603
  const edge = validateEdgeShape(requireObject(params, 'edge'), 'add_edge');
426
604
  flow.addEdges(edge);
@@ -461,6 +639,7 @@ export class AngflowAgentBridge {
461
639
  this.handlers.set('fit_view', (flow, params) => {
462
640
  const padding = typeof params['padding'] === 'number' ? params['padding'] : undefined;
463
641
  const duration = typeof params['duration'] === 'number' ? params['duration'] : undefined;
642
+ const minZoom = optionalPositiveNumber(params, 'minZoom');
464
643
  const nodeIds = optionalStringArray(params, 'nodeIds');
465
644
  const nodes = nodeIds
466
645
  ? nodeIds
@@ -468,7 +647,7 @@ export class AngflowAgentBridge {
468
647
  .filter((n) => !!n)
469
648
  .map((n) => ({ id: n.id }))
470
649
  : undefined;
471
- return flow.fitView({ padding, duration, nodes });
650
+ return flow.fitView({ padding, duration, minZoom, nodes });
472
651
  });
473
652
  this.handlers.set('set_viewport', (flow, params) => {
474
653
  const viewport = requireObject(params, 'viewport');
@@ -590,7 +769,8 @@ export class AngflowAgentBridge {
590
769
  const bounds = requireObject(params, 'bounds');
591
770
  const padding = typeof params['padding'] === 'number' ? params['padding'] : undefined;
592
771
  const duration = typeof params['duration'] === 'number' ? params['duration'] : undefined;
593
- return flow.fitBounds(bounds, { padding, duration });
772
+ const minZoom = optionalPositiveNumber(params, 'minZoom');
773
+ return flow.fitBounds(bounds, { padding, duration, minZoom });
594
774
  });
595
775
  this.handlers.set('add_nodes', (flow, params) => {
596
776
  const nodes = requireArray(params, 'nodes').map((n, i) => validateNodeShape(n, `add_nodes[${i}]`));
@@ -743,6 +923,13 @@ export class AngflowAgentBridge {
743
923
  this.history.clear(flowId);
744
924
  this.emitHistory(flowId);
745
925
  });
926
+ this.handlers.set('get_changes_since', (flow, params) => {
927
+ const flowId = this.findFlowId(flow);
928
+ if (!this.opLog || !flowId)
929
+ return { ops: [], cursor: 0, truncated: false };
930
+ const since = typeof params['since'] === 'number' ? params['since'] : 0;
931
+ return this.opLog.since(flowId, since);
932
+ });
746
933
  this.handlers.set('list_node_types', (flow) => ({ types: flow.getNodeTypeNames() }));
747
934
  this.handlers.set('list_edge_types', (flow) => ({ types: flow.getEdgeTypeNames() }));
748
935
  this.handlers.set('register_node_template', (flow, params) => {
@@ -779,6 +966,7 @@ export class AngflowAgentBridge {
779
966
  }
780
967
  const nodeSep = typeof params['nodeSep'] === 'number' ? params['nodeSep'] : undefined;
781
968
  const rankSep = typeof params['rankSep'] === 'number' ? params['rankSep'] : undefined;
969
+ const minZoom = optionalPositiveNumber(params, 'minZoom');
782
970
  const nodeIds = optionalStringArray(params, 'nodeIds');
783
971
  if (nodeIds) {
784
972
  for (const id of nodeIds) {
@@ -855,9 +1043,10 @@ export class AngflowAgentBridge {
855
1043
  // position, keeping grouped children inside their group.
856
1044
  await flow.setNodePositions(actuallyApplied, { coordinateSpace: 'absolute' });
857
1045
  const shouldFit = params['fitView'] !== false;
1046
+ let fit = null;
858
1047
  if (shouldFit && Object.keys(actuallyApplied).length > 0) {
859
1048
  try {
860
- await flow.fitView({});
1049
+ fit = await flow.fitView({ minZoom });
861
1050
  }
862
1051
  catch (err) {
863
1052
  // Best-effort viewport fit: never fail the tool over a cosmetic step,
@@ -865,10 +1054,10 @@ export class AngflowAgentBridge {
865
1054
  this.reportError(err, { kind: 'dispatch', method: 'layout_nodes' });
866
1055
  }
867
1056
  }
868
- return { positions: actuallyApplied };
1057
+ return { positions: actuallyApplied, fit };
869
1058
  });
870
1059
  }
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 }); }
1060
+ 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
1061
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: AngflowAgentBridge, providedIn: 'root' }); }
873
1062
  }
874
1063
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImport: i0, type: AngflowAgentBridge, decorators: [{
@@ -894,6 +1083,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.15", ngImpo
894
1083
  }, {
895
1084
  type: Inject,
896
1085
  args: [AGENT_LAYOUT]
1086
+ }] }, { type: undefined, decorators: [{
1087
+ type: Optional
1088
+ }, {
1089
+ type: Inject,
1090
+ args: [AGENT_CAN_MUTATE]
1091
+ }] }, { type: undefined, decorators: [{
1092
+ type: Optional
1093
+ }, {
1094
+ type: Inject,
1095
+ args: [AGENT_ON_OP]
1096
+ }] }, { type: undefined, decorators: [{
1097
+ type: Optional
1098
+ }, {
1099
+ type: Inject,
1100
+ args: [AGENT_OPLOG_OPTIONS]
897
1101
  }] }] });
898
1102
  class FlowNotFoundError extends Error {
899
1103
  }
@@ -902,6 +1106,8 @@ class InvalidParamsError extends Error {
902
1106
  /** Tool exists in the catalog but the deployment lacks a required capability. Maps to -32601. */
903
1107
  class MethodUnavailableError extends Error {
904
1108
  }
1109
+ class MutationDeniedError extends Error {
1110
+ }
905
1111
  class ApplyChangesError extends Error {
906
1112
  constructor(failedIndex, message) {
907
1113
  super(message);
@@ -1113,6 +1319,75 @@ function optionalStringArray(params, key) {
1113
1319
  }
1114
1320
  return value;
1115
1321
  }
1322
+ function optionalPositiveNumber(params, key) {
1323
+ const value = params[key];
1324
+ if (value == null)
1325
+ return undefined;
1326
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
1327
+ throw new InvalidParamsError(`Param "${key}" must be a finite number greater than 0.`);
1328
+ }
1329
+ return value;
1330
+ }
1331
+ function requireRect(params, key) {
1332
+ const v = requireObject(params, key);
1333
+ for (const k of ['x', 'y', 'width', 'height']) {
1334
+ if (typeof v[k] !== 'number' || !Number.isFinite(v[k])) {
1335
+ throw new InvalidParamsError(`Param "${key}.${k}" must be a finite number.`);
1336
+ }
1337
+ }
1338
+ return v;
1339
+ }
1340
+ /** Build a parentId → child-ids map from a node list in one pass. */
1341
+ function buildChildMap(nodes) {
1342
+ const map = new Map();
1343
+ for (const n of nodes) {
1344
+ if (n.parentId != null) {
1345
+ const arr = map.get(n.parentId);
1346
+ if (arr)
1347
+ arr.push(n.id);
1348
+ else
1349
+ map.set(n.parentId, [n.id]);
1350
+ }
1351
+ }
1352
+ return map;
1353
+ }
1354
+ /**
1355
+ * Nesting-aware descendant ids of a node (excludes the node itself; cycle-guarded).
1356
+ * Takes a prebuilt child map so callers that resolve many groups (e.g. get_summary)
1357
+ * build it once rather than per group.
1358
+ */
1359
+ function descendantIdsOf(groupId, childrenByParent) {
1360
+ const out = new Set();
1361
+ // BFS with an index pointer rather than queue.shift() (which is O(n) per
1362
+ // dequeue) — keeps this O(n) for deep hierarchies up to the bulk cap.
1363
+ const queue = [...(childrenByParent.get(groupId) ?? [])];
1364
+ for (let head = 0; head < queue.length; head++) {
1365
+ const id = queue[head];
1366
+ if (out.has(id))
1367
+ continue; // self-parent / cycle guard
1368
+ out.add(id);
1369
+ const kids = childrenByParent.get(id);
1370
+ if (kids)
1371
+ queue.push(...kids);
1372
+ }
1373
+ return out;
1374
+ }
1375
+ /** Edges whose source AND target are both in the id set. */
1376
+ function inducedEdges(edges, nodeIds) {
1377
+ return edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
1378
+ }
1379
+ /** Best-effort display title for a node: data.label/title/name, else type, else id. */
1380
+ function nodeTitle(node) {
1381
+ const data = node.data;
1382
+ for (const key of ['label', 'title', 'name']) {
1383
+ const v = data?.[key];
1384
+ if (typeof v === 'string' && v.length > 0)
1385
+ return v;
1386
+ }
1387
+ if (typeof node.type === 'string' && node.type.length > 0)
1388
+ return node.type;
1389
+ return node.id;
1390
+ }
1116
1391
  const BADGE_COLOR_SET = new Set(['slate', 'indigo', 'emerald', 'amber', 'rose']);
1117
1392
  const HANDLE_POSITION_SET = new Set(['top', 'right', 'bottom', 'left']);
1118
1393
  const KNOWN_SPEC_KEYS = new Set(['title', 'icon', 'accent', 'variant', 'badges', 'fields', 'body', 'handles']);