newo 3.7.2 → 3.7.4
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 +33 -1
- package/README.md +25 -0
- package/dist/domain/strategies/sync/V2ProjectSyncStrategy.d.ts +34 -0
- package/dist/domain/strategies/sync/V2ProjectSyncStrategy.js +316 -5
- package/dist/sync/json-attr-utils.d.ts +40 -11
- package/dist/sync/json-attr-utils.js +89 -22
- package/package.json +1 -1
- package/src/domain/strategies/sync/V2ProjectSyncStrategy.ts +429 -6
- package/src/sync/json-attr-utils.ts +84 -19
|
@@ -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,
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 →
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newo",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.4",
|
|
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": {
|
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
listFlowSkills,
|
|
47
47
|
listFlowEvents,
|
|
48
48
|
listFlowStates,
|
|
49
|
+
createSkill,
|
|
50
|
+
createSkillParameter,
|
|
49
51
|
updateSkill,
|
|
50
52
|
publishFlow,
|
|
51
53
|
getProjectAttributes,
|
|
@@ -70,6 +72,7 @@ import { sha256, saveHashes, loadHashes } from '../../../hash.js';
|
|
|
70
72
|
import {
|
|
71
73
|
v2ImportVersionPath,
|
|
72
74
|
v2ProjectYamlPath,
|
|
75
|
+
v2AgentDir,
|
|
73
76
|
v2AgentYamlPath,
|
|
74
77
|
v2FlowYamlPath,
|
|
75
78
|
v2SkillScriptPath,
|
|
@@ -89,10 +92,10 @@ import {
|
|
|
89
92
|
generateV2FlowYaml,
|
|
90
93
|
generateV2ProjectYaml,
|
|
91
94
|
generateV2AgentYaml,
|
|
95
|
+
parseV2FlowYaml,
|
|
92
96
|
buildV2InlineSkill,
|
|
93
97
|
buildV2FlowEvent,
|
|
94
98
|
buildV2StateField,
|
|
95
|
-
parseV2FlowYaml,
|
|
96
99
|
type V2InlineSkill,
|
|
97
100
|
type V2FlowEvent,
|
|
98
101
|
type V2StateField,
|
|
@@ -100,6 +103,7 @@ import {
|
|
|
100
103
|
import { isContentDifferent } from '../../../sync/skill-files.js';
|
|
101
104
|
import yaml from 'js-yaml';
|
|
102
105
|
import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
|
|
106
|
+
import type { RunnerType, SkillParameter } from '../../../types.js';
|
|
103
107
|
|
|
104
108
|
/**
|
|
105
109
|
* V2ProjectSyncStrategy - same API, newo_v2 file layout
|
|
@@ -655,9 +659,17 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
655
659
|
}
|
|
656
660
|
|
|
657
661
|
const mapData = await fs.readJson(mapFile) as ProjectMap;
|
|
662
|
+
const metadataSync = await this.syncV2FlowYamlDefinitions(client, customer, mapData, newHashes);
|
|
663
|
+
result.created += metadataSync.created;
|
|
664
|
+
result.updated += metadataSync.updated;
|
|
665
|
+
result.errors.push(...metadataSync.errors);
|
|
658
666
|
|
|
659
667
|
for (const change of changes) {
|
|
660
668
|
try {
|
|
669
|
+
if (metadataSync.syncedPaths.has(change.path)) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
|
|
661
673
|
if (change.operation === 'modified') {
|
|
662
674
|
// V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
|
|
663
675
|
// The flow YAML carries title, events, and state_fields inline, so
|
|
@@ -682,6 +694,10 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
682
694
|
}
|
|
683
695
|
}
|
|
684
696
|
|
|
697
|
+
if (metadataSync.created > 0 || metadataSync.updated > 0) {
|
|
698
|
+
await writeFileSafe(mapFile, JSON.stringify(mapData, null, 2));
|
|
699
|
+
}
|
|
700
|
+
|
|
685
701
|
await saveHashes(newHashes, customer.idn);
|
|
686
702
|
|
|
687
703
|
if (result.created > 0 || result.updated > 0) {
|
|
@@ -800,6 +816,321 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
800
816
|
return total;
|
|
801
817
|
}
|
|
802
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Reconcile inline skill definitions from V2 flow YAML before pushing scripts.
|
|
821
|
+
*
|
|
822
|
+
* V2 keeps skill metadata (model, runner_type, parameters) in the flow YAML,
|
|
823
|
+
* not in a separate skill metadata file. The map only contains the remote IDs
|
|
824
|
+
* from a previous pull, so new local skills must be created before their
|
|
825
|
+
* callers can be published.
|
|
826
|
+
*/
|
|
827
|
+
private async syncV2FlowYamlDefinitions(
|
|
828
|
+
client: AxiosInstance,
|
|
829
|
+
customer: CustomerConfig,
|
|
830
|
+
mapData: ProjectMap,
|
|
831
|
+
newHashes: HashStore
|
|
832
|
+
): Promise<{ created: number; updated: number; syncedPaths: Set<string>; errors: string[] }> {
|
|
833
|
+
let created = 0;
|
|
834
|
+
let updated = 0;
|
|
835
|
+
const syncedPaths = new Set<string>();
|
|
836
|
+
const errors: string[] = [];
|
|
837
|
+
|
|
838
|
+
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
839
|
+
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
840
|
+
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
841
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
842
|
+
if (!(await fs.pathExists(flowYamlPath))) {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
let flowDef;
|
|
847
|
+
try {
|
|
848
|
+
flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
this.logger.warn(
|
|
851
|
+
`[newo_v2] Failed to parse flow YAML ${flowYamlPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
852
|
+
);
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
for (const skill of flowDef.skills || []) {
|
|
857
|
+
const skillLocator = `${projectIdn}/${agentIdn}/${flowIdn}/${skill.idn}`;
|
|
858
|
+
// Per-skill failure isolation: one broken skill must not abort the
|
|
859
|
+
// push of every other project/flow in the workspace.
|
|
860
|
+
try {
|
|
861
|
+
const runnerType = this.normalizeRunnerType(skill.runner_type);
|
|
862
|
+
const scriptPath = await this.resolveV2FlowSkillScriptPath(
|
|
863
|
+
customer.idn,
|
|
864
|
+
projectIdn,
|
|
865
|
+
agentIdn,
|
|
866
|
+
flowIdn,
|
|
867
|
+
skill.idn,
|
|
868
|
+
runnerType,
|
|
869
|
+
skill.prompt_script
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
if (!(await fs.pathExists(scriptPath))) {
|
|
873
|
+
errors.push(
|
|
874
|
+
`[newo_v2] Missing script for skill ${skillLocator}: ${scriptPath}`
|
|
875
|
+
);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const content = await fs.readFile(scriptPath, 'utf8');
|
|
880
|
+
const localMetadata = this.buildV2SkillMetadataFromYaml(skill, flowDef, runnerType, flowData.skills[skill.idn]);
|
|
881
|
+
const existingSkill = flowData.skills[skill.idn];
|
|
882
|
+
|
|
883
|
+
if (!existingSkill) {
|
|
884
|
+
this.assertSkillModelResolved(localMetadata, skillLocator);
|
|
885
|
+
try {
|
|
886
|
+
const createdSkill = await createSkill(client, flowData.id, {
|
|
887
|
+
idn: localMetadata.idn,
|
|
888
|
+
title: localMetadata.title,
|
|
889
|
+
prompt_script: content,
|
|
890
|
+
runner_type: localMetadata.runner_type,
|
|
891
|
+
model: localMetadata.model,
|
|
892
|
+
parameters: localMetadata.parameters,
|
|
893
|
+
path: localMetadata.path || ''
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// The create endpoint ignores inline `parameters` (verified
|
|
897
|
+
// against the live platform) — create them explicitly.
|
|
898
|
+
await this.createMissingSkillParameters(
|
|
899
|
+
client,
|
|
900
|
+
{ ...localMetadata, id: createdSkill.id, parameters: [] },
|
|
901
|
+
localMetadata
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
flowData.skills[skill.idn] = {
|
|
905
|
+
...localMetadata,
|
|
906
|
+
id: createdSkill.id
|
|
907
|
+
};
|
|
908
|
+
newHashes[scriptPath] = sha256(content);
|
|
909
|
+
syncedPaths.add(scriptPath);
|
|
910
|
+
created++;
|
|
911
|
+
this.logger.info(`[newo_v2] Created skill: ${flowIdn}/${skill.idn}`);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
if (!this.isAlreadyExistsApiError(error)) {
|
|
914
|
+
throw error;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const remoteSkills = await listFlowSkills(client, flowData.id);
|
|
918
|
+
const remoteSkill = remoteSkills.find(s => s.idn === skill.idn);
|
|
919
|
+
if (!remoteSkill) {
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const remoteMetadata: SkillMetadata = {
|
|
924
|
+
id: remoteSkill.id,
|
|
925
|
+
idn: remoteSkill.idn,
|
|
926
|
+
title: remoteSkill.title,
|
|
927
|
+
runner_type: remoteSkill.runner_type,
|
|
928
|
+
model: remoteSkill.model,
|
|
929
|
+
parameters: this.normalizeParameters(remoteSkill.parameters),
|
|
930
|
+
path: remoteSkill.path
|
|
931
|
+
};
|
|
932
|
+
await this.createMissingSkillParameters(client, remoteMetadata, localMetadata);
|
|
933
|
+
await updateSkill(client, {
|
|
934
|
+
id: remoteSkill.id,
|
|
935
|
+
title: localMetadata.title,
|
|
936
|
+
idn: localMetadata.idn,
|
|
937
|
+
prompt_script: content,
|
|
938
|
+
runner_type: localMetadata.runner_type,
|
|
939
|
+
model: localMetadata.model,
|
|
940
|
+
parameters: localMetadata.parameters,
|
|
941
|
+
path: remoteSkill.path || localMetadata.path
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
flowData.skills[skill.idn] = {
|
|
945
|
+
...localMetadata,
|
|
946
|
+
id: remoteSkill.id,
|
|
947
|
+
path: remoteSkill.path || localMetadata.path
|
|
948
|
+
};
|
|
949
|
+
newHashes[scriptPath] = sha256(content);
|
|
950
|
+
syncedPaths.add(scriptPath);
|
|
951
|
+
updated++;
|
|
952
|
+
this.logger.info(`[newo_v2] Reused existing skill: ${flowIdn}/${skill.idn}`);
|
|
953
|
+
}
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const createdParameters = await this.createMissingSkillParameters(client, existingSkill, localMetadata);
|
|
958
|
+
|
|
959
|
+
if (createdParameters > 0 || this.skillMetadataDiffers(existingSkill, localMetadata)) {
|
|
960
|
+
this.assertSkillModelResolved(localMetadata, skillLocator);
|
|
961
|
+
await updateSkill(client, {
|
|
962
|
+
id: existingSkill.id,
|
|
963
|
+
title: localMetadata.title,
|
|
964
|
+
idn: localMetadata.idn,
|
|
965
|
+
prompt_script: content,
|
|
966
|
+
runner_type: localMetadata.runner_type,
|
|
967
|
+
model: localMetadata.model,
|
|
968
|
+
parameters: localMetadata.parameters,
|
|
969
|
+
path: localMetadata.path
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
flowData.skills[skill.idn] = {
|
|
973
|
+
...localMetadata,
|
|
974
|
+
id: existingSkill.id
|
|
975
|
+
};
|
|
976
|
+
newHashes[scriptPath] = sha256(content);
|
|
977
|
+
syncedPaths.add(scriptPath);
|
|
978
|
+
updated++;
|
|
979
|
+
this.logger.info(`[newo_v2] Updated skill metadata: ${flowIdn}/${skill.idn}`);
|
|
980
|
+
}
|
|
981
|
+
} catch (error) {
|
|
982
|
+
errors.push(
|
|
983
|
+
`Failed to sync skill ${skillLocator}: ${error instanceof Error ? error.message : String(error)}`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return { created, updated, syncedPaths, errors };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private async createMissingSkillParameters(
|
|
995
|
+
client: AxiosInstance,
|
|
996
|
+
existing: SkillMetadata,
|
|
997
|
+
local: SkillMetadata
|
|
998
|
+
): Promise<number> {
|
|
999
|
+
const existingNames = new Set(this.normalizeParameters(existing.parameters).map(p => p.name));
|
|
1000
|
+
let created = 0;
|
|
1001
|
+
|
|
1002
|
+
for (const parameter of local.parameters) {
|
|
1003
|
+
if (existingNames.has(parameter.name)) {
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
await createSkillParameter(client, existing.id, {
|
|
1009
|
+
name: parameter.name,
|
|
1010
|
+
default_value: parameter.default_value ?? ''
|
|
1011
|
+
});
|
|
1012
|
+
created++;
|
|
1013
|
+
this.logger.info(`[newo_v2] Created skill parameter: ${local.idn}/${parameter.name}`);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
if (!this.isAlreadyExistsApiError(error)) {
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
existingNames.add(parameter.name);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return created;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Detect "resource already exists" API errors.
|
|
1027
|
+
*
|
|
1028
|
+
* Matches only on the precise phrases the platform actually returns
|
|
1029
|
+
* ("already exists", "duplicate key"). Loose substrings like "exist"
|
|
1030
|
+
* would otherwise sweep up unrelated "does not exist" / "doesn't exist"
|
|
1031
|
+
* errors and trigger an incorrect reuse fallback.
|
|
1032
|
+
*/
|
|
1033
|
+
private isAlreadyExistsApiError(error: unknown): boolean {
|
|
1034
|
+
const response = (error as { response?: { status?: number; data?: unknown } } | null | undefined)?.response;
|
|
1035
|
+
const status = response?.status;
|
|
1036
|
+
if (status !== 400 && status !== 409 && status !== 422) {
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const haystack = JSON.stringify(
|
|
1041
|
+
response?.data ?? (error instanceof Error ? error.message : String(error))
|
|
1042
|
+
).toLowerCase();
|
|
1043
|
+
|
|
1044
|
+
return haystack.includes('already exists') || haystack.includes('duplicate key');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private normalizeRunnerType(runnerType: string | undefined): RunnerType {
|
|
1048
|
+
return runnerType === 'nsl' ? 'nsl' : 'guidance';
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private normalizeParameters(parameters: readonly SkillParameter[] | undefined): SkillParameter[] {
|
|
1052
|
+
return (parameters || []).map(p => ({
|
|
1053
|
+
name: p.name,
|
|
1054
|
+
default_value: p.default_value ?? ''
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Fail fast if no model could be resolved for a V2 skill.
|
|
1060
|
+
*
|
|
1061
|
+
* `buildV2SkillMetadataFromYaml` falls back to empty strings when neither
|
|
1062
|
+
* the skill nor the flow declare a model. The platform rejects empty
|
|
1063
|
+
* model_idn/provider_idn at creation/update time, but the error it returns
|
|
1064
|
+
* is generic — we surface a clearer message before issuing the request.
|
|
1065
|
+
*/
|
|
1066
|
+
private assertSkillModelResolved(metadata: SkillMetadata, locator: string): void {
|
|
1067
|
+
if (!metadata.model.model_idn || !metadata.model.provider_idn) {
|
|
1068
|
+
throw new Error(
|
|
1069
|
+
`[newo_v2] Cannot resolve model for skill ${locator}: ` +
|
|
1070
|
+
`model_idn="${metadata.model.model_idn}", provider_idn="${metadata.model.provider_idn}". ` +
|
|
1071
|
+
`Set either skill.model.* or flow default_model_idn/default_provider_idn in the flow YAML.`
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private buildV2SkillMetadataFromYaml(
|
|
1077
|
+
skill: V2InlineSkill,
|
|
1078
|
+
flowDef: Awaited<ReturnType<typeof parseV2FlowYaml>>,
|
|
1079
|
+
runnerType: RunnerType,
|
|
1080
|
+
existing?: SkillMetadata
|
|
1081
|
+
): SkillMetadata {
|
|
1082
|
+
return {
|
|
1083
|
+
id: existing?.id || '',
|
|
1084
|
+
idn: skill.idn,
|
|
1085
|
+
title: skill.title || '',
|
|
1086
|
+
runner_type: runnerType,
|
|
1087
|
+
model: {
|
|
1088
|
+
model_idn: skill.model?.model_idn || flowDef.default_model_idn || '',
|
|
1089
|
+
provider_idn: skill.model?.provider_idn || flowDef.default_provider_idn || ''
|
|
1090
|
+
},
|
|
1091
|
+
parameters: this.normalizeParameters(skill.parameters),
|
|
1092
|
+
path: existing?.path || ''
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private skillMetadataDiffers(existing: SkillMetadata, local: SkillMetadata): boolean {
|
|
1097
|
+
// Compare model/parameters field-by-field, never via JSON.stringify of the
|
|
1098
|
+
// raw objects: the map stores model keys in platform API order
|
|
1099
|
+
// (provider_idn first) while YAML-built metadata uses model_idn first, and
|
|
1100
|
+
// a key-order-sensitive comparison flags every skill as changed.
|
|
1101
|
+
const paramsKey = (params: readonly SkillParameter[] | undefined): string =>
|
|
1102
|
+
JSON.stringify(
|
|
1103
|
+
this.normalizeParameters(params).sort((a, b) => a.name.localeCompare(b.name))
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
return (
|
|
1107
|
+
existing.title !== local.title ||
|
|
1108
|
+
existing.runner_type !== local.runner_type ||
|
|
1109
|
+
existing.model.model_idn !== local.model.model_idn ||
|
|
1110
|
+
existing.model.provider_idn !== local.model.provider_idn ||
|
|
1111
|
+
paramsKey(existing.parameters) !== paramsKey(local.parameters)
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private async resolveV2FlowSkillScriptPath(
|
|
1116
|
+
customerIdn: string,
|
|
1117
|
+
projectIdn: string,
|
|
1118
|
+
agentIdn: string,
|
|
1119
|
+
flowIdn: string,
|
|
1120
|
+
skillIdn: string,
|
|
1121
|
+
runnerType: RunnerType,
|
|
1122
|
+
promptScript?: string
|
|
1123
|
+
): Promise<string> {
|
|
1124
|
+
if (promptScript) {
|
|
1125
|
+
const fromPromptScript = `${v2AgentDir(customerIdn, projectIdn, agentIdn)}/${promptScript}`;
|
|
1126
|
+
if (await fs.pathExists(fromPromptScript)) {
|
|
1127
|
+
return fromPromptScript;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return v2SkillScriptPath(customerIdn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
803
1134
|
/**
|
|
804
1135
|
* Push a V2 skill update
|
|
805
1136
|
*
|
|
@@ -949,11 +1280,30 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
949
1280
|
}
|
|
950
1281
|
|
|
951
1282
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1283
|
+
const flowYamlSkills = await this.loadLocalV2FlowSkills(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
1284
|
+
const skillIdns = new Set([
|
|
1285
|
+
...Object.keys(flowData.skills),
|
|
1286
|
+
...flowYamlSkills.keys()
|
|
1287
|
+
]);
|
|
1288
|
+
|
|
1289
|
+
for (const skillIdn of skillIdns) {
|
|
1290
|
+
const yamlSkill = flowYamlSkills.get(skillIdn);
|
|
1291
|
+
const skillMeta = flowData.skills[skillIdn];
|
|
1292
|
+
const runnerType = this.normalizeRunnerType(yamlSkill?.runner_type || skillMeta?.runner_type);
|
|
1293
|
+
const scriptPath = yamlSkill
|
|
1294
|
+
? await this.resolveV2FlowSkillScriptPath(
|
|
1295
|
+
customer.idn,
|
|
1296
|
+
projectIdn,
|
|
1297
|
+
agentIdn,
|
|
1298
|
+
flowIdn,
|
|
1299
|
+
skillIdn,
|
|
1300
|
+
runnerType,
|
|
1301
|
+
yamlSkill.prompt_script
|
|
1302
|
+
)
|
|
1303
|
+
: v2SkillScriptPath(
|
|
1304
|
+
customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
|
|
1305
|
+
runnerType
|
|
1306
|
+
);
|
|
957
1307
|
|
|
958
1308
|
if (await fs.pathExists(scriptPath)) {
|
|
959
1309
|
const content = await fs.readFile(scriptPath, 'utf8');
|
|
@@ -1002,6 +1352,25 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
1002
1352
|
return changes;
|
|
1003
1353
|
}
|
|
1004
1354
|
|
|
1355
|
+
private async loadLocalV2FlowSkills(
|
|
1356
|
+
customerIdn: string,
|
|
1357
|
+
projectIdn: string,
|
|
1358
|
+
agentIdn: string,
|
|
1359
|
+
flowIdn: string
|
|
1360
|
+
): Promise<Map<string, V2InlineSkill>> {
|
|
1361
|
+
const flowYamlPath = v2FlowYamlPath(customerIdn, projectIdn, agentIdn, flowIdn);
|
|
1362
|
+
if (!(await fs.pathExists(flowYamlPath))) {
|
|
1363
|
+
return new Map();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
try {
|
|
1367
|
+
const flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
1368
|
+
return new Map((flowDef.skills || []).map(skill => [skill.idn, skill]));
|
|
1369
|
+
} catch {
|
|
1370
|
+
return new Map();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1005
1374
|
async validate(customer: CustomerConfig, _items: LocalProjectData[]): Promise<ValidationResult> {
|
|
1006
1375
|
const errors: ValidationError[] = [];
|
|
1007
1376
|
|
|
@@ -1020,7 +1389,61 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
|
|
|
1020
1389
|
for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
|
|
1021
1390
|
for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
|
|
1022
1391
|
for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
|
|
1392
|
+
const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
|
|
1393
|
+
let localYamlSkills: Map<string, V2InlineSkill> | undefined;
|
|
1394
|
+
if (await fs.pathExists(flowYamlPath)) {
|
|
1395
|
+
try {
|
|
1396
|
+
const flowDef = await parseV2FlowYaml(flowYamlPath);
|
|
1397
|
+
localYamlSkills = new Map((flowDef.skills || []).map(s => [s.idn, s]));
|
|
1398
|
+
const skillIdns = new Set([
|
|
1399
|
+
...Object.keys(flowData.skills),
|
|
1400
|
+
...localYamlSkills.keys()
|
|
1401
|
+
]);
|
|
1402
|
+
|
|
1403
|
+
for (const skillIdn of skillIdns) {
|
|
1404
|
+
const localYamlSkill = localYamlSkills.get(skillIdn);
|
|
1405
|
+
const skillMeta = flowData.skills[skillIdn];
|
|
1406
|
+
|
|
1407
|
+
if (!localYamlSkill) {
|
|
1408
|
+
errors.push({
|
|
1409
|
+
field: `skill.${skillIdn}`,
|
|
1410
|
+
message: `Skill exists in project map but is missing from flow YAML: ${flowYamlPath}`,
|
|
1411
|
+
path: flowYamlPath
|
|
1412
|
+
});
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const runnerType = this.normalizeRunnerType(
|
|
1417
|
+
localYamlSkill.runner_type || skillMeta?.runner_type
|
|
1418
|
+
);
|
|
1419
|
+
const scriptPath = await this.resolveV2FlowSkillScriptPath(
|
|
1420
|
+
customer.idn,
|
|
1421
|
+
projectIdn,
|
|
1422
|
+
agentIdn,
|
|
1423
|
+
flowIdn,
|
|
1424
|
+
skillIdn,
|
|
1425
|
+
runnerType,
|
|
1426
|
+
localYamlSkill.prompt_script
|
|
1427
|
+
);
|
|
1428
|
+
|
|
1429
|
+
if (!(await fs.pathExists(scriptPath))) {
|
|
1430
|
+
errors.push({
|
|
1431
|
+
field: `skill.${localYamlSkill.idn}`,
|
|
1432
|
+
message: `Script file not found: ${scriptPath}`,
|
|
1433
|
+
path: scriptPath
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
} catch {
|
|
1438
|
+
localYamlSkills = undefined;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1023
1442
|
for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
|
|
1443
|
+
if (localYamlSkills) {
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1024
1447
|
const scriptPath = v2SkillScriptPath(
|
|
1025
1448
|
customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
|
|
1026
1449
|
skillMeta.runner_type
|