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/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);
@@ -39,6 +39,11 @@ import {
39
39
  customerAttributesMapPath
40
40
  } from '../../../fsutil.js';
41
41
  import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
42
+ import {
43
+ isJsonValueType,
44
+ normalizeJsonValueForStorage,
45
+ jsonValuesEqual
46
+ } from '../../../sync/json-attr-utils.js';
42
47
  import { sha256, saveHashes, loadHashes } from '../../../hash.js';
43
48
 
44
49
  /**
@@ -220,8 +225,14 @@ export class AttributeSyncStrategy implements ISyncStrategy<CustomerAttributesRe
220
225
  private cleanAttribute(attr: CustomerAttribute): CustomerAttribute {
221
226
  let processedValue = attr.value;
222
227
 
223
- // Handle JSON string values
224
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
228
+ // Coerce JSON-typed values to a STRING. The API may return parsed
229
+ // objects for `value_type: json`; if we let yaml.dump turn them into
230
+ // YAML structures, the next push sends an object and the Workflow
231
+ // Builder canvas blanks out. See src/sync/json-attr-utils.ts.
232
+ if (isJsonValueType(attr.value_type)) {
233
+ processedValue = normalizeJsonValueForStorage(attr.value);
234
+ } else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
235
+ // Legacy: reformat array-of-objects JSON strings for readability
225
236
  try {
226
237
  const parsed = JSON.parse(attr.value);
227
238
  processedValue = JSON.stringify(parsed, null, 0);
@@ -342,10 +353,24 @@ export class AttributeSyncStrategy implements ISyncStrategy<CustomerAttributesRe
342
353
  const remoteAttr = remoteMap.get(localAttr.idn);
343
354
  if (!remoteAttr) continue;
344
355
 
345
- if (String(localAttr.value) !== String(remoteAttr.value)) {
356
+ // For JSON-typed attrs, compare canonical JSON (handles
357
+ // pretty/compact and string/object differences). Always send the
358
+ // value as a STRING so the platform stores the canvas the way the
359
+ // Workflow Builder expects to read it back.
360
+ const isJson = isJsonValueType(localAttr.value_type);
361
+ const valuesAreEqual = isJson
362
+ ? jsonValuesEqual(localAttr.value, remoteAttr.value)
363
+ : String(localAttr.value) === String(remoteAttr.value);
364
+
365
+ if (!valuesAreEqual) {
366
+ const valueToSend = isJson
367
+ ? normalizeJsonValueForStorage(localAttr.value)
368
+ : localAttr.value;
369
+
346
370
  await updateCustomerAttribute(client, {
347
- id: attributeId,
348
- ...localAttr
371
+ ...localAttr,
372
+ value: valueToSend,
373
+ id: attributeId
349
374
  });
350
375
  updatedCount++;
351
376
  this.logger.info(` ✓ Updated customer attribute: ${localAttr.idn}`);
@@ -399,10 +424,22 @@ export class AttributeSyncStrategy implements ISyncStrategy<CustomerAttributesRe
399
424
  const remoteAttr = remoteMap.get(localAttr.idn);
400
425
  if (!remoteAttr) continue;
401
426
 
402
- if (String(localAttr.value) !== String(remoteAttr.value)) {
427
+ // Same canonical-JSON / always-string-on-push policy as customer
428
+ // attributes (see pushCustomerAttributes for rationale).
429
+ const isJson = isJsonValueType(localAttr.value_type);
430
+ const valuesAreEqual = isJson
431
+ ? jsonValuesEqual(localAttr.value, remoteAttr.value)
432
+ : String(localAttr.value) === String(remoteAttr.value);
433
+
434
+ if (!valuesAreEqual) {
435
+ const valueToSend = isJson
436
+ ? normalizeJsonValueForStorage(localAttr.value)
437
+ : localAttr.value;
438
+
403
439
  await updateProjectAttribute(client, project.id, {
404
- id: attributeId,
405
- ...localAttr
440
+ ...localAttr,
441
+ value: valueToSend,
442
+ id: attributeId
406
443
  });
407
444
  updatedCount++;
408
445
  this.logger.info(` ✓ Updated project attribute: ${projectIdn}/${localAttr.idn}`);
@@ -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
 
@@ -33,6 +33,7 @@ import type {
33
33
  Skill,
34
34
  FlowEvent,
35
35
  FlowState,
36
+ FlowMetadata,
36
37
  ProjectData,
37
38
  ProjectMap,
38
39
  SkillMetadata
@@ -51,7 +52,14 @@ import {
51
52
  getCustomerAttributes,
52
53
  listLibraries,
53
54
  updateLibrarySkill,
55
+ getFlow,
54
56
  } from '../../../api.js';
57
+ import {
58
+ syncFlowMetadata,
59
+ emptyFlowSyncCounts,
60
+ totalFlowSyncOps,
61
+ describeFlowSyncCounts
62
+ } from '../../../sync/flow-metadata.js';
55
63
  import type { LibraryResponse } from '../../../api.js';
56
64
  import {
57
65
  ensureStateOnly,
@@ -84,7 +92,10 @@ import {
84
92
  buildV2InlineSkill,
85
93
  buildV2FlowEvent,
86
94
  buildV2StateField,
95
+ parseV2FlowYaml,
87
96
  type V2InlineSkill,
97
+ type V2FlowEvent,
98
+ type V2StateField,
88
99
  } from '../../../format/v2-yaml.js';
89
100
  import { isContentDifferent } from '../../../sync/skill-files.js';
90
101
  import yaml from 'js-yaml';
@@ -648,6 +659,15 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
648
659
  for (const change of changes) {
649
660
  try {
650
661
  if (change.operation === 'modified') {
662
+ // V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
663
+ // The flow YAML carries title, events, and state_fields inline, so
664
+ // changes there must sync to the platform like V1 metadata.yaml.
665
+ if (this.isV2FlowYamlPath(change.path)) {
666
+ const count = await this.pushV2FlowYamlUpdate(client, change, mapData, newHashes);
667
+ result.updated += count;
668
+ continue;
669
+ }
670
+
651
671
  // Detect if this is a library skill or flow skill by path
652
672
  const isLibrary = change.path.includes('/libraries/');
653
673
  const count = isLibrary
@@ -671,6 +691,115 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
671
691
  return result;
672
692
  }
673
693
 
694
+ /**
695
+ * Recognize the V2 flow YAML location: .../agents/{agent}/flows/{flow}/{flow}.yaml
696
+ * Distinguishes it from skill scripts, library YAMLs, and attribute files.
697
+ */
698
+ private isV2FlowYamlPath(p: string): boolean {
699
+ const parts = p.split('/');
700
+ const file = parts[parts.length - 1];
701
+ if (!file || !file.endsWith('.yaml')) return false;
702
+ // .../agents/{agent}/flows/{flow}/{flow}.yaml → last 5 parts:
703
+ // agents, {agent}, flows, {flow}, {flow}.yaml
704
+ if (parts.length < 5) return false;
705
+ const flowsKeyword = parts[parts.length - 3];
706
+ const agentsKeyword = parts[parts.length - 5];
707
+ const flowFolder = parts[parts.length - 2] || '';
708
+ const stem = file.slice(0, -'.yaml'.length);
709
+ return flowsKeyword === 'flows' && agentsKeyword === 'agents' && stem === flowFolder;
710
+ }
711
+
712
+ /**
713
+ * Push V2 flow YAML changes. Closes GH issue #3 for newo_v2 layout.
714
+ * Parses the V2 YAML, converts to V1-shaped FlowMetadata, and reuses the
715
+ * shared syncFlowMetadata routine that calls PATCH/POST/DELETE per child.
716
+ */
717
+ private async pushV2FlowYamlUpdate(
718
+ client: AxiosInstance,
719
+ change: ChangeItem<LocalProjectData>,
720
+ mapData: ProjectMap,
721
+ newHashes: HashStore
722
+ ): Promise<number> {
723
+ const parts = change.path.split('/');
724
+ const flowIdn = parts[parts.length - 2] || '';
725
+ const agentIdn = parts[parts.length - 4] || '';
726
+ const projectIdn = parts[parts.length - 6] || '';
727
+
728
+ const projectData = mapData.projects[projectIdn];
729
+ const agentData = projectData?.agents[agentIdn];
730
+ const flowData = agentData?.flows[flowIdn];
731
+
732
+ if (!flowData?.id) {
733
+ this.logger.warn(`[newo_v2] Flow YAML change but flow not in project map: ${projectIdn}/${agentIdn}/${flowIdn}`);
734
+ return 0;
735
+ }
736
+
737
+ const v2Flow = await parseV2FlowYaml(change.path);
738
+
739
+ // Convert V2 → V1-shaped FlowMetadata for the shared sync routine.
740
+ // V2 events lack `id` and `description`; we fill defaults so the shape
741
+ // matches FlowEvent[] / FlowState[] expected by syncFlowMetadata.
742
+ // Optional fields are omitted (not set to undefined) to satisfy
743
+ // exactOptionalPropertyTypes.
744
+ const localMeta: FlowMetadata = {
745
+ id: flowData.id,
746
+ idn: v2Flow.idn,
747
+ title: v2Flow.title,
748
+ description: v2Flow.description ?? '',
749
+ default_runner_type: (v2Flow.default_runner_type as 'guidance' | 'nsl') || 'guidance',
750
+ default_model: {
751
+ provider_idn: v2Flow.default_provider_idn,
752
+ model_idn: v2Flow.default_model_idn,
753
+ },
754
+ events: (v2Flow.events || []).map((e: V2FlowEvent) => {
755
+ const out: FlowEvent = {
756
+ id: '',
757
+ idn: e.idn,
758
+ description: '',
759
+ skill_selector: e.skill_selector as 'first' | 'last' | 'random' | 'all',
760
+ interrupt_mode: (e.interrupt_mode || 'queue') as 'allow' | 'deny' | 'queue',
761
+ ...(e.skill_idn != null ? { skill_idn: e.skill_idn } : {}),
762
+ ...(e.state_idn != null ? { state_idn: e.state_idn } : {}),
763
+ ...(e.integration_idn != null ? { integration_idn: e.integration_idn } : {}),
764
+ ...(e.connector_idn != null ? { connector_idn: e.connector_idn } : {}),
765
+ };
766
+ return out;
767
+ }),
768
+ state_fields: (v2Flow.state_fields || []).map((s: V2StateField) => {
769
+ const out: FlowState = {
770
+ id: '',
771
+ idn: s.idn,
772
+ title: s.title || s.idn,
773
+ scope: (s.scope || 'flow') as 'flow' | 'agent' | 'project' | 'global',
774
+ ...(s.default_value != null ? { default_value: s.default_value } : {}),
775
+ };
776
+ return out;
777
+ }),
778
+ };
779
+
780
+ let remoteFlow = null;
781
+ try {
782
+ remoteFlow = await getFlow(client, flowData.id);
783
+ } catch (error: any) {
784
+ this.logger.verbose(`[newo_v2] Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
785
+ }
786
+
787
+ const counts = emptyFlowSyncCounts();
788
+ await syncFlowMetadata(client, flowData.id, localMeta, remoteFlow, false, counts);
789
+
790
+ const total = totalFlowSyncOps(counts);
791
+ if (total > 0) {
792
+ this.logger.info(`[newo_v2] ↑ Flow ${flowIdn}: ${describeFlowSyncCounts(counts)}`);
793
+ }
794
+ for (const err of counts.errors) {
795
+ this.logger.warn(err);
796
+ }
797
+
798
+ const content = await fs.readFile(change.path, 'utf8');
799
+ newHashes[change.path] = sha256(content);
800
+ return total;
801
+ }
802
+
674
803
  /**
675
804
  * Push a V2 skill update
676
805
  *
@@ -801,6 +930,24 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
801
930
  for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
802
931
  // Flow skills
803
932
  for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
933
+ for (const [flowIdn, _flowData] of Object.entries(agentData.flows)) {
934
+ // V2 stores flow events / state_fields / title inline in the flow
935
+ // YAML. Detect changes here so push() can sync them (GH issue #3).
936
+ const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
937
+ if (await fs.pathExists(flowYamlPath)) {
938
+ const content = await fs.readFile(flowYamlPath, 'utf8');
939
+ const currentHash = sha256(content);
940
+ const storedHash = hashes[flowYamlPath];
941
+ if (storedHash !== currentHash) {
942
+ changes.push({
943
+ item: {} as LocalProjectData,
944
+ operation: 'modified',
945
+ path: flowYamlPath
946
+ });
947
+ }
948
+ }
949
+ }
950
+
804
951
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
805
952
  for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
806
953
  const scriptPath = v2SkillScriptPath(
@@ -12,6 +12,11 @@ import path from 'path';
12
12
  import fs from 'fs-extra';
13
13
  import yaml from 'js-yaml';
14
14
  import { patchYamlToPyyaml } from '../format/yaml-patch.js';
15
+ import {
16
+ isJsonValueType,
17
+ normalizeJsonValueForStorage,
18
+ jsonValuesEqual
19
+ } from './json-attr-utils.js';
15
20
  import type { AxiosInstance } from 'axios';
16
21
  import type { CustomerConfig } from '../types.js';
17
22
 
@@ -43,15 +48,21 @@ export async function saveCustomerAttributes(
43
48
  idMapping[attr.idn] = attr.id;
44
49
  }
45
50
 
46
- // Special handling for complex JSON string values
51
+ // Coerce JSON-typed values to a STRING. The API can return the value
52
+ // as a parsed object for `value_type: json` attributes; if we let
53
+ // yaml.dump serialize that as a YAML structure the next push sends
54
+ // `{"value": {...}}` instead of `{"value": "..."}` and the Workflow
55
+ // Builder canvas breaks. See src/sync/json-attr-utils.ts for the
56
+ // full rationale.
47
57
  let processedValue = attr.value;
48
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
58
+ if (isJsonValueType(attr.value_type)) {
59
+ processedValue = normalizeJsonValueForStorage(attr.value);
60
+ } else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
61
+ // Legacy: reformat array-of-objects JSON strings for readability
49
62
  try {
50
- // Parse and reformat JSON for better readability
51
63
  const parsed = JSON.parse(attr.value);
52
- processedValue = JSON.stringify(parsed, null, 0); // No extra spacing, but valid JSON
64
+ processedValue = JSON.stringify(parsed, null, 0); // compact, valid JSON
53
65
  } catch (e) {
54
- // Keep original if parsing fails
55
66
  processedValue = attr.value;
56
67
  }
57
68
  }
@@ -145,9 +156,12 @@ export async function saveProjectAttributes(
145
156
  idMapping[attr.idn] = attr.id;
146
157
  }
147
158
 
148
- // Special handling for complex JSON string values
159
+ // Coerce JSON-typed values to a STRING. See json-attr-utils.ts for
160
+ // why this matters (Workflow Builder canvas blank-screen bug).
149
161
  let processedValue = attr.value;
150
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
162
+ if (isJsonValueType(attr.value_type)) {
163
+ processedValue = normalizeJsonValueForStorage(attr.value);
164
+ } else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
151
165
  try {
152
166
  const parsed = JSON.parse(attr.value);
153
167
  processedValue = JSON.stringify(parsed, null, 0);
@@ -297,19 +311,34 @@ export async function pushProjectAttributes(
297
311
 
298
312
  // Value type is already parsed (we removed !enum tags above)
299
313
  const valueType = localAttr.value_type;
300
-
301
- // Check if value changed (use ?? to preserve 0, false, empty string)
302
- const localValue = String(localAttr.value ?? '');
303
- const remoteValue = String(remoteAttr.value ?? '');
304
-
305
- if (localValue !== remoteValue) {
314
+ const isJson = isJsonValueType(valueType);
315
+
316
+ // Check if value changed.
317
+ // For JSON-typed values, compare canonical (compact) JSON so that
318
+ // pretty- vs compact-printed forms don't register as changes and
319
+ // string vs object representations compare equal. For everything
320
+ // else, fall back to the existing String() comparison (which still
321
+ // preserves 0, false, "" via ??).
322
+ const valuesAreEqual = isJson
323
+ ? jsonValuesEqual(localAttr.value, remoteAttr.value)
324
+ : String(localAttr.value ?? '') === String(remoteAttr.value ?? '');
325
+
326
+ if (!valuesAreEqual) {
306
327
  if (verbose) console.log(` 🔄 Updating project attribute: ${localAttr.idn}`);
307
328
 
308
329
  try {
330
+ // Always send JSON-typed values as a STRING. If the API or our
331
+ // YAML loader handed us an object, the platform stores it
332
+ // differently from the original string and the Workflow Builder
333
+ // canvas blanks out.
334
+ const valueToSend = isJson
335
+ ? normalizeJsonValueForStorage(localAttr.value)
336
+ : localAttr.value;
337
+
309
338
  const attributeToUpdate = {
310
339
  id: attributeId,
311
340
  idn: localAttr.idn,
312
- value: localAttr.value,
341
+ value: valueToSend,
313
342
  title: localAttr.title,
314
343
  description: localAttr.description,
315
344
  group: localAttr.group,