newo 3.7.1 → 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,283 @@
1
+ import { listFlowEvents, listFlowStates, createFlowEvent, updateFlowEvent, deleteFlowEvent, createFlowState, updateFlowState, deleteFlowState, updateFlow } from '../api.js';
2
+ export function emptyFlowSyncCounts() {
3
+ return {
4
+ flowsUpdated: 0,
5
+ eventsCreated: 0,
6
+ eventsUpdated: 0,
7
+ eventsDeleted: 0,
8
+ statesCreated: 0,
9
+ statesUpdated: 0,
10
+ statesDeleted: 0,
11
+ errors: []
12
+ };
13
+ }
14
+ /**
15
+ * True when remote FlowEvent fields differ from what the local metadata says.
16
+ * We only compare semantic fields the platform stores - `id` is platform-owned.
17
+ */
18
+ export function flowEventDiffers(local, remote) {
19
+ return (normalizeStr(local.description) !== normalizeStr(remote.description) ||
20
+ normalizeStr(local.skill_selector) !== normalizeStr(remote.skill_selector) ||
21
+ normalizeStr(local.skill_idn) !== normalizeStr(remote.skill_idn) ||
22
+ normalizeStr(local.state_idn) !== normalizeStr(remote.state_idn) ||
23
+ normalizeStr(local.interrupt_mode) !== normalizeStr(remote.interrupt_mode) ||
24
+ normalizeStr(local.integration_idn) !== normalizeStr(remote.integration_idn) ||
25
+ normalizeStr(local.connector_idn) !== normalizeStr(remote.connector_idn));
26
+ }
27
+ export function flowStateDiffers(local, remote) {
28
+ return (normalizeStr(local.title) !== normalizeStr(remote.title) ||
29
+ normalizeStr(local.default_value) !== normalizeStr(remote.default_value) ||
30
+ normalizeStr(local.scope) !== normalizeStr(remote.scope));
31
+ }
32
+ /**
33
+ * Normalizes nullable/undefined string fields so YAML round-trips don't
34
+ * register as differences. `null`, `undefined`, and missing all collapse to ''.
35
+ */
36
+ function normalizeStr(value) {
37
+ if (value === null || value === undefined)
38
+ return '';
39
+ return String(value);
40
+ }
41
+ function buildEventCreateRequest(local) {
42
+ return {
43
+ idn: local.idn,
44
+ description: local.description ?? '',
45
+ skill_selector: local.skill_selector,
46
+ ...(local.skill_idn != null ? { skill_idn: local.skill_idn } : {}),
47
+ state_idn: local.state_idn ?? null,
48
+ interrupt_mode: local.interrupt_mode,
49
+ integration_idn: local.integration_idn ?? '',
50
+ connector_idn: local.connector_idn ?? ''
51
+ };
52
+ }
53
+ function buildEventUpdateRequest(local) {
54
+ return {
55
+ idn: local.idn,
56
+ description: local.description ?? '',
57
+ skill_selector: local.skill_selector,
58
+ skill_idn: local.skill_idn ?? null,
59
+ state_idn: local.state_idn ?? null,
60
+ interrupt_mode: local.interrupt_mode,
61
+ integration_idn: local.integration_idn ?? null,
62
+ connector_idn: local.connector_idn ?? null
63
+ };
64
+ }
65
+ function buildStateCreateRequest(local) {
66
+ const req = {
67
+ title: local.title || local.idn,
68
+ idn: local.idn,
69
+ scope: local.scope
70
+ };
71
+ if (local.default_value != null) {
72
+ req.default_value = local.default_value;
73
+ }
74
+ return req;
75
+ }
76
+ function buildStateUpdateRequest(local) {
77
+ const req = {
78
+ title: local.title || local.idn,
79
+ idn: local.idn,
80
+ scope: local.scope
81
+ };
82
+ if (local.default_value != null) {
83
+ req.default_value = local.default_value;
84
+ }
85
+ return req;
86
+ }
87
+ function shouldUpdateFlow(local, remote) {
88
+ return (normalizeStr(local.title) !== normalizeStr(remote.title) ||
89
+ normalizeStr(local.description) !== normalizeStr(remote.description) ||
90
+ normalizeStr(local.default_runner_type) !== normalizeStr(remote.default_runner_type));
91
+ }
92
+ /**
93
+ * Reconcile one flow's metadata with the platform.
94
+ *
95
+ * @param client authenticated Axios client
96
+ * @param flowId platform flow ID (UUID)
97
+ * @param local parsed FlowMetadata from the customer's local YAML
98
+ * @param remoteFlow flow data fetched from GET /flows/{id} - if null,
99
+ * flow-level updates are skipped (still syncs children).
100
+ * Pass null when caller already knows the GET endpoint
101
+ * will 404 (e.g. legacy data) or wants children-only.
102
+ * @param verbose when true, emits per-operation log lines
103
+ * @param counts shared counter mutated in place across multiple flows
104
+ */
105
+ export async function syncFlowMetadata(client, flowId, local, remoteFlow, verbose, counts) {
106
+ // 1. Flow-level fields (title, description, runner type)
107
+ if (remoteFlow && shouldUpdateFlow(local, remoteFlow)) {
108
+ try {
109
+ const updateRequest = {
110
+ idn: local.idn,
111
+ title: local.title,
112
+ description: local.description ?? '',
113
+ default_runner_type: local.default_runner_type,
114
+ default_model: local.default_model
115
+ };
116
+ await updateFlow(client, flowId, updateRequest);
117
+ counts.flowsUpdated++;
118
+ if (verbose) {
119
+ console.log(` ↑ Updated flow metadata: ${local.idn} (title: "${remoteFlow.title}" → "${local.title}")`);
120
+ }
121
+ }
122
+ catch (error) {
123
+ const msg = error instanceof Error ? error.message : String(error);
124
+ counts.errors.push(`Failed to update flow ${local.idn}: ${msg}`);
125
+ console.error(` ❌ Failed to update flow ${local.idn}: ${msg}`);
126
+ }
127
+ }
128
+ // 2. Events
129
+ let remoteEvents = [];
130
+ try {
131
+ remoteEvents = await listFlowEvents(client, flowId);
132
+ }
133
+ catch (error) {
134
+ const msg = error instanceof Error ? error.message : String(error);
135
+ counts.errors.push(`Failed to list events for flow ${local.idn}: ${msg}`);
136
+ return;
137
+ }
138
+ const localEvents = local.events ?? [];
139
+ const remoteByIdn = new Map(remoteEvents.map(e => [e.idn, e]));
140
+ const localByIdn = new Map(localEvents.map(e => [e.idn, e]));
141
+ // Create or update events present locally
142
+ for (const localEvent of localEvents) {
143
+ const remote = remoteByIdn.get(localEvent.idn);
144
+ if (!remote) {
145
+ // Create
146
+ try {
147
+ await createFlowEvent(client, flowId, buildEventCreateRequest(localEvent));
148
+ counts.eventsCreated++;
149
+ if (verbose)
150
+ console.log(` ↑ Created event: ${local.idn}/${localEvent.idn}`);
151
+ }
152
+ catch (error) {
153
+ const msg = error instanceof Error ? error.message : String(error);
154
+ counts.errors.push(`Failed to create event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
155
+ console.error(` ❌ Failed to create event ${localEvent.idn}: ${msg}`);
156
+ }
157
+ }
158
+ else if (flowEventDiffers(localEvent, remote)) {
159
+ try {
160
+ await updateFlowEvent(client, remote.id, buildEventUpdateRequest(localEvent));
161
+ counts.eventsUpdated++;
162
+ if (verbose)
163
+ console.log(` ↑ Updated event: ${local.idn}/${localEvent.idn}`);
164
+ }
165
+ catch (error) {
166
+ const msg = error instanceof Error ? error.message : String(error);
167
+ counts.errors.push(`Failed to update event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
168
+ console.error(` ❌ Failed to update event ${localEvent.idn}: ${msg}`);
169
+ }
170
+ }
171
+ }
172
+ // Delete events present remotely but missing locally
173
+ for (const remoteEvent of remoteEvents) {
174
+ if (!localByIdn.has(remoteEvent.idn)) {
175
+ try {
176
+ await deleteFlowEvent(client, remoteEvent.id);
177
+ counts.eventsDeleted++;
178
+ if (verbose)
179
+ console.log(` ↑ Deleted event: ${local.idn}/${remoteEvent.idn}`);
180
+ }
181
+ catch (error) {
182
+ const msg = error instanceof Error ? error.message : String(error);
183
+ counts.errors.push(`Failed to delete event ${remoteEvent.idn} in flow ${local.idn}: ${msg}`);
184
+ console.error(` ❌ Failed to delete event ${remoteEvent.idn}: ${msg}`);
185
+ }
186
+ }
187
+ }
188
+ // 3. State fields
189
+ let remoteStates = [];
190
+ try {
191
+ remoteStates = await listFlowStates(client, flowId);
192
+ }
193
+ catch (error) {
194
+ const msg = error instanceof Error ? error.message : String(error);
195
+ counts.errors.push(`Failed to list states for flow ${local.idn}: ${msg}`);
196
+ return;
197
+ }
198
+ const localStates = local.state_fields ?? [];
199
+ const remoteStatesByIdn = new Map(remoteStates.map(s => [s.idn, s]));
200
+ const localStatesByIdn = new Map(localStates.map(s => [s.idn, s]));
201
+ for (const localState of localStates) {
202
+ const remote = remoteStatesByIdn.get(localState.idn);
203
+ if (!remote) {
204
+ try {
205
+ await createFlowState(client, flowId, buildStateCreateRequest(localState));
206
+ counts.statesCreated++;
207
+ if (verbose)
208
+ console.log(` ↑ Created state: ${local.idn}/${localState.idn}`);
209
+ }
210
+ catch (error) {
211
+ const msg = error instanceof Error ? error.message : String(error);
212
+ counts.errors.push(`Failed to create state ${localState.idn} in flow ${local.idn}: ${msg}`);
213
+ console.error(` ❌ Failed to create state ${localState.idn}: ${msg}`);
214
+ }
215
+ }
216
+ else if (flowStateDiffers(localState, remote)) {
217
+ try {
218
+ await updateFlowState(client, remote.id, buildStateUpdateRequest(localState));
219
+ counts.statesUpdated++;
220
+ if (verbose)
221
+ console.log(` ↑ Updated state: ${local.idn}/${localState.idn}`);
222
+ }
223
+ catch (error) {
224
+ const msg = error instanceof Error ? error.message : String(error);
225
+ counts.errors.push(`Failed to update state ${localState.idn} in flow ${local.idn}: ${msg}`);
226
+ console.error(` ❌ Failed to update state ${localState.idn}: ${msg}`);
227
+ }
228
+ }
229
+ }
230
+ for (const remoteState of remoteStates) {
231
+ if (!localStatesByIdn.has(remoteState.idn)) {
232
+ try {
233
+ await deleteFlowState(client, remoteState.id);
234
+ counts.statesDeleted++;
235
+ if (verbose)
236
+ console.log(` ↑ Deleted state: ${local.idn}/${remoteState.idn}`);
237
+ }
238
+ catch (error) {
239
+ const msg = error instanceof Error ? error.message : String(error);
240
+ counts.errors.push(`Failed to delete state ${remoteState.idn} in flow ${local.idn}: ${msg}`);
241
+ console.error(` ❌ Failed to delete state ${remoteState.idn}: ${msg}`);
242
+ }
243
+ }
244
+ }
245
+ }
246
+ /**
247
+ * Combined count of operations across all categories.
248
+ */
249
+ export function totalFlowSyncOps(counts) {
250
+ return (counts.flowsUpdated +
251
+ counts.eventsCreated + counts.eventsUpdated + counts.eventsDeleted +
252
+ counts.statesCreated + counts.statesUpdated + counts.statesDeleted);
253
+ }
254
+ /**
255
+ * Human-readable summary line for the push report.
256
+ */
257
+ export function describeFlowSyncCounts(counts) {
258
+ const parts = [];
259
+ if (counts.flowsUpdated)
260
+ parts.push(`${counts.flowsUpdated} flow(s)`);
261
+ if (counts.eventsCreated || counts.eventsUpdated || counts.eventsDeleted) {
262
+ const eventOps = [];
263
+ if (counts.eventsCreated)
264
+ eventOps.push(`+${counts.eventsCreated}`);
265
+ if (counts.eventsUpdated)
266
+ eventOps.push(`~${counts.eventsUpdated}`);
267
+ if (counts.eventsDeleted)
268
+ eventOps.push(`-${counts.eventsDeleted}`);
269
+ parts.push(`events ${eventOps.join('/')}`);
270
+ }
271
+ if (counts.statesCreated || counts.statesUpdated || counts.statesDeleted) {
272
+ const stateOps = [];
273
+ if (counts.statesCreated)
274
+ stateOps.push(`+${counts.statesCreated}`);
275
+ if (counts.statesUpdated)
276
+ stateOps.push(`~${counts.statesUpdated}`);
277
+ if (counts.statesDeleted)
278
+ stateOps.push(`-${counts.statesDeleted}`);
279
+ parts.push(`states ${stateOps.join('/')}`);
280
+ }
281
+ return parts.join(', ');
282
+ }
283
+ //# sourceMappingURL=flow-metadata.js.map
package/dist/sync/push.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Push operations for changed files
3
3
  */
4
- import { updateSkill, createAgent, createFlow, createSkill, publishFlow } from '../api.js';
5
- import { ensureState, mapPath, skillMetadataPath, projectDir, agentMetadataPath } from '../fsutil.js';
4
+ import { updateSkill, createAgent, createFlow, createSkill, publishFlow, getFlow } from '../api.js';
5
+ import { ensureState, mapPath, skillMetadataPath, projectDir, agentMetadataPath, flowMetadataPath } from '../fsutil.js';
6
6
  import { validateSkillFolder, getSingleSkillFile, getExtensionForRunner } from './skill-files.js';
7
7
  import fs from 'fs-extra';
8
8
  import { sha256, loadHashes, saveHashes } from '../hash.js';
@@ -11,6 +11,7 @@ import { generateFlowsYaml } from './metadata.js';
11
11
  import { isProjectMap, isLegacyProjectMap } from './projects.js';
12
12
  import { flowsYamlPath } from '../fsutil.js';
13
13
  import { pushAllProjectAttributes } from './attributes.js';
14
+ import { syncFlowMetadata, emptyFlowSyncCounts, totalFlowSyncOps, describeFlowSyncCounts } from './flow-metadata.js';
14
15
  /**
15
16
  * Scan filesystem for local-only entities not in the project map yet
16
17
  */
@@ -444,6 +445,68 @@ export async function pushChanged(client, customer, verbose = false, shouldPubli
444
445
  }
445
446
  }
