newo 3.7.1 → 3.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ import { listFlowEvents, listFlowStates, createFlowEvent, updateFlowEvent, deleteFlowEvent, createFlowState, updateFlowState, deleteFlowState, updateFlow } from '../api.js';
2
+ export function emptyFlowSyncCounts() {
3
+ return {
4
+ flowsUpdated: 0,
5
+ eventsCreated: 0,
6
+ eventsUpdated: 0,
7
+ eventsDeleted: 0,
8
+ statesCreated: 0,
9
+ statesUpdated: 0,
10
+ statesDeleted: 0,
11
+ errors: []
12
+ };
13
+ }
14
+ /**
15
+ * True when remote FlowEvent fields differ from what the local metadata says.
16
+ * We only compare semantic fields the platform stores - `id` is platform-owned.
17
+ */
18
+ export function flowEventDiffers(local, remote) {
19
+ return (normalizeStr(local.description) !== normalizeStr(remote.description) ||
20
+ normalizeStr(local.skill_selector) !== normalizeStr(remote.skill_selector) ||
21
+ normalizeStr(local.skill_idn) !== normalizeStr(remote.skill_idn) ||
22
+ normalizeStr(local.state_idn) !== normalizeStr(remote.state_idn) ||
23
+ normalizeStr(local.interrupt_mode) !== normalizeStr(remote.interrupt_mode) ||
24
+ normalizeStr(local.integration_idn) !== normalizeStr(remote.integration_idn) ||
25
+ normalizeStr(local.connector_idn) !== normalizeStr(remote.connector_idn));
26
+ }
27
+ export function flowStateDiffers(local, remote) {
28
+ return (normalizeStr(local.title) !== normalizeStr(remote.title) ||
29
+ normalizeStr(local.default_value) !== normalizeStr(remote.default_value) ||
30
+ normalizeStr(local.scope) !== normalizeStr(remote.scope));
31
+ }
32
+ /**
33
+ * Normalizes nullable/undefined string fields so YAML round-trips don't
34
+ * register as differences. `null`, `undefined`, and missing all collapse to ''.
35
+ */
36
+ function normalizeStr(value) {
37
+ if (value === null || value === undefined)
38
+ return '';
39
+ return String(value);
40
+ }
41
+ function buildEventCreateRequest(local) {
42
+ return {
43
+ idn: local.idn,
44
+ description: local.description ?? '',
45
+ skill_selector: local.skill_selector,
46
+ ...(local.skill_idn != null ? { skill_idn: local.skill_idn } : {}),
47
+ state_idn: local.state_idn ?? null,
48
+ interrupt_mode: local.interrupt_mode,
49
+ integration_idn: local.integration_idn ?? '',
50
+ connector_idn: local.connector_idn ?? ''
51
+ };
52
+ }
53
+ function buildEventUpdateRequest(local) {
54
+ return {
55
+ idn: local.idn,
56
+ description: local.description ?? '',
57
+ skill_selector: local.skill_selector,
58
+ skill_idn: local.skill_idn ?? null,
59
+ state_idn: local.state_idn ?? null,
60
+ interrupt_mode: local.interrupt_mode,
61
+ integration_idn: local.integration_idn ?? null,
62
+ connector_idn: local.connector_idn ?? null
63
+ };
64
+ }
65
+ function buildStateCreateRequest(local) {
66
+ const req = {
67
+ title: local.title || local.idn,
68
+ idn: local.idn,
69
+ scope: local.scope
70
+ };
71
+ if (local.default_value != null) {
72
+ req.default_value = local.default_value;
73
+ }
74
+ return req;
75
+ }
76
+ function buildStateUpdateRequest(local) {
77
+ const req = {
78
+ title: local.title || local.idn,
79
+ idn: local.idn,
80
+ scope: local.scope
81
+ };
82
+ if (local.default_value != null) {
83
+ req.default_value = local.default_value;
84
+ }
85
+ return req;
86
+ }
87
+ function shouldUpdateFlow(local, remote) {
88
+ return (normalizeStr(local.title) !== normalizeStr(remote.title) ||
89
+ normalizeStr(local.description) !== normalizeStr(remote.description) ||
90
+ normalizeStr(local.default_runner_type) !== normalizeStr(remote.default_runner_type));
91
+ }
92
+ /**
93
+ * Reconcile one flow's metadata with the platform.
94
+ *
95
+ * @param client authenticated Axios client
96
+ * @param flowId platform flow ID (UUID)
97
+ * @param local parsed FlowMetadata from the customer's local YAML
98
+ * @param remoteFlow flow data fetched from GET /flows/{id} - if null,
99
+ * flow-level updates are skipped (still syncs children).
100
+ * Pass null when caller already knows the GET endpoint
101
+ * will 404 (e.g. legacy data) or wants children-only.
102
+ * @param verbose when true, emits per-operation log lines
103
+ * @param counts shared counter mutated in place across multiple flows
104
+ */
105
+ export async function syncFlowMetadata(client, flowId, local, remoteFlow, verbose, counts) {
106
+ // 1. Flow-level fields (title, description, runner type)
107
+ if (remoteFlow && shouldUpdateFlow(local, remoteFlow)) {
108
+ try {
109
+ const updateRequest = {
110
+ idn: local.idn,
111
+ title: local.title,
112
+ description: local.description ?? '',
113
+ default_runner_type: local.default_runner_type,
114
+ default_model: local.default_model
115
+ };
116
+ await updateFlow(client, flowId, updateRequest);
117
+ counts.flowsUpdated++;
118
+ if (verbose) {
119
+ console.log(` ↑ Updated flow metadata: ${local.idn} (title: "${remoteFlow.title}" → "${local.title}")`);
120
+ }
121
+ }
122
+ catch (error) {
123
+ const msg = error instanceof Error ? error.message : String(error);
124
+ counts.errors.push(`Failed to update flow ${local.idn}: ${msg}`);
125
+ console.error(` ❌ Failed to update flow ${local.idn}: ${msg}`);
126
+ }
127
+ }
128
+ // 2. Events
129
+ let remoteEvents = [];
130
+ try {
131
+ remoteEvents = await listFlowEvents(client, flowId);
132
+ }
133
+ catch (error) {
134
+ const msg = error instanceof Error ? error.message : String(error);
135
+ counts.errors.push(`Failed to list events for flow ${local.idn}: ${msg}`);
136
+ return;
137
+ }
138
+ const localEvents = local.events ?? [];
139
+ const remoteByIdn = new Map(remoteEvents.map(e => [e.idn, e]));
140
+ const localByIdn = new Map(localEvents.map(e => [e.idn, e]));
141
+ // Create or update events present locally
142
+ for (const localEvent of localEvents) {
143
+ const remote = remoteByIdn.get(localEvent.idn);
144
+ if (!remote) {
145
+ // Create
146
+ try {
147
+ await createFlowEvent(client, flowId, buildEventCreateRequest(localEvent));
148
+ counts.eventsCreated++;
149
+ if (verbose)
150
+ console.log(` ↑ Created event: ${local.idn}/${localEvent.idn}`);
151
+ }
152
+ catch (error) {
153
+ const msg = error instanceof Error ? error.message : String(error);
154
+ counts.errors.push(`Failed to create event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
155
+ console.error(` ❌ Failed to create event ${localEvent.idn}: ${msg}`);
156
+ }
157
+ }
158
+ else if (flowEventDiffers(localEvent, remote)) {
159
+ try {
160
+ await updateFlowEvent(client, remote.id, buildEventUpdateRequest(localEvent));
161
+ counts.eventsUpdated++;
162
+ if (verbose)
163
+ console.log(` ↑ Updated event: ${local.idn}/${localEvent.idn}`);
164
+ }
165
+ catch (error) {
166
+ const msg = error instanceof Error ? error.message : String(error);
167
+ counts.errors.push(`Failed to update event ${localEvent.idn} in flow ${local.idn}: ${msg}`);
168
+ console.error(` ❌ Failed to update event ${localEvent.idn}: ${msg}`);
169
+ }
170
+ }
171
+ }
172
+ // Delete events present remotely but missing locally
173
+ for (const remoteEvent of remoteEvents) {
174
+ if (!localByIdn.has(remoteEvent.idn)) {
175
+ try {
176
+ await deleteFlowEvent(client, remoteEvent.id);
177
+ counts.eventsDeleted++;
178
+ if (verbose)
179
+ console.log(` ↑ Deleted event: ${local.idn}/${remoteEvent.idn}`);
180
+ }
181
+ catch (error) {
182
+ const msg = error instanceof Error ? error.message : String(error);
183
+ counts.errors.push(`Failed to delete event ${remoteEvent.idn} in flow ${local.idn}: ${msg}`);
184
+ console.error(` ❌ Failed to delete event ${remoteEvent.idn}: ${msg}`);
185
+ }
186
+ }
187
+ }
188
+ // 3. State fields
189
+ let remoteStates = [];
190
+ try {
191
+ remoteStates = await listFlowStates(client, flowId);
192
+ }
193
+ catch (error) {
194
+ const msg = error instanceof Error ? error.message : String(error);
195
+ counts.errors.push(`Failed to list states for flow ${local.idn}: ${msg}`);
196
+ return;
197
+ }
198
+ const localStates = local.state_fields ?? [];
199
+ const remoteStatesByIdn = new Map(remoteStates.map(s => [s.idn, s]));
200
+ const localStatesByIdn = new Map(localStates.map(s => [s.idn, s]));
201
+ for (const localState of localStates) {
202
+ const remote = remoteStatesByIdn.get(localState.idn);
203
+ if (!remote) {
204
+ try {
205
+ await createFlowState(client, flowId, buildStateCreateRequest(localState));
206
+ counts.statesCreated++;
207
+ if (verbose)
208
+ console.log(` ↑ Created state: ${local.idn}/${localState.idn}`);
209
+ }
210
+ catch (error) {
211
+ const msg = error instanceof Error ? error.message : String(error);
212
+ counts.errors.push(`Failed to create state ${localState.idn} in flow ${local.idn}: ${msg}`);
213
+ console.error(` ❌ Failed to create state ${localState.idn}: ${msg}`);
214
+ }
215
+ }
216
+ else if (flowStateDiffers(localState, remote)) {
217
+ try {
218
+ await updateFlowState(client, remote.id, buildStateUpdateRequest(localState));
219
+ counts.statesUpdated++;
220
+ if (verbose)
221
+ console.log(` ↑ Updated state: ${local.idn}/${localState.idn}`);
222
+ }
223
+ catch (error) {
224
+ const msg = error instanceof Error ? error.message : String(error);
225
+ counts.errors.push(`Failed to update state ${localState.idn} in flow ${local.idn}: ${msg}`);
226
+ console.error(` ❌ Failed to update state ${localState.idn}: ${msg}`);
227
+ }
228
+ }
229
+ }
230
+ for (const remoteState of remoteStates) {
231
+ if (!localStatesByIdn.has(remoteState.idn)) {
232
+ try {
233
+ await deleteFlowState(client, remoteState.id);
234
+ counts.statesDeleted++;
235
+ if (verbose)
236
+ console.log(` ↑ Deleted state: ${local.idn}/${remoteState.idn}`);
237
+ }
238
+ catch (error) {
239
+ const msg = error instanceof Error ? error.message : String(error);
240
+ counts.errors.push(`Failed to delete state ${remoteState.idn} in flow ${local.idn}: ${msg}`);
241
+ console.error(` ❌ Failed to delete state ${remoteState.idn}: ${msg}`);
242
+ }
243
+ }
244
+ }
245
+ }
246
+ /**
247
+ * Combined count of operations across all categories.
248
+ */
249
+ export function totalFlowSyncOps(counts) {
250
+ return (counts.flowsUpdated +
251
+ counts.eventsCreated + counts.eventsUpdated + counts.eventsDeleted +
252
+ counts.statesCreated + counts.statesUpdated + counts.statesDeleted);
253
+ }
254
+ /**
255
+ * Human-readable summary line for the push report.
256
+ */
257
+ export function describeFlowSyncCounts(counts) {
258
+ const parts = [];
259
+ if (counts.flowsUpdated)
260
+ parts.push(`${counts.flowsUpdated} flow(s)`);
261
+ if (counts.eventsCreated || counts.eventsUpdated || counts.eventsDeleted) {
262
+ const eventOps = [];
263
+ if (counts.eventsCreated)
264
+ eventOps.push(`+${counts.eventsCreated}`);
265
+ if (counts.eventsUpdated)
266
+ eventOps.push(`~${counts.eventsUpdated}`);
267
+ if (counts.eventsDeleted)
268
+ eventOps.push(`-${counts.eventsDeleted}`);
269
+ parts.push(`events ${eventOps.join('/')}`);
270
+ }
271
+ if (counts.statesCreated || counts.statesUpdated || counts.statesDeleted) {
272
+ const stateOps = [];
273
+ if (counts.statesCreated)
274
+ stateOps.push(`+${counts.statesCreated}`);
275
+ if (counts.statesUpdated)
276
+ stateOps.push(`~${counts.statesUpdated}`);
277
+ if (counts.statesDeleted)
278
+ stateOps.push(`-${counts.statesDeleted}`);
279
+ parts.push(`states ${stateOps.join('/')}`);
280
+ }
281
+ return parts.join(', ');
282
+ }
283
+ //# sourceMappingURL=flow-metadata.js.map
@@ -8,7 +8,7 @@
8
8
  * `value_type: json`. The API may return the `value` field as either a
9
9
  * STRING containing JSON or as an already-parsed OBJECT.
10
10
  *
11
- * Without normalization, two bugs leak through:
11
+ * Without normalization, several bugs leak through:
12
12
  *
13
13
  * 1. When the API returns the value as an OBJECT, `yaml.dump` serializes
14
14
  * it as a YAML structure (mappings/sequences). Pushing back then sends
@@ -21,10 +21,25 @@
21
21
  * string vs object representations it triggers spurious pushes that
22
22
  * overwrite the canvas with the wrong shape (Builder shows blank).
23
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.
24
+ * 3. (Bug 3.7.2-a) Canvas JSON strings with structural newlines (real
25
+ * U+000A between tokens) can be emitted by yaml.dump as double-quoted
26
+ * scalars with `\n` escape sequences. patchYamlToPyyaml then converts
27
+ * those to single-quoted YAML scalars, where `\n` is treated as two
28
+ * literal chars (backslash + n). On push the platform stores those
29
+ * literal chars and the Builder calls JSON.parse, which fails on
30
+ * backslash-n as structural whitespace.
31
+ *
32
+ * 4. (Bug 3.7.2-b) Canvas body text contains Markdown with `\_`
33
+ * (backslash + underscore). `\_` is not a valid JSON escape sequence
34
+ * per RFC 8259 (valid ones: " \ / b f n r t uXXXX). Chrome V8's
35
+ * JSON.parse is strict: it throws SyntaxError on `\_`, silently
36
+ * blanking the Builder.
37
+ *
38
+ * The fix for (3) and (4): for `value_type: json` string values, strip
39
+ * invalid escape sequences then compact via JSON.parse + JSON.stringify.
40
+ * Compaction removes structural newlines and re-serializes all string
41
+ * values with only valid JSON escapes, producing a single-line string
42
+ * that round-trips through YAML without corruption.
28
43
  */
29
44
  /**
30
45
  * True if the attribute is a JSON-typed attribute (case- and
@@ -32,26 +47,40 @@
32
47
  * `ValueType.JSON`, etc.).
33
48
  */
34
49
  export declare function isJsonValueType(valueType: unknown): boolean;
50
+ /**
51
+ * Fix invalid JSON escape sequences inside JSON string values.
52
+ *
53
+ * Per RFC 8259, valid escape sequences inside a JSON string are:
54
+ * \" \\ \/ \b \f \n \r \t \uXXXX
55
+ * Anything else (e.g. `\_` `\.` from Markdown) is invalid and causes
56
+ * JSON.parse to throw. Fix: drop the backslash (e.g. `\_` → `_`).
57
+ *
58
+ * Only modifies characters inside JSON string values (tracks quote
59
+ * context). Structural characters outside strings are untouched.
60
+ */
61
+ export declare function fixInvalidJsonEscapes(s: string): string;
35
62
  /**
36
63
  * Coerce a JSON-typed attribute's value to a STRING suitable for storage
37
64
  * in attributes.yaml and for sending to the platform.
38
65
  *
39
66
  * - `null` / `undefined` → `''`
40
67
  * - object → compact JSON string (`JSON.stringify(value)`)
41
- * - string → returned as-is (we trust the platform's existing format)
68
+ * - string → fix invalid escapes (e.g. `\_` `_`), then compact via
69
+ * JSON.parse + JSON.stringify. If parsing still fails after
70
+ * fixing escapes, return the fixed string as-is.
42
71
  * - other → `String(value)`
43
72
  *
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.
73
+ * Compacting removes structural newlines and guarantees a single-line
74
+ * string that yaml.dump serializes without escape-sequence corruption in
75
+ * the patchYamlToPyyaml pass. See module-level comment for full context.
47
76
  */
48
77
  export declare function normalizeJsonValueForStorage(value: unknown): string;
49
78
  /**
50
79
  * Canonical comparison for JSON-typed attribute values.
51
80
  *
52
81
  * 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
82
+ * fixed string). Use this on both sides of a comparison so that pretty-
83
+ * vs compact-printed JSON does not register as a change, and so that an
55
84
  * object on one side equals its stringified form on the other side.
56
85
  */
57
86
  export declare function canonicalJsonValue(value: unknown): string;
@@ -8,7 +8,7 @@
8
8
  * `value_type: json`. The API may return the `value` field as either a
9
9
  * STRING containing JSON or as an already-parsed OBJECT.
10
10
  *
11
- * Without normalization, two bugs leak through:
11
+ * Without normalization, several bugs leak through:
12
12
  *
13
13
  * 1. When the API returns the value as an OBJECT, `yaml.dump` serializes
14
14
  * it as a YAML structure (mappings/sequences). Pushing back then sends
@@ -21,10 +21,25 @@
21
21
  * string vs object representations it triggers spurious pushes that
22
22
  * overwrite the canvas with the wrong shape (Builder shows blank).
23
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.
24
+ * 3. (Bug 3.7.2-a) Canvas JSON strings with structural newlines (real
25
+ * U+000A between tokens) can be emitted by yaml.dump as double-quoted
26
+ * scalars with `\n` escape sequences. patchYamlToPyyaml then converts
27
+ * those to single-quoted YAML scalars, where `\n` is treated as two
28
+ * literal chars (backslash + n). On push the platform stores those
29
+ * literal chars and the Builder calls JSON.parse, which fails on
30
+ * backslash-n as structural whitespace.
31
+ *
32
+ * 4. (Bug 3.7.2-b) Canvas body text contains Markdown with `\_`
33
+ * (backslash + underscore). `\_` is not a valid JSON escape sequence
34
+ * per RFC 8259 (valid ones: " \ / b f n r t uXXXX). Chrome V8's
35
+ * JSON.parse is strict: it throws SyntaxError on `\_`, silently
36
+ * blanking the Builder.
37
+ *
38
+ * The fix for (3) and (4): for `value_type: json` string values, strip
39
+ * invalid escape sequences then compact via JSON.parse + JSON.stringify.
40
+ * Compaction removes structural newlines and re-serializes all string
41
+ * values with only valid JSON escapes, producing a single-line string
42
+ * that round-trips through YAML without corruption.
28
43
  */
29
44
  /**
30
45
  * True if the attribute is a JSON-typed attribute (case- and
@@ -37,24 +52,84 @@ export function isJsonValueType(valueType) {
37
52
  const lower = valueType.toLowerCase();
38
53
  return lower === 'json' || lower.endsWith('.json');
39
54
  }
55
+ /**
56
+ * Fix invalid JSON escape sequences inside JSON string values.
57
+ *
58
+ * Per RFC 8259, valid escape sequences inside a JSON string are:
59
+ * \" \\ \/ \b \f \n \r \t \uXXXX
60
+ * Anything else (e.g. `\_` `\.` from Markdown) is invalid and causes
61
+ * JSON.parse to throw. Fix: drop the backslash (e.g. `\_` → `_`).
62
+ *
63
+ * Only modifies characters inside JSON string values (tracks quote
64
+ * context). Structural characters outside strings are untouched.
65
+ */
66
+ export function fixInvalidJsonEscapes(s) {
67
+ const VALID_ESCAPES = new Set(['"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u']);
68
+ const result = [];
69
+ let inString = false;
70
+ let i = 0;
71
+ while (i < s.length) {
72
+ const c = s[i];
73
+ if (inString) {
74
+ if (c === '\\' && i + 1 < s.length) {
75
+ const next = s[i + 1];
76
+ if (VALID_ESCAPES.has(next)) {
77
+ result.push(c, next);
78
+ }
79
+ else {
80
+ result.push(next); // drop the backslash — \_ → _, etc.
81
+ }
82
+ i += 2;
83
+ continue;
84
+ }
85
+ else if (c === '"') {
86
+ inString = false;
87
+ result.push(c);
88
+ }
89
+ else {
90
+ result.push(c);
91
+ }
92
+ }
93
+ else {
94
+ if (c === '"') {
95
+ inString = true;
96
+ result.push(c);
97
+ }
98
+ else {
99
+ result.push(c);
100
+ }
101
+ }
102
+ i++;
103
+ }
104
+ return result.join('');
105
+ }
40
106
  /**
41
107
  * Coerce a JSON-typed attribute's value to a STRING suitable for storage
42
108
  * in attributes.yaml and for sending to the platform.
43
109
  *
44
110
  * - `null` / `undefined` → `''`
45
111
  * - object → compact JSON string (`JSON.stringify(value)`)
46
- * - string → returned as-is (we trust the platform's existing format)
112
+ * - string → fix invalid escapes (e.g. `\_` `_`), then compact via
113
+ * JSON.parse + JSON.stringify. If parsing still fails after
114
+ * fixing escapes, return the fixed string as-is.
47
115
  * - other → `String(value)`
48
116
  *
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.
117
+ * Compacting removes structural newlines and guarantees a single-line
118
+ * string that yaml.dump serializes without escape-sequence corruption in
119
+ * the patchYamlToPyyaml pass. See module-level comment for full context.
52
120
  */
53
121
  export function normalizeJsonValueForStorage(value) {
54
122
  if (value == null)
55
123
  return '';
56
- if (typeof value === 'string')
57
- return value;
124
+ if (typeof value === 'string') {
125
+ const fixed = fixInvalidJsonEscapes(value);
126
+ try {
127
+ return JSON.stringify(JSON.parse(fixed));
128
+ }
129
+ catch {
130
+ return fixed;
131
+ }
132
+ }
58
133
  if (typeof value === 'object') {
59
134
  try {
60
135
  return JSON.stringify(value);
@@ -69,20 +144,12 @@ export function normalizeJsonValueForStorage(value) {
69
144
  * Canonical comparison for JSON-typed attribute values.
70
145
  *
71
146
  * 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
147
+ * fixed string). Use this on both sides of a comparison so that pretty-
148
+ * vs compact-printed JSON does not register as a change, and so that an
74
149
  * object on one side equals its stringified form on the other side.
75
150
  */
76
151
  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
- }
152
+ return normalizeJsonValueForStorage(value);
86
153
  }
87
154
  /**
88
155
  * True if two JSON-typed attribute values are semantically equal.
package/dist/sync/push.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Push operations for changed files
3
3
  */
4
- import { updateSkill, createAgent, createFlow, createSkill, publishFlow } from '../api.js';
5
- import { ensureState, mapPath, skillMetadataPath, projectDir, agentMetadataPath } from '../fsutil.js';
4
+ import { updateSkill, createAgent, createFlow, createSkill, publishFlow, getFlow } from '../api.js';
5
+ import { ensureState, mapPath, skillMetadataPath, projectDir, agentMetadataPath, flowMetadataPath } from '../fsutil.js';
6
6
  import { validateSkillFolder, getSingleSkillFile, getExtensionForRunner } from './skill-files.js';
7
7
  import fs from 'fs-extra';
8
8
  import { sha256, loadHashes, saveHashes } from '../hash.js';
@@ -11,6 +11,7 @@ import { generateFlowsYaml } from './metadata.js';
11
11
  import { isProjectMap, isLegacyProjectMap } from './projects.js';
12
12
  import { flowsYamlPath } from '../fsutil.js';
13
13
  import { pushAllProjectAttributes } from './attributes.js';
14
+ import { syncFlowMetadata, emptyFlowSyncCounts, totalFlowSyncOps, describeFlowSyncCounts } from './flow-metadata.js';
14
15
  /**
15
16
  * Scan filesystem for local-only entities not in the project map yet
16
17
  */
@@ -444,6 +445,68 @@ export async function pushChanged(client, customer, verbose = false, shouldPubli
444
445
  }
445
446
  }
446
447
  }
448
+ // Sync flow metadata (title, events, state_fields) for any flow whose
449
+ // metadata.yaml hash has changed. This closes the loop on GH issue #3:
450
+ // previously push only updated skill scripts, leaving local edits to
451
+ // flow events and title silently un-synced.
452
+ const flowSyncCounts = emptyFlowSyncCounts();
453
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
454
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
455
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
456
+ if (!flowObj.id)
457
+ continue;
458
+ const metaPath = flowMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn);
459
+ if (!(await fs.pathExists(metaPath)))
460
+ continue;
461
+ const metaContent = await fs.readFile(metaPath, 'utf8');
462
+ const metaHash = sha256(metaContent);
463
+ const oldHash = hashes[metaPath];
464
+ if (oldHash === metaHash) {
465
+ if (verbose)
466
+ console.log(` ✓ Flow metadata unchanged: ${flowIdn}`);
467
+ continue;
468
+ }
469
+ if (verbose)
470
+ console.log(` 🔄 Flow metadata changed, syncing: ${agentIdn}/${flowIdn}`);
471
+ let localFlow;
472
+ try {
473
+ localFlow = yaml.load(metaContent);
474
+ }
475
+ catch (error) {
476
+ console.error(`❌ Failed to parse flow metadata for ${flowIdn}: ${error instanceof Error ? error.message : String(error)}`);
477
+ continue;
478
+ }
479
+ let remoteFlow = null;
480
+ try {
481
+ remoteFlow = await getFlow(client, flowObj.id);
482
+ }
483
+ catch (error) {
484
+ // 404 means the flow ID is stale; skip flow-level update but still
485
+ // try to sync children since list endpoints may still work.
486
+ if (verbose) {
487
+ console.log(` ⚠️ Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
488
+ }
489
+ }
490
+ const opsBefore = totalFlowSyncOps(flowSyncCounts);
491
+ await syncFlowMetadata(client, flowObj.id, localFlow, remoteFlow, verbose, flowSyncCounts);
492
+ const opsAfter = totalFlowSyncOps(flowSyncCounts);
493
+ if (opsAfter > opsBefore) {
494
+ pushed += (opsAfter - opsBefore);
495
+ metadataChanged = true;
496
+ }
497
+ // Hash is updated regardless of whether ops happened, so we don't
498
+ // re-scan the same untouched flow on the next push.
499
+ newHashes[metaPath] = metaHash;
500
+ }
501
+ }
502
+ }
503
+ const totalFlowOps = totalFlowSyncOps(flowSyncCounts);
504
+ if (totalFlowOps > 0) {
505
+ console.log(`↑ Flow metadata synced: ${describeFlowSyncCounts(flowSyncCounts)}`);
506
+ }
507
+ if (flowSyncCounts.errors.length > 0) {
508
+ console.warn(`⚠️ ${flowSyncCounts.errors.length} flow-metadata error(s) during push.`);
509
+ }
447
510
  if (verbose)
