newo 3.6.2 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +44 -3
  2. package/README.md +61 -0
  3. package/dist/cli/commands/check.d.ts +3 -0
  4. package/dist/cli/commands/check.js +15 -0
  5. package/dist/cli/commands/format.d.ts +3 -0
  6. package/dist/cli/commands/format.js +105 -0
  7. package/dist/cli/commands/help.js +13 -0
  8. package/dist/cli/commands/lint.d.ts +3 -0
  9. package/dist/cli/commands/lint.js +195 -0
  10. package/dist/cli-new/di/tokens.d.ts +1 -1
  11. package/dist/cli.js +45 -9
  12. package/dist/domain/strategies/sync/AttributeSyncStrategy.js +38 -8
  13. package/dist/lint/config.d.ts +4 -0
  14. package/dist/lint/config.js +14 -0
  15. package/dist/lint/discovery.d.ts +34 -0
  16. package/dist/lint/discovery.js +112 -0
  17. package/dist/lint/live-schema.d.ts +20 -0
  18. package/dist/lint/live-schema.js +52 -0
  19. package/dist/lint/reporters/index.d.ts +4 -0
  20. package/dist/lint/reporters/index.js +19 -0
  21. package/dist/lint/reporters/json.d.ts +3 -0
  22. package/dist/lint/reporters/json.js +6 -0
  23. package/dist/lint/reporters/sarif.d.ts +3 -0
  24. package/dist/lint/reporters/sarif.js +47 -0
  25. package/dist/lint/reporters/text.d.ts +3 -0
  26. package/dist/lint/reporters/text.js +51 -0
  27. package/dist/lint/reporters/types.d.ts +6 -0
  28. package/dist/lint/reporters/types.js +2 -0
  29. package/dist/sync/attributes.js +38 -12
  30. package/dist/sync/conversations.d.ts +1 -1
  31. package/dist/sync/conversations.js +240 -193
  32. package/dist/sync/json-attr-utils.d.ts +67 -0
  33. package/dist/sync/json-attr-utils.js +98 -0
  34. package/package.json +3 -1
  35. package/src/cli/commands/check.ts +21 -0
  36. package/src/cli/commands/format.ts +131 -0
  37. package/src/cli/commands/help.ts +13 -0
  38. package/src/cli/commands/lint.ts +246 -0
  39. package/src/cli.ts +50 -9
  40. package/src/domain/strategies/sync/AttributeSyncStrategy.ts +45 -8
  41. package/src/lint/config.ts +17 -0
  42. package/src/lint/discovery.ts +148 -0
  43. package/src/lint/live-schema.ts +62 -0
  44. package/src/lint/reporters/index.ts +22 -0
  45. package/src/lint/reporters/json.ts +12 -0
  46. package/src/lint/reporters/sarif.ts +59 -0
  47. package/src/lint/reporters/text.ts +58 -0
  48. package/src/lint/reporters/types.ts +7 -0
  49. package/src/sync/attributes.ts +43 -14
  50. package/src/sync/conversations.ts +265 -212
  51. package/src/sync/json-attr-utils.ts +95 -0
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * Conversations synchronization module
3
+ *
4
+ * Incremental/resumable conversation pull:
5
+ * - Writes per-persona JSON files to newo_customers/<idn>/conversations/<persona_id>.json as they arrive
6
+ * - Updates conversations.yaml aggregate after each persona finishes
7
+ * - Skips personas already fully fetched (resume support) unless --force passed via env NEWO_CONV_FORCE=1
8
+ * - Graceful on partial failure: individual persona errors do not abort the batch, state is preserved
3
9
  */
4
10
  import { listUserPersonas, getChatHistory } from '../api.js';
5
11
  import { writeFileSafe } from '../fsutil.js';
12
+ import fs from 'fs-extra';
13
+ import path from 'path';
6
14
  import yaml from 'js-yaml';
7
15
  import pLimit from 'p-limit';
8
16
  import type { AxiosInstance } from 'axios';
@@ -16,11 +24,108 @@ import type {
16
24
  ConversationsData
17
25
  } from '../types.js';
18
26
 
19
- // Concurrency limit for API calls
20
27
  const concurrencyLimit = pLimit(5);