446
447
  }
448
+ // Sync flow metadata (title, events, state_fields) for any flow whose
449
+ // metadata.yaml hash has changed. This closes the loop on GH issue #3:
450
+ // previously push only updated skill scripts, leaving local edits to
451
+ // flow events and title silently un-synced.
452
+ const flowSyncCounts = emptyFlowSyncCounts();
453
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
454
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
455
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
456
+ if (!flowObj.id)
457
+ continue;
458
+ const metaPath = flowMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn);
459
+ if (!(await fs.pathExists(metaPath)))
460
+ continue;
461
+ const metaContent = await fs.readFile(metaPath, 'utf8');
462
+ const metaHash = sha256(metaContent);
463
+ const oldHash = hashes[metaPath];
464
+ if (oldHash === metaHash) {
465
+ if (verbose)
466
+ console.log(` ✓ Flow metadata unchanged: ${flowIdn}`);
467
+ continue;
468
+ }
469
+ if (verbose)
470
+ console.log(` 🔄 Flow metadata changed, syncing: ${agentIdn}/${flowIdn}`);
471
+ let localFlow;
472
+ try {
473
+ localFlow = yaml.load(metaContent);
474
+ }
475
+ catch (error) {
476
+ console.error(`❌ Failed to parse flow metadata for ${flowIdn}: ${error instanceof Error ? error.message : String(error)}`);
477
+ continue;
478
+ }
479
+ let remoteFlow = null;
480
+ try {
481
+ remoteFlow = await getFlow(client, flowObj.id);
482
+ }
483
+ catch (error) {
484
+ // 404 means the flow ID is stale; skip flow-level update but still
485
+ // try to sync children since list endpoints may still work.
486
+ if (verbose) {
487
+ console.log(` ⚠️ Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
488
+ }
489
+ }
490
+ const opsBefore = totalFlowSyncOps(flowSyncCounts);
491
+ await syncFlowMetadata(client, flowObj.id, localFlow, remoteFlow, verbose, flowSyncCounts);
492
+ const opsAfter = totalFlowSyncOps(flowSyncCounts);
493
+ if (opsAfter > opsBefore) {
494
+ pushed += (opsAfter - opsBefore);
495
+ metadataChanged = true;
496
+ }
497
+ // Hash is updated regardless of whether ops happened, so we don't
498
+ // re-scan the same untouched flow on the next push.
499
+ newHashes[metaPath] = metaHash;
500
+ }
501
+ }
502
+ }
503
+ const totalFlowOps = totalFlowSyncOps(flowSyncCounts);
504
+ if (totalFlowOps > 0) {
505
+ console.log(`↑ Flow metadata synced: ${describeFlowSyncCounts(flowSyncCounts)}`);
506
+ }
507
+ if (flowSyncCounts.errors.length > 0) {
508
+ console.warn(`⚠️ ${flowSyncCounts.errors.length} flow-metadata error(s) during push.`);
509
+ }
447
510
  if (verbose)
448
511
  console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
449
512
  // Push project attributes for all projects
package/dist/types.d.ts CHANGED
@@ -441,6 +441,22 @@ export interface CreateFlowEventRequest {
441
441
  export interface CreateFlowEventResponse {
442
442
  id: string;
443
443
  }
444
+ /**
445
+ * Payload for PATCH /api/v1/designer/flows/events/{eventId}
446
+ *
447
+ * Required fields per probe testing: idn, skill_selector, interrupt_mode.
448
+ * Sending the full event body is the platform's expected shape.
449
+ */
450
+ export interface UpdateFlowEventRequest {
451
+ idn: string;
452
+ description?: string | null;
453
+ skill_selector: string;
454
+ skill_idn?: string | null;
455
+ state_idn?: string | null;
456
+ interrupt_mode: string;
457
+ integration_idn?: string | null;
458
+ connector_idn?: string | null;
459
+ }
444
460
  export interface CreateFlowStateRequest {
445
461
  title: string;
446
462
  idn: string;
@@ -450,6 +466,27 @@ export interface CreateFlowStateRequest {
450
466
  export interface CreateFlowStateResponse {
451
467
  id: string;
452
468
  }
469
+ /**
470
+ * Payload for PUT /api/v1/designer/flows/states/{stateId}
471
+ */
472
+ export interface UpdateFlowStateRequest {
473
+ title: string;
474
+ idn: string;
475
+ default_value?: string;
476
+ scope: string;
477
+ }
478
+ /**
479
+ * Payload for PATCH /api/v1/designer/flows/{flowId}
480
+ *
481
+ * Empty body returns 500; the platform requires the full descriptor.
482
+ */
483
+ export interface UpdateFlowRequest {
484
+ idn: string;
485
+ title: string;
486
+ description?: string;
487
+ default_runner_type: RunnerType;
488
+ default_model: ModelConfig;
489
+ }
453
490
  export interface CreateSkillParameterRequest {
454
491
  name: string;
455
492
  default_value?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "3.7.1",
3
+ "version": "3.7.2",
4
4
  "description": "NEWO CLI: Professional command-line tool with modular architecture for NEWO AI Agent development. Features account migration, integration management, webhook automation, AKB knowledge base, project attributes, sandbox testing, IDN-based file management, real-time progress tracking, intelligent sync operations, and comprehensive multi-customer support.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.ts CHANGED
@@ -23,8 +23,11 @@ import type {
23
23
  CreateSkillResponse,
24
24
  CreateFlowEventRequest,
25
25
  CreateFlowEventResponse,
26
+ UpdateFlowEventRequest,
26
27
  CreateFlowStateRequest,
27
28
  CreateFlowStateResponse,
29
+ UpdateFlowStateRequest,
30
+ UpdateFlowRequest,
28
31
  CreateSkillParameterRequest,
29
32
  CreateSkillParameterResponse,
30
33
  CreateCustomerAttributeRequest,
@@ -151,6 +154,30 @@ export async function listFlowSkills(client: AxiosInstance, flowId: string): Pro
151
154
  return response.data;
152
155
  }
153
156
 
157
+ /**
158
+ * Fetch a single flow's top-level descriptor.
159
+ *
160
+ * Used by push to detect whether local metadata.yaml title/description
161
+ * differ from the platform before patching.
162
+ */
163
+ export interface FlowDescriptor {
164
+ id: string;
165
+ idn: string;
166
+ title: string;
167
+ description: string | null;
168
+ agent_id: string;
169
+ default_runner_type: string;
170
+ default_model: { provider_idn: string; model_idn: string };
171
+ publication_datetime?: string;
172
+ last_change_datetime?: string;
173
+ creation_datetime?: string;
174
+ }
175
+
176
+ export async function getFlow(client: AxiosInstance, flowId: string): Promise<FlowDescriptor> {
177
+ const response = await client.get<FlowDescriptor>(`/api/v1/designer/flows/${flowId}`);
178
+ return response.data;
179
+ }
180
+
154
181
  export async function getSkill(client: AxiosInstance, skillId: string): Promise<Skill> {
155
182
  const response = await client.get<Skill>(`/api/v1/designer/skills/${skillId}`);
156
183
  return response.data;
@@ -345,6 +372,14 @@ export async function createFlowEvent(client: AxiosInstance, flowId: string, eve
345
372
  return response.data;
346
373
  }
347
374
 
375
+ export async function updateFlowEvent(
376
+ client: AxiosInstance,
377
+ eventId: string,
378
+ eventData: UpdateFlowEventRequest
379
+ ): Promise<void> {
380
+ await client.patch(`/api/v1/designer/flows/events/${eventId}`, eventData);
381
+ }
382
+
348
383
  export async function deleteFlowEvent(client: AxiosInstance, eventId: string): Promise<void> {
349
384
  await client.delete(`/api/v1/designer/flows/events/${eventId}`);
350
385
  }
@@ -354,6 +389,32 @@ export async function createFlowState(client: AxiosInstance, flowId: string, sta
354
389
  return response.data;
355
390
  }
356
391
 
392
+ export async function updateFlowState(
393
+ client: AxiosInstance,
394
+ stateId: string,
395
+ stateData: UpdateFlowStateRequest
396
+ ): Promise<void> {
397
+ await client.put(`/api/v1/designer/flows/states/${stateId}`, stateData);
398
+ }
399
+
400
+ export async function deleteFlowState(client: AxiosInstance, stateId: string): Promise<void> {
401
+ await client.delete(`/api/v1/designer/flows/states/${stateId}`);
402
+ }
403
+
404
+ /**
405
+ * Update flow metadata (title, description, runner type, default model).
406
+ *
407
+ * Uses PATCH /api/v1/designer/flows/{flowId}. The platform requires the
408
+ * full flow descriptor; sending a partial body returns 500.
409
+ */
410
+ export async function updateFlow(
411
+ client: AxiosInstance,
412
+ flowId: string,
413
+ flowData: UpdateFlowRequest
414
+ ): Promise<void> {
415
+ await client.patch(`/api/v1/designer/flows/${flowId}`, flowData);
416
+ }
417
+
357
418
  export async function createSkillParameter(client: AxiosInstance, skillId: string, paramData: CreateSkillParameterRequest): Promise<CreateSkillParameterResponse> {
358
419
  // Debug the parameter creation request
359
420
  console.log('Creating parameter for skill:', skillId);
@@ -49,7 +49,14 @@ import {
49
49
  publishFlow,
50
50
  listLibraries,
51
51
  updateLibrarySkill,
52
+ getFlow,
52
53
  } from '../../../api.js';
54
+ import {
55
+ syncFlowMetadata,
56
+ emptyFlowSyncCounts,
57
+ totalFlowSyncOps,
58
+ describeFlowSyncCounts
59
+ } from '../../../sync/flow-metadata.js';
53
60
  import {
54
61
  ensureState,
55
62
  writeFileSafe,
@@ -540,6 +547,24 @@ export class ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalProj
540
547
  for (const change of changes) {
541
548
  try {
542
549
  if (change.operation === 'modified') {
550
+ // Flow-level metadata.yaml needs different handling than a skill
551
+ // script: we sync title/events/state_fields rather than uploading a
552
+ // file. Detected by filename. (GH issue #3)
553
+ if (change.path.endsWith('/metadata.yaml') && !change.path.includes('/libraries/')) {
554
+ const pathParts = change.path.split('/');
555
+ // {customer}/projects/{project}/{agent}/{flow}/metadata.yaml
556
+ // Last 5 segments end with metadata.yaml; skip if it's a skill
557
+ // metadata file (one extra segment) - skill metadata is handled
558
+ // by V1 legacy push, not by this strategy yet.
559
+ const tail = pathParts.slice(-5);
560
+ const isFlowMeta = tail[0] === 'projects' || tail[2] && tail[4] === 'metadata.yaml';
561
+ if (isFlowMeta && tail.length === 5) {
562
+ const updateResult = await this.pushFlowMetadataUpdate(client, change, mapData, newHashes);
563
+ result.updated += updateResult;
564
+ continue;
565
+ }
566
+ }
567
+
543
568
  const isLibrary = change.path.includes('/libraries/');
544
569
  if (isLibrary) {
545
570
  const updateResult = await this.pushLibrarySkillUpdate(client, change, mapData, newHashes);
@@ -568,6 +593,64 @@ export class ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalProj
568
593
  return result;
569
594
  }
570
595
 
596
+ /**
597
+ * Push a flow metadata.yaml change — syncs title, events, and state_fields
598
+ * to the platform. Closes GH issue #3 (events/title silently un-synced).
599
+ *
600
+ * Path shape: newo_customers/{customer}/projects/{project}/{agent}/{flow}/metadata.yaml
601
+ */
602
+ private async pushFlowMetadataUpdate(
603
+ client: AxiosInstance,
604
+ change: ChangeItem<LocalProjectData>,
605
+ mapData: ProjectMap,
606
+ newHashes: HashStore
607
+ ): Promise<number> {
608
+ const pathParts = change.path.split('/');
609
+ // Tail: projects/{project}/{agent}/{flow}/metadata.yaml
610
+ const flowIdn = pathParts[pathParts.length - 2] || '';
611
+ const agentIdn = pathParts[pathParts.length - 3] || '';
612
+ const projectIdn = pathParts[pathParts.length - 4] || '';
613
+
614
+ const projectData = mapData.projects[projectIdn];
615
+ const agentData = projectData?.agents[agentIdn];
616
+ const flowData = agentData?.flows[flowIdn];
617
+
618
+ if (!flowData?.id) {
619
+ this.logger.warn(`Flow metadata change but flow not in project map: ${projectIdn}/${agentIdn}/${flowIdn}`);
620
+ return 0;
621
+ }
622
+
623
+ const content = await fs.readFile(change.path, 'utf8');
624
+ let localFlow: FlowMetadata;
625
+ try {
626
+ localFlow = yaml.load(content) as FlowMetadata;
627
+ } catch (error) {
628
+ throw new Error(`Failed to parse ${change.path}: ${error instanceof Error ? error.message : String(error)}`);
629
+ }
630
+
631
+ let remoteFlow = null;
632
+ try {
633
+ remoteFlow = await getFlow(client, flowData.id);
634
+ } catch (error: any) {
635
+ this.logger.verbose(`Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
636
+ }
637
+
638
+ const counts = emptyFlowSyncCounts();
639
+ await syncFlowMetadata(client, flowData.id, localFlow, remoteFlow, false, counts);
640
+
641
+ const total = totalFlowSyncOps(counts);
642
+ if (total > 0) {
643
+ this.logger.info(`↑ Flow ${flowIdn}: ${describeFlowSyncCounts(counts)}`);
644
+ }
645
+ for (const err of counts.errors) {
646
+ this.logger.warn(err);
647
+ }
648
+
649
+ // Stamp the hash so the next push skips this file unless it changes again.
650
+ newHashes[change.path] = sha256(content);
651
+ return total;
652
+ }
653
+
571
654
  /**
572
655
  * Push a skill update
573
656
  */
@@ -709,6 +792,23 @@ export class ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalProj
709
792
  for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
710
793
  for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
711
794
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
795
+ // Flow metadata change detection (title/events/state_fields).
796
+ // Surfaced as a change so push() picks it up alongside skill edits.
797
+ const flowMetaPath = flowMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn);
798
+ if (await fs.pathExists(flowMetaPath)) {
799
+ const content = await fs.readFile(flowMetaPath, 'utf8');
800
+ const currentHash = sha256(content);
801
+ const storedHash = hashes[flowMetaPath];
802
+
803
+ if (storedHash !== currentHash) {
804
+ changes.push({
805
+ item: {} as LocalProjectData,
806
+ operation: 'modified',
807
+ path: flowMetaPath
808
+ });
809
+ }
810
+ }
811
+
712
812
  for (const [skillIdn, _skillData] of Object.entries(flowData.skills)) {
713
813
  const skillFile = await getSingleSkillFile(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn);
714
814