448
511
  console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
449
512
  // Push project attributes for all projects
package/dist/types.d.ts CHANGED
@@ -441,6 +441,22 @@ export interface CreateFlowEventRequest {
441
441
  export interface CreateFlowEventResponse {
442
442
  id: string;
443
443
  }
444
+ /**
445
+ * Payload for PATCH /api/v1/designer/flows/events/{eventId}
446
+ *
447
+ * Required fields per probe testing: idn, skill_selector, interrupt_mode.
448
+ * Sending the full event body is the platform's expected shape.
449
+ */
450
+ export interface UpdateFlowEventRequest {
451
+ idn: string;
452
+ description?: string | null;
453
+ skill_selector: string;
454
+ skill_idn?: string | null;
455
+ state_idn?: string | null;
456
+ interrupt_mode: string;
457
+ integration_idn?: string | null;
458
+ connector_idn?: string | null;
459
+ }
444
460
  export interface CreateFlowStateRequest {
445
461
  title: string;
446
462
  idn: string;
@@ -450,6 +466,27 @@ export interface CreateFlowStateRequest {
450
466
  export interface CreateFlowStateResponse {
451
467
  id: string;
452
468
  }
469
+ /**
470
+ * Payload for PUT /api/v1/designer/flows/states/{stateId}
471
+ */
472
+ export interface UpdateFlowStateRequest {
473
+ title: string;
474
+ idn: string;
475
+ default_value?: string;
476
+ scope: string;
477
+ }
478
+ /**
479
+ * Payload for PATCH /api/v1/designer/flows/{flowId}
480
+ *
481
+ * Empty body returns 500; the platform requires the full descriptor.
482
+ */
483
+ export interface UpdateFlowRequest {
484
+ idn: string;
485
+ title: string;
486
+ description?: string;
487
+ default_runner_type: RunnerType;
488
+ default_model: ModelConfig;
489
+ }
453
490
  export interface CreateSkillParameterRequest {
454
491
  name: string;
455
492
  default_value?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "3.7.1",
3
+ "version": "3.7.3",
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": {