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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.7.2] - 2026-05-17
11
+
12
+ ### Fixed
13
+
14
+ - **`newo push` now syncs flow metadata (title, events, state_fields) to the platform** — closes [#3](https://github.com/sabbah13/newo-cli/issues/3). Previously `newo push` only uploaded skill scripts; edits to a flow's `metadata.yaml` (V1) or `{FlowIdn}.yaml` (V2) silently never reached the platform, and events added via `newo create-event` could appear to disappear after a subsequent push because the local-side definition lagged the remote one. Push now performs hash-gated reconciliation per flow: when `metadata.yaml` changes locally it calls `PATCH /api/v1/designer/flows/{id}` for title/description/runner, then create/update/delete child events and state_fields against `/events` and `/states` so the platform mirrors local. Hash-gating is critical — flows whose `metadata.yaml` is untouched are never even compared, so stale local trees cannot accidentally wipe events created out-of-band via the Builder UI. Wired into the legacy `pushChanged` path, `ProjectSyncStrategy`, and `V2ProjectSyncStrategy`. Reported by Bob (issue #3).
15
+
16
+ ### Added
17
+
18
+ - **`updateFlow`, `updateFlowEvent`, `updateFlowState`, `deleteFlowState`, `getFlow` API wrappers** in `src/api.ts` for the previously-undocumented `PATCH /api/v1/designer/flows/{id}`, `PATCH /api/v1/designer/flows/events/{id}`, `PUT /api/v1/designer/flows/states/{id}`, `DELETE /api/v1/designer/flows/states/{id}`, and `GET /api/v1/designer/flows/{id}` endpoints. Empty payload returns 500; the platform requires the full descriptor.
19
+ - **`src/sync/flow-metadata.ts`** module — pure-logic reconciler shared by both V1 and V2 push paths. Comparator helpers `flowEventDiffers`/`flowStateDiffers` normalize null/undefined/empty so YAML round-trips don't trigger spurious updates.
20
+ - **18 unit tests** in `test/flow-metadata.test.js` covering create/update/delete event flows, state CRUD in a single pass, no-op when local matches remote, and graceful error handling that records failures without aborting the loop.
21
+
22
+ ## [3.7.1] - 2026-04-29
23
+
24
+ ### Fixed
25
+
26
+ - **Workflow Builder canvas blank-screen after `newo push --only attributes`** - JSON-typed project/customer attributes (e.g. `project_attributes_private_dynamic_workflow_builder_canvas`) are now always coerced to a JSON STRING when persisted to `attributes.yaml` and when sent on push. Previously, when the API returned the `value` field as a parsed object, `yaml.dump` serialized it as a YAML structure and the next push sent `{"value": {...object...}}` instead of `{"value": "...json..."}` - the platform stored a shape Builder could not render and the canvas blanked out. Change-detection now compares both sides as canonical (compact) JSON, so pretty- vs compact-printed forms and string vs object representations no longer trigger spurious pushes. String-typed values are left bit-for-bit untouched, so no churn on existing repos. New helpers in `src/sync/json-attr-utils.ts` are wired into both `src/sync/attributes.ts` and `src/domain/strategies/sync/AttributeSyncStrategy.ts`. Reported by Bob; 19 regression tests in `test/json-attribute-roundtrip.test.js`.
27
+
10
28
  ## [3.7.0] - 2026-04-23
11
29
 
12
30
  ### Added
@@ -1033,7 +1051,8 @@ Another Item: $Price [Modifiers: modifier3]
1033
1051
  - GitHub Actions CI/CD integration
1034
1052
  - Robust authentication with token refresh
1035
1053
 
1036
- [Unreleased]: https://github.com/sabbah13/newo-cli/compare/v3.3.0...HEAD
1054
+ [Unreleased]: https://github.com/sabbah13/newo-cli/compare/v3.7.1...HEAD
1055
+ [3.7.1]: https://github.com/sabbah13/newo-cli/compare/v3.7.0...v3.7.1
1037
1056
  [3.3.0]: https://github.com/sabbah13/newo-cli/compare/v3.2.0...v3.3.0
1038
1057
  [3.2.0]: https://github.com/sabbah13/newo-cli/compare/v3.1.0...v3.2.0
1039
1058
  [3.1.0]: https://github.com/sabbah13/newo-cli/compare/v3.0.0...v3.1.0
package/dist/api.d.ts CHANGED
@@ -1,10 +1,32 @@
1
1
  import { type AxiosInstance } from 'axios';
2
- import type { ProjectMeta, Agent, Skill, FlowEvent, FlowState, AkbImportArticle, CustomerProfile, CustomerAttribute, CustomerAttributesResponse, UserPersonaResponse, UserPersona, ChatHistoryParams, ChatHistoryResponse, CreateAgentRequest, CreateAgentResponse, CreateFlowRequest, CreateFlowResponse, CreateSkillRequest, CreateSkillResponse, CreateFlowEventRequest, CreateFlowEventResponse, CreateFlowStateRequest, CreateFlowStateResponse, CreateSkillParameterRequest, CreateSkillParameterResponse, CreateCustomerAttributeRequest, CreateCustomerAttributeResponse, CreatePersonaRequest, CreatePersonaResponse, CreateProjectRequest, CreateProjectResponse, PublishFlowRequest, PublishFlowResponse, Integration, Connector, CreateSandboxPersonaRequest, CreateSandboxPersonaResponse, CreateActorRequest, CreateActorResponse, SendChatMessageRequest, ConversationActsParams, ConversationActsResponse, ScriptAction, IntegrationSetting, CreateConnectorRequest, CreateConnectorResponse, UpdateConnectorRequest, OutgoingWebhook, IncomingWebhook, PersonaSearchResponse, AkbTopicsResponse, Registry, RegistryItem, AddProjectFromRegistryRequest, CreateNewoCustomerRequest, CreateNewoCustomerResponse, LogsQueryParams, LogsResponse } from './types.js';
2
+ import type { ProjectMeta, Agent, Skill, FlowEvent, FlowState, AkbImportArticle, CustomerProfile, CustomerAttribute, CustomerAttributesResponse, UserPersonaResponse, UserPersona, ChatHistoryParams, ChatHistoryResponse, CreateAgentRequest, CreateAgentResponse, CreateFlowRequest, CreateFlowResponse, CreateSkillRequest, CreateSkillResponse, CreateFlowEventRequest, CreateFlowEventResponse, UpdateFlowEventRequest, CreateFlowStateRequest, CreateFlowStateResponse, UpdateFlowStateRequest, UpdateFlowRequest, CreateSkillParameterRequest, CreateSkillParameterResponse, CreateCustomerAttributeRequest, CreateCustomerAttributeResponse, CreatePersonaRequest, CreatePersonaResponse, CreateProjectRequest, CreateProjectResponse, PublishFlowRequest, PublishFlowResponse, Integration, Connector, CreateSandboxPersonaRequest, CreateSandboxPersonaResponse, CreateActorRequest, CreateActorResponse, SendChatMessageRequest, ConversationActsParams, ConversationActsResponse, ScriptAction, IntegrationSetting, CreateConnectorRequest, CreateConnectorResponse, UpdateConnectorRequest, OutgoingWebhook, IncomingWebhook, PersonaSearchResponse, AkbTopicsResponse, Registry, RegistryItem, AddProjectFromRegistryRequest, CreateNewoCustomerRequest, CreateNewoCustomerResponse, LogsQueryParams, LogsResponse } from './types.js';
3
3
  export declare function makeClient(verbose?: boolean, token?: string): Promise<AxiosInstance>;
4
4
  export declare function listProjects(client: AxiosInstance): Promise<ProjectMeta[]>;
5
5
  export declare function listAgents(client: AxiosInstance, projectId: string): Promise<Agent[]>;
6
6
  export declare function getProjectMeta(client: AxiosInstance, projectId: string): Promise<ProjectMeta>;
7
7
  export declare function listFlowSkills(client: AxiosInstance, flowId: string): Promise<Skill[]>;
8
+ /**
9
+ * Fetch a single flow's top-level descriptor.
10
+ *
11
+ * Used by push to detect whether local metadata.yaml title/description
12
+ * differ from the platform before patching.
13
+ */
14
+ export interface FlowDescriptor {
15
+ id: string;
16
+ idn: string;
17
+ title: string;
18
+ description: string | null;
19
+ agent_id: string;
20
+ default_runner_type: string;
21
+ default_model: {
22
+ provider_idn: string;
23
+ model_idn: string;
24
+ };
25
+ publication_datetime?: string;
26
+ last_change_datetime?: string;
27
+ creation_datetime?: string;
28
+ }
29
+ export declare function getFlow(client: AxiosInstance, flowId: string): Promise<FlowDescriptor>;
8
30
  export declare function getSkill(client: AxiosInstance, skillId: string): Promise<Skill>;
9
31
  export declare function updateSkill(client: AxiosInstance, skillObject: Skill): Promise<void>;
10
32
  export declare function listFlowEvents(client: AxiosInstance, flowId: string): Promise<FlowEvent[]>;
@@ -32,8 +54,18 @@ export declare function createSkill(client: AxiosInstance, flowId: string, skill
32
54
  export declare function deleteSkill(client: AxiosInstance, skillId: string): Promise<void>;
33
55
  export declare function deleteSkillById(client: AxiosInstance, skillId: string): Promise<void>;
34
56
  export declare function createFlowEvent(client: AxiosInstance, flowId: string, eventData: CreateFlowEventRequest): Promise<CreateFlowEventResponse>;
57
+ export declare function updateFlowEvent(client: AxiosInstance, eventId: string, eventData: UpdateFlowEventRequest): Promise<void>;
35
58
  export declare function deleteFlowEvent(client: AxiosInstance, eventId: string): Promise<void>;
36
59
  export declare function createFlowState(client: AxiosInstance, flowId: string, stateData: CreateFlowStateRequest): Promise<CreateFlowStateResponse>;
60
+ export declare function updateFlowState(client: AxiosInstance, stateId: string, stateData: UpdateFlowStateRequest): Promise<void>;
61
+ export declare function deleteFlowState(client: AxiosInstance, stateId: string): Promise<void>;
62
+ /**
63
+ * Update flow metadata (title, description, runner type, default model).
64
+ *
65
+ * Uses PATCH /api/v1/designer/flows/{flowId}. The platform requires the
66
+ * full flow descriptor; sending a partial body returns 500.
67
+ */
68
+ export declare function updateFlow(client: AxiosInstance, flowId: string, flowData: UpdateFlowRequest): Promise<void>;
37
69
  export declare function createSkillParameter(client: AxiosInstance, skillId: string, paramData: CreateSkillParameterRequest): Promise<CreateSkillParameterResponse>;
38
70
  export declare function createCustomerAttribute(client: AxiosInstance, attributeData: CreateCustomerAttributeRequest): Promise<CreateCustomerAttributeResponse>;
39
71
  export declare function createProject(client: AxiosInstance, projectData: CreateProjectRequest): Promise<CreateProjectResponse>;
package/dist/api.js CHANGED
@@ -77,6 +77,10 @@ export async function listFlowSkills(client, flowId) {
77
77
  const response = await client.get(`/api/v1/designer/flows/${flowId}/skills`);
78
78
  return response.data;
79
79
  }
80
+ export async function getFlow(client, flowId) {
81
+ const response = await client.get(`/api/v1/designer/flows/${flowId}`);
82
+ return response.data;
83
+ }
80
84
  export async function getSkill(client, skillId) {
81
85
  const response = await client.get(`/api/v1/designer/skills/${skillId}`);
82
86
  return response.data;
@@ -221,6 +225,9 @@ export async function createFlowEvent(client, flowId, eventData) {
221
225
  const response = await client.post(`/api/v1/designer/flows/${flowId}/events`, eventData);
222
226
  return response.data;
223
227
  }
228
+ export async function updateFlowEvent(client, eventId, eventData) {
229
+ await client.patch(`/api/v1/designer/flows/events/${eventId}`, eventData);
230
+ }
224
231
  export async function deleteFlowEvent(client, eventId) {
225
232
  await client.delete(`/api/v1/designer/flows/events/${eventId}`);
226
233
  }
@@ -228,6 +235,21 @@ export async function createFlowState(client, flowId, stateData) {
228
235
  const response = await client.post(`/api/v1/designer/flows/${flowId}/states`, stateData);
229
236
  return response.data;
230
237
  }
238
+ export async function updateFlowState(client, stateId, stateData) {
239
+ await client.put(`/api/v1/designer/flows/states/${stateId}`, stateData);
240
+ }
241
+ export async function deleteFlowState(client, stateId) {
242
+ await client.delete(`/api/v1/designer/flows/states/${stateId}`);
243
+ }
244
+ /**
245
+ * Update flow metadata (title, description, runner type, default model).
246
+ *
247
+ * Uses PATCH /api/v1/designer/flows/{flowId}. The platform requires the
248
+ * full flow descriptor; sending a partial body returns 500.
249
+ */
250
+ export async function updateFlow(client, flowId, flowData) {
251
+ await client.patch(`/api/v1/designer/flows/${flowId}`, flowData);
252
+ }
231
253
  export async function createSkillParameter(client, skillId, paramData) {
232
254
  // Debug the parameter creation request
233
255
  console.log('Creating parameter for skill:', skillId);
@@ -15,6 +15,7 @@ import path from 'path';
15
15
  import { getCustomerAttributes, getProjectAttributes, updateCustomerAttribute, updateProjectAttribute, listProjects } from '../../../api.js';
16
16
  import { writeFileSafe, customerAttributesPath, customerAttributesMapPath } from '../../../fsutil.js';
17
17
  import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
18
+ import { isJsonValueType, normalizeJsonValueForStorage, jsonValuesEqual } from '../../../sync/json-attr-utils.js';
18
19
  import { sha256, saveHashes, loadHashes } from '../../../hash.js';
19
20
  /**
20
21
  * AttributeSyncStrategy - Handles attribute synchronization
@@ -142,8 +143,15 @@ export class AttributeSyncStrategy {
142
143
  */
143
144
  cleanAttribute(attr) {
144
145
  let processedValue = attr.value;
145
- // Handle JSON string values
146
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
146
+ // Coerce JSON-typed values to a STRING. The API may return parsed
147
+ // objects for `value_type: json`; if we let yaml.dump turn them into
148
+ // YAML structures, the next push sends an object and the Workflow
149
+ // Builder canvas blanks out. See src/sync/json-attr-utils.ts.
150
+ if (isJsonValueType(attr.value_type)) {
151
+ processedValue = normalizeJsonValueForStorage(attr.value);
152
+ }
153
+ else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
154
+ // Legacy: reformat array-of-objects JSON strings for readability
147
155
  try {
148
156
  const parsed = JSON.parse(attr.value);
149
157
  processedValue = JSON.stringify(parsed, null, 0);
@@ -243,10 +251,22 @@ export class AttributeSyncStrategy {
243
251
  const remoteAttr = remoteMap.get(localAttr.idn);
244
252
  if (!remoteAttr)
245
253
  continue;
246
- if (String(localAttr.value) !== String(remoteAttr.value)) {
254
+ // For JSON-typed attrs, compare canonical JSON (handles
255
+ // pretty/compact and string/object differences). Always send the
256
+ // value as a STRING so the platform stores the canvas the way the
257
+ // Workflow Builder expects to read it back.
258
+ const isJson = isJsonValueType(localAttr.value_type);
259
+ const valuesAreEqual = isJson
260
+ ? jsonValuesEqual(localAttr.value, remoteAttr.value)
261
+ : String(localAttr.value) === String(remoteAttr.value);
262
+ if (!valuesAreEqual) {
263
+ const valueToSend = isJson
264
+ ? normalizeJsonValueForStorage(localAttr.value)
265
+ : localAttr.value;
247
266
  await updateCustomerAttribute(client, {
248
- id: attributeId,
249
- ...localAttr
267
+ ...localAttr,
268
+ value: valueToSend,
269
+ id: attributeId
250
270
  });
251
271
  updatedCount++;
252
272
  this.logger.info(` ✓ Updated customer attribute: ${localAttr.idn}`);
@@ -286,10 +306,20 @@ export class AttributeSyncStrategy {
286
306
  const remoteAttr = remoteMap.get(localAttr.idn);
287
307
  if (!remoteAttr)
288
308
  continue;
289
- if (String(localAttr.value) !== String(remoteAttr.value)) {
309
+ // Same canonical-JSON / always-string-on-push policy as customer
310
+ // attributes (see pushCustomerAttributes for rationale).
311
+ const isJson = isJsonValueType(localAttr.value_type);
312
+ const valuesAreEqual = isJson
313
+ ? jsonValuesEqual(localAttr.value, remoteAttr.value)
314
+ : String(localAttr.value) === String(remoteAttr.value);
315
+ if (!valuesAreEqual) {
316
+ const valueToSend = isJson
317
+ ? normalizeJsonValueForStorage(localAttr.value)
318
+ : localAttr.value;
290
319
  await updateProjectAttribute(client, project.id, {
291
- id: attributeId,
292
- ...localAttr
320
+ ...localAttr,
321
+ value: valueToSend,
322
+ id: attributeId
293
323
  });
294
324
  updatedCount++;
295
325
  this.logger.info(` ✓ Updated project attribute: ${projectIdn}/${localAttr.idn}`);
@@ -75,6 +75,13 @@ export declare class ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, L
75
75
  * Push changed projects to NEWO platform
76
76
  */
77
77
  push(customer: CustomerConfig, changes?: ChangeItem<LocalProjectData>[]): Promise<PushResult>;
78
+ /**
79
+ * Push a flow metadata.yaml change — syncs title, events, and state_fields
80
+ * to the platform. Closes GH issue #3 (events/title silently un-synced).
81
+ *
82
+ * Path shape: newo_customers/{customer}/projects/{project}/{agent}/{flow}/metadata.yaml
83
+ */
84
+ private pushFlowMetadataUpdate;
78
85
  /**
79
86
  * Push a skill update
80
87
  */
@@ -12,7 +12,8 @@
12
12
  */
13
13
  import fs from 'fs-extra';
14
14
  import yaml from 'js-yaml';
15
- import { listProjects, listAgents, listFlowSkills, listFlowEvents, listFlowStates, updateSkill, publishFlow, listLibraries, updateLibrarySkill, } from '../../../api.js';
15
+ import { listProjects, listAgents, listFlowSkills, listFlowEvents, listFlowStates, updateSkill, publishFlow, listLibraries, updateLibrarySkill, getFlow, } from '../../../api.js';
16
+ import { syncFlowMetadata, emptyFlowSyncCounts, totalFlowSyncOps, describeFlowSyncCounts } from '../../../sync/flow-metadata.js';
16
17
  import { ensureState, writeFileSafe, mapPath, projectMetadataPath, agentMetadataPath, flowMetadataPath, skillMetadataPath, skillScriptPath, skillFolderPath, flowsYamlPath, customerProjectsDir, projectDir, libraryMetadataPath, librarySkillMetadataPath, librarySkillScriptPath, } from '../../../fsutil.js';
17
18
  import { sha256, saveHashes, loadHashes } from '../../../hash.js';
18
19
  import { generateFlowsYaml } from '../../../sync/metadata.js';
@@ -354,6 +355,23 @@ export class ProjectSyncStrategy {
354
355
  for (const change of changes) {
355
356
  try {
356
357
  if (change.operation === 'modified') {
358
+ // Flow-level metadata.yaml needs different handling than a skill
359
+ // script: we sync title/events/state_fields rather than uploading a
360
+ // file. Detected by filename. (GH issue #3)
361
+ if (change.path.endsWith('/metadata.yaml') && !change.path.includes('/libraries/')) {
362
+ const pathParts = change.path.split('/');
363
+ // {customer}/projects/{project}/{agent}/{flow}/metadata.yaml
364
+ // Last 5 segments end with metadata.yaml; skip if it's a skill
365
+ // metadata file (one extra segment) - skill metadata is handled
366
+ // by V1 legacy push, not by this strategy yet.
367
+ const tail = pathParts.slice(-5);
368
+ const isFlowMeta = tail[0] === 'projects' || tail[2] && tail[4] === 'metadata.yaml';
369
+ if (isFlowMeta && tail.length === 5) {
370
+ const updateResult = await this.pushFlowMetadataUpdate(client, change, mapData, newHashes);
371
+ result.updated += updateResult;
372
+ continue;
373
+ }
374
+ }
357
375
  const isLibrary = change.path.includes('/libraries/');
358
376
  if (isLibrary) {
359
377
  const updateResult = await this.pushLibrarySkillUpdate(client, change, mapData, newHashes);
@@ -381,6 +399,53 @@ export class ProjectSyncStrategy {
381
399
  }
382
400
  return result;
383
401
  }
402
+ /**
403
+ * Push a flow metadata.yaml change — syncs title, events, and state_fields
404
+ * to the platform. Closes GH issue #3 (events/title silently un-synced).
405
+ *
406
+ * Path shape: newo_customers/{customer}/projects/{project}/{agent}/{flow}/metadata.yaml
407
+ */
408
+ async pushFlowMetadataUpdate(client, change, mapData, newHashes) {
409
+ const pathParts = change.path.split('/');
410
+ // Tail: projects/{project}/{agent}/{flow}/metadata.yaml
411
+ const flowIdn = pathParts[pathParts.length - 2] || '';
412
+ const agentIdn = pathParts[pathParts.length - 3] || '';
413
+ const projectIdn = pathParts[pathParts.length - 4] || '';
414
+ const projectData = mapData.projects[projectIdn];
415
+ const agentData = projectData?.agents[agentIdn];
416
+ const flowData = agentData?.flows[flowIdn];
417
+ if (!flowData?.id) {
418
+ this.logger.warn(`Flow metadata change but flow not in project map: ${projectIdn}/${agentIdn}/${flowIdn}`);
419
+ return 0;
420
+ }
421
+ const content = await fs.readFile(change.path, 'utf8');
422
+ let localFlow;
423
+ try {
424
+ localFlow = yaml.load(content);
425
+ }
426
+ catch (error) {
427
+ throw new Error(`Failed to parse ${change.path}: ${error instanceof Error ? error.message : String(error)}`);
428
+ }
429
+ let remoteFlow = null;
430
+ try {
431
+ remoteFlow = await getFlow(client, flowData.id);
432
+ }
433
+ catch (error) {
434
+ this.logger.verbose(`Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
435
+ }
436
+ const counts = emptyFlowSyncCounts();
437
+ await syncFlowMetadata(client, flowData.id, localFlow, remoteFlow, false, counts);
438
+ const total = totalFlowSyncOps(counts);
439
+ if (total > 0) {
440
+ this.logger.info(`↑ Flow ${flowIdn}: ${describeFlowSyncCounts(counts)}`);
441
+ }
442
+ for (const err of counts.errors) {
443
+ this.logger.warn(err);
444
+ }
445
+ // Stamp the hash so the next push skips this file unless it changes again.
446
+ newHashes[change.path] = sha256(content);
447
+ return total;
448
+ }
384
449
  /**
385
450
  * Push a skill update
386
451
  */
@@ -488,6 +553,21 @@ export class ProjectSyncStrategy {
488
553
  for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
489
554
  for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
490
555
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
556
+ // Flow metadata change detection (title/events/state_fields).
557
+ // Surfaced as a change so push() picks it up alongside skill edits.
558
+ const flowMetaPath = flowMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn);
559
+ if (await fs.pathExists(flowMetaPath)) {
560
+ const content = await fs.readFile(flowMetaPath, 'utf8');
561
+ const currentHash = sha256(content);
562
+ const storedHash = hashes[flowMetaPath];
563
+ if (storedHash !== currentHash) {
564
+ changes.push({
565
+ item: {},
566
+ operation: 'modified',
567
+ path: flowMetaPath
568
+ });
569
+ }
570
+ }
491
571
  for (const [skillIdn, _skillData] of Object.entries(flowData.skills)) {
492
572
  const skillFile = await getSingleSkillFile(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn);
493
573
  if (skillFile) {
@@ -54,6 +54,17 @@ export declare class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta,
54
54
  */
55
55
  private pullLibrary;
56
56
  push(customer: CustomerConfig, changes?: ChangeItem<LocalProjectData>[]): Promise<PushResult>;
57
+ /**
58
+ * Recognize the V2 flow YAML location: .../agents/{agent}/flows/{flow}/{flow}.yaml
59
+ * Distinguishes it from skill scripts, library YAMLs, and attribute files.
60
+ */
61
+ private isV2FlowYamlPath;
62
+ /**
63
+ * Push V2 flow YAML changes. Closes GH issue #3 for newo_v2 layout.
64
+ * Parses the V2 YAML, converts to V1-shaped FlowMetadata, and reuses the
65
+ * shared syncFlowMetadata routine that calls PATCH/POST/DELETE per child.
66
+ */
67
+ private pushV2FlowYamlUpdate;
57
68
  /**
58
69
  * Push a V2 skill update
59
70
  *
@@ -14,12 +14,13 @@
14
14
  * skills/{SkillIdn}.nsl|.nslg
15
15
  */
16
16
  import fs from 'fs-extra';
17
- import { listProjects, listAgents, listFlowSkills, listFlowEvents, listFlowStates, updateSkill, publishFlow, getProjectAttributes, getCustomerAttributes, listLibraries, updateLibrarySkill, } from '../../../api.js';
17
+ import { listProjects, listAgents, listFlowSkills, listFlowEvents, listFlowStates, updateSkill, publishFlow, getProjectAttributes, getCustomerAttributes, listLibraries, updateLibrarySkill, getFlow, } from '../../../api.js';
18
+ import { syncFlowMetadata, emptyFlowSyncCounts, totalFlowSyncOps, describeFlowSyncCounts } from '../../../sync/flow-metadata.js';
18
19
  import { ensureStateOnly, writeFileSafe, mapPath, } from '../../../fsutil.js';
19
20
  import { sha256, saveHashes, loadHashes } from '../../../hash.js';
20
21
  import { v2ImportVersionPath, v2ProjectYamlPath, v2AgentYamlPath, v2FlowYamlPath, v2SkillScriptPath, v2SkillRelativePath, v2ProjectAttributesPath, v2CustomerAttributesPath, v2AkbDir, v2AkbPath, v2LibraryYamlPath, v2LibrarySkillScriptPath, v2LibrarySkillRelativePath, } from '../../../format/paths-v2.js';
21
22
  import { V2_IMPORT_VERSION, } from '../../../format/types.js';
22
- import { generateV2FlowYaml, generateV2ProjectYaml, generateV2AgentYaml, buildV2InlineSkill, buildV2FlowEvent, buildV2StateField, } from '../../../format/v2-yaml.js';
23
+ import { generateV2FlowYaml, generateV2ProjectYaml, generateV2AgentYaml, buildV2InlineSkill, buildV2FlowEvent, buildV2StateField, parseV2FlowYaml, } from '../../../format/v2-yaml.js';
23
24
  import { isContentDifferent } from '../../../sync/skill-files.js';
24
25
  import yaml from 'js-yaml';
25
26
  import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
@@ -440,6 +441,14 @@ export class V2ProjectSyncStrategy {
440
441
  for (const change of changes) {
441
442
  try {
442
443
  if (change.operation === 'modified') {
444
+ // V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
445
+ // The flow YAML carries title, events, and state_fields inline, so
446
+ // changes there must sync to the platform like V1 metadata.yaml.
447
+ if (this.isV2FlowYamlPath(change.path)) {
448
+ const count = await this.pushV2FlowYamlUpdate(client, change, mapData, newHashes);
449
+ result.updated += count;
450
+ continue;
451
+ }
443
452
  // Detect if this is a library skill or flow skill by path
444
453
  const isLibrary = change.path.includes('/libraries/');
445
454
  const count = isLibrary
@@ -458,6 +467,103 @@ export class V2ProjectSyncStrategy {
458
467
  }
459
468
  return result;
460
469
  }
470
+ /**
471
+ * Recognize the V2 flow YAML location: .../agents/{agent}/flows/{flow}/{flow}.yaml
472
+ * Distinguishes it from skill scripts, library YAMLs, and attribute files.
473
+ */
474
+ isV2FlowYamlPath(p) {
475
+ const parts = p.split('/');
476
+ const file = parts[parts.length - 1];
477
+ if (!file || !file.endsWith('.yaml'))
478
+ return false;
479
+ // .../agents/{agent}/flows/{flow}/{flow}.yaml → last 5 parts:
480
+ // agents, {agent}, flows, {flow}, {flow}.yaml
481
+ if (parts.length < 5)
482
+ return false;
483
+ const flowsKeyword = parts[parts.length - 3];
484
+ const agentsKeyword = parts[parts.length - 5];
485
+ const flowFolder = parts[parts.length - 2] || '';
486
+ const stem = file.slice(0, -'.yaml'.length);
487
+ return flowsKeyword === 'flows' && agentsKeyword === 'agents' && stem === flowFolder;
488
+ }
489
+ /**
490
+ * Push V2 flow YAML changes. Closes GH issue #3 for newo_v2 layout.
491
+ * Parses the V2 YAML, converts to V1-shaped FlowMetadata, and reuses the
492
+ * shared syncFlowMetadata routine that calls PATCH/POST/DELETE per child.
493
+ */
494
+ async pushV2FlowYamlUpdate(client, change, mapData, newHashes) {
495
+ const parts = change.path.split('/');
496
+ const flowIdn = parts[parts.length - 2] || '';
497
+ const agentIdn = parts[parts.length - 4] || '';
498
+ const projectIdn = parts[parts.length - 6] || '';
499
+ const projectData = mapData.projects[projectIdn];
500
+ const agentData = projectData?.agents[agentIdn];
501
+ const flowData = agentData?.flows[flowIdn];
502
+ if (!flowData?.id) {
503
+ this.logger.warn(`[newo_v2] Flow YAML change but flow not in project map: ${projectIdn}/${agentIdn}/${flowIdn}`);
504
+ return 0;
505
+ }
506
+ const v2Flow = await parseV2FlowYaml(change.path);
507
+ // Convert V2 → V1-shaped FlowMetadata for the shared sync routine.
508
+ // V2 events lack `id` and `description`; we fill defaults so the shape
509
+ // matches FlowEvent[] / FlowState[] expected by syncFlowMetadata.
510
+ // Optional fields are omitted (not set to undefined) to satisfy
511
+ // exactOptionalPropertyTypes.
512
+ const localMeta = {
513
+ id: flowData.id,
514
+ idn: v2Flow.idn,
515
+ title: v2Flow.title,
516
+ description: v2Flow.description ?? '',
517
+ default_runner_type: v2Flow.default_runner_type || 'guidance',
518
+ default_model: {
519
+ provider_idn: v2Flow.default_provider_idn,
520
+ model_idn: v2Flow.default_model_idn,
521
+ },
522
+ events: (v2Flow.events || []).map((e) => {
523
+ const out = {
524
+ id: '',
525
+ idn: e.idn,
526
+ description: '',
527
+ skill_selector: e.skill_selector,
528
+ interrupt_mode: (e.interrupt_mode || 'queue'),
529
+ ...(e.skill_idn != null ? { skill_idn: e.skill_idn } : {}),
530
+ ...(e.state_idn != null ? { state_idn: e.state_idn } : {}),
531
+ ...(e.integration_idn != null ? { integration_idn: e.integration_idn } : {}),
532
+ ...(e.connector_idn != null ? { connector_idn: e.connector_idn } : {}),
533
+ };
534
+ return out;
535
+ }),
536
+ state_fields: (v2Flow.state_fields || []).map((s) => {
537
+ const out = {
538
+ id: '',
539
+ idn: s.idn,
540
+ title: s.title || s.idn,
541
+ scope: (s.scope || 'flow'),
542
+ ...(s.default_value != null ? { default_value: s.default_value } : {}),
543
+ };
544
+ return out;
545
+ }),
546
+ };
547
+ let remoteFlow = null;
548
+ try {
549
+ remoteFlow = await getFlow(client, flowData.id);
550
+ }
551
+ catch (error) {
552
+ this.logger.verbose(`[newo_v2] Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
553
+ }
554
+ const counts = emptyFlowSyncCounts();
555
+ await syncFlowMetadata(client, flowData.id, localMeta, remoteFlow, false, counts);
556
+ const total = totalFlowSyncOps(counts);
557
+ if (total > 0) {
558
+ this.logger.info(`[newo_v2] ↑ Flow ${flowIdn}: ${describeFlowSyncCounts(counts)}`);
559
+ }
560
+ for (const err of counts.errors) {
561
+ this.logger.warn(err);
562
+ }
563
+ const content = await fs.readFile(change.path, 'utf8');
564
+ newHashes[change.path] = sha256(content);
565
+ return total;
566
+ }
461
567
  /**
462
568
  * Push a V2 skill update
463
569
  *
@@ -562,6 +668,23 @@ export class V2ProjectSyncStrategy {
562
668
  for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
563
669
  // Flow skills
564
670
  for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
671
+ for (const [flowIdn, _flowData] of Object.entries(agentData.flows)) {
672
+ // V2 stores flow events / state_fields / title inline in the flow
673
+ // YAML. Detect changes here so push() can sync them (GH issue #3).
674
+ const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
675
+ if (await fs.pathExists(flowYamlPath)) {
676
+ const content = await fs.readFile(flowYamlPath, 'utf8');
677
+ const currentHash = sha256(content);
678
+ const storedHash = hashes[flowYamlPath];
679
+ if (storedHash !== currentHash) {
680
+ changes.push({
681
+ item: {},
682
+ operation: 'modified',
683
+ path: flowYamlPath
684
+ });
685
+ }
686
+ }
687
+ }
565
688
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
566
689
  for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
567
690
  const scriptPath = v2SkillScriptPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
@@ -7,6 +7,7 @@ import path from 'path';
7
7
  import fs from 'fs-extra';
8
8
  import yaml from 'js-yaml';
9
9
  import { patchYamlToPyyaml } from '../format/yaml-patch.js';
10
+ import { isJsonValueType, normalizeJsonValueForStorage, jsonValuesEqual } from './json-attr-utils.js';
10
11
  /**
11
12
  * Save customer attributes to YAML format and return content for hashing
12
13
  */
@@ -28,16 +29,23 @@ export async function saveCustomerAttributes(client, customer, verbose = false)
28
29
  if (attr.id) {
29
30
  idMapping[attr.idn] = attr.id;
30
31
  }
31
- // Special handling for complex JSON string values
32
+ // Coerce JSON-typed values to a STRING. The API can return the value
33
+ // as a parsed object for `value_type: json` attributes; if we let
34
+ // yaml.dump serialize that as a YAML structure the next push sends
35
+ // `{"value": {...}}` instead of `{"value": "..."}` and the Workflow
36
+ // Builder canvas breaks. See src/sync/json-attr-utils.ts for the
37
+ // full rationale.
32
38
  let processedValue = attr.value;
33
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
39
+ if (isJsonValueType(attr.value_type)) {
40
+ processedValue = normalizeJsonValueForStorage(attr.value);
41
+ }
42
+ else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
43
+ // Legacy: reformat array-of-objects JSON strings for readability
34
44
  try {
35
- // Parse and reformat JSON for better readability
36
45
  const parsed = JSON.parse(attr.value);
37
- processedValue = JSON.stringify(parsed, null, 0); // No extra spacing, but valid JSON
46
+ processedValue = JSON.stringify(parsed, null, 0); // compact, valid JSON
38
47
  }
39
48
  catch (e) {
40
- // Keep original if parsing fails
41
49
  processedValue = attr.value;
42
50
  }
43
51
  }
@@ -114,9 +122,13 @@ export async function saveProjectAttributes(client, customer, projectId, project
114
122
  if (attr.id) {
115
123
  idMapping[attr.idn] = attr.id;
116
124
  }
117
- // Special handling for complex JSON string values
125
+ // Coerce JSON-typed values to a STRING. See json-attr-utils.ts for
126
+ // why this matters (Workflow Builder canvas blank-screen bug).
118
127
  let processedValue = attr.value;
119
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
128
+ if (isJsonValueType(attr.value_type)) {
129
+ processedValue = normalizeJsonValueForStorage(attr.value);
130
+ }
131
+ else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
120
132
  try {
121
133
  const parsed = JSON.parse(attr.value);
122
134
  processedValue = JSON.stringify(parsed, null, 0);
@@ -243,17 +255,31 @@ export async function pushProjectAttributes(client, customer, projectId, project
243
255
  }
244
256
  // Value type is already parsed (we removed !enum tags above)
245
257
  const valueType = localAttr.value_type;
246
- // Check if value changed (use ?? to preserve 0, false, empty string)
247
- const localValue = String(localAttr.value ?? '');
248
- const remoteValue = String(remoteAttr.value ?? '');
249
- if (localValue !== remoteValue) {
258
+ const isJson = isJsonValueType(valueType);
259
+ // Check if value changed.
260
+ // For JSON-typed values, compare canonical (compact) JSON so that
261
+ // pretty- vs compact-printed forms don't register as changes and
262
+ // string vs object representations compare equal. For everything
263
+ // else, fall back to the existing String() comparison (which still
264
+ // preserves 0, false, "" via ??).
265
+ const valuesAreEqual = isJson
266
+ ? jsonValuesEqual(localAttr.value, remoteAttr.value)
267
+ : String(localAttr.value ?? '') === String(remoteAttr.value ?? '');
268
+ if (!valuesAreEqual) {
250
269
  if (verbose)
251
270
  console.log(` 🔄 Updating project attribute: ${localAttr.idn}`);
252
271
  try {
272
+ // Always send JSON-typed values as a STRING. If the API or our
273
+ // YAML loader handed us an object, the platform stores it
274
+ // differently from the original string and the Workflow Builder
275
+ // canvas blanks out.
276
+ const valueToSend = isJson
277
+ ? normalizeJsonValueForStorage(localAttr.value)
278
+ : localAttr.value;
253
279
  const attributeToUpdate = {
254
280
  id: attributeId,
255
281
  idn: localAttr.idn,
256
- value: localAttr.value,
282
+ value: valueToSend,
257
283
  title: localAttr.title,
258
284
  description: localAttr.description,
259
285
  group: localAttr.group,