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 +8 -1
- package/dist/domain/strategies/sync/AttributeSyncStrategy.js +38 -8
- package/dist/sync/attributes.js +38 -12
- package/dist/sync/json-attr-utils.d.ts +67 -0
- package/dist/sync/json-attr-utils.js +98 -0
- package/package.json +1 -1
- package/src/domain/strategies/sync/AttributeSyncStrategy.ts +45 -8
- package/src/sync/attributes.ts +43 -14
- package/src/sync/json-attr-utils.ts +95 -0
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.
|
|
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
|
-
//
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
320
|
+
...localAttr,
|
|
321
|
+
value: valueToSend,
|
|
322
|
+
id: attributeId
|
|
293
323
|
});
|
|
294
324
|
updatedCount++;
|
|
295
325
|
this.logger.info(` ✓ Updated project attribute: ${projectIdn}/${localAttr.idn}`);
|
package/dist/sync/attributes.js
CHANGED
|
@@ -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
|
-
//
|
|
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 (
|
|
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); //
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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:
|
|
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.
|
|
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
|
-
//
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
440
|
+
...localAttr,
|
|
441
|
+
value: valueToSend,
|
|
442
|
+
id: attributeId
|
|
406
443
|
});
|
|
407
444
|
updatedCount++;
|
|
408
445
|
this.logger.info(` ✓ Updated project attribute: ${projectIdn}/${localAttr.idn}`);
|
package/src/sync/attributes.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 (
|
|
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); //
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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:
|
|
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
|
+
}
|