newo 3.7.0 → 3.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/dist/api.d.ts +33 -1
- package/dist/api.js +22 -0
- package/dist/domain/strategies/sync/AttributeSyncStrategy.js +38 -8
- package/dist/domain/strategies/sync/ProjectSyncStrategy.d.ts +7 -0
- package/dist/domain/strategies/sync/ProjectSyncStrategy.js +81 -1
- package/dist/domain/strategies/sync/V2ProjectSyncStrategy.d.ts +11 -0
- package/dist/domain/strategies/sync/V2ProjectSyncStrategy.js +125 -2
- package/dist/sync/attributes.js +38 -12
- package/dist/sync/flow-metadata.d.ts +67 -0
- package/dist/sync/flow-metadata.js +283 -0
- package/dist/sync/json-attr-utils.d.ts +67 -0
- package/dist/sync/json-attr-utils.js +98 -0
- package/dist/sync/push.js +65 -2
- package/dist/types.d.ts +37 -0
- package/package.json +1 -1
- package/src/api.ts +61 -0
- package/src/domain/strategies/sync/AttributeSyncStrategy.ts +45 -8
- package/src/domain/strategies/sync/ProjectSyncStrategy.ts +100 -0
- package/src/domain/strategies/sync/V2ProjectSyncStrategy.ts +147 -0
- package/src/sync/attributes.ts +43 -14
- package/src/sync/flow-metadata.ts +345 -0
- package/src/sync/json-attr-utils.ts +95 -0
- package/src/sync/push.ts +75 -2
- package/src/types.ts +40 -0
package/src/api.ts
CHANGED
|
@@ -23,8 +23,11 @@ import type {
|
|
|
23
23
|
CreateSkillResponse,
|
|
24
24
|
CreateFlowEventRequest,
|
|
25
25
|
CreateFlowEventResponse,
|
|
26
|
+
UpdateFlowEventRequest,
|
|
26
27
|
CreateFlowStateRequest,
|
|
27
28
|
CreateFlowStateResponse,
|
|
29
|
+
UpdateFlowStateRequest,
|
|
30
|
+
UpdateFlowRequest,
|
|
28
31
|
CreateSkillParameterRequest,
|
|
29
32
|
CreateSkillParameterResponse,
|
|
30
33
|
CreateCustomerAttributeRequest,
|
|
@@ -151,6 +154,30 @@ export async function listFlowSkills(client: AxiosInstance, flowId: string): Pro
|
|
|
151
154
|
return response.data;
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Fetch a single flow's top-level descriptor.
|
|
159
|
+
*
|
|
160
|
+
* Used by push to detect whether local metadata.yaml title/description
|
|
161
|
+
* differ from the platform before patching.
|
|
162
|
+
*/
|
|
163
|
+
export interface FlowDescriptor {
|
|
164
|
+
id: string;
|
|
165
|
+
idn: string;
|
|
166
|
+
title: string;
|
|
167
|
+
description: string | null;
|
|
168
|
+
agent_id: string;
|
|
169
|
+
default_runner_type: string;
|
|
170
|
+
default_model: { provider_idn: string; model_idn: string };
|
|
171
|
+
publication_datetime?: string;
|
|
172
|
+
last_change_datetime?: string;
|
|
173
|
+
creation_datetime?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function getFlow(client: AxiosInstance, flowId: string): Promise<FlowDescriptor> {
|
|
177
|
+
const response = await client.get<FlowDescriptor>(`/api/v1/designer/flows/${flowId}`);
|
|
178
|
+
return response.data;
|
|
179
|
+
}
|
|
180
|
+
|
|
154
181
|
export async function getSkill(client: AxiosInstance, skillId: string): Promise<Skill> {
|
|
155
182
|
const response = await client.get<Skill>(`/api/v1/designer/skills/${skillId}`);
|
|
156
183
|
return response.data;
|
|
@@ -345,6 +372,14 @@ export async function createFlowEvent(client: AxiosInstance, flowId: string, eve
|
|
|
345
372
|
return response.data;
|
|
346
373
|
}
|
|
347
374
|
|
|
375
|
+
export async function updateFlowEvent(
|
|
376
|
+
client: AxiosInstance,
|
|
377
|
+
eventId: string,
|
|
378
|
+
eventData: UpdateFlowEventRequest
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
await client.patch(`/api/v1/designer/flows/events/${eventId}`, eventData);
|
|
381
|
+
}
|
|
382
|
+
|
|
348
383
|
export async function deleteFlowEvent(client: AxiosInstance, eventId: string): Promise<void> {
|
|
349
384
|
await client.delete(`/api/v1/designer/flows/events/${eventId}`);
|
|
350
385
|
}
|
|
@@ -354,6 +389,32 @@ export async function createFlowState(client: AxiosInstance, flowId: string, sta
|
|
|
354
389
|
return response.data;
|
|
355
390
|
}
|
|
356
391
|
|
|
392
|
+
export async function updateFlowState(
|
|
393
|
+
client: AxiosInstance,
|
|
394
|
+
stateId: string,
|
|
395
|
+
stateData: UpdateFlowStateRequest
|
|
396
|
+
): Promise<void> {
|
|
397
|
+
await client.put(`/api/v1/designer/flows/states/${stateId}`, stateData);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function deleteFlowState(client: AxiosInstance, stateId: string): Promise<void> {
|
|
401
|
+
await client.delete(`/api/v1/designer/flows/states/${stateId}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Update flow metadata (title, description, runner type, default model).
|
|
406
|
+
*
|
|
407
|
+
* Uses PATCH /api/v1/designer/flows/{flowId}. The platform requires the
|
|
408
|
+
* full flow descriptor; sending a partial body returns 500.
|
|
409
|
+
*/
|
|
410
|
+
export async function updateFlow(
|
|
411
|
+
client: AxiosInstance,
|
|
412
|
+
flowId: string,
|
|
413
|
+
flowData: UpdateFlowRequest
|
|
414
|
+
): Promise<void> {
|
|
415
|
+
await client.patch(`/api/v1/designer/flows/${flowId}`, flowData);
|
|
416
|
+
}
|
|
417
|
+
|
|
357
418
|
export async function createSkillParameter(client: AxiosInstance, skillId: string, paramData: CreateSkillParameterRequest): Promise<CreateSkillParameterResponse> {
|
|
358
419
|
// Debug the parameter creation request
|
|
359
420
|
console.log('Creating parameter for skill:', skillId);
|
|
@@ -39,6 +39,11 @@ import {
|
|
|
39
39
|
customerAttributesMapPath
|
|
40
40
|
} from '../../../fsutil.js';
|
|
41
41
|
import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
|
|
42
|
+
import {
|
|
43
|
+
isJsonValueType,
|
|
44
|
+
normalizeJsonValueForStorage,
|
|
45
|
+
jsonValuesEqual
|
|
46
|
+
} from '../../../sync/json-attr-utils.js';
|
|
42
47
|
import { sha256, saveHashes, loadHashes } from '../../../hash.js';
|
|
43
48
|
|
|
44
49
|
/**
|
|
@@ -220,8 +225,14 @@ export class AttributeSyncStrategy implements ISyncStrategy<CustomerAttributesRe
|
|
|
220
225
|
private cleanAttribute(attr: CustomerAttribute): CustomerAttribute {
|
|
221
226
|
let processedValue = attr.value;
|
|
222
227
|
|
|
223
|
-
//
|
|
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}`);
|
|
@@ -49,7 +49,14 @@ import {
|
|
|
49
49
|
publishFlow,
|
|
50
50
|
listLibraries,
|
|
51
51
|
updateLibrarySkill,
|
|
52
|
+
getFlow,
|
|
52
53
|
} from '../../../api.js';
|
|
54
|
+
import {
|
|
55
|
+
syncFlowMetadata,
|
|
56
|
+
emptyFlowSyncCounts,
|
|
57
|
+
totalFlowSyncOps,
|
|
58
|
+
describeFlowSyncCounts
|
|
59
|
+
} from '../../../sync/flow-metadata.js';
|
|
53
60
|
import {
|
|
54
61
|
ensureState,
|
|
55
62
|
writeFileSafe,
|
|
@@ -540,6 +547,24 @@ export class ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalProj
|
|
|
540
547
|
for (const change of changes) {
|
|
541
548
|
try {
|
|
542
549
|
if (change.operation === 'modified') {
|
|
550
|
+
// Flow-level metadata.yaml needs different handling than a skill
|
|
551
|
+
// script: we sync title/events/state_fields rather than uploading a
|
|
552
|
+
// file. Detected by filename. (GH issue #3)
|
|
553
|
+
if (change.path.endsWith('/metadata.yaml') && !change.path.includes('/libraries/')) {
|
|
554
|
+
const pathParts = change.path.split('/');
|
|
555
|
+
// {customer}/projects/{project}/{agent}/{flow}/metadata.yaml
|
|
556
|
+
// Last 5 segments end with metadata.yaml; skip if it's a skill
|
|
557
|
+
// metadata file (one extra segment) - skill metadata is handled
|
|
558
|
+
// by V1 legacy push, not by this strategy yet.
|
|
559
|
+
const tail = pathParts.slice(-5);
|
|
560
|
+
const isFlowMeta = tail[0] === 'projects' || tail[2] && tail[4] === 'metadata.yaml';
|
|
561
|
+
if (isFlowMeta && tail.length === 5) {
|
|
562
|
+
const updateResult = await this.pushFlowMetadataUpdate(client, change, mapData, newHashes);
|
|
563
|
+
result.updated += updateResult;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
543
568
|
const isLibrary = change.path.includes('/libraries/');
|
|
544
569
|
if (isLibrary) {
|
|
545
570
|
const updateResult = await this.pushLibrarySkillUpdate(client, change, mapData, newHashes);
|
|
@@ -568,6 +593,64 @@ export class ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalProj
|
|
|
568
593
|
return result;
|
|
569
594
|
}
|
|
570
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Push a flow metadata.yaml change — syncs title, events, and state_fields
|
|
598
|
+
* to the platform. Closes GH issue #3 (events/title silently un-synced).
|
|
599
|
+
*
|
|
600
|
+
* Path shape: newo_customers/{customer}/projects/{project}/{agent}/{flow}/metadata.yaml
|
|
601
|
+
*/
|
|
602
|
+
private async pushFlowMetadataUpdate(
|
|
603
|
+
client: AxiosInstance,
|
|
604
|
+
change: ChangeItem<LocalProjectData>,
|
|
605
|
+
mapData: ProjectMap,
|
|
606
|
+
newHashes: HashStore
|
|
607
|
+
): Promise<number> {
|
|
608
|
+
const pathParts = change.path.split('/');
|
|
609
|
+
// Tail: projects/{project}/{agent}/{flow}/metadata.yaml
|
|
610
|
+
const flowIdn = pathParts[pathParts.length - 2] || '';
|
|
611
|
+
const agentIdn = pathParts[pathParts.length - 3] || '';
|
|
612
|
+
const projectIdn = pathParts[pathParts.length - 4] || '';
|
|
613
|
+
|
|
614
|
+
const projectData = mapData.projects[projectIdn];
|
|
615
|
+
const agentData = projectData?.agents[agentIdn];
|
|
616
|
+
const flowData = agentData?.flows[flowIdn];
|
|
617
|
+
|
|
618
|
+
if (!flowData?.id) {
|
|
619
|
+
this.logger.warn(`Flow metadata change but flow not in project map: ${projectIdn}/${agentIdn}/${flowIdn}`);
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const content = await fs.readFile(change.path, 'utf8');
|
|
624
|
+
let localFlow: FlowMetadata;
|
|
625
|
+
try {
|
|
626
|
+
localFlow = yaml.load(content) as FlowMetadata;
|
|
627
|
+
} catch (error) {
|
|
628
|
+
throw new Error(`Failed to parse ${change.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
let remoteFlow = null;
|
|
632
|
+
try {
|
|
633
|
+
remoteFlow = await getFlow(client, flowData.id);
|
|
634
|
+
} catch (error: any) {
|
|
635
|
+
this.logger.verbose(`Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const counts = emptyFlowSyncCounts();
|
|
639
|
+
await syncFlowMetadata(client, flowData.id, localFlow, remoteFlow, false, counts);
|
|
640
|
+
|
|
641
|
+
const total = totalFlowSyncOps(counts);
|
|
642
|
+
if (total > 0) {
|
|
643
|
+
this.logger.info(`↑ Flow ${flowIdn}: ${describeFlowSyncCounts(counts)}`);
|
|
644
|
+
}
|
|
645
|
+
for (const err of counts.errors) {
|
|
646
|
+
this.logger.warn(err);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Stamp the hash so the next push skips this file unless it changes again.
|
|
650
|
+
newHashes[change.path] = sha256(content);
|
|
651
|
+
return total;
|
|
652
|
+
}
|
|
653
|
+
|
|
571
654
|
/**
|
|
572
655
|
* Push a skill update
|
|
573
656
|
*/
|
|
@@ -709,6 +792,23 @@ export class ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalProj
|
|
|
709
792
|
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
710
793
|
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
711
794
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
795
|
+
// Flow metadata change detection (title/events/state_fields).
|
|
796
|
+
// Surfaced as a change so push() picks it up alongside skill edits.
|
|
797
|
+
const flowMetaPath = flowMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
798
|
+
if (await fs.pathExists(flowMetaPath)) {
|
|
799
|
+
const content = await fs.readFile(flowMetaPath, 'utf8');
|
|
800
|
+
const currentHash = sha256(content);
|
|
801
|
+
const storedHash = hashes[flowMetaPath];
|
|
802
|
+
|
|
803
|
+
if (storedHash !== currentHash) {
|
|
804
|
+
changes.push({
|
|
805
|
+
item: {} as LocalProjectData,
|
|
806
|
+
operation: 'modified',
|
|
807
|
+
path: flowMetaPath
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
712
812
|
for (const [skillIdn, _skillData] of Object.entries(flowData.skills)) {
|
|
713
813
|
const skillFile = await getSingleSkillFile(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn);
|
|
714
814
|
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
Skill,
|
|
34
34
|
FlowEvent,
|
|
35
35
|
FlowState,
|
|
36
|
+
FlowMetadata,
|
|
36
37
|
ProjectData,
|
|
37
38
|
ProjectMap,
|
|
38
39
|
SkillMetadata
|
|
@@ -51,7 +52,14 @@ import {
|
|
|
51
52
|
getCustomerAttributes,
|
|
52
53
|
listLibraries,
|
|
53
54
|
updateLibrarySkill,
|
|
55
|
+
getFlow,
|
|
54
56
|
} from '../../../api.js';
|
|
57
|
+
import {
|
|
58
|
+
syncFlowMetadata,
|
|
59
|
+
emptyFlowSyncCounts,
|
|
60
|
+
totalFlowSyncOps,
|
|
61
|
+
describeFlowSyncCounts
|
|
62
|
+
} from '../../../sync/flow-metadata.js';
|
|
55
63
|
import type { LibraryResponse } from '../../../api.js';
|
|
56
64
|
import {
|
|
57
65
|
ensureStateOnly,
|
|
@@ -84,7 +92,10 @@ import {
|
|
|
84
92
|
buildV2InlineSkill,
|
|
85
93
|
buildV2FlowEvent,
|
|
86
94
|
buildV2StateField,
|
|
95
|
+
parseV2FlowYaml,
|
|
87
96
|
type V2InlineSkill,
|
|
97
|
+
type V2FlowEvent,
|
|
98
|
+
type V2StateField,
|
|
88
99
|
} from '../../../format/v2-yaml.js';
|
|
89
100
|
import { isContentDifferent } from '../../../sync/skill-files.js';
|
|
90
101
|
import yaml from 'js-yaml';
|
|
@@ -648,6 +659,15 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
648
659
|
for (const change of changes) {
|
|
649
660
|
try {
|
|
650
661
|
if (change.operation === 'modified') {
|
|
662
|
+
// V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
|
|
663
|
+
// The flow YAML carries title, events, and state_fields inline, so
|
|
664
|
+
// changes there must sync to the platform like V1 metadata.yaml.
|
|
665
|
+
if (this.isV2FlowYamlPath(change.path)) {
|
|
666
|
+
const count = await this.pushV2FlowYamlUpdate(client, change, mapData, newHashes);
|
|
667
|
+
result.updated += count;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
651
671
|
// Detect if this is a library skill or flow skill by path
|
|
652
672
|
const isLibrary = change.path.includes('/libraries/');
|
|
653
673
|
const count = isLibrary
|
|
@@ -671,6 +691,115 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
671
691
|
return result;
|
|
672
692
|
}
|
|
673
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Recognize the V2 flow YAML location: .../agents/{agent}/flows/{flow}/{flow}.yaml
|
|
696
|
+
* Distinguishes it from skill scripts, library YAMLs, and attribute files.
|
|
697
|
+
*/
|
|
698
|
+
private isV2FlowYamlPath(p: string): boolean {
|
|
699
|
+
const parts = p.split('/');
|
|
700
|
+
const file = parts[parts.length - 1];
|
|
701
|
+
if (!file || !file.endsWith('.yaml')) return false;
|
|
702
|
+
// .../agents/{agent}/flows/{flow}/{flow}.yaml → last 5 parts:
|
|
703
|
+
// agents, {agent}, flows, {flow}, {flow}.yaml
|
|
704
|
+
if (parts.length < 5) return false;
|
|
705
|
+
const flowsKeyword = parts[parts.length - 3];
|
|
706
|
+
const agentsKeyword = parts[parts.length - 5];
|
|
707
|
+
const flowFolder = parts[parts.length - 2] || '';
|
|
708
|
+
const stem = file.slice(0, -'.yaml'.length);
|
|
709
|
+
return flowsKeyword === 'flows' && agentsKeyword === 'agents' && stem === flowFolder;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Push V2 flow YAML changes. Closes GH issue #3 for newo_v2 layout.
|
|
714
|
+
* Parses the V2 YAML, converts to V1-shaped FlowMetadata, and reuses the
|
|
715
|
+
* shared syncFlowMetadata routine that calls PATCH/POST/DELETE per child.
|
|
716
|
+
*/
|
|
717
|
+
private async pushV2FlowYamlUpdate(
|
|
718
|
+
client: AxiosInstance,
|
|
719
|
+
change: ChangeItem<LocalProjectData>,
|
|
720
|
+
mapData: ProjectMap,
|
|
721
|
+
newHashes: HashStore
|
|
722
|
+
): Promise<number> {
|
|
723
|
+
const parts = change.path.split('/');
|
|
724
|
+
const flowIdn = parts[parts.length - 2] || '';
|
|
725
|
+
const agentIdn = parts[parts.length - 4] || '';
|
|
726
|
+
const projectIdn = parts[parts.length - 6] || '';
|
|
727
|
+
|
|
728
|
+
const projectData = mapData.projects[projectIdn];
|
|
729
|
+
const agentData = projectData?.agents[agentIdn];
|
|
730
|
+
const flowData = agentData?.flows[flowIdn];
|
|
731
|
+
|
|
732
|
+
if (!flowData?.id) {
|
|
733
|
+
this.logger.warn(`[newo_v2] Flow YAML change but flow not in project map: ${projectIdn}/${agentIdn}/${flowIdn}`);
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const v2Flow = await parseV2FlowYaml(change.path);
|
|
738
|
+
|
|
739
|
+
// Convert V2 → V1-shaped FlowMetadata for the shared sync routine.
|
|
740
|
+
// V2 events lack `id` and `description`; we fill defaults so the shape
|
|
741
|
+
// matches FlowEvent[] / FlowState[] expected by syncFlowMetadata.
|
|
742
|
+
// Optional fields are omitted (not set to undefined) to satisfy
|
|
743
|
+
// exactOptionalPropertyTypes.
|
|
744
|
+
const localMeta: FlowMetadata = {
|
|
745
|
+
id: flowData.id,
|
|
746
|
+
idn: v2Flow.idn,
|
|
747
|
+
title: v2Flow.title,
|
|
748
|
+
description: v2Flow.description ?? '',
|
|
749
|
+
default_runner_type: (v2Flow.default_runner_type as 'guidance' | 'nsl') || 'guidance',
|
|
750
|
+
default_model: {
|
|
751
|
+
provider_idn: v2Flow.default_provider_idn,
|
|
752
|
+
model_idn: v2Flow.default_model_idn,
|
|
753
|
+
},
|
|
754
|
+
events: (v2Flow.events || []).map((e: V2FlowEvent) => {
|
|
755
|
+
const out: FlowEvent = {
|
|
756
|
+
id: '',
|
|
757
|
+
idn: e.idn,
|
|
758
|
+
description: '',
|
|
759
|
+
skill_selector: e.skill_selector as 'first' | 'last' | 'random' | 'all',
|
|
760
|
+
interrupt_mode: (e.interrupt_mode || 'queue') as 'allow' | 'deny' | 'queue',
|
|
761
|
+
...(e.skill_idn != null ? { skill_idn: e.skill_idn } : {}),
|
|
762
|
+
...(e.state_idn != null ? { state_idn: e.state_idn } : {}),
|
|
763
|
+
...(e.integration_idn != null ? { integration_idn: e.integration_idn } : {}),
|
|
764
|
+
...(e.connector_idn != null ? { connector_idn: e.connector_idn } : {}),
|
|
765
|
+
};
|
|
766
|
+
return out;
|
|
767
|
+
}),
|
|
768
|
+
state_fields: (v2Flow.state_fields || []).map((s: V2StateField) => {
|
|
769
|
+
const out: FlowState = {
|
|
770
|
+
id: '',
|
|
771
|
+
idn: s.idn,
|
|
772
|
+
title: s.title || s.idn,
|
|
773
|
+
scope: (s.scope || 'flow') as 'flow' | 'agent' | 'project' | 'global',
|
|
774
|
+
...(s.default_value != null ? { default_value: s.default_value } : {}),
|
|
775
|
+
};
|
|
776
|
+
return out;
|
|
777
|
+
}),
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
let remoteFlow = null;
|
|
781
|
+
try {
|
|
782
|
+
remoteFlow = await getFlow(client, flowData.id);
|
|
783
|
+
} catch (error: any) {
|
|
784
|
+
this.logger.verbose(`[newo_v2] Could not GET flow ${flowIdn}: ${error.response?.status ?? error.message}`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const counts = emptyFlowSyncCounts();
|
|
788
|
+
await syncFlowMetadata(client, flowData.id, localMeta, remoteFlow, false, counts);
|
|
789
|
+
|
|
790
|
+
const total = totalFlowSyncOps(counts);
|
|
791
|
+
if (total > 0) {
|
|
792
|
+
this.logger.info(`[newo_v2] ↑ Flow ${flowIdn}: ${describeFlowSyncCounts(counts)}`);
|
|
793
|
+
}
|
|
794
|
+
for (const err of counts.errors) {
|
|
795
|
+
this.logger.warn(err);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const content = await fs.readFile(change.path, 'utf8');
|
|
799
|
+
newHashes[change.path] = sha256(content);
|
|
800
|
+
return total;
|
|
801
|
+
}
|
|
802
|
+
|
|
674
803
|
/**
|
|
675
804
|
* Push a V2 skill update
|
|
676
805
|
*
|
|
@@ -801,6 +930,24 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
801
930
|
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
802
931
|
// Flow skills
|
|
803
932
|
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
933
|
+
for (const [flowIdn, _flowData] of Object.entries(agentData.flows)) {
|
|
934
|
+
// V2 stores flow events / state_fields / title inline in the flow
|
|
935
|
+
// YAML. Detect changes here so push() can sync them (GH issue #3).
|
|
936
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
937
|
+
if (await fs.pathExists(flowYamlPath)) {
|
|
938
|
+
const content = await fs.readFile(flowYamlPath, 'utf8');
|
|
939
|
+
const currentHash = sha256(content);
|
|
940
|
+
const storedHash = hashes[flowYamlPath];
|
|
941
|
+
if (storedHash !== currentHash) {
|
|
942
|
+
changes.push({
|
|
943
|
+
item: {} as LocalProjectData,
|
|
944
|
+
operation: 'modified',
|
|
945
|
+
path: flowYamlPath
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
804
951
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
805
952
|
for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
|
|
806
953
|
const scriptPath = v2SkillScriptPath(
|
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,
|