newo 3.7.0 → 3.7.1

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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.7.1] - 2026-04-29
11
+
12
+ ### Fixed
13
+
14
+ - **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`.
15
+
10
16
  ## [3.7.0] - 2026-04-23
11
17
 
12
18
  ### Added
@@ -1033,7 +1039,8 @@ Another Item: $Price [Modifiers: modifier3]
1033
1039
  - GitHub Actions CI/CD integration
1034
1040
  - Robust authentication with token refresh
1035
1041
 
1036
- [Unreleased]: https://github.com/sabbah13/newo-cli/compare/v3.3.0...HEAD
1042
+ [Unreleased]: https://github.com/sabbah13/newo-cli/compare/v3.7.1...HEAD
1043
+ [3.7.1]: https://github.com/sabbah13/newo-cli/compare/v3.7.0...v3.7.1
1037
1044
  [3.3.0]: https://github.com/sabbah13/newo-cli/compare/v3.2.0...v3.3.0
1038
1045
  [3.2.0]: https://github.com/sabbah13/newo-cli/compare/v3.1.0...v3.2.0
1039
1046
  [3.1.0]: https://github.com/sabbah13/newo-cli/compare/v3.0.0...v3.1.0
@@ -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}`);
@@ -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,
@@ -0,0 +1,67 @@
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
+ * True if the attribute is a JSON-typed attribute (case- and
31
+ * format-insensitive: handles `json`, `JSON`, `AttributeValueTypes.json`,
32
+ * `ValueType.JSON`, etc.).
33
+ */
34
+ export declare function isJsonValueType(valueType: unknown): boolean;
35
+ /**
36
+ * Coerce a JSON-typed attribute's value to a STRING suitable for storage
37
+ * in attributes.yaml and for sending to the platform.
38
+ *
39
+ * - `null` / `undefined` → `''`
40
+ * - object → compact JSON string (`JSON.stringify(value)`)
41
+ * - string → returned as-is (we trust the platform's existing format)
42
+ * - other → `String(value)`
43
+ *
44
+ * We deliberately do NOT re-format string values, even when they look
45
+ * like JSON. Many existing canvases are stored pretty-printed and
46
+ * reformatting would create huge spurious diffs in users' repos.
47
+ */
48
+ export declare function normalizeJsonValueForStorage(value: unknown): string;
49
+ /**
50
+ * Canonical comparison for JSON-typed attribute values.
51
+ *
52
+ * Returns the canonical form (compact JSON if parseable, otherwise the
53
+ * raw string). Use this on both sides of a comparison so that pretty- vs
54
+ * compact-printed JSON does not register as a change, and so that an
55
+ * object on one side equals its stringified form on the other side.
56
+ */
57
+ export declare function canonicalJsonValue(value: unknown): string;
58
+ /**
59
+ * True if two JSON-typed attribute values are semantically equal.
60
+ *
61
+ * Handles the four mismatched representations that can occur during a
62
+ * pull/push cycle:
63
+ * string vs string (different whitespace/indent), object vs string,
64
+ * string vs object, object vs object.
65
+ */
66
+ export declare function jsonValuesEqual(a: unknown, b: unknown): boolean;
67
+ //# sourceMappingURL=json-attr-utils.d.ts.map
@@ -0,0 +1,98 @@
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
+ * True if the attribute is a JSON-typed attribute (case- and
31
+ * format-insensitive: handles `json`, `JSON`, `AttributeValueTypes.json`,
32
+ * `ValueType.JSON`, etc.).
33
+ */
34
+ export function isJsonValueType(valueType) {
35
+ if (typeof valueType !== 'string')
36
+ return false;
37
+ const lower = valueType.toLowerCase();
38
+ return lower === 'json' || lower.endsWith('.json');
39
+ }
40
+ /**
41
+ * Coerce a JSON-typed attribute's value to a STRING suitable for storage
42
+ * in attributes.yaml and for sending to the platform.
43
+ *
44
+ * - `null` / `undefined` → `''`
45
+ * - object → compact JSON string (`JSON.stringify(value)`)
46
+ * - string → returned as-is (we trust the platform's existing format)
47
+ * - other → `String(value)`
48
+ *
49
+ * We deliberately do NOT re-format string values, even when they look
50
+ * like JSON. Many existing canvases are stored pretty-printed and
51
+ * reformatting would create huge spurious diffs in users' repos.
52
+ */
53
+ export function normalizeJsonValueForStorage(value) {
54
+ if (value == null)
55
+ return '';
56
+ if (typeof value === 'string')
57
+ return value;
58
+ if (typeof value === 'object') {
59
+ try {
60
+ return JSON.stringify(value);
61
+ }
62
+ catch {
63
+ return String(value);
64
+ }
65
+ }
66
+ return String(value);
67
+ }
68
+ /**
69
+ * Canonical comparison for JSON-typed attribute values.
70
+ *
71
+ * Returns the canonical form (compact JSON if parseable, otherwise the
72
+ * raw string). Use this on both sides of a comparison so that pretty- vs
73
+ * compact-printed JSON does not register as a change, and so that an
74
+ * object on one side equals its stringified form on the other side.
75
+ */
76
+ export function canonicalJsonValue(value) {
77
+ const stringified = normalizeJsonValueForStorage(value);
78
+ if (stringified === '')
79
+ return '';
80
+ try {
81
+ return JSON.stringify(JSON.parse(stringified));
82
+ }
83
+ catch {
84
+ return stringified;
85
+ }
86
+ }
87
+ /**
88
+ * True if two JSON-typed attribute values are semantically equal.
89
+ *
90
+ * Handles the four mismatched representations that can occur during a
91
+ * pull/push cycle:
92
+ * string vs string (different whitespace/indent), object vs string,
93
+ * string vs object, object vs object.
94
+ */
95
+ export function jsonValuesEqual(a, b) {
96
+ return canonicalJsonValue(a) === canonicalJsonValue(b);
97
+ }
98
+ //# sourceMappingURL=json-attr-utils.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
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": {
@@ -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}`);
@@ -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,
@@ -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
+ }