newo 3.7.0 → 3.7.2

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.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Flow metadata sync — reconciles local flow metadata.yaml with the platform.
3
+ *
4
+ * Closes the gap behind GH issue #3 (push wiping flow event subscriptions and
5
+ * title): before this module existed, push only updated skill scripts. Local
6
+ * edits to flow title, events, or state_fields silently never reached the
7
+ * platform; new events created via `newo create-event` had no path to flow
8
+ * therefore disappeared from local metadata.yaml after a subsequent pull.
9
+ *
10
+ * Reconciliation strategy per flow (only runs when metadata.yaml hash changed):
11
+ * - Compare local FlowMetadata against fresh GET responses from the platform
12
+ * - Update flow title/description/runner via PATCH /api/v1/designer/flows/{id}
13
+ * - For each child collection (events, state_fields):
14
+ * • idn present locally + missing remotely → create
15
+ * • idn present in both, contents differ → update
16
+ * • idn missing locally + present remotely → delete
17
+ *
18
+ * Hash-gating is critical: if the user never touched metadata.yaml, we never
19
+ * compute a remote diff, which means a stale or partially-pulled tree cannot
20
+ * accidentally wipe events that were created out-of-band via the Builder UI.
21
+ */
22
+ import type { AxiosInstance } from 'axios';
23
+ import {
24
+ listFlowEvents,
25
+ listFlowStates,
26
+ createFlowEvent,
27
+ updateFlowEvent,
28
+ deleteFlowEvent,
29
+ createFlowState,
30
+ updateFlowState,
31
+ deleteFlowState,
32
+ updateFlow
33
+ } from '../api.js';
34
+ import type {
35
+ FlowMetadata,
36
+ FlowEvent,
37
+ FlowState,
38
+ CreateFlowEventRequest,
39
+ UpdateFlowEventRequest,
40
+ CreateFlowStateRequest,
41
+ UpdateFlowStateRequest,
42
+ UpdateFlowRequest
43
+ } from '../types.js';
44
+
45
+ export interface FlowMetadataSyncCounts {
46
+ flowsUpdated: number;
47
+ eventsCreated: number;
48
+ eventsUpdated: number;
49
+ eventsDeleted: number;
50
+ statesCreated: number;
51
+ statesUpdated: number;
52
+ statesDeleted: number;
53
+ errors: string[];
54
+ }
55
+
56
+ export function emptyFlowSyncCounts(): FlowMetadataSyncCounts {
57
+ return {
58
+ flowsUpdated: 0,
59
+ eventsCreated: 0,
60
+ eventsUpdated: 0,
61
+ eventsDeleted: 0,
62
+ statesCreated: 0,
63
+ statesUpdated: 0,
64
+ statesDeleted: 0,
65
+ errors: []
66
+ };
67
+ }
68
+
69
+ /**
70
+ * True when remote FlowEvent fields differ from what the local metadata says.
71
+ * We only compare semantic fields the platform stores - `id` is platform-owned.
72
+ */
73
+ export function flowEventDiffers(local: FlowEvent, remote: FlowEvent): boolean {
74
+ return (
75
+ normalizeStr(local.description) !== normalizeStr(remote.description) ||
76
+ normalizeStr(local.skill_selector) !== normalizeStr(remote.skill_selector) ||
77
+ normalizeStr(local.skill_idn) !== normalizeStr(remote.skill_idn) ||
78
+ normalizeStr(local.state_idn) !== normalizeStr(remote.state_idn) ||
79
+ normalizeStr(local.interrupt_mode) !== normalizeStr(remote.interrupt_mode) ||
80
+ normalizeStr(local.integration_idn) !== normalizeStr(remote.integration_idn) ||
81
+ normalizeStr(local.connector_idn) !== normalizeStr(remote.connector_idn)
82
+ );
83
+ }
84
+
85
+ export function flowStateDiffers(local: FlowState, remote: FlowState): boolean {
86
+ return (
87
+ normalizeStr(local.title) !== normalizeStr(remote.title) ||
88
+ normalizeStr(local.default_value) !== normalizeStr(remote.default_value) ||
89
+ normalizeStr(local.scope) !== normalizeStr(remote.scope)
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Normalizes nullable/undefined string fields so YAML round-trips don't
95
+ * register as differences. `null`, `undefined`, and missing all collapse to ''.
96
+ */
97
+ function normalizeStr(value: unknown): string {
98
+ if (value === null || value === undefined) return '';
99
+ return String(value);
100
+ }
101
+
102
+ function buildEventCreateRequest(local: FlowEvent): CreateFlowEventRequest {
103
+ return {
104
+ idn: local.idn,
105
+ description: local.description ?? '',
106
+ skill_selector: local.skill_selector,
107
+ ...(local.skill_idn != null ? { skill_idn: local.skill_idn } : {}),
108
+ state_idn: local.state_idn ?? null,
109
+ interrupt_mode: local.interrupt_mode,
110
+ integration_idn: local.integration_idn ?? '',
111
+ connector_idn: local.connector_idn ?? ''
112
+ };
113
+ }
114
+
115
+ function buildEventUpdateRequest(local: FlowEvent): UpdateFlowEventRequest {
116
+ return {
117
+ idn: local.idn,
118
+ description: local.description ?? '',
119
+ skill_selector: local.skill_selector,
120
+ skill_idn: local.skill_idn ?? null,
121
+ state_idn: local.state_idn ?? null,
122
+ interrupt_mode: local.interrupt_mode,
123
+ integration_idn: local.integration_idn ?? null,
124
+ connector_idn: local.connector_idn ?? null
125
+ };
126
+ }
127
+
128
+ function buildStateCreateRequest(local: FlowState): CreateFlowStateRequest {
129
+ const req: CreateFlowStateRequest = {
130
+ title: local.title || local.idn,
131
+ idn: local.idn,
132
+ scope: local.scope
133
+ };
134
+ if (local.default_value != null) {
135
+ req.default_value = local.default_value;
136
+ }
137
+ return req;
138
+ }
139
+
140
+ function buildStateUpdateRequest(local: FlowState): UpdateFlowStateRequest {
141
+ const req: UpdateFlowStateRequest = {
142
+ title: local.title || local.idn,
143
+ idn: local.idn,
144
+ scope: local.scope
145
+ };
146
+ if (local.default_value != null) {
147
+ req.default_value = local.default_value;
148
+ }
149
+ return req;
150
+ }
151
+
152
+ function shouldUpdateFlow(local: FlowMetadata, remote: { title: string; description: string | null; default_runner_type?: string }): boolean {
153
+ return (
154
+ normalizeStr(local.title) !== normalizeStr(remote.title) ||
155
+ normalizeStr(local.description) !== normalizeStr(remote.description) ||
156
+ normalizeStr(local.default_runner_type) !== normalizeStr(remote.default_runner_type)
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Reconcile one flow's metadata with the platform.
162
+ *
163
+ * @param client authenticated Axios client
164
+ * @param flowId platform flow ID (UUID)
165
+ * @param local parsed FlowMetadata from the customer's local YAML
166
+ * @param remoteFlow flow data fetched from GET /flows/{id} - if null,
167
+ * flow-level updates are skipped (still syncs children).
168
+ * Pass null when caller already knows the GET endpoint
169
+ * will 404 (e.g. legacy data) or wants children-only.
170
+ * @param verbose when true, emits per-operation log lines
171
+ * @param counts shared counter mutated in place across multiple flows
172
+ */
173
+ export async function syncFlowMetadata(
174
+ client: AxiosInstance,
175
+ flowId: string,
176
+ local: FlowMetadata,
177
+ remoteFlow: { title: string; description: string | null; default_runner_type?: string } | null,
178
+ verbose: boolean,
179
+ counts: FlowMetadataSyncCounts
180
+ ): Promise<void> {
181
+ // 1. Flow-level fields (title, description, runner type)
182
+ if (remoteFlow && shouldUpdateFlow(local, remoteFlow)) {
183
+ try {
184
+ const updateRequest: UpdateFlowRequest = {
185
+ idn: local.idn,
186
+ title: local.title,
187
+ description: local.description ?? '',
188
+ default_runner_type: local.default_runner_type,
189
+ default_model: local.default_model
190
+ };
191
+ await updateFlow(client, flowId, updateRequest);
192
+ counts.flowsUpdated++;
193
+ if (verbose) {
194
+ console.log(` ↑ Updated flow metadata: ${local.idn} (title: "${remoteFlow.title}" → "${local.title}")`);
195
+ }
196
+ } catch (error: unknown) {
197
+ const msg = error instanceof Error ? error.message : String(error);
198
+ counts.errors.push(`Failed to update flow ${local.idn}: ${msg}`);
199
+ console.error(` ❌ Failed to update flow ${local.idn}: ${msg}`);
200
+ }
201
+ }
202
+
203
+ // 2. Events
204
+ let remoteEvents: FlowEvent[] = [];
205
+ try {
206
+ remoteEvents = await listFlowEvents(client, flowId);
207
+ } catch (error: unknown) {
208
+ const msg = error instanceof Error ? error.message : String(error);
209
+ counts.errors.push(`Failed to list events for flow ${local.idn}: ${msg}`);
210
+ return;
211
+ }
212
+
213
+ const localEvents = local.events ?? [];
214
+ const remoteByIdn = new Map(remoteEvents.map(e => [e.idn, e]));
215
+ const localByIdn = new Map(localEvents.map(e => [e.idn, e]));
216
+
217
+ // Create or update events present locally
218
+ for (const localEvent of localEvents) {
219
+ const remote = remoteByIdn.get(localEvent.idn);
220
+ if (!remote) {
221
+ // Create
222
+ try {
223
+ await createFlowEvent(client, flowId, buildEventCreateRequest(localEvent));
224
+ counts.eventsCreated++;
225
+ if (verbose) console.log(` ↑ Created event: ${local.idn}/${localEvent.idn}`);
226
+ } catch (error: unknown) {
227
+ const msg = error instanceof Error ? error.message : String(error);
228
+ counts.errors.push(`Failed to create event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
229
+ console.error(` ❌ Failed to create event ${localEvent.idn}: ${msg}`);
230
+ }
231
+ } else if (flowEventDiffers(localEvent, remote)) {
232
+ try {
233
+ await updateFlowEvent(client, remote.id, buildEventUpdateRequest(localEvent));
234
+ counts.eventsUpdated++;
235
+ if (verbose) console.log(` ↑ Updated event: ${local.idn}/${localEvent.idn}`);
236
+ } catch (error: unknown) {
237
+ const msg = error instanceof Error ? error.message : String(error);
238
+ counts.errors.push(`Failed to update event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
239
+ console.error(` ❌ Failed to update event ${localEvent.idn}: ${msg}`);
240
+ }
241
+ }
242
+ }
243
+
244
+ // Delete events present remotely but missing locally
245
+ for (const remoteEvent of remoteEvents) {
246
+ if (!localByIdn.has(remoteEvent.idn)) {
247
+ try {
248
+ await deleteFlowEvent(client, remoteEvent.id);
249
+ counts.eventsDeleted++;
250
+ if (verbose) console.log(` ↑ Deleted event: ${local.idn}/${remoteEvent.idn}`);
251
+ } catch (error: unknown) {
252
+ const msg = error instanceof Error ? error.message : String(error);
253
+ counts.errors.push(`Failed to delete event ${remoteEvent.idn} in flow ${local.idn}: ${msg}`);
254
+ console.error(` ❌ Failed to delete event ${remoteEvent.idn}: ${msg}`);
255
+ }
256
+ }
257
+ }
258
+
259
+ // 3. State fields
260
+ let remoteStates: FlowState[] = [];
261
+ try {
262
+ remoteStates = await listFlowStates(client, flowId);
263
+ } catch (error: unknown) {
264
+ const msg = error instanceof Error ? error.message : String(error);
265
+ counts.errors.push(`Failed to list states for flow ${local.idn}: ${msg}`);
266
+ return;
267
+ }
268
+
269
+ const localStates = local.state_fields ?? [];
270
+ const remoteStatesByIdn = new Map(remoteStates.map(s => [s.idn, s]));
271
+ const localStatesByIdn = new Map(localStates.map(s => [s.idn, s]));
272
+
273
+ for (const localState of localStates) {
274
+ const remote = remoteStatesByIdn.get(localState.idn);
275
+ if (!remote) {
276
+ try {
277
+ await createFlowState(client, flowId, buildStateCreateRequest(localState));
278
+ counts.statesCreated++;
279
+ if (verbose) console.log(` ↑ Created state: ${local.idn}/${localState.idn}`);
280
+ } catch (error: unknown) {
281
+ const msg = error instanceof Error ? error.message : String(error);
282
+ counts.errors.push(`Failed to create state ${localState.idn} in flow ${local.idn}: ${msg}`);
283
+ console.error(` ❌ Failed to create state ${localState.idn}: ${msg}`);
284
+ }
285
+ } else if (flowStateDiffers(localState, remote)) {
286
+ try {
287
+ await updateFlowState(client, remote.id, buildStateUpdateRequest(localState));
288
+ counts.statesUpdated++;
289
+ if (verbose) console.log(` ↑ Updated state: ${local.idn}/${localState.idn}`);
290
+ } catch (error: unknown) {
291
+ const msg = error instanceof Error ? error.message : String(error);
292
+ counts.errors.push(`Failed to update state ${localState.idn} in flow ${local.idn}: ${msg}`);
293
+ console.error(` ❌ Failed to update state ${localState.idn}: ${msg}`);
294
+ }
295
+ }
296
+ }
297
+
298
+ for (const remoteState of remoteStates) {
299
+ if (!localStatesByIdn.has(remoteState.idn)) {
300
+ try {
301
+ await deleteFlowState(client, remoteState.id);
302
+ counts.statesDeleted++;
303
+ if (verbose) console.log(` ↑ Deleted state: ${local.idn}/${remoteState.idn}`);
304
+ } catch (error: unknown) {
305
+ const msg = error instanceof Error ? error.message : String(error);
306
+ counts.errors.push(`Failed to delete state ${remoteState.idn} in flow ${local.idn}: ${msg}`);
307
+ console.error(` ❌ Failed to delete state ${remoteState.idn}: ${msg}`);
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Combined count of operations across all categories.
315
+ */
316
+ export function totalFlowSyncOps(counts: FlowMetadataSyncCounts): number {
317
+ return (
318
+ counts.flowsUpdated +
319
+ counts.eventsCreated + counts.eventsUpdated + counts.eventsDeleted +
320
+ counts.statesCreated + counts.statesUpdated + counts.statesDeleted
321
+ );
322
+ }
323
+
324
+ /**
325
+ * Human-readable summary line for the push report.
326
+ */
327
+ export function describeFlowSyncCounts(counts: FlowMetadataSyncCounts): string {
328
+ const parts: string[] = [];
329
+ if (counts.flowsUpdated) parts.push(`${counts.flowsUpdated} flow(s)`);
330
+ if (counts.eventsCreated || counts.eventsUpdated || counts.eventsDeleted) {
331
+ const eventOps: string[] = [];
332
+ if (counts.eventsCreated) eventOps.push(`+${counts.eventsCreated}`);
333
+ if (counts.eventsUpdated) eventOps.push(`~${counts.eventsUpdated}`);
334
+ if (counts.eventsDeleted) eventOps.push(`-${counts.eventsDeleted}`);
335
+ parts.push(`events ${eventOps.join('/')}`);
336
+ }
337
+ if (counts.statesCreated || counts.statesUpdated || counts.statesDeleted) {
338
+ const stateOps: string[] = [];
339
+ if (counts.statesCreated) stateOps.push(`+${counts.statesCreated}`);
340
+ if (counts.statesUpdated) stateOps.push(`~${counts.statesUpdated}`);
341
+ if (counts.statesDeleted) stateOps.push(`-${counts.statesDeleted}`);
342
+ parts.push(`states ${stateOps.join('/')}`);
343
+ }
344
+ return parts.join(', ');
345
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * JSON-typed attribute helpers.
3
+ *
4
+ * Why this exists:
5
+ *
6
+ * The NEWO platform stores some attributes (e.g.
7
+ * `project_attributes_private_dynamic_workflow_builder_canvas`) as
8
+ * `value_type: json`. The API may return the `value` field as either a
9
+ * STRING containing JSON or as an already-parsed OBJECT.
10
+ *
11
+ * Without normalization, two bugs leak through:
12
+ *
13
+ * 1. When the API returns the value as an OBJECT, `yaml.dump` serializes
14
+ * it as a YAML structure (mappings/sequences). Pushing back then sends
15
+ * `{"value": {...}}` instead of `{"value": "..."}`, breaking the
16
+ * Workflow Builder which expects the canvas as a JSON STRING.
17
+ *
18
+ * 2. The push-time change check used `String(localAttr.value)` for
19
+ * comparison. With objects this collapses to `"[object Object]"` on
20
+ * both sides — silently masking real changes — and with mismatched
21
+ * string vs object representations it triggers spurious pushes that
22
+ * overwrite the canvas with the wrong shape (Builder shows blank).
23
+ *
24
+ * The fix is conservative: for `value_type: json` only, always coerce the
25
+ * value to a STRING when persisting and when pushing, and use canonical
26
+ * JSON for comparisons. String-typed values in the wild are left
27
+ * untouched, so no churn for the majority of attributes.
28
+ */
29
+
30
+ /**
31
+ * True if the attribute is a JSON-typed attribute (case- and
32
+ * format-insensitive: handles `json`, `JSON`, `AttributeValueTypes.json`,
33
+ * `ValueType.JSON`, etc.).
34
+ */
35
+ export function isJsonValueType(valueType: unknown): boolean {
36
+ if (typeof valueType !== 'string') return false;
37
+ const lower = valueType.toLowerCase();
38
+ return lower === 'json' || lower.endsWith('.json');
39
+ }
40
+
41
+ /**
42
+ * Coerce a JSON-typed attribute's value to a STRING suitable for storage
43
+ * in attributes.yaml and for sending to the platform.
44
+ *
45
+ * - `null` / `undefined` → `''`
46
+ * - object → compact JSON string (`JSON.stringify(value)`)
47
+ * - string → returned as-is (we trust the platform's existing format)
48
+ * - other → `String(value)`
49
+ *
50
+ * We deliberately do NOT re-format string values, even when they look
51
+ * like JSON. Many existing canvases are stored pretty-printed and
52
+ * reformatting would create huge spurious diffs in users' repos.
53
+ */
54
+ export function normalizeJsonValueForStorage(value: unknown): string {
55
+ if (value == null) return '';
56
+ if (typeof value === 'string') return value;
57
+ if (typeof value === 'object') {
58
+ try {
59
+ return JSON.stringify(value);
60
+ } catch {
61
+ return String(value);
62
+ }
63
+ }
64
+ return String(value);
65
+ }
66
+
67
+ /**
68
+ * Canonical comparison for JSON-typed attribute values.
69
+ *
70
+ * Returns the canonical form (compact JSON if parseable, otherwise the
71
+ * raw string). Use this on both sides of a comparison so that pretty- vs
72
+ * compact-printed JSON does not register as a change, and so that an
73
+ * object on one side equals its stringified form on the other side.
74
+ */
75
+ export function canonicalJsonValue(value: unknown): string {
76
+ const stringified = normalizeJsonValueForStorage(value);
77
+ if (stringified === '') return '';
78
+ try {
79
+ return JSON.stringify(JSON.parse(stringified));
80
+ } catch {
81
+ return stringified;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * True if two JSON-typed attribute values are semantically equal.
87
+ *
88
+ * Handles the four mismatched representations that can occur during a
89
+ * pull/push cycle:
90
+ * string vs string (different whitespace/indent), object vs string,
91
+ * string vs object, object vs object.
92
+ */
93
+ export function jsonValuesEqual(a: unknown, b: unknown): boolean {
94
+ return canonicalJsonValue(a) === canonicalJsonValue(b);
95
+ }
package/src/sync/push.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Push operations for changed files
3
3
  */
4
- import { updateSkill, createAgent, createFlow, createSkill, publishFlow } from '../api.js';
4
+ import { updateSkill, createAgent, createFlow, createSkill, publishFlow, getFlow } from '../api.js';
5
5
  import {
6
6
  ensureState,
7
7
  mapPath,
8
8
  skillMetadataPath,
9
9
  projectDir,
10
- agentMetadataPath
10
+ agentMetadataPath,
11
+ flowMetadataPath
11
12
  } from '../fsutil.js';
12
13
  import {
13
14
  validateSkillFolder,
@@ -21,6 +22,12 @@ import { generateFlowsYaml } from './metadata.js';
21
22
  import { isProjectMap, isLegacyProjectMap } from './projects.js';
22
23
  import { flowsYamlPath } from '../fsutil.js';
23
24
  import { pushAllProjectAttributes } from './attributes.js';
25
+ import {
26
+ syncFlowMetadata,
27
+ emptyFlowSyncCounts,
28
+ totalFlowSyncOps,
29
+ describeFlowSyncCounts
30
+ } from './flow-metadata.js';
24
31
  import type { AxiosInstance } from 'axios';
25
32
  import type {
26
33
  ProjectData,
@@ -506,6 +513,72 @@ export async function pushChanged(client: AxiosInstance, customer: CustomerConfi
506
513
  }
507
514
  }
508
515
 
516
+ // Sync flow metadata (title, events, state_fields) for any flow whose
517
+ // metadata.yaml hash has changed. This closes the loop on GH issue #3:
518
+ // previously push only updated skill scripts, leaving local edits to
519
+ // flow events and title silently un-synced.
520
+ const flowSyncCounts = emptyFlowSyncCounts();
521
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
522
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
523
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
524
+ if (!flowObj.id) continue;
525
+
526
+ const metaPath = flowMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn);
527
+ if (!(await fs.pathExists(metaPath))) continue;
528
+
529
+ const metaContent = await fs.readFile(metaPath, 'utf8');
530
+ const metaHash = sha256(metaContent);
531
+ const oldHash = hashes[metaPath];
532
+
533
+ if (oldHash === metaHash) {
534
+ if (verbose) console.log(` ✓ Flow metadata unchanged: ${flowIdn}`);
535
+ continue;
536
+ }
537
+
538
+ if (verbose) console.log(` 🔄 Flow metadata changed, syncing: ${agentIdn}/${flowIdn}`);
539
+
540
+ let localFlow: FlowMetadata;
541
+ try {
542
+ localFlow = yaml.load(metaContent) as FlowMetadata;
543
+ } catch (error) {
544
+ console.error(`❌ Failed to parse flow metadata for ${flowIdn}: ${error instanceof Error ? error.message : String(error)}`);
545
+ continue;
546
+ }
547
+
548
+ let remoteFlow = null;
549
+ try {
550
+ remoteFlow = await getFlow(client, flowObj.id);
551
+ } catch (error: any) {
552
+ // 404 means the flow ID is stale; skip flow-level update but still
553
+ // try to sync children since list endpoints may still work.
554
+ if (verbose) {
555
+ console.log(` ⚠️ Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
556
+ }
557
+ }
558
+
559
+ const opsBefore = totalFlowSyncOps(flowSyncCounts);
560
+ await syncFlowMetadata(client, flowObj.id, localFlow, remoteFlow, verbose, flowSyncCounts);
561
+ const opsAfter = totalFlowSyncOps(flowSyncCounts);
562
+
563
+ if (opsAfter > opsBefore) {
564
+ pushed += (opsAfter - opsBefore);
565
+ metadataChanged = true;
566
+ }
567
+ // Hash is updated regardless of whether ops happened, so we don't
568
+ // re-scan the same untouched flow on the next push.
569
+ newHashes[metaPath] = metaHash;
570
+ }
571
+ }
572
+ }
573
+
574
+ const totalFlowOps = totalFlowSyncOps(flowSyncCounts);
575
+ if (totalFlowOps > 0) {
576
+ console.log(`↑ Flow metadata synced: ${describeFlowSyncCounts(flowSyncCounts)}`);
577
+ }
578
+ if (flowSyncCounts.errors.length > 0) {
579
+ console.warn(`⚠️ ${flowSyncCounts.errors.length} flow-metadata error(s) during push.`);
580
+ }
581
+
509
582
  if (verbose) console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
510
583
 
511
584
  // Push project attributes for all projects
package/src/types.ts CHANGED
@@ -521,6 +521,23 @@ export interface CreateFlowEventResponse {
521
521
  id: string;
522
522
  }
523
523
 
524
+ /**
525
+ * Payload for PATCH /api/v1/designer/flows/events/{eventId}
526
+ *
527
+ * Required fields per probe testing: idn, skill_selector, interrupt_mode.
528
+ * Sending the full event body is the platform's expected shape.
529
+ */
530
+ export interface UpdateFlowEventRequest {
531
+ idn: string;
532
+ description?: string | null;
533
+ skill_selector: string;
534
+ skill_idn?: string | null;
535
+ state_idn?: string | null;
536
+ interrupt_mode: string;
537
+ integration_idn?: string | null;
538
+ connector_idn?: string | null;
539
+ }
540
+
524
541
  export interface CreateFlowStateRequest {
525
542
  title: string;
526
543
  idn: string;
@@ -532,6 +549,29 @@ export interface CreateFlowStateResponse {
532
549
  id: string;
533
550
  }
534
551
 
552
+ /**
553
+ * Payload for PUT /api/v1/designer/flows/states/{stateId}
554
+ */
555
+ export interface UpdateFlowStateRequest {
556
+ title: string;
557
+ idn: string;
558
+ default_value?: string;
559
+ scope: string;
560
+ }
561
+
562
+ /**
563
+ * Payload for PATCH /api/v1/designer/flows/{flowId}
564
+ *
565
+ * Empty body returns 500; the platform requires the full descriptor.
566
+ */
567
+ export interface UpdateFlowRequest {
568
+ idn: string;
569
+ title: string;
570
+ description?: string;
571
+ default_runner_type: RunnerType;
572
+ default_model: ModelConfig;
573
+ }
574
+
535
575
  export interface CreateSkillParameterRequest {
536
576
  name: string;
537
577
  default_value?: string;