21
28
 
29
+ type PersonaState = {
30
+ id: string;
31
+ name: string;
32
+ phone: string | null;
33
+ act_count: number;
34
+ acts: ProcessedAct[];
35
+ fetched_at: string;
36
+ complete: boolean;
37
+ last_error?: string;
38
+ };
39
+
40
+ function personaFilePath(customerIdn: string, personaId: string): string {
41
+ return path.join('newo_customers', customerIdn, 'conversations', `${personaId}.json`);
42
+ }
43
+
44
+ function aggregateYamlPath(customerIdn: string): string {
45
+ return path.join('newo_customers', customerIdn, 'conversations.yaml');
46
+ }
47
+
48
+ async function readPersonaState(customerIdn: string, personaId: string): Promise<PersonaState | null> {
49
+ const p = personaFilePath(customerIdn, personaId);
50
+ if (!(await fs.pathExists(p))) return null;
51
+ try {
52
+ return JSON.parse(await fs.readFile(p, 'utf8')) as PersonaState;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function writePersonaState(customerIdn: string, state: PersonaState): Promise<void> {
59
+ await writeFileSafe(personaFilePath(customerIdn, state.id), JSON.stringify(state, null, 2));
60
+ }
61
+
62
+ async function writeAggregateYaml(customerIdn: string): Promise<{ personas: number; acts: number }> {
63
+ const dir = path.join('newo_customers', customerIdn, 'conversations');
64
+ const files = (await fs.pathExists(dir)) ? await fs.readdir(dir) : [];
65
+ const personas: ProcessedPersona[] = [];
66
+ for (const f of files) {
67
+ if (!f.endsWith('.json')) continue;
68
+ try {
69
+ const state = JSON.parse(await fs.readFile(path.join(dir, f), 'utf8')) as PersonaState;
70
+ personas.push({
71
+ id: state.id,
72
+ name: state.name,
73
+ phone: state.phone,
74
+ act_count: state.act_count,
75
+ acts: state.acts
76
+ });
77
+ } catch {
78
+ // skip corrupted file
79
+ }
80
+ }
81
+
82
+ personas.sort((a, b) => {
83
+ const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
84
+ const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
85
+ return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
86
+ });
87
+
88
+ const totalActs = personas.reduce((sum, p) => sum + p.acts.length, 0);
89
+
90
+ const data: ConversationsData = {
91
+ personas,
92
+ total_personas: personas.length,
93
+ total_acts: totalActs,
94
+ generated_at: new Date().toISOString()
95
+ };
96
+
97
+ const yamlContent = yaml.dump(data, {
98
+ indent: 2,
99
+ quotingType: '"',
100
+ forceQuotes: false,
101
+ lineWidth: 120,
102
+ noRefs: true,
103
+ sortKeys: false,
104
+ flowLevel: -1
105
+ });
106
+
107
+ await writeFileSafe(aggregateYamlPath(customerIdn), yamlContent);
108
+ return { personas: personas.length, acts: totalActs };
109
+ }
110
+
111
+ function buildProcessedActs(raw: ConversationAct[]): ProcessedAct[] {
112
+ const sorted = [...raw].sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
113
+ return sorted.map(act => {
114
+ const processedAct: ProcessedAct = {
115
+ datetime: act.datetime,
116
+ type: act.reference_idn,
117
+ message: act.source_text
118
+ };
119
+ if (act.contact_information) (processedAct as any).contact_information = act.contact_information;
120
+ if (act.flow_idn && act.flow_idn !== 'unknown') (processedAct as any).flow_idn = act.flow_idn;
121
+ if (act.skill_idn && act.skill_idn !== 'unknown') (processedAct as any).skill_idn = act.skill_idn;
122
+ if (act.session_id && act.session_id !== 'unknown') (processedAct as any).session_id = act.session_id;
123
+ return processedAct;
124
+ });
125
+ }
126
+
22
127
  /**
23
- * Pull conversations for a customer and save to YAML
128
+ * Pull conversations for a customer and save incrementally.
24
129
  */
25
130
  export async function pullConversations(
26
131
  client: AxiosInstance,
@@ -28,230 +133,178 @@ export async function pullConversations(
28
133
  options: ConversationOptions = {},
29
134
  verbose: boolean = false
30
135
  ): Promise<void> {
31
- if (verbose) console.log(`šŸ’¬ Fetching conversations for customer ${customer.idn}...`);
136
+ const force = process.env.NEWO_CONV_FORCE === '1';
137
+ console.log(`šŸ’¬ Fetching conversations for ${customer.idn}${force ? ' (force re-fetch)' : ' (resume mode)'}...`);
138
+
139
+ // Ensure output dirs exist
140
+ await fs.ensureDir(path.join('newo_customers', customer.idn, 'conversations'));
141
+
142
+ // 1. Enumerate all personas
143
+ const allPersonas: UserPersona[] = [];
144
+ let page = 1;
145
+ const perPage = 50;
146
+ let hasMore = true;
147
+
148
+ while (hasMore) {
149
+ const response = await listUserPersonas(client, page, perPage);
150
+ allPersonas.push(...response.items);
151
+ if (verbose) console.log(`šŸ“‹ Page ${page}: ${response.items.length} personas (${allPersonas.length}/${response.metadata.total})`);
152
+ hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
153
+ page++;
154
+ }
32
155
 
33
- try {
34
- // Get all user personas with pagination
35
- const allPersonas: UserPersona[] = [];
36
- let page = 1;
37
- const perPage = 50;
38
- let hasMore = true;
156
+ if (options.maxPersonas && allPersonas.length > options.maxPersonas) {
157
+ allPersonas.splice(options.maxPersonas);
158
+ }
39
159
 
40
- while (hasMore) {
41
- const response = await listUserPersonas(client, page, perPage);
42
- allPersonas.push(...response.items);
160
+ const total = allPersonas.length;
161
+ console.log(`šŸ‘„ Found ${total} personas. Processing with concurrency=5...`);
162
+
163
+ let done = 0;
164
+ let skipped = 0;
165
+ let failed = 0;
166
+
167
+ await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
168
+ try {
169
+ // Resume: skip if already complete
170
+ const existing = await readPersonaState(customer.idn, persona.id);
171
+ if (!force && existing && existing.complete) {
172
+ skipped++;
173
+ done++;
174
+ if (verbose) console.log(`ā­ļø [${done}/${total}] ${persona.name}: already complete (${existing.acts.length} acts)`);
175
+ return;
176
+ }
43
177
 
44
- if (verbose) console.log(`šŸ“‹ Page ${page}: Found ${response.items.length} personas (${allPersonas.length}/${response.metadata.total} total)`);
178
+ const phoneActor = persona.actors.find(actor =>
179
+ actor.integration_idn === 'newo_voice' &&
180
+ actor.connector_idn === 'newo_voice_connector' &&
181
+ actor.contact_information?.startsWith('+')
182
+ );
183
+ const phone = phoneActor?.contact_information || null;
45
184
 
46
- hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
47
- page++;
48
- }
185
+ const userActors = persona.actors.filter(actor =>
186
+ actor.integration_idn === 'newo_voice' &&
187
+ actor.connector_idn === 'newo_voice_connector'
188
+ );
49
189
 
50
- if (options.maxPersonas && allPersonas.length > options.maxPersonas) {
51
- allPersonas.splice(options.maxPersonas);
52
- if (verbose) console.log(`āš ļø Limited to ${options.maxPersonas} personas as requested`);
53
- }
190
+ if (userActors.length === 0) {
191
+ const state: PersonaState = {
192
+ id: persona.id,
193
+ name: persona.name,
194
+ phone,
195
+ act_count: persona.act_count,
196
+ acts: [],
197
+ fetched_at: new Date().toISOString(),
198
+ complete: true
199
+ };
200
+ await writePersonaState(customer.idn, state);
201
+ done++;
202
+ if (verbose) console.log(`āœ“ [${done}/${total}] ${persona.name}: no voice actors`);
203
+ return;
204
+ }
54
205
 
55
- if (verbose) console.log(`šŸ‘„ Processing ${allPersonas.length} personas...`);
56
-
57
- // Process personas concurrently with limited concurrency
58
- const processedPersonas: ProcessedPersona[] = [];
59
-
60
- await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
61
- try {
62
- // Extract phone number from actors
63
- const phoneActor = persona.actors.find(actor =>
64
- actor.integration_idn === 'newo_voice' &&
65
- actor.connector_idn === 'newo_voice_connector' &&
66
- actor.contact_information?.startsWith('+')
67
- );
68
- const phone = phoneActor?.contact_information || null;
69
-
70
- // Get acts for this persona
71
- const allActs: ConversationAct[] = [];
72
- let actPage = 1;
73
- const actsPerPage = 100; // Higher limit for acts
74
- let hasMoreActs = true;
75
-
76
- // Get user actor IDs from persona actors first
77
- const userActors = persona.actors.filter(actor =>
78
- actor.integration_idn === 'newo_voice' &&
79
- actor.connector_idn === 'newo_voice_connector'
80
- );
81
-
82
- if (userActors.length === 0) {
83
- if (verbose) console.log(` šŸ‘¤ ${persona.name}: No voice actors found, skipping`);
84
- // No voice actors, can't get chat history - add persona with empty acts
85
- processedPersonas.push({
86
- id: persona.id,
87
- name: persona.name,
88
- phone,
89
- act_count: persona.act_count,
90
- acts: []
206
+ // Fetch acts paginated
207
+ const allActs: ConversationAct[] = [];
208
+ let actPage = 1;
209
+ const actsPerPage = 100;
210
+ let hasMoreActs = true;
211
+ const maxPages = 50;
212
+ let lastError: string | undefined;
213
+
214
+ while (hasMoreActs && actPage <= maxPages) {
215
+ try {
216
+ const chatResponse = await getChatHistory(client, {
217
+ user_actor_id: userActors[0]!.id,
218
+ page: actPage,
219
+ per: actsPerPage
91
220
  });
92
- if (verbose) console.log(` āœ“ Processed ${persona.name}: 0 acts (no voice actors)`);
93
- return; // Return from the concurrency function
94
- }
95
-
96
- // Safety mechanism to prevent infinite loops
97
- const maxPages = 50; // Limit to 50 pages (5000 acts max per persona)
98
221
 
99
- while (hasMoreActs && actPage <= maxPages) {
100
- try {
101
- const chatHistoryParams = {
222
+ if (chatResponse.items && chatResponse.items.length > 0) {
223
+ const convertedActs: ConversationAct[] = chatResponse.items.map((item: any) => ({
224
+ id: item.id || `chat_${Math.random()}`,
225
+ command_act_id: null,
226
+ external_event_id: item.external_event_id || 'chat_history',
227
+ arguments: [],
228
+ reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
229
+ runtime_context_id: item.runtime_context_id || 'chat_history',
230
+ source_text: item.payload?.text || item.message || item.content || item.text || '',
231
+ original_text: item.payload?.text || item.message || item.content || item.text || '',
232
+ datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
102
233
  user_actor_id: userActors[0]!.id,
103
- page: actPage,
104
- per: actsPerPage
234
+ agent_actor_id: null,
235
+ user_persona_id: persona.id,
236
+ user_persona_name: persona.name,
237
+ agent_persona_id: item.agent_persona_id || 'unknown',
238
+ external_id: item.external_id || null,
239
+ integration_idn: 'newo_voice',
240
+ connector_idn: 'newo_voice_connector',
241
+ to_integration_idn: null,
242
+ to_connector_idn: null,
243
+ is_agent: Boolean(item.is_agent === true),
244
+ project_idn: null,
245
+ flow_idn: item.flow_idn || 'unknown',
246
+ skill_idn: item.skill_idn || 'unknown',
247
+ session_id: item.session_id || 'unknown',
248
+ recordings: item.recordings || [],
249
+ contact_information: item.contact_information || null
250
+ }));
251
+
252
+ allActs.push(...convertedActs);
253
+
254
+ // Save partial progress every page
255
+ const partialState: PersonaState = {
256
+ id: persona.id,
257
+ name: persona.name,
258
+ phone,
259
+ act_count: persona.act_count,
260
+ acts: buildProcessedActs(allActs),
261
+ fetched_at: new Date().toISOString(),
262
+ complete: false
105
263
  };
264
+ await writePersonaState(customer.idn, partialState);
106
265
 
107
- if (verbose) console.log(` šŸ“„ ${persona.name}: Fetching page ${actPage}...`);
108
- const chatResponse = await getChatHistory(client, chatHistoryParams);
109
-
110
- if (chatResponse.items && chatResponse.items.length > 0) {
111
- // Convert chat history format to acts format - create minimal ConversationAct objects
112
- const convertedActs: ConversationAct[] = chatResponse.items.map((item: any) => ({
113
- id: item.id || `chat_${Math.random()}`,
114
- command_act_id: null,
115
- external_event_id: item.external_event_id || 'chat_history',
116
- arguments: [],
117
- reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
118
- runtime_context_id: item.runtime_context_id || 'chat_history',
119
- source_text: item.payload?.text || item.message || item.content || item.text || '',
120
- original_text: item.payload?.text || item.message || item.content || item.text || '',
121
- datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
122
- user_actor_id: userActors[0]!.id,
123
- agent_actor_id: null,
124
- user_persona_id: persona.id,
125
- user_persona_name: persona.name,
126
- agent_persona_id: item.agent_persona_id || 'unknown',
127
- external_id: item.external_id || null,
128
- integration_idn: 'newo_voice',
129
- connector_idn: 'newo_voice_connector',
130
- to_integration_idn: null,
131
- to_connector_idn: null,
132
- is_agent: Boolean(item.is_agent === true),
133
- project_idn: null,
134
- flow_idn: item.flow_idn || 'unknown',
135
- skill_idn: item.skill_idn || 'unknown',
136
- session_id: item.session_id || 'unknown',
137
- recordings: item.recordings || [],
138
- contact_information: item.contact_information || null
139
- }));
140
-
141
- allActs.push(...convertedActs);
142
-
143
- if (verbose && convertedActs.length > 0) {
144
- console.log(` šŸ‘¤ ${persona.name}: Chat History - ${convertedActs.length} messages (${allActs.length} total)`);
145
- }
146
-
147
- // Check if we should continue paginating
148
- const hasMetadata = chatResponse.metadata?.total !== undefined;
149
- const currentTotal = chatResponse.metadata?.total || 0;
150
-
151
- hasMoreActs = chatResponse.items.length === actsPerPage &&
152
- hasMetadata &&
153
- allActs.length < currentTotal;
154
-
155
- actPage++;
156
-
157
- if (verbose) console.log(` šŸ“Š ${persona.name}: Page ${actPage - 1} done, ${allActs.length}/${currentTotal} total acts`);
158
- } else {
159
- // No more items
160
- hasMoreActs = false;
161
- if (verbose) console.log(` šŸ“Š ${persona.name}: No more chat history items`);
162
- }
163
- } catch (chatError) {
164
- if (verbose) console.log(` āš ļø Chat history failed for ${persona.name}: ${chatError instanceof Error ? chatError.message : String(chatError)}`);
266
+ const currentTotal = chatResponse.metadata?.total || 0;
267
+ hasMoreActs = chatResponse.items.length === actsPerPage && allActs.length < currentTotal;
268
+ actPage++;
269
+ } else {
165
270
  hasMoreActs = false;
166
271
  }
272
+ } catch (chatError) {
273
+ lastError = chatError instanceof Error ? chatError.message : String(chatError);
274
+ if (verbose) console.log(`āš ļø ${persona.name} page ${actPage}: ${lastError}`);
275
+ hasMoreActs = false;
167
276
  }
168
-
169
- if (actPage > maxPages) {
170
- if (verbose) console.log(` āš ļø ${persona.name}: Reached max pages limit (${maxPages}), stopping pagination`);
171
- }
172
-
173
- // Sort acts by datetime ascending (chronological order)
174
- allActs.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
175
-
176
- // Process acts into simplified format - exclude redundant fields
177
- const processedActs: ProcessedAct[] = allActs.map(act => {
178
- const processedAct: ProcessedAct = {
179
- datetime: act.datetime,
180
- type: act.reference_idn,
181
- message: act.source_text
182
- };
183
-
184
- // Only include non-redundant fields
185
- if (act.contact_information) {
186
- (processedAct as any).contact_information = act.contact_information;
187
- }
188
- if (act.flow_idn && act.flow_idn !== 'unknown') {
189
- (processedAct as any).flow_idn = act.flow_idn;
190
- }
191
- if (act.skill_idn && act.skill_idn !== 'unknown') {
192
- (processedAct as any).skill_idn = act.skill_idn;
193
- }
194
- if (act.session_id && act.session_id !== 'unknown') {
195
- (processedAct as any).session_id = act.session_id;
196
- }
197
-
198
- return processedAct;
199
- });
200
-
201
- processedPersonas.push({
202
- id: persona.id,
203
- name: persona.name,
204
- phone,
205
- act_count: persona.act_count,
206
- acts: processedActs
207
- });
208
-
209
- if (verbose) console.log(` āœ“ Processed ${persona.name}: ${processedActs.length} acts`);
210
- } catch (error) {
211
- console.error(`āŒ Failed to process persona ${persona.name}:`, error);
212
- // Continue with other personas
213
277
  }
214
- })));
215
-
216
- // Sort personas by most recent act time (descending) - use latest act from acts array
217
- processedPersonas.sort((a, b) => {
218
- const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
219
- const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
220
- return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
221
- });
222
-
223
- // Calculate totals
224
- const totalActs = processedPersonas.reduce((sum, persona) => sum + persona.acts.length, 0);
225
-
226
- // Create final conversations data
227
- const conversationsData: ConversationsData = {
228
- personas: processedPersonas,
229
- total_personas: processedPersonas.length,
230
- total_acts: totalActs,
231
- generated_at: new Date().toISOString()
232
- };
233
278
 
234
- // Save to YAML file
235
- const conversationsPath = `newo_customers/${customer.idn}/conversations.yaml`;
236
- const yamlContent = yaml.dump(conversationsData, {
237
- indent: 2,
238
- quotingType: '"',
239
- forceQuotes: false,
240
- lineWidth: 120,
241
- noRefs: true,
242
- sortKeys: false,
243
- flowLevel: -1
244
- });
245
-
246
- await writeFileSafe(conversationsPath, yamlContent);
247
-
248
- if (verbose) {
249
- console.log(`āœ“ Saved conversations to ${conversationsPath}`);
250
- console.log(`šŸ“Š Summary: ${processedPersonas.length} personas, ${totalActs} total acts`);
279
+ const finalState: PersonaState = {
280
+ id: persona.id,
281
+ name: persona.name,
282
+ phone,
283
+ act_count: persona.act_count,
284
+ acts: buildProcessedActs(allActs),
285
+ fetched_at: new Date().toISOString(),
286
+ complete: !lastError
287
+ };
288
+ if (lastError) finalState.last_error = lastError;
289
+ await writePersonaState(customer.idn, finalState);
290
+
291
+ // Incremental YAML aggregate every persona
292
+ const agg = await writeAggregateYaml(customer.idn);
293
+
294
+ done++;
295
+ if (lastError) failed++;
296
+ console.log(`${lastError ? 'āš ļø ' : 'āœ“'} [${done}/${total}] ${persona.name}: ${finalState.acts.length} acts${lastError ? ` (partial: ${lastError})` : ''} | total so far: ${agg.personas} personas / ${agg.acts} acts`);
297
+ } catch (error) {
298
+ failed++;
299
+ done++;
300
+ const msg = error instanceof Error ? error.message : String(error);
301
+ console.error(`āŒ [${done}/${total}] ${persona.name}: ${msg}`);
251
302
  }
252
-
253
- } catch (error) {
254
- console.error(`āŒ Failed to pull conversations for ${customer.idn}:`, error);
255
- throw error;
256
- }
257
- }
303
+ })));
304
+
305
+ // Final aggregate write
306
+ const final = await writeAggregateYaml(customer.idn);
307
+ console.log(`\nāœ… Done. ${final.personas} personas, ${final.acts} acts. Skipped ${skipped} (already cached), ${failed} had errors.`);
308
+ console.log(` Aggregate: ${aggregateYamlPath(customer.idn)}`);
309
+ console.log(` Per-persona: newo_customers/${customer.idn}/conversations/<id>.json`);
310
+ }
@@ -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
+